internal/fakecgo: preserve frame pointer across mstart call in threadentry - #486
Merged
hajimehoshi merged 4 commits intoJul 31, 2026
Merged
Conversation
gdams
marked this pull request as draft
July 31, 2026 09:20
gdams
force-pushed
the
fix/fakecgo-threadentry-framepointer
branch
2 times, most recently
from
July 31, 2026 09:39
64ae08d to
a706472
Compare
…entry threadentry calls runtime.mstart directly. For a fakecgo thread the M's stack is system-allocated, so when the M exits mexit(osStack=true) returns from mstart back into threadentry. mstart returns with BP clobbered (0); on amd64 the frame-pointer LEAVE epilogue (MOV BP,SP; POP BP) then dereferences a NULL pointer and crashes. Route the mstart call through an assembly shim that saves and restores the callee-saved registers (including BP) on the stack, mirroring what real cgo's crosscall_amd64 does. The existing call5 helper cannot be reused because it stashes SP in BP, which mstart zeroes. Add a regression test that forces locked OS threads to exit. Fixes ebitengine#485
gdams
force-pushed
the
fix/fakecgo-threadentry-framepointer
branch
from
July 31, 2026 09:52
a706472 to
455f9c0
Compare
gdams
marked this pull request as ready for review
July 31, 2026 10:10
hajimehoshi
reviewed
Jul 31, 2026
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes an amd64-specific crash in internal/fakecgo when a fakecgo-created OS thread exits and runtime.mstart returns back into threadentry with BP clobbered, which can break the frame-pointer epilogue on newer Go toolchains.
Changes:
- Routes
threadentry’s call tots.fn(runtime.mstart) through a new amd64 assembly shim that saves/restores callee-saved registers (notablyBP) around the call. - Adds Go declarations / non-amd64 fallback for
callThreadEntryFn. - Adds a regression test that forces locked OS threads to exit to exercise the
mstartreturn path.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| internal/fakecgo/trampolines_amd64.s | Adds callThreadEntryFn asm shim to preserve callee-saved regs (incl. frame pointer) across mstart. |
| internal/fakecgo/threadentry_repro_test.go | Adds regression test that reproduces the prior crash by forcing locked threads to exit. |
| internal/fakecgo/threadentry_noasm.go | Adds non-amd64 Go fallback implementation for callThreadEntryFn. |
| internal/fakecgo/threadentry_amd64.go | Adds amd64 Go declaration for the asm-implemented callThreadEntryFn. |
| internal/fakecgo/go_linux.go | Updates threadentry to call callThreadEntryFn(ts.fn) instead of directly invoking the raw PC. |
| internal/fakecgo/go_darwin.go | Same threadentry update for Darwin. |
| internal/fakecgo/go_freebsd.go | Same threadentry update for FreeBSD. |
| internal/fakecgo/go_netbsd.go | Same threadentry update for NetBSD. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
hajimehoshi
reviewed
Jul 31, 2026
gdams
force-pushed
the
fix/fakecgo-threadentry-framepointer
branch
from
July 31, 2026 12:37
b9d7cba to
56d2923
Compare
gdams
force-pushed
the
fix/fakecgo-threadentry-framepointer
branch
from
July 31, 2026 12:44
56d2923 to
a197cd4
Compare
hajimehoshi
approved these changes
Jul 31, 2026
Contributor
Author
|
@hajimehoshi updated PTAL |
hajimehoshi
pushed a commit
that referenced
this pull request
Jul 31, 2026
…entry (#486) threadentry calls runtime.mstart directly. For a fakecgo thread the M's stack is system-allocated, so when the M exits mexit(osStack=true) returns from mstart back into threadentry. mstart returns with BP clobbered (0); on amd64 the frame-pointer LEAVE epilogue (MOV BP,SP; POP BP) then dereferences a NULL pointer and crashes. Route the mstart call through an assembly shim that saves and restores the callee-saved registers (including BP) on the stack, mirroring what real cgo's crosscall_amd64 does. The existing call5 helper cannot be reused because it stashes SP in BP, which mstart zeroes. Add a regression test that forces locked OS threads to exit. Closes #485
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #485.
Problem
internal/fakecgo'sthreadentrycallsruntime.mstartdirectly. For a fakecgo-created thread the M's stack is system-allocated, so when the M exits the runtime doesmexit(osStack=true), which returns frommstartback intothreadentry(by design, so the pthread can unwind and exit). Butmstartruns the scheduler and comes back withBP = 0.On Go 1.28 the amd64 compiler emits a frame-pointer
LEAVEepilogue (MOV BP,SP; POP BP) forthreadentry. WithBP = 0,LEAVEsetsSP = 0and the followingRETdereferences a NULL pointer:Real cgo doesn't hit this because its
x_cgo_threadentryinvokesmstartthroughcrosscall_amd64, which saves/restores the callee-saved registers (includingBP) on the stack around the call. fakecgo'sthreadentryskipped that step.This is mostly latent because Ms usually park and get reused rather than exiting. It surfaces whenever an M actually exits — e.g. a
runtime.LockOSThreadgoroutine that finishes (which is how the Go standard library'sos.TestProgWideChdirtripped it underCGO_ENABLED=0).Fix
Route the
ts.fn(runtime.mstart) call through a small assembly shim,callThreadEntryFn, that saves and restores the callee-saved registers on the stack around the call (using the existingPUSH_REGS_HOST_TO_ABI0/POP_REGS_HOST_TO_ABI0macros), mirroringcrosscall_amd64. The existingcall5helper can't be reused because it stashesSPinBP(MOV SP,BP), whichmstartzeroes.go_linux.go,go_darwin.go,go_freebsd.go,go_netbsd.go: callcallThreadEntryFn(ts.fn).trampolines_amd64.s: the shim.threadentry_amd64.go: asm function declaration.threadentry_noasm.go: plain-call fallback for non-amd64 (unaffected — those epilogues don't rely onBP).threadentry_test.go: regression test that forces locked OS threads to exit.Testing
TestThreadEntryReturncrashes reliably before this change and passes after.CGO_ENABLED=0+ Go 1.28-devel toolchain, the standard library'sos,syscallandruntimesuites pass again.linux/amd64,linux/arm64,linux/386,darwin/amd64,netbsd/amd64;gofmtclean.