From 455f9c03f478ae6ed7e374cf60c96c005972f04a Mon Sep 17 00:00:00 2001 From: George Adams Date: Fri, 31 Jul 2026 10:12:46 +0100 Subject: [PATCH 1/4] internal/fakecgo: preserve frame pointer across mstart call in threadentry 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 #485 --- internal/fakecgo/go_darwin.go | 8 ++-- internal/fakecgo/go_freebsd.go | 8 ++-- internal/fakecgo/go_linux.go | 8 ++-- internal/fakecgo/go_netbsd.go | 8 ++-- internal/fakecgo/threadentry_amd64.go | 12 ++++++ internal/fakecgo/threadentry_noasm.go | 24 +++++++++++ internal/fakecgo/threadentry_repro_test.go | 47 ++++++++++++++++++++++ internal/fakecgo/trampolines_amd64.s | 17 ++++++++ 8 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 internal/fakecgo/threadentry_amd64.go create mode 100644 internal/fakecgo/threadentry_noasm.go create mode 100644 internal/fakecgo/threadentry_repro_test.go diff --git a/internal/fakecgo/go_darwin.go b/internal/fakecgo/go_darwin.go index d0868f0f..4ea58e9d 100644 --- a/internal/fakecgo/go_darwin.go +++ b/internal/fakecgo/go_darwin.go @@ -55,9 +55,11 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { //#endif setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) - // faking funcs in go is a bit a... involved - but the following works :) - fn := uintptr(unsafe.Pointer(&ts.fn)) - (*(*func())(unsafe.Pointer(&fn)))() + // Call ts.fn (runtime.mstart) through an assembly shim that saves and + // restores the frame pointer around the call. mstart returns with BP + // clobbered, which would otherwise make this function's frame-pointer + // epilogue (LEAVE on amd64) fault when it returns. + callThreadEntryFn(ts.fn) return nil } diff --git a/internal/fakecgo/go_freebsd.go b/internal/fakecgo/go_freebsd.go index 9bb99554..c47a645a 100644 --- a/internal/fakecgo/go_freebsd.go +++ b/internal/fakecgo/go_freebsd.go @@ -49,9 +49,11 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) - // faking funcs in go is a bit a... involved - but the following works :) - fn := uintptr(unsafe.Pointer(&ts.fn)) - (*(*func())(unsafe.Pointer(&fn)))() + // Call ts.fn (runtime.mstart) through an assembly shim that saves and + // restores the frame pointer around the call. mstart returns with BP + // clobbered, which would otherwise make this function's frame-pointer + // epilogue (LEAVE on amd64) fault when it returns. + callThreadEntryFn(ts.fn) return nil } diff --git a/internal/fakecgo/go_linux.go b/internal/fakecgo/go_linux.go index 089d9fe4..d14c7cc6 100644 --- a/internal/fakecgo/go_linux.go +++ b/internal/fakecgo/go_linux.go @@ -49,9 +49,11 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) - // faking funcs in go is a bit a... involved - but the following works :) - fn := uintptr(unsafe.Pointer(&ts.fn)) - (*(*func())(unsafe.Pointer(&fn)))() + // Call ts.fn (runtime.mstart) through an assembly shim that saves and + // restores the frame pointer around the call. mstart returns with BP + // clobbered, which would otherwise make this function's frame-pointer + // epilogue (LEAVE on amd64) fault when it returns. + callThreadEntryFn(ts.fn) return nil } diff --git a/internal/fakecgo/go_netbsd.go b/internal/fakecgo/go_netbsd.go index 89f475e6..4cb9e958 100644 --- a/internal/fakecgo/go_netbsd.go +++ b/internal/fakecgo/go_netbsd.go @@ -60,9 +60,11 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) - // faking funcs in go is a bit a... involved - but the following works :) - fn := uintptr(unsafe.Pointer(&ts.fn)) - (*(*func())(unsafe.Pointer(&fn)))() + // Call ts.fn (runtime.mstart) through an assembly shim that saves and + // restores the frame pointer around the call. mstart returns with BP + // clobbered, which would otherwise make this function's frame-pointer + // epilogue (LEAVE on amd64) fault when it returns. + callThreadEntryFn(ts.fn) return nil } diff --git a/internal/fakecgo/threadentry_amd64.go b/internal/fakecgo/threadentry_amd64.go new file mode 100644 index 00000000..bfd0d894 --- /dev/null +++ b/internal/fakecgo/threadentry_amd64.go @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 The Ebitengine Authors + +//go:build !cgo && amd64 && (darwin || freebsd || linux || netbsd) + +package fakecgo + +// callThreadEntryFn calls fn (runtime.mstart) while saving and restoring the +// frame pointer and other callee-saved registers around the call. mstart +// returns with BP clobbered, so without this shim the caller's frame-pointer +// epilogue would fault. Implemented in trampolines_amd64.s. +func callThreadEntryFn(fn uintptr) diff --git a/internal/fakecgo/threadentry_noasm.go b/internal/fakecgo/threadentry_noasm.go new file mode 100644 index 00000000..6490baf8 --- /dev/null +++ b/internal/fakecgo/threadentry_noasm.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 The Ebitengine Authors + +//go:build !cgo && !amd64 && (darwin || freebsd || linux || netbsd) + +package fakecgo + +import "unsafe" + +// callThreadEntryFn calls fn (runtime.mstart). On architectures without the +// amd64 frame-pointer epilogue issue this is a plain indirect call. It must be +// nosplit and norace like the threadentry callers so it neither inserts a +// morestack preamble nor runs race instrumentation during the fragile +// thread-bootstrap window. +// +//go:nosplit +//go:norace +func callThreadEntryFn(fn uintptr) { + // fn is the code pointer. Build a func value whose first word is fn by + // pointing the closure at &fn, then call it (same trick fakecgo has always + // used to call a raw PC from Go). + fnPtr := uintptr(unsafe.Pointer(&fn)) + (*(*func())(unsafe.Pointer(&fnPtr)))() +} diff --git a/internal/fakecgo/threadentry_repro_test.go b/internal/fakecgo/threadentry_repro_test.go new file mode 100644 index 00000000..6f0812f1 --- /dev/null +++ b/internal/fakecgo/threadentry_repro_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 The Ebitengine Authors + +// The frame-pointer LEAVE epilogue that this regression guards against is +// amd64-specific, so the test only runs there. +//go:build !cgo && amd64 && (linux || darwin || freebsd || netbsd) + +package fakecgo + +import ( + "runtime" + "sync" + "testing" +) + +// TestThreadEntryReturn exercises the fakecgo threadentry -> runtime.mstart +// return path. +// +// When iscgo is forced true by fakecgo, the runtime creates every new OS +// thread through _cgo_thread_start -> threadentry_trampoline -> threadentry, +// which calls runtime.mstart. 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. On amd64 with Go's frame-pointer LEAVE epilogue +// this used to crash, because mstart returns with BP clobbered. +// +// Locking an OS thread and then letting the goroutine exit (without calling +// UnlockOSThread) forces the locked fakecgo M to exit, driving mstart to return +// into threadentry. A busy loop that never lets the goroutine exit does NOT +// reproduce the crash, because the M never exits and mstart never returns. +func TestThreadEntryReturn(t *testing.T) { + const rounds = 50 + const workers = 64 + for r := 0; r < rounds; r++ { + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + runtime.LockOSThread() + // Intentionally do NOT call UnlockOSThread: returning here exits + // the goroutine while its OS thread is locked, forcing the M to + // exit and mstart to return into fakecgo.threadentry. + }() + } + wg.Wait() + } +} diff --git a/internal/fakecgo/trampolines_amd64.s b/internal/fakecgo/trampolines_amd64.s index c88d3a22..e8a152d8 100644 --- a/internal/fakecgo/trampolines_amd64.s +++ b/internal/fakecgo/trampolines_amd64.s @@ -83,6 +83,23 @@ TEXT threadentry_trampoline(SB), NOSPLIT, $0 POP_REGS_HOST_TO_ABI0() RET +// func callThreadEntryFn(fn uintptr) +// Calls fn (runtime.mstart) with the frame pointer and other callee-saved +// registers saved on the stack across the call. mstart returns with BP +// clobbered (BP==0); saving/restoring BP here (as real cgo's crosscall does) +// keeps the caller's frame-pointer LEAVE epilogue valid on return. +TEXT ·callThreadEntryFn(SB), NOSPLIT, $0-8 + MOVQ fn+0(FP), R11 + PUSH_REGS_HOST_TO_ABI0() + + // X15 is designated by Go as a fixed zero register. + PXOR X15, X15 + + CALL R11 + + POP_REGS_HOST_TO_ABI0() + RET + TEXT ·call5(SB), NOSPLIT, $0-56 MOVQ fn+0(FP), R11 MOVQ a1+8(FP), DI From 8c4e29189bdfcf1dee64831ff14016a2dfd60558 Mon Sep 17 00:00:00 2001 From: George Adams Date: Fri, 31 Jul 2026 12:43:37 +0100 Subject: [PATCH 2/4] rename test --- .../fakecgo/{threadentry_repro_test.go => threadentry_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/fakecgo/{threadentry_repro_test.go => threadentry_test.go} (100%) diff --git a/internal/fakecgo/threadentry_repro_test.go b/internal/fakecgo/threadentry_test.go similarity index 100% rename from internal/fakecgo/threadentry_repro_test.go rename to internal/fakecgo/threadentry_test.go From a197cd4ee96702cbd8c065d00fae1728613e6dd5 Mon Sep 17 00:00:00 2001 From: George Adams Date: Fri, 31 Jul 2026 12:51:11 +0100 Subject: [PATCH 3/4] review fixes --- internal/fakecgo/threadentry_amd64.go | 5 ++++- internal/fakecgo/threadentry_noasm.go | 2 +- internal/fakecgo/threadentry_test.go | 25 ++++++++++++++----------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/internal/fakecgo/threadentry_amd64.go b/internal/fakecgo/threadentry_amd64.go index bfd0d894..7f78dd57 100644 --- a/internal/fakecgo/threadentry_amd64.go +++ b/internal/fakecgo/threadentry_amd64.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 The Ebitengine Authors +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors //go:build !cgo && amd64 && (darwin || freebsd || linux || netbsd) @@ -9,4 +9,7 @@ package fakecgo // frame pointer and other callee-saved registers around the call. mstart // returns with BP clobbered, so without this shim the caller's frame-pointer // epilogue would fault. Implemented in trampolines_amd64.s. +// +//go:nosplit +//go:norace func callThreadEntryFn(fn uintptr) diff --git a/internal/fakecgo/threadentry_noasm.go b/internal/fakecgo/threadentry_noasm.go index 6490baf8..8771852d 100644 --- a/internal/fakecgo/threadentry_noasm.go +++ b/internal/fakecgo/threadentry_noasm.go @@ -1,5 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 The Ebitengine Authors +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors //go:build !cgo && !amd64 && (darwin || freebsd || linux || netbsd) diff --git a/internal/fakecgo/threadentry_test.go b/internal/fakecgo/threadentry_test.go index 6f0812f1..b6b27c5f 100644 --- a/internal/fakecgo/threadentry_test.go +++ b/internal/fakecgo/threadentry_test.go @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2025 The Ebitengine Authors +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors -// The frame-pointer LEAVE epilogue that this regression guards against is -// amd64-specific, so the test only runs there. -//go:build !cgo && amd64 && (linux || darwin || freebsd || netbsd) +// 386/arm are excluded: the standalone fakecgo test binary resolves pthread +// from libpthread.so.0, which fails the dynamic symbol lookup on 32-bit linux. +//go:build !cgo && (linux || darwin || freebsd || netbsd) && !386 && !arm package fakecgo @@ -14,26 +14,29 @@ import ( ) // TestThreadEntryReturn exercises the fakecgo threadentry -> runtime.mstart -// return path. +// return path, i.e. that an M created by fakecgo can exit cleanly. // // When iscgo is forced true by fakecgo, the runtime creates every new OS // thread through _cgo_thread_start -> threadentry_trampoline -> threadentry, // which calls runtime.mstart. 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. On amd64 with Go's frame-pointer LEAVE epilogue -// this used to crash, because mstart returns with BP clobbered. +// mstart back into threadentry. This return path has been the source of +// crashes (a frame-pointer LEAVE fault on amd64, a bad indirect call and race +// instrumentation on other platforms), so it is worth exercising everywhere. // // Locking an OS thread and then letting the goroutine exit (without calling // UnlockOSThread) forces the locked fakecgo M to exit, driving mstart to return // into threadentry. A busy loop that never lets the goroutine exit does NOT // reproduce the crash, because the M never exits and mstart never returns. func TestThreadEntryReturn(t *testing.T) { - const rounds = 50 - const workers = 64 - for r := 0; r < rounds; r++ { + // The M exits on the first goroutine return, so a modest amount of churn is + // enough to catch a broken teardown while staying quick under emulation. + const rounds = 10 + const workers = 16 + for range rounds { var wg sync.WaitGroup wg.Add(workers) - for i := 0; i < workers; i++ { + for range workers { go func() { defer wg.Done() runtime.LockOSThread() From 598be92cc637cc214b286b7ce6b3c5a189945672 Mon Sep 17 00:00:00 2001 From: George Adams Date: Fri, 31 Jul 2026 13:54:25 +0100 Subject: [PATCH 4/4] internal/fakecgo: drop redundant inline comments --- internal/fakecgo/go_darwin.go | 4 ---- internal/fakecgo/go_freebsd.go | 4 ---- internal/fakecgo/go_linux.go | 4 ---- internal/fakecgo/go_netbsd.go | 4 ---- 4 files changed, 16 deletions(-) diff --git a/internal/fakecgo/go_darwin.go b/internal/fakecgo/go_darwin.go index 4ea58e9d..53126065 100644 --- a/internal/fakecgo/go_darwin.go +++ b/internal/fakecgo/go_darwin.go @@ -55,10 +55,6 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { //#endif setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) - // Call ts.fn (runtime.mstart) through an assembly shim that saves and - // restores the frame pointer around the call. mstart returns with BP - // clobbered, which would otherwise make this function's frame-pointer - // epilogue (LEAVE on amd64) fault when it returns. callThreadEntryFn(ts.fn) return nil diff --git a/internal/fakecgo/go_freebsd.go b/internal/fakecgo/go_freebsd.go index c47a645a..352c2a56 100644 --- a/internal/fakecgo/go_freebsd.go +++ b/internal/fakecgo/go_freebsd.go @@ -49,10 +49,6 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) - // Call ts.fn (runtime.mstart) through an assembly shim that saves and - // restores the frame pointer around the call. mstart returns with BP - // clobbered, which would otherwise make this function's frame-pointer - // epilogue (LEAVE on amd64) fault when it returns. callThreadEntryFn(ts.fn) return nil diff --git a/internal/fakecgo/go_linux.go b/internal/fakecgo/go_linux.go index d14c7cc6..122157b1 100644 --- a/internal/fakecgo/go_linux.go +++ b/internal/fakecgo/go_linux.go @@ -49,10 +49,6 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) - // Call ts.fn (runtime.mstart) through an assembly shim that saves and - // restores the frame pointer around the call. mstart returns with BP - // clobbered, which would otherwise make this function's frame-pointer - // epilogue (LEAVE on amd64) fault when it returns. callThreadEntryFn(ts.fn) return nil diff --git a/internal/fakecgo/go_netbsd.go b/internal/fakecgo/go_netbsd.go index 4cb9e958..0b979a48 100644 --- a/internal/fakecgo/go_netbsd.go +++ b/internal/fakecgo/go_netbsd.go @@ -60,10 +60,6 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) - // Call ts.fn (runtime.mstart) through an assembly shim that saves and - // restores the frame pointer around the call. mstart returns with BP - // clobbered, which would otherwise make this function's frame-pointer - // epilogue (LEAVE on amd64) fault when it returns. callThreadEntryFn(ts.fn) return nil