detached 077e9d5

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 UnverifiedB5690EEEBB952194 The 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 {
5555 //#endif
5656 setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g)))
5757
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)
6159
6260 return nil
6361 }
@@ -55,9 +55,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer {
55 //#endif55 //#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 nil60 return nil
63 }61 }
M internal/fakecgo/go_freebsd.go +1 -3
@@ -49,9 +49,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer {
4949
5050 setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g)))
5151
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)
5553
5654 return nil
5755 }
@@ -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 nil54 return nil
57 }55 }
M internal/fakecgo/go_linux.go +1 -3
@@ -49,9 +49,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer {
4949
5050 setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g)))
5151
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)
5553
5654 return nil
5755 }
@@ -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 nil54 return nil
57 }55 }
M internal/fakecgo/go_netbsd.go +1 -3
@@ -60,9 +60,7 @@ func threadentry(v unsafe.Pointer) unsafe.Pointer {
6060
6161 setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g)))
6262
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)
6664
6765 return nil
6866 }
@@ -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 nil65 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
8383 POP_REGS_HOST_TO_ABI0()
8484 RET
8585
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+
86103 TEXT ·call5(SB), NOSPLIT, $0-56
87104 MOVQ fn+0(FP), R11
88105 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 RET84 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-56103 TEXT ·call5(SB), NOSPLIT, $0-56
87 MOVQ fn+0(FP), R11104 MOVQ fn+0(FP), R11
88 MOVQ a1+8(FP), DI105 MOVQ a1+8(FP), DI

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 102

↑↓ moveenter openesc close