Skip to content

fix: sync fakecgo fixes from purego, lint and test every target - #92

Merged
kolkov merged 13 commits into
mainfrom
fix/fakecgo-upstream-sync
Oct 15, 2026
Merged

kolkov merged 13 commits into
mainfrom
fix/fakecgo-upstream-sync

Conversation

@kolkov

@kolkov kolkov commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Three related pieces of work:

  1. fakecgo fixes from ebitengine/purego. Every internal/fakecgo change in purego since our snapshot (23e4f41 → d456b1b, v0.12.0-alpha.1) was checked against our code and against primary sources (Go runtime, glibc/FreeBSD/NetBSD headers). Each applicable fix is ported in its own commit, with a test or a check that fails before the change.
  2. Lint on every target. Linting the other targets found real bugs; those are fixed, the deliberate cases are documented, and CI now lints every supported target.
  3. Close the CI gaps that let these bugs through. Tests now run natively on every production target that has a hosted runner, on the minimum and the current stable Go, with Go tip as an informational job.

Every commit builds for linux, darwin, windows, freebsd (amd64/arm64) and android/arm64 and passes the full test suite on linux/amd64 (CGO_ENABLED=0 and 1) on its own. Fixes for bugs already on main come before the commits whose tests expose them, so on linux/arm64 (QEMU) every commit from the second on passes too; the first one still carries the flaky-test bug from main that the second commit fixes.

Bugs fixed

Commit Bug Status on main
fix(fakecgo): implement setuid and friends on Linux syscall.Setuid/Setgid/Setgroups/… panic with doAllThreadsSyscall not supported with cgo enabled in every CGO_ENABLED=0 Linux program that imports goffi active (port of purego#426)
fix(ffi): reject library and symbol names containing NUL Windows GetSymbol panics on a NUL (deprecated syscall.StringBytePtr); elsewhere the name is cut at the NUL and a different library/symbol can be used active
test: build internal/arch/arm64 tests on windows/arm64 go test ./internal/arch/arm64 fails with [setup failed] on Windows ARM64 (since v0.5.1) active (tests only)
fix(fakecgo): preserve frame pointer across mstart in threadentry SIGSEGV on amd64 when a locked thread exits, with the LEAVE epilogue of the Go 1.28 dev tree latent (port of purego#486)
fix(fakecgo): link pthread from libthr.so.3 on FreeBSD DT_NEEDED libpthread.so is a dev-only symlink on FreeBSD port of purego#481
fix(fakecgo): correct NetBSD stack_t, mutex and cond layouts wrong stack_t order (purego#433); mutex/cond were zeroed 8-byte integers instead of NetBSD's structs with a magic number NetBSD not supported yet (#88)

Cleanups: purego#456 (load_g in arm64 crosscall2), #432 (x_cgo_init attr on the stack), #502 (HostLayout), 8-byte aligned pthread_attr_t, reflect.Pointer, a darwin test that no longer converts uintptr to unsafe.Pointer, and four Darwin test helpers that were never called (added in #9).

Not ported: purego#464 (our FreeBSD files are already per-arch) and style-only or other-architecture commits.

Found by the first native linux/arm64 run of this PR

Commit Bug Status on main
fix(fakecgo): stop arm64 threadentry_trampoline clobbering the caller's FP threadentry_trampoline saved R29/R30 by hand inside the frame the assembler already sets up and overwrote the caller's saved FP; when a runtime thread exits (e.g. a goroutine returns while locked to its OS thread), glibc's start_thread faults. SIGSEGV 3/3 on linux/arm64. Upstream removed the same lines in purego#426; my first audit wrongly marked that part as not applicable active (linux/arm64, CGO_ENABLED=0)
test: stop hiding Go pointers from the runtime in uintptr TestExecuteCaptureRegistersSimple and TestCallback_PointerArg passed Go pointers as uintptr, so the target stayed on the goroutine stack and a stack copy left the callee writing to the old stack; random failures (forced stack copy: 15/15 and 5/5 failures before, 0 after) test bug
docs: hold pointer arguments in unsafe.Pointer, never uintptr the README example taught the same uintptr pattern; package docs now state the rule and point to runtime.Pinner docs

Details

setuid. With fakecgo, runtime.iscgo is true but syscall.cgo_libc_setuid and friends were nil, so syscall.Setuid called AllThreadsSyscall → runtime.doAllThreadsSyscall, which panics when iscgo is set (runtime/os_linux.go). The hooks now call glibc's set*id, which changes the credentials of every thread. gen.go can generate OS-specific wrappers; the existing generated files are byte-for-byte unchanged. Android (Bionic uses __errno, not __errno_location) is left for a follow-up.

Frame pointer. mstart returns into threadentry with BP = 0 when an M exits. Go up to 1.27 restores SP with ADDQ/POPQ BP there, so nothing happens (checked with objdump on 1.25.3 and 1.27.1). golang/go CL 548317 (July 2026) made the compiler use LEAVE, which then faults. Current tip skips LEAVE in functions that call ABI0 code (golang/go#80710), which covers threadentry only by accident. With Go built at golang/go 3d45c388, TestThreadEntryReturn crashed with SIGSEGV 3/3 before the change and passes 3/3 after it.

FreeBSD. lib/libthr/Makefile installs libpthread.so through SYMLINKS, which share/mk/bsd.lib.mk tags dev, so on pkgbase systems it only ships with FreeBSD-clibs-dev. readelf -d: libpthread.so before, libthr.so.3 after (amd64 and arm64).

NetBSD. stack_t had the Linux field order (sys/sys/sigtypes.h: ss_sp, ss_size, ss_flags), so threadentry never disabled the inherited signal stack. pthread_mutex_t/pthread_cond_t were 8-byte integers set to 0; NetBSD's are structs with a magic number (48/40 bytes on amd64, 40/40 on aarch64), so pthread_mutex_lock returned EINVAL without locking. Layouts were computed by gcc from the NetBSD headers and are asserted at compile time. trampolines_{amd64,arm64}.s are now also built on NetBSD. Nothing was run on NetBSD.

Lint

.golangci.yml had build-tags: [linux]: a GOOS-named tag adds *_linux.go to the host files instead of switching the target, so linting off Linux type-checked nothing, and CI linted only linux/amd64. The tag is gone; a new lint-targets job (in CI Success) lints linux/arm64, darwin, windows, freebsd and linux/amd64 with cgo; android is linted in its informational job. One documented exclusion: SA4006 in ffi/struct_e2e_test.go, because on Android NewCallback always panics by design.

CI

Gap Now
windows/arm64 tests never ran windows-11-arm in the test matrix
darwin/amd64 never tested (comment claimed no Intel runners) macos-15-intel in the test matrix
linux/arm64 only cross-compiled ubuntu-24.04-arm in the test matrix
tests only on Go 1.25 test-go-stable (gates) and test-go-tip (informational), weekly scheduled run
set*id behaviour untested TestSetuidEtc as root on Linux
FreeBSD: only the library was compiled every test package is compiled and linked; DT_NEEDED checked

Not covered yet: running anything on FreeBSD/NetBSD (VM job, planned).

Testing done locally

  • WSL Ubuntu 22.04 (glibc 2.35): go test -count=1 ./... with CGO_ENABLED=0 and 1 on Go 1.27.1, tip d61b240b and 3d45c388; fakecgo tests also with a Go 1.25.3 build. TestSetuidEtc as root: all 21 cases change the credentials of every thread.
  • Windows amd64 (host): go test -count=1 ./... with CGO_ENABLED=0 and 1.
  • golangci-lint v2.14.0 (the version CI installs): 0 issues on all linted targets.
  • actionlint v1.7.12 on ci.yml: clean.
  • linux/arm64 under QEMU 6.2 (qemu-aarch64, glibc 2.35 arm64): internal/arch/arm64 5/5, TestThreadEntryReturn 3/3; both bugs were reproduced there first and checked under gdb. TestSetuidEtc hangs under this QEMU (purego documents the QEMU ≤ 8.2 tgkill bug for the same test), so it is left to the native arm64 runner.
  • Not run locally: windows/arm64, macOS, FreeBSD, NetBSD natively. CI runs windows/arm64 and macOS.

Note for #88: the NetBSD trampolines_*.s build tags and layouts are now in this PR, so #88 will need a rebase.

@kolkov kolkov added this to the v0.6.5 milestone Oct 15, 2026
@kolkov kolkov added priority: high Important for next release type: bug Something isn't working area: ffi Public API (PrepareCallInterface, CallFunction) area: fakecgo Fake CGO runtime integration platform: linux Linux-specific area: ci CI workflows, check scripts, branch protection platform: freebsd FreeBSD-specific platform: netbsd NetBSD-specific labels Oct 15, 2026
@codecov

codecov Bot commented Oct 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

kolkov added 13 commits October 16, 2026 01:01
fakecgo_test.go (arm64 && !cgo) imports internal/fakecgo, which has no
files on Windows, so go test ./internal/arch/arm64 failed with
"[setup failed]" on Windows ARM64. Windows needs no fakecgo:
runtime.cgocall works there without it. Exclude windows from the
constraint.
TestExecuteCaptureRegistersSimple failed at random on linux/arm64
(GPR mismatch: 0 0 0 0) depending on test flags and Go version. It passed
&out to the capture shim as a uintptr. A uintptr is invisible to escape
analysis, so out stayed on the goroutine stack; when the stack grew
before the call, the shim wrote to the old stack and the test read zeros.
Forcing a stack copy right after taking the address made it fail 15/15.
Keep the argument an unsafe.Pointer, as ffi.CallFunction documents:
avalue leaks, so out moves to the heap.

TestCallback_PointerArg had the same bug: it stored &original as a raw
word in a fake callback frame, and a forced stack copy made it fail 5/5.
The frame plays the C caller, so pin the target with runtime.Pinner, as
for any Go pointer stored in memory C reads.

Both tests now pass with a forced stack copy.
A uintptr hides the target from escape analysis: a local it points to can
stay on the goroutine stack and move before the C function runs, so C
gets a dangling pointer. Two goffi tests failed at random on linux/arm64
for exactly this reason.

- Package docs and CallFunctionContext Safety notes: keep pointer
  arguments in unsafe.Pointer variables, and pin Go pointers stored in
  memory that C reads with runtime.Pinner.
- README strlen example: strPtr is an unsafe.Pointer. The old example was
  harmless (string literal data is not on the stack) but taught the
  pattern. Checked on Windows and Linux: strlen(...) = 13.
…'s FP

threadentry_trampoline is not NOFRAME, so the assembler's prologue saves
R30 and stores the caller's R29 at -8(RSP). The manual SUB of 8*24 moves
that slot to 8*23(RSP), and the hand-written STP (R29, R30) at 8*22 wrote
R30 over it. The epilogue then returned to C with R29 = R30.

When an OS thread created by the runtime exits, for example because a
goroutine returns while locked with runtime.LockOSThread, the thread
returns from threadentry_trampoline into glibc's start_thread, which
reads [x29, #56] and faults. TestThreadEntryReturn (added later in this
series with the amd64 frame-pointer fix) crashed with SIGSEGV 3/3 on
linux/arm64 without this change (QEMU, Go 1.25.3 to tip, and the native
ubuntu-24.04-arm runner) and passes 3/3 with it; darwin/arm64 did not
crash because its libpthread does not use x29 there. This fix comes
before the set*id and frame-pointer commits because their tests exit
locked threads.

Drop the manual R29/R30 save and restore, as upstream did in
ebitengine/purego#426. crosscall2, the other function saving R29/R30 by
hand, is NOFRAME and needs it.
With fakecgo, runtime.iscgo is true but the syscall.cgo_libc_set*id hooks
that runtime/cgo provides were missing. syscall.Setuid, Setgid, Setgroups
and the other set*id calls then fell back to runtime.doAllThreadsSyscall,
which panics with "doAllThreadsSyscall not supported with cgo enabled".
Every CGO_ENABLED=0 Linux program importing goffi crashed on these calls.

Provide the hooks like runtime/cgo does (linux.go + linux_syscall.c): they
call the glibc functions, which apply the change to every thread of the
process. syscall.AllThreadsSyscall now returns ENOTSUP, as with real cgo.

gen.go can now generate wrappers for OS-specific symbols; the existing
generated files are unchanged.

Port of ebitengine/purego#426. TestSetuidEtc comes from Go's
cmd/cgo/internal/test; as root it checks the credentials of every thread.
Android is not covered: Bionic needs __errno instead of __errno_location.
stack_t had ss_flags before ss_size (the Linux order). On NetBSD
(sys/sys/sigtypes.h) ss_size comes first, so the sigaltstack call in
threadentry passed ss_size=4, ss_flags=0 and never disabled the signal
stack inherited from the creating thread (ebitengine/purego#433, #304).

pthread_mutex_t and pthread_cond_t were 8-byte integers initialised to 0.
NetBSD defines them as structs (48/40 bytes on amd64, 40/40 on aarch64)
whose magic field every libpthread call checks, so locking the runtime
init mutex returned EINVAL without locking, or aborted with
PTHREAD_DIAGASSERT=a. Mirror struct __pthread_mutex_st and
__pthread_cond_st and use the magic values of the static initializers.

Also build trampolines_{amd64,arm64}.s on NetBSD: without them
x_cgo_init_trampoline and threadentry_trampoline were not code.

The expected layouts were computed by gcc from the NetBSD headers and are
checked at compile time, so cross-compiling the tests for netbsd catches a
drift. NetBSD is not a supported platform yet; nothing was run on NetBSD.
When an M created by fakecgo exits (e.g. a goroutine returns while locked
to its OS thread), mexit returns from runtime.mstart into threadentry with
BP set to 0. Real cgo calls mstart through crosscall, which saves and
restores the callee-saved registers; fakecgo called it directly.

Go releases up to 1.27 restore SP arithmetically in threadentry's epilogue,
so the zero BP went unnoticed. With the amd64 LEAVE epilogue (golang/go
CL 548317, July 2026) it faults: TestThreadEntryReturn crashed with SIGSEGV
3 of 3 runs with go1.28-devel 3d45c388. Current tip skips LEAVE in
functions that call ABI0 code (golang/go#80710), which only incidentally
covers threadentry today.

Call mstart through callThreadEntryFn, an amd64 assembly shim that saves
and restores the callee-saved registers like crosscall does. Other
architectures keep the indirect call. Port of ebitengine/purego#486.
FreeBSD binaries built with CGO_ENABLED=0 had DT_NEEDED libpthread.so.
That name is only a development symlink to libthr.so: lib/libthr/Makefile
installs it through SYMLINKS, which bsd.lib.mk tags as dev, so a system
installed from base packages (pkgbase) has it only with FreeBSD-clibs-dev.
ebitengine/purego#480 reports the resulting start-up failure on
FreeBSD 15.1-STABLE.

Import the pthread symbols from libthr.so.3, the name the library is
installed under (SHLIB_MAJOR=3, /lib). ffi/callback_cthread_test.go
already loads libthr.so.3. Port of ebitengine/purego#481.
- crosscall2 (arm64): drop the runtime·load_g call. runtime·cgocallback
  loads g itself, or takes it from frame when fn is nil, so the call was
  redundant and only kept one more runtime-internal linkname in use
  (ebitengine/purego#456). The callback dispatcher comments no longer
  claim crosscall2 calls load_g (amd64 never did).
- x_cgo_init: keep pthread_attr_t on the stack, as _cgo_sys_thread_start
  already does, instead of a malloc that only worked around clang < 3.8
  msan (ebitengine/purego#432, golang/go CL 745662).
- pthread_attr_t is now [8]uint64 instead of [64]byte, so it has the
  8-byte alignment of the C type on the Go stack; the size still covers
  glibc (56/64), Darwin (64), FreeBSD (8) and NetBSD (16).
- structs.HostLayout on Darwin pthread_mutex_t and pthread_cond_t
  (ebitengine/purego#502).
On Windows, GetSymbol used the deprecated syscall.StringBytePtr, which
panics on a NUL byte, so GetSymbol(h, "a\x00b") crashed the process. On
Linux, macOS, FreeBSD and Android the name was passed to dlopen/dlsym as
a C string and silently cut at the NUL: LoadLibrary and GetSymbol then
looked up a different library or symbol.

LoadLibrary and GetSymbol now return a *LibraryError wrapping
syscall.EINVAL on every OS, as Windows LoadLibrary already did through
syscall.UTF16PtrFromString. GetSymbol on Windows uses
syscall.BytePtrFromString.
- Darwin ObjC test: objcCallCPtr lets the FFI call write the UTF8String
  result straight into an unsafe.Pointer, so cString no longer converts a
  uintptr (go vet unsafeptr).
- Remove objcArgInt64, objcArgRect, objcArgSize and objcCallBool from the
  Darwin ObjC tests: added in b7dcd3d (#9) and never called.
- reflect.Ptr is the old name of reflect.Pointer; callback.go already uses
  the new one, callback_arm64.go and callback_windows.go now do too.
- classification.go: elementKind's type is inferred from the typed
  constant (staticcheck QF1011).

No behaviour change.
.golangci.yml had run.build-tags: [linux] since the initial commit. A
GOOS-named tag adds *_linux.go to the host OS files instead of switching
the target, so off Linux typecheck failed and golangci-lint analysed
nothing. CI linted only linux/amd64, so arm64, darwin, windows, freebsd
and android code was never linted.

- Remove the build tag.
- New lint-targets job (part of CI Success): linux/arm64, darwin and
  windows (amd64, arm64), freebsd (amd64, arm64) with GOOS/GOARCH and
  CGO_ENABLED=0, plus linux/amd64 with CGO_ENABLED=1 for the cgo-only
  files. The existing Lint job (a required check) is unchanged.
- android/arm64 is linted in the informational android-cross job.
- Exclude SA4006 in ffi/struct_e2e_test.go: on Android NewCallback always
  panics by design, so staticcheck sees the code after it as unreachable.
- Drop the Git Flow header and the develop branch triggers; the branch
  no longer exists.

All targets report 0 issues locally with golangci-lint v2.14.0, the
version CI installs.
Bugs fixed on this branch reached releases because CI never ran tests on
the target or toolchain where they live.

- Test matrix: add ubuntu-24.04-arm, windows-11-arm and macos-15-intel.
  linux/arm64, windows/arm64 and darwin/amd64 were only cross-compiled;
  go test ./internal/arch/arm64 did not even build on Windows ARM64 since
  v0.5.1. The comment claiming there are no Intel macOS runners was wrong
  (macos-15-intel is available to public repositories).
- test-go-stable (part of CI Success): the suite ran only on Go 1.25, the
  go.mod minimum; fakecgo depends on runtime and compiler internals that
  change between releases.
- test-go-tip (informational): the amd64 LEAVE epilogue that crashed
  fakecgo's threadentry was visible on tip before any release.
- Run the fakecgo set*id tests as root on Linux (CGO_ENABLED=0): only as
  root does TestSetuidEtc check that every thread got the new
  credentials. The test binary is built as the runner user so root never
  writes to the Go caches.
- Cross-compile: every FreeBSD test package must compile and link
  (go test -exec=/bin/true), FreeBSD binaries must have DT_NEEDED
  libthr.so.3 and not libpthread.so, and vetting internal/fakecgo for
  netbsd evaluates its compile-time ABI layout asserts.
- Weekly scheduled run and workflow_dispatch, so toolchain changes are
  noticed without a commit.
@kolkov
kolkov force-pushed the fix/fakecgo-upstream-sync branch from 9cce03a to 3eceb5a Compare October 15, 2026 22:05
@kolkov

kolkov commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

@lkmavi thanks for checking #90 on your Mac, TestSighowMatchesSystemHeaders passing there was exactly the confirmation we needed.

Could you run this PR's branch on macOS as well? It touches code that only runs for real on Apple hardware:

  • crosscall2 on arm64 no longer calls runtime·load_g (with CGO_ENABLED=0 every C-to-Go callback goes through it, e.g. Metal completion handlers on Apple's threads);
  • threadentry now calls runtime.mstart through a new helper.
git fetch origin fix/fakecgo-upstream-sync && git checkout FETCH_HEAD
CGO_ENABLED=0 go test -count=1 ./...
CGO_ENABLED=1 go test -count=1 ./...

If you have a gogpu app at hand, a quick run of one of its examples on top of this branch would also help. No rush.

@kolkov
kolkov merged commit ed41a64 into main Oct 15, 2026
45 checks passed
@kolkov
kolkov deleted the fix/fakecgo-upstream-sync branch October 15, 2026 22:13
@lkmavi

lkmavi commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@kolkov Passed
Снимок экрана — 2026-10-16 в 11 19 56

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ci CI workflows, check scripts, branch protection area: fakecgo Fake CGO runtime integration area: ffi Public API (PrepareCallInterface, CallFunction) platform: freebsd FreeBSD-specific platform: linux Linux-specific platform: netbsd NetBSD-specific priority: high Important for next release type: bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants