internal/fakecgo: preserve frame pointer across mstart call in threadentry (#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
- commit
- 077e9d53635a94054efc341688c8e1e7ed499382
- author
- George Adams · · 2026-07-31 13:10 UTC
- committer
- GitHub · · 2026-07-31 13:10 UTC
- parent
- d476432
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
8 files · +110 -12
M internal/fakecgo/go_darwin.go +1 -3
| @@ -55,9 +55,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { | |||
| 55 | 55 | //#endif | |
| 56 | 56 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | |
| 57 | 57 | ||
| 58 | - | // faking funcs in go is a bit a... involved - but the following works :) | |
| 59 | - | fn := uintptr(unsafe.Pointer(&ts.fn)) | |
| 60 | - | (*(*func())(unsafe.Pointer(&fn)))() | |
| 58 | + | callThreadEntryFn(ts.fn) | |
| 61 | 59 | ||
| 62 | 60 | return nil | |
| 63 | 61 | } | |
| @@ -55,9 +55,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { | |||||
| 55 | //#endif | 55 | //#endif | ||
| 56 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | 56 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | ||
| 57 | 57 | ||||
| 58 | - | // faking funcs in go is a bit a... involved - but the following works :) | 58 | + | callThreadEntryFn(ts.fn) |
| 59 | - | fn := uintptr(unsafe.Pointer(&ts.fn)) | |||
| 60 | - | (*(*func())(unsafe.Pointer(&fn)))() | |||
| 61 | 59 | ||||
| 62 | return nil | 60 | return nil | ||
| 63 | } | 61 | } | ||
M internal/fakecgo/go_freebsd.go +1 -3
| @@ -49,9 +49,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { | |||
| 49 | 49 | ||
| 50 | 50 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | |
| 51 | 51 | ||
| 52 | - | // faking funcs in go is a bit a... involved - but the following works :) | |
| 53 | - | fn := uintptr(unsafe.Pointer(&ts.fn)) | |
| 54 | - | (*(*func())(unsafe.Pointer(&fn)))() | |
| 52 | + | callThreadEntryFn(ts.fn) | |
| 55 | 53 | ||
| 56 | 54 | return nil | |
| 57 | 55 | } | |
| @@ -49,9 +49,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { | |||||
| 49 | 49 | ||||
| 50 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | 50 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | ||
| 51 | 51 | ||||
| 52 | - | // faking funcs in go is a bit a... involved - but the following works :) | 52 | + | callThreadEntryFn(ts.fn) |
| 53 | - | fn := uintptr(unsafe.Pointer(&ts.fn)) | |||
| 54 | - | (*(*func())(unsafe.Pointer(&fn)))() | |||
| 55 | 53 | ||||
| 56 | return nil | 54 | return nil | ||
| 57 | } | 55 | } | ||
M internal/fakecgo/go_linux.go +1 -3
| @@ -49,9 +49,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { | |||
| 49 | 49 | ||
| 50 | 50 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | |
| 51 | 51 | ||
| 52 | - | // faking funcs in go is a bit a... involved - but the following works :) | |
| 53 | - | fn := uintptr(unsafe.Pointer(&ts.fn)) | |
| 54 | - | (*(*func())(unsafe.Pointer(&fn)))() | |
| 52 | + | callThreadEntryFn(ts.fn) | |
| 55 | 53 | ||
| 56 | 54 | return nil | |
| 57 | 55 | } | |
| @@ -49,9 +49,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { | |||||
| 49 | 49 | ||||
| 50 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | 50 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | ||
| 51 | 51 | ||||
| 52 | - | // faking funcs in go is a bit a... involved - but the following works :) | 52 | + | callThreadEntryFn(ts.fn) |
| 53 | - | fn := uintptr(unsafe.Pointer(&ts.fn)) | |||
| 54 | - | (*(*func())(unsafe.Pointer(&fn)))() | |||
| 55 | 53 | ||||
| 56 | return nil | 54 | return nil | ||
| 57 | } | 55 | } | ||
M internal/fakecgo/go_netbsd.go +1 -3
| @@ -60,9 +60,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { | |||
| 60 | 60 | ||
| 61 | 61 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | |
| 62 | 62 | ||
| 63 | - | // faking funcs in go is a bit a... involved - but the following works :) | |
| 64 | - | fn := uintptr(unsafe.Pointer(&ts.fn)) | |
| 65 | - | (*(*func())(unsafe.Pointer(&fn)))() | |
| 63 | + | callThreadEntryFn(ts.fn) | |
| 66 | 64 | ||
| 67 | 65 | return nil | |
| 68 | 66 | } | |
| @@ -60,9 +60,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer { | |||||
| 60 | 60 | ||||
| 61 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | 61 | setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) | ||
| 62 | 62 | ||||
| 63 | - | // faking funcs in go is a bit a... involved - but the following works :) | 63 | + | callThreadEntryFn(ts.fn) |
| 64 | - | fn := uintptr(unsafe.Pointer(&ts.fn)) | |||
| 65 | - | (*(*func())(unsafe.Pointer(&fn)))() | |||
| 66 | 64 | ||||
| 67 | return nil | 65 | return nil | ||
| 68 | } | 66 | } | ||
A internal/fakecgo/threadentry_amd64.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | // SPDX-License-Identifier: Apache-2.0 | |
| 2 | + | // SPDX-FileCopyrightText: 2026 The Ebitengine Authors | |
| 3 | + | ||
| 4 | + | //go:build !cgo && amd64 && (darwin || freebsd || linux || netbsd) | |
| 5 | + | ||
| 6 | + | package fakecgo | |
| 7 | + | ||
| 8 | + | // callThreadEntryFn calls fn (runtime.mstart) while saving and restoring the | |
| 9 | + | // frame pointer and other callee-saved registers around the call. mstart | |
| 10 | + | // returns with BP clobbered, so without this shim the caller's frame-pointer | |
| 11 | + | // epilogue would fault. Implemented in trampolines_amd64.s. | |
| 12 | + | // | |
| 13 | + | //go:nosplit | |
| 14 | + | //go:norace | |
| 15 | + | func callThreadEntryFn(fn uintptr) | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | // SPDX-License-Identifier: Apache-2.0 | |||
| 2 | + | // SPDX-FileCopyrightText: 2026 The Ebitengine Authors | |||
| 3 | + | ||||
| 4 | + | //go:build !cgo && amd64 && (darwin || freebsd || linux || netbsd) | |||
| 5 | + | ||||
| 6 | + | package fakecgo | |||
| 7 | + | ||||
| 8 | + | // callThreadEntryFn calls fn (runtime.mstart) while saving and restoring the | |||
| 9 | + | // frame pointer and other callee-saved registers around the call. mstart | |||
| 10 | + | // returns with BP clobbered, so without this shim the caller's frame-pointer | |||
| 11 | + | // epilogue would fault. Implemented in trampolines_amd64.s. | |||
| 12 | + | // | |||
| 13 | + | //go:nosplit | |||
| 14 | + | //go:norace | |||
| 15 | + | func callThreadEntryFn(fn uintptr) | |||
A internal/fakecgo/threadentry_noasm.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | // SPDX-License-Identifier: Apache-2.0 | |
| 2 | + | // SPDX-FileCopyrightText: 2026 The Ebitengine Authors | |
| 3 | + | ||
| 4 | + | //go:build !cgo && !amd64 && (darwin || freebsd || linux || netbsd) | |
| 5 | + | ||
| 6 | + | package fakecgo | |
| 7 | + | ||
| 8 | + | import "unsafe" | |
| 9 | + | ||
| 10 | + | // callThreadEntryFn calls fn (runtime.mstart). On architectures without the | |
| 11 | + | // amd64 frame-pointer epilogue issue this is a plain indirect call. It must be | |
| 12 | + | // nosplit and norace like the threadentry callers so it neither inserts a | |
| 13 | + | // morestack preamble nor runs race instrumentation during the fragile | |
| 14 | + | // thread-bootstrap window. | |
| 15 | + | // | |
| 16 | + | //go:nosplit | |
| 17 | + | //go:norace | |
| 18 | + | func callThreadEntryFn(fn uintptr) { | |
| 19 | + | // fn is the code pointer. Build a func value whose first word is fn by | |
| 20 | + | // pointing the closure at &fn, then call it (same trick fakecgo has always | |
| 21 | + | // used to call a raw PC from Go). | |
| 22 | + | fnPtr := uintptr(unsafe.Pointer(&fn)) | |
| 23 | + | (*(*func())(unsafe.Pointer(&fnPtr)))() | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | // SPDX-License-Identifier: Apache-2.0 | |||
| 2 | + | // SPDX-FileCopyrightText: 2026 The Ebitengine Authors | |||
| 3 | + | ||||
| 4 | + | //go:build !cgo && !amd64 && (darwin || freebsd || linux || netbsd) | |||
| 5 | + | ||||
| 6 | + | package fakecgo | |||
| 7 | + | ||||
| 8 | + | import "unsafe" | |||
| 9 | + | ||||
| 10 | + | // callThreadEntryFn calls fn (runtime.mstart). On architectures without the | |||
| 11 | + | // amd64 frame-pointer epilogue issue this is a plain indirect call. It must be | |||
| 12 | + | // nosplit and norace like the threadentry callers so it neither inserts a | |||
| 13 | + | // morestack preamble nor runs race instrumentation during the fragile | |||
| 14 | + | // thread-bootstrap window. | |||
| 15 | + | // | |||
| 16 | + | //go:nosplit | |||
| 17 | + | //go:norace | |||
| 18 | + | func callThreadEntryFn(fn uintptr) { | |||
| 19 | + | // fn is the code pointer. Build a func value whose first word is fn by | |||
| 20 | + | // pointing the closure at &fn, then call it (same trick fakecgo has always | |||
| 21 | + | // used to call a raw PC from Go). | |||
| 22 | + | fnPtr := uintptr(unsafe.Pointer(&fn)) | |||
| 23 | + | (*(*func())(unsafe.Pointer(&fnPtr)))() | |||
| 24 | + | } | |||
A internal/fakecgo/threadentry_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | // SPDX-License-Identifier: Apache-2.0 | |
| 2 | + | // SPDX-FileCopyrightText: 2026 The Ebitengine Authors | |
| 3 | + | ||
| 4 | + | // 386/arm are excluded: the standalone fakecgo test binary resolves pthread | |
| 5 | + | // from libpthread.so.0, which fails the dynamic symbol lookup on 32-bit linux. | |
| 6 | + | //go:build !cgo && (linux || darwin || freebsd || netbsd) && !386 && !arm | |
| 7 | + | ||
| 8 | + | package fakecgo | |
| 9 | + | ||
| 10 | + | import ( | |
| 11 | + | "runtime" | |
| 12 | + | "sync" | |
| 13 | + | "testing" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // TestThreadEntryReturn exercises the fakecgo threadentry -> runtime.mstart | |
| 17 | + | // return path, i.e. that an M created by fakecgo can exit cleanly. | |
| 18 | + | // | |
| 19 | + | // When iscgo is forced true by fakecgo, the runtime creates every new OS | |
| 20 | + | // thread through _cgo_thread_start -> threadentry_trampoline -> threadentry, | |
| 21 | + | // which calls runtime.mstart. For a fakecgo thread the M's stack is | |
| 22 | + | // system-allocated, so when the M exits, mexit(osStack=true) returns from | |
| 23 | + | // mstart back into threadentry. This return path has been the source of | |
| 24 | + | // crashes (a frame-pointer LEAVE fault on amd64, a bad indirect call and race | |
| 25 | + | // instrumentation on other platforms), so it is worth exercising everywhere. | |
| 26 | + | // | |
| 27 | + | // Locking an OS thread and then letting the goroutine exit (without calling | |
| 28 | + | // UnlockOSThread) forces the locked fakecgo M to exit, driving mstart to return | |
| 29 | + | // into threadentry. A busy loop that never lets the goroutine exit does NOT | |
| 30 | + | // reproduce the crash, because the M never exits and mstart never returns. | |
| 31 | + | func TestThreadEntryReturn(t *testing.T) { | |
| 32 | + | // The M exits on the first goroutine return, so a modest amount of churn is | |
| 33 | + | // enough to catch a broken teardown while staying quick under emulation. | |
| 34 | + | const rounds = 10 | |
| 35 | + | const workers = 16 | |
| 36 | + | for range rounds { | |
| 37 | + | var wg sync.WaitGroup | |
| 38 | + | wg.Add(workers) | |
| 39 | + | for range workers { | |
| 40 | + | go func() { | |
| 41 | + | defer wg.Done() | |
| 42 | + | runtime.LockOSThread() | |
| 43 | + | // Intentionally do NOT call UnlockOSThread: returning here exits | |
| 44 | + | // the goroutine while its OS thread is locked, forcing the M to | |
| 45 | + | // exit and mstart to return into fakecgo.threadentry. | |
| 46 | + | }() | |
| 47 | + | } | |
| 48 | + | wg.Wait() | |
| 49 | + | } | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | // SPDX-License-Identifier: Apache-2.0 | |||
| 2 | + | // SPDX-FileCopyrightText: 2026 The Ebitengine Authors | |||
| 3 | + | ||||
| 4 | + | // 386/arm are excluded: the standalone fakecgo test binary resolves pthread | |||
| 5 | + | // from libpthread.so.0, which fails the dynamic symbol lookup on 32-bit linux. | |||
| 6 | + | //go:build !cgo && (linux || darwin || freebsd || netbsd) && !386 && !arm | |||
| 7 | + | ||||
| 8 | + | package fakecgo | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "runtime" | |||
| 12 | + | "sync" | |||
| 13 | + | "testing" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // TestThreadEntryReturn exercises the fakecgo threadentry -> runtime.mstart | |||
| 17 | + | // return path, i.e. that an M created by fakecgo can exit cleanly. | |||
| 18 | + | // | |||
| 19 | + | // When iscgo is forced true by fakecgo, the runtime creates every new OS | |||
| 20 | + | // thread through _cgo_thread_start -> threadentry_trampoline -> threadentry, | |||
| 21 | + | // which calls runtime.mstart. For a fakecgo thread the M's stack is | |||
| 22 | + | // system-allocated, so when the M exits, mexit(osStack=true) returns from | |||
| 23 | + | // mstart back into threadentry. This return path has been the source of | |||
| 24 | + | // crashes (a frame-pointer LEAVE fault on amd64, a bad indirect call and race | |||
| 25 | + | // instrumentation on other platforms), so it is worth exercising everywhere. | |||
| 26 | + | // | |||
| 27 | + | // Locking an OS thread and then letting the goroutine exit (without calling | |||
| 28 | + | // UnlockOSThread) forces the locked fakecgo M to exit, driving mstart to return | |||
| 29 | + | // into threadentry. A busy loop that never lets the goroutine exit does NOT | |||
| 30 | + | // reproduce the crash, because the M never exits and mstart never returns. | |||
| 31 | + | func TestThreadEntryReturn(t *testing.T) { | |||
| 32 | + | // The M exits on the first goroutine return, so a modest amount of churn is | |||
| 33 | + | // enough to catch a broken teardown while staying quick under emulation. | |||
| 34 | + | const rounds = 10 | |||
| 35 | + | const workers = 16 | |||
| 36 | + | for range rounds { | |||
| 37 | + | var wg sync.WaitGroup | |||
| 38 | + | wg.Add(workers) | |||
| 39 | + | for range workers { | |||
| 40 | + | go func() { | |||
| 41 | + | defer wg.Done() | |||
| 42 | + | runtime.LockOSThread() | |||
| 43 | + | // Intentionally do NOT call UnlockOSThread: returning here exits | |||
| 44 | + | // the goroutine while its OS thread is locked, forcing the M to | |||
| 45 | + | // exit and mstart to return into fakecgo.threadentry. | |||
| 46 | + | }() | |||
| 47 | + | } | |||
| 48 | + | wg.Wait() | |||
| 49 | + | } | |||
| 50 | + | } | |||
M internal/fakecgo/trampolines_amd64.s +17 -0
| @@ -83,6 +83,23 @@ TEXT threadentry_trampoline(SB), NOSPLIT, $0 | |||
| 83 | 83 | POP_REGS_HOST_TO_ABI0() | |
| 84 | 84 | RET | |
| 85 | 85 | ||
| 86 | + | // func callThreadEntryFn(fn uintptr) | |
| 87 | + | // Calls fn (runtime.mstart) with the frame pointer and other callee-saved | |
| 88 | + | // registers saved on the stack across the call. mstart returns with BP | |
| 89 | + | // clobbered (BP==0); saving/restoring BP here (as real cgo's crosscall does) | |
| 90 | + | // keeps the caller's frame-pointer LEAVE epilogue valid on return. | |
| 91 | + | TEXT ·callThreadEntryFn(SB), NOSPLIT, $0-8 | |
| 92 | + | MOVQ fn+0(FP), R11 | |
| 93 | + | PUSH_REGS_HOST_TO_ABI0() | |
| 94 | + | ||
| 95 | + | // X15 is designated by Go as a fixed zero register. | |
| 96 | + | PXOR X15, X15 | |
| 97 | + | ||
| 98 | + | CALL R11 | |
| 99 | + | ||
| 100 | + | POP_REGS_HOST_TO_ABI0() | |
| 101 | + | RET | |
| 102 | + | ||
| 86 | 103 | TEXT ·call5(SB), NOSPLIT, $0-56 | |
| 87 | 104 | MOVQ fn+0(FP), R11 | |
| 88 | 105 | MOVQ a1+8(FP), DI | |
| @@ -83,6 +83,23 @@ TEXT threadentry_trampoline(SB), NOSPLIT, $0 | |||||
| 83 | POP_REGS_HOST_TO_ABI0() | 83 | POP_REGS_HOST_TO_ABI0() | ||
| 84 | RET | 84 | RET | ||
| 85 | 85 | ||||
| 86 | + | // func callThreadEntryFn(fn uintptr) | |||
| 87 | + | // Calls fn (runtime.mstart) with the frame pointer and other callee-saved | |||
| 88 | + | // registers saved on the stack across the call. mstart returns with BP | |||
| 89 | + | // clobbered (BP==0); saving/restoring BP here (as real cgo's crosscall does) | |||
| 90 | + | // keeps the caller's frame-pointer LEAVE epilogue valid on return. | |||
| 91 | + | TEXT ·callThreadEntryFn(SB), NOSPLIT, $0-8 | |||
| 92 | + | MOVQ fn+0(FP), R11 | |||
| 93 | + | PUSH_REGS_HOST_TO_ABI0() | |||
| 94 | + | ||||
| 95 | + | // X15 is designated by Go as a fixed zero register. | |||
| 96 | + | PXOR X15, X15 | |||
| 97 | + | ||||
| 98 | + | CALL R11 | |||
| 99 | + | ||||
| 100 | + | POP_REGS_HOST_TO_ABI0() | |||
| 101 | + | RET | |||
| 102 | + | ||||
| 86 | TEXT ·call5(SB), NOSPLIT, $0-56 | 103 | TEXT ·call5(SB), NOSPLIT, $0-56 | ||
| 87 | MOVQ fn+0(FP), R11 | 104 | MOVQ fn+0(FP), R11 | ||
| 88 | MOVQ a1+8(FP), DI | 105 | MOVQ a1+8(FP), DI | ||