diff --git a/internal/fakecgo/go_darwin.go b/internal/fakecgo/go_darwin.go index d0868f0f..53126065 100644 --- a/internal/fakecgo/go_darwin.go +++ b/internal/fakecgo/go_darwin.go @@ -55,9 +55,7 @@ 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)))() + callThreadEntryFn(ts.fn) return nil } diff --git a/internal/fakecgo/go_freebsd.go b/internal/fakecgo/go_freebsd.go index 9bb99554..352c2a56 100644 --- a/internal/fakecgo/go_freebsd.go +++ b/internal/fakecgo/go_freebsd.go @@ -49,9 +49,7 @@ 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)))() + callThreadEntryFn(ts.fn) return nil } diff --git a/internal/fakecgo/go_linux.go b/internal/fakecgo/go_linux.go index 089d9fe4..122157b1 100644 --- a/internal/fakecgo/go_linux.go +++ b/internal/fakecgo/go_linux.go @@ -49,9 +49,7 @@ 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)))() + callThreadEntryFn(ts.fn) return nil } diff --git a/internal/fakecgo/go_netbsd.go b/internal/fakecgo/go_netbsd.go index 89f475e6..0b979a48 100644 --- a/internal/fakecgo/go_netbsd.go +++ b/internal/fakecgo/go_netbsd.go @@ -60,9 +60,7 @@ 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)))() + 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..7f78dd57 --- /dev/null +++ b/internal/fakecgo/threadentry_amd64.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 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. +// +//go:nosplit +//go:norace +func callThreadEntryFn(fn uintptr) diff --git a/internal/fakecgo/threadentry_noasm.go b/internal/fakecgo/threadentry_noasm.go new file mode 100644 index 00000000..8771852d --- /dev/null +++ b/internal/fakecgo/threadentry_noasm.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 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_test.go b/internal/fakecgo/threadentry_test.go new file mode 100644 index 00000000..b6b27c5f --- /dev/null +++ b/internal/fakecgo/threadentry_test.go @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Ebitengine Authors + +// 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 + +import ( + "runtime" + "sync" + "testing" +) + +// TestThreadEntryReturn exercises the fakecgo threadentry -> runtime.mstart +// 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. 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) { + // 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 range workers { + 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