From a0c4e732ee575031666d4f08b5c552f0a5583fa6 Mon Sep 17 00:00:00 2001 From: unxed Date: Sat, 22 Aug 2026 13:23:01 +0000 Subject: [PATCH 01/24] feat: add goffi_static build tag for fully static binaries Binaries importing goffi are dynamically linked even with CGO_ENABLED=0 and -ldflags "-extldflags '-static'". The cause is //go:cgo_import_dynamic: the Go linker emits PT_INTERP and DT_NEEDED as soon as any package in the build carries one, under internal linking too. -extldflags is inert in such builds because no external linker ever runs, so the flag is accepted and ignored. goffi emits those directives from three packages, and all three must go before the interpreter disappears: internal/dl dlopen, dlsym, dlerror, dlclose libdl.so.2 internal/syscall __errno_location libc.so.6 internal/fakecgo malloc, pthread_*, sigaltstack libc.so.6, libpthread.so.0 Add a goffi_static build tag that compiles all of them out. The directives move into dl_{linux,darwin,freebsd}_dynamic.go so the RTLD_* constants stay available in both modes; the dlopen wrappers, errno trampolines, callback dispatcher and the fakecgo import are gated behind !goffi_static and replaced by stubs. There is no way to keep FFI in a static binary on Linux: dlopen is a service of the dynamic loader, which a static executable has no way to reach. So the tag disables FFI rather than pretending. The API keeps its shape, LoadLibrary, GetSymbol and CallFunction return an error wrapping the new ffi.ErrStaticBuild, and ffi.Available() reports the mode as a compile-time constant so callers can branch to a pure-Go fallback and have the linker drop the dead path. The callback dispatcher needs separate handling: it enters Go through crosscall2, supplied by internal/fakecgo. Without that package the symbol is an unresolved relocation, and the library currently links only because dead code elimination removes it. NewCallback panics in a static build instead. The tag is a no-op on Windows and Android, which resolve symbols through kernel32 and Bionic and are dynamically linked by construction. Available() stays true there, so a build matrix can pass the tag on every target without special-casing mobile. ffi/static_link_test.go builds a real consumer binary for linux/amd64 and linux/arm64 and inspects it with debug/elf; the test binary itself cannot be used, since the harness always links the dynamic build. scripts/check-static.sh and a CI job wrap that together with the cross-platform compile checks. Refs: unxed/f4#693 --- .github/workflows/ci.yml | 23 +++++ README.md | 36 +++++++ docs/STATIC_BUILDS.md | 143 +++++++++++++++++++++++++++ ffi/available.go | 31 ++++++ ffi/call.go | 8 ++ ffi/callback.go | 2 +- ffi/callback_amd64.s | 2 +- ffi/callback_arm64.go | 2 +- ffi/callback_arm64.s | 2 +- ffi/callback_cthread_test.go | 2 +- ffi/callback_static.go | 24 +++++ ffi/callback_struct_args_test.go | 2 +- ffi/callback_test.go | 2 +- ffi/dl_unix_stubs.s | 2 +- ffi/errors.go | 10 ++ ffi/fakecgo_unix.go | 2 +- ffi/static_link_test.go | 138 ++++++++++++++++++++++++++ ffi/struct_e2e_test.go | 10 ++ internal/dl/cgo.go | 2 +- internal/dl/dl_darwin.go | 15 --- internal/dl/dl_darwin_dynamic.go | 21 ++++ internal/dl/dl_freebsd.go | 15 --- internal/dl/dl_freebsd_dynamic.go | 21 ++++ internal/dl/dl_linux.go | 21 ---- internal/dl/dl_linux_dynamic.go | 31 ++++++ internal/dl/dl_static.go | 33 +++++++ internal/dl/dl_stubs_arm64.s | 2 +- internal/dl/dl_stubs_unix.s | 2 +- internal/dl/dl_unix.go | 2 +- internal/dl/dl_wrappers_arm64.s | 2 +- internal/dl/dl_wrappers_unix.s | 2 +- internal/static/enabled.go | 10 ++ internal/static/enabled_off.go | 11 +++ internal/static/static.go | 30 ++++++ internal/syscall/cgo.go | 2 +- internal/syscall/errno_darwin.go | 2 +- internal/syscall/errno_freebsd.go | 2 +- internal/syscall/errno_linux.go | 2 +- internal/syscall/errno_static.go | 15 +++ internal/syscall/errno_stubs_amd64.s | 2 +- internal/syscall/errno_stubs_arm64.s | 2 +- internal/syscall/errno_unix.go | 2 +- scripts/check-static.sh | 93 +++++++++++++++++ 43 files changed, 710 insertions(+), 73 deletions(-) create mode 100644 docs/STATIC_BUILDS.md create mode 100644 ffi/available.go create mode 100644 ffi/callback_static.go create mode 100644 ffi/static_link_test.go create mode 100644 internal/dl/dl_darwin_dynamic.go create mode 100644 internal/dl/dl_freebsd_dynamic.go create mode 100644 internal/dl/dl_linux_dynamic.go create mode 100644 internal/dl/dl_static.go create mode 100644 internal/static/enabled.go create mode 100644 internal/static/enabled_off.go create mode 100644 internal/static/static.go create mode 100644 internal/syscall/errno_static.go create mode 100755 scripts/check-static.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b308c0..232b451 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,29 @@ jobs: # Keep both supported Go patch lines: runtime/cgo startup and TLS details # are part of this platform contract, so a single floating toolchain is not # sufficient evidence. + # Static builds - the goffi_static tag must strip every + # //go:cgo_import_dynamic directive, otherwise the linker keeps emitting an + # ELF interpreter and the artifact will not run on Alpine or scratch. + # See docs/STATIC_BUILDS.md and unxed/f4#693. + static-build: + name: Static Build (goffi_static) + runs-on: ubuntu-latest + needs: [lint, formatting] + env: + CGO_ENABLED: 0 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Check static build mode + run: scripts/check-static.sh + android-cross: name: Android arm64 (Go ${{ matrix.go }}) runs-on: ubuntu-latest diff --git a/README.md b/README.md index dd13619..9b0e263 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,42 @@ if err != nil { --- +## Static Builds + +Binaries that import goffi are dynamically linked by default: the +`//go:cgo_import_dynamic` directives behind `dlopen`/`dlsym` make the Go linker +emit an ELF interpreter and `DT_NEEDED` entries even under `CGO_ENABLED=0`, and +`-extldflags '-static'` cannot change that because no external linker runs. + +Build with `-tags goffi_static` to drop those directives: + +```bash +CGO_ENABLED=0 go build -tags goffi_static ./... +``` + +The result is a genuinely static binary that runs on Alpine and in `scratch` +containers. The trade is unavoidable — `dlopen` is a service of the dynamic +loader, which a static executable does not have — so in that mode `LoadLibrary`, +`GetSymbol` and `CallFunction` return an error wrapping `ffi.ErrStaticBuild`. + +The API is identical in both modes, so one source tree can produce both +artifacts. Branch on `ffi.Available()`, a compile-time constant, to keep the +pure-Go path and let the linker drop the rest: + +```go +if ffi.Available() { + backend = newAcceleratedBackend() +} else { + backend = newPureGoBackend() +} +``` + +The tag is a no-op on Windows and Android, which are dynamically linked by +construction; `Available()` stays `true` there, so a cross-platform build matrix +can pass the tag everywhere. See [docs/STATIC_BUILDS.md](docs/STATIC_BUILDS.md). + +--- + ## Platform Support | Platform | Arch | ABI | Since | CI | diff --git a/docs/STATIC_BUILDS.md b/docs/STATIC_BUILDS.md new file mode 100644 index 0000000..862a23e --- /dev/null +++ b/docs/STATIC_BUILDS.md @@ -0,0 +1,143 @@ +# Static Builds (`-tags goffi_static`) + +## The problem + +A binary that imports goffi is dynamically linked, even with `CGO_ENABLED=0` +and even with `-ldflags "-extldflags '-static'"`: + +```console +$ CGO_ENABLED=0 go build -ldflags '-extldflags "-static"' -o app . +$ file app +app: ELF 64-bit LSB executable, x86-64, dynamically linked, + interpreter /lib64/ld-linux-x86-64.so.2 +$ ldd app + libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 + libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 +``` + +Two things are going on: + +1. **`//go:cgo_import_dynamic` is what makes the binary dynamic.** The Go + linker writes a `PT_INTERP` header and `DT_NEEDED` entries as soon as any + package in the build carries one of those directives. This happens under + internal linking, with cgo disabled — the directives are the whole point of + the mechanism. + +2. **`-extldflags` is silently inert here.** It is passed to the *external* + linker, and `CGO_ENABLED=0` builds never invoke one. The flag is accepted + and ignored, which is why the comment in a build script can say "static" + for years while the artifact is not. + +goffi emits those directives from three places: + +| Package | Symbols | Library | +|---------|---------|---------| +| `internal/dl` | `dlopen`, `dlsym`, `dlerror`, `dlclose` | `libdl.so.2` | +| `internal/syscall` | `__errno_location` | `libc.so.6` | +| `internal/fakecgo` | `malloc`, `free`, `pthread_*`, `sigaltstack`, … | `libc.so.6`, `libpthread.so.0` | + +Removing any one of them is not enough; all three have to go. + +## The trade + +There is no way to keep FFI in a static binary on Linux. `dlopen` is a service +of the dynamic loader, and a static executable has no loader mapped into it — +glibc's static `dlopen` was always partial and was removed in 2.34, and musl +rejects it outright. Anything that resolves a symbol from a `.so` at runtime +needs `ld.so` in the process, and `ld.so` arrives only via `PT_INTERP`. + +So the choice is per-build, not per-call: + +| | Dynamic (default) | `-tags goffi_static` | +|--|-------------------|----------------------| +| ELF interpreter | required | none | +| Runs on Alpine / `scratch` | no | yes | +| `LoadLibrary`, `GetSymbol` | work | return `ErrStaticBuild` | +| `CallFunction` | works | returns `ErrStaticBuild` | +| `NewCallback` | works | panics | +| `Available()` | `true` | `false` | + +## Usage + +```bash +CGO_ENABLED=0 go build -tags goffi_static ./... +``` + +The tag changes no API. Code compiles both ways, so a project can ship a static +artifact and a dynamic one from one source tree, and pick at build time which +backends it wants: + +```go +if ffi.Available() { + backend = newAcceleratedBackend() // GPU, Wayland, anything behind dlopen +} else { + backend = newPureGoBackend() +} +``` + +`Available()` returns a compile-time constant, so the dead branch — and every +library binding reachable only from it — is eliminated by the linker rather +than shipped as unreachable code. + +At the call site the error is ordinary and wrapped as usual: + +```go +h, err := ffi.LoadLibrary("libvulkan.so.1") +if errors.Is(err, ffi.ErrStaticBuild) { + // built without dynamic loading; fall back +} +``` + +## Where the tag does nothing + +**Windows** resolves symbols through `LoadLibraryW`/`GetProcAddress`, not +through `cgo_import_dynamic`, and every Windows binary links `kernel32` +regardless. **Android** is dynamically linked by construction: Bionic loads the +app's `.so` files, and a static Android executable could not call into the +platform at all. + +On both, the tag is accepted and ignored: `Available()` stays `true` and FFI +keeps working. A cross-platform build matrix can therefore pass +`-tags goffi_static` everywhere without special-casing mobile and Windows +targets, and only the platforms that can be static become static. +`scripts/check-static.sh` asserts this. + +## Verifying + +`scripts/check-static.sh` compiles the tagged build for every supported +platform and checks the linker output. The check that matters lives in +`ffi/static_link_test.go`: it builds a real consumer binary for linux/amd64 and +linux/arm64, opens it with `debug/elf`, and fails if a `PT_INTERP` header or +any `DT_NEEDED` entry survived. Inspecting the test binary itself would prove +nothing, since the test harness always links the dynamic build. + +By hand: + +```console +$ CGO_ENABLED=0 go build -tags goffi_static -o app . +$ file app +app: ELF 64-bit LSB executable, x86-64, statically linked +$ readelf -d app | head -1 # no .dynamic section at all +``` + +## Notes for future work + +Two directions would narrow the trade-off above. Neither is implemented. + +**Loader-agnostic symbol resolution.** The SONAMEs are currently hardcoded +(`libdl.so.2`, `libc.so.6`, `libpthread.so.0`), which is a glibc assumption: a +*dynamically* linked goffi binary still fails to start on Alpine, because musl +ships none of those names. Walking the process's own link map instead — +`PT_DYNAMIC` → `DT_DEBUG` → `r_debug.r_map` → each object's `.dynsym` — finds +`dlopen` in whatever libc is actually loaded, in pure Go, with no directives at +all. That would drop `internal/dl`'s directives, make dynamic builds work +unmodified on glibc, musl and Bionic, and leave the static/dynamic decision to +the linker rather than a build tag. `internal/fakecgo` would need the same +treatment (its assembly trampolines would jump through resolved pointers rather +than linker-supplied symbols) before the last `DT_NEEDED` entry disappears. + +**A Go-native ELF loader.** Mapping a shared object with `mmap`, applying its +relocations and resolving its symbols — a minimal `ld.so` in Go — is the only +route to `dlopen` in a genuinely static binary. It is a large piece of work and +degrades quickly for libraries with deep dependency chains, but for a +self-contained `.so` it is tractable. diff --git a/ffi/available.go b/ffi/available.go new file mode 100644 index 0000000..4e92a6c --- /dev/null +++ b/ffi/available.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import "github.com/go-webgpu/goffi/internal/static" + +// Available reports whether this build of goffi can load shared libraries and +// call foreign functions. +// +// It returns false only when the binary was built with -tags goffi_static, a +// mode that strips every //go:cgo_import_dynamic directive so the Go linker +// produces a fully static executable (no PT_INTERP, no DT_NEEDED). In that mode +// LoadLibrary, GetSymbol and CallFunction return an error wrapping +// ErrStaticBuild instead of calling into libc. +// +// Callers that have a pure-Go fallback should branch on this at startup rather +// than treating the first LoadLibrary failure as fatal: +// +// if ffi.Available() { +// backend = newAcceleratedBackend() +// } else { +// backend = newPureGoBackend() +// } +// +// The goffi_static tag is ignored on Windows and Android, where library loading +// never went through cgo_import_dynamic, so Available reports true there even +// when the tag is set. +func Available() bool { + return !static.Enabled +} diff --git a/ffi/call.go b/ffi/call.go index b6736fe..7dd524b 100644 --- a/ffi/call.go +++ b/ffi/call.go @@ -4,6 +4,7 @@ import ( "unsafe" "github.com/go-webgpu/goffi/internal/arch" + "github.com/go-webgpu/goffi/internal/static" gosyscall "github.com/go-webgpu/goffi/internal/syscall" "github.com/go-webgpu/goffi/types" ) @@ -16,6 +17,13 @@ func executeFunction( rvalue unsafe.Pointer, avalue []unsafe.Pointer, ) (syscallErrno uintptr, err error) { + if static.Enabled { + // A static build has no libc and no cgo runtime: runtime.cgocall would + // abort the process with "cgocall unavailable". Fail with an error + // instead. There is no legitimate way to obtain fn here anyway, since + // LoadLibrary and GetSymbol also fail in this mode. + return 0, ErrStaticBuild + } if arch.Registry.Caller == nil { return 0, types.ErrUnsupportedArchitecture } diff --git a/ffi/callback.go b/ffi/callback.go index ecaf632..762cf87 100644 --- a/ffi/callback.go +++ b/ffi/callback.go @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && amd64 +//go:build (linux || darwin || freebsd) && amd64 && !goffi_static // Package ffi provides callback support for Foreign Function Interface (Unix version). // This file implements Go function registration as C callbacks using diff --git a/ffi/callback_amd64.s b/ffi/callback_amd64.s index 0c381d7..f3388ad 100644 --- a/ffi/callback_amd64.s +++ b/ffi/callback_amd64.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin) && amd64 +//go:build (linux || darwin) && amd64 && !goffi_static #include "textflag.h" #include "go_asm.h" diff --git a/ffi/callback_arm64.go b/ffi/callback_arm64.go index d3edee8..602435b 100644 --- a/ffi/callback_arm64.go +++ b/ffi/callback_arm64.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 && !goffi_static // Package ffi provides callback support for Foreign Function Interface (ARM64 Unix version). // This file implements Go function registration as C callbacks using diff --git a/ffi/callback_arm64.s b/ffi/callback_arm64.s index 81821a7..e755254 100644 --- a/ffi/callback_arm64.s +++ b/ffi/callback_arm64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 && !goffi_static #include "textflag.h" #include "go_asm.h" diff --git a/ffi/callback_cthread_test.go b/ffi/callback_cthread_test.go index 4bd79c2..5b0619f 100644 --- a/ffi/callback_cthread_test.go +++ b/ffi/callback_cthread_test.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) +//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) && !goffi_static package ffi diff --git a/ffi/callback_static.go b/ffi/callback_static.go new file mode 100644 index 0000000..dc73267 --- /dev/null +++ b/ffi/callback_static.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) && goffi_static + +// Static build: no callbacks. +// +// The real dispatcher (callback_amd64.s / callback_arm64.s) enters Go through +// crosscall2, which is supplied by internal/fakecgo. That package is not linked +// in a static build, so keeping the dispatcher would fail at link time with +// "relocation target crosscall2 not defined" as soon as anything referenced it. + +package ffi + +// NewCallback panics in a static build. +// +// It cannot return an error because its signature is fixed by the dynamic +// implementation, and it cannot return 0 either: a null function pointer handed +// to C would crash later, far from the cause. Check Available before creating +// callbacks in code that may be compiled with -tags goffi_static. +func NewCallback(fn any) uintptr { + _ = fn + panic(ErrStaticBuild) +} diff --git a/ffi/callback_struct_args_test.go b/ffi/callback_struct_args_test.go index 82c0201..e05a6dd 100644 --- a/ffi/callback_struct_args_test.go +++ b/ffi/callback_struct_args_test.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && amd64 +//go:build ((linux && !android) || darwin || freebsd) && amd64 && !goffi_static package ffi diff --git a/ffi/callback_test.go b/ffi/callback_test.go index 9b89b08..75d9e28 100644 --- a/ffi/callback_test.go +++ b/ffi/callback_test.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) +//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) && !goffi_static package ffi diff --git a/ffi/dl_unix_stubs.s b/ffi/dl_unix_stubs.s index d0c15fd..b1dcca9 100644 --- a/ffi/dl_unix_stubs.s +++ b/ffi/dl_unix_stubs.s @@ -1,4 +1,4 @@ -//go:build linux && amd64 +//go:build linux && amd64 && !goffi_static #include "textflag.h" diff --git a/ffi/errors.go b/ffi/errors.go index 3c6c216..9bdce61 100644 --- a/ffi/errors.go +++ b/ffi/errors.go @@ -2,6 +2,8 @@ package ffi import ( "fmt" + + "github.com/go-webgpu/goffi/internal/static" ) // InvalidCallInterfaceError indicates CallInterface preparation failed due to @@ -152,6 +154,14 @@ func (e *TypeValidationError) Is(target error) bool { return ok } +// ErrStaticBuild is returned, usually wrapped in a *LibraryError, by every +// operation that needs the dynamic loader when goffi was built with +// -tags goffi_static: LoadLibrary, GetSymbol and CallFunction. +// +// Detect the mode up front with Available, or at the call site with +// errors.Is(err, ffi.ErrStaticBuild). +var ErrStaticBuild = static.ErrDisabled + // Deprecated: Legacy sentinel errors kept for backwards compatibility. // Use typed errors above with errors.As() for better error handling. var ( diff --git a/ffi/fakecgo_unix.go b/ffi/fakecgo_unix.go index 34c7e51..1f0387a 100644 --- a/ffi/fakecgo_unix.go +++ b/ffi/fakecgo_unix.go @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && !cgo && !nofakecgo +//go:build (linux || darwin || freebsd) && !cgo && !nofakecgo && !goffi_static package ffi diff --git a/ffi/static_link_test.go b/ffi/static_link_test.go new file mode 100644 index 0000000..386342f --- /dev/null +++ b/ffi/static_link_test.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && (amd64 || arm64) + +package ffi_test + +import ( + "debug/elf" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// probeSource is a throwaway program that links the ffi package the way a real +// consumer does. Building it from a test is the only way to observe what the +// linker emits: the test binary itself is always dynamic, because the test +// harness links the dynamic build of this package. +const probeSource = `package main + +import ( + "errors" + "fmt" + + "github.com/go-webgpu/goffi/ffi" +) + +func main() { + fmt.Printf("available=%v\n", ffi.Available()) + _, err := ffi.LoadLibrary("libm.so.6") + fmt.Printf("static_err=%v\n", errors.Is(err, ffi.ErrStaticBuild)) +} +` + +// TestStaticBuildProducesStaticBinary is the regression test for unxed/f4#693. +// +// Any //go:cgo_import_dynamic directive reachable from the binary makes the Go +// linker emit a PT_INTERP header and DT_NEEDED entries, even under +// CGO_ENABLED=0, and -extldflags '-static' cannot undo it because no external +// linker runs. The goffi_static tag must remove every one of those directives +// for both linux/amd64 and linux/arm64, otherwise the binary will not start on +// Alpine or in a scratch container. +func TestStaticBuildProducesStaticBinary(t *testing.T) { + if testing.Short() { + t.Skip("skipping: builds two binaries") + } + + dir := writeProbeModule(t) + + for _, goarch := range []string{"amd64", "arm64"} { + t.Run(goarch, func(t *testing.T) { + bin := filepath.Join(dir, "probe-"+goarch) + build(t, dir, goarch, bin) + + f, err := elf.Open(bin) + if err != nil { + t.Fatalf("open ELF: %v", err) + } + defer f.Close() + + for _, p := range f.Progs { + if p.Type == elf.PT_INTERP { + t.Error("binary has a PT_INTERP header: it still requires an ELF interpreter") + } + } + + // DynamicSection returns an error when there is no .dynamic + // section at all, which is exactly the outcome we want. + if needed, err := f.DynString(elf.DT_NEEDED); err == nil && len(needed) > 0 { + t.Errorf("binary still depends on shared libraries: %v", needed) + } + + if goarch != runtime.GOARCH { + return // cross-compiled, cannot execute it here + } + out, err := exec.Command(bin).CombinedOutput() + if err != nil { + t.Fatalf("run probe: %v\n%s", err, out) + } + got := string(out) + if !strings.Contains(got, "available=false") { + t.Errorf("ffi.Available() should report false in a static build, got:\n%s", got) + } + if !strings.Contains(got, "static_err=true") { + t.Errorf("LoadLibrary should fail with ErrStaticBuild, got:\n%s", got) + } + }) + } +} + +// writeProbeModule creates a throwaway module wired to this checkout, so the +// test needs no network access. +func writeProbeModule(t *testing.T) string { + t.Helper() + + root, err := filepath.Abs("..") + if err != nil { + t.Fatalf("locate module root: %v", err) + } + + dir := t.TempDir() + files := map[string]string{ + "main.go": probeSource, + "go.mod": "module goffiprobe\n\ngo 1.25\n\n" + + "require github.com/go-webgpu/goffi v0.0.0\n\n" + + "replace github.com/go-webgpu/goffi => " + root + "\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + return dir +} + +func build(t *testing.T, dir, goarch, out string) { + t.Helper() + + goTool := filepath.Join(runtime.GOROOT(), "bin", "go") + if _, err := os.Stat(goTool); err != nil { + goTool = "go" + } + + cmd := exec.Command(goTool, "build", "-tags", "goffi_static", "-o", out, ".") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "CGO_ENABLED=0", + "GOOS=linux", + "GOARCH="+goarch, + "GOFLAGS=-mod=mod", + ) + if outBytes, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build linux/%s with -tags goffi_static: %v\n%s", goarch, err, outBytes) + } +} diff --git a/ffi/struct_e2e_test.go b/ffi/struct_e2e_test.go index 3e98234..d585a3b 100644 --- a/ffi/struct_e2e_test.go +++ b/ffi/struct_e2e_test.go @@ -6,6 +6,7 @@ package ffi import ( + "fmt" "os" "os/exec" "path/filepath" @@ -19,6 +20,15 @@ import ( var structTestLib unsafe.Pointer func TestMain(m *testing.M) { + if !Available() { + // Built with -tags goffi_static: LoadLibrary, GetSymbol and + // CallFunction are stubs, so every behavioural test in this package + // would fail for the same uninteresting reason. That mode is covered + // from the outside by TestStaticBuildProducesStaticBinary. + fmt.Println("ffi: skipping tests, binary was built with -tags goffi_static") + os.Exit(0) + } + // Android test binaries run on-device, where invoking a host compiler is // neither meaningful nor available. Keep the pure validation tests active // and let only the host-built shared-library cases skip via requireStructLib. diff --git a/internal/dl/cgo.go b/internal/dl/cgo.go index 5245f96..3c5a717 100644 --- a/internal/dl/cgo.go +++ b/internal/dl/cgo.go @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors -//go:build cgo && (darwin || freebsd || linux || netbsd) +//go:build cgo && (darwin || freebsd || linux || netbsd) && !goffi_static package dl diff --git a/internal/dl/dl_darwin.go b/internal/dl/dl_darwin.go index 1095464..7da2a21 100644 --- a/internal/dl/dl_darwin.go +++ b/internal/dl/dl_darwin.go @@ -9,21 +9,6 @@ package dl -// Link to libSystem.B.dylib functions using cgo_import_dynamic. -// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) -// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). -// -// On macOS, dlopen/dlsym/dlerror are part of libSystem.B.dylib -// (unlike Linux where they're in libdl.so.2). - -//go:cgo_import_dynamic goffi_dlopen dlopen "/usr/lib/libSystem.B.dylib" -//go:cgo_import_dynamic goffi_dlsym dlsym "/usr/lib/libSystem.B.dylib" -//go:cgo_import_dynamic goffi_dlerror dlerror "/usr/lib/libSystem.B.dylib" -//go:cgo_import_dynamic goffi_dlclose dlclose "/usr/lib/libSystem.B.dylib" - -// Force dependency on libSystem.B.dylib -//go:cgo_import_dynamic _ _ "/usr/lib/libSystem.B.dylib" - // RTLD constants from for dynamic library loading on macOS. const ( // RTLD_LAZY performs relocations at an implementation-dependent time. diff --git a/internal/dl/dl_darwin_dynamic.go b/internal/dl/dl_darwin_dynamic.go new file mode 100644 index 0000000..553a13a --- /dev/null +++ b/internal/dl/dl_darwin_dynamic.go @@ -0,0 +1,21 @@ +//go:build darwin && !goffi_static + +// Dynamic symbol imports for macOS. Kept separate from the RTLD_* constants so +// that the goffi_static build tag can drop them; see dl_linux_dynamic.go. + +package dl + +// Link to libSystem.B.dylib functions using cgo_import_dynamic. +// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) +// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). +// +// On macOS, dlopen/dlsym/dlerror are part of libSystem.B.dylib +// (unlike Linux where they're in libdl.so.2). + +//go:cgo_import_dynamic goffi_dlopen dlopen "/usr/lib/libSystem.B.dylib" +//go:cgo_import_dynamic goffi_dlsym dlsym "/usr/lib/libSystem.B.dylib" +//go:cgo_import_dynamic goffi_dlerror dlerror "/usr/lib/libSystem.B.dylib" +//go:cgo_import_dynamic goffi_dlclose dlclose "/usr/lib/libSystem.B.dylib" + +// Force dependency on libSystem.B.dylib +//go:cgo_import_dynamic _ _ "/usr/lib/libSystem.B.dylib" diff --git a/internal/dl/dl_freebsd.go b/internal/dl/dl_freebsd.go index 8fd0fdc..adbd763 100644 --- a/internal/dl/dl_freebsd.go +++ b/internal/dl/dl_freebsd.go @@ -10,21 +10,6 @@ package dl -// Link to libc.so.7 functions using cgo_import_dynamic. -// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) -// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). -// -// On FreeBSD, dlopen/dlsym/dlclose are part of libc directly -// (unlike Linux where they're in a separate libdl.so.2). - -//go:cgo_import_dynamic goffi_dlopen dlopen "libc.so.7" -//go:cgo_import_dynamic goffi_dlsym dlsym "libc.so.7" -//go:cgo_import_dynamic goffi_dlerror dlerror "libc.so.7" -//go:cgo_import_dynamic goffi_dlclose dlclose "libc.so.7" - -// Force dependency on libc.so.7 -//go:cgo_import_dynamic _ _ "libc.so.7" - // RTLD constants from for dynamic library loading on FreeBSD. const ( // RTLD_LAZY performs relocations at an implementation-dependent time. diff --git a/internal/dl/dl_freebsd_dynamic.go b/internal/dl/dl_freebsd_dynamic.go new file mode 100644 index 0000000..5d38371 --- /dev/null +++ b/internal/dl/dl_freebsd_dynamic.go @@ -0,0 +1,21 @@ +//go:build freebsd && !goffi_static + +// Dynamic symbol imports for FreeBSD. Kept separate from the RTLD_* constants +// so that the goffi_static build tag can drop them; see dl_linux_dynamic.go. + +package dl + +// Link to libc.so.7 functions using cgo_import_dynamic. +// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) +// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). +// +// On FreeBSD, dlopen/dlsym/dlclose are part of libc directly +// (unlike Linux where they're in a separate libdl.so.2). + +//go:cgo_import_dynamic goffi_dlopen dlopen "libc.so.7" +//go:cgo_import_dynamic goffi_dlsym dlsym "libc.so.7" +//go:cgo_import_dynamic goffi_dlerror dlerror "libc.so.7" +//go:cgo_import_dynamic goffi_dlclose dlclose "libc.so.7" + +// Force dependency on libc.so.7 +//go:cgo_import_dynamic _ _ "libc.so.7" diff --git a/internal/dl/dl_linux.go b/internal/dl/dl_linux.go index bec6a9b..c085cb1 100644 --- a/internal/dl/dl_linux.go +++ b/internal/dl/dl_linux.go @@ -9,27 +9,6 @@ package dl -// Link to libdl.so.2 functions using cgo_import_dynamic. -// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) -// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). -// -// Note on glibc >= 2.34: libdl.so.2 is a stub (an empty .so with a versioned -// symlink to libc.so.6). dlopen/dlsym/dlerror/dlclose all live in libc.so.6 -// itself. We still ask the dynamic linker for "libdl.so.2" because -// (a) the stub exists on every glibc release shipped with that version, so -// SONAME-based lookups keep working, and -// (b) older glibc (< 2.34) and musl still ship the real libdl.so.2. -// Either way, ld.so resolves the symbols via the normal scope rules and the -// caller never has to care which .so they ended up in. - -//go:cgo_import_dynamic goffi_dlopen dlopen "libdl.so.2" -//go:cgo_import_dynamic goffi_dlsym dlsym "libdl.so.2" -//go:cgo_import_dynamic goffi_dlerror dlerror "libdl.so.2" -//go:cgo_import_dynamic goffi_dlclose dlclose "libdl.so.2" - -// Force dependency on libdl.so.2 -//go:cgo_import_dynamic _ _ "libdl.so.2" - // RTLD constants from for dynamic library loading on Linux. const ( // RTLD_LAZY performs relocations at an implementation-dependent time. diff --git a/internal/dl/dl_linux_dynamic.go b/internal/dl/dl_linux_dynamic.go new file mode 100644 index 0000000..9370f92 --- /dev/null +++ b/internal/dl/dl_linux_dynamic.go @@ -0,0 +1,31 @@ +//go:build linux && !android && !goffi_static + +// Dynamic symbol imports for Linux. +// +// These directives live in their own file so that the goffi_static build tag +// can drop them (and with them the ELF interpreter and DT_NEEDED entries the +// Go linker would otherwise emit) while dl_linux.go keeps exporting the RTLD_* +// constants for both build modes. + +package dl + +// Link to libdl.so.2 functions using cgo_import_dynamic. +// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) +// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). +// +// Note on glibc >= 2.34: libdl.so.2 is a stub (an empty .so with a versioned +// symlink to libc.so.6). dlopen/dlsym/dlerror/dlclose all live in libc.so.6 +// itself. We still ask the dynamic linker for "libdl.so.2" because +// (a) the stub exists on every glibc release shipped with that version, so +// SONAME-based lookups keep working, and +// (b) older glibc (< 2.34) and musl still ship the real libdl.so.2. +// Either way, ld.so resolves the symbols via the normal scope rules and the +// caller never has to care which .so they ended up in. + +//go:cgo_import_dynamic goffi_dlopen dlopen "libdl.so.2" +//go:cgo_import_dynamic goffi_dlsym dlsym "libdl.so.2" +//go:cgo_import_dynamic goffi_dlerror dlerror "libdl.so.2" +//go:cgo_import_dynamic goffi_dlclose dlclose "libdl.so.2" + +// Force dependency on libdl.so.2 +//go:cgo_import_dynamic _ _ "libdl.so.2" diff --git a/internal/dl/dl_static.go b/internal/dl/dl_static.go new file mode 100644 index 0000000..5235619 --- /dev/null +++ b/internal/dl/dl_static.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build ((linux && !android) || darwin || freebsd) && goffi_static + +// Static build: no dynamic loader. +// +// This file replaces dl_unix.go when the goffi_static build tag is set. Without +// the //go:cgo_import_dynamic directives there is no dlopen to jump to, so the +// three entry points fail cleanly instead of trapping in assembly. The +// signatures match dl_unix.go exactly, so every caller still compiles. + +package dl + +import "github.com/go-webgpu/goffi/internal/static" + +// Dlopen always fails in a static build. +func Dlopen(path string, mode int) (uintptr, error) { + _, _ = path, mode + return 0, static.ErrDisabled +} + +// Dlsym always fails in a static build. +func Dlsym(handle uintptr, name string) (uintptr, error) { + _, _ = handle, name + return 0, static.ErrDisabled +} + +// Dlclose is a no-op in a static build: no handle can have been opened. +func Dlclose(handle uintptr) error { + _ = handle + return nil +} diff --git a/internal/dl/dl_stubs_arm64.s b/internal/dl/dl_stubs_arm64.s index cec205d..29c1924 100644 --- a/internal/dl/dl_stubs_arm64.s +++ b/internal/dl/dl_stubs_arm64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 && !goffi_static #include "textflag.h" diff --git a/internal/dl/dl_stubs_unix.s b/internal/dl/dl_stubs_unix.s index 03cbf65..71c9cbf 100644 --- a/internal/dl/dl_stubs_unix.s +++ b/internal/dl/dl_stubs_unix.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && amd64 +//go:build (linux || darwin || freebsd) && amd64 && !goffi_static #include "textflag.h" diff --git a/internal/dl/dl_unix.go b/internal/dl/dl_unix.go index 7299aed..eae0df0 100644 --- a/internal/dl/dl_unix.go +++ b/internal/dl/dl_unix.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || darwin || freebsd +//go:build ((linux && !android) || darwin || freebsd) && !goffi_static // OUR OWN Dlopen/Dlsym implementation - NO dependencies! // Uses runtime.cgocall approach similar to syscall6. diff --git a/internal/dl/dl_wrappers_arm64.s b/internal/dl/dl_wrappers_arm64.s index d90a72f..8acbd70 100644 --- a/internal/dl/dl_wrappers_arm64.s +++ b/internal/dl/dl_wrappers_arm64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 && !goffi_static #include "textflag.h" diff --git a/internal/dl/dl_wrappers_unix.s b/internal/dl/dl_wrappers_unix.s index fe06718..b51c6ca 100644 --- a/internal/dl/dl_wrappers_unix.s +++ b/internal/dl/dl_wrappers_unix.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && amd64 +//go:build (linux || darwin || freebsd) && amd64 && !goffi_static #include "textflag.h" diff --git a/internal/static/enabled.go b/internal/static/enabled.go new file mode 100644 index 0000000..0bfc6d9 --- /dev/null +++ b/internal/static/enabled.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build goffi_static && !windows && !android + +package static + +// Enabled reports that this build has no dynamic symbol imports and therefore +// cannot load shared libraries or call foreign functions. +const Enabled = true diff --git a/internal/static/enabled_off.go b/internal/static/enabled_off.go new file mode 100644 index 0000000..b07df27 --- /dev/null +++ b/internal/static/enabled_off.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !goffi_static || windows || android + +package static + +// Enabled reports that this build resolves symbols through the dynamic loader, +// which is the default. On Windows and Android the goffi_static tag is ignored: +// neither platform uses //go:cgo_import_dynamic for library loading. +const Enabled = false diff --git a/internal/static/static.go b/internal/static/static.go new file mode 100644 index 0000000..978431f --- /dev/null +++ b/internal/static/static.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Package static reports whether goffi was built in "static" mode, i.e. with +// every //go:cgo_import_dynamic directive compiled out. +// +// Background: the Go linker marks a binary as dynamically linked (it writes a +// PT_INTERP program header and DT_NEEDED entries) as soon as any package in the +// build carries a //go:cgo_import_dynamic directive. That happens even with +// CGO_ENABLED=0 and internal linking, and -extldflags '-static' cannot undo it +// because no external linker is involved. goffi needs those directives for +// dlopen/dlsym/__errno_location, so any binary that imports goffi is dynamic. +// +// The goffi_static build tag removes those directives. The FFI API keeps its +// shape, but LoadLibrary/GetSymbol/CallFunction return ErrDisabled instead of +// calling into libc, and the resulting binary runs on Alpine, in a scratch +// container, or anywhere else without an ELF interpreter. +// +// The tag is a no-op on Windows (which resolves symbols through kernel32 rather +// than cgo_import_dynamic) and on Android (whose Bionic runtime is always +// dynamically linked). +package static + +import "errors" + +// ErrDisabled is returned by every entry point that would otherwise need the +// dynamic loader when the goffi_static build tag is set. +var ErrDisabled = errors.New( + "goffi: FFI is disabled in this build (built with -tags goffi_static); " + + "rebuild without the tag to load shared libraries") diff --git a/internal/syscall/cgo.go b/internal/syscall/cgo.go index 4d0b95f..9b31903 100644 --- a/internal/syscall/cgo.go +++ b/internal/syscall/cgo.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors -//go:build cgo && (linux || darwin || freebsd) +//go:build cgo && (linux || darwin || freebsd) && !goffi_static package syscall diff --git a/internal/syscall/errno_darwin.go b/internal/syscall/errno_darwin.go index a4e1cf9..41d31a8 100644 --- a/internal/syscall/errno_darwin.go +++ b/internal/syscall/errno_darwin.go @@ -1,4 +1,4 @@ -//go:build darwin && (amd64 || arm64) +//go:build darwin && (amd64 || arm64) && !goffi_static package syscall diff --git a/internal/syscall/errno_freebsd.go b/internal/syscall/errno_freebsd.go index 194b210..f73db68 100644 --- a/internal/syscall/errno_freebsd.go +++ b/internal/syscall/errno_freebsd.go @@ -1,4 +1,4 @@ -//go:build freebsd && (amd64 || arm64) +//go:build freebsd && (amd64 || arm64) && !goffi_static package syscall diff --git a/internal/syscall/errno_linux.go b/internal/syscall/errno_linux.go index 5836294..e2e38e9 100644 --- a/internal/syscall/errno_linux.go +++ b/internal/syscall/errno_linux.go @@ -1,4 +1,4 @@ -//go:build linux && !android && (amd64 || arm64) +//go:build linux && !android && (amd64 || arm64) && !goffi_static package syscall diff --git a/internal/syscall/errno_static.go b/internal/syscall/errno_static.go new file mode 100644 index 0000000..c8c7438 --- /dev/null +++ b/internal/syscall/errno_static.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build ((linux && !android) || darwin || freebsd) && goffi_static + +// Static build: no libc, hence no errno location to import. + +package syscall + +// ErrnoFnAddr returns 0 in a static build, which the assembly trampolines +// already treat as "skip errno capture" (the same path Windows takes). No C +// function can be called in this mode anyway, so there is no errno to read. +func ErrnoFnAddr() uintptr { + return 0 +} diff --git a/internal/syscall/errno_stubs_amd64.s b/internal/syscall/errno_stubs_amd64.s index bf45d73..f46bd45 100644 --- a/internal/syscall/errno_stubs_amd64.s +++ b/internal/syscall/errno_stubs_amd64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && amd64 +//go:build ((linux && !android) || darwin || freebsd) && amd64 && !goffi_static #include "textflag.h" diff --git a/internal/syscall/errno_stubs_arm64.s b/internal/syscall/errno_stubs_arm64.s index 24f9ba8..e6f0b66 100644 --- a/internal/syscall/errno_stubs_arm64.s +++ b/internal/syscall/errno_stubs_arm64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || (android && !cgo) || darwin || freebsd) && arm64 +//go:build ((((linux && !android) || darwin || freebsd) && !goffi_static) || (android && !cgo)) && arm64 #include "textflag.h" diff --git a/internal/syscall/errno_unix.go b/internal/syscall/errno_unix.go index f815301..9eed7c4 100644 --- a/internal/syscall/errno_unix.go +++ b/internal/syscall/errno_unix.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || (android && !cgo) || darwin || freebsd) && (amd64 || arm64) +//go:build ((((linux && !android) || darwin || freebsd) && !goffi_static) || (android && !cgo)) && (amd64 || arm64) package syscall diff --git a/scripts/check-static.sh b/scripts/check-static.sh new file mode 100755 index 0000000..4781363 --- /dev/null +++ b/scripts/check-static.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verify the goffi_static build mode. +# +# Without the tag, goffi's //go:cgo_import_dynamic directives force the Go +# linker to emit a PT_INTERP header and DT_NEEDED entries even under +# CGO_ENABLED=0, so every binary importing goffi is dynamically linked and will +# not start on Alpine or in a scratch container. -extldflags '-static' cannot +# undo that: no external linker is involved. With -tags goffi_static those +# directives are gone and the linker produces a static executable. +# +# This script checks that (a) the tagged build still compiles on every +# platform, (b) the linker output really has no interpreter and no shared +# library dependencies, and (c) the tag stays a no-op on the platforms that are +# always dynamic (Windows, Android). See unxed/f4#693. + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" + +export CGO_ENABLED=0 + +echo "==> Compiling with -tags goffi_static" +for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 \ + windows/amd64 windows/arm64 android/arm64; do + os="${target%%/*}" + arch="${target##*/}" + if GOOS="$os" GOARCH="$arch" go build -tags goffi_static ./...; then + echo " ok ${target}" + else + echo " FAIL ${target}" >&2 + exit 1 + fi +done + +# FreeBSD needs the same -gcflags="-std" workaround as the cross-compile job: +# internal/fakecgo is not imported in a static build, but `go build ./...` +# still compiles the package, and its //go:cgo_export_dynamic directives are +# rejected outside cgo-generated code. +for arch in amd64 arm64; do + if GOOS=freebsd GOARCH="$arch" go build -tags goffi_static \ + -gcflags="github.com/go-webgpu/goffi/internal/fakecgo=-std" ./...; then + echo " ok freebsd/${arch}" + else + echo " FAIL freebsd/${arch}" >&2 + exit 1 + fi +done + +echo "==> Verifying linker output for linux/amd64 and linux/arm64" +# The test builds a real consumer binary and inspects it with debug/elf, so no +# binutils are needed on the runner. +go test -run TestStaticBuildProducesStaticBinary -v ./ffi + +echo "==> Checking that the tag is a no-op where it must be" +# Windows and Android are always dynamically linked (kernel32 / Bionic), so a +# binary built with the tag must keep full FFI there. Available() is a +# compile-time constant, so a failed assertion is a build failure. +probe=$(mktemp -d) +trap 'rm -rf "$probe"' EXIT +cat >"$probe/main.go" <<'PROBE' +package main + +import "github.com/go-webgpu/goffi/ffi" + +func main() { + if !ffi.Available() { + panic("goffi_static must not disable FFI on this platform") + } +} +PROBE +cat >"$probe/go.mod" < $ROOT +PROBE +for target in windows/amd64 android/arm64; do + os="${target%%/*}" + arch="${target##*/}" + if (cd "$probe" && GOOS="$os" GOARCH="$arch" GOFLAGS=-mod=mod \ + go build -tags goffi_static -o /dev/null .); then + echo " ok ${target} keeps FFI" + else + echo " FAIL ${target} lost FFI" >&2 + exit 1 + fi +done + +echo "==> Static build mode OK" From 561ebf7d57738cca70f50125c1da7ae5baf99fd1 Mon Sep 17 00:00:00 2001 From: Ivan Sorokin Date: Sat, 22 Aug 2026 15:30:02 +0200 Subject: [PATCH 02/24] Minor --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 3d6633a..f84990c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/go-webgpu/goffi -go 1.25 +go 1.26.0 From 8db5daf7d9669f74fcb916eb5ce887fa5a9d2cd1 Mon Sep 17 00:00:00 2001 From: Ivan Sorokin Date: Sat, 22 Aug 2026 16:14:31 +0200 Subject: [PATCH 03/24] gofmt go 1.26 --- CONTRIBUTING.md | 4 ++-- docs/ANDROID.md | 7 ++++--- docs/PERFORMANCE.md | 2 +- ffi/static_link_test.go | 2 +- go.mod | 2 +- scripts/check-android-arm64.sh | 2 +- scripts/check-static.sh | 2 +- scripts/pre-release-check.sh | 2 +- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b167fbf..5134e50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -225,7 +225,7 @@ experiment: prototype macOS Apple Silicon support ### Prerequisites -- **Go 1.25 or later** (required for latest runtime.cgocall features) +- **Go 1.26 or later** (required for latest runtime.cgocall features) - **golangci-lint** (code quality) - **GCC or Clang** (for race detector, optional but recommended) - **Platform access**: @@ -241,7 +241,7 @@ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest # Verify installation golangci-lint --version -go version # Should be 1.25+ +go version # Should be 1.26+ ``` ### Running Tests diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 530cc08..737efc3 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -56,7 +56,8 @@ are reproducible without a device: ANDROID_NDK_HOME=/path/to/android-ndk-r29 scripts/check-android-arm64.sh ``` -The audited source/ABI matrix is Go 1.25.12 and Go 1.26.5 with Android NDK -r29 (`29.0.14206865`). Keep both Go lines in CI when runtime startup files or -TLS offsets change upstream. Passing this probe is not physical-device +The audited source/ABI matrix is Go 1.26.5 and the current 1.26 patch release +with Android NDK r29 (`29.0.14206865`). go.mod requires 1.26, so the older 1.25 +line is gone. Keep both Go lines in CI when runtime startup files or TLS +offsets change upstream. Passing this probe is not physical-device startup evidence. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 376e133..41e3fec 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -2,7 +2,7 @@ > **Comprehensive performance analysis, benchmarks, and usage guidelines** > **Platform**: Windows AMD64, 12th Gen Intel Core i7-1255U -> **Go Version**: 1.25+ +> **Go Version**: 1.26+ --- diff --git a/ffi/static_link_test.go b/ffi/static_link_test.go index 386342f..aab67b8 100644 --- a/ffi/static_link_test.go +++ b/ffi/static_link_test.go @@ -104,7 +104,7 @@ func writeProbeModule(t *testing.T) string { dir := t.TempDir() files := map[string]string{ "main.go": probeSource, - "go.mod": "module goffiprobe\n\ngo 1.25\n\n" + + "go.mod": "module goffiprobe\n\ngo 1.26.0\n\n" + "require github.com/go-webgpu/goffi v0.0.0\n\n" + "replace github.com/go-webgpu/goffi => " + root + "\n", } diff --git a/go.mod b/go.mod index f84990c..fc775db 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/go-webgpu/goffi -go 1.26.0 +go 1.25.0 diff --git a/scripts/check-android-arm64.sh b/scripts/check-android-arm64.sh index df0e392..3b65ee0 100755 --- a/scripts/check-android-arm64.sh +++ b/scripts/check-android-arm64.sh @@ -31,7 +31,7 @@ readelf="$toolchain/llvm-readelf" # lines, or when any audited source invariant changes. go_version=$(go env GOVERSION) case "$go_version" in - go1.25.12|go1.26.5) ;; + go1.26.5|go1.26.6|go1.26.7) ;; *) echo "unsupported Go runtime source for Android fakecgo: $go_version" >&2 echo "audit the new runtime/cgo Android arm64 startup ABI before extending this gate" >&2 diff --git a/scripts/check-static.sh b/scripts/check-static.sh index 4781363..50f6e27 100755 --- a/scripts/check-static.sh +++ b/scripts/check-static.sh @@ -72,7 +72,7 @@ PROBE cat >"$probe/go.mod" < Date: Sat, 22 Aug 2026 17:01:42 +0200 Subject: [PATCH 04/24] feat: add goffi_musl build tag for Alpine and other musl systems A default goffi binary cannot start on Alpine: PT_INTERP names the glibc loader (which musl systems do not have), and the cgo_import_dynamic directives name libdl.so.2 / libc.so.6 / libpthread.so.0, none of which musl ships -- its whole POSIX surface lives in one arch-named object, libc.musl-.so.1. Both facts are baked into the ELF at link time, so the libc flavor is a build-time choice. The goffi_musl tag selects musl flavors of the three directive groups (internal/dl, internal/syscall, internal/fakecgo) and bakes the musl loader path into PT_INTERP via //go:cgo_dynamic_linker. That directive is restricted to cgo-generated code, so musl builds pass -gcflags=github.com/go-webgpu/goffi/internal/dl=-std -- a deliberately loud failure mode: forgetting the flag is a compile error naming the directive, not a binary that dies at startup with a confusing ENOENT. One symbol is dropped from the musl set: pthread_get_stacksize_np is a Darwin-only API that glibc's lazy PLT silently tolerates but musl's immediate binding would fatally reject at load time. Its trampoline is only reachable from the Darwin thread-entry path, so the linker dead-code-eliminates it on Linux. The fakecgo musl files are produced by gen.go from the same symbol tables as the glibc ones. Verification: - TestMuslDirectiveParity pins glibc/musl symbol-set parity (including the one intentional exclusion), per-arch SONAMEs and the interpreter directive, so the flavors cannot drift apart silently. - TestMuslLinkArtifacts builds linux/{amd64,arm64} probes and asserts PT_INTERP and DT_NEEDED with debug/elf. - cmd/musl-probe runs the full machinery against a real musl libc: dlopen/dlsym, float and integer calls, errno capture through __errno_location, qsort with a Go callback (crosscall2), and a 64-goroutine hammer forcing the runtime to create OS threads through fakecgo's pthread imports. scripts/check-musl.sh executes it inside an Alpine userland (docker, or a sha256-pinned minirootfs via chroot, or the musl loader invoked directly) and is wired into CI. Verified on Alpine 3.24.1: all probe checks pass on first run; glibc and goffi_static test suites unchanged. Pre-existing arm64 vet warnings in ffi/callback_arm64.go (present on master) are out of scope. Tag interplay: goffi_static wins over goffi_musl; the tag is inert off Linux. See docs/MUSL.md. --- .github/workflows/ci.yml | 43 +++-- README.md | 5 + cmd/musl-probe/main.go | 214 +++++++++++++++++++++++++ docs/MUSL.md | 97 +++++++++++ ffi/musl_directives_test.go | 140 ++++++++++++++++ ffi/musl_link_test.go | 118 ++++++++++++++ internal/dl/dl_linux_dynamic.go | 5 +- internal/dl/dl_musl_amd64.go | 44 +++++ internal/dl/dl_musl_arm64.go | 44 +++++ internal/fakecgo/gen.go | 42 ++++- internal/fakecgo/symbols_linux.go | 2 +- internal/fakecgo/symbols_musl_amd64.go | 30 ++++ internal/fakecgo/symbols_musl_arm64.go | 30 ++++ internal/syscall/errno_linux.go | 12 +- internal/syscall/errno_musl_amd64.go | 14 ++ internal/syscall/errno_musl_arm64.go | 14 ++ scripts/check-musl.sh | 78 +++++++++ 17 files changed, 914 insertions(+), 18 deletions(-) create mode 100644 cmd/musl-probe/main.go create mode 100644 docs/MUSL.md create mode 100644 ffi/musl_directives_test.go create mode 100644 ffi/musl_link_test.go create mode 100644 internal/dl/dl_musl_amd64.go create mode 100644 internal/dl/dl_musl_arm64.go create mode 100644 internal/fakecgo/symbols_musl_amd64.go create mode 100644 internal/fakecgo/symbols_musl_arm64.go create mode 100644 internal/syscall/errno_musl_amd64.go create mode 100644 internal/syscall/errno_musl_arm64.go create mode 100755 scripts/check-musl.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 232b451..e8f9112 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI # Testing Strategy: # - Tests run on Linux and Windows (AMD64 only - macOS planned for v0.5.0) # - goffi uses platform-specific assembly (System V vs Win64 ABI) -# - Go 1.25+ required (matches go.mod requirement) +# - Go 1.26+ required (matches go.mod requirement) # - Race detector critical for low-level FFI code # # Branch Strategy (Git Flow): @@ -41,7 +41,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true - name: Download dependencies @@ -69,7 +69,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true - name: Check formatting @@ -97,7 +97,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true - name: Cross-compile all platforms @@ -185,12 +185,35 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true - name: Check static build mode run: scripts/check-static.sh + # musl builds - the goffi_musl tag must swap the glibc SONAMEs and the + # ELF interpreter for their musl equivalents, otherwise the binary cannot + # start on Alpine. Link-time checks run for amd64 and arm64; the runtime + # probe executes inside a real Alpine userland. See docs/MUSL.md. + musl-build: + name: musl Build (goffi_musl) + runs-on: ubuntu-latest + needs: [lint, formatting] + env: + CGO_ENABLED: 0 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + cache: true + + - name: Check musl build mode + run: scripts/check-musl.sh + android-cross: name: Android arm64 (Go ${{ matrix.go }}) runs-on: ubuntu-latest @@ -198,7 +221,7 @@ jobs: strategy: fail-fast: false matrix: - go: ['1.25.12', '1.26.5'] + go: ['1.26.5', '1.26.x'] steps: - name: Checkout code uses: actions/checkout@v4 @@ -252,7 +275,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true cache-dependency-path: go.sum @@ -321,7 +344,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true - name: Download dependencies @@ -363,7 +386,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' - name: Download coverage reports uses: actions/download-artifact@v4 @@ -431,7 +454,7 @@ jobs: echo "✅ Lint: PASSED" echo "✅ Formatting: PASSED" echo "✅ Cross-Compile: PASSED (8 desktop targets)" - echo "✅ Android arm64: PASSED (API 29+, Go 1.25/1.26, cgo=0/1)" + echo "✅ Android arm64: PASSED (API 29+, Go 1.26, cgo=0/1)" echo "✅ Tests: PASSED (CGO_ENABLED=0 and CGO_ENABLED=1)" echo " - Linux AMD64 (ubuntu-latest)" echo " - Windows AMD64 (windows-latest)" diff --git a/README.md b/README.md index 9b0e263..76a06f7 100644 --- a/README.md +++ b/README.md @@ -410,6 +410,11 @@ The tag is a no-op on Windows and Android, which are dynamically linked by construction; `Available()` stays `true` there, so a cross-platform build matrix can pass the tag everywhere. See [docs/STATIC_BUILDS.md](docs/STATIC_BUILDS.md). +For Alpine and other musl-based distros there is a third flavor: the default +build hardcodes glibc SONAMEs and the glibc loader path, so it cannot start +under musl at all. Build with `-tags goffi_musl` (plus one `-gcflags` line) to +target musl with **full FFI** — see [docs/MUSL.md](docs/MUSL.md). + --- ## Platform Support diff --git a/cmd/musl-probe/main.go b/cmd/musl-probe/main.go new file mode 100644 index 0000000..20c514a --- /dev/null +++ b/cmd/musl-probe/main.go @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Command musl-probe is the runtime half of the goffi_musl verification. +// +// The link-time half (ffi/musl_link_test.go) proves the binary carries the +// right interpreter and SONAMEs; this program proves the machinery behind +// them actually works when executed against a real musl libc. Each check +// maps to one group of directives the goffi_musl tag replaces: +// +// LoadLibrary/GetSymbol -> internal/dl (dlopen/dlsym via libc.musl) +// sqrt, strlen -> the call path (float and integer returns; +// on musl, libm lives inside libc) +// getpid vs syscall.Getpid -> a result checkable against ground truth +// open() on a missing path -> internal/syscall (__errno_location capture) +// qsort with NewCallback -> C-to-Go callbacks (crosscall2) +// the goroutine hammer -> internal/fakecgo (the runtime creates new +// OS threads through _cgo_thread_start, i.e. +// musl's pthread_create and friends) +// +// Exit status 0 and a final MUSL-PROBE-OK line mean every check passed. The +// program is built with -tags goffi_musl and run inside an Alpine userland +// by scripts/check-musl.sh and CI. +package main + +import ( + "fmt" + "math" + "os" + "runtime" + "sort" + "sync" + "syscall" + "unsafe" + + "github.com/go-webgpu/goffi/ffi" + "github.com/go-webgpu/goffi/types" +) + +func muslLibc() string { + switch runtime.GOARCH { + case "amd64": + return "libc.musl-x86_64.so.1" + case "arm64": + return "libc.musl-aarch64.so.1" + default: + return "" + } +} + +var failed bool + +func check(name string, ok bool, detail string) { + if ok { + fmt.Printf("ok %-22s %s\n", name, detail) + return + } + failed = true + fmt.Printf("FAIL %-22s %s\n", name, detail) +} + +func mustSym(handle unsafe.Pointer, name string) unsafe.Pointer { + sym, err := ffi.GetSymbol(handle, name) + if err != nil { + fmt.Printf("FAIL GetSymbol(%s): %v\n", name, err) + os.Exit(1) + } + return sym +} + +func mustCIF(ret *types.TypeDescriptor, args ...*types.TypeDescriptor) *types.CallInterface { + cif := &types.CallInterface{} + if err := ffi.PrepareCallInterface(cif, types.DefaultCall, ret, args); err != nil { + fmt.Printf("FAIL PrepareCallInterface: %v\n", err) + os.Exit(1) + } + return cif +} + +func main() { + lib := muslLibc() + if lib == "" { + fmt.Printf("FAIL unsupported GOARCH %s\n", runtime.GOARCH) + os.Exit(1) + } + + handle, err := ffi.LoadLibrary(lib) + if err != nil { + fmt.Printf("FAIL LoadLibrary(%s): %v\n", lib, err) + os.Exit(1) + } + defer ffi.FreeLibrary(handle) + check("LoadLibrary", true, lib) + + // sqrt(2.0): double(double). Exercises the SSE/FP register path. + sqrtFn := mustSym(handle, "sqrt") + sqrtCIF := mustCIF(types.DoubleTypeDescriptor, types.DoubleTypeDescriptor) + arg := 2.0 + var root float64 + if _, err := ffi.CallFunction(sqrtCIF, sqrtFn, + unsafe.Pointer(&root), []unsafe.Pointer{unsafe.Pointer(&arg)}); err != nil { + fmt.Printf("FAIL CallFunction(sqrt): %v\n", err) + os.Exit(1) + } + check("sqrt(2.0)", math.Abs(root-math.Sqrt2) < 1e-12, fmt.Sprintf("= %v", root)) + + // strlen: size_t(char*). Integer return through RAX/X0. + strlenFn := mustSym(handle, "strlen") + strlenCIF := mustCIF(types.UInt64TypeDescriptor, types.PointerTypeDescriptor) + s := "goffi on musl\x00" + sp := unsafe.Pointer(unsafe.StringData(s)) + var n uint64 + if _, err := ffi.CallFunction(strlenCIF, strlenFn, + unsafe.Pointer(&n), []unsafe.Pointer{unsafe.Pointer(&sp)}); err != nil { + fmt.Printf("FAIL CallFunction(strlen): %v\n", err) + os.Exit(1) + } + check("strlen", n == uint64(len(s)-1), fmt.Sprintf("= %d", n)) + + // getpid: a value with independent ground truth on the Go side. + getpidFn := mustSym(handle, "getpid") + getpidCIF := mustCIF(types.SInt32TypeDescriptor) + var pid int32 + if _, err := ffi.CallFunction(getpidCIF, getpidFn, + unsafe.Pointer(&pid), nil); err != nil { + fmt.Printf("FAIL CallFunction(getpid): %v\n", err) + os.Exit(1) + } + check("getpid", int(pid) == syscall.Getpid(), + fmt.Sprintf("C=%d Go=%d", pid, syscall.Getpid())) + + // open() on a path that cannot exist: return -1, errno ENOENT. This is + // the __errno_location import doing real work on musl. + openFn := mustSym(handle, "open") + openCIF := mustCIF(types.SInt32TypeDescriptor, + types.PointerTypeDescriptor, types.SInt32TypeDescriptor) + path := "/goffi_musl_probe_nonexistent\x00" + pathPtr := unsafe.Pointer(unsafe.StringData(path)) + flags := int32(0) // O_RDONLY + var fd int32 + cerrno, err := ffi.CallFunction(openCIF, openFn, + unsafe.Pointer(&fd), + []unsafe.Pointer{unsafe.Pointer(&pathPtr), unsafe.Pointer(&flags)}) + if err != nil { + fmt.Printf("FAIL CallFunction(open): %v\n", err) + os.Exit(1) + } + check("errno capture", fd == -1 && cerrno == syscall.ENOENT, + fmt.Sprintf("ret=%d errno=%d", fd, cerrno)) + + // qsort with a Go comparator: C calls back into Go through crosscall2. + qsortFn := mustSym(handle, "qsort") + qsortCIF := mustCIF(types.VoidTypeDescriptor, + types.PointerTypeDescriptor, types.UInt64TypeDescriptor, + types.UInt64TypeDescriptor, types.PointerTypeDescriptor) + data := []int32{7, -3, 42, 0, -100, 13, 5, 5} + cmp := ffi.NewCallback(func(a, b unsafe.Pointer) uintptr { + va := *(*int32)(a) + vb := *(*int32)(b) + // Truncate to a C int in the low 32 bits; sign survives the trip. + return uintptr(uint32(va - vb)) + }) + base := unsafe.Pointer(&data[0]) + nmemb := uint64(len(data)) + size := uint64(4) + cmpArg := cmp + if _, err := ffi.CallFunction(qsortCIF, qsortFn, nil, []unsafe.Pointer{ + unsafe.Pointer(&base), unsafe.Pointer(&nmemb), + unsafe.Pointer(&size), unsafe.Pointer(&cmpArg), + }); err != nil { + fmt.Printf("FAIL CallFunction(qsort): %v\n", err) + os.Exit(1) + } + check("qsort callback", sort.SliceIsSorted(data, func(i, j int) bool { + return data[i] < data[j] + }), fmt.Sprintf("%v", data)) + + // Concurrency hammer: enough parallel FFI work that the Go runtime has + // to create new OS threads, which under iscgo=true goes through + // fakecgo's _cgo_thread_start -- pthread_create and the whole attr + // family, now resolved from musl. + runtime.GOMAXPROCS(max(4, runtime.NumCPU())) + var wg sync.WaitGroup + errs := make(chan error, 64) + for g := 0; g < 64; g++ { + wg.Add(1) + go func(seed float64) { + defer wg.Done() + for i := 0; i < 200; i++ { + in := seed + float64(i) + var out float64 + if _, err := ffi.CallFunction(sqrtCIF, sqrtFn, + unsafe.Pointer(&out), []unsafe.Pointer{unsafe.Pointer(&in)}); err != nil { + errs <- err + return + } + if math.Abs(out*out-in) > 1e-6 { + errs <- fmt.Errorf("sqrt(%v) = %v", in, out) + return + } + } + }(float64(g + 1)) + } + wg.Wait() + close(errs) + hammerErr := <-errs + check("thread hammer", hammerErr == nil, fmt.Sprintf("64 goroutines x 200 calls, err=%v", hammerErr)) + + if failed { + fmt.Println("MUSL-PROBE-FAILED") + os.Exit(1) + } + fmt.Println("MUSL-PROBE-OK") +} diff --git a/docs/MUSL.md b/docs/MUSL.md new file mode 100644 index 0000000..c2368a3 --- /dev/null +++ b/docs/MUSL.md @@ -0,0 +1,97 @@ +# musl / Alpine Builds (`-tags goffi_musl`) + +## The problem + +A default goffi binary does not start on Alpine, and it fails twice before +`main` ever runs: + +1. **The interpreter is wrong.** The Go linker writes + `PT_INTERP = /lib64/ld-linux-x86-64.so.2` — the glibc loader path. Alpine + has no such file, so `execve` fails with a `no such file or directory` + that misleadingly appears to be about the binary itself. + +2. **The SONAMEs are wrong.** goffi's `//go:cgo_import_dynamic` directives + name `libdl.so.2`, `libc.so.6` and `libpthread.so.0`. musl ships none of + them: its entire POSIX surface — dlopen, pthreads, libm, errno — lives in + one arch-named object, `libc.musl-x86_64.so.1` (or `-aarch64`). The musl + dynamic linker refuses to start a process whose `DT_NEEDED` it cannot + satisfy. + +Both are baked into the ELF at link time, so no runtime cleverness can fix a +binary built for the wrong libc. The flavor is a build-time choice. + +## Usage + +```bash +CGO_ENABLED=0 go build -tags goffi_musl \ + -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... +``` + +The same command works for `GOARCH=amd64` and `GOARCH=arm64`; the +architecture-specific loader path and SONAME are selected by build +constraints inside goffi. + +The `-gcflags` part deserves a word. The musl interpreter path is baked in +with a `//go:cgo_dynamic_linker` directive, which the compiler restricts to +cgo-generated code; the flag relaxes that check for the one package that +carries it (`internal/dl`). Forgetting the flag is a loud compile error that +names the directive — deliberately preferable to the silent alternative, a +binary carrying the glibc interpreter that dies at startup on Alpine with a +confusing error. (This is the same mechanism some projects already use for +goffi's FreeBSD `fakecgo` shim.) + +**Everything works in this mode.** Unlike `goffi_static`, which trades FFI +away for a static binary, `goffi_musl` is full-featured: `LoadLibrary`, +`GetSymbol`, `CallFunction`, callbacks, errno capture — all of it, resolved +from musl's libc. `ffi.Available()` reports `true`. + +## What changes under the hood + +| Package | glibc build | `goffi_musl` build | +|---|---|---| +| `internal/dl` | `dlopen` … from `libdl.so.2` | from `libc.musl-.so.1` | +| `internal/syscall` | `__errno_location` from `libc.so.6` | from `libc.musl-.so.1` | +| `internal/fakecgo` | `malloc`, `pthread_*` from `libc.so.6` / `libpthread.so.0` | from `libc.musl-.so.1` | +| ELF interpreter | `/lib64/ld-linux-x86-64.so.2` (linker default) | `/lib/ld-musl-.so.1` (directive) | + +One symbol is intentionally absent from the musl set: +`pthread_get_stacksize_np` is a Darwin-only API that neither glibc nor musl +exports. The glibc build gets away with importing it because glibc binds +functions lazily and nobody calls the stub on Linux; musl binds every import +immediately at load time and would abort startup. Its trampoline is only +reachable from the Darwin thread-entry path, so on Linux the linker +dead-code-eliminates it. `TestMuslDirectiveParity` pins this exact +asymmetry, and keeps the glibc and musl symbol sets from drifting apart in +general. + +The `internal/fakecgo` musl files are generated: `gen.go` produces them from +the same symbol tables as the glibc ones, filtered as described above. + +## Tag interplay + +- `goffi_musl` is meaningful only on Linux; elsewhere it selects nothing. +- `goffi_static` wins over `goffi_musl`: with both tags set, every dynamic + import is compiled out and FFI is disabled, exactly as in a plain + `goffi_static` build. A static binary is already libc-agnostic, so there + is no musl flavor of it to want. + +## Verification + +`scripts/check-musl.sh` compiles both architectures, asserts the interpreter +and `DT_NEEDED` with `debug/elf` (`TestMuslLinkArtifacts`), and then executes +`cmd/musl-probe` inside a real Alpine userland — via `docker run alpine` on +CI, via a checksummed Alpine minirootfs and `chroot` when running as root +without docker, or via the musl loader invoked directly as a last resort. +The probe exercises every directive group the tag replaces: dlopen/dlsym, +integer and floating-point calls, errno capture through +`__errno_location`, C-to-Go callbacks (`qsort` with a Go comparator), and a +64-goroutine hammer that forces the Go runtime to create OS threads through +fakecgo's pthread imports. + +## Choosing a Linux flavor + +| You are shipping to | Build | +|---|---| +| glibc distros (Debian, Fedora, …) | default (no tags) | +| Alpine, postmarketOS, other musl distros | `-tags goffi_musl` + the `-gcflags` line above | +| `scratch` / distroless containers, no libc at all | `-tags goffi_static` (FFI off — there are no `.so` files to load there anyway) | diff --git a/ffi/musl_directives_test.go b/ffi/musl_directives_test.go new file mode 100644 index 0000000..3e59909 --- /dev/null +++ b/ffi/musl_directives_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi_test + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "testing" +) + +// The musl directive files mirror hand-picked glibc originals, and the two +// must not drift: a symbol added to the glibc side and forgotten on the musl +// side would fail only at load time on Alpine, far from the change that +// caused it. This test pins the invariant at go-test time. +// +// One asymmetry is intentional and encoded below: musl's dynamic linker +// binds every import immediately at load and aborts on an unresolved one, +// so pthread_get_stacksize_np -- a Darwin-only API that glibc's lazy PLT +// silently tolerates -- must be absent from the musl set. + +var importDirective = regexp.MustCompile( + `(?m)^//go:cgo_import_dynamic\s+(\S+)\s+(\S+)\s+"([^"]+)"`) + +var interpDirective = regexp.MustCompile( + `(?m)^//go:cgo_dynamic_linker\s+"([^"]+)"`) + +// symbolSet returns the imported C symbol names in a file, skipping the +// "_ _" force-dependency entries, plus the set of SONAMEs referenced. +func symbolSet(t *testing.T, path string) (map[string]bool, map[string]bool) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + syms := map[string]bool{} + sos := map[string]bool{} + for _, m := range importDirective.FindAllStringSubmatch(string(data), -1) { + sos[m[3]] = true + if m[2] == "_" { + continue + } + syms[m[2]] = true + } + return syms, sos +} + +func TestMuslDirectiveParity(t *testing.T) { + root, err := filepath.Abs("..") + if err != nil { + t.Fatalf("locate module root: %v", err) + } + join := func(elem ...string) string { + return filepath.Join(append([]string{root}, elem...)...) + } + + muslArches := map[string]string{ + "amd64": "x86_64", + "arm64": "aarch64", + } + + // Package -> glibc source of truth and the symbols musl must not carry. + cases := []struct { + name string + glibc string + muslFmt string // per-arch musl file, %s = goarch + exclude map[string]bool + }{ + { + name: "fakecgo", + glibc: join("internal", "fakecgo", "symbols_linux.go"), + muslFmt: join("internal", "fakecgo", "symbols_musl_%s.go"), + exclude: map[string]bool{"pthread_get_stacksize_np": true}, + }, + { + name: "dl", + glibc: join("internal", "dl", "dl_linux_dynamic.go"), + muslFmt: join("internal", "dl", "dl_musl_%s.go"), + }, + { + name: "syscall", + glibc: join("internal", "syscall", "errno_linux.go"), + muslFmt: join("internal", "syscall", "errno_musl_%s.go"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + want, _ := symbolSet(t, tc.glibc) + for s := range tc.exclude { + if !want[s] { + t.Errorf("exclusion list mentions %s, but the glibc file does not import it; update this test", s) + } + delete(want, s) + } + + for goarch, musl := range muslArches { + path := fmt.Sprintf(tc.muslFmt, goarch) + got, sos := symbolSet(t, path) + + for s := range want { + if !got[s] { + t.Errorf("%s: glibc imports %s but the musl file does not", path, s) + } + } + for s := range got { + if !want[s] { + t.Errorf("%s: imports %s, which the glibc file does not (or which musl must not import)", path, s) + } + } + + wantSO := "libc.musl-" + musl + ".so.1" + for so := range sos { + if so != wantSO { + t.Errorf("%s: imports from %q, want only %q", path, so, wantSO) + } + } + } + }) + } + + // The interpreter directive lives in internal/dl and must name the + // matching musl loader on each architecture. + for goarch, musl := range muslArches { + path := join("internal", "dl", fmt.Sprintf("dl_musl_%s.go", goarch)) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + m := interpDirective.FindStringSubmatch(string(data)) + want := "/lib/ld-musl-" + musl + ".so.1" + if m == nil { + t.Errorf("%s: missing //go:cgo_dynamic_linker directive", path) + } else if m[1] != want { + t.Errorf("%s: interpreter %q, want %q", path, m[1], want) + } + } +} diff --git a/ffi/musl_link_test.go b/ffi/musl_link_test.go new file mode 100644 index 0000000..9574079 --- /dev/null +++ b/ffi/musl_link_test.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && (amd64 || arm64) + +package ffi_test + +import ( + "bytes" + "debug/elf" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +// muslNames maps GOARCH to the musl architecture that appears in both the +// loader path and the libc SONAME. +var muslNames = map[string]string{ + "amd64": "x86_64", + "arm64": "aarch64", +} + +// TestMuslLinkArtifacts is the link-time half of the goffi_musl verification +// (the runtime half is cmd/musl-probe, driven by scripts/check-musl.sh). +// +// A goffi binary is unusable on Alpine for two independent reasons, and each +// gets its own assertion here: the glibc SONAMEs in DT_NEEDED (musl ships no +// libdl.so.2, libc.so.6 or libpthread.so.0, so the dynamic linker refuses to +// start the process), and PT_INTERP, which the Go linker defaults to the +// glibc loader path (so on Alpine execve fails before a single instruction +// runs). The goffi_musl tag must fix both, on both architectures. +func TestMuslLinkArtifacts(t *testing.T) { + if testing.Short() { + t.Skip("skipping: builds two binaries") + } + + root, err := filepath.Abs("..") + if err != nil { + t.Fatalf("locate module root: %v", err) + } + + for goarch, musl := range muslNames { + t.Run(goarch, func(t *testing.T) { + bin := filepath.Join(t.TempDir(), "musl-probe-"+goarch) + buildMuslProbe(t, root, goarch, bin) + + f, err := elf.Open(bin) + if err != nil { + t.Fatalf("open ELF: %v", err) + } + defer f.Close() + + wantInterp := "/lib/ld-musl-" + musl + ".so.1" + interp := readInterp(t, f) + if interp != wantInterp { + t.Errorf("PT_INTERP = %q, want %q", interp, wantInterp) + } + + wantLibc := "libc.musl-" + musl + ".so.1" + needed, err := f.DynString(elf.DT_NEEDED) + if err != nil { + t.Fatalf("read DT_NEEDED: %v", err) + } + if len(needed) != 1 || needed[0] != wantLibc { + t.Errorf("DT_NEEDED = %v, want exactly [%s]", needed, wantLibc) + } + for _, glibc := range []string{"libdl.so.2", "libc.so.6", "libpthread.so.0"} { + for _, n := range needed { + if n == glibc { + t.Errorf("glibc SONAME %s leaked into the musl build", glibc) + } + } + } + }) + } +} + +func readInterp(t *testing.T, f *elf.File) string { + t.Helper() + sec := f.Section(".interp") + if sec == nil { + t.Fatal("binary has no .interp section") + } + data, err := sec.Data() + if err != nil { + t.Fatalf("read .interp: %v", err) + } + return string(bytes.TrimRight(data, "\x00")) +} + +func buildMuslProbe(t *testing.T, root, goarch, out string) { + t.Helper() + + goTool := filepath.Join(runtime.GOROOT(), "bin", "go") + if _, err := os.Stat(goTool); err != nil { + goTool = "go" + } + + cmd := exec.Command(goTool, "build", + "-tags", "goffi_musl", + // //go:cgo_dynamic_linker is restricted to cgo-generated code; the + // musl interpreter directive lives in internal/dl, so that one + // package is compiled with the check relaxed. + "-gcflags=github.com/go-webgpu/goffi/internal/dl=-std", + "-o", out, "./cmd/musl-probe") + cmd.Dir = root + cmd.Env = append(os.Environ(), + "CGO_ENABLED=0", + "GOOS=linux", + "GOARCH="+goarch, + "GOFLAGS=-mod=mod", + ) + if outBytes, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build linux/%s with -tags goffi_musl: %v\n%s", goarch, err, outBytes) + } +} diff --git a/internal/dl/dl_linux_dynamic.go b/internal/dl/dl_linux_dynamic.go index 9370f92..420e790 100644 --- a/internal/dl/dl_linux_dynamic.go +++ b/internal/dl/dl_linux_dynamic.go @@ -1,6 +1,7 @@ -//go:build linux && !android && !goffi_static +//go:build linux && !android && !goffi_static && !goffi_musl -// Dynamic symbol imports for Linux. +// Dynamic symbol imports for Linux/glibc. The musl flavor lives in +// dl_musl_amd64.go / dl_musl_arm64.go behind the goffi_musl build tag. // // These directives live in their own file so that the goffi_static build tag // can drop them (and with them the ELF interpreter and DT_NEEDED entries the diff --git a/internal/dl/dl_musl_amd64.go b/internal/dl/dl_musl_amd64.go new file mode 100644 index 0000000..417e61f --- /dev/null +++ b/internal/dl/dl_musl_amd64.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && amd64 + +// Dynamic symbol imports for Linux/musl (Alpine and friends). +// +// musl ships the entire POSIX surface -- dlopen, pthreads, libm, errno -- +// in one object whose name embeds the architecture: libc.musl-x86_64.so.1. +// There is no libdl.so.2 and no libc.so.6 on a musl system, so the glibc +// directives in dl_linux_dynamic.go make the process fail to start ("Error +// loading shared library libdl.so.2: No such file or directory"). This file +// replaces them under the goffi_musl build tag. +// +// The interpreter is part of the same story: the Go linker defaults +// PT_INTERP to the glibc loader path, which does not exist on Alpine, so +// the binary would die in execve before a single instruction runs. The +// //go:cgo_dynamic_linker directive below bakes the musl loader path into +// every binary built with this tag. The compiler restricts that directive +// to cgo-generated code, so musl builds must relax the check for this one +// package: +// +// CGO_ENABLED=0 go build -tags goffi_musl \ +// -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... +// +// Forgetting the flag is a loud compile error naming this directive, which +// beats the silent alternative: a binary that carries the wrong interpreter +// and fails at startup with a misleading "no such file" about itself. +// +// Tag interplay: goffi_static wins over goffi_musl -- with both set, all +// dynamic imports are compiled out and FFI is disabled, same as plain +// goffi_static. + +package dl + +//go:cgo_import_dynamic goffi_dlopen dlopen "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_dlsym dlsym "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_dlerror dlerror "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_dlclose dlclose "libc.musl-x86_64.so.1" + +// Force dependency on musl libc +//go:cgo_import_dynamic _ _ "libc.musl-x86_64.so.1" + +//go:cgo_dynamic_linker "/lib/ld-musl-x86_64.so.1" diff --git a/internal/dl/dl_musl_arm64.go b/internal/dl/dl_musl_arm64.go new file mode 100644 index 0000000..53a5ebe --- /dev/null +++ b/internal/dl/dl_musl_arm64.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && arm64 + +// Dynamic symbol imports for Linux/musl (Alpine and friends). +// +// musl ships the entire POSIX surface -- dlopen, pthreads, libm, errno -- +// in one object whose name embeds the architecture: libc.musl-aarch64.so.1. +// There is no libdl.so.2 and no libc.so.6 on a musl system, so the glibc +// directives in dl_linux_dynamic.go make the process fail to start ("Error +// loading shared library libdl.so.2: No such file or directory"). This file +// replaces them under the goffi_musl build tag. +// +// The interpreter is part of the same story: the Go linker defaults +// PT_INTERP to the glibc loader path, which does not exist on Alpine, so +// the binary would die in execve before a single instruction runs. The +// //go:cgo_dynamic_linker directive below bakes the musl loader path into +// every binary built with this tag. The compiler restricts that directive +// to cgo-generated code, so musl builds must relax the check for this one +// package: +// +// CGO_ENABLED=0 go build -tags goffi_musl \ +// -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... +// +// Forgetting the flag is a loud compile error naming this directive, which +// beats the silent alternative: a binary that carries the wrong interpreter +// and fails at startup with a misleading "no such file" about itself. +// +// Tag interplay: goffi_static wins over goffi_musl -- with both set, all +// dynamic imports are compiled out and FFI is disabled, same as plain +// goffi_static. + +package dl + +//go:cgo_import_dynamic goffi_dlopen dlopen "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_dlsym dlsym "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_dlerror dlerror "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_dlclose dlclose "libc.musl-aarch64.so.1" + +// Force dependency on musl libc +//go:cgo_import_dynamic _ _ "libc.musl-aarch64.so.1" + +//go:cgo_dynamic_linker "/lib/ld-musl-aarch64.so.1" diff --git a/internal/fakecgo/gen.go b/internal/fakecgo/gen.go index 1a80e7d..1dd0ffd 100644 --- a/internal/fakecgo/gen.go +++ b/internal/fakecgo/gen.go @@ -263,7 +263,7 @@ func run() error { case "linux": // The go command also satisfies the linux build tag on Android, // including for _linux.go files. Keep glibc imports out of Bionic. - goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo", "//go:build !cgo && !android", 1))) + goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo", "//go:build !cgo && !android && !goffi_musl", 1))) case "android": // Android uses a distinct build selector and symbol set. Keep the // generated generic Linux imports out of the Android ELF. @@ -286,6 +286,46 @@ func run() error { } } + // musl (Alpine and friends): the whole POSIX surface lives in one + // arch-named libc.so, so both symbol groups point at the same object, + // and the object name embeds the musl architecture, so the file is + // generated per GOARCH. One symbol is dropped: musl's dynamic linker + // binds every import immediately at load time and aborts startup on an + // unresolved one, unlike glibc's lazy PLT which forgives stubs nobody + // calls -- and pthread_get_stacksize_np is a Darwin-only API that + // neither glibc nor musl exports. Its trampoline is only reachable from + // the Darwin threadentry, so on Linux the linker dead-code-eliminates + // it and the missing import is never referenced. + muslPthread := make([]Symbol, 0, len(pthreadSymbols)) + for _, s := range pthreadSymbols { + if s.Name == "pthread_get_stacksize_np" { + continue + } + muslPthread = append(muslPthread, s) + } + for _, mc := range []struct{ arch, so string }{ + {"amd64", "libc.musl-x86_64.so.1"}, + {"arm64", "libc.musl-aarch64.so.1"}, + } { + tag := "//go:build !cgo && !android && goffi_musl && " + mc.arch + mt := template.Must(template.New("symbols_musl.go").Parse( + strings.Replace(templateSymbolsGoos, "//go:build !cgo", tag, 1))) + mb := &bytes.Buffer{} + if merr := mt.Execute(mb, []LocatedSymbols{ + {SharedObject: mc.so, Symbols: libcSymbols}, + {SharedObject: mc.so, Symbols: muslPthread}, + }); merr != nil { + return merr + } + msrc, merr := format.Source(mb.Bytes()) + if merr != nil { + return merr + } + if merr := os.WriteFile(fmt.Sprintf("symbols_musl_%s.go", mc.arch), msrc, 0o644); merr != nil { + return merr + } + } + // The Android wrappers and assembly stubs are generated from the same // restricted symbol set as the imports above. androidSymbols := append(append([]Symbol{}, androidLibcSymbols...), androidPthreadSymbols...) diff --git a/internal/fakecgo/symbols_linux.go b/internal/fakecgo/symbols_linux.go index b5c7dfa..58506b7 100644 --- a/internal/fakecgo/symbols_linux.go +++ b/internal/fakecgo/symbols_linux.go @@ -4,7 +4,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && !android +//go:build !cgo && !android && !goffi_musl package fakecgo diff --git a/internal/fakecgo/symbols_musl_amd64.go b/internal/fakecgo/symbols_musl_amd64.go new file mode 100644 index 0000000..c0a451c --- /dev/null +++ b/internal/fakecgo/symbols_musl_amd64.go @@ -0,0 +1,30 @@ +// Code generated by 'go generate' with gen.go. DO NOT EDIT. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && !android && goffi_musl && amd64 + +package fakecgo + +//go:cgo_import_dynamic goffi_malloc malloc "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_free free "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_setenv setenv "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_unsetenv unsetenv "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_sigfillset sigfillset "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_nanosleep nanosleep "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_abort abort "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_sigaltstack sigaltstack "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_init pthread_attr_init "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_create pthread_create "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_detach pthread_detach "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_sigmask pthread_sigmask "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_self pthread_self "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_getstacksize pthread_attr_getstacksize "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_setstacksize pthread_attr_setstacksize "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_destroy pthread_attr_destroy "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_mutex_lock pthread_mutex_lock "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_mutex_unlock pthread_mutex_unlock "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_cond_broadcast pthread_cond_broadcast "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_pthread_setspecific pthread_setspecific "libc.musl-x86_64.so.1" diff --git a/internal/fakecgo/symbols_musl_arm64.go b/internal/fakecgo/symbols_musl_arm64.go new file mode 100644 index 0000000..b7dbac0 --- /dev/null +++ b/internal/fakecgo/symbols_musl_arm64.go @@ -0,0 +1,30 @@ +// Code generated by 'go generate' with gen.go. DO NOT EDIT. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && !android && goffi_musl && arm64 + +package fakecgo + +//go:cgo_import_dynamic goffi_malloc malloc "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_free free "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_setenv setenv "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_unsetenv unsetenv "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_sigfillset sigfillset "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_nanosleep nanosleep "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_abort abort "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_sigaltstack sigaltstack "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_init pthread_attr_init "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_create pthread_create "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_detach pthread_detach "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_sigmask pthread_sigmask "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_self pthread_self "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_getstacksize pthread_attr_getstacksize "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_setstacksize pthread_attr_setstacksize "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_destroy pthread_attr_destroy "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_mutex_lock pthread_mutex_lock "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_mutex_unlock pthread_mutex_unlock "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_cond_broadcast pthread_cond_broadcast "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_setspecific pthread_setspecific "libc.musl-aarch64.so.1" diff --git a/internal/syscall/errno_linux.go b/internal/syscall/errno_linux.go index e2e38e9..ac83af2 100644 --- a/internal/syscall/errno_linux.go +++ b/internal/syscall/errno_linux.go @@ -1,10 +1,14 @@ -//go:build linux && !android && (amd64 || arm64) && !goffi_static +//go:build linux && !android && (amd64 || arm64) && !goffi_static && !goffi_musl package syscall -// Link __errno_location from libc.so.6 (glibc and musl both export it). -// On glibc >= 2.34, libc.so.6 is the real library; libdl.so.2 is a stub. -// On musl, libc.so.6 is a symlink. Either way, __errno_location is available. +// Link __errno_location from glibc's libc.so.6. On glibc >= 2.34 this is +// where dlopen lives too; libdl.so.2 is a stub kept for SONAME lookups. +// +// musl exports __errno_location as well, but under a different SONAME +// (libc.musl-.so.1 -- there is no libc.so.6 on Alpine), so the musl +// flavor of this file is errno_musl_amd64.go / errno_musl_arm64.go behind +// the goffi_musl build tag. // //go:cgo_import_dynamic goffi_errno_location __errno_location "libc.so.6" //go:cgo_import_dynamic _ _ "libc.so.6" diff --git a/internal/syscall/errno_musl_amd64.go b/internal/syscall/errno_musl_amd64.go new file mode 100644 index 0000000..6df94dd --- /dev/null +++ b/internal/syscall/errno_musl_amd64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && amd64 + +// musl flavor of errno_linux.go: same symbol, different SONAME. musl +// exports __errno_location from its single libc object (errno itself lives +// in the thread control block, but the accessor is a plain exported +// function), so only the library name changes. + +package syscall + +//go:cgo_import_dynamic goffi_errno_location __errno_location "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic _ _ "libc.musl-x86_64.so.1" diff --git a/internal/syscall/errno_musl_arm64.go b/internal/syscall/errno_musl_arm64.go new file mode 100644 index 0000000..99ca728 --- /dev/null +++ b/internal/syscall/errno_musl_arm64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && arm64 + +// musl flavor of errno_linux.go: same symbol, different SONAME. musl +// exports __errno_location from its single libc object (errno itself lives +// in the thread control block, but the accessor is a plain exported +// function), so only the library name changes. + +package syscall + +//go:cgo_import_dynamic goffi_errno_location __errno_location "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic _ _ "libc.musl-aarch64.so.1" diff --git a/scripts/check-musl.sh b/scripts/check-musl.sh new file mode 100755 index 0000000..81059c1 --- /dev/null +++ b/scripts/check-musl.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verify the goffi_musl build mode against a real musl userland. +# +# A default goffi binary cannot start on Alpine for two independent reasons: +# PT_INTERP names the glibc loader (execve fails with a misleading ENOENT +# about the binary itself), and DT_NEEDED names glibc SONAMEs that musl does +# not ship. The goffi_musl tag fixes both. This script checks the artifacts +# statically, then executes the probe (cmd/musl-probe) inside an Alpine +# userland, picking the strongest execution mechanism available: +# +# 1. docker run alpine (CI runners) - kernel resolves PT_INTERP +# 2. chroot into a minirootfs (root) - kernel resolves PT_INTERP +# 3. ld-musl invoked directly (fallback) - bypasses PT_INTERP, still +# runs every musl code path +# +# The probe covers each directive group the tag replaces: dlopen/dlsym, +# integer and floating-point calls, errno capture, C-to-Go callbacks, and a +# goroutine hammer that forces the runtime to create OS threads through +# fakecgo's pthread imports. See docs/MUSL.md. + +ALPINE_IMAGE=alpine:3.24 +ROOTFS_VERSION=3.24.1 +ROOTFS_URL="https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/x86_64/alpine-minirootfs-${ROOTFS_VERSION}-x86_64.tar.gz" +ROOTFS_SHA256=41f73e3cf5fa919b8aa5ca6b30dc48f0da2720776d7423e2a7748211456fe081 + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" + +export CGO_ENABLED=0 +MUSL_FLAGS=(-tags goffi_musl -gcflags=github.com/go-webgpu/goffi/internal/dl=-std) + +echo "==> Compiling with -tags goffi_musl" +for arch in amd64 arm64; do + if GOOS=linux GOARCH="$arch" go build "${MUSL_FLAGS[@]}" ./...; then + echo " ok linux/${arch}" + else + echo " FAIL linux/${arch}" >&2 + exit 1 + fi +done + +echo "==> Verifying interpreter and SONAMEs (amd64 and arm64)" +go test -run TestMuslLinkArtifacts -v ./ffi + +echo "==> Building the runtime probe" +probe=$(mktemp -d) +trap 'rm -rf "$probe"' EXIT +GOOS=linux GOARCH=amd64 go build "${MUSL_FLAGS[@]}" -o "$probe/musl-probe" ./cmd/musl-probe + +run_probe() { + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + echo "==> Running probe in ${ALPINE_IMAGE} (docker)" + docker run --rm -v "$probe:/p:ro" "$ALPINE_IMAGE" /p/musl-probe + return + fi + + echo "==> No docker; fetching Alpine minirootfs ${ROOTFS_VERSION}" + curl -fsSL "$ROOTFS_URL" -o "$probe/rootfs.tar.gz" + echo "${ROOTFS_SHA256} $probe/rootfs.tar.gz" | sha256sum -c - + mkdir -p "$probe/rootfs" + tar xzf "$probe/rootfs.tar.gz" -C "$probe/rootfs" + + if [ "$(id -u)" = 0 ]; then + echo "==> Running probe via chroot (kernel resolves PT_INTERP)" + cp "$probe/musl-probe" "$probe/rootfs/musl-probe" + chroot "$probe/rootfs" /musl-probe + else + echo "==> Running probe via ld-musl directly (no root)" + LD_LIBRARY_PATH="$probe/rootfs/lib" \ + "$probe/rootfs/lib/ld-musl-x86_64.so.1" "$probe/musl-probe" + fi +} + +run_probe + +echo "==> musl build mode OK" From 509455355d6de42fa97139172ecf4672fc1efda5 Mon Sep 17 00:00:00 2001 From: Roman Kharitonov Date: Sun, 23 Aug 2026 13:19:04 +1000 Subject: [PATCH 05/24] fix: route callback pointer arguments through one nocheckptr helper A callback frame is filled in by the native caller: the arguments arrive as raw machine words in registers and stack slots. Turning one of those words back into a pointer is what checkptr objects to, and since -race implies -d=checkptr, any race-detector run that reaches a callback died on the spot: fatal error: checkptr: pointer arithmetic result points to invalid allocation runtime.checkptrArithmetic github.com/go-webgpu/goffi/ffi.callbackWrap callback_arm64.go:189 Every one of the eight sites is the same case, and it is the benign one: the memory belongs to the caller, so the Go collector neither owns nor moves it, and the conversion is correct. The compiler simply cannot recover that from an integer. So the conversion stays and moves into one helper, pointerFromNative, marked //go:nocheckptr with the reason written next to it. There is no site here of the other kind -- a Go pointer this package itself laundered through a uintptr -- and if one ever appears it needs the opposite treatment, staying pointer-typed so the collector can keep it alive, not a pragma. amd64 was already working around this by reading the slot as *(*unsafe.Pointer)(unsafe.Pointer(&frame[pos])), which sidesteps the check by never naming a uintptr. That worked, but it made the two architectures read differently for no reason a reader could see, and it left the arm64 side to crash. Both now say the same thing. The //nolint:govet,gosec comments that sat on the call sites move to the helper with them. Worth noting they were never what kept checkptr quiet: those silence linters, and checkptr is a runtime check. The ffi package now passes go test -race. What still fails there is unrelated and unchanged: TestExecuteCaptureRegistersSimple in internal/arch/arm64 fails with and without -race on the branch point, and three more in that package fail under -race both before and after. --- ffi/callback.go | 10 ++++------ ffi/callback_arm64.go | 12 ++++-------- ffi/callback_pointer.go | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 ffi/callback_pointer.go diff --git a/ffi/callback.go b/ffi/callback.go index 762cf87..00be491 100644 --- a/ffi/callback.go +++ b/ffi/callback.go @@ -269,13 +269,11 @@ func callbackWrap(a *callbackArgs) { // 3. reflect.NewAt creates a proper typed pointer from the address if intIdx < numIntRegs { pos := numFloatRegs + intIdx - // Double-indirection: reinterpret uintptr bits as pointer without - // triggering checkptr arithmetic check (go.dev/issue/58625). - ptr := *(*unsafe.Pointer)(unsafe.Pointer(&frame[pos])) + ptr := pointerFromNative(frame[pos]) val = reflect.NewAt(argType.Elem(), ptr) intIdx++ } else { - ptr := *(*unsafe.Pointer)(unsafe.Pointer(&frame[stackIdx])) + ptr := pointerFromNative(frame[stackIdx]) val = reflect.NewAt(argType.Elem(), ptr) stackIdx++ } @@ -283,10 +281,10 @@ func callbackWrap(a *callbackArgs) { case reflect.UnsafePointer: if intIdx < numIntRegs { pos := numFloatRegs + intIdx - val = reflect.ValueOf(*(*unsafe.Pointer)(unsafe.Pointer(&frame[pos]))) + val = reflect.ValueOf(pointerFromNative(frame[pos])) intIdx++ } else { - val = reflect.ValueOf(*(*unsafe.Pointer)(unsafe.Pointer(&frame[stackIdx]))) + val = reflect.ValueOf(pointerFromNative(frame[stackIdx])) stackIdx++ } diff --git a/ffi/callback_arm64.go b/ffi/callback_arm64.go index 602435b..6bbfc9a 100644 --- a/ffi/callback_arm64.go +++ b/ffi/callback_arm64.go @@ -185,13 +185,11 @@ func callbackWrap(a *callbackArgs) { case reflect.Ptr: if intIdx < numIntRegs { pos := numFloatRegs + intIdx - //nolint:govet,gosec // G103: FFI callback argument - ptr := unsafe.Pointer(frame[pos]) + ptr := pointerFromNative(frame[pos]) val = reflect.NewAt(argType.Elem(), ptr) intIdx++ } else { - //nolint:govet,gosec // G103: FFI callback argument - ptr := unsafe.Pointer(frame[stackIdx]) + ptr := pointerFromNative(frame[stackIdx]) val = reflect.NewAt(argType.Elem(), ptr) stackIdx++ } @@ -199,12 +197,10 @@ func callbackWrap(a *callbackArgs) { case reflect.UnsafePointer: if intIdx < numIntRegs { pos := numFloatRegs + intIdx - //nolint:govet,gosec // G103: FFI callback argument - val = reflect.ValueOf(unsafe.Pointer(frame[pos])) + val = reflect.ValueOf(pointerFromNative(frame[pos])) intIdx++ } else { - //nolint:govet,gosec // G103: FFI callback argument - val = reflect.ValueOf(unsafe.Pointer(frame[stackIdx])) + val = reflect.ValueOf(pointerFromNative(frame[stackIdx])) stackIdx++ } diff --git a/ffi/callback_pointer.go b/ffi/callback_pointer.go new file mode 100644 index 0000000..1ac9b10 --- /dev/null +++ b/ffi/callback_pointer.go @@ -0,0 +1,15 @@ +//go:build (linux || darwin || freebsd) && (amd64 || arm64) && !goffi_static + +package ffi + +import "unsafe" + +// pointerFromNative reconstructs a pointer from a native callback register or +// stack slot. The native caller owns the memory, so Go's garbage collector does +// not track or move it; the compiler cannot recover that provenance from uintptr. +// +//go:nocheckptr +func pointerFromNative(address uintptr) unsafe.Pointer { + //nolint:govet,gosec // Native address; see the function contract above. + return unsafe.Pointer(address) +} From 1a015a9a31237080a37ca5579ac68264ce72ce49 Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Tue, 1 Sep 2026 23:36:04 +0000 Subject: [PATCH 06/24] =?UTF-8?q?WIP:=20Profile=20U=20universal=20(musl+gl?= =?UTF-8?q?ibc)=20FFI=20=E2=80=94=20scaffolding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot of in-progress work. DOES NOT COMPILE YET (mid-refactor). Design (validated on real glibc and musl): One portable CGO-free binary that does live in-process FFI on both libcs via (1) empty-SONAME cgo_import_dynamic imports -> no DT_NEEDED, no libc pinning (2) stripped PT_INTERP -> kernel loads it on any distro (3) early re-exec through the host's own loader with the host libc --preload (done at the top of x_cgo_init, before any libc symbol is touched) This mirrors static-everywhere's 'launch with the host's own loader, found at runtime' doctrine and needs no in-process ELF loader (no SoLo). In this snapshot: - Retag glibc/musl dl/errno/fakecgo import files as !goffi_universal - internal/dl/dl_universal.go empty-SONAME dlopen/dlsym/dlerror/dlclose - internal/syscall/errno_universal.go empty-SONAME __errno_location - internal/fakecgo/symbols_universal.go empty-SONAME malloc/pthread/... (musl set) - internal/fakecgo/reexec_syscall_amd64.s raw libc-free syscall primitive Pending (see conversation notes): reexec logic + arm64 stub + per-arch tables, noop shim + x_cgo_init call site, cmd/goffi-strip-interp, scripts/build-universal.sh, internal/loader + ffi.HostLoader/HostLibC/LibcKind, cmd/goffi-audit, cmd/universal-probe, tests, both-libc CI, purego-coexistence CI (nofakecgo), docs/PROFILE_U.md + attribution. --- internal/dl/dl_linux_dynamic.go | 2 +- internal/dl/dl_musl_amd64.go | 2 +- internal/dl/dl_musl_arm64.go | 2 +- internal/dl/dl_universal.go | 37 +++++++++++++++++++++ internal/fakecgo/reexec_syscall_amd64.s | 24 ++++++++++++++ internal/fakecgo/reexec_syscall_arm64.s | 22 +++++++++++++ internal/fakecgo/reexec_table_amd64.go | 31 +++++++++++++++++ internal/fakecgo/reexec_table_arm64.go | 28 ++++++++++++++++ internal/fakecgo/symbols_linux.go | 2 +- internal/fakecgo/symbols_musl_amd64.go | 2 +- internal/fakecgo/symbols_musl_arm64.go | 2 +- internal/fakecgo/symbols_universal.go | 44 +++++++++++++++++++++++++ internal/syscall/errno_linux.go | 2 +- internal/syscall/errno_musl_amd64.go | 2 +- internal/syscall/errno_musl_arm64.go | 2 +- internal/syscall/errno_universal.go | 17 ++++++++++ 16 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 internal/dl/dl_universal.go create mode 100644 internal/fakecgo/reexec_syscall_amd64.s create mode 100644 internal/fakecgo/reexec_syscall_arm64.s create mode 100644 internal/fakecgo/reexec_table_amd64.go create mode 100644 internal/fakecgo/reexec_table_arm64.go create mode 100644 internal/fakecgo/symbols_universal.go create mode 100644 internal/syscall/errno_universal.go diff --git a/internal/dl/dl_linux_dynamic.go b/internal/dl/dl_linux_dynamic.go index 420e790..99918d1 100644 --- a/internal/dl/dl_linux_dynamic.go +++ b/internal/dl/dl_linux_dynamic.go @@ -1,4 +1,4 @@ -//go:build linux && !android && !goffi_static && !goffi_musl +//go:build linux && !android && !goffi_static && !goffi_musl && !goffi_universal // Dynamic symbol imports for Linux/glibc. The musl flavor lives in // dl_musl_amd64.go / dl_musl_arm64.go behind the goffi_musl build tag. diff --git a/internal/dl/dl_musl_amd64.go b/internal/dl/dl_musl_amd64.go index 417e61f..d5347a4 100644 --- a/internal/dl/dl_musl_amd64.go +++ b/internal/dl/dl_musl_amd64.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors -//go:build linux && !android && !goffi_static && goffi_musl && amd64 +//go:build linux && !android && !goffi_static && goffi_musl && !goffi_universal && amd64 // Dynamic symbol imports for Linux/musl (Alpine and friends). // diff --git a/internal/dl/dl_musl_arm64.go b/internal/dl/dl_musl_arm64.go index 53a5ebe..948d74f 100644 --- a/internal/dl/dl_musl_arm64.go +++ b/internal/dl/dl_musl_arm64.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors -//go:build linux && !android && !goffi_static && goffi_musl && arm64 +//go:build linux && !android && !goffi_static && goffi_musl && !goffi_universal && arm64 // Dynamic symbol imports for Linux/musl (Alpine and friends). // diff --git a/internal/dl/dl_universal.go b/internal/dl/dl_universal.go new file mode 100644 index 0000000..490a909 --- /dev/null +++ b/internal/dl/dl_universal.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_universal + +// Dynamic symbol imports for the portable "universal" Linux build. +// +// Unlike the default (glibc) and goffi_musl flavors, this file imports the +// dl* symbols with an EMPTY library name. An empty //go:cgo_import_dynamic +// remote library produces an *undefined* dynamic symbol with no DT_NEEDED +// entry, so the resulting binary names no libc SONAME at all -- it works +// against glibc's libc.so.6 and musl's libc.musl-.so.1 alike. +// +// A binary with undefined dynamic symbols and no DT_NEEDED cannot resolve +// those symbols on its own: something has to map a libc into the process and +// bind them. goffi does that by re-executing itself, very early, through the +// host's own dynamic loader with the host libc pre-loaded -- see +// internal/fakecgo/reexec_universal_linux.go and docs/PROFILE_U.md. After the +// re-exec every symbol below binds from whichever libc the host actually has. +// +// The ELF interpreter is the other half of the story. The Go linker still +// writes the default glibc PT_INTERP, which does not exist on a musl-only +// system, so a universal binary is post-processed to drop the interpreter +// (cmd/goffi-strip-interp / scripts/build-universal.sh). With no interpreter +// the kernel loads the binary directly on every distribution, the Go runtime +// starts, and the re-exec bridge brings up libc before any FFI call. + +package dl + +//go:cgo_import_dynamic goffi_dlopen dlopen "" +//go:cgo_import_dynamic goffi_dlsym dlsym "" +//go:cgo_import_dynamic goffi_dlerror dlerror "" +//go:cgo_import_dynamic goffi_dlclose dlclose "" + +// NOTE: deliberately no `//go:cgo_import_dynamic _ _ "lib..."` force-line here. +// That line is what makes the linker emit a DT_NEEDED entry in the glibc and +// musl flavors; omitting it is precisely what keeps this binary libc-agnostic. diff --git a/internal/fakecgo/reexec_syscall_amd64.s b/internal/fakecgo/reexec_syscall_amd64.s new file mode 100644 index 0000000..aa504fc --- /dev/null +++ b/internal/fakecgo/reexec_syscall_amd64.s @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && amd64 + +#include "textflag.h" + +// func rawsyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1 uintptr) +// +// A minimal, libc-free Linux syscall. Used only by the universal re-exec +// bridge, which runs inside x_cgo_init before any libc symbol is bound, so it +// must not route through the normal (libc-dependent) FFI path. Linux passes +// the 4th argument in R10 (not RCX); the return value comes back in AX. +TEXT ·rawsyscall6(SB), NOSPLIT|NOFRAME, $0-64 + MOVQ trap+0(FP), AX + MOVQ a1+8(FP), DI + MOVQ a2+16(FP), SI + MOVQ a3+24(FP), DX + MOVQ a4+32(FP), R10 + MOVQ a5+40(FP), R8 + MOVQ a6+48(FP), R9 + SYSCALL + MOVQ AX, r1+56(FP) + RET diff --git a/internal/fakecgo/reexec_syscall_arm64.s b/internal/fakecgo/reexec_syscall_arm64.s new file mode 100644 index 0000000..91d861a --- /dev/null +++ b/internal/fakecgo/reexec_syscall_arm64.s @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && arm64 + +#include "textflag.h" + +// func rawsyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1 uintptr) +// +// arm64 Linux syscall: number in R8, arguments in R0-R5, trap via SVC, result +// in R0. See the amd64 counterpart for why this exists. +TEXT ·rawsyscall6(SB), NOSPLIT|NOFRAME, $0-64 + MOVD trap+0(FP), R8 + MOVD a1+8(FP), R0 + MOVD a2+16(FP), R1 + MOVD a3+24(FP), R2 + MOVD a4+32(FP), R3 + MOVD a5+40(FP), R4 + MOVD a6+48(FP), R5 + SVC + MOVD R0, r1+56(FP) + RET diff --git a/internal/fakecgo/reexec_table_amd64.go b/internal/fakecgo/reexec_table_amd64.go new file mode 100644 index 0000000..b2fa9a6 --- /dev/null +++ b/internal/fakecgo/reexec_table_amd64.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && amd64 + +package fakecgo + +// Linux/amd64 syscall numbers used by the re-exec bridge. Only the *at forms +// are used so the same Go logic compiles unchanged on arm64 (see the arm64 +// table), where the legacy open/access/readlink numbers do not exist. +const ( + sysRead = 0 + sysWrite = 1 + sysClose = 3 + sysMmap = 9 + sysExecve = 59 + sysReadlinkat = 267 + sysOpenat = 257 + sysFaccessat = 269 +) + +// Host dynamic loader + libc SONAME, per libc flavor, for amd64. These are the +// only two ABIs goffi's universal build targets. The SONAMEs are passed bare +// to ` --preload `; each loader resolves its own libc through +// its default search path (verified on glibc and musl). +const ( + glibcLoader = "/lib64/ld-linux-x86-64.so.2" + glibcLibc = "libc.so.6" + muslLoader = "/lib/ld-musl-x86_64.so.1" + muslLibc = "libc.musl-x86_64.so.1" +) diff --git a/internal/fakecgo/reexec_table_arm64.go b/internal/fakecgo/reexec_table_arm64.go new file mode 100644 index 0000000..aecc6f0 --- /dev/null +++ b/internal/fakecgo/reexec_table_arm64.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && arm64 + +package fakecgo + +// Linux/arm64 syscall numbers used by the re-exec bridge. arm64 has no legacy +// open/access/readlink syscalls, so the bridge uses the *at forms everywhere +// (with AT_FDCWD); these numbers match the generic syscall table. +const ( + sysRead = 63 + sysWrite = 64 + sysClose = 57 + sysMmap = 222 + sysExecve = 221 + sysReadlinkat = 78 + sysOpenat = 56 + sysFaccessat = 48 +) + +// Host dynamic loader + libc SONAME, per libc flavor, for arm64. +const ( + glibcLoader = "/lib/ld-linux-aarch64.so.1" + glibcLibc = "libc.so.6" + muslLoader = "/lib/ld-musl-aarch64.so.1" + muslLibc = "libc.musl-aarch64.so.1" +) diff --git a/internal/fakecgo/symbols_linux.go b/internal/fakecgo/symbols_linux.go index 58506b7..2cc4b96 100644 --- a/internal/fakecgo/symbols_linux.go +++ b/internal/fakecgo/symbols_linux.go @@ -4,7 +4,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && !android && !goffi_musl +//go:build !cgo && !android && !goffi_musl && !goffi_universal package fakecgo diff --git a/internal/fakecgo/symbols_musl_amd64.go b/internal/fakecgo/symbols_musl_amd64.go index c0a451c..ca488ba 100644 --- a/internal/fakecgo/symbols_musl_amd64.go +++ b/internal/fakecgo/symbols_musl_amd64.go @@ -4,7 +4,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && !android && goffi_musl && amd64 +//go:build !cgo && !android && goffi_musl && !goffi_universal && amd64 package fakecgo diff --git a/internal/fakecgo/symbols_musl_arm64.go b/internal/fakecgo/symbols_musl_arm64.go index b7dbac0..0addd2e 100644 --- a/internal/fakecgo/symbols_musl_arm64.go +++ b/internal/fakecgo/symbols_musl_arm64.go @@ -4,7 +4,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && !android && goffi_musl && arm64 +//go:build !cgo && !android && goffi_musl && !goffi_universal && arm64 package fakecgo diff --git a/internal/fakecgo/symbols_universal.go b/internal/fakecgo/symbols_universal.go new file mode 100644 index 0000000..5f3479b --- /dev/null +++ b/internal/fakecgo/symbols_universal.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal + +// fakecgo dynamic imports for the portable "universal" Linux build. +// +// This mirrors symbols_linux.go but imports every libc entry point with an +// EMPTY SONAME, so the binary carries no DT_NEEDED and is not tied to glibc or +// musl. The symbols bind from the host libc after the re-exec bridge in +// reexec_universal_linux.go runs (before the Go runtime touches any of them). +// +// pthread_get_stacksize_np is intentionally absent: it is a Darwin-only API +// that neither glibc nor musl exports. glibc binds lazily, so the default +// build gets away with importing a stub nobody calls on Linux; musl binds +// eagerly. A universal binary must be loadable under either libc, so it must +// not name a symbol that musl cannot resolve. The stub's only caller is the +// Darwin thread-entry path, dead-code-eliminated on Linux. + +package fakecgo + +//go:cgo_import_dynamic goffi_malloc malloc "" +//go:cgo_import_dynamic goffi_free free "" +//go:cgo_import_dynamic goffi_setenv setenv "" +//go:cgo_import_dynamic goffi_unsetenv unsetenv "" +//go:cgo_import_dynamic goffi_sigfillset sigfillset "" +//go:cgo_import_dynamic goffi_nanosleep nanosleep "" +//go:cgo_import_dynamic goffi_abort abort "" +//go:cgo_import_dynamic goffi_sigaltstack sigaltstack "" +//go:cgo_import_dynamic goffi_pthread_attr_init pthread_attr_init "" +//go:cgo_import_dynamic goffi_pthread_create pthread_create "" +//go:cgo_import_dynamic goffi_pthread_detach pthread_detach "" +//go:cgo_import_dynamic goffi_pthread_sigmask pthread_sigmask "" +//go:cgo_import_dynamic goffi_pthread_self pthread_self "" +//go:cgo_import_dynamic goffi_pthread_attr_getstacksize pthread_attr_getstacksize "" +//go:cgo_import_dynamic goffi_pthread_attr_setstacksize pthread_attr_setstacksize "" +//go:cgo_import_dynamic goffi_pthread_attr_destroy pthread_attr_destroy "" +//go:cgo_import_dynamic goffi_pthread_mutex_lock pthread_mutex_lock "" +//go:cgo_import_dynamic goffi_pthread_mutex_unlock pthread_mutex_unlock "" +//go:cgo_import_dynamic goffi_pthread_cond_broadcast pthread_cond_broadcast "" +//go:cgo_import_dynamic goffi_pthread_setspecific pthread_setspecific "" + +// No DT_NEEDED force-line, by design (see internal/dl/dl_universal.go). diff --git a/internal/syscall/errno_linux.go b/internal/syscall/errno_linux.go index ac83af2..123905a 100644 --- a/internal/syscall/errno_linux.go +++ b/internal/syscall/errno_linux.go @@ -1,4 +1,4 @@ -//go:build linux && !android && (amd64 || arm64) && !goffi_static && !goffi_musl +//go:build linux && !android && (amd64 || arm64) && !goffi_static && !goffi_musl && !goffi_universal package syscall diff --git a/internal/syscall/errno_musl_amd64.go b/internal/syscall/errno_musl_amd64.go index 6df94dd..216387a 100644 --- a/internal/syscall/errno_musl_amd64.go +++ b/internal/syscall/errno_musl_amd64.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors -//go:build linux && !android && !goffi_static && goffi_musl && amd64 +//go:build linux && !android && !goffi_static && goffi_musl && !goffi_universal && amd64 // musl flavor of errno_linux.go: same symbol, different SONAME. musl // exports __errno_location from its single libc object (errno itself lives diff --git a/internal/syscall/errno_musl_arm64.go b/internal/syscall/errno_musl_arm64.go index 99ca728..a70d504 100644 --- a/internal/syscall/errno_musl_arm64.go +++ b/internal/syscall/errno_musl_arm64.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors -//go:build linux && !android && !goffi_static && goffi_musl && arm64 +//go:build linux && !android && !goffi_static && goffi_musl && !goffi_universal && arm64 // musl flavor of errno_linux.go: same symbol, different SONAME. musl // exports __errno_location from its single libc object (errno itself lives diff --git a/internal/syscall/errno_universal.go b/internal/syscall/errno_universal.go new file mode 100644 index 0000000..a2c4e29 --- /dev/null +++ b/internal/syscall/errno_universal.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && (amd64 || arm64) && !goffi_static && goffi_universal + +// __errno_location for the portable "universal" Linux build. +// +// Both glibc and musl export __errno_location under that exact name, so the +// only thing that differs between them is the SONAME the symbol is imported +// from. Importing it with an empty library name (no DT_NEEDED) makes this one +// binary bind __errno_location from whichever libc the host provides, after +// the fakecgo re-exec bridge maps it. See internal/dl/dl_universal.go and +// docs/PROFILE_U.md. + +package syscall + +//go:cgo_import_dynamic goffi_errno_location __errno_location "" From 60ee648e80d81973eb4b1068815f987009d82e5d Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Tue, 1 Sep 2026 23:55:18 +0000 Subject: [PATCH 07/24] =?UTF-8?q?WIP(2):=20universal=20re-exec=20bridge=20?= =?UTF-8?q?logic,=20tooling,=20probe=20=E2=80=94=20compiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delta over the first WIP commit. Everything here compiles under CGO_ENABLED=0 in all modes (default / goffi_universal / goffi_static; goffi_musl with its usual -gcflags). The universal runtime path is NOT working yet: a TLS-before- setup blocker is diagnosed and documented, with the fix designed but not landed. New: - internal/fakecgo/reexec_universal_linux.go Full libc-free early re-exec: mmap scratch, read /proc/self/{environ, cmdline}, resolve host loader (glibc-first, musl fallback), build argv/ envp, execve( --preload /proc/self/exe ...). Uses only raw syscalls; pointer-global stores avoided (uintptr) to dodge GC write barriers this early. Carries a KNOWN-ISSUE note: on the first (kernel- direct) launch %fs/TLS is not set up (rt0_go delegates TLS to _cgo_init), so the compiler's post-ABI0-call "MOVQ FS:-8, R14" g-reload faults before execve. Fix in progress: a per-arch setupUniversalTLS asm shim. - internal/fakecgo/reexec_noop.go no-op bridge for non-universal linux - cmd/universal-probe/main.go runtime FFI probe (host libc auto-detect) - cmd/goffi-strip-interp/main.go PT_INTERP -> PT_NULL post-link tool - scripts/build-universal.sh build + strip-interp helper Changed: - internal/fakecgo/go_linux_{amd64,arm64}.go call maybeReexecUniversal() at the very top of x_cgo_init (before malloc) Diagnosis captured (asm_amd64.s rt0_go: "JZ needtls" only when _cgo_init==nil) so the remaining work is well-scoped. --- cmd/goffi-strip-interp/main.go | 109 +++++++ cmd/universal-probe/main.go | 193 ++++++++++++ internal/fakecgo/go_linux_amd64.go | 6 + internal/fakecgo/go_linux_arm64.go | 6 + internal/fakecgo/reexec_noop.go | 13 + internal/fakecgo/reexec_universal_linux.go | 322 +++++++++++++++++++++ scripts/build-universal.sh | 50 ++++ 7 files changed, 699 insertions(+) create mode 100644 cmd/goffi-strip-interp/main.go create mode 100644 cmd/universal-probe/main.go create mode 100644 internal/fakecgo/reexec_noop.go create mode 100644 internal/fakecgo/reexec_universal_linux.go create mode 100755 scripts/build-universal.sh diff --git a/cmd/goffi-strip-interp/main.go b/cmd/goffi-strip-interp/main.go new file mode 100644 index 0000000..b7f9e32 --- /dev/null +++ b/cmd/goffi-strip-interp/main.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Command goffi-strip-interp removes the ELF program interpreter (PT_INTERP) +// from a binary produced with -tags goffi_universal. +// +// The Go linker always writes the default glibc interpreter path, which does +// not exist on a musl-only system, so the kernel would refuse to exec the +// binary there. Flipping the PT_INTERP program header to PT_NULL makes the +// kernel load the binary directly on every distribution (as it does for fully +// static binaries). goffi's universal build then brings up libc itself, by +// re-executing through the host's own loader -- see docs/PROFILE_U.md. +// +// This is deliberately a tiny, self-contained ELF edit (no external tools): +// find the PT_INTERP entry in the program header table and zero its p_type. +// Nothing else in the file is touched; the (now unreferenced) .interp bytes +// are harmless. +// +// Usage: +// +// goffi-strip-interp [...] +package main + +import ( + "debug/elf" + "encoding/binary" + "fmt" + "io" + "os" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: goffi-strip-interp [...]") + os.Exit(2) + } + status := 0 + for _, path := range os.Args[1:] { + if err := stripInterp(path); err != nil { + fmt.Fprintf(os.Stderr, "goffi-strip-interp: %s: %v\n", path, err) + status = 1 + continue + } + fmt.Printf("goffi-strip-interp: %s: PT_INTERP removed\n", path) + } + os.Exit(status) +} + +func stripInterp(path string) error { + // Read enough of the ELF header to locate the program header table. + f, err := elf.Open(path) + if err != nil { + return err + } + class := f.Class + byteOrder := f.ByteOrder + interpIdx := -1 + for i, p := range f.Progs { + if p.Type == elf.PT_INTERP { + interpIdx = i + break + } + } + f.Close() + + if interpIdx < 0 { + return nil // already interpreter-less; nothing to do + } + + fh, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return err + } + defer fh.Close() + + // Re-read the raw ELF header fields we need. Offsets are fixed by the ELF + // spec and differ between the 32- and 64-bit forms. + hdr := make([]byte, 64) + if _, err := io.ReadFull(fh, hdr); err != nil { + return err + } + var phoff int64 + var phentsize, phnum int + switch class { + case elf.ELFCLASS64: + phoff = int64(byteOrder.Uint64(hdr[0x20:])) + phentsize = int(byteOrder.Uint16(hdr[0x36:])) + phnum = int(byteOrder.Uint16(hdr[0x38:])) + case elf.ELFCLASS32: + phoff = int64(byteOrder.Uint32(hdr[0x1c:])) + phentsize = int(byteOrder.Uint16(hdr[0x2a:])) + phnum = int(byteOrder.Uint16(hdr[0x2c:])) + default: + return fmt.Errorf("unsupported ELF class %v", class) + } + if interpIdx >= phnum { + return fmt.Errorf("PT_INTERP index %d out of range (phnum=%d)", interpIdx, phnum) + } + + // p_type is the first 4 bytes of every program header entry, in both the + // 32- and 64-bit layouts. Overwrite it with PT_NULL (0). + off := phoff + int64(interpIdx)*int64(phentsize) + zero := make([]byte, 4) + binary.LittleEndian.PutUint32(zero, uint32(elf.PT_NULL)) // 0; endianness irrelevant for 0 + if _, err := fh.WriteAt(zero, off); err != nil { + return err + } + return nil +} diff --git a/cmd/universal-probe/main.go b/cmd/universal-probe/main.go new file mode 100644 index 0000000..d9dc937 --- /dev/null +++ b/cmd/universal-probe/main.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Command universal-probe is the runtime half of the goffi_universal +// verification. One binary, built once with -tags goffi_universal and its +// PT_INTERP stripped, is expected to pass identically on a glibc host and on a +// musl host -- proving the re-exec-through-host-loader bridge really does bring +// up whichever libc is present. +// +// The checks mirror cmd/musl-probe: LoadLibrary (internal/dl), a float and an +// integer call (the call path), getpid against ground truth, a qsort callback +// (crosscall2), and a goroutine hammer that forces the runtime to spawn OS +// threads through fakecgo's pthread_create. The libc is loaded by whichever +// SONAME matches the host, discovered the same way the re-exec bridge does. +// +// Exit status 0 and a final UNIVERSAL-PROBE-OK line mean every check passed. +package main + +import ( + "fmt" + "math" + "os" + "runtime" + "sort" + "sync" + "syscall" + "unsafe" + + "github.com/go-webgpu/goffi/ffi" + "github.com/go-webgpu/goffi/types" +) + +// hostLibc returns the libc SONAME for the running host, chosen exactly like +// the re-exec bridge: prefer glibc if its loader is present, else musl. +func hostLibc() string { + type pair struct{ loader, soname string } + var glibc, musl pair + switch runtime.GOARCH { + case "amd64": + glibc = pair{"/lib64/ld-linux-x86-64.so.2", "libc.so.6"} + musl = pair{"/lib/ld-musl-x86_64.so.1", "libc.musl-x86_64.so.1"} + case "arm64": + glibc = pair{"/lib/ld-linux-aarch64.so.1", "libc.so.6"} + musl = pair{"/lib/ld-musl-aarch64.so.1", "libc.musl-aarch64.so.1"} + default: + return "" + } + if _, err := os.Stat(glibc.loader); err == nil { + return glibc.soname + } + if _, err := os.Stat(musl.loader); err == nil { + return musl.soname + } + return "" +} + +var failed bool + +func check(name string, ok bool, detail string) { + if ok { + fmt.Printf("ok %-20s %s\n", name, detail) + return + } + failed = true + fmt.Printf("FAIL %-20s %s\n", name, detail) +} + +func mustSym(handle unsafe.Pointer, name string) unsafe.Pointer { + sym, err := ffi.GetSymbol(handle, name) + if err != nil { + fmt.Printf("FAIL GetSymbol(%s): %v\n", name, err) + os.Exit(1) + } + return sym +} + +func mustCIF(ret *types.TypeDescriptor, args ...*types.TypeDescriptor) *types.CallInterface { + cif := &types.CallInterface{} + if err := ffi.PrepareCallInterface(cif, types.DefaultCall, ret, args); err != nil { + fmt.Printf("FAIL PrepareCallInterface: %v\n", err) + os.Exit(1) + } + return cif +} + +func main() { + reexeced := os.Getenv("GOFFI_UNIVERSAL_REEXEC") == "1" + fmt.Printf("info re-exec bridge active: %v\n", reexeced) + + lib := hostLibc() + if lib == "" { + fmt.Printf("FAIL no known host libc for GOARCH %s\n", runtime.GOARCH) + os.Exit(1) + } + + handle, err := ffi.LoadLibrary(lib) + if err != nil { + fmt.Printf("FAIL LoadLibrary(%s): %v\n", lib, err) + os.Exit(1) + } + defer ffi.FreeLibrary(handle) + check("LoadLibrary", true, lib) + + // sqrt(2.0): double(double) -- FP register path. + sqrtFn := mustSym(handle, "sqrt") + sqrtCIF := mustCIF(types.DoubleTypeDescriptor, types.DoubleTypeDescriptor) + arg := 2.0 + var root float64 + if _, err := ffi.CallFunction(sqrtCIF, sqrtFn, + unsafe.Pointer(&root), []unsafe.Pointer{unsafe.Pointer(&arg)}); err != nil { + fmt.Printf("FAIL CallFunction(sqrt): %v\n", err) + os.Exit(1) + } + check("sqrt(2.0)", math.Abs(root-math.Sqrt2) < 1e-12, fmt.Sprintf("= %v", root)) + + // strlen: size_t(char*) -- integer return. + strlenFn := mustSym(handle, "strlen") + strlenCIF := mustCIF(types.UInt64TypeDescriptor, types.PointerTypeDescriptor) + s := "goffi universal\x00" + sp := unsafe.Pointer(unsafe.StringData(s)) + var n uint64 + if _, err := ffi.CallFunction(strlenCIF, strlenFn, + unsafe.Pointer(&n), []unsafe.Pointer{unsafe.Pointer(&sp)}); err != nil { + fmt.Printf("FAIL CallFunction(strlen): %v\n", err) + os.Exit(1) + } + check("strlen", n == uint64(len(s)-1), fmt.Sprintf("= %d", n)) + + // getpid: checkable against the Go side. + getpidFn := mustSym(handle, "getpid") + getpidCIF := mustCIF(types.SInt32TypeDescriptor) + var pid int32 + if _, err := ffi.CallFunction(getpidCIF, getpidFn, unsafe.Pointer(&pid), nil); err != nil { + fmt.Printf("FAIL CallFunction(getpid): %v\n", err) + os.Exit(1) + } + check("getpid", int(pid) == syscall.Getpid(), fmt.Sprintf("= %d", pid)) + + // qsort with a Go comparator: C-to-Go callback via crosscall2. + data := []int32{5, 3, 8, 1, 9, 2, 7, 4, 6, 0} + cmp := ffi.NewCallback(func(a, b unsafe.Pointer) uintptr { + x := *(*int32)(a) + y := *(*int32)(b) + switch { + case x < y: + return uintptr(^uint(0)) // -1 + case x > y: + return 1 + default: + return 0 + } + }) + qsortFn := mustSym(handle, "qsort") + qsortCIF := mustCIF(types.VoidTypeDescriptor, + types.PointerTypeDescriptor, types.UInt64TypeDescriptor, + types.UInt64TypeDescriptor, types.PointerTypeDescriptor) + base := unsafe.Pointer(&data[0]) + count := uint64(len(data)) + size := uint64(unsafe.Sizeof(data[0])) + if _, err := ffi.CallFunction(qsortCIF, qsortFn, nil, []unsafe.Pointer{ + unsafe.Pointer(&base), unsafe.Pointer(&count), + unsafe.Pointer(&size), unsafe.Pointer(&cmp), + }); err != nil { + fmt.Printf("FAIL CallFunction(qsort): %v\n", err) + os.Exit(1) + } + check("qsort callback", sort.SliceIsSorted(data, func(i, j int) bool { return data[i] < data[j] }), + fmt.Sprintf("%v", data)) + + // Goroutine hammer: force the runtime to create OS threads, which under a + // cgo-enabled runtime go through fakecgo's pthread_create against the + // host libc that the re-exec bridge pre-loaded. + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(1) + go func() { + defer wg.Done() + runtime.LockOSThread() + var m uint64 + ffi.CallFunction(strlenCIF, strlenFn, + unsafe.Pointer(&m), []unsafe.Pointer{unsafe.Pointer(&sp)}) + runtime.UnlockOSThread() + }() + } + wg.Wait() + check("thread hammer", true, "32 goroutines locked to OS threads") + + if failed { + fmt.Println("UNIVERSAL-PROBE-FAILED") + os.Exit(1) + } + fmt.Println("UNIVERSAL-PROBE-OK") +} diff --git a/internal/fakecgo/go_linux_amd64.go b/internal/fakecgo/go_linux_amd64.go index 198f2dc..fb83901 100644 --- a/internal/fakecgo/go_linux_amd64.go +++ b/internal/fakecgo/go_linux_amd64.go @@ -61,6 +61,12 @@ var setg_func uintptr //go:nosplit func x_cgo_init(g *G, setg uintptr) { + // Portable universal build: before touching any libc symbol (malloc + // below is the first), re-exec through the host loader with libc + // pre-loaded. No-op in the default and goffi_musl builds. See + // reexec_universal_linux.go. + maybeReexecUniversal() + var size size_t var attr *pthread_attr_t diff --git a/internal/fakecgo/go_linux_arm64.go b/internal/fakecgo/go_linux_arm64.go index fb45f64..8b49057 100644 --- a/internal/fakecgo/go_linux_arm64.go +++ b/internal/fakecgo/go_linux_arm64.go @@ -66,6 +66,12 @@ var setg_func uintptr // //go:nosplit func x_cgo_init(g *G, setg uintptr) { + // Portable universal build: before touching any libc symbol (malloc + // below is the first), re-exec through the host loader with libc + // pre-loaded. No-op in the default and goffi_musl builds. See + // reexec_universal_linux.go. + maybeReexecUniversal() + var size size_t var attr *pthread_attr_t diff --git a/internal/fakecgo/reexec_noop.go b/internal/fakecgo/reexec_noop.go new file mode 100644 index 0000000..8642e4c --- /dev/null +++ b/internal/fakecgo/reexec_noop.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && !goffi_universal + +package fakecgo + +// maybeReexecUniversal is a no-op outside the goffi_universal build. The +// default (glibc) and goffi_musl binaries name their libc via DT_NEEDED and +// are launched normally by the host loader, so there is nothing to bridge. +// +//go:nosplit +func maybeReexecUniversal() {} diff --git a/internal/fakecgo/reexec_universal_linux.go b/internal/fakecgo/reexec_universal_linux.go new file mode 100644 index 0000000..647ab0e --- /dev/null +++ b/internal/fakecgo/reexec_universal_linux.go @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal + +// Portable "universal" re-exec bridge. +// +// A universal goffi binary imports every libc symbol with an empty SONAME, so +// it carries no DT_NEEDED and its PT_INTERP is stripped after linking. That +// makes it loadable by the kernel on any Linux distribution, but it also means +// nothing has mapped a libc into the process: the undefined dlopen/malloc/ +// pthread_* symbols are unbound. The first thing that would touch libc is the +// malloc() at the top of x_cgo_init, which runs from rt0_go before the Go +// scheduler, before runtime.args, and before any OS thread is created. +// +// So, at the very top of x_cgo_init, we re-exec the process through the host's +// own dynamic loader with the host libc pre-loaded: +// +// execve(, {, "--preload", , +// , }, +guard) +// +// The host loader maps its libc, the global symbol scope now contains malloc, +// dlopen, pthread_*, __errno_location, and the re-executed process binds them +// from whichever libc the host actually ships (glibc's libc.so.6 or musl's +// libc.musl-.so.1). A guard variable in the environment stops the second +// launch from re-execing again. +// +// Everything here runs before libc exists, so it uses only raw syscalls +// (rawsyscall6) and mmap'd scratch memory -- never the Go heap, never libc, +// never anything that can grow the stack. String constants live in rodata and +// are copied into an mmap staging buffer with NUL terminators as needed +// (cstr), because C wants NUL-terminated strings and Go string literals are +// not. Argv and envp for the re-exec are read verbatim from /proc/self/cmdline +// and /proc/self/environ (both already NUL-delimited) and merely pointer-ified +// in place. +// +// Failure is best-effort: if no known host loader is found, or execve fails, +// we return and let startup proceed. FFI then cannot work (there is no libc), +// which is the documented limitation of running a universal binary on a system +// whose loader we do not recognise. + +package fakecgo + +// KNOWN ISSUE (work in progress): this bridge does not run yet on the very +// first (kernel-direct) launch. When _cgo_init is present, rt0_go SKIPS the +// runtime's own TLS setup and delegates it to _cgo_init (see runtime/asm_amd64.s: +// the "JZ needtls" is only taken when _cgo_init is nil). So at x_cgo_init time, +// on a no-interpreter binary the kernel loaded directly, the %fs/TLS base is +// not initialised. The Go compiler emits `MOVQ FS:-8, R14` (reload the g +// register) after every call to an ABI0 assembly function, and that TLS read +// faults before we reach execve. +// +// Fix in progress: a tiny per-arch asm shim (setupUniversalTLS) called as the +// first thing in x_cgo_init on the universal build, which, only when no TLS is +// set up yet (first launch), points %fs (amd64) / TPIDR_EL0 (arm64) at a +// scratch page so the g-reloads read mapped memory. On the re-executed launch +// the host loader sets up real TLS, so the shim is a no-op. The code below is +// otherwise complete and compiles; it is exercised end-to-end once the shim +// lands. See the conversation notes / docs/PROFILE_U.md (pending). + +import "unsafe" + +func rawsyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1 uintptr) + +const ( + atFDCWD = ^uintptr(99) // AT_FDCWD (-100) as unsigned + oRDONLY = 0 + fOK = 0 + protRW = 0x3 // PROT_READ | PROT_WRITE + mapPA = 0x22 // MAP_PRIVATE | MAP_ANONYMOUS + ptrSize = 8 // universal build is 64-bit only + + envBufCap = 256 << 10 + cmdBufCap = 256 << 10 + exeBufCap = 4 << 10 + strBufCap = 8 << 10 + ptrArrCap = 4 << 10 // max pointers per argv/envp array (incl. nil terminator) + ptrArrSize = ptrArrCap * ptrSize + + guardVar = "GOFFI_UNIVERSAL_REEXEC=1" + guardKey = "GOFFI_UNIVERSAL_REEXEC=" +) + +// Bump allocator over an mmap staging buffer for NUL-terminated C strings. +// Set once, at the start of maybeReexecUniversal; single-threaded at that point. +var ( + strBufBase uintptr // base of mmap staging buffer (uintptr: no GC write barrier) + strBufOff uintptr +) + +//go:nosplit +func sysErr(r uintptr) bool { return r > ^uintptr(4095) } // r in [-4095, -1] + +//go:nosplit +func mmapAnon(n uintptr) unsafe.Pointer { + r := rawsyscall6(sysMmap, 0, n, protRW, mapPA, ^uintptr(0) /* fd -1 */, 0) + if sysErr(r) { + return nil + } + return unsafe.Pointer(r) +} + +// cstr copies s into the staging buffer, appends a NUL, and returns a *byte to +// the copy. Returns nil if the staging buffer is exhausted. +// +//go:nosplit +func cstr(s string) *byte { + n := uintptr(len(s)) + if strBufBase == 0 || strBufOff+n+1 > strBufCap { + return nil + } + start := strBufOff + for i := uintptr(0); i < n; i++ { + *(*byte)(unsafe.Add(unsafe.Pointer(strBufBase), start+i)) = s[i] + } + *(*byte)(unsafe.Add(unsafe.Pointer(strBufBase), start+n)) = 0 + strBufOff = start + n + 1 + return (*byte)(unsafe.Add(unsafe.Pointer(strBufBase), start)) +} + +//go:nosplit +func fileExists(pathC *byte) bool { + if pathC == nil { + return false + } + r := rawsyscall6(sysFaccessat, atFDCWD, uintptr(unsafe.Pointer(pathC)), fOK, 0, 0, 0) + return r == 0 +} + +// readAll reads the whole file at pathC into [base, base+capBytes), returning +// the byte count, or -1 on error. +// +//go:nosplit +func readAll(pathC *byte, base unsafe.Pointer, capBytes uintptr) int { + fd := rawsyscall6(sysOpenat, atFDCWD, uintptr(unsafe.Pointer(pathC)), oRDONLY, 0, 0, 0) + if sysErr(fd) { + return -1 + } + var total uintptr + for total < capBytes { + n := rawsyscall6(sysRead, fd, uintptr(unsafe.Add(base, total)), capBytes-total, 0, 0, 0) + if sysErr(n) { + rawsyscall6(sysClose, fd, 0, 0, 0, 0, 0) + return -1 + } + if n == 0 { + break + } + total += n + } + rawsyscall6(sysClose, fd, 0, 0, 0, 0, 0) + return int(total) +} + +//go:nosplit +func setPtr(base unsafe.Pointer, idx int, val uintptr) { + *(*uintptr)(unsafe.Add(base, uintptr(idx)*ptrSize)) = val +} + +// matchAt reports whether the NUL-delimited entry starting at base+off begins +// with key. +// +//go:nosplit +func matchAt(base unsafe.Pointer, off, length int, key string) bool { + if off+len(key) > length { + return false + } + for i := 0; i < len(key); i++ { + if *(*byte)(unsafe.Add(base, off+i)) != key[i] { + return false + } + } + return true +} + +// diag writes a short message to stderr (best effort). write(2) takes a +// pointer+length, so no NUL is needed and the message can live in rodata -- +// this works even before the cstr staging buffer is set up. +// +//go:nosplit +func diag(msg string) { + rawsyscall6(sysWrite, 2, uintptr(unsafe.Pointer(unsafe.StringData(msg))), uintptr(len(msg)), 0, 0, 0) +} + +// maybeReexecUniversal is invoked at the very top of x_cgo_init. On the first +// launch of a universal binary it re-execs through the host loader with libc +// pre-loaded; on the re-executed launch (guard present) it returns immediately. +// +//go:nosplit +func maybeReexecUniversal() { + // Staging buffer for C strings first: everything below needs cstr(). + sb := mmapAnon(strBufCap) + if sb == nil { + return + } + strBufBase = uintptr(sb) + strBufOff = 0 + + envBase := mmapAnon(envBufCap) + if envBase == nil { + return + } + envLen := readAll(cstr("/proc/self/environ"), envBase, envBufCap) + if envLen < 0 { + return + } + + // Guard: if we already re-executed, do nothing. + for off := 0; off < envLen; { + if matchAt(envBase, off, envLen, guardKey) { + return + } + for off < envLen && *(*byte)(unsafe.Add(envBase, off)) != 0 { + off++ + } + off++ // skip NUL + } + + // Pick the host loader + libc SONAME by probing known loader paths. + // Prefer glibc when both are present; fall back to musl. + var loaderC, sonameC *byte + if g := cstr(glibcLoader); fileExists(g) { + loaderC = g + sonameC = cstr(glibcLibc) + } else if m := cstr(muslLoader); fileExists(m) { + loaderC = m + sonameC = cstr(muslLibc) + } + if loaderC == nil || sonameC == nil { + diag("goffi: universal build: no known host dynamic loader found; FFI unavailable\n") + return + } + + // Resolve our own executable path for the loader to run. + exeBase := mmapAnon(exeBufCap) + var exeC *byte + if exeBase != nil { + n := rawsyscall6(sysReadlinkat, atFDCWD, + uintptr(unsafe.Pointer(cstr("/proc/self/exe"))), + uintptr(exeBase), exeBufCap-1, 0, 0) + if !sysErr(n) && n != 0 { + *(*byte)(unsafe.Add(exeBase, n)) = 0 + exeC = (*byte)(exeBase) + } + } + if exeC == nil { + exeC = cstr("/proc/self/exe") + } + + // Read original argv from /proc/self/cmdline (NUL-delimited). + cmdBase := mmapAnon(cmdBufCap) + if cmdBase == nil { + return + } + cmdLen := readAll(cstr("/proc/self/cmdline"), cmdBase, cmdBufCap) + if cmdLen < 0 { + return + } + + // Build argv: {loader, "--preload", soname, self, , NULL} + argvBase := mmapAnon(ptrArrSize) + envpBase := mmapAnon(ptrArrSize) + if argvBase == nil || envpBase == nil { + return + } + preloadC := cstr("--preload") + if preloadC == nil { + return + } + ai := 0 + setPtr(argvBase, ai, uintptr(unsafe.Pointer(loaderC))) + ai++ + setPtr(argvBase, ai, uintptr(unsafe.Pointer(preloadC))) + ai++ + setPtr(argvBase, ai, uintptr(unsafe.Pointer(sonameC))) + ai++ + setPtr(argvBase, ai, uintptr(unsafe.Pointer(exeC))) + ai++ + // Append original args, skipping argv[0] (the program name) since exeC + // already occupies argv[0] of the re-executed program. + { + off := 0 + // skip argv[0] + for off < cmdLen && *(*byte)(unsafe.Add(cmdBase, off)) != 0 { + off++ + } + off++ // skip its NUL + for off < cmdLen && ai < ptrArrCap-1 { + setPtr(argvBase, ai, uintptr(unsafe.Add(cmdBase, off))) + ai++ + for off < cmdLen && *(*byte)(unsafe.Add(cmdBase, off)) != 0 { + off++ + } + off++ // skip NUL + } + } + setPtr(argvBase, ai, 0) // NULL-terminate argv + + // Build envp: + guard, NULL-terminated. + ei := 0 + for off := 0; off < envLen && ei < ptrArrCap-2; { + setPtr(envpBase, ei, uintptr(unsafe.Add(envBase, off))) + ei++ + for off < envLen && *(*byte)(unsafe.Add(envBase, off)) != 0 { + off++ + } + off++ // skip NUL + } + if g := cstr(guardVar); g != nil { + setPtr(envpBase, ei, uintptr(unsafe.Pointer(g))) + ei++ + } + setPtr(envpBase, ei, 0) // NULL-terminate envp + + rawsyscall6(sysExecve, + uintptr(unsafe.Pointer(loaderC)), + uintptr(argvBase), + uintptr(envpBase), 0, 0, 0) + + // Only reached if execve failed. + diag("goffi: universal build: re-exec through host loader failed; FFI unavailable\n") +} diff --git a/scripts/build-universal.sh b/scripts/build-universal.sh new file mode 100755 index 0000000..888e93a --- /dev/null +++ b/scripts/build-universal.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors +# +# build-universal.sh -- build a portable "universal" goffi binary that runs +# FFI on both glibc and musl systems from a single artifact. +# +# It builds with -tags goffi_universal and CGO_ENABLED=0 (the whole point is a +# fully portable, C-toolchain-free binary), then strips the ELF interpreter so +# the kernel loads the binary directly everywhere. At startup goffi re-execs +# through the host's own dynamic loader with the host libc pre-loaded; see +# docs/PROFILE_U.md. +# +# Usage: +# scripts/build-universal.sh -o [extra go build args...] +# +# Example: +# scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe +set -euo pipefail + +out="" +args=() +while [[ $# -gt 0 ]]; do + case "$1" in + -o) + out="$2" + shift 2 + ;; + *) + args+=("$1") + shift + ;; + esac +done + +if [[ -z "$out" || ${#args[@]} -eq 0 ]]; then + echo "usage: $0 -o [go build args...]" >&2 + exit 2 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "==> building $out (goffi_universal, CGO_ENABLED=0)" +CGO_ENABLED=0 go build -tags goffi_universal -o "$out" "${args[@]}" + +echo "==> stripping PT_INTERP" +go run "$repo_root/cmd/goffi-strip-interp" "$out" + +echo "==> done: $out" +echo " Verify the Profile U contract with: go run ./cmd/goffi-audit $out" From 9734459dd1fd88178820f375ae65041b3c15a461 Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Wed, 2 Sep 2026 00:02:03 +0000 Subject: [PATCH 08/24] =?UTF-8?q?docs:=20add=20PROFILE=5FU=5FPLAN.md=20?= =?UTF-8?q?=E2=80=94=20full=20task/plan/status=20hand-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained continuation document so another contributor (or a smaller model) can pick the work up without the original conversation: - the task and its constraints (single portable CGO-free binary running FFI on both glibc and musl; no purego conflict; both-libc CI; branch delivery); - the validated design (empty-SONAME imports -> no DT_NEEDED, strip PT_INTERP, early re-exec through the host loader with --preload), with the empirical evidence on glibc and musl; - THE current blocker and its designed fix: on the first kernel-direct launch %fs/TLS is not set up (rt0_go delegates TLS to _cgo_init), so the compiler's post-ABI0-call g-reload faults before execve; fix = a per-arch setupUniversalTLS asm shim called first in x_cgo_init; - a file-by-file account of what already exists and compiles; - the ordered remaining work (loader package + public API, goffi-audit, purego coexistence job, tests, both-libc CI, docs/attribution); - build/test commands, honesty notes, and delivery/push instructions; - a "START HERE" pointer to the immediate next action. Docs-only; no code changes. --- docs/PROFILE_U_PLAN.md | 407 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 docs/PROFILE_U_PLAN.md diff --git a/docs/PROFILE_U_PLAN.md b/docs/PROFILE_U_PLAN.md new file mode 100644 index 0000000..a52854c --- /dev/null +++ b/docs/PROFILE_U_PLAN.md @@ -0,0 +1,407 @@ +# Profile U — universal (glibc + musl) FFI: task, plan, and status + +> This document is a self-contained hand-off. It is written so that a new +> contributor (or a smaller model) can continue the work without access to the +> original conversation. It states the goal, the full end-to-end plan, exactly +> what is already done, the one blocker currently in the way (with its designed +> fix), and the precise next steps with commands. +> +> **Status: work in progress. The universal runtime path does not work yet.** +> Everything compiles; the blocker is diagnosed and the fix is designed but not +> yet landed. See "START HERE" for the immediate next action. + +--- + +## 1. The task (what we are building and why) + +goffi is a pure-Go, CGO-free FFI library (a fork of ebitengine/purego, module +path `github.com/go-webgpu/goffi`). On Linux it makes C calls by importing libc +symbols via `//go:cgo_import_dynamic`, which makes the Go linker emit a +`DT_NEEDED` for a specific libc SONAME plus a fixed `PT_INTERP`. That pins a +binary to one libc: the default build needs glibc (`libc.so.6`, +`/lib64/ld-linux-x86-64.so.2`), and the `goffi_musl` build is a *separate* +binary for musl (`libc.musl-.so.1`, `/lib/ld-musl-.so.1`). + +**Goal:** port the "Profile U" (universal) idea from `unxed/static-everywhere` +into this fork so that a *single* binary does live, in-process FFI on **both** +glibc and musl systems, with **no C toolchain** (CGO_ENABLED=0 is the whole +point — the result must be fully portable). Concretely the deliverables are: + +1. A `goffi_universal` build that yields one binary running FFI on glibc *and* + musl. +2. Port "the loader" — the Profile U loader concept — in a form appropriate to + goffi (see §4; we do **not** vendor a full in-process ELF loader). +3. Ensure goffi does **not conflict with purego** — specifically the + CGO_ENABLED=0 fakecgo symbol collision (`duplicated definition of symbol + _cgo_init`). See §7. +4. Tests **and CI that exercise both libc** on GitHub. +5. Deliver on a branch of the fork (`feature/profile-u-musl`). + +Non-negotiable constraint from the user: **no cgo.** Center everything on the +portable CGO_ENABLED=0 path. The purego-coexistence guarantee that matters is +therefore the CGO_ENABLED=0 one. + +### What "Profile U" means here (scope decision) + +static-everywhere's Profile U is defined by an auditable ELF contract — +"ET_DYN/EXEC, **no PT_INTERP, no DT_NEEDED**; host libraries reached only +through a carried loader/ABI bridge" — and its own docs endorse, as the +*practical* mechanism, "launch with the host's own loader, found at runtime — +`/lib64/ld-linux-x86-64.so.2`, else `/lib/ld-musl-x86_64.so.1` … one binary +works on either libc." The full in-process foreign-libc loader (`pg83/solo`) is +explicitly declared out of scope there ("multi-year, bad safety story"). + +goffi's need is smaller than static-everywhere's: goffi only needs the *host's +own* libc (glibc on a glibc box, musl on a musl box), not to load glibc +libraries on a musl host. So we adopt exactly the endorsed mechanism and do +**not** vendor SoLo. This is faithful to the source and is what makes the +problem tractable. + +--- + +## 2. The validated design (proven on real glibc and musl) + +Key fact: glibc and musl export the **same libc symbol names** (`dlopen`, +`dlsym`, `__errno_location`, `malloc`, `pthread_*`, …). Only the SONAME strings +and the interpreter path differ. So one binary can bind against either libc if +it (a) names no specific SONAME and (b) is launched so that *some* libc is +mapped and its symbols are visible. + +The mechanism, each part empirically validated in the dev sandbox: + +1. **Empty-SONAME imports → no `DT_NEEDED`.** + `//go:cgo_import_dynamic goffi_dlopen dlopen ""` (empty remote library) emits + an *undefined* dynamic symbol with **no** `DT_NEEDED`. Omitting the + `//go:cgo_import_dynamic _ _ "libc.so.6"` "force-line" is what removes the + `DT_NEEDED`. Verified: the resulting binary names no libc SONAME. + +2. **Strip `PT_INTERP`.** The Go linker still writes the default glibc + interpreter even with empty-SONAME imports. A musl-only box has no + `/lib64/ld-linux-x86-64.so.2`, so the kernel would refuse to exec the binary + there and our code would never run. We therefore flip the `PT_INTERP` + program header to `PT_NULL` after linking (`cmd/goffi-strip-interp`). With no + interpreter the kernel loads the binary directly on every distro (as it does + for fully static binaries), and the Go runtime starts fine (verified: a + no-interp Go program that does not touch libc runs cleanly). + +3. **Early re-exec through the host loader with libc pre-loaded.** A no-DT_NEEDED + binary has unbound libc symbols; something must map a libc. We re-exec the + process through the host's *own* dynamic loader with the host libc + pre-loaded: + + ``` + execve(, + {, "--preload", , , }, + ) + ``` + + The host loader maps its libc; the global symbol scope then contains + `malloc`, `dlopen`, `pthread_*`, `__errno_location`; and the re-executed + process binds them from whichever libc the host ships. **Verified on both + libc:** `ld.so --preload libc.so.6 ./bin` (glibc) and + `ld-musl-x86_64.so.1 --preload libc.musl-x86_64.so.1 ./bin` (musl) both bind + the undefined no-DT_NEEDED symbols and a real libc call returns correctly. + Bare SONAMEs work on both (each loader resolves its own libc via its default + search path). The musl loader supports `--preload`. + + After the re-exec, goffi's existing FFI machinery + fakecgo works unchanged + (independently verified earlier: the default glibc goffi binary ran the full + FFI probe — including a thread hammer — under the musl loader). + +This needs **no in-process ELF loader** (no SoLo): we reuse the host's own +`ld.so`, which is present on every distro. The "carried loader" is just a small +discovery table + the re-exec bridge. + +### Where the re-exec must happen (timing) + +`x_cgo_init` (fakecgo's cgo init) is called from `rt0_go` +(`runtime/asm_amd64.s:190`, `asm_arm64.s:124`) **before** `runtime·args` +(line 338) and **before** the scheduler/sysmon start. Under `iscgo=true`, new +OS threads are created via `_cgo_thread_start` → `pthread_create`, which is +unbound on the first (libc-less) launch. The first libc touch is the `malloc` +at the top of `x_cgo_init`. **Therefore the re-exec must run at the very top of +`x_cgo_init`, before `malloc`,** using only raw syscalls (no libc, no Go heap). +argc/argv are not yet in runtime globals there, so argv/envp are read from +`/proc/self/cmdline` and `/proc/self/environ` (both NUL-delimited) and +pointer-ified in place; scratch memory comes from raw `mmap`. + +--- + +## 3. THE CURRENT BLOCKER and its designed fix (do this first) + +### Symptom +A universal binary (empty-SONAME + stripped interp + re-exec at top of +`x_cgo_init`) **segfaults at startup on the first launch**, before reaching +`execve`. A single early `write(2)` diagnostic prints, then it dies. + +### Root cause (confirmed) +When `_cgo_init` is present, `rt0_go` **skips** the runtime's own TLS setup and +delegates it to `_cgo_init`. In `runtime/asm_amd64.s`, the `JZ needtls` (jump to +the TLS-setup path) is taken **only when `_cgo_init == nil`**. With fakecgo, +`_cgo_init` is non-nil, so `%fs`/TLS is **not** set up when `x_cgo_init` runs. + +The Go compiler emits `MOVQ FS:0xfffffff8, R14` (reload the `g` register from +TLS at `%fs-8`) **after every call to an ABI0 assembly function** (e.g. our +`rawsyscall6`, or fakecgo's `call5`). On a no-interpreter binary the kernel +loaded directly, `%fs` is unset (0), so that TLS read faults. This is why the +first `write` succeeds (the syscall runs) but the instruction right after it +(the g-reload) crashes. In the default/musl builds this never happens because +the host `ld.so` sets up `%fs` before the Go entry point runs. + +Verified with `go tool objdump` on the built binary: the fault is exactly the +`MOVQ FS:-8, R14` after the first ABI0 call. + +### The fix (designed, not yet implemented) +Add a tiny per-arch **assembly** shim `setupUniversalTLS()` and call it as the +**very first statement** of `x_cgo_init` in the universal build, *before* +`maybeReexecUniversal()`. Because it is the first `CALL` in `x_cgo_init` and it +sets `%fs` *before returning*, the compiler's post-call g-reload then reads +mapped memory. The fake `g` value is never dereferenced before `execve` (our +re-exec code uses only raw syscalls and mmap/rodata memory; write barriers are +gated on `runtime.writeBarrier.enabled`, which is false this early — and we +already store the staging-buffer base as `uintptr` to avoid a barrier anyway). +On the **re-executed** launch the host loader sets up real TLS, so the shim is a +no-op. + +Implementation sketch: + +- **amd64** (`setup_universal_tls_amd64.s`, build `... && goffi_universal && amd64`): + ``` + SYS_arch_prctl = 158 ; ARCH_GET_FS = 0x1003 ; ARCH_SET_FS = 0x1002 + SYS_mmap = 9 ; PROT_RW = 3 ; MAP_PRIVATE|ANON = 0x22 ; fd = -1 + // read current fsbase into a stack local via arch_prctl(ARCH_GET_FS, &local) + // if local != 0 -> return (real TLS already set up: re-executed launch) + // else: p = mmap(0, 4096, 3, 0x22, -1, 0); arch_prctl(ARCH_SET_FS, p+2048) + ``` + Do NOT touch R14 (g) or BP. SYSCALL clobbers RCX/R11 (fine here). + +- **arm64** (`setup_universal_tls_arm64.s`): the thread pointer is `TPIDR_EL0`. + `MRS Rx, TPIDR_EL0`; if 0, `mmap` then `MSR TPIDR_EL0, Rx` (no syscall needed + to set it). arm64 is cross-compile-verified only in this environment; mark it + as such. + +- **no-op** (`setup_universal_tls_noop.go`, build `!cgo && linux && !android && + !goffi_universal`): `func setupUniversalTLS() {}`. + +- **call site:** in `internal/fakecgo/go_linux_amd64.go` and `go_linux_arm64.go`, + make `setupUniversalTLS()` the first line of `x_cgo_init`, immediately before + the existing `maybeReexecUniversal()` call. + +### How to verify the fix +``` +export PATH=$PATH:/usr/local/go/bin +bash scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe +# glibc host: +/tmp/uprobe # expect: ... UNIVERSAL-PROBE-OK +# musl: run the SAME /tmp/uprobe inside an Alpine userland (chroot/container) +# expect: "info re-exec bridge active: true" then UNIVERSAL-PROBE-OK +``` +Both must print `UNIVERSAL-PROBE-OK`. `cmd/universal-probe` already auto-detects +the host libc the same way the bridge does. + +--- + +## 4. What is already done (file-by-file) + +All of the following compiles under CGO_ENABLED=0 in every mode (default / +`goffi_universal` / `goffi_static`; `goffi_musl` with its usual +`-gcflags=github.com/go-webgpu/goffi/internal/dl=-std`). It is committed on +branch `feature/profile-u-musl` as two WIP commits. + +**Retagged to be mutually exclusive with `goffi_universal`** (added +`&& !goffi_universal` to their build constraints), so the universal empty-SONAME +files take over cleanly: +- `internal/dl/dl_linux_dynamic.go`, `internal/dl/dl_musl_amd64.go`, + `internal/dl/dl_musl_arm64.go` +- `internal/syscall/errno_linux.go`, `internal/syscall/errno_musl_amd64.go`, + `internal/syscall/errno_musl_arm64.go` +- `internal/fakecgo/symbols_linux.go`, `internal/fakecgo/symbols_musl_amd64.go`, + `internal/fakecgo/symbols_musl_arm64.go` + +**New — universal empty-SONAME imports (no DT_NEEDED):** +- `internal/dl/dl_universal.go` — `dlopen/dlsym/dlerror/dlclose` with `""`. +- `internal/syscall/errno_universal.go` — `__errno_location` with `""`. +- `internal/fakecgo/symbols_universal.go` — `malloc/free/setenv/unsetenv/` + `sigfillset/nanosleep/abort/sigaltstack/pthread_*` with `""`. **Matches the + musl symbol set** (i.e. **no** `pthread_get_stacksize_np`, which is Darwin-only + and musl binds eagerly; it would fail to resolve). + +**New — the re-exec bridge:** +- `internal/fakecgo/reexec_syscall_amd64.s`, `reexec_syscall_arm64.s` — + `func rawsyscall6(trap,a1..a6 uintptr) (r1 uintptr)`, libc-free raw syscall. +- `internal/fakecgo/reexec_table_amd64.go`, `reexec_table_arm64.go` — per-arch + syscall numbers (the `*at` forms only, so the same Go compiles on both arches) + and the loader/libc table: + - amd64: glibc `/lib64/ld-linux-x86-64.so.2` + `libc.so.6`; musl + `/lib/ld-musl-x86_64.so.1` + `libc.musl-x86_64.so.1`. + - arm64: glibc `/lib/ld-linux-aarch64.so.1` + `libc.so.6`; musl + `/lib/ld-musl-aarch64.so.1` + `libc.musl-aarch64.so.1`. +- `internal/fakecgo/reexec_universal_linux.go` — the full nosplit, + heap-free, libc-free re-exec: mmap scratch, read `/proc/self/environ` and + `/proc/self/cmdline`, guard check (`GOFFI_UNIVERSAL_REEXEC=1`), discover the + host loader (glibc first, musl fallback, via raw `faccessat`), build argv/envp + pointer arrays (with a `cstr` bump-allocator that copies rodata strings + + NUL into mmap), and `execve`. Contains a prominent **KNOWN ISSUE** comment + describing the TLS blocker in §3. +- `internal/fakecgo/reexec_noop.go` — no-op `maybeReexecUniversal()` for + non-universal linux builds. + +**Changed — call site:** +- `internal/fakecgo/go_linux_amd64.go`, `go_linux_arm64.go` — call + `maybeReexecUniversal()` at the very top of `x_cgo_init` (before `malloc`). + *(The `setupUniversalTLS()` call from §3 must be added immediately before it.)* + +**New — tooling:** +- `cmd/goffi-strip-interp/main.go` — flips `PT_INTERP` → `PT_NULL` (tiny, + dependency-free ELF edit; handles ELFCLASS32/64). +- `cmd/universal-probe/main.go` — runtime FFI probe (LoadLibrary, sqrt, strlen, + getpid, qsort callback, 32-goroutine thread hammer); auto-detects the host + libc; prints `UNIVERSAL-PROBE-OK`. +- `scripts/build-universal.sh` — `go build -tags goffi_universal + CGO_ENABLED=0` then strip the interpreter. + +`ffi.Available()` returns `!static.Enabled`; universal is **not** static, so FFI +is enabled (`Available()==true`) automatically — no change needed there. + +--- + +## 5. Remaining work (ordered, concrete) + +1. **[BLOCKER] `setupUniversalTLS` shim** (amd64 asm + arm64 asm + no-op stub + + call site) — see §3. Then verify `UNIVERSAL-PROBE-OK` on glibc **and** musl. + *Nothing else is worth doing until this passes.* + +2. **`internal/loader` package** — the auditable "loader" port. A per-arch table + (glibc/musl × amd64/arm64) of loader path + libc SONAME, plus runtime + probing: detect the host libc flavor (filesystem existence of the loader + paths; optionally read `PT_INTERP` from `/proc/self/exe` and/or auxv + `AT_BASE`). Expose via the public `ffi` package: + - `ffi.HostLoader() string`, `ffi.HostLibC() string`, `ffi.LibcKind()` + (glibc/musl/unknown). Keep the table in sync with + `reexec_table_*.go` (or have that file reference this package — but note the + re-exec path is nosplit/libc-free and cannot import general code, so a small + duplicated table there is acceptable; add a test asserting they match). + +3. **`cmd/goffi-audit`** — Profile-U contract checker: open a binary with + `debug/elf` and assert it has **no `PT_INTERP`** and **no `DT_NEEDED`**. Exit + non-zero otherwise. This is the portable essence of static-everywhere's + onebin `c_profile.c`. + +4. **purego coexistence (CGO_ENABLED=0)** — goffi already has a `nofakecgo` + build tag (`ffi/fakecgo_unix.go` is `//go:build ... && !nofakecgo ...`) that + drops goffi's fakecgo so purego's provides the cgo-runtime symbols. Verified: + `-tags nofakecgo` makes goffi+purego build **and** run under CGO_ENABLED=0. + Harden + document this, and add a CI job that builds and runs a tiny + goffi+purego program with `-tags nofakecgo`. Document that the `goffi_universal` + build owns the cgo runtime and must not be combined with purego's fakecgo + (using `nofakecgo` there would disable the re-exec bridge). + +5. **Tests:** + - `internal/loader` unit tests (table correctness; flavor detection). + - `ffi/universal_link_test.go`: build a `-tags goffi_universal` binary and + assert **no `DT_NEEDED`** via `debug/elf` (interp-strip is a separate build + step; the audit tool covers the interp check). + - Confirm the existing `ffi/musl_directives_test.go` (`TestMuslDirectiveParity`) + still passes; extend only if it needs to know about the universal files. + +6. **CI (`.github/workflows/ci.yml`)** — mirror the existing musl pattern + (`scripts/check-musl.sh` + `cmd/musl-probe`). Add: + - a `universal-build` job that builds the probe, strips the interp, and + asserts the ELF contract (no PT_INTERP, no DT_NEEDED); + - runs of `cmd/universal-probe` under **both** an Alpine (musl) container and + a Debian/Ubuntu (glibc) container — the *same* built binary in both; + - a `goffi-audit` job; + - a `purego-coexistence` job (CGO_ENABLED=0, `-tags nofakecgo`). + Existing CI pins a specific Go toolchain; the universal build needs **no** + `-gcflags` (empty-SONAME `cgo_import_dynamic` is allowed in non-cgo code; only + `//go:cgo_dynamic_linker`, which universal does not use, was restricted). + +7. **Docs + attribution:** + - Update `docs/MUSL.md` (mention the universal build as the single-binary + alternative). + - New `docs/PROFILE_U.md` (user-facing): how to build a universal binary, the + runtime re-exec behavior, and the limitations (Linux-only; a system whose + loader we do not recognise cannot do FFI; argv[0] becomes the resolved exe + path after re-exec). + - Attribute `unxed/static-everywhere` and `pg83/solo` in `NOTICE`/`README`. + +--- + +## 6. How to build and test (reference) + +``` +export PATH=$PATH:/usr/local/go/bin + +# Compile checks (all must pass): +CGO_ENABLED=0 go build ./... # default (glibc) +CGO_ENABLED=0 go build -tags goffi_universal ./... # universal +CGO_ENABLED=0 go build -tags goffi_static ./... # static (FFI off) +CGO_ENABLED=0 go build -tags goffi_musl \ + -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... # musl + +# Build + strip a universal binary: +bash scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe + +# ELF contract (after strip): expect NO interpreter, NO NEEDED: +readelf -lW /tmp/uprobe | grep -i "Requesting" # -> nothing +readelf -dW /tmp/uprobe | grep NEEDED # -> nothing + +# Run on glibc (the dev host) and on musl (Alpine userland via chroot/container): +/tmp/uprobe # -> UNIVERSAL-PROBE-OK (after §3 fix) + +# purego coexistence (CGO-free): +# build a small program importing both goffi and purego with -tags nofakecgo, +# CGO_ENABLED=0, and run it. +``` + +Notes for the dev sandbox specifically (may not exist in other environments): +Go is at `/usr/local/go`; an Alpine musl rootfs was extracted at +`/tmp/u/rootfs` (loader `/tmp/u/rootfs/lib/ld-musl-x86_64.so.1`), usable via +`chroot /tmp/u/rootfs …`. In CI, use an official Alpine container instead. + +--- + +## 7. Honesty notes / decisions (do not silently reverse) + +- **No SoLo.** A full in-process foreign-libc loader is intentionally *not* + vendored, matching static-everywhere's own scoping. The universal build reuses + the host's own `ld.so` via `--preload`. +- **Linux-only.** The universal mode targets Linux. On a system whose dynamic + loader we do not recognise, FFI is impossible (the process cannot bind libc); + that is a documented limitation. A program that needs FFI on such a system + cannot run in universal mode. +- **arm64 is cross-compile-verified only** in this environment; state that in + docs/commits. amd64 is the run-tested arch. +- **purego coexistence is guaranteed for the default/musl/static modes** + (CGO_ENABLED=0, `-tags nofakecgo`). The universal build owns the cgo runtime + and should not be combined with purego's fakecgo. +- **argv[0]** of a universal binary becomes the resolved executable path after + the re-exec (we pass `/proc/self/exe` or its readlink target as the program). + Acceptable; document it. + +--- + +## 8. Delivery / pushing + +Development happened in a sandbox with **no GitHub credentials** (the fork remote +is read-only over HTTPS there), so commits are handed off as `git format-patch` +series (and/or a repo tarball) to be applied and pushed by the maintainer: + +``` +git checkout -b feature/profile-u-musl # base: origin/main +git am 0001-*.patch 0002-*.patch [0003-...] # in order +git push -u origin feature/profile-u-musl +``` + +From here on, each hand-off is an **incremental** patch (only the delta over the +previous commit). + +--- + +## START HERE (immediate next action) + +Implement §3: add `setupUniversalTLS()` (amd64 asm now, arm64 asm + no-op stub), +call it as the first statement of `x_cgo_init` before `maybeReexecUniversal()`, +then run `scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe` and +confirm `UNIVERSAL-PROBE-OK` on **both** glibc and musl. Only then proceed to +§5.2 onward. From 163669edfe4a272934c00203f20ec9695248b2d3 Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Wed, 2 Sep 2026 00:20:51 +0000 Subject: [PATCH 09/24] =?UTF-8?q?fakecgo:=20add=20setupUniversalTLS=20shim?= =?UTF-8?q?=20=E2=80=94=20fix=20first-launch=20TLS=20fault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the first, kernel-direct launch of a universal binary there is no host ld.so, so the thread pointer (%fs on amd64, TPIDR_EL0 on arm64) is unset: rt0_go delegates TLS setup to _cgo_init (the "JZ needtls" branch is taken only when _cgo_init is nil), and the compiler's post-ABI0-call g-reload (MOVQ FS:-8, R14) then faults before the re-exec bridge can run. setupUniversalTLS points the thread pointer at a scratch mmap page, but only when it is not yet set up; on the re-executed launch the host loader has configured real TLS and the shim is a no-op. It is called as the very first statement of x_cgo_init, before maybeReexecUniversal, so every subsequent ABI0-call g-reload reads mapped memory. The fake g it exposes is never dereferenced before execve. - setup_universal_tls.go prototype + amd64 scratch slot (universal) - setup_universal_tls_amd64.s arch_prctl(GET_FS/SET_FS) + mmap - setup_universal_tls_arm64.s MRS/MSR TPIDR_EL0 + mmap (cross-asm only) - setup_universal_tls_noop.go no-op for default/goffi_musl - go_linux_{amd64,arm64}.go call setupUniversalTLS() first in x_cgo_init Verified: with this shim the first launch now sets up TLS and re-execs through the host loader (previously it segfaulted at the first g-reload). musl is end-to-end green (the re-executed no-interp binary binds the empty-SONAME symbols under the musl loader and passes the full FFI probe). Still open (tracked in docs/PROFILE_U_PLAN.md): glibc's ld.so does not bind the empty-SONAME symbols of a PT_INTERP-stripped binary, so the glibc re-exec path is not yet green; musl's loader does. Also the universal probe looks for sqrt in libc, which holds on musl but not glibc (sqrt is in libm). --- internal/fakecgo/go_linux_amd64.go | 15 +++++-- internal/fakecgo/go_linux_arm64.go | 15 +++++-- internal/fakecgo/reexec_universal_linux.go | 2 + internal/fakecgo/setup_universal_tls.go | 33 ++++++++++++++ internal/fakecgo/setup_universal_tls_amd64.s | 45 ++++++++++++++++++++ internal/fakecgo/setup_universal_tls_arm64.s | 37 ++++++++++++++++ internal/fakecgo/setup_universal_tls_noop.go | 13 ++++++ 7 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 internal/fakecgo/setup_universal_tls.go create mode 100644 internal/fakecgo/setup_universal_tls_amd64.s create mode 100644 internal/fakecgo/setup_universal_tls_arm64.s create mode 100644 internal/fakecgo/setup_universal_tls_noop.go diff --git a/internal/fakecgo/go_linux_amd64.go b/internal/fakecgo/go_linux_amd64.go index fb83901..972d9c2 100644 --- a/internal/fakecgo/go_linux_amd64.go +++ b/internal/fakecgo/go_linux_amd64.go @@ -61,10 +61,17 @@ var setg_func uintptr //go:nosplit func x_cgo_init(g *G, setg uintptr) { - // Portable universal build: before touching any libc symbol (malloc - // below is the first), re-exec through the host loader with libc - // pre-loaded. No-op in the default and goffi_musl builds. See - // reexec_universal_linux.go. + // Portable universal build. Two shims run before we touch any libc + // symbol (the malloc below is the first). Both are no-ops in the default + // and goffi_musl builds. + // + // 1. setupUniversalTLS: on the first, kernel-direct launch the thread + // pointer is unset (rt0_go delegates TLS setup to _cgo_init); give it a + // scratch page so the compiler's g-reloads after ABI0 calls don't fault. + // 2. maybeReexecUniversal: re-exec through the host loader with libc + // pre-loaded, so the empty-SONAME imports bind. See + // reexec_universal_linux.go. + setupUniversalTLS() maybeReexecUniversal() var size size_t diff --git a/internal/fakecgo/go_linux_arm64.go b/internal/fakecgo/go_linux_arm64.go index 8b49057..dd9d329 100644 --- a/internal/fakecgo/go_linux_arm64.go +++ b/internal/fakecgo/go_linux_arm64.go @@ -66,10 +66,17 @@ var setg_func uintptr // //go:nosplit func x_cgo_init(g *G, setg uintptr) { - // Portable universal build: before touching any libc symbol (malloc - // below is the first), re-exec through the host loader with libc - // pre-loaded. No-op in the default and goffi_musl builds. See - // reexec_universal_linux.go. + // Portable universal build. Two shims run before we touch any libc + // symbol (the malloc below is the first). Both are no-ops in the default + // and goffi_musl builds. + // + // 1. setupUniversalTLS: on the first, kernel-direct launch the thread + // pointer is unset (rt0_go delegates TLS setup to _cgo_init); give it a + // scratch page so the compiler's g-reloads after ABI0 calls don't fault. + // 2. maybeReexecUniversal: re-exec through the host loader with libc + // pre-loaded, so the empty-SONAME imports bind. See + // reexec_universal_linux.go. + setupUniversalTLS() maybeReexecUniversal() var size size_t diff --git a/internal/fakecgo/reexec_universal_linux.go b/internal/fakecgo/reexec_universal_linux.go index 647ab0e..32943e5 100644 --- a/internal/fakecgo/reexec_universal_linux.go +++ b/internal/fakecgo/reexec_universal_linux.go @@ -226,6 +226,8 @@ func maybeReexecUniversal() { loaderC = m sonameC = cstr(muslLibc) } + if loaderC != nil { + } if loaderC == nil || sonameC == nil { diag("goffi: universal build: no known host dynamic loader found; FFI unavailable\n") return diff --git a/internal/fakecgo/setup_universal_tls.go b/internal/fakecgo/setup_universal_tls.go new file mode 100644 index 0000000..4fdd57f --- /dev/null +++ b/internal/fakecgo/setup_universal_tls.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal + +package fakecgo + +// setupUniversalTLS makes the thread pointer usable before the Go code in +// x_cgo_init runs. +// +// When _cgo_init is present, rt0_go skips the runtime's own TLS setup and +// delegates it to _cgo_init (see runtime/asm_amd64.s: the "JZ needtls" branch +// is taken only when _cgo_init is nil). On a universal binary the kernel +// loaded directly -- no interpreter, so no host ld.so ran -- the %fs (amd64) / +// TPIDR_EL0 (arm64) thread pointer is therefore still zero. The Go compiler +// emits a g-register reload from thread-local storage (`MOVQ FS:-8, R14` on +// amd64) after every call to an ABI0 assembly function, and that read faults +// when the thread pointer is unset. +// +// This shim, called as the very first statement of x_cgo_init on the universal +// build, points the thread pointer at a scratch page *only when it is not yet +// set up* (the first launch). The fake g value it exposes is never +// dereferenced before the re-exec bridge calls execve. On the re-executed +// launch the host loader has configured real TLS, so arch_prctl(ARCH_GET_FS) +// / MRS TPIDR_EL0 returns non-zero and the shim does nothing. +// +// Implemented in assembly per architecture; it must not itself touch the g +// register or grow the stack. +func setupUniversalTLS() + +// utlsScratch is a scratch slot for arch_prctl(ARCH_GET_FS) on amd64 (arm64 +// reads TPIDR_EL0 straight into a register and does not use it). +var utlsScratch uintptr diff --git a/internal/fakecgo/setup_universal_tls_amd64.s b/internal/fakecgo/setup_universal_tls_amd64.s new file mode 100644 index 0000000..1bfc025 --- /dev/null +++ b/internal/fakecgo/setup_universal_tls_amd64.s @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && amd64 + +#include "textflag.h" + +// func setupUniversalTLS() +// +// If %fs is unset (first, kernel-direct launch), point it at a scratch page so +// the compiler's post-ABI0-call `MOVQ FS:-8, R14` g-reloads read mapped memory. +// No-op when %fs is already set (the re-executed launch). Never touches R14 (g) +// or BP; SYSCALL's clobber of RCX/R11 is irrelevant here. +TEXT ·setupUniversalTLS(SB), NOSPLIT, $0-0 + // current = arch_prctl(ARCH_GET_FS, &utlsScratch) + MOVQ $0, ·utlsScratch(SB) + MOVQ $158, AX // SYS_arch_prctl + MOVQ $0x1003, DI // ARCH_GET_FS + LEAQ ·utlsScratch(SB), SI + SYSCALL + MOVQ ·utlsScratch(SB), AX + TESTQ AX, AX + JNE tlsdone // TLS already set up + + // p = mmap(0, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0) + MOVQ $9, AX // SYS_mmap + MOVQ $0, DI + MOVQ $4096, SI + MOVQ $0x3, DX // PROT_READ|PROT_WRITE + MOVQ $0x22, R10 // MAP_PRIVATE|MAP_ANONYMOUS + MOVQ $-1, R8 + MOVQ $0, R9 + SYSCALL + CMPQ AX, $-4096 + JAE tlsdone // mmap failed; nothing better to do + + // arch_prctl(ARCH_SET_FS, p+2048) -- offset keeps FS:-8 inside the page + ADDQ $2048, AX + MOVQ AX, SI + MOVQ $158, AX // SYS_arch_prctl + MOVQ $0x1002, DI // ARCH_SET_FS + SYSCALL + +tlsdone: + RET diff --git a/internal/fakecgo/setup_universal_tls_arm64.s b/internal/fakecgo/setup_universal_tls_arm64.s new file mode 100644 index 0000000..1944238 --- /dev/null +++ b/internal/fakecgo/setup_universal_tls_arm64.s @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && arm64 + +#include "textflag.h" + +// func setupUniversalTLS() +// +// arm64 counterpart of the amd64 shim. The thread pointer is TPIDR_EL0, which +// EL0 may read and write directly (no syscall). Go stores g at TPIDR_EL0+16 +// (runtime.tls_g). If TPIDR_EL0 is unset (first, kernel-direct launch), point +// it at a scratch page so the compiler's g-reloads read mapped memory. No-op +// when it is already set (the re-executed launch). +// +// NOTE: arm64 is cross-compile-verified only in the development environment; +// it has not been run-tested. The logic mirrors the amd64 path. +TEXT ·setupUniversalTLS(SB), NOSPLIT, $0-0 + MRS TPIDR_EL0, R0 + CBNZ R0, tlsdone // TLS already set up + + // p = mmap(0, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0) + MOVD $0, R0 + MOVD $4096, R1 + MOVD $0x3, R2 // PROT_READ|PROT_WRITE + MOVD $0x22, R3 // MAP_PRIVATE|MAP_ANONYMOUS + MOVD $-1, R4 + MOVD $0, R5 + MOVD $222, R8 // SYS_mmap + SVC + TBNZ $63, R0, tlsdone // negative => -errno => mmap failed + + ADD $2048, R0 // keep TPIDR_EL0+16 inside the page + MSR R0, TPIDR_EL0 + +tlsdone: + RET diff --git a/internal/fakecgo/setup_universal_tls_noop.go b/internal/fakecgo/setup_universal_tls_noop.go new file mode 100644 index 0000000..0851bab --- /dev/null +++ b/internal/fakecgo/setup_universal_tls_noop.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && !goffi_universal + +package fakecgo + +// setupUniversalTLS is a no-op outside the goffi_universal build: the default +// and goffi_musl binaries are launched by the host loader, which sets up TLS +// before the Go entry point runs. +// +//go:nosplit +func setupUniversalTLS() {} From 4767268a3af2b8f180e225395c6ba7f25dfc01f4 Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Wed, 2 Sep 2026 00:49:33 +0000 Subject: [PATCH 10/24] =?UTF-8?q?fakecgo:=20bind=20on=20glibc=20via=20memf?= =?UTF-8?q?d=20interp-restore=20=E2=80=94=20universal=20now=20green=20on?= =?UTF-8?q?=20both=20libcs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-SONAME + stripped-PT_INTERP + re-exec design relies on the host loader binding the undefined symbols under --preload. Empirically the two loaders differ: musl's ld.so binds a no-PT_INTERP main object directly, but glibc's ld.so only binds a re-exec'd main object that carries a PT_INTERP. So the bridge now branches on the detected libc: - musl -> re-exec the on-disk binary as-is (musl binds it). - glibc -> copy /proc/self/exe into a memfd with the PT_INTERP header restored (only the program header's p_type was cleared when the interp was stripped; the .interp string is intact, so restoring it is a single field write), then re-exec the loader against /proc/self/fd/. The memfd is created without MFD_CLOEXEC (it must survive execve) and prefers MFD_EXEC with a fallback to flags=0 on pre-6.3 kernels. New raw-syscall helpers, all //go:nosplit and heap-free (this runs before the Go scheduler): restoreInterpToMemfd, copyExeToMemfd, patchInterpInBuf, procFdPath, plus memfd_create in the amd64/arm64 syscall tables. The universal probe's floating-point check moves from sqrt (which glibc keeps in libm, so it will not resolve from the libc handle) to atof (in libc on both glibc and musl). Verified end-to-end with ONE stripped binary, autonomous re-exec (no manual --preload): glibc host -> UNIVERSAL-PROBE-OK musl (Alpine, /proc mounted, chroot) -> UNIVERSAL-PROBE-OK Both bind libc, load it via dlopen, call atof/strlen/getpid, run a Go comparator through qsort, and hammer 32 OS threads through pthread_create. ELF contract holds: no PT_INTERP, no DT_NEEDED. Existing tests still pass. arm64 remains cross-compile-verified only. Also updates docs/PROFILE_U_PLAN.md to mark the TLS shim and the glibc-binding problem resolved and to repoint START HERE at the remaining work. --- cmd/universal-probe/main.go | 21 ++- docs/PROFILE_U_PLAN.md | 28 ++- internal/fakecgo/reexec_table_amd64.go | 1 + internal/fakecgo/reexec_table_arm64.go | 1 + internal/fakecgo/reexec_universal_linux.go | 190 +++++++++++++++++++-- 5 files changed, 207 insertions(+), 34 deletions(-) diff --git a/cmd/universal-probe/main.go b/cmd/universal-probe/main.go index d9dc937..89555b5 100644 --- a/cmd/universal-probe/main.go +++ b/cmd/universal-probe/main.go @@ -101,17 +101,20 @@ func main() { defer ffi.FreeLibrary(handle) check("LoadLibrary", true, lib) - // sqrt(2.0): double(double) -- FP register path. - sqrtFn := mustSym(handle, "sqrt") - sqrtCIF := mustCIF(types.DoubleTypeDescriptor, types.DoubleTypeDescriptor) - arg := 2.0 - var root float64 - if _, err := ffi.CallFunction(sqrtCIF, sqrtFn, - unsafe.Pointer(&root), []unsafe.Pointer{unsafe.Pointer(&arg)}); err != nil { - fmt.Printf("FAIL CallFunction(sqrt): %v\n", err) + // atof("2.0"): double(char*) -- FP return register path. atof lives in + // libc on both glibc and musl (sqrt is in libm on glibc, so it would not + // resolve from the libc handle on a glibc host). + atofFn := mustSym(handle, "atof") + atofCIF := mustCIF(types.DoubleTypeDescriptor, types.PointerTypeDescriptor) + numStr := "2.0\x00" + numPtr := unsafe.Pointer(unsafe.StringData(numStr)) + var val float64 + if _, err := ffi.CallFunction(atofCIF, atofFn, + unsafe.Pointer(&val), []unsafe.Pointer{unsafe.Pointer(&numPtr)}); err != nil { + fmt.Printf("FAIL CallFunction(atof): %v\n", err) os.Exit(1) } - check("sqrt(2.0)", math.Abs(root-math.Sqrt2) < 1e-12, fmt.Sprintf("= %v", root)) + check("atof(\"2.0\")", math.Abs(val-2.0) < 1e-12, fmt.Sprintf("= %v", val)) // strlen: size_t(char*) -- integer return. strlenFn := mustSym(handle, "strlen") diff --git a/docs/PROFILE_U_PLAN.md b/docs/PROFILE_U_PLAN.md index a52854c..c36c3d9 100644 --- a/docs/PROFILE_U_PLAN.md +++ b/docs/PROFILE_U_PLAN.md @@ -267,9 +267,17 @@ is enabled (`Available()==true`) automatically — no change needed there. ## 5. Remaining work (ordered, concrete) -1. **[BLOCKER] `setupUniversalTLS` shim** (amd64 asm + arm64 asm + no-op stub + - call site) — see §3. Then verify `UNIVERSAL-PROBE-OK` on glibc **and** musl. - *Nothing else is worth doing until this passes.* +1. **[DONE] `setupUniversalTLS` shim** (amd64 asm + arm64 asm + no-op stub + + call site) — see §3. Landed; the first launch now sets up TLS and re-execs. + +1b. **[DONE] glibc no-interp binding.** glibc's `ld.so` only binds a re-exec'd + main object that carries a `PT_INTERP`; musl binds a no-interp binary + directly. The bridge now branches on the detected libc: on musl it re-execs + the on-disk binary as-is; on glibc it copies `/proc/self/exe` into a memfd + with the `PT_INTERP` header restored and re-execs that. **Result: + `UNIVERSAL-PROBE-OK` on both glibc and musl end-to-end (same binary, + autonomous re-exec).** The universal probe's FP check was switched from + `sqrt` (libm on glibc) to `atof` (libc on both). 2. **`internal/loader` package** — the auditable "loader" port. A per-arch table (glibc/musl × amd64/arm64) of loader path + libc SONAME, plus runtime @@ -400,8 +408,12 @@ previous commit). ## START HERE (immediate next action) -Implement §3: add `setupUniversalTLS()` (amd64 asm now, arm64 asm + no-op stub), -call it as the first statement of `x_cgo_init` before `maybeReexecUniversal()`, -then run `scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe` and -confirm `UNIVERSAL-PROBE-OK` on **both** glibc and musl. Only then proceed to -§5.2 onward. +The core mechanism is done: the universal build is green on both glibc and +musl end-to-end (§5 items 1 and 1b). Continue with §5.2 onward — the +`internal/loader` package and public API first, then `cmd/goffi-audit`, the +purego-coexistence job, tests, both-libc CI, and the user-facing docs. + +Quick check that the mechanism still works after any change: +`scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe`, then run +`/tmp/uprobe` on glibc and inside an Alpine (musl) userland with `/proc` +mounted; both must print `UNIVERSAL-PROBE-OK`. diff --git a/internal/fakecgo/reexec_table_amd64.go b/internal/fakecgo/reexec_table_amd64.go index b2fa9a6..f980a29 100644 --- a/internal/fakecgo/reexec_table_amd64.go +++ b/internal/fakecgo/reexec_table_amd64.go @@ -17,6 +17,7 @@ const ( sysReadlinkat = 267 sysOpenat = 257 sysFaccessat = 269 + sysMemfdCreate = 319 ) // Host dynamic loader + libc SONAME, per libc flavor, for amd64. These are the diff --git a/internal/fakecgo/reexec_table_arm64.go b/internal/fakecgo/reexec_table_arm64.go index aecc6f0..ac757aa 100644 --- a/internal/fakecgo/reexec_table_arm64.go +++ b/internal/fakecgo/reexec_table_arm64.go @@ -17,6 +17,7 @@ const ( sysReadlinkat = 78 sysOpenat = 56 sysFaccessat = 48 + sysMemfdCreate = 279 ) // Host dynamic loader + libc SONAME, per libc flavor, for arm64. diff --git a/internal/fakecgo/reexec_universal_linux.go b/internal/fakecgo/reexec_universal_linux.go index 32943e5..cf7242d 100644 --- a/internal/fakecgo/reexec_universal_linux.go +++ b/internal/fakecgo/reexec_universal_linux.go @@ -41,22 +41,22 @@ package fakecgo -// KNOWN ISSUE (work in progress): this bridge does not run yet on the very -// first (kernel-direct) launch. When _cgo_init is present, rt0_go SKIPS the -// runtime's own TLS setup and delegates it to _cgo_init (see runtime/asm_amd64.s: -// the "JZ needtls" is only taken when _cgo_init is nil). So at x_cgo_init time, -// on a no-interpreter binary the kernel loaded directly, the %fs/TLS base is -// not initialised. The Go compiler emits `MOVQ FS:-8, R14` (reload the g -// register) after every call to an ABI0 assembly function, and that TLS read -// faults before we reach execve. +// TLS: on the very first (kernel-direct) launch of a universal binary there is +// no host loader, so the thread pointer is unset (rt0_go delegates TLS setup to +// _cgo_init). setupUniversalTLS, called before this function in x_cgo_init, +// installs a scratch thread pointer so the compiler's g-reloads don't fault; +// see setup_universal_tls_*.s. On the re-executed launch the host loader sets +// up real TLS. // -// Fix in progress: a tiny per-arch asm shim (setupUniversalTLS) called as the -// first thing in x_cgo_init on the universal build, which, only when no TLS is -// set up yet (first launch), points %fs (amd64) / TPIDR_EL0 (arm64) at a -// scratch page so the g-reloads read mapped memory. On the re-executed launch -// the host loader sets up real TLS, so the shim is a no-op. The code below is -// otherwise complete and compiles; it is exercised end-to-end once the shim -// lands. See the conversation notes / docs/PROFILE_U.md (pending). +// Loader asymmetry (empirically established): musl's ld.so binds the +// empty-SONAME symbols of a PT_INTERP-stripped binary directly, so on musl we +// re-exec our own on-disk binary as-is. glibc's ld.so does NOT bind a +// re-exec'd main object unless it carries a PT_INTERP -- so on glibc we hand +// the loader an in-memory copy (memfd) of the binary with the PT_INTERP header +// restored (restoreInterpToMemfd). The .interp string was left intact when the +// interp was stripped; only the program header's p_type was cleared, so +// restoring it is a single field write. Either way the loader then binds the +// symbols from the pre-loaded libc. import "unsafe" @@ -79,6 +79,12 @@ const ( guardVar = "GOFFI_UNIVERSAL_REEXEC=1" guardKey = "GOFFI_UNIVERSAL_REEXEC=" + + // interp restore (glibc path) + mfdExec = 0x0010 // MFD_EXEC (kernel 6.3+); fall back to 0 on older kernels + ptNull = 0 // PT_NULL (what the interp header was stripped to) + ptInterp = 3 // PT_INTERP + copyChunk = 64 << 10 ) // Bump allocator over an mmap staging buffer for NUL-terminated C strings. @@ -182,6 +188,142 @@ func diag(msg string) { rawsyscall6(sysWrite, 2, uintptr(unsafe.Pointer(unsafe.StringData(msg))), uintptr(len(msg)), 0, 0, 0) } +// procFdPath writes "/proc/self/fd/" (NUL-terminated) into the staging +// buffer and returns a *byte to it, or nil if the buffer is exhausted. +// +//go:nosplit +func procFdPath(fd uintptr) *byte { + const prefix = "/proc/self/fd/" + var digs [20]byte + di := len(digs) + v := fd + if v == 0 { + di-- + digs[di] = '0' + } + for v > 0 { + di-- + digs[di] = byte('0' + v%10) + v /= 10 + } + pn := uintptr(len(prefix)) + dn := uintptr(len(digs) - di) + if strBufBase == 0 || strBufOff+pn+dn+1 > strBufCap { + return nil + } + base := unsafe.Pointer(strBufBase) + start := strBufOff + for i := uintptr(0); i < pn; i++ { + *(*byte)(unsafe.Add(base, start+i)) = prefix[i] + } + for i := uintptr(0); i < dn; i++ { + *(*byte)(unsafe.Add(base, start+pn+i)) = digs[di+int(i)] + } + *(*byte)(unsafe.Add(base, start+pn+dn)) = 0 + strBufOff = start + pn + dn + 1 + return (*byte)(unsafe.Add(base, start)) +} + +// patchInterpInBuf finds the stripped PT_INTERP program header in an ELF64 +// header buffer (the first chunk of the file) and restores its p_type to +// PT_INTERP. The stripped header is the PT_NULL entry whose p_offset points at +// a path ('/'). Returns false if the header table is not fully present in buf +// or no such entry is found. +// +//go:nosplit +func patchInterpInBuf(buf unsafe.Pointer, n uintptr) bool { + if n < 64 { + return false + } + phoff := uintptr(*(*uint64)(unsafe.Add(buf, 0x20))) + phentsize := uintptr(*(*uint16)(unsafe.Add(buf, 0x36))) + phnum := uintptr(*(*uint16)(unsafe.Add(buf, 0x38))) + if phentsize < 56 || phoff+phnum*phentsize > n { + return false + } + for i := uintptr(0); i < phnum; i++ { + pe := phoff + i*phentsize + if *(*uint32)(unsafe.Add(buf, pe)) != ptNull { + continue + } + poff := uintptr(*(*uint64)(unsafe.Add(buf, pe+8))) // p_offset + if poff < n && *(*byte)(unsafe.Add(buf, poff)) == '/' { + *(*uint32)(unsafe.Add(buf, pe)) = ptInterp + return true + } + } + return false +} + +// restoreInterpToMemfd copies /proc/self/exe into a new memfd with the +// PT_INTERP header restored, and returns the memfd descriptor (an -errno-style +// value on failure, testable with sysErr). The memfd is created without +// MFD_CLOEXEC so it survives the upcoming execve and the loader can open it via +// /proc/self/fd/. Used only on the glibc path. +// +//go:nosplit +func restoreInterpToMemfd() uintptr { + nameC := cstr("goffi") + if nameC == nil { + return ^uintptr(0) + } + // No MFD_CLOEXEC: the fd must survive execve so the loader can open + // /proc/self/fd/. Prefer MFD_EXEC so the copy may be mapped + // executable; fall back to 0 on pre-6.3 kernels that reject the flag. + memfd := rawsyscall6(sysMemfdCreate, uintptr(unsafe.Pointer(nameC)), mfdExec, 0, 0, 0, 0) + if sysErr(memfd) { + memfd = rawsyscall6(sysMemfdCreate, uintptr(unsafe.Pointer(nameC)), 0, 0, 0, 0, 0) + if sysErr(memfd) { + return ^uintptr(0) + } + } + exeFd := rawsyscall6(sysOpenat, atFDCWD, + uintptr(unsafe.Pointer(cstr("/proc/self/exe"))), oRDONLY, 0, 0, 0) + if sysErr(exeFd) { + rawsyscall6(sysClose, memfd, 0, 0, 0, 0, 0) + return ^uintptr(0) + } + buf := mmapAnon(copyChunk) + ok := buf != nil && copyExeToMemfd(exeFd, memfd, buf) + rawsyscall6(sysClose, exeFd, 0, 0, 0, 0, 0) + if !ok { + rawsyscall6(sysClose, memfd, 0, 0, 0, 0, 0) + return ^uintptr(0) + } + return memfd +} + +// copyExeToMemfd streams exeFd into memfd through buf, restoring the PT_INTERP +// header in the first chunk. Returns false on any short or failed I/O. A plain +// helper (not a closure) so nothing heap-allocates in this pre-scheduler code. +// +//go:nosplit +func copyExeToMemfd(exeFd, memfd uintptr, buf unsafe.Pointer) bool { + first := true + for { + n := rawsyscall6(sysRead, exeFd, uintptr(buf), copyChunk, 0, 0, 0) + if sysErr(n) { + return false + } + if n == 0 { + return true + } + if first { + first = false + if !patchInterpInBuf(buf, n) { + return false + } + } + for off := uintptr(0); off < n; { + w := rawsyscall6(sysWrite, memfd, uintptr(unsafe.Add(buf, off)), n-off, 0, 0, 0) + if sysErr(w) || w == 0 { + return false + } + off += w + } + } +} + // maybeReexecUniversal is invoked at the very top of x_cgo_init. On the first // launch of a universal binary it re-execs through the host loader with libc // pre-loaded; on the re-executed launch (guard present) it returns immediately. @@ -219,15 +361,15 @@ func maybeReexecUniversal() { // Pick the host loader + libc SONAME by probing known loader paths. // Prefer glibc when both are present; fall back to musl. var loaderC, sonameC *byte + var isGlibc bool if g := cstr(glibcLoader); fileExists(g) { loaderC = g sonameC = cstr(glibcLibc) + isGlibc = true } else if m := cstr(muslLoader); fileExists(m) { loaderC = m sonameC = cstr(muslLibc) } - if loaderC != nil { - } if loaderC == nil || sonameC == nil { diag("goffi: universal build: no known host dynamic loader found; FFI unavailable\n") return @@ -249,6 +391,20 @@ func maybeReexecUniversal() { exeC = cstr("/proc/self/exe") } + // glibc only binds a re-exec'd main object that carries a PT_INTERP. Our + // on-disk binary has none (so the kernel can load it directly on musl), so + // give glibc an in-memory copy with the interp restored and point the + // loader at it. musl binds the no-interp binary directly and needs none of + // this. If the memfd copy fails we fall back to the real path (which will + // not bind on glibc, but the diagnostic below is best-effort anyway). + if isGlibc { + if fd := restoreInterpToMemfd(); !sysErr(fd) { + if p := procFdPath(fd); p != nil { + exeC = p + } + } + } + // Read original argv from /proc/self/cmdline (NUL-delimited). cmdBase := mmapAnon(cmdBufCap) if cmdBase == nil { From 227e007375de630bee2249f4ccaa062e689a213a Mon Sep 17 00:00:00 2001 From: Ivan Sorokin Date: Wed, 2 Sep 2026 02:56:22 +0200 Subject: [PATCH 11/24] gofmt --- internal/fakecgo/reexec_table_amd64.go | 16 ++++++++-------- internal/fakecgo/reexec_table_arm64.go | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/fakecgo/reexec_table_amd64.go b/internal/fakecgo/reexec_table_amd64.go index f980a29..1fb8f9a 100644 --- a/internal/fakecgo/reexec_table_amd64.go +++ b/internal/fakecgo/reexec_table_amd64.go @@ -9,14 +9,14 @@ package fakecgo // are used so the same Go logic compiles unchanged on arm64 (see the arm64 // table), where the legacy open/access/readlink numbers do not exist. const ( - sysRead = 0 - sysWrite = 1 - sysClose = 3 - sysMmap = 9 - sysExecve = 59 - sysReadlinkat = 267 - sysOpenat = 257 - sysFaccessat = 269 + sysRead = 0 + sysWrite = 1 + sysClose = 3 + sysMmap = 9 + sysExecve = 59 + sysReadlinkat = 267 + sysOpenat = 257 + sysFaccessat = 269 sysMemfdCreate = 319 ) diff --git a/internal/fakecgo/reexec_table_arm64.go b/internal/fakecgo/reexec_table_arm64.go index ac757aa..4b449eb 100644 --- a/internal/fakecgo/reexec_table_arm64.go +++ b/internal/fakecgo/reexec_table_arm64.go @@ -9,14 +9,14 @@ package fakecgo // open/access/readlink syscalls, so the bridge uses the *at forms everywhere // (with AT_FDCWD); these numbers match the generic syscall table. const ( - sysRead = 63 - sysWrite = 64 - sysClose = 57 - sysMmap = 222 - sysExecve = 221 - sysReadlinkat = 78 - sysOpenat = 56 - sysFaccessat = 48 + sysRead = 63 + sysWrite = 64 + sysClose = 57 + sysMmap = 222 + sysExecve = 221 + sysReadlinkat = 78 + sysOpenat = 56 + sysFaccessat = 48 sysMemfdCreate = 279 ) From 5865f9b62713f1b9c42347e80e045a73328c6d56 Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Wed, 2 Sep 2026 00:57:51 +0000 Subject: [PATCH 12/24] loader: add internal/loader package + public HostLoader/HostLibC/LibcKind The auditable "Profile U" loader table, as a plain-Go package usable from any build mode (not just the universal, nosplit re-exec bridge). For each targeted architecture (linux/amd64, linux/arm64) it records the host dynamic loader path and libc SONAME for glibc and musl, and Detect() probes the running host -- glibc first, then musl -- exactly as the re-exec bridge chooses. Unsupported architectures report KindUnknown. Public API on the ffi package: - ffi.HostLoader() string -- path to the host ld.so (or "") - ffi.HostLibC() string -- host libc SONAME (or "") - ffi.LibcKind() string -- "glibc" / "musl" / "unknown" Tests: - internal/loader: Kind.String and Detect consistency. - internal/fakecgo (goffi_universal): assert the bridge's reexec_table_*.go constants match internal/loader's table, so the two descriptions of the host cannot drift apart. Verified: compiles in default/universal/static/musl and on arm64; on a glibc host the API returns loader=/lib64/ld-linux-x86-64.so.2 libc=libc.so.6 kind=glibc. --- ffi/hostinfo.go | 19 ++++++ internal/fakecgo/reexec_table_sync_test.go | 33 +++++++++++ internal/loader/loader.go | 68 ++++++++++++++++++++++ internal/loader/loader_test.go | 27 +++++++++ internal/loader/table_amd64.go | 14 +++++ internal/loader/table_arm64.go | 14 +++++ internal/loader/table_other.go | 14 +++++ 7 files changed, 189 insertions(+) create mode 100644 ffi/hostinfo.go create mode 100644 internal/fakecgo/reexec_table_sync_test.go create mode 100644 internal/loader/loader.go create mode 100644 internal/loader/loader_test.go create mode 100644 internal/loader/table_amd64.go create mode 100644 internal/loader/table_arm64.go create mode 100644 internal/loader/table_other.go diff --git a/ffi/hostinfo.go b/ffi/hostinfo.go new file mode 100644 index 0000000..dffc9da --- /dev/null +++ b/ffi/hostinfo.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import "github.com/go-webgpu/goffi/internal/loader" + +// HostLoader returns the absolute path of the host's dynamic loader (ld.so) for +// the running libc flavor, or "" if it cannot be determined (an unsupported +// architecture, or a host running neither glibc nor musl). This is the loader a +// universal binary re-execs through. +func HostLoader() string { return loader.Detect().Loader } + +// HostLibC returns the host libc SONAME (e.g. "libc.so.6" on glibc or +// "libc.musl-x86_64.so.1" on musl), or "" if it cannot be determined. +func HostLibC() string { return loader.Detect().LibC } + +// LibcKind returns the host libc flavor as "glibc", "musl", or "unknown". +func LibcKind() string { return loader.Detect().Kind.String() } diff --git a/internal/fakecgo/reexec_table_sync_test.go b/internal/fakecgo/reexec_table_sync_test.go new file mode 100644 index 0000000..26d7feb --- /dev/null +++ b/internal/fakecgo/reexec_table_sync_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && (amd64 || arm64) + +package fakecgo + +import ( + "testing" + + "github.com/go-webgpu/goffi/internal/loader" +) + +// The re-exec bridge (this package) and the public loader table must agree on +// the loader paths and libc SONAMEs, since both claim to describe the same host. +func TestReexecTableMatchesLoaderPackage(t *testing.T) { + cases := []struct { + name string + gotL, gotC string + wantL, wantC string + }{ + {"glibc", glibcLoader, glibcLibc, loader.Glibc.Loader, loader.Glibc.LibC}, + {"musl", muslLoader, muslLibc, loader.Musl.Loader, loader.Musl.LibC}, + } + for _, c := range cases { + if c.gotL != c.wantL { + t.Errorf("%s loader: bridge %q != loader pkg %q", c.name, c.gotL, c.wantL) + } + if c.gotC != c.wantC { + t.Errorf("%s libc: bridge %q != loader pkg %q", c.name, c.gotC, c.wantC) + } + } +} diff --git a/internal/loader/loader.go b/internal/loader/loader.go new file mode 100644 index 0000000..9296b58 --- /dev/null +++ b/internal/loader/loader.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Package loader is the auditable "Profile U" loader table: for each supported +// architecture it records the host dynamic loader path and libc SONAME of the +// two libc flavors goffi's universal build targets (glibc and musl), and probes +// the running host to report which is present. +// +// This mirrors the constants used by the universal re-exec bridge +// (internal/fakecgo/reexec_table_*.go); a test asserts the two stay in sync. +// Unlike the bridge, this package is plain Go usable from any build mode, and +// backs the public ffi.HostLoader / ffi.HostLibC / ffi.LibcKind helpers. +package loader + +import "os" + +// Kind identifies a libc flavor. +type Kind int + +const ( + KindUnknown Kind = iota + KindGlibc + KindMusl +) + +// String returns "glibc", "musl", or "unknown". +func (k Kind) String() string { + switch k { + case KindGlibc: + return "glibc" + case KindMusl: + return "musl" + default: + return "unknown" + } +} + +// Entry describes one libc: its dynamic loader path and its libc SONAME. +type Entry struct { + Loader string // absolute path to the dynamic loader (ld.so) + LibC string // libc SONAME, passed bare to ` --preload ` + Kind Kind +} + +// Detect reports the libc flavor of the running host by probing the known +// loader paths (glibc first, then musl), matching the re-exec bridge's choice. +// It returns an Entry with Kind == KindUnknown on architectures goffi's +// universal build does not target, or when neither loader is present. +func Detect() Entry { + if !Known { + return Entry{Kind: KindUnknown} + } + if fileExists(Glibc.Loader) { + return Glibc + } + if fileExists(Musl.Loader) { + return Musl + } + return Entry{Kind: KindUnknown} +} + +func fileExists(p string) bool { + if p == "" { + return false + } + _, err := os.Stat(p) + return err == nil +} diff --git a/internal/loader/loader_test.go b/internal/loader/loader_test.go new file mode 100644 index 0000000..1daf7c3 --- /dev/null +++ b/internal/loader/loader_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package loader + +import "testing" + +func TestKindString(t *testing.T) { + for k, want := range map[Kind]string{KindGlibc: "glibc", KindMusl: "musl", KindUnknown: "unknown"} { + if got := k.String(); got != want { + t.Errorf("Kind(%d).String() = %q, want %q", k, got, want) + } + } +} + +func TestDetectConsistent(t *testing.T) { + e := Detect() + if e.Kind == KindUnknown { + return // exotic host or non-target arch; nothing more to assert + } + if e.Loader == "" || e.LibC == "" { + t.Fatalf("Detect returned kind %v with empty Loader/LibC: %+v", e.Kind, e) + } + if !fileExists(e.Loader) { + t.Errorf("Detect chose %q but it does not exist", e.Loader) + } +} diff --git a/internal/loader/table_amd64.go b/internal/loader/table_amd64.go new file mode 100644 index 0000000..4f962b1 --- /dev/null +++ b/internal/loader/table_amd64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && amd64 + +package loader + +// Host loader + libc SONAME per libc flavor for linux/amd64. Keep in sync with +// internal/fakecgo/reexec_table_amd64.go (asserted by a test). +var ( + Glibc = Entry{Loader: "/lib64/ld-linux-x86-64.so.2", LibC: "libc.so.6", Kind: KindGlibc} + Musl = Entry{Loader: "/lib/ld-musl-x86_64.so.1", LibC: "libc.musl-x86_64.so.1", Kind: KindMusl} + Known = true +) diff --git a/internal/loader/table_arm64.go b/internal/loader/table_arm64.go new file mode 100644 index 0000000..22cb834 --- /dev/null +++ b/internal/loader/table_arm64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && arm64 + +package loader + +// Host loader + libc SONAME per libc flavor for linux/arm64. Keep in sync with +// internal/fakecgo/reexec_table_arm64.go (asserted by a test). +var ( + Glibc = Entry{Loader: "/lib/ld-linux-aarch64.so.1", LibC: "libc.so.6", Kind: KindGlibc} + Musl = Entry{Loader: "/lib/ld-musl-aarch64.so.1", LibC: "libc.musl-aarch64.so.1", Kind: KindMusl} + Known = true +) diff --git a/internal/loader/table_other.go b/internal/loader/table_other.go new file mode 100644 index 0000000..0f6923d --- /dev/null +++ b/internal/loader/table_other.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !linux || (!amd64 && !arm64) + +package loader + +// The universal build targets only linux/amd64 and linux/arm64; elsewhere the +// loader flavor is unknown. +var ( + Glibc = Entry{Kind: KindUnknown} + Musl = Entry{Kind: KindUnknown} + Known = false +) From a507f73b31e5a9f0812bc1cd496a36de72acbfa8 Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Wed, 2 Sep 2026 01:00:05 +0000 Subject: [PATCH 13/24] audit: add cmd/goffi-audit + ffi universal DT_NEEDED test cmd/goffi-audit opens a binary with debug/elf and asserts the Profile U ELF contract: no PT_INTERP program header and no DT_NEEDED entries. Non-zero exit on failure; accepts multiple paths. The portable equivalent of static-everywhere's onebin profile check, and what scripts/build-universal.sh points at. ffi/universal_link_test.go builds a CGO-free -tags goffi_universal binary and asserts it has no DT_NEEDED (the empty-SONAME imports must not pull in a specific libc). The interp strip is a separate build step covered by goffi-audit, so it is not asserted here. Guarded by testing.Short(). Verified: audit passes a stripped universal binary and fails a default glibc build (PT_INTERP + libc.so.6/libdl.so.2/libpthread.so.0); the link test passes. --- .gitignore | 2 ++ cmd/goffi-audit/main.go | 59 ++++++++++++++++++++++++++++++++++++++ ffi/universal_link_test.go | 44 ++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 cmd/goffi-audit/main.go create mode 100644 ffi/universal_link_test.go diff --git a/.gitignore b/.gitignore index 6dff94e..d9f455f 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ RESEARCH*.md # Go modules (don't ignore go.mod and go.sum) !go.mod !go.sum +/goffi-audit +/def_probe diff --git a/cmd/goffi-audit/main.go b/cmd/goffi-audit/main.go new file mode 100644 index 0000000..50794e9 --- /dev/null +++ b/cmd/goffi-audit/main.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Command goffi-audit checks that a binary satisfies the "Profile U" ELF +// contract: no PT_INTERP program header and no DT_NEEDED dynamic entries. It is +// the portable, debug/elf-based equivalent of static-everywhere's onebin +// profile check. Exit status is non-zero if any listed binary fails. +// +// Usage: goffi-audit [binary...] +package main + +import ( + "debug/elf" + "fmt" + "os" + "strings" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: goffi-audit [binary...]") + os.Exit(2) + } + rc := 0 + for _, path := range os.Args[1:] { + if err := audit(path); err != nil { + fmt.Printf("FAIL %s: %v\n", path, err) + rc = 1 + } else { + fmt.Printf("ok %s: no PT_INTERP, no DT_NEEDED\n", path) + } + } + os.Exit(rc) +} + +func audit(path string) error { + f, err := elf.Open(path) + if err != nil { + return err + } + defer f.Close() + + var problems []string + for _, p := range f.Progs { + if p.Type == elf.PT_INTERP { + problems = append(problems, "has PT_INTERP") + break + } + } + if libs, err := f.ImportedLibraries(); err != nil { + return err + } else if len(libs) > 0 { + problems = append(problems, "has DT_NEEDED: "+strings.Join(libs, ", ")) + } + if len(problems) > 0 { + return fmt.Errorf("%s", strings.Join(problems, "; ")) + } + return nil +} diff --git a/ffi/universal_link_test.go b/ffi/universal_link_test.go new file mode 100644 index 0000000..3a05535 --- /dev/null +++ b/ffi/universal_link_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && (amd64 || arm64) + +package ffi + +import ( + "debug/elf" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestUniversalNoDTNeeded builds a CGO-free -tags goffi_universal binary and +// asserts it carries no DT_NEEDED entries (the empty-SONAME imports must not +// pull in a specific libc). The PT_INTERP strip is a separate build step +// (scripts/build-universal.sh) checked by cmd/goffi-audit, so it is not +// asserted here. +func TestUniversalNoDTNeeded(t *testing.T) { + if testing.Short() { + t.Skip("skipping: builds a binary") + } + out := filepath.Join(t.TempDir(), "universal-probe") + cmd := exec.Command("go", "build", "-tags", "goffi_universal", + "-o", out, "github.com/go-webgpu/goffi/cmd/universal-probe") + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build universal binary: %v\n%s", err, b) + } + f, err := elf.Open(out) + if err != nil { + t.Fatal(err) + } + defer f.Close() + libs, err := f.ImportedLibraries() + if err != nil { + t.Fatal(err) + } + if len(libs) > 0 { + t.Errorf("universal binary has DT_NEEDED %v, want none", libs) + } +} From 09ce585636cad301d9582960fbfa8b7b474746f4 Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Wed, 2 Sep 2026 01:03:10 +0000 Subject: [PATCH 14/24] ci: add both-libc universal workflow + purego coexistence .github/workflows/universal.yml: - build: build every mode (default/universal/static/musl), vet, unit tests (incl. the goffi_universal sync test), then build+strip the universal probe and assert the Profile U contract with cmd/goffi-audit; upload the binary. - run-glibc: download that SAME binary and run it under debian:stable-slim and ubuntu:24.04, expecting UNIVERSAL-PROBE-OK. - run-musl: run the SAME binary under alpine:latest (musl), expecting UNIVERSAL-PROBE-OK. - purego-coexistence: build and run a CGO-free program importing both goffi and purego with -tags nofakecgo. This is the "one binary, both libc" guarantee wired into CI. YAML validated locally; the actual Actions run cannot be exercised in the dev sandbox, so the first real run may need minor tweaks (runner images, action versions). --- .github/workflows/universal.yml | 109 ++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .github/workflows/universal.yml diff --git a/.github/workflows/universal.yml b/.github/workflows/universal.yml new file mode 100644 index 0000000..141c234 --- /dev/null +++ b/.github/workflows/universal.yml @@ -0,0 +1,109 @@ +name: universal + +on: + push: + branches: [ "**" ] + pull_request: + +permissions: + contents: read + +jobs: + build: + name: build + Profile U contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: 'stable' + - name: Build every mode, vet and unit tests + run: | + set -euo pipefail + CGO_ENABLED=0 go build ./... + CGO_ENABLED=0 go build -tags goffi_universal ./... + CGO_ENABLED=0 go build -tags goffi_static ./... + CGO_ENABLED=0 go build -tags goffi_musl -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... + CGO_ENABLED=0 go vet ./... + CGO_ENABLED=0 go test ./... + CGO_ENABLED=0 go test -tags goffi_universal ./internal/... + - name: Build the universal probe and check the ELF contract + run: | + set -euo pipefail + bash scripts/build-universal.sh -o universal-probe ./cmd/universal-probe + go run ./cmd/goffi-audit universal-probe + - name: Upload universal probe + uses: actions/upload-artifact@v4 + with: + name: universal-probe + path: universal-probe + + run-glibc: + name: run on glibc (${{ matrix.image }}) + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + image: [ "debian:stable-slim", "ubuntu:24.04" ] + container: + image: ${{ matrix.image }} + steps: + - uses: actions/download-artifact@v4 + with: + name: universal-probe + - name: Run the same universal binary on glibc + run: | + set -eu + chmod +x universal-probe + ./universal-probe | tee out.txt + grep -q UNIVERSAL-PROBE-OK out.txt + + run-musl: + name: run on musl (alpine) + needs: build + runs-on: ubuntu-latest + container: + image: alpine:latest + steps: + - uses: actions/download-artifact@v4 + with: + name: universal-probe + - name: Run the same universal binary on musl + shell: sh + run: | + set -eu + chmod +x universal-probe + ./universal-probe | tee out.txt + grep -q UNIVERSAL-PROBE-OK out.txt + + purego-coexistence: + name: purego coexistence (CGO-free, -tags nofakecgo) + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: goffi + - uses: actions/setup-go@v5 + with: + go-version: 'stable' + - name: Build and run a program importing goffi and purego together + run: | + set -euo pipefail + mkdir coexist && cd coexist + printf '%s\n' \ + 'package main' \ + '' \ + 'import (' \ + ' _ "github.com/ebitengine/purego"' \ + ' "github.com/go-webgpu/goffi/ffi"' \ + ')' \ + '' \ + 'func main() { println("coexist ffi.Available =", ffi.Available()) }' \ + > main.go + go mod init coexist + go mod edit -replace github.com/go-webgpu/goffi=../goffi + GOFLAGS=-mod=mod go get github.com/go-webgpu/goffi/ffi + GOFLAGS=-mod=mod go get github.com/ebitengine/purego + CGO_ENABLED=0 go build -tags nofakecgo -o coexist . + ./coexist From c6a4fd085434170e19ab1e816a4445ac8a4c985d Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Wed, 2 Sep 2026 01:05:36 +0000 Subject: [PATCH 15/24] docs: user-facing PROFILE_U.md + attribution (static-everywhere, pg83/solo) - docs/PROFILE_U.md: how to build a universal binary, how the empty-SONAME / stripped-PT_INTERP / re-exec mechanism works, the public HostLoader/ HostLibC/LibcKind API, limitations, and attribution. - NOTICE: credit unxed/static-everywhere for the Profile U concept and note pg83/solo as the (intentionally not vendored) in-process loader. - docs/MUSL.md and README.md: short pointers to the universal build. --- NOTICE | 10 ++++++++ README.md | 6 +++++ docs/MUSL.md | 5 ++++ docs/PROFILE_U.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 docs/PROFILE_U.md diff --git a/NOTICE b/NOTICE index 62996b8..752bf4d 100644 --- a/NOTICE +++ b/NOTICE @@ -11,3 +11,13 @@ under the Apache License, Version 2.0. Original copyright: The Apache-2.0 licensed files retain their original SPDX headers with dual copyright attribution. + +-------------------------------------------------------------------------------- +Profile U (universal glibc/musl) build +-------------------------------------------------------------------------------- +goffi's universal build ports the "Profile U" concept from +unxed/static-everywhere (https://github.com/unxed/static-everywhere): a single +CGO-free binary with no PT_INTERP and no DT_NEEDED that reaches the host libc +through the host's own dynamic loader. The full in-process foreign-libc loader +pg83/solo (https://github.com/pg83/solo) is referenced but not vendored. See +docs/PROFILE_U.md. diff --git a/README.md b/README.md index 11d016e..8ee9448 100644 --- a/README.md +++ b/README.md @@ -524,3 +524,9 @@ MIT — see [LICENSE](LICENSE). --- *goffi v0.4.1 | [GitHub](https://github.com/go-webgpu/goffi) | [pkg.go.dev](https://pkg.go.dev/github.com/go-webgpu/goffi) | [Dev.to](https://dev.to/kolkov/goffi-zero-cgo-foreign-function-interface-for-go-how-we-call-c-libraries-without-a-c-compiler-ca5)* + +## Universal build (glibc + musl) + +One CGO-free binary can do FFI on both glibc and musl systems — see +[docs/PROFILE_U.md](docs/PROFILE_U.md). Attribution for the Profile U concept +(unxed/static-everywhere, pg83/solo) is in [NOTICE](NOTICE). diff --git a/docs/MUSL.md b/docs/MUSL.md index c2368a3..b26ae50 100644 --- a/docs/MUSL.md +++ b/docs/MUSL.md @@ -95,3 +95,8 @@ fakecgo's pthread imports. | glibc distros (Debian, Fedora, …) | default (no tags) | | Alpine, postmarketOS, other musl distros | `-tags goffi_musl` + the `-gcflags` line above | | `scratch` / distroless containers, no libc at all | `-tags goffi_static` (FFI off — there are no `.so` files to load there anyway) | + +## Single binary for both libcs + +Instead of separate glibc and `-tags goffi_musl` binaries, the universal build +produces one CGO-free binary that runs on both. See [PROFILE_U.md](PROFILE_U.md). diff --git a/docs/PROFILE_U.md b/docs/PROFILE_U.md new file mode 100644 index 0000000..04c23ac --- /dev/null +++ b/docs/PROFILE_U.md @@ -0,0 +1,58 @@ +# Profile U — one CGO-free binary for both glibc and musl + +The universal ("Profile U") build produces a **single** `CGO_ENABLED=0` binary +that does live, in-process FFI on **both** glibc and musl systems (Debian, +Ubuntu, Alpine, …) — no C toolchain, no per-distro rebuild. + +## Build + +```sh +bash scripts/build-universal.sh -o app ./path/to/your/main +go run ./cmd/goffi-audit app # asserts: no PT_INTERP, no DT_NEEDED +``` + +`scripts/build-universal.sh` builds with `-tags goffi_universal CGO_ENABLED=0` +and then strips the program interpreter. The universal build needs **no** +`-gcflags` (unlike the `goffi_musl` build). + +## How it works + +1. **No `DT_NEEDED`.** Every libc symbol is imported with an empty SONAME, so + the linker records undefined symbols but pins the binary to no specific libc. +2. **No `PT_INTERP`.** The interpreter is stripped after linking, so the kernel + loads the binary directly on any distribution (as it does a static binary). +3. **Re-exec through the host loader.** At the very top of startup — before any + libc symbol is touched — the process re-execs itself through the host's own + dynamic loader with the host libc pre-loaded. musl's loader binds the + empty-SONAME symbols of a no-interp binary directly; glibc's loader only + binds a main object that carries a `PT_INTERP`, so on glibc the binary hands + the loader an in-memory copy of itself with the interpreter header restored. + Either way the symbols bind against whichever libc the host ships. + +The host loader and libc are discovered from an auditable table +(`internal/loader`), also exposed publicly: + +```go +ffi.HostLoader() // e.g. "/lib64/ld-linux-x86-64.so.2" or "/lib/ld-musl-x86_64.so.1" +ffi.HostLibC() // e.g. "libc.so.6" or "libc.musl-x86_64.so.1" +ffi.LibcKind() // "glibc" | "musl" | "unknown" +``` + +## Limitations + +- **Linux only.** amd64 is run-tested; arm64 is cross-compile-verified. +- A host whose dynamic loader goffi does not recognise cannot do FFI (the + process cannot bind a libc); that is a hard limitation of universal mode. +- After the re-exec, `argv[0]` becomes the resolved executable path. +- The universal build **owns the cgo runtime**; do not combine it with purego's + fakecgo. To run goffi alongside purego, use the default build with + `-tags nofakecgo` (see `docs/MUSL.md` and the CI `purego-coexistence` job). + +## Attribution + +The Profile U concept — an auditable "no `PT_INTERP`, no `DT_NEEDED`, reach the +host libc through its own loader" contract — is ported from +[`unxed/static-everywhere`](https://github.com/unxed/static-everywhere). The +in-process foreign-libc loader (`pg83/solo`) is intentionally **not** vendored; +goffi only needs the host's own libc, so it reuses the host loader via +`--preload`, exactly as static-everywhere endorses as the practical mechanism. From ff80377920eca14958a5713b70360a3b3edfb202 Mon Sep 17 00:00:00 2001 From: unxed Date: Wed, 2 Sep 2026 01:11:21 +0000 Subject: [PATCH 16/24] ci: make bare go vet match the repo's govet exclusions; pin toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The universal workflow ran a bare 'go vet ./...', which trips the unsafeptr analyzer on ffi/callback_pointer.go — the deliberate //go:nocheckptr helper that reconstructs a pointer from a native callback register. ci.yml never hit this because it runs vet through golangci-lint, whose .golangci.yml excludes govet on exactly those paths. Pass -unsafeptr=false so the bare vet enforces the same contract, and pin go-version to 1.26.x as ci.yml does (the goffi_musl build's -gcflags workaround is calibrated against that toolchain). --- .github/workflows/universal.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/universal.yml b/.github/workflows/universal.yml index 141c234..8b1d901 100644 --- a/.github/workflows/universal.yml +++ b/.github/workflows/universal.yml @@ -16,7 +16,10 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: 'stable' + # Pinned to match .github/workflows/ci.yml: the goffi_musl build below + # depends on the toolchain's cgo_import_dynamic restrictions, which + # the -gcflags=...=-std workaround is calibrated against. + go-version: '1.26.x' - name: Build every mode, vet and unit tests run: | set -euo pipefail @@ -24,7 +27,12 @@ jobs: CGO_ENABLED=0 go build -tags goffi_universal ./... CGO_ENABLED=0 go build -tags goffi_static ./... CGO_ENABLED=0 go build -tags goffi_musl -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... - CGO_ENABLED=0 go vet ./... + # -unsafeptr=false: goffi reconstructs pointers from native call + # registers and stack slots by design (see ffi/callback_pointer.go's + # //go:nocheckptr contract). ci.yml runs vet through golangci-lint, + # which excludes govet on exactly those paths (.golangci.yml); this + # flag is the bare-vet equivalent of that exclusion. + CGO_ENABLED=0 go vet -unsafeptr=false ./... CGO_ENABLED=0 go test ./... CGO_ENABLED=0 go test -tags goffi_universal ./internal/... - name: Build the universal probe and check the ELF contract @@ -86,7 +94,7 @@ jobs: path: goffi - uses: actions/setup-go@v5 with: - go-version: 'stable' + go-version: '1.26.x' - name: Build and run a program importing goffi and purego together run: | set -euo pipefail From 5bdf933d53fb44bd5a1f87e0cd08d2030ddfe434 Mon Sep 17 00:00:00 2001 From: unxed Date: Wed, 2 Sep 2026 01:12:07 +0000 Subject: [PATCH 17/24] docs: close out the Profile U plan; record the purego/pureffi position The hand-off plan still opened with "work in progress, the universal runtime path does not work yet" and pointed START HERE at \xc2\xa75.2, although \xc2\xa75.2-\xc2\xa75.7 (loader package, audit tool, tests, CI, docs) have all landed. Mark them DONE, demote \xc2\xa73 to a historical record of the fixed TLS blocker, and repoint START HERE at what actually remains: a green CI run (the unit tests on this branch have never executed \xe2\x80\x94 vet failed first), real-hardware arm64 verification, and release notes. Also document why -tags nofakecgo is not available in universal mode (it drops the re-exec bridge with fakecgo) and that unxed/pureffi is therefore the way to satisfy a purego-API dependency here \xe2\x80\x94 it carries no fakecgo of its own and needs no changes for this branch. --- docs/PROFILE_U.md | 11 ++++++++ docs/PROFILE_U_PLAN.md | 57 ++++++++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/docs/PROFILE_U.md b/docs/PROFILE_U.md index 04c23ac..62a2a4c 100644 --- a/docs/PROFILE_U.md +++ b/docs/PROFILE_U.md @@ -47,6 +47,17 @@ ffi.LibcKind() // "glibc" | "musl" | "unknown" - The universal build **owns the cgo runtime**; do not combine it with purego's fakecgo. To run goffi alongside purego, use the default build with `-tags nofakecgo` (see `docs/MUSL.md` and the CI `purego-coexistence` job). + `-tags nofakecgo` is *not* an option in universal mode: dropping goffi's + fakecgo also drops the re-exec bridge, which lives at the top of its + `x_cgo_init`. + + If a dependency needs the purego *API*, prefer + [`unxed/pureffi`](https://github.com/unxed/pureffi) (a drop-in replacement + installed through a `replace` directive, already listed in the README): it + implements that API entirely on top of goffi and carries **no fakecgo of its + own**, so it needs no build tags and works unchanged in universal mode. No + pureffi change is required for Profile U — this branch only *adds* public API + (`HostLoader`/`HostLibC`/`LibcKind`) and build-tag-gated files. ## Attribution diff --git a/docs/PROFILE_U_PLAN.md b/docs/PROFILE_U_PLAN.md index c36c3d9..2569fe5 100644 --- a/docs/PROFILE_U_PLAN.md +++ b/docs/PROFILE_U_PLAN.md @@ -6,9 +6,10 @@ > what is already done, the one blocker currently in the way (with its designed > fix), and the precise next steps with commands. > -> **Status: work in progress. The universal runtime path does not work yet.** -> Everything compiles; the blocker is diagnosed and the fix is designed but not -> yet landed. See "START HERE" for the immediate next action. +> **Status: the plan below is complete.** The universal path works end to end on +> both glibc and musl (§5.1/§5.1b), the loader package, audit tool, tests, CI and +> docs (§5.2-§5.7) have landed. Section 3 is kept as the historical record of the +> blocker that was fixed. See "START HERE" for what is left. --- @@ -127,7 +128,7 @@ pointer-ified in place; scratch memory comes from raw `mmap`. --- -## 3. THE CURRENT BLOCKER and its designed fix (do this first) +## 3. The startup blocker and its fix (RESOLVED - historical record) ### Symptom A universal binary (empty-SONAME + stripped interp + re-exec at top of @@ -151,7 +152,7 @@ the host `ld.so` sets up `%fs` before the Go entry point runs. Verified with `go tool objdump` on the built binary: the fault is exactly the `MOVQ FS:-8, R14` after the first ABI0 call. -### The fix (designed, not yet implemented) +### The fix (implemented; landed in `setup_universal_tls_*.s`) Add a tiny per-arch **assembly** shim `setupUniversalTLS()` and call it as the **very first statement** of `x_cgo_init` in the universal build, *before* `maybeReexecUniversal()`. Because it is the first `CALL` in `x_cgo_init` and it @@ -279,7 +280,7 @@ is enabled (`Available()==true`) automatically — no change needed there. autonomous re-exec).** The universal probe's FP check was switched from `sqrt` (libm on glibc) to `atof` (libc on both). -2. **`internal/loader` package** — the auditable "loader" port. A per-arch table +2. **[DONE] `internal/loader` package** — the auditable "loader" port. A per-arch table (glibc/musl × amd64/arm64) of loader path + libc SONAME, plus runtime probing: detect the host libc flavor (filesystem existence of the loader paths; optionally read `PT_INTERP` from `/proc/self/exe` and/or auxv @@ -290,12 +291,12 @@ is enabled (`Available()==true`) automatically — no change needed there. re-exec path is nosplit/libc-free and cannot import general code, so a small duplicated table there is acceptable; add a test asserting they match). -3. **`cmd/goffi-audit`** — Profile-U contract checker: open a binary with +3. **[DONE] `cmd/goffi-audit`** — Profile-U contract checker: open a binary with `debug/elf` and assert it has **no `PT_INTERP`** and **no `DT_NEEDED`**. Exit non-zero otherwise. This is the portable essence of static-everywhere's onebin `c_profile.c`. -4. **purego coexistence (CGO_ENABLED=0)** — goffi already has a `nofakecgo` +4. **[DONE] purego coexistence (CGO_ENABLED=0)** — goffi already has a `nofakecgo` build tag (`ffi/fakecgo_unix.go` is `//go:build ... && !nofakecgo ...`) that drops goffi's fakecgo so purego's provides the cgo-runtime symbols. Verified: `-tags nofakecgo` makes goffi+purego build **and** run under CGO_ENABLED=0. @@ -304,7 +305,7 @@ is enabled (`Available()==true`) automatically — no change needed there. build owns the cgo runtime and must not be combined with purego's fakecgo (using `nofakecgo` there would disable the re-exec bridge). -5. **Tests:** +5. **[DONE] Tests:** - `internal/loader` unit tests (table correctness; flavor detection). - `ffi/universal_link_test.go`: build a `-tags goffi_universal` binary and assert **no `DT_NEEDED`** via `debug/elf` (interp-strip is a separate build @@ -312,7 +313,8 @@ is enabled (`Available()==true`) automatically — no change needed there. - Confirm the existing `ffi/musl_directives_test.go` (`TestMuslDirectiveParity`) still passes; extend only if it needs to know about the universal files. -6. **CI (`.github/workflows/ci.yml`)** — mirror the existing musl pattern +6. **[DONE] CI** — landed as a separate `.github/workflows/universal.yml` + rather than inside `ci.yml`, mirroring the existing musl pattern (`scripts/check-musl.sh` + `cmd/musl-probe`). Add: - a `universal-build` job that builds the probe, strips the interp, and asserts the ELF contract (no PT_INTERP, no DT_NEEDED); @@ -323,8 +325,17 @@ is enabled (`Available()==true`) automatically — no change needed there. Existing CI pins a specific Go toolchain; the universal build needs **no** `-gcflags` (empty-SONAME `cgo_import_dynamic` is allowed in non-cgo code; only `//go:cgo_dynamic_linker`, which universal does not use, was restricted). - -7. **Docs + attribution:** + Landed with all four jobs. Two notes for anyone editing that workflow: + - it pins `go-version: '1.26.x'` like `ci.yml`, because the `goffi_musl` + build's `-gcflags=...=-std` workaround is calibrated against that toolchain; + - it runs `go vet -unsafeptr=false`. A bare `go vet ./...` fails on + `ffi/callback_pointer.go` (the deliberate `//go:nocheckptr` helper that + rebuilds a pointer from a native callback register). `ci.yml` never saw + this because it runs vet through golangci-lint, whose `.golangci.yml` + excludes `govet` on exactly those paths; the flag is the bare-vet + equivalent of that exclusion. Do not "fix" it by rewriting the helper. + +7. **[DONE] Docs + attribution:** - Update `docs/MUSL.md` (mention the universal build as the single-binary alternative). - New `docs/PROFILE_U.md` (user-facing): how to build a universal binary, the @@ -332,6 +343,13 @@ is enabled (`Available()==true`) automatically — no change needed there. loader we do not recognise cannot do FFI; argv[0] becomes the resolved exe path after re-exec). - Attribute `unxed/static-everywhere` and `pg83/solo` in `NOTICE`/`README`. + `docs/PROFILE_U.md` also records the purego/pureffi situation: `-tags + nofakecgo` cannot be used in universal mode (it removes the re-exec bridge + along with fakecgo), so a purego-API dependency should go through + `unxed/pureffi`, which carries no fakecgo of its own. pureffi needs **no** + adaptation for this branch: Profile U only adds build-tag-gated files plus + the new `ffi.HostLoader`/`HostLibC`/`LibcKind` helpers, and pureffi touches + neither. --- @@ -408,10 +426,17 @@ previous commit). ## START HERE (immediate next action) -The core mechanism is done: the universal build is green on both glibc and -musl end-to-end (§5 items 1 and 1b). Continue with §5.2 onward — the -`internal/loader` package and public API first, then `cmd/goffi-audit`, the -purego-coexistence job, tests, both-libc CI, and the user-facing docs. +Everything in §5 has landed. What remains is verification and release hygiene: + +1. **Watch the `universal` workflow go green.** Until the vet fix above, the + build job died before `go test`, so the unit-test and both-libc run jobs on + this branch are still **unverified in CI**. Read that run before trusting + the tests. +2. **arm64 is still cross-compile-verified only.** Run `cmd/universal-probe` on + a real aarch64 glibc host and an aarch64 Alpine host, then drop the caveat + from `docs/PROFILE_U.md` and this file. +3. **Release notes.** `CHANGELOG.md` / `ROADMAP.md` have no Profile U entry yet; + add one when the branch is proposed upstream. Quick check that the mechanism still works after any change: `scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe`, then run From 7dc87b50ace9cd12436859fced31ac3e102c6c3c Mon Sep 17 00:00:00 2001 From: goffi-lint-fix Date: Wed, 2 Sep 2026 20:28:52 +0000 Subject: [PATCH 18/24] ci: fix golangci-lint errcheck/govet/staticcheck issues Clears all 12 issues reported by golangci-lint 2.13.2 on the profile_u branch, without changing any observable behaviour. errcheck (6): - goffi-audit, musl-probe, universal-probe: wrap the deferred read-only Close()/FreeLibrary() calls whose errors are genuinely uninteresting in `func(){ _ = ... }()`. - goffi-strip-interp: the read-only elf.File Close is discarded with `_ =`, while the read-write file handle now reports its Close error via a named return without clobbering an earlier WriteAt/ReadFull error, so a failed flush on close is no longer silently dropped. - universal-probe thread-hammer goroutine: the CallFunction result is intentionally discarded, now made explicit with `_, _ =`. govet shadow (4): - musl-probe: the three CallFunction checks in main() reused the existing outer err via `=` instead of shadowing it with `:=`. - static_link_test: the inner DynString err is renamed to derr so it no longer shadows the elf.Open err. staticcheck SA1019 (2): - static_link_test / musl_link_test: runtime.GOROOT (deprecated in Go 1.24) is replaced by a shared goToolPath() helper that queries `go env GOROOT` and falls back to PATH. The now-unused runtime import is dropped from musl_link_test. --- cmd/goffi-audit/main.go | 2 +- cmd/goffi-strip-interp/main.go | 10 +++++++--- cmd/musl-probe/main.go | 8 ++++---- cmd/universal-probe/main.go | 4 ++-- ffi/musl_link_test.go | 6 +----- ffi/static_link_test.go | 28 +++++++++++++++++++++++----- 6 files changed, 38 insertions(+), 20 deletions(-) diff --git a/cmd/goffi-audit/main.go b/cmd/goffi-audit/main.go index 50794e9..bc07bdb 100644 --- a/cmd/goffi-audit/main.go +++ b/cmd/goffi-audit/main.go @@ -38,7 +38,7 @@ func audit(path string) error { if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() var problems []string for _, p := range f.Progs { diff --git a/cmd/goffi-strip-interp/main.go b/cmd/goffi-strip-interp/main.go index b7f9e32..b45c8e0 100644 --- a/cmd/goffi-strip-interp/main.go +++ b/cmd/goffi-strip-interp/main.go @@ -46,7 +46,7 @@ func main() { os.Exit(status) } -func stripInterp(path string) error { +func stripInterp(path string) (err error) { // Read enough of the ELF header to locate the program header table. f, err := elf.Open(path) if err != nil { @@ -61,7 +61,7 @@ func stripInterp(path string) error { break } } - f.Close() + _ = f.Close() if interpIdx < 0 { return nil // already interpreter-less; nothing to do @@ -71,7 +71,11 @@ func stripInterp(path string) error { if err != nil { return err } - defer fh.Close() + defer func() { + if cerr := fh.Close(); cerr != nil && err == nil { + err = cerr + } + }() // Re-read the raw ELF header fields we need. Offsets are fixed by the ELF // spec and differ between the 32- and 64-bit forms. diff --git a/cmd/musl-probe/main.go b/cmd/musl-probe/main.go index 20c514a..ff47123 100644 --- a/cmd/musl-probe/main.go +++ b/cmd/musl-probe/main.go @@ -89,7 +89,7 @@ func main() { fmt.Printf("FAIL LoadLibrary(%s): %v\n", lib, err) os.Exit(1) } - defer ffi.FreeLibrary(handle) + defer func() { _ = ffi.FreeLibrary(handle) }() check("LoadLibrary", true, lib) // sqrt(2.0): double(double). Exercises the SSE/FP register path. @@ -97,7 +97,7 @@ func main() { sqrtCIF := mustCIF(types.DoubleTypeDescriptor, types.DoubleTypeDescriptor) arg := 2.0 var root float64 - if _, err := ffi.CallFunction(sqrtCIF, sqrtFn, + if _, err = ffi.CallFunction(sqrtCIF, sqrtFn, unsafe.Pointer(&root), []unsafe.Pointer{unsafe.Pointer(&arg)}); err != nil { fmt.Printf("FAIL CallFunction(sqrt): %v\n", err) os.Exit(1) @@ -110,7 +110,7 @@ func main() { s := "goffi on musl\x00" sp := unsafe.Pointer(unsafe.StringData(s)) var n uint64 - if _, err := ffi.CallFunction(strlenCIF, strlenFn, + if _, err = ffi.CallFunction(strlenCIF, strlenFn, unsafe.Pointer(&n), []unsafe.Pointer{unsafe.Pointer(&sp)}); err != nil { fmt.Printf("FAIL CallFunction(strlen): %v\n", err) os.Exit(1) @@ -121,7 +121,7 @@ func main() { getpidFn := mustSym(handle, "getpid") getpidCIF := mustCIF(types.SInt32TypeDescriptor) var pid int32 - if _, err := ffi.CallFunction(getpidCIF, getpidFn, + if _, err = ffi.CallFunction(getpidCIF, getpidFn, unsafe.Pointer(&pid), nil); err != nil { fmt.Printf("FAIL CallFunction(getpid): %v\n", err) os.Exit(1) diff --git a/cmd/universal-probe/main.go b/cmd/universal-probe/main.go index 89555b5..4a0f78a 100644 --- a/cmd/universal-probe/main.go +++ b/cmd/universal-probe/main.go @@ -98,7 +98,7 @@ func main() { fmt.Printf("FAIL LoadLibrary(%s): %v\n", lib, err) os.Exit(1) } - defer ffi.FreeLibrary(handle) + defer func() { _ = ffi.FreeLibrary(handle) }() check("LoadLibrary", true, lib) // atof("2.0"): double(char*) -- FP return register path. atof lives in @@ -180,7 +180,7 @@ func main() { defer wg.Done() runtime.LockOSThread() var m uint64 - ffi.CallFunction(strlenCIF, strlenFn, + _, _ = ffi.CallFunction(strlenCIF, strlenFn, unsafe.Pointer(&m), []unsafe.Pointer{unsafe.Pointer(&sp)}) runtime.UnlockOSThread() }() diff --git a/ffi/musl_link_test.go b/ffi/musl_link_test.go index 9574079..0fd948c 100644 --- a/ffi/musl_link_test.go +++ b/ffi/musl_link_test.go @@ -11,7 +11,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "testing" ) @@ -93,10 +92,7 @@ func readInterp(t *testing.T, f *elf.File) string { func buildMuslProbe(t *testing.T, root, goarch, out string) { t.Helper() - goTool := filepath.Join(runtime.GOROOT(), "bin", "go") - if _, err := os.Stat(goTool); err != nil { - goTool = "go" - } + goTool := goToolPath() cmd := exec.Command(goTool, "build", "-tags", "goffi_musl", diff --git a/ffi/static_link_test.go b/ffi/static_link_test.go index aab67b8..3764bbd 100644 --- a/ffi/static_link_test.go +++ b/ffi/static_link_test.go @@ -69,7 +69,7 @@ func TestStaticBuildProducesStaticBinary(t *testing.T) { // DynamicSection returns an error when there is no .dynamic // section at all, which is exactly the outcome we want. - if needed, err := f.DynString(elf.DT_NEEDED); err == nil && len(needed) > 0 { + if needed, derr := f.DynString(elf.DT_NEEDED); derr == nil && len(needed) > 0 { t.Errorf("binary still depends on shared libraries: %v", needed) } @@ -116,13 +116,31 @@ func writeProbeModule(t *testing.T) string { return dir } +// goToolPath returns the path to the go command. It prefers the go binary in +// the active GOROOT (as reported by "go env GOROOT"), falling back to whatever +// "go" resolves to on PATH. runtime.GOROOT was deprecated in Go 1.24 because +// the build-time GOROOT is meaningless once a binary is moved, so the value is +// queried from the tool at run time instead. Shared with musl_link_test.go. +func goToolPath() string { + out, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + return "go" + } + root := strings.TrimSpace(string(out)) + if root == "" { + return "go" + } + tool := filepath.Join(root, "bin", "go") + if _, statErr := os.Stat(tool); statErr != nil { + return "go" + } + return tool +} + func build(t *testing.T, dir, goarch, out string) { t.Helper() - goTool := filepath.Join(runtime.GOROOT(), "bin", "go") - if _, err := os.Stat(goTool); err != nil { - goTool = "go" - } + goTool := goToolPath() cmd := exec.Command(goTool, "build", "-tags", "goffi_static", "-o", out, ".") cmd.Dir = dir From d650a9a19cdb77e9150fbd19d39b0677f38d8c0d Mon Sep 17 00:00:00 2001 From: goffi-ci-fix Date: Wed, 2 Sep 2026 20:49:11 +0000 Subject: [PATCH 19/24] ci: fix Android fakecgo staleness gate and example builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent CI failures on profile_u, none related to the earlier lint fix (Lint stays green). Android arm64 (Check Android arm64): the gate regenerates the fakecgo sources from internal/fakecgo/gen.go and diffs them against the committed files. symbols_linux.go carried a hand-added `&& !goffi_universal` build constraint (required so it does not collide with symbols_universal.go in a universal build), but the generator never emitted it, so the check reported the file as stale. Reverting the file is not an option — it would break the universal build — so the generator is taught to emit the tag for the linux symbols file. The two musl symbol files had the same latent drift (the gate does not check them, but regenerating would have silently dropped their `!goffi_universal` tag and broken the universal build), so they are fixed in the same place. No generated file changes: regeneration now reproduces every committed file byte-for-byte under Go 1.26.5. Cross-Compile (examples build): two masked failures. - examples/simple and examples/struct declared `go 1.25`, which is lower than the goffi module's `go 1.25.0` they depend on via replace, so `go build` demanded `go mod tidy`. Both are bumped to `go 1.25.0`. - With that resolved, examples/struct then failed because structlib.c sits in the package directory and the package does not use cgo, so `go build` rejected the stray C source. The file is compiled with gcc and loaded at run time, not linked into the package, so it is moved to a csrc/ subdirectory (out of the package file set) and embedded via //go:embed; buildLib writes it to its temp dir before compiling. This also drops the fragile os.Args[0]/CWD source lookup, so the example runs even when the binary is relocated. Codecov patch coverage: add ffi/hostinfo_test.go covering the three public host-inspection helpers (HostLoader/HostLibC/LibcKind), which were the new, trivially testable uncovered lines. The static-build guard in call.go is a compile-time-false branch outside goffi_static and is left uncovered by design. Codecov patch is informational and not a merge gate regardless. --- examples/simple/go.mod | 2 +- examples/struct/{ => csrc}/structlib.c | 0 examples/struct/go.mod | 2 +- examples/struct/main.go | 18 +++++++--- ffi/hostinfo_test.go | 48 ++++++++++++++++++++++++++ internal/fakecgo/gen.go | 4 +-- 6 files changed, 66 insertions(+), 8 deletions(-) rename examples/struct/{ => csrc}/structlib.c (100%) create mode 100644 ffi/hostinfo_test.go diff --git a/examples/simple/go.mod b/examples/simple/go.mod index b9ecfe3..5a9cc67 100644 --- a/examples/simple/go.mod +++ b/examples/simple/go.mod @@ -1,6 +1,6 @@ module example -go 1.25 +go 1.25.0 require github.com/go-webgpu/goffi v0.0.0 diff --git a/examples/struct/structlib.c b/examples/struct/csrc/structlib.c similarity index 100% rename from examples/struct/structlib.c rename to examples/struct/csrc/structlib.c diff --git a/examples/struct/go.mod b/examples/struct/go.mod index 09011dd..a4f9cb1 100644 --- a/examples/struct/go.mod +++ b/examples/struct/go.mod @@ -1,6 +1,6 @@ module struct-example -go 1.25 +go 1.25.0 require github.com/go-webgpu/goffi v0.0.0 diff --git a/examples/struct/main.go b/examples/struct/main.go index 7a8e875..058389f 100644 --- a/examples/struct/main.go +++ b/examples/struct/main.go @@ -15,6 +15,7 @@ package main import ( + _ "embed" "fmt" "os" "os/exec" @@ -26,6 +27,15 @@ import ( "github.com/go-webgpu/goffi/types" ) +// structlibSource is the C library exercised by this example. It is embedded so +// the example builds as a normal Go package (a .c file in the package directory +// would otherwise be rejected as cgo input) and so it runs regardless of the +// working directory: buildLib writes it to a temp file and compiles it at run +// time. Building the shared library still requires a C compiler (gcc/clang). +// +//go:embed csrc/structlib.c +var structlibSource string + // Point mirrors C: typedef struct { int64_t x; int64_t y; } Point; type Point struct { X int64 @@ -251,10 +261,10 @@ func buildLib() (string, bool) { return "", false } - src := filepath.Join(filepath.Dir(os.Args[0]), "structlib.c") - if _, statErr := os.Stat(src); statErr != nil { - // When invoked via "go run .", the source directory is the working directory. - src = "structlib.c" + src := filepath.Join(dir, "structlib.c") + if err = os.WriteFile(src, []byte(structlibSource), 0o600); err != nil { + fmt.Println("write C source error:", err) + return "", false } var soPath string diff --git a/ffi/hostinfo_test.go b/ffi/hostinfo_test.go new file mode 100644 index 0000000..0783689 --- /dev/null +++ b/ffi/hostinfo_test.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import ( + "path/filepath" + "testing" +) + +// TestHostInfo exercises the public host-inspection helpers. The concrete +// values are host-dependent, so the test asserts the documented contract +// rather than a fixed string: LibcKind is always one of the three documented +// flavors, and the loader path and libc SONAME are populated together with a +// recognized libc and empty together with an unknown one. +func TestHostInfo(t *testing.T) { + loaderPath := HostLoader() + libc := HostLibC() + kind := LibcKind() + + switch kind { + case "glibc", "musl", "unknown": + // documented set + default: + t.Fatalf("LibcKind() = %q, want one of glibc/musl/unknown", kind) + } + + if kind == "unknown" { + // Nothing to re-exec through: both strings must be empty so callers + // can treat "" as "no universal loader available". + if loaderPath != "" || libc != "" { + t.Errorf("unknown libc but HostLoader()=%q HostLibC()=%q, want both empty", + loaderPath, libc) + } + return + } + + // A recognized libc must report a concrete, absolute loader path and a + // non-empty SONAME: a universal binary re-execs through exactly these. + if loaderPath == "" { + t.Errorf("HostLoader() is empty for kind %q", kind) + } else if !filepath.IsAbs(loaderPath) { + t.Errorf("HostLoader() = %q, want an absolute path", loaderPath) + } + if libc == "" { + t.Errorf("HostLibC() is empty for kind %q", kind) + } +} diff --git a/internal/fakecgo/gen.go b/internal/fakecgo/gen.go index 1dd0ffd..c4f8d8c 100644 --- a/internal/fakecgo/gen.go +++ b/internal/fakecgo/gen.go @@ -263,7 +263,7 @@ func run() error { case "linux": // The go command also satisfies the linux build tag on Android, // including for _linux.go files. Keep glibc imports out of Bionic. - goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo", "//go:build !cgo && !android && !goffi_musl", 1))) + goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo", "//go:build !cgo && !android && !goffi_musl && !goffi_universal", 1))) case "android": // Android uses a distinct build selector and symbol set. Keep the // generated generic Linux imports out of the Android ELF. @@ -307,7 +307,7 @@ func run() error { {"amd64", "libc.musl-x86_64.so.1"}, {"arm64", "libc.musl-aarch64.so.1"}, } { - tag := "//go:build !cgo && !android && goffi_musl && " + mc.arch + tag := "//go:build !cgo && !android && goffi_musl && !goffi_universal && " + mc.arch mt := template.Must(template.New("symbols_musl.go").Parse( strings.Replace(templateSymbolsGoos, "//go:build !cgo", tag, 1))) mb := &bytes.Buffer{} From f295fd5d48b208b1b48f126b1dd02656d574f3f5 Mon Sep 17 00:00:00 2001 From: goffi-ci-fix Date: Wed, 2 Sep 2026 20:57:50 +0000 Subject: [PATCH 20/24] ci: disable unsafeptr in the Android arm64 bare go vet The Android gate got past the fakecgo staleness check and the tests, then failed at `go vet ./...` with: ffi/callback_pointer.go:14:9: possible misuse of unsafe.Pointer pointerFromNative reconstructs a pointer from a native callback register or stack slot: the native caller owns the memory, so the uintptr->unsafe.Pointer conversion is intentional and carries a //go:nocheckptr contract. The function is already annotated with //nolint:govet,gosec, but that only suppresses golangci-lint; a bare `go vet` honors no such comment, and unlike `go test` (which runs a reduced vet subset) it runs the full analyzer set, including unsafeptr. That is why Lint and the Test jobs stay green while this explicit vet does not. universal.yml already runs `go vet -unsafeptr=false ./...` for exactly this reason, documenting it as the bare-vet equivalent of the golangci-lint govet exclusion on these FFI paths. This applies the same flag to the two vet invocations in check-android-arm64.sh so the Android gate matches. Only the unsafeptr analyzer is disabled; every other vet check still runs. --- scripts/check-android-arm64.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/check-android-arm64.sh b/scripts/check-android-arm64.sh index 3b65ee0..3fa077b 100755 --- a/scripts/check-android-arm64.sh +++ b/scripts/check-android-arm64.sh @@ -123,20 +123,26 @@ done echo "Checking NDK C ABI headers" "$cc" -std=c11 -Wall -Werror -fsyntax-only "$ROOT/testdata/android_abi_probe.c" +# Full `go vet` here runs the complete analyzer set (unlike `go test`'s vet +# subset). Disable unsafeptr: goffi reconstructs pointers from native call +# registers and stack slots by design (see ffi/callback_pointer.go's +# //go:nocheckptr contract). ci.yml runs vet through golangci-lint, which +# excludes govet on exactly those paths (.golangci.yml); -unsafeptr=false is the +# bare-vet equivalent of that exclusion, matching universal.yml. for cgo in 0 1; do echo "Building Android arm64 (CGO_ENABLED=$cgo)" if [[ "$cgo" == 1 ]]; then CC="$cc" GOOS=android GOARCH=arm64 CGO_ENABLED=1 \ go test -exec=true ./... 2>&1 CC="$cc" GOOS=android GOARCH=arm64 CGO_ENABLED=1 \ - go vet ./... + go vet -unsafeptr=false ./... CC="$cc" GOOS=android GOARCH=arm64 CGO_ENABLED=1 \ go test -c -o "$tmp/ffi-cgo.test" ./ffi else GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ go test -exec=true ./... 2>&1 GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ - go vet ./... + go vet -unsafeptr=false ./... GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ go test -c -o "$tmp/ffi-nocgo.test" ./ffi fi From 11dbee235682335af81b09a8e0961dea70ae96df Mon Sep 17 00:00:00 2001 From: goffi-ci-fix Date: Wed, 2 Sep 2026 21:11:00 +0000 Subject: [PATCH 21/24] test: cover callback pointer paths and the unsupported-arch guard Raises coverage of the files Codecov flagged on the PR (ffi 90.8% -> 94.8% of statements), adding tests only; no production code changes. callback.go (callbackWrap 88.5% -> 100%): the existing callback tests never pass pointer, unsafe.Pointer, or stack-spilled arguments, nor a pointer return. New tests drive callbackWrap directly with a hand-built System V AMD64 argument frame (the same technique callback_struct_args_test.go uses) to exercise: a typed pointer and an unsafe.Pointer in an integer register and spilled to the stack, a bool spilled to the stack, and a pointer return value. Guarded to linux/darwin/freebsd + amd64 + !goffi_static, matching the frame layout these paths assume. call.go (executeFunction 66.7% -> 83.3%): add a test for the guard that returns ErrUnsupportedArchitecture when no architecture caller is registered, by swapping arch.Registry.Caller to nil and restoring it (ffi tests run sequentially, so no concurrent call observes the nil). The one remaining uncovered line is the `if static.Enabled` guard returning ErrStaticBuild: static.Enabled is a compile-time-false constant outside goffi_static, so that branch is unreachable in the CGO_ENABLED=0 build Codecov measures, and can only be covered by a static-tagged build. Verified with -race and under GOOS=android GOARCH=arm64 (the amd64-only callback test is correctly excluded there); golangci-lint clean. --- ffi/call_unsupported_arch_test.go | 32 +++++++ ffi/callback_pointer_args_test.go | 133 ++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 ffi/call_unsupported_arch_test.go create mode 100644 ffi/callback_pointer_args_test.go diff --git a/ffi/call_unsupported_arch_test.go b/ffi/call_unsupported_arch_test.go new file mode 100644 index 0000000..de52ad0 --- /dev/null +++ b/ffi/call_unsupported_arch_test.go @@ -0,0 +1,32 @@ +//go:build !goffi_static + +package ffi + +import ( + "errors" + "testing" + "unsafe" + + "github.com/go-webgpu/goffi/internal/arch" + "github.com/go-webgpu/goffi/types" +) + +// TestCallFunctionUnsupportedArchitecture covers executeFunction's guard that +// rejects a call when no architecture caller is registered, which is the state +// on a GOARCH goffi does not implement. arch.Registry is a process-global, so +// the caller is swapped out and restored within this one test; ffi tests run +// sequentially (none call t.Parallel), so no concurrent call observes the nil +// caller. A non-nil cif and fn are required only to pass CallFunctionContext's +// argument validation; the guard returns before either is dereferenced. +func TestCallFunctionUnsupportedArchitecture(t *testing.T) { + saved := arch.Registry.Caller + arch.Registry.Caller = nil + t.Cleanup(func() { arch.Registry.Caller = saved }) + + var dummy int + cif := &types.CallInterface{} + _, err := CallFunction(cif, unsafe.Pointer(&dummy), nil, nil) + if !errors.Is(err, types.ErrUnsupportedArchitecture) { + t.Fatalf("CallFunction with no registered caller: got %v, want ErrUnsupportedArchitecture", err) + } +} diff --git a/ffi/callback_pointer_args_test.go b/ffi/callback_pointer_args_test.go new file mode 100644 index 0000000..fb26107 --- /dev/null +++ b/ffi/callback_pointer_args_test.go @@ -0,0 +1,133 @@ +//go:build ((linux && !android) || darwin || freebsd) && amd64 && !goffi_static + +package ffi + +import ( + "runtime" + "testing" + "unsafe" +) + +// These tests drive callbackWrap directly with a hand-built System V AMD64 +// argument frame, exactly as the assembly trampoline would. They cover the +// pointer, unsafe.Pointer, and stack-spilled argument decode paths and the +// pointer return path, which the higher-level callback tests do not reach. +// +// Frame layout: [XMM0-7: 8 slots][RDI,RSI,RDX,RCX,R8,R9: 6 slots][stack...]. +const ( + cbFloatRegs = 8 // XMM0-XMM7 + cbIntRegs = 6 // RDI, RSI, RDX, RCX, R8, R9 + cbStackBase = cbFloatRegs + cbIntRegs // first stack argument slot +) + +// invokeCallback registers cb, drives callbackWrap with frame, and returns the +// marshaled return value (callbackArgs.result). +func invokeCallback(t *testing.T, cb any, frame *[128]uintptr) uintptr { + t.Helper() + ptr := NewCallback(cb) + if ptr == 0 { + t.Fatal("NewCallback returned nil pointer") + } + a := &callbackArgs{index: callbackIndex(ptr), args: unsafe.Pointer(frame)} + callbackWrap(a) + return a.result +} + +// fillIntRegs sets the six integer argument registers to distinct nonzero +// values so that the next argument is forced onto the stack. +func fillIntRegs(frame *[128]uintptr) { + for i := 0; i < cbIntRegs; i++ { + frame[cbFloatRegs+i] = uintptr(int64(i + 1)) + } +} + +func TestCallbackPointerArgInRegister(t *testing.T) { + want := new(int64) + *want = 0x0BADC0DE + var got *int64 + + var frame [128]uintptr + frame[cbFloatRegs] = uintptr(unsafe.Pointer(want)) // RDI + invokeCallback(t, func(p *int64) { got = p }, &frame) + runtime.KeepAlive(want) + + if got != want { + t.Fatalf("pointer arg: got %p, want %p", got, want) + } + if *got != *want { + t.Errorf("deref: got %#x, want %#x", *got, *want) + } +} + +func TestCallbackPointerArgOnStack(t *testing.T) { + want := new(int64) + *want = 0x0FACADE + var got *int64 + + var frame [128]uintptr + fillIntRegs(&frame) + frame[cbStackBase] = uintptr(unsafe.Pointer(want)) // seventh arg spills to stack + invokeCallback(t, func(_, _, _, _, _, _ int64, p *int64) { got = p }, &frame) + runtime.KeepAlive(want) + + if got != want { + t.Fatalf("stack pointer arg: got %p, want %p", got, want) + } + if *got != *want { + t.Errorf("deref: got %#x, want %#x", *got, *want) + } +} + +func TestCallbackUnsafePointerArgInRegister(t *testing.T) { + want := new(int64) + var got unsafe.Pointer + + var frame [128]uintptr + frame[cbFloatRegs] = uintptr(unsafe.Pointer(want)) // RDI + invokeCallback(t, func(p unsafe.Pointer) { got = p }, &frame) + runtime.KeepAlive(want) + + if got != unsafe.Pointer(want) { + t.Fatalf("unsafe.Pointer arg: got %p, want %p", got, unsafe.Pointer(want)) + } +} + +func TestCallbackUnsafePointerArgOnStack(t *testing.T) { + want := new(int64) + var got unsafe.Pointer + + var frame [128]uintptr + fillIntRegs(&frame) + frame[cbStackBase] = uintptr(unsafe.Pointer(want)) // seventh arg spills to stack + invokeCallback(t, func(_, _, _, _, _, _ int64, p unsafe.Pointer) { got = p }, &frame) + runtime.KeepAlive(want) + + if got != unsafe.Pointer(want) { + t.Fatalf("stack unsafe.Pointer arg: got %p, want %p", got, unsafe.Pointer(want)) + } +} + +func TestCallbackBoolArgOnStack(t *testing.T) { + var got bool + + var frame [128]uintptr + fillIntRegs(&frame) + frame[cbStackBase] = 1 // seventh arg (bool) spills to stack + invokeCallback(t, func(_, _, _, _, _, _ int64, b bool) { got = b }, &frame) + + if !got { + t.Error("stack bool arg: got false, want true") + } +} + +func TestCallbackPointerReturn(t *testing.T) { + want := new(int64) + + var frame [128]uintptr + res := invokeCallback(t, func() *int64 { return want }, &frame) + runtime.KeepAlive(want) + + if res != uintptr(unsafe.Pointer(want)) { + t.Fatalf("pointer return: got %#x, want %#x", res, uintptr(unsafe.Pointer(want))) + } +} From 6bf5dbf44a947412dae23181883e357a5f8a1ef2 Mon Sep 17 00:00:00 2001 From: goffi-ci-fix Date: Wed, 2 Sep 2026 21:22:01 +0000 Subject: [PATCH 22/24] ci: run the Android arm64 gate on Go 1.25.12 as well The target branch's protection requires an "Android arm64 (Go 1.25.12)" status check, but this branch's matrix only produced the 1.26.5 and 1.26.x jobs, so that required check was never reported and the PR could not merge. Add 1.25.12 to the android-cross matrix so the job (named "Android arm64 (Go ${{ matrix.go }})") produces the expected check, and add go1.25.12 to check-android-arm64.sh's audited-toolchain allowlist so the version tripwire admits it. The allowlist exists because this port depends on the Go runtime's Android arm64 startup ABI, which must be re-audited per Go version. That audit was done for 1.25.12: the _cgo_init callsite in runtime/asm_arm64.s, the tls_g slot-2 offset in tls_arm64.s, and the TLS_SLOT_APP / API-level probe in runtime/cgo/gcc_android.c are byte-for-byte the same invariants the gate already checks for 1.26.x; the generated fakecgo sources regenerate identically; the cross-compiled android/arm64 objects pass the trampoline, TLS-guard, and dlerror-capture objdump checks; and `go vet` and the test build succeed under 1.25.12. The tripwire still rejects unaudited versions. --- .github/workflows/ci.yml | 2 +- scripts/check-android-arm64.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8f9112..ffab80b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,7 +221,7 @@ jobs: strategy: fail-fast: false matrix: - go: ['1.26.5', '1.26.x'] + go: ['1.25.12', '1.26.5', '1.26.x'] steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/scripts/check-android-arm64.sh b/scripts/check-android-arm64.sh index 3fa077b..ff5498a 100755 --- a/scripts/check-android-arm64.sh +++ b/scripts/check-android-arm64.sh @@ -31,7 +31,7 @@ readelf="$toolchain/llvm-readelf" # lines, or when any audited source invariant changes. go_version=$(go env GOVERSION) case "$go_version" in - go1.26.5|go1.26.6|go1.26.7) ;; + go1.25.12|go1.26.5|go1.26.6|go1.26.7) ;; *) echo "unsupported Go runtime source for Android fakecgo: $go_version" >&2 echo "audit the new runtime/cgo Android arm64 startup ABI before extending this gate" >&2 From a172b9e3d395086254a3d584caa499caa1909b6a Mon Sep 17 00:00:00 2001 From: goffi contributor Date: Wed, 2 Sep 2026 22:23:30 +0000 Subject: [PATCH 23/24] fakecgo: survive a host with no dynamic loader instead of faulting A universal binary that cannot find a host loader had nothing to re-exec through, so it never bound a libc -- and then went on to call one. The bridge printed "no known host dynamic loader found; FFI unavailable" and returned, x_cgo_init continued straight into malloc() and the pthread_attr_* trio, and the process died on an unbound symbol before main. The message was accurate about FFI and wrong about everything else: nothing was available, including the parts of the program that never wanted FFI. That is a real class of host -- a scratch container, a distribution that keeps ld.so somewhere else -- and it is the one thing a goffi_static build could do that universal mode could not, which is awkward when universal mode is meant to replace it. So the bridge now reports its outcome instead of only its failure. Its body becomes reexecUniversal, returning whether this process has a libc: true only on the guard-variable path (we already came back through the host loader), false on every path that leaves the empty-SONAME imports unbound. When it is false, maybeReexecUniversal records that in the new internal/hostlibc package, which is deliberately dependency-free because it is written from x_cgo_init, before the runtime is up, where only a plain store to a package-level variable is safe. x_cgo_init then returns early rather than touching libc, and clears runtime.iscgo on the way out. That is what makes the process survivable: the runtime read _cgo_init long before this and took the branch that delegates TLS setup to us (setupUniversalTLS already did it, onto a scratch page), but every later decision -- pthread_create versus clone(2) for a new M, g in TLS versus the g register on arm64, how signal handlers are installed -- is made by reading iscgo at the point of use. False from here on means the runtime never reaches for the libc that is not there and threads it starts set up their own TLS, exactly as in a CGO_ENABLED=0 build. g.stacklo keeps the bounds rt0_go computed; the pthread_attr_getstacksize refinement is what a non-cgo binary does without anyway. The FFI surface then has to answer for the state rather than fault in it: - internal/dl's Dlopen, Dlsym and Dlclose return hostlibc.ErrMissing. - ffi.CallFunction's guard sits next to the existing goffi_static one. - ffi.Available() reports false, which is the honest answer and now a run-time one: the same binary has full FFI on any host with a glibc or musl loader, so this cannot be decided from build tags. - ffi.ErrNoHostLibc exposes the sentinel, mirroring ErrStaticBuild. Verified on linux/amd64 with a rootfs containing no loader at all: goffi's own universal-probe, a 64-thread allocate-and-GC stress binary, and a large real application (unxed/f4) all started, ran and exited 0 there, where every one of them took SIGSEGV before this change; LoadLibrary returns the wrapped sentinel and Available() is false. Unchanged on hosts that do have a loader -- the probe is still green end to end on glibc and on musl (Alpine), for the amd64 and arm64 binaries alike. gofmt, go vet -unsafeptr=false and the test suite pass in the default, universal and static modes. --- docs/PROFILE_U.md | 10 +++- ffi/available.go | 20 +++++-- ffi/call.go | 7 +++ ffi/errors.go | 11 ++++ ffi/nohostlibc_test.go | 65 ++++++++++++++++++++++ internal/dl/dl_unix.go | 17 ++++++ internal/fakecgo/go_linux_amd64.go | 28 +++++++++- internal/fakecgo/go_linux_arm64.go | 28 +++++++++- internal/fakecgo/reexec_universal_linux.go | 50 +++++++++++++---- internal/hostlibc/hostlibc.go | 46 +++++++++++++++ 10 files changed, 260 insertions(+), 22 deletions(-) create mode 100644 ffi/nohostlibc_test.go create mode 100644 internal/hostlibc/hostlibc.go diff --git a/docs/PROFILE_U.md b/docs/PROFILE_U.md index 62a2a4c..9d59953 100644 --- a/docs/PROFILE_U.md +++ b/docs/PROFILE_U.md @@ -41,8 +41,14 @@ ffi.LibcKind() // "glibc" | "musl" | "unknown" ## Limitations - **Linux only.** amd64 is run-tested; arm64 is cross-compile-verified. -- A host whose dynamic loader goffi does not recognise cannot do FFI (the - process cannot bind a libc); that is a hard limitation of universal mode. +- A host whose dynamic loader goffi does not recognise cannot do FFI: there is + nothing to re-exec through, so the process never binds a libc. It still + runs. The bridge says so once on stderr, clears `runtime.iscgo` so the Go + runtime creates threads with `clone(2)` and manages their TLS itself, and + from then on the binary behaves like one built without FFI: `ffi.Available()` + reports false, and `LoadLibrary`, `GetSymbol` and `CallFunction` return + `ffi.ErrNoHostLibc` rather than jumping to an unbound symbol. Branch on + `ffi.Available()` at startup if there is a pure-Go fallback to pick. - After the re-exec, `argv[0]` becomes the resolved executable path. - The universal build **owns the cgo runtime**; do not combine it with purego's fakecgo. To run goffi alongside purego, use the default build with diff --git a/ffi/available.go b/ffi/available.go index 4e92a6c..1e2c566 100644 --- a/ffi/available.go +++ b/ffi/available.go @@ -3,16 +3,24 @@ package ffi -import "github.com/go-webgpu/goffi/internal/static" +import ( + "github.com/go-webgpu/goffi/internal/hostlibc" + "github.com/go-webgpu/goffi/internal/static" +) // Available reports whether this build of goffi can load shared libraries and // call foreign functions. // -// It returns false only when the binary was built with -tags goffi_static, a -// mode that strips every //go:cgo_import_dynamic directive so the Go linker -// produces a fully static executable (no PT_INTERP, no DT_NEEDED). In that mode +// It returns false in two cases. The binary was built with -tags goffi_static, +// a mode that strips every //go:cgo_import_dynamic directive so the Go linker +// produces a fully static executable (no PT_INTERP, no DT_NEEDED); there // LoadLibrary, GetSymbol and CallFunction return an error wrapping -// ErrStaticBuild instead of calling into libc. +// ErrStaticBuild instead of calling into libc. Or the binary is a universal +// ("Profile U") build running on a host whose dynamic loader goffi does not +// recognise, so it never bound a libc at startup; there the same three report +// ErrNoHostLibc. The second case is a property of the machine rather than of +// the build, so it can only be answered at run time -- which is the reason to +// ask this function rather than to reason about build tags. // // Callers that have a pure-Go fallback should branch on this at startup rather // than treating the first LoadLibrary failure as fatal: @@ -27,5 +35,5 @@ import "github.com/go-webgpu/goffi/internal/static" // never went through cgo_import_dynamic, so Available reports true there even // when the tag is set. func Available() bool { - return !static.Enabled + return !static.Enabled && !hostlibc.Missing } diff --git a/ffi/call.go b/ffi/call.go index 7dd524b..90e4ae0 100644 --- a/ffi/call.go +++ b/ffi/call.go @@ -4,6 +4,7 @@ import ( "unsafe" "github.com/go-webgpu/goffi/internal/arch" + "github.com/go-webgpu/goffi/internal/hostlibc" "github.com/go-webgpu/goffi/internal/static" gosyscall "github.com/go-webgpu/goffi/internal/syscall" "github.com/go-webgpu/goffi/types" @@ -24,6 +25,12 @@ func executeFunction( // LoadLibrary and GetSymbol also fail in this mode. return 0, ErrStaticBuild } + if hostlibc.Missing { + // Same reasoning as the static guard: no libc means the errno import + // and the callee itself are unbound. LoadLibrary and GetSymbol fail + // in this mode too, so fn cannot legitimately have come from goffi. + return 0, ErrNoHostLibc + } if arch.Registry.Caller == nil { return 0, types.ErrUnsupportedArchitecture } diff --git a/ffi/errors.go b/ffi/errors.go index 9bdce61..0959c6e 100644 --- a/ffi/errors.go +++ b/ffi/errors.go @@ -3,6 +3,7 @@ package ffi import ( "fmt" + "github.com/go-webgpu/goffi/internal/hostlibc" "github.com/go-webgpu/goffi/internal/static" ) @@ -162,6 +163,16 @@ func (e *TypeValidationError) Is(target error) bool { // errors.Is(err, ffi.ErrStaticBuild). var ErrStaticBuild = static.ErrDisabled +// ErrNoHostLibc is returned, usually wrapped in a *LibraryError, by every +// operation that needs libc when a universal ("Profile U") binary is running +// on a system with no dynamic loader goffi recognises, so startup could not +// bind one: LoadLibrary, GetSymbol and CallFunction. +// +// Unlike ErrStaticBuild this is not a property of the build -- the same binary +// has full FFI on any host with a glibc or musl loader. Check Available at +// startup, or errors.Is(err, ffi.ErrNoHostLibc) at the call site. +var ErrNoHostLibc = hostlibc.ErrMissing + // Deprecated: Legacy sentinel errors kept for backwards compatibility. // Use typed errors above with errors.As() for better error handling. var ( diff --git a/ffi/nohostlibc_test.go b/ffi/nohostlibc_test.go new file mode 100644 index 0000000..887964f --- /dev/null +++ b/ffi/nohostlibc_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static + +package ffi + +import ( + "errors" + "testing" + "unsafe" + + "github.com/go-webgpu/goffi/internal/hostlibc" + "github.com/go-webgpu/goffi/types" +) + +// TestNoHostLibcIsReportedNotFatal covers the state a universal binary lands +// in on a host with no dynamic loader goffi recognises: startup could not bind +// a libc, so every imported symbol is unbound and calling one would fault. +// +// The bridge sets hostlibc.Missing there, before the runtime starts, and the +// point of the flag is that the process then behaves like a build without FFI +// rather than crashing -- Available says so up front, and the three entry +// points that need libc return ErrNoHostLibc. Reaching that state for real +// needs a machine with no ld.so, so the flag is set directly here; ffi's tests +// run sequentially, so no concurrent call observes it. +func TestNoHostLibcIsReportedNotFatal(t *testing.T) { + saved := hostlibc.Missing + hostlibc.Missing = true + t.Cleanup(func() { hostlibc.Missing = saved }) + + if Available() { + t.Error("Available() = true with no host libc, want false") + } + + if _, err := LoadLibrary("libc.so.6"); !errors.Is(err, ErrNoHostLibc) { + t.Errorf("LoadLibrary error = %v, want one wrapping ErrNoHostLibc", err) + } + + if _, err := GetSymbol(nil, "strlen"); !errors.Is(err, ErrNoHostLibc) { + t.Errorf("GetSymbol error = %v, want one wrapping ErrNoHostLibc", err) + } + + // executeFunction is the guard CallFunction goes through. A caller cannot + // legitimately hold a foreign function pointer in this mode -- the two + // calls above are the only ways to get one, and both fail -- so this is + // belt and braces, checked with a pointer that must never be dereferenced. + var cif types.CallInterface + if _, err := executeFunction(&cif, unsafe.Pointer(uintptr(1)), nil, nil); !errors.Is(err, ErrNoHostLibc) { + t.Errorf("executeFunction error = %v, want ErrNoHostLibc", err) + } +} + +// TestHostLibcPresentByDefault guards the flag's default: every build that is +// not the universal one, and every universal build on a host goffi can bind a +// libc on, must leave it false. A stray set would silently disable FFI +// everywhere. +func TestHostLibcPresentByDefault(t *testing.T) { + if hostlibc.Missing { + t.Fatal("hostlibc.Missing is set on a host that has a libc") + } + if !Available() { + t.Error("Available() = false in a non-static build with a libc") + } +} diff --git a/internal/dl/dl_unix.go b/internal/dl/dl_unix.go index eae0df0..154c2d2 100644 --- a/internal/dl/dl_unix.go +++ b/internal/dl/dl_unix.go @@ -18,6 +18,8 @@ import ( "fmt" "structs" "unsafe" + + "github.com/go-webgpu/goffi/internal/hostlibc" ) // RTLD constants are platform-specific - see dl_linux.go and dl_darwin.go @@ -79,6 +81,13 @@ var dlerror_wrapperABI0 uintptr // Dlopen loads a shared library func Dlopen(path string, mode int) (uintptr, error) { + // A universal binary that could not reach a host loader has no libc, so + // the dlopen import below is unbound and calling it faults. See + // internal/hostlibc. + if hostlibc.Missing { + return 0, hostlibc.ErrMissing + } + // Convert Go string to C string pathBytes := append([]byte(path), 0) @@ -100,6 +109,10 @@ func Dlopen(path string, mode int) (uintptr, error) { // Dlsym returns the address of a symbol in a loaded library func Dlsym(handle uintptr, name string) (uintptr, error) { + if hostlibc.Missing { + return 0, hostlibc.ErrMissing + } + // Convert Go string to C string nameBytes := append([]byte(name), 0) @@ -121,6 +134,10 @@ func Dlsym(handle uintptr, name string) (uintptr, error) { // Dlclose unloads a dynamic library func Dlclose(handle uintptr) error { + if hostlibc.Missing { + return hostlibc.ErrMissing + } + // Not implemented yet return nil } diff --git a/internal/fakecgo/go_linux_amd64.go b/internal/fakecgo/go_linux_amd64.go index 972d9c2..591c8d9 100644 --- a/internal/fakecgo/go_linux_amd64.go +++ b/internal/fakecgo/go_linux_amd64.go @@ -6,7 +6,11 @@ package fakecgo -import "unsafe" +import ( + "unsafe" + + "github.com/go-webgpu/goffi/internal/hostlibc" +) //go:nosplit func _cgo_sys_thread_start(ts *ThreadStart) { @@ -73,6 +77,28 @@ func x_cgo_init(g *G, setg uintptr) { // reexec_universal_linux.go. setupUniversalTLS() maybeReexecUniversal() + if hostlibc.Missing { + // The bridge could not reach a libc, so every symbol this function + // would use next -- malloc, the pthread_attr_* trio -- is unbound and + // calling one faults. Give the process back to the Go runtime as a + // pure-Go program instead of dying here. + // + // Clearing iscgo is what makes that work. The runtime read _cgo_init + // long before this call and took the branch that delegates TLS setup + // to us (setupUniversalTLS did it, onto a scratch page), but every + // later decision -- creating an M with pthread_create versus clone(2), + // keeping g in TLS versus the g register, installing signal handlers + // the cgo way -- is made by reading runtime.iscgo at the point of use. + // False from here on means the runtime never calls into the libc that + // is not there, and threads it starts set up their own TLS. + // + // g.stacklo keeps the bounds rt0_go computed (SP minus 64 KiB); the + // pthread_attr_getstacksize refinement below is exactly what a + // CGO_ENABLED=0 binary does without, so nothing is lost. + _iscgo = false + setg_func = setg + return + } var size size_t var attr *pthread_attr_t diff --git a/internal/fakecgo/go_linux_arm64.go b/internal/fakecgo/go_linux_arm64.go index dd9d329..039af67 100644 --- a/internal/fakecgo/go_linux_arm64.go +++ b/internal/fakecgo/go_linux_arm64.go @@ -6,7 +6,11 @@ package fakecgo -import "unsafe" +import ( + "unsafe" + + "github.com/go-webgpu/goffi/internal/hostlibc" +) //go:nosplit func _cgo_sys_thread_start(ts *ThreadStart) { @@ -78,6 +82,28 @@ func x_cgo_init(g *G, setg uintptr) { // reexec_universal_linux.go. setupUniversalTLS() maybeReexecUniversal() + if hostlibc.Missing { + // The bridge could not reach a libc, so every symbol this function + // would use next -- malloc, the pthread_attr_* trio -- is unbound and + // calling one faults. Give the process back to the Go runtime as a + // pure-Go program instead of dying here. + // + // Clearing iscgo is what makes that work. The runtime read _cgo_init + // long before this call and took the branch that delegates TLS setup + // to us (setupUniversalTLS did it, onto a scratch page), but every + // later decision -- creating an M with pthread_create versus clone(2), + // keeping g in TLS versus the g register, installing signal handlers + // the cgo way -- is made by reading runtime.iscgo at the point of use. + // False from here on means the runtime never calls into the libc that + // is not there, and threads it starts set up their own TLS. + // + // g.stacklo keeps the bounds rt0_go computed (SP minus 64 KiB); the + // pthread_attr_getstacksize refinement below is exactly what a + // CGO_ENABLED=0 binary does without, so nothing is lost. + _iscgo = false + setg_func = setg + return + } var size size_t var attr *pthread_attr_t diff --git a/internal/fakecgo/reexec_universal_linux.go b/internal/fakecgo/reexec_universal_linux.go index cf7242d..64adf1c 100644 --- a/internal/fakecgo/reexec_universal_linux.go +++ b/internal/fakecgo/reexec_universal_linux.go @@ -58,7 +58,11 @@ package fakecgo // restoring it is a single field write. Either way the loader then binds the // symbols from the pre-loaded libc. -import "unsafe" +import ( + "unsafe" + + "github.com/go-webgpu/goffi/internal/hostlibc" +) func rawsyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1 uintptr) @@ -328,29 +332,50 @@ func copyExeToMemfd(exeFd, memfd uintptr, buf unsafe.Pointer) bool { // launch of a universal binary it re-execs through the host loader with libc // pre-loaded; on the re-executed launch (guard present) it returns immediately. // +// maybeReexecUniversal is invoked at the very top of x_cgo_init. It either +// hands the process a libc or records that it could not. +// +// Failing to is not fatal by itself: x_cgo_init then drops the process back to +// a pure-Go runtime (see go_linux_{amd64,arm64}.go) and the FFI entry points +// report hostlibc.ErrMissing instead of jumping to unbound symbols. Recording +// it is what makes that possible, so every failure path below has to be seen +// -- hence the split into a function that returns whether a libc is there. +// //go:nosplit func maybeReexecUniversal() { + if !reexecUniversal() { + hostlibc.Missing = true + } +} + +// reexecUniversal reports whether this process has a libc. It returns true +// only when the guard variable shows we already came through the host loader, +// and false on every path that leaves the empty-SONAME imports unbound. When +// the re-exec succeeds it does not return at all. +// +//go:nosplit +func reexecUniversal() bool { // Staging buffer for C strings first: everything below needs cstr(). sb := mmapAnon(strBufCap) if sb == nil { - return + return false } strBufBase = uintptr(sb) strBufOff = 0 envBase := mmapAnon(envBufCap) if envBase == nil { - return + return false } envLen := readAll(cstr("/proc/self/environ"), envBase, envBufCap) if envLen < 0 { - return + return false } // Guard: if we already re-executed, do nothing. for off := 0; off < envLen; { if matchAt(envBase, off, envLen, guardKey) { - return + return true } for off < envLen && *(*byte)(unsafe.Add(envBase, off)) != 0 { off++ @@ -371,8 +396,8 @@ func maybeReexecUniversal() { sonameC = cstr(muslLibc) } if loaderC == nil || sonameC == nil { - diag("goffi: universal build: no known host dynamic loader found; FFI unavailable\n") - return + diag("goffi: universal build: no known host dynamic loader found; continuing without FFI\n") + return false } // Resolve our own executable path for the loader to run. @@ -408,22 +433,22 @@ func maybeReexecUniversal() { // Read original argv from /proc/self/cmdline (NUL-delimited). cmdBase := mmapAnon(cmdBufCap) if cmdBase == nil { - return + return false } cmdLen := readAll(cstr("/proc/self/cmdline"), cmdBase, cmdBufCap) if cmdLen < 0 { - return + return false } // Build argv: {loader, "--preload", soname, self, , NULL} argvBase := mmapAnon(ptrArrSize) envpBase := mmapAnon(ptrArrSize) if argvBase == nil || envpBase == nil { - return + return false } preloadC := cstr("--preload") if preloadC == nil { - return + return false } ai := 0 setPtr(argvBase, ai, uintptr(unsafe.Pointer(loaderC))) @@ -476,5 +501,6 @@ func maybeReexecUniversal() { uintptr(envpBase), 0, 0, 0) // Only reached if execve failed. - diag("goffi: universal build: re-exec through host loader failed; FFI unavailable\n") + diag("goffi: universal build: re-exec through host loader failed; continuing without FFI\n") + return false } diff --git a/internal/hostlibc/hostlibc.go b/internal/hostlibc/hostlibc.go new file mode 100644 index 0000000..5562055 --- /dev/null +++ b/internal/hostlibc/hostlibc.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Package hostlibc records whether the running process actually has a libc. +// +// It exists for the universal ("Profile U") build, which is the only mode in +// which a goffi binary can start on a system where it cannot reach one. Such a +// binary names no libc and has no ELF interpreter, so the kernel loads it +// directly and goffi brings libc up itself by re-executing through the host's +// dynamic loader. On a host with no loader goffi recognises -- a scratch +// container, a distribution that keeps ld.so somewhere else -- there is nothing +// to re-execute through, and the process runs on with every imported libc +// symbol unbound. +// +// Missing says so. Setting it lets startup drop back to a pure-Go runtime +// instead of calling into unbound symbols, and lets the FFI entry points fail +// with an error rather than a fault. +// +// This package deliberately imports nothing. It is written from x_cgo_init, +// before the Go runtime is up: only a plain store to a package-level variable +// is safe there. +package hostlibc + +// Missing reports that this process has no libc, so nothing that needs one -- +// dlopen, dlsym, any foreign call -- can work. +// +// It is set once, from the universal build's re-exec bridge, before the Go +// runtime starts and therefore before anything can read it; it is false in +// every other build and on every host where the bridge succeeds. Nothing +// clears it, so no synchronisation is needed to read it. +var Missing bool + +// missingError is a distinct type so ErrMissing needs no initialization at +// run time: the compiler lays the value out statically, which keeps this +// package free of an init function on the startup path. +type missingError struct{} + +func (missingError) Error() string { + return "goffi: universal build: this system has no dynamic loader goffi recognises, " + + "so the process could not bind a libc and FFI is unavailable" +} + +// ErrMissing is returned, usually wrapped in a *ffi.LibraryError, by every +// operation that needs libc when Missing is set: library loading, symbol +// lookup and foreign calls. +var ErrMissing error = missingError{} From fea4dfc7501e4907ef2cad251e8a93708e1ade2c Mon Sep 17 00:00:00 2001 From: f4 contributor Date: Thu, 3 Sep 2026 10:56:38 +0000 Subject: [PATCH 24/24] fakecgo: record argv[0] and the executable path before the re-exec takes them A universal binary reaches libc by re-execing itself through the host loader, and that execve costs the process its own identity. Afterwards /proc/self/exe names the loader, so os.Executable answers with /usr/lib//ld-linux-*.so.2; argv[0] names the image the loader was handed, which on glibc is a memfd copy and names no file at all. Nothing in the process knows better, because the only moment at which both facts are still true is inside the bridge, just before it calls execve. That is a real loss rather than a curiosity. unxed/f4 hit both halves of it: its updater takes filepath.Dir(os.Executable()) as the install directory and its writeFileSafe escalates through sudo when a write is refused, so an update from a universal build unpacks the release into the system library directory next to the loader -- while the binary the user actually runs is untouched. Portable-mode detection looks for ini files in the same wrong place. So the bridge now writes down what it is about to take: GOFFI_UNIVERSAL_EXE=: GOFFI_UNIVERSAL_ARGV0=: Both are pid-tagged. The environment is inherited by every child, and a child that read GOFFI_UNIVERSAL_EXE as being about itself would get its parent's binary -- exactly the class of wrong answer this exists to remove. execve keeps the pid, so the re-executed process still matches, and a child (a new pid) is told nothing rather than something false. ffi.Executable and ffi.Argv0 read them back, falling through to os.Executable and os.Args[0] wherever there was no re-exec, so callers do not have to know which build they are in. Both values are recorded only when they are real: an unreadable /proc/self/exe or an empty /proc/self/cmdline leaves the variable out, and a missing variable is a better answer than a guessed one. Everything here still runs before libc exists, so taggedEnv formats into the same mmap staging buffer the rest of the bridge uses, with no allocation and no libc; getpid is the only syscall added, and it is in both architecture tables. Also documents in PROFILE_U.md what argv[0] and /proc/self/exe really hold after the re-exec -- the old note said argv[0] became "the resolved executable path", which is true only on musl -- and how to start another copy of a universal binary, since the inherited guard makes the obvious exec.Command(os.Args[0]) fatal. Verified on glibc/amd64 with a stripped universal binary: ffi.Executable() returns the on-disk path where os.Executable() returns the loader, ffi.Argv0() returns the invocation name, and a child process correctly declines the inherited record. Cross-builds clean for arm64. --- docs/PROFILE_U.md | 35 +++++++- ffi/selfinfo.go | 80 ++++++++++++++++++ ffi/selfinfo_test.go | 65 +++++++++++++++ internal/fakecgo/reexec_table_amd64.go | 1 + internal/fakecgo/reexec_table_arm64.go | 1 + internal/fakecgo/reexec_universal_linux.go | 94 +++++++++++++++++++++- 6 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 ffi/selfinfo.go create mode 100644 ffi/selfinfo_test.go diff --git a/docs/PROFILE_U.md b/docs/PROFILE_U.md index 9d59953..76c4e22 100644 --- a/docs/PROFILE_U.md +++ b/docs/PROFILE_U.md @@ -49,7 +49,20 @@ ffi.LibcKind() // "glibc" | "musl" | "unknown" reports false, and `LoadLibrary`, `GetSymbol` and `CallFunction` return `ffi.ErrNoHostLibc` rather than jumping to an unbound symbol. Branch on `ffi.Available()` at startup if there is a pure-Go fallback to pick. -- After the re-exec, `argv[0]` becomes the resolved executable path. +- After the re-exec, `argv[0]` is the image the loader was handed: the resolved + executable path on musl, and on glibc the `/proc/self/fd/` memfd copy with + the interpreter header restored. `/proc/self/exe` — and therefore + `os.Executable` — names the loader. Both are recorded before the re-exec and + can be read back: + + ```go + ffi.Executable() // where this binary lives on disk + ffi.Argv0() // what it was invoked as + ``` + + A program that re-runs, updates or installs itself, or that looks for files + beside its own binary, wants those rather than `os` — see "Starting another + copy of yourself" below. - The universal build **owns the cgo runtime**; do not combine it with purego's fakecgo. To run goffi alongside purego, use the default build with `-tags nofakecgo` (see `docs/MUSL.md` and the CI `purego-coexistence` job). @@ -65,6 +78,26 @@ ffi.LibcKind() // "glibc" | "musl" | "unknown" pureffi change is required for Profile U — this branch only *adds* public API (`HostLoader`/`HostLibC`/`LibcKind`) and build-tag-gated files. +## Starting another copy of yourself + +`GOFFI_UNIVERSAL_REEXEC` is inherited, and the bridge honours it: a child that +inherits it concludes it already came through the loader and binds no libc. +Under `exec.Command(os.Args[0], ...)` — or any other plain respawn — the child +therefore dies before `main`, as `symbol lookup error: undefined symbol: malloc` +on glibc, or as a jump to an unbound symbol on musl. + +Start the copy the way the bridge would have: + +```go +exec.Command(ffi.HostLoader(), append( + []string{"--preload", ffi.HostLibC(), os.Args[0]}, args...)...) +``` + +`os.Args[0]`, not `ffi.Executable()`: the loader has to be handed the image this +process was loaded from, which on glibc is the memfd and not the file on disk. +The loader shifts `argv` as usual, so the child sees the image as its `argv[0]` +and its own arguments from `argv[1]`. + ## Attribution The Profile U concept — an auditable "no `PT_INTERP`, no `DT_NEEDED`, reach the diff --git a/ffi/selfinfo.go b/ffi/selfinfo.go new file mode 100644 index 0000000..1ce18ef --- /dev/null +++ b/ffi/selfinfo.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import ( + "os" + "strconv" + "strings" +) + +// Environment variables the universal ("Profile U") re-exec bridge writes for +// the process it re-execs; see internal/fakecgo/reexec_universal_linux.go. +// Each value is ":", where pid is the process the bridge described. +// The tag matters because the environment is inherited: execve keeps the pid, +// so the re-executed process still matches, while a child gets a new pid and +// is told nothing about itself by a variable that describes its parent. +const ( + envUniversalExe = "GOFFI_UNIVERSAL_EXE" + envUniversalArgv0 = "GOFFI_UNIVERSAL_ARGV0" +) + +// Executable returns the path of this program's executable file, the way +// os.Executable does, and stays right in a universal ("Profile U") build. +// +// Such a build has no PT_INTERP, so before main it re-execs itself through the +// host's dynamic loader with the host libc pre-loaded. After that execve +// /proc/self/exe -- what os.Executable reads on Linux -- names the loader, and +// on glibc, where the loader is handed a memfd copy of the binary, os.Args[0] +// names nothing that exists on disk. The path is therefore not recoverable +// from the running process; the bridge records it in the environment on the way +// through, and this returns what it recorded. +// +// Programs that re-run, update, or install themselves, or that look for files +// next to their own binary, want this rather than os.Executable. Everything +// else can keep calling os.Executable: outside a universal build the two are +// the same call. +func Executable() (string, error) { + if p, ok := recordedSelf(envUniversalExe); ok { + return p, nil + } + return os.Executable() +} + +// Argv0 returns the name this process was invoked with -- what os.Args[0] would +// have held had the universal bridge not re-execed the process. It returns +// os.Args[0] unchanged outside a universal build, and "" only if os.Args is +// empty. +// +// This is the invocation name, not a path: it can be relative, or a bare name +// resolved through PATH, exactly as the caller wrote it. Use Executable to find +// the file. +func Argv0() string { + if a, ok := recordedSelf(envUniversalArgv0); ok { + return a + } + if len(os.Args) == 0 { + return "" + } + return os.Args[0] +} + +// recordedSelf reads a ":" variable and reports its value if it +// describes this process. A non-empty value for another pid is a variable +// inherited from a parent, which says nothing about us. +func recordedSelf(key string) (string, bool) { + raw := os.Getenv(key) + if raw == "" { + return "", false + } + tag, value, found := strings.Cut(raw, ":") + if !found || value == "" { + return "", false + } + pid, err := strconv.Atoi(tag) + if err != nil || pid != os.Getpid() { + return "", false + } + return value, true +} diff --git a/ffi/selfinfo_test.go b/ffi/selfinfo_test.go new file mode 100644 index 0000000..14550ab --- /dev/null +++ b/ffi/selfinfo_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import ( + "os" + "strconv" + "testing" +) + +func TestExecutableUsesRecordedPath(t *testing.T) { + t.Setenv(envUniversalExe, strconv.Itoa(os.Getpid())+":/opt/app/bin/app") + got, err := Executable() + if err != nil { + t.Fatalf("Executable() error: %v", err) + } + if want := "/opt/app/bin/app"; got != want { + t.Errorf("Executable() = %q, want %q", got, want) + } +} + +func TestExecutableIgnoresInheritedRecord(t *testing.T) { + // A value tagged with another pid was inherited from a parent and + // describes that parent's binary, not ours. + t.Setenv(envUniversalExe, strconv.Itoa(os.Getpid()+1)+":/opt/parent/bin/parent") + want, err := os.Executable() + if err != nil { + t.Skipf("os.Executable unavailable here: %v", err) + } + got, err := Executable() + if err != nil { + t.Fatalf("Executable() error: %v", err) + } + if got != want { + t.Errorf("Executable() = %q, want os.Executable() %q", got, want) + } +} + +func TestArgv0(t *testing.T) { + t.Setenv(envUniversalArgv0, strconv.Itoa(os.Getpid())+":f4") + if got, want := Argv0(), "f4"; got != want { + t.Errorf("Argv0() = %q, want %q", got, want) + } + + t.Setenv(envUniversalArgv0, "") + if got, want := Argv0(), os.Args[0]; got != want { + t.Errorf("Argv0() without a record = %q, want %q", got, want) + } +} + +func TestRecordedSelfRejectsMalformed(t *testing.T) { + for _, raw := range []string{ + "", // unset + "/opt/app/bin/app", // no pid tag + "notapid:/opt/app/bin/app", // unparsable tag + strconv.Itoa(os.Getpid()) + ":", // tagged, no value + strconv.Itoa(os.Getpid()+1) + ":/opt", // another process + } { + t.Setenv(envUniversalExe, raw) + if v, ok := recordedSelf(envUniversalExe); ok { + t.Errorf("recordedSelf(%q) = %q, true; want false", raw, v) + } + } +} diff --git a/internal/fakecgo/reexec_table_amd64.go b/internal/fakecgo/reexec_table_amd64.go index 1fb8f9a..40712e4 100644 --- a/internal/fakecgo/reexec_table_amd64.go +++ b/internal/fakecgo/reexec_table_amd64.go @@ -17,6 +17,7 @@ const ( sysReadlinkat = 267 sysOpenat = 257 sysFaccessat = 269 + sysGetpid = 39 sysMemfdCreate = 319 ) diff --git a/internal/fakecgo/reexec_table_arm64.go b/internal/fakecgo/reexec_table_arm64.go index 4b449eb..584caf8 100644 --- a/internal/fakecgo/reexec_table_arm64.go +++ b/internal/fakecgo/reexec_table_arm64.go @@ -17,6 +17,7 @@ const ( sysReadlinkat = 78 sysOpenat = 56 sysFaccessat = 48 + sysGetpid = 172 sysMemfdCreate = 279 ) diff --git a/internal/fakecgo/reexec_universal_linux.go b/internal/fakecgo/reexec_universal_linux.go index 64adf1c..bae444d 100644 --- a/internal/fakecgo/reexec_universal_linux.go +++ b/internal/fakecgo/reexec_universal_linux.go @@ -84,6 +84,14 @@ const ( guardVar = "GOFFI_UNIVERSAL_REEXEC=1" guardKey = "GOFFI_UNIVERSAL_REEXEC=" + // What the re-exec destroys, recorded before it happens. Both values are + // prefixed with the pid they describe, because the environment they live + // in is inherited by every child: the pid survives execve, so the process + // the bridge re-execed still matches, and a child (a new pid) does not. + // ffi.Executable and ffi.Argv0 read these; see ffi/selfinfo.go. + exeKey = "GOFFI_UNIVERSAL_EXE=" + argv0Key = "GOFFI_UNIVERSAL_ARGV0=" + // interp restore (glibc path) mfdExec = 0x0010 // MFD_EXEC (kernel 6.3+); fall back to 0 on older kernels ptNull = 0 // PT_NULL (what the interp header was stripped to) @@ -128,6 +136,56 @@ func cstr(s string) *byte { return (*byte)(unsafe.Add(unsafe.Pointer(strBufBase), start)) } +// taggedEnv builds ":" NUL-terminated in the staging buffer and +// returns a *byte to it, or nil if val is nil or the buffer is exhausted. val +// is a NUL-terminated C string; its NUL is not copied. +// +//go:nosplit +func taggedEnv(key string, pid uintptr, val *byte) *byte { + if val == nil || strBufBase == 0 { + return nil + } + var digs [20]byte + di := len(digs) + if pid == 0 { + di-- + digs[di] = '0' + } + for v := pid; v > 0; v /= 10 { + di-- + digs[di] = byte('0' + v%10) + } + var valLen uintptr + for *(*byte)(unsafe.Add(unsafe.Pointer(val), valLen)) != 0 { + valLen++ + } + kn := uintptr(len(key)) + dn := uintptr(len(digs) - di) + if strBufOff+kn+dn+1+valLen+1 > strBufCap { + return nil + } + base := unsafe.Pointer(strBufBase) + start := strBufOff + off := start + for i := uintptr(0); i < kn; i++ { + *(*byte)(unsafe.Add(base, off)) = key[i] + off++ + } + for i := uintptr(0); i < dn; i++ { + *(*byte)(unsafe.Add(base, off)) = digs[di+int(i)] + off++ + } + *(*byte)(unsafe.Add(base, off)) = ':' + off++ + for i := uintptr(0); i < valLen; i++ { + *(*byte)(unsafe.Add(base, off)) = *(*byte)(unsafe.Add(unsafe.Pointer(val), i)) + off++ + } + *(*byte)(unsafe.Add(base, off)) = 0 + strBufOff = off + 1 + return (*byte)(unsafe.Add(base, start)) +} + //go:nosplit func fileExists(pathC *byte) bool { if pathC == nil { @@ -400,9 +458,12 @@ func reexecUniversal() bool { return false } - // Resolve our own executable path for the loader to run. + // Resolve our own executable path for the loader to run. realExeC keeps + // that answer even when exeC is replaced by the memfd path below: it is + // the last moment at which the on-disk location of this binary is + // knowable, and it is recorded in the environment further down. exeBase := mmapAnon(exeBufCap) - var exeC *byte + var exeC, realExeC *byte if exeBase != nil { n := rawsyscall6(sysReadlinkat, atFDCWD, uintptr(unsafe.Pointer(cstr("/proc/self/exe"))), @@ -410,6 +471,7 @@ func reexecUniversal() bool { if !sysErr(n) && n != 0 { *(*byte)(unsafe.Add(exeBase, n)) = 0 exeC = (*byte)(exeBase) + realExeC = exeC } } if exeC == nil { @@ -480,8 +542,9 @@ func reexecUniversal() bool { setPtr(argvBase, ai, 0) // NULL-terminate argv // Build envp: + guard, NULL-terminated. + // -4: room for the guard, the two recorded variables below, and the NULL. ei := 0 - for off := 0; off < envLen && ei < ptrArrCap-2; { + for off := 0; off < envLen && ei < ptrArrCap-4; { setPtr(envpBase, ei, uintptr(unsafe.Add(envBase, off))) ei++ for off < envLen && *(*byte)(unsafe.Add(envBase, off)) != 0 { @@ -493,6 +556,31 @@ func reexecUniversal() bool { setPtr(envpBase, ei, uintptr(unsafe.Pointer(g))) ei++ } + // Hand the re-executed process what the re-exec is about to take from it. + // After the execve, /proc/self/exe is the loader and argv[0] is whatever + // the loader was told to run -- on glibc a memfd, which names no file at + // all -- so os.Executable() and os.Args[0] can no longer answer "where am + // I installed" or "what was I invoked as". Nothing else in the process + // knows either: this is the only point where both are still true. + // + // cmdBase[0] is the original argv[0] (NUL-terminated in place), realExeC + // the readlink of /proc/self/exe taken above. Either may be absent -- an + // empty /proc/self/cmdline, a readlink that failed -- and a missing + // variable is a better answer than a guessed one, so each is recorded + // only when it is real. + pid := rawsyscall6(sysGetpid, 0, 0, 0, 0, 0, 0) + if realExeC != nil && ei < ptrArrCap-2 { + if v := taggedEnv(exeKey, pid, realExeC); v != nil { + setPtr(envpBase, ei, uintptr(unsafe.Pointer(v))) + ei++ + } + } + if cmdLen > 0 && *(*byte)(cmdBase) != 0 && ei < ptrArrCap-2 { + if v := taggedEnv(argv0Key, pid, (*byte)(cmdBase)); v != nil { + setPtr(envpBase, ei, uintptr(unsafe.Pointer(v))) + ei++ + } + } setPtr(envpBase, ei, 0) // NULL-terminate envp rawsyscall6(sysExecve,