Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions internal/fakecgo/go_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 1 addition & 3 deletions internal/fakecgo/go_freebsd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 1 addition & 3 deletions internal/fakecgo/go_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 1 addition & 3 deletions internal/fakecgo/go_netbsd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
15 changes: 15 additions & 0 deletions internal/fakecgo/threadentry_amd64.go
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions internal/fakecgo/threadentry_noasm.go
Original file line number Diff line number Diff line change
@@ -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)))()
}
50 changes: 50 additions & 0 deletions internal/fakecgo/threadentry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: Apache-2.0
Comment thread
gdams marked this conversation as resolved.
// 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()
}
}
17 changes: 17 additions & 0 deletions internal/fakecgo/trampolines_amd64.s
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down