diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 954202a..e7f800b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,8 +186,15 @@ jobs: go-version: ${{ matrix.go }} cache: true + # setup-android installs `tools platform-tools` by default. Google's SDK + # repository no longer lists the obsolete `tools` package, so + # `sdkmanager tools` exits 1 and fails the step + # (android-actions/setup-android#537). Nothing here uses `tools`; request + # only platform-tools. The NDK is installed explicitly below. - name: Set up Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Install Android NDK r29 shell: bash @@ -453,10 +460,37 @@ jobs: if: matrix.arch == 'amd64' run: go test -tags goffi_static ./ffi -count=1 -run 'StaticBuild' + # musl builds: -tags goffi_musl must replace the glibc SONAMEs and the ELF + # interpreter with their musl equivalents, otherwise the binary cannot start + # on Alpine. Link-time checks cover amd64 and arm64; the runtime probe runs + # inside a real Alpine userland. See docs/MUSL.md. + musl-build: + name: musl Build (goffi_musl, Go ${{ matrix.go }}) + runs-on: ubuntu-latest + needs: [lint, formatting] + strategy: + fail-fast: false + matrix: + go: ['1.25', '1.26'] + env: + CGO_ENABLED: "0" + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + cache: true + + - name: Check musl build mode + run: scripts/check-musl.sh + # Final status - All checks passed ci-success: name: CI Success - needs: [lint, formatting, cross-compile, android-cross, test, benchmarks, quality-gate, elf-linking] + needs: [lint, formatting, cross-compile, android-cross, test, benchmarks, quality-gate, elf-linking, musl-build] runs-on: ubuntu-latest if: success() steps: @@ -472,6 +506,7 @@ jobs: echo " - Windows AMD64 (windows-latest)" echo " - macOS ARM64 (macos-latest)" echo "✅ ELF Linking: PASSED (default dynamic + goffi_static)" + echo "✅ musl: PASSED (goffi_musl, Alpine runtime probe)" echo "✅ Benchmarks: PASSED" echo "✅ Quality Gate: PASSED" echo "" diff --git a/.github/workflows/universal.yml b/.github/workflows/universal.yml new file mode 100644 index 0000000..b5676cf --- /dev/null +++ b/.github/workflows/universal.yml @@ -0,0 +1,183 @@ +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: + # 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 + 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 ./... + # -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 + run: | + set -euo pipefail + bash scripts/build-universal.sh -o universal-probe ./cmd/universal-probe + go run ./cmd/goffi-audit universal-probe + bash scripts/build-universal.sh -o universal-respawn ./cmd/universal-respawn + go run ./cmd/goffi-audit universal-respawn + - name: A universal binary starts copies of itself (runner host) + run: | + set -euo pipefail + ./universal-respawn | tee out.txt + grep -q RESPAWN-PROBE-OK out.txt + - name: Upload universal probe + uses: actions/upload-artifact@v4 + with: + name: universal-probe + path: | + universal-probe + universal-respawn + + run-glibc: + name: run on glibc (${{ matrix.image }}) + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + # bullseye and 20.04 carry glibc 2.31, which still keeps the pthread_* + # and dl* functions in libpthread.so.0 and libdl.so.2 rather than in + # libc.so.6 (merged in 2.34): the images above cannot catch a bridge + # that preloads libc alone (f4 #1381). + image: [ "debian:stable-slim", "ubuntu:24.04", "debian:bullseye-slim", "ubuntu:20.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 + - name: The same binary starts copies of itself + run: | + set -eu + chmod +x universal-respawn + ./universal-respawn | tee out.txt + grep -q RESPAWN-PROBE-OK out.txt + + preload-failure: + name: a preloaded library that aborts (f4 #1213) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + - name: Build a universal binary that reports whether FFI is available + run: | + set -euo pipefail + bash scripts/build-universal.sh -o universal-available ./cmd/universal-available + - name: Build a preloadable library that aborts only in the re-executed process + run: | + set -euo pipefail + # ESET's libesets_pac.so, preloaded into every process through + # /etc/ld.so.preload, aborts in its constructor when the universal + # binary is re-executed through the host loader (f4 #1213). This + # library does the same for exactly that launch -- a loader started + # with --preload and an image named /proc/self/fd/N -- and leaves + # every other process alone. + cat > abort.c <<'C' + #include + #include + #include + #include + __attribute__((constructor)) static void init(void) { + char buf[4096]; + int fd = open("/proc/self/cmdline", O_RDONLY); + if (fd < 0) return; + ssize_t n = read(fd, buf, sizeof buf - 1); + close(fd); + if (n <= 0) return; + for (ssize_t i = 0; i < n; i++) if (buf[i] == 0) buf[i] = ' '; + buf[n] = 0; + if (strstr(buf, "--preload") && strstr(buf, " /proc/self/fd/")) abort(); + } + C + echo 'static int unused;' > noop.c + sudo gcc -shared -fPIC -o /usr/local/lib/libabort.so abort.c + sudo gcc -shared -fPIC -o /usr/local/lib/libnoop.so noop.c + - name: Nothing preloaded, the bridge is unchanged + run: | + set -eu + ./universal-available | tee out.txt + grep -q 'ffi.Available = true' out.txt + grep -q 'workload = ok' out.txt + - name: A harmless LD_PRELOAD still gets FFI + run: | + set -eu + LD_PRELOAD=/usr/local/lib/libnoop.so ./universal-available | tee out.txt + grep -q 'ffi.Available = true' out.txt + grep -q 'workload = ok' out.txt + - name: An aborting LD_PRELOAD library falls back to no FFI + run: | + set -eu + LD_PRELOAD=/usr/local/lib/libabort.so ./universal-available 2>err.txt | tee out.txt + cat err.txt + grep -q 'ffi.Available = false' out.txt + grep -q 'workload = ok' out.txt + grep -q 'continuing without FFI' err.txt + - name: An aborting /etc/ld.so.preload library falls back to no FFI + run: | + set -eu + echo /usr/local/lib/libabort.so | sudo tee /etc/ld.so.preload + trap 'sudo rm -f /etc/ld.so.preload' EXIT + ./universal-available 2>err.txt | tee out.txt + cat err.txt + grep -q 'ffi.Available = false' out.txt + grep -q 'workload = ok' out.txt + grep -q 'continuing without FFI' err.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 + - name: The same binary starts copies of itself + shell: sh + run: | + set -eu + chmod +x universal-respawn + ./universal-respawn | tee out.txt + grep -q RESPAWN-PROBE-OK out.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7618146..38c50d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Universal build ("Profile U", `-tags goffi_universal`)** — one `CGO_ENABLED=0` binary for linux/amd64 and linux/arm64 that does FFI on both glibc and musl hosts. Every libc symbol is imported with an empty SONAME and the ELF interpreter is stripped after linking (`scripts/build-universal.sh`, `cmd/goffi-strip-interp`), so the kernel loads the binary directly anywhere; before any libc symbol is touched the process re-execs itself through the host loader with the host libc preloaded. On a host with no loader goffi recognises, or where a preloaded library kills the re-executed process, the binary carries on without FFI. See `docs/PROFILE_U.md`. +- **`ffi.Available()`** — whether this build and host can do FFI: constant false under `goffi_static`, decided at startup under `goffi_universal`, true otherwise. **`ffi.ErrNoHostLibc`** — returned by `LoadLibrary`/`GetSymbol`/`CallFunction` in a universal binary that could not bind a libc. +- **`ffi.HostLoader()`, `HostLibC()`, `HostPreload()`, `LibcKind()`** — the host loader table the universal build uses (`internal/loader`). +- **`ffi.Executable()`, `ffi.Argv0()`** — `os.Executable`/`os.Args[0]` as they were before the universal re-exec replaced them; the same as `os` in every other build. +- A universal program can start copies of itself with plain `exec.Command(os.Args[0])`: the re-exec guard `GOFFI_UNIVERSAL_REEXEC` is tagged with the pid it was written for, so a child runs the bridge itself. +- CI: `universal.yml` builds one universal probe and runs the same binary on four glibc images (glibc 2.31 to current) and on Alpine, checks the ELF contract with `cmd/goffi-audit`, runs a respawn probe (`cmd/universal-respawn`) everywhere, and covers the preload-abort fallback. +- **`-tags goffi_musl`** — CGO-free binaries for Alpine and other musl systems (linux/amd64, linux/arm64): the dynamic imports name `libc.musl-.so.1` and `PT_INTERP` is `/lib/ld-musl-.so.1`. FFI stays fully available. Needs `-gcflags=github.com/go-webgpu/goffi/internal/dl=-std`. See `docs/MUSL.md`. + ## [0.6.4] - 2026-09-10 ### Added 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 7f07b8a..36e1c20 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,9 @@ CGO_ENABLED=1 go build ./... | Mode | How | ELF shape | `LoadLibrary` | Typical use | |------|-----|-----------|---------------|-------------| | **Dynamic FFI** (default) | `CGO_ENABLED=0 go build` | dynamic + `libdl`/`libc` | yes | desktop GPU/GUI | -| **Musl dynamic** | build on Alpine / `CC=musl-gcc` | dynamic vs musl | yes | Alpine containers with GPU/GUI | +| **Musl dynamic** | `CGO_ENABLED=0 go build -tags goffi_musl -gcflags=github.com/go-webgpu/goffi/internal/dl=-std` | dynamic vs musl (`/lib/ld-musl-.so.1`, `libc.musl-.so.1`) | yes | Alpine containers with GPU/GUI | | **Static no-FFI** | `CGO_ENABLED=0 go build -tags goffi_static` | fully static (no `PT_INTERP`, no `NEEDED`) | no (`errors.Is(err, ffi.ErrStaticBuild)`) | `FROM scratch`, air-gapped CLI | +| **Universal** | `scripts/build-universal.sh` (`-tags goffi_universal`) | no `PT_INTERP`, no `NEEDED`; re-execs through the host loader | yes, on glibc and musl hosts (`ffi.Available()`) | one binary for every Linux distro ([docs/PROFILE_U.md](docs/PROFILE_U.md)) | ```bash # Fully static Linux amd64/arm64 binary (FFI unavailable) @@ -92,6 +93,8 @@ scripts/check-elf-linking.sh --static ./app Under `-tags goffi_static`, errno capture is unavailable (always returns 0): `ErrnoFnAddr()` is a no-op so the assembly trampoline skips `__errno_location` / `__error`, which need dynamic libc. +A default binary does not start on Alpine: its `PT_INTERP` and `DT_NEEDED` name the glibc loader and SONAMEs. `-tags goffi_musl` (linux/amd64 and linux/arm64) names the musl ones instead; FFI stays fully available. See [docs/MUSL.md](docs/MUSL.md). To ship one binary for both libcs, use the universal build instead: [docs/PROFILE_U.md](docs/PROFILE_U.md). + `FROM scratch` + Vulkan/Wayland/libX11 via host `dlopen` is not possible without either `ld.so` or a userspace ELF loader (see [docs/ADR-001-userspace-elf-loader.md](docs/ADR-001-userspace-elf-loader.md)). Windows is unaffected (`LoadLibraryW` via ntdll). ### Example: Calling strlen @@ -403,7 +406,7 @@ if err != nil { ## Known Limitations **Linux: default builds are dynamically linked** ([#74](https://github.com/go-webgpu/goffi/issues/74)) -- Importing goffi records `libdl`/`libc` via `cgo_import_dynamic` even with `CGO_ENABLED=0`. Use `-tags goffi_static` for a fully static ELF (no runtime `.so` loading), or build against musl for Alpine. See [Linking modes](#linking-modes-linux). +- Importing goffi records `libdl`/`libc` via `cgo_import_dynamic` even with `CGO_ENABLED=0`. Use `-tags goffi_static` for a fully static ELF (no runtime `.so` loading), or `-tags goffi_musl` for Alpine. See [Linking modes](#linking-modes-linux). **Windows: C++ exceptions may crash the program** ([#12516](https://github.com/golang/go/issues/12516)) - Go runtime limitation, not goffi-specific. Go 1.22+ added partial SEH support ([#58542](https://github.com/golang/go/issues/58542)), but edge cases remain. diff --git a/cmd/goffi-audit/main.go b/cmd/goffi-audit/main.go new file mode 100644 index 0000000..bc07bdb --- /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 func() { _ = 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/cmd/goffi-strip-interp/main.go b/cmd/goffi-strip-interp/main.go new file mode 100644 index 0000000..b45c8e0 --- /dev/null +++ b/cmd/goffi-strip-interp/main.go @@ -0,0 +1,113 @@ +// 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) (err 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 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. + 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/musl-probe/main.go b/cmd/musl-probe/main.go new file mode 100644 index 0000000..ff47123 --- /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 func() { _ = 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/cmd/universal-available/main.go b/cmd/universal-available/main.go new file mode 100644 index 0000000..cd0ade8 --- /dev/null +++ b/cmd/universal-available/main.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Command universal-available prints whether the process has a libc to call +// into, and exits 0 either way. It is the test subject for hosts where the +// re-exec through the host loader cannot be trusted to work -- a library +// preloaded through /etc/ld.so.preload that aborts while it initialises -- and +// where a universal binary is expected to carry on without FFI instead of +// dying before main. +// +// Carrying on has to mean more than reaching main: a real program sets +// environment variables, starts goroutines on new threads and collects +// garbage. Each of those reaches a hook the cgo runtime installs, and a hook +// that calls into a libc that is not there is a jump to address 0. The first +// version of the fallback survived main and died on the first os.Setenv (f4 +// #1213), so the program exercises them and prints "workload = ok" only when +// they all came back. +package main + +import ( + "fmt" + "os" + "runtime" + "sync" + + "github.com/go-webgpu/goffi/ffi" +) + +func main() { + fmt.Printf("ffi.Available = %v\n", ffi.Available()) + + if err := os.Setenv("GOFFI_UNIVERSAL_TEST", "1"); err != nil || os.Getenv("GOFFI_UNIVERSAL_TEST") != "1" { + fmt.Printf("workload = FAILED (Setenv: %v)\n", err) + os.Exit(1) + } + if err := os.Unsetenv("GOFFI_UNIVERSAL_TEST"); err != nil || os.Getenv("GOFFI_UNIVERSAL_TEST") != "" { + fmt.Printf("workload = FAILED (Unsetenv: %v)\n", err) + os.Exit(1) + } + + // New OS threads: with iscgo false the runtime must start them itself. + var wg sync.WaitGroup + total := make([]int, 8) + for i := range total { + wg.Add(1) + go func() { + defer wg.Done() + runtime.LockOSThread() + for n := 0; n < 200000; n++ { + total[i] += n & 1 + } + runtime.Gosched() + }() + } + wg.Wait() + runtime.GC() + + fmt.Println("workload = ok") +} diff --git a/cmd/universal-probe/main.go b/cmd/universal-probe/main.go new file mode 100644 index 0000000..88a938b --- /dev/null +++ b/cmd/universal-probe/main.go @@ -0,0 +1,197 @@ +// 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() { + // The guard is ":1", written for the process the bridge re-execed. + reexeced := os.Getenv("GOFFI_UNIVERSAL_REEXEC") == fmt.Sprintf("%d:1", os.Getpid()) + 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 func() { _ = ffi.FreeLibrary(handle) }() + check("LoadLibrary", true, lib) + + // 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("atof(\"2.0\")", math.Abs(val-2.0) < 1e-12, fmt.Sprintf("= %v", val)) + + // 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/cmd/universal-respawn/main.go b/cmd/universal-respawn/main.go new file mode 100644 index 0000000..3dad0c4 --- /dev/null +++ b/cmd/universal-respawn/main.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Command universal-respawn checks that a universal ("Profile U") binary can +// start another copy of itself the plain way and that the copy gets FFI. +// +// The re-exec bridge marks the process it re-execs with a guard variable, and +// every child inherits the environment. The guard carries the pid it was +// written for, so a child -- a new pid -- does not take its parent's guard for +// its own and runs the bridge itself. Without the pid a child started with +// exec.Command(os.Args[0]) skipped the bridge and died before main on an +// unbound libc symbol. +// +// Run without arguments, it starts itself four ways and checks each child: +// +// os.Args[0] (on glibc a /proc/self/fd/ memfd image) +// ffi.Executable() (the file on disk) +// the host loader ( --preload os.Args[0], by hand) +// a grandchild (a child that starts its own child via os.Args[0]) +// +// Each child calls getpid and strlen through goffi and reports ffi.Executable, +// which must still name the file on disk. Exit status 0 and a final +// RESPAWN-PROBE-OK line mean every required child passed. +// +// The host-loader launch is reported but not required. glibc 2.31's loader +// refuses a /proc/self/fd/ image with "loader cannot load itself" (Debian +// 11, Ubuntu 20.04) before any goffi code runs; newer glibc and musl accept +// it. The plain launches above need no such workaround. +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + "unsafe" + + "github.com/go-webgpu/goffi/ffi" + "github.com/go-webgpu/goffi/types" +) + +func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "child": + os.Exit(child()) + case "relay": + out, err := exec.Command(os.Args[0], "child").CombinedOutput() + fmt.Print(string(out)) + if err != nil { + fmt.Printf("relay: grandchild failed: %v\n", err) + os.Exit(1) + } + return + } + } + os.Exit(parent()) +} + +func parent() int { + if !ffi.Available() { + fmt.Println("FAIL parent: ffi.Available() = false") + return 1 + } + exe, err := ffi.Executable() + if err != nil { + fmt.Printf("FAIL parent: ffi.Executable: %v\n", err) + return 1 + } + fmt.Printf("info parent pid=%d os.Args[0]=%s ffi.Executable=%s\n", os.Getpid(), os.Args[0], exe) + + ways := []struct { + name string + cmd *exec.Cmd + optional bool + }{ + {"os.Args[0]", exec.Command(os.Args[0], "child"), false}, + {"ffi.Executable()", exec.Command(exe, "child"), false}, + {"host loader", exec.Command(ffi.HostLoader(), "--preload", ffi.HostPreload(), os.Args[0], "child"), true}, + {"grandchild", exec.Command(os.Args[0], "relay"), false}, + } + failed := false + for _, w := range ways { + out, err := w.cmd.CombinedOutput() + text := string(out) + ok := err == nil && strings.Contains(text, "CHILD-OK exe="+exe+"\n") + switch { + case ok: + fmt.Printf("ok %-18s\n", w.name) + case w.optional: + fmt.Printf("warn %-18s err=%v (not required)\n", w.name, err) + default: + failed = true + fmt.Printf("FAIL %-18s err=%v\n", w.name, err) + } + for _, line := range strings.Split(strings.TrimSpace(text), "\n") { + fmt.Printf(" | %s\n", line) + } + } + if failed { + fmt.Println("RESPAWN-PROBE-FAILED") + return 1 + } + fmt.Println("RESPAWN-PROBE-OK") + return 0 +} + +func child() int { + if !ffi.Available() { + fmt.Println("child: ffi.Available() = false") + return 1 + } + h, err := ffi.LoadLibrary(ffi.HostLibC()) + if err != nil { + fmt.Printf("child: LoadLibrary(%s): %v\n", ffi.HostLibC(), err) + return 1 + } + + getpid, err := ffi.GetSymbol(h, "getpid") + if err != nil { + fmt.Printf("child: GetSymbol(getpid): %v\n", err) + return 1 + } + cif := &types.CallInterface{} + if err = ffi.PrepareCallInterface(cif, types.DefaultCall, types.SInt32TypeDescriptor, nil); err != nil { + fmt.Printf("child: PrepareCallInterface: %v\n", err) + return 1 + } + var pid int32 + if _, err = ffi.CallFunction(cif, getpid, unsafe.Pointer(&pid), nil); err != nil { + fmt.Printf("child: CallFunction(getpid): %v\n", err) + return 1 + } + if int(pid) != os.Getpid() { + fmt.Printf("child: getpid() = %d, os.Getpid() = %d\n", pid, os.Getpid()) + return 1 + } + + strlen, err := ffi.GetSymbol(h, "strlen") + if err != nil { + fmt.Printf("child: GetSymbol(strlen): %v\n", err) + return 1 + } + scif := &types.CallInterface{} + if err = ffi.PrepareCallInterface(scif, types.DefaultCall, types.UInt64TypeDescriptor, + []*types.TypeDescriptor{types.PointerTypeDescriptor}); err != nil { + fmt.Printf("child: PrepareCallInterface(strlen): %v\n", err) + return 1 + } + s := "respawned\x00" + sp := unsafe.Pointer(unsafe.StringData(s)) + var n uint64 + if _, err = ffi.CallFunction(scif, strlen, unsafe.Pointer(&n), []unsafe.Pointer{unsafe.Pointer(&sp)}); err != nil || n != 9 { + fmt.Printf("child: strlen = %d, err = %v\n", n, err) + return 1 + } + + exe, err := ffi.Executable() + if err != nil { + fmt.Printf("child: ffi.Executable: %v\n", err) + return 1 + } + fmt.Printf("child pid=%d os.Args[0]=%s\n", os.Getpid(), os.Args[0]) + fmt.Printf("CHILD-OK exe=%s\n", exe) + return 0 +} diff --git a/docs/MUSL.md b/docs/MUSL.md new file mode 100644 index 0000000..12ea2a5 --- /dev/null +++ b/docs/MUSL.md @@ -0,0 +1,102 @@ +# 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. + +## 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) | + +## 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..a2323d5 --- /dev/null +++ b/docs/PROFILE_U.md @@ -0,0 +1,144 @@ +# 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 (on glibc together with + `libpthread.so.0` and `libdl.so.2`: before glibc 2.34 the `pthread_*` and + `dl*` functions live there, not in `libc.so.6`). 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.HostPreload() // e.g. "libc.so.6 libpthread.so.0 libdl.so.2" or "libc.musl-x86_64.so.1" +ffi.LibcKind() // "glibc" | "musl" | "unknown" +``` + +## Limitations + +- **Linux only.** amd64 is run-tested; arm64 is cross-compile-verified. +- **glibc 2.30 or newer.** The loader's `--preload` option first appeared in + glibc 2.30. An older loader takes `--preload` for the name of the program to + run and the re-executed process dies before `main` with + `--preload: cannot open shared object file` (observed on Ubuntu 18.04, + glibc 2.27). The oldest glibc CI runs the universal binary on is 2.31 + (Debian 11, Ubuntu 20.04). +- 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]` 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` (the CI `purego-coexistence` job builds that combination). + `-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`. + +## Hosts that preload a library + +A library named in `/etc/ld.so.preload`, or in `LD_PRELOAD`, is initialised in +every process the host loader starts -- including the re-executed one. If its +constructor aborts there (ESET's `libesets_pac.so` does on some hosts, f4 +#1213), the process dies before `main`: it is the re-executed process, not the +one that could still have chosen to go on without FFI. + +So when either is non-empty, the bridge first starts the same launch -- same +loader, libc, image and environment -- in a forked child, with +`GOFFI_UNIVERSAL_PROBE=:1` added. That child stops as soon as it reaches the +bridge, so nothing of the program runs. If it exits 0 the real re-exec goes +ahead; if it was killed or failed, the bridge says so once on stderr and +continues without FFI, exactly as on a host with no known loader +(`ffi.Available()` is false). A probe that cannot be carried out at all (fork +refused, no scratch memory) is not held against the launch. With nothing +preloaded there is no probe and no cost. + +The probe variable is set only in the child's environment; the running process +never sees it. + +## Starting another copy of yourself + +Just start it: `exec.Command(os.Args[0], ...)` or `exec.Command(exe, ...)` +with `exe` from `ffi.Executable()`. The child runs the bridge itself and gets a +working libc. + +Starting the copy through the host loader by hand +(`exec.Command(ffi.HostLoader(), "--preload", ffi.HostPreload(), os.Args[0], ...)`) +is no longer needed. It still works on musl and on glibc 2.34+, but glibc 2.31's +loader rejects the `/proc/self/fd/` image with `loader cannot load itself` +(Debian 11, Ubuntu 20.04), before any goffi code runs. + +The guard the bridge leaves in the environment is `GOFFI_UNIVERSAL_REEXEC=:1`, +tagged like `GOFFI_UNIVERSAL_EXE` and `GOFFI_UNIVERSAL_ARGV0`. The re-executed +process keeps its pid across `execve` and finds its own tag. A child inherits +the variable but has a new pid, so it runs the bridge itself. The bridge also +drops the inherited copies of all four variables before writing its own, so +they do not pile up across generations. + +Two ways of being started need no re-exec, and the bridge recognises both: + +- by the host loader as a program (`/proc/self/exe` is the loader): the loader + has already preloaded the libc it was given; +- from the parent's memfd (on glibc a re-executed process has `os.Args[0]` = + `/proc/self/fd/`, and a child started with it runs that image). This one + does go through the bridge again, and records the parent's `ffi.Executable()` + as its own, since `/proc/self/exe` names no file. + +`ffi.Executable()` therefore names the file on disk in every child. +`cmd/universal-respawn` starts itself through `os.Args[0]`, `ffi.Executable()`, +the host loader and a grandchild, and checks that each child makes FFI calls +(the host-loader launch is reported but not required). CI runs it on glibc and +musl. + +## 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. diff --git a/ffi/available.go b/ffi/available.go new file mode 100644 index 0000000..950bd47 --- /dev/null +++ b/ffi/available.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import "github.com/go-webgpu/goffi/internal/hostlibc" + +// Available reports whether this build of goffi can load shared libraries and +// call foreign functions. The answer depends on the build mode: +// +// - -tags goffi_static: always false, a compile-time constant. The build has +// no //go:cgo_import_dynamic directives, so the linker emits a fully static +// executable (no PT_INTERP, no DT_NEEDED) and there is no loader to ask. +// LoadLibrary and GetSymbol return an error wrapping ErrStaticBuild, and +// a callback created with NewCallback aborts the process if C calls it. +// This applies on linux, darwin, freebsd and android (amd64/arm64 only +// for the first three, arm64 for android). Elsewhere the tag has no +// effect and Available follows the default rule below. +// - -tags goffi_universal ("Profile U"): decided at run time, once, before +// main. The binary re-execs itself through the host's dynamic loader with +// the host libc preloaded. On a host whose loader goffi does not recognise, +// or where that re-exec cannot be made to work, the process carries on as a +// pure-Go program and Available is false; the FFI entry points then return +// ErrNoHostLibc. The same binary can answer differently on two machines. +// - Every other build (the default, goffi_musl, CGO_ENABLED=1): always true. +// The loader is a link-time dependency (PT_INTERP and DT_NEEDED on ELF +// systems, kernel32 on Windows), so a process that reached main has it; +// nothing is probed at run time. +// +// 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() +// } +// +// Under goffi_static the call is a constant false, so the compiler drops the +// accelerated branch and everything only it reaches. +func Available() bool { + return !staticBuild && !hostlibc.Missing +} diff --git a/ffi/call.go b/ffi/call.go index b6736fe..eba41c7 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" gosyscall "github.com/go-webgpu/goffi/internal/syscall" "github.com/go-webgpu/goffi/types" ) @@ -16,6 +17,12 @@ func executeFunction( rvalue unsafe.Pointer, avalue []unsafe.Pointer, ) (syscallErrno uintptr, err error) { + if hostlibc.Missing { + // A universal binary that could not bind a libc at startup: 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 3c6c216..2f7c2bf 100644 --- a/ffi/errors.go +++ b/ffi/errors.go @@ -2,6 +2,8 @@ package ffi import ( "fmt" + + "github.com/go-webgpu/goffi/internal/hostlibc" ) // InvalidCallInterfaceError indicates CallInterface preparation failed due to @@ -152,6 +154,16 @@ func (e *TypeValidationError) Is(target error) bool { return ok } +// 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/hostinfo.go b/ffi/hostinfo.go new file mode 100644 index 0000000..455cf65 --- /dev/null +++ b/ffi/hostinfo.go @@ -0,0 +1,28 @@ +// 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 } + +// HostPreload returns the list of objects a universal binary has the host +// loader preload (` --preload `), or "" whenever HostLibC does. +// On musl it is HostLibC alone. On glibc it is libc.so.6 followed by +// libpthread.so.0 and libdl.so.2: before glibc 2.34 the pthread_* and dl* +// functions a universal binary imports live there rather than in libc.so.6. +// A program that starts another copy of itself through the host loader passes +// this, not HostLibC, exactly as the re-exec bridge does. +func HostPreload() string { return loader.Detect().Preload } + +// LibcKind returns the host libc flavor as "glibc", "musl", or "unknown". +func LibcKind() string { return loader.Detect().Kind.String() } diff --git a/ffi/hostinfo_test.go b/ffi/hostinfo_test.go new file mode 100644 index 0000000..346528c --- /dev/null +++ b/ffi/hostinfo_test.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import ( + "path/filepath" + "strings" + "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) + } +} + +// TestHostPreload pins the relation between the preload list and the libc: +// empty together, and the libc named first when there is one. +func TestHostPreload(t *testing.T) { + libc, preload := HostLibC(), HostPreload() + if libc == "" { + if preload != "" { + t.Errorf("HostLibC() is empty but HostPreload() = %q", preload) + } + return + } + if !strings.HasPrefix(preload+" ", libc+" ") { + t.Errorf("HostPreload() = %q, want it to start with HostLibC() %q", preload, libc) + } + if LibcKind() == "glibc" { + for _, lib := range []string{"libpthread.so.0", "libdl.so.2"} { + if !strings.Contains(" "+preload+" ", " "+lib+" ") { + t.Errorf("HostPreload() = %q on glibc, want it to name %s", preload, lib) + } + } + } +} diff --git a/ffi/musl_directives_test.go b/ffi/musl_directives_test.go new file mode 100644 index 0000000..8d64def --- /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_glibc.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..84137e7 --- /dev/null +++ b/ffi/musl_link_test.go @@ -0,0 +1,135 @@ +// 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" + "strings" + "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 := goToolPath() + + 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) + } +} + +// goToolPath returns the go command from the active GOROOT (as reported by +// "go env GOROOT"), falling back to whatever "go" resolves to on PATH. +// runtime.GOROOT is deprecated since Go 1.24, so the value is queried from +// the tool at run time instead. +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 +} diff --git a/ffi/nohostlibc_test.go b/ffi/nohostlibc_test.go new file mode 100644 index 0000000..1298aef --- /dev/null +++ b/ffi/nohostlibc_test.go @@ -0,0 +1,68 @@ +// 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. It passes the address of a real object rather than a + // fabricated address: the guard returns before fn is read, and a uintptr + // conversion here would be a genuine unsafe.Pointer misuse. + var cif types.CallInterface + var neverCalled byte + if _, err := executeFunction(&cif, unsafe.Pointer(&neverCalled), 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/ffi/selfinfo.go b/ffi/selfinfo.go new file mode 100644 index 0000000..5f9149e --- /dev/null +++ b/ffi/selfinfo.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import ( + "os" + "path/filepath" + "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 + } + exe, err := os.Executable() + if err != nil { + return exe, err + } + // A copy of a universal program started by hand through the host loader + // (" --preload ") never passes through the bridge, + // so nothing was recorded for it, and os.Executable names the loader. The + // same holds for a copy the bridge could not describe because it was + // started from its parent's memfd. Either way the parent's record is the + // answer: both run the parent's image. + if isLoaderOrMemfd(exe) { + if p, ok := recordedFor(envUniversalExe, os.Getppid()); ok { + return p, nil + } + } + return exe, nil +} + +// isLoaderOrMemfd reports whether exe, an os.Executable result, is the host +// dynamic loader or a memfd rather than a program file. +func isLoaderOrMemfd(exe string) bool { + if strings.HasPrefix(exe, "/memfd:") { + return true + } + l := HostLoader() + return l != "" && filepath.Base(exe) == filepath.Base(l) +} + +// 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) { + return recordedFor(key, os.Getpid()) +} + +// recordedFor reads a ":" variable and reports its value if it is +// tagged with pid. +func recordedFor(key string, want int) (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 != want { + 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/ffi/static_mode.go b/ffi/static_mode.go new file mode 100644 index 0000000..0959ab5 --- /dev/null +++ b/ffi/static_mode.go @@ -0,0 +1,7 @@ +//go:build goffi_static && ((linux && !android) || darwin || freebsd || (android && arm64)) && (amd64 || arm64) + +package ffi + +// staticBuild reports that this is a -tags goffi_static build on a platform +// where the tag takes effect (the same constraint as dl_static.go). +const staticBuild = true diff --git a/ffi/static_mode_off.go b/ffi/static_mode_off.go new file mode 100644 index 0000000..47b0780 --- /dev/null +++ b/ffi/static_mode_off.go @@ -0,0 +1,6 @@ +//go:build !(goffi_static && ((linux && !android) || darwin || freebsd || (android && arm64)) && (amd64 || arm64)) + +package ffi + +// staticBuild is false: dynamic loading is compiled in. See static_mode.go. +const staticBuild = false 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) + } +} diff --git a/internal/dl/dl_linux.go b/internal/dl/dl_linux.go index b507c45..c877927 100644 --- a/internal/dl/dl_linux.go +++ b/internal/dl/dl_linux.go @@ -9,26 +9,9 @@ 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" +// The libdl imports live in dl_linux_glibc.go (default) or +// dl_musl_.go (-tags goffi_musl), so each libc flavor can name its own +// SONAMEs while these constants stay shared. // RTLD constants from for dynamic library loading on Linux. const ( diff --git a/internal/dl/dl_linux_glibc.go b/internal/dl/dl_linux_glibc.go new file mode 100644 index 0000000..a781064 --- /dev/null +++ b/internal/dl/dl_linux_glibc.go @@ -0,0 +1,28 @@ +//go:build linux && !android && !goffi_static && !goffi_universal && !goffi_musl + +// 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. + +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) still ships the real libdl.so.2. +// musl has no libdl.so.2 at all; see dl_musl_.go. +// 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_musl_amd64.go b/internal/dl/dl_musl_amd64.go new file mode 100644 index 0000000..630e033 --- /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_universal && 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_glibc.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..7f1e521 --- /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_universal && 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_glibc.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/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/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/gen.go b/internal/fakecgo/gen.go index c0e655a..91b62ac 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 && !goffi_static", "//go:build !cgo && !android && !goffi_static", 1))) + goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo && !goffi_static", "//go:build !cgo && !android && !goffi_static && !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. @@ -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_static && goffi_musl && !goffi_universal && " + mc.arch + mt := template.Must(template.New("symbols_musl.go").Parse( + strings.Replace(templateSymbolsGoos, "//go:build !cgo && !goffi_static", 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/go_linux_amd64.go b/internal/fakecgo/go_linux_amd64.go index 498e7d6..52ad46e 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) { @@ -61,6 +65,42 @@ var setg_func uintptr //go:nosplit func x_cgo_init(g *G, setg uintptr) { + // 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() + 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 + dropLibcEnvHooks() + 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 2d79493..b10a02b 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) { @@ -66,6 +70,42 @@ var setg_func uintptr // //go:nosplit func x_cgo_init(g *G, setg uintptr) { + // 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() + 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 + dropLibcEnvHooks() + setg_func = setg + return + } + 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..c1a0417 --- /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_static && !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_syscall_amd64.s b/internal/fakecgo/reexec_syscall_amd64.s new file mode 100644 index 0000000..090a58e --- /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_static && 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..3621665 --- /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_static && 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..62950f8 --- /dev/null +++ b/internal/fakecgo/reexec_table_amd64.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && !goffi_static && 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 + sysGetpid = 39 + sysGetppid = 110 + sysMemfdCreate = 319 + sysClone = 56 + sysWait4 = 61 + sysExitGroup = 231 +) + +// Host dynamic loader, libc SONAME and preload list, per libc flavor, for +// amd64. These are the only two ABIs goffi's universal build targets. The +// preload list is passed bare to ` --preload `; each loader +// resolves the names through its default search path (verified on glibc and +// musl). See glibcPreloadExtra for why glibc needs more than its libc. +const ( + glibcLoader = "/lib64/ld-linux-x86-64.so.2" + glibcLibc = "libc.so.6" + glibcPreload = glibcLibc + " " + glibcPreloadExtra + muslLoader = "/lib/ld-musl-x86_64.so.1" + muslLibc = "libc.musl-x86_64.so.1" + muslPreload = muslLibc +) diff --git a/internal/fakecgo/reexec_table_arm64.go b/internal/fakecgo/reexec_table_arm64.go new file mode 100644 index 0000000..88454b6 --- /dev/null +++ b/internal/fakecgo/reexec_table_arm64.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && !goffi_static && 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 + sysGetpid = 172 + sysGetppid = 173 + sysMemfdCreate = 279 + sysClone = 220 + sysWait4 = 260 + sysExitGroup = 94 +) + +// Host dynamic loader, libc SONAME and preload list, per libc flavor, for +// arm64. See the amd64 table and glibcPreloadExtra. +const ( + glibcLoader = "/lib/ld-linux-aarch64.so.1" + glibcLibc = "libc.so.6" + glibcPreload = glibcLibc + " " + glibcPreloadExtra + muslLoader = "/lib/ld-musl-aarch64.so.1" + muslLibc = "libc.musl-aarch64.so.1" + muslPreload = muslLibc +) diff --git a/internal/fakecgo/reexec_table_sync_test.go b/internal/fakecgo/reexec_table_sync_test.go new file mode 100644 index 0000000..45540ef --- /dev/null +++ b/internal/fakecgo/reexec_table_sync_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && !goffi_static && 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, libc SONAMEs and preload lists, since both claim to +// describe the same host -- and a program that starts another copy of itself +// through ffi.HostPreload has to hand the loader exactly what the bridge did. +func TestReexecTableMatchesLoaderPackage(t *testing.T) { + cases := []struct { + name string + gotL, gotC, gotP string + wantL, wantC, wantP string + }{ + {"glibc", glibcLoader, glibcLibc, glibcPreload, + loader.Glibc.Loader, loader.Glibc.LibC, loader.Glibc.Preload}, + {"musl", muslLoader, muslLibc, muslPreload, + loader.Musl.Loader, loader.Musl.LibC, loader.Musl.Preload}, + } + 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) + } + if c.gotP != c.wantP { + t.Errorf("%s preload: bridge %q != loader pkg %q", c.name, c.gotP, c.wantP) + } + } +} diff --git a/internal/fakecgo/reexec_universal_linux.go b/internal/fakecgo/reexec_universal_linux.go new file mode 100644 index 0000000..d0929c6 --- /dev/null +++ b/internal/fakecgo/reexec_universal_linux.go @@ -0,0 +1,850 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && !goffi_static && 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 -- with +// libpthread.so.0 and libdl.so.2, see glibcPreloadExtra -- or musl's +// libc.musl-.so.1). A guard variable in the environment stops the second +// launch from re-execing again. The guard names the pid it was written for, so +// a child the program starts later (a new pid) does not mistake its parent's +// guard for its own and runs the bridge itself. +// +// 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 + +// 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. +// +// 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" + + "github.com/go-webgpu/goffi/internal/hostlibc" +) + +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 + + // The guard: "GOFFI_UNIVERSAL_REEXEC=:1", written for the process + // the bridge is about to re-exec. execve keeps the pid, so the re-executed + // process finds its own pid there and stops. A child it starts later + // inherits the variable but has a new pid, so it runs the bridge itself + // instead of assuming a libc its parent had (the exec.Command(os.Args[0]) + // footgun). Tagged the same way as exeKey and argv0Key below. + guardKey = "GOFFI_UNIVERSAL_REEXEC=" + + // The probe: see probeHostLoader. "GOFFI_UNIVERSAL_PROBE=:1", set + // only in the environment of the throwaway child, never in the process + // that goes on to run. pid is the bridge process that forked the probe, + // which is the probe's parent: the probe matches it against getppid. + probeKey = "GOFFI_UNIVERSAL_PROBE=" + ldPreloadKey = "LD_PRELOAD=" + preloadFile = "/etc/ld.so.preload" + preloadCap = 4 << 10 + sigchld = 17 + eintr = 4 + + // 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=" + + // glibcPreloadExtra is what glibc needs preloaded beside libc.so.6. + // Before 2.34 glibc split the API the fakecgo runtime imports across three + // objects: pthread_create, pthread_detach, pthread_sigmask, + // pthread_attr_getstacksize and pthread_setspecific are in libpthread.so.0, + // dlopen, dlsym and dlerror in libdl.so.2, and none of them in libc.so.6. + // With libc alone the re-executed process dies before main with + // "symbol lookup error: undefined symbol: pthread_attr_getstacksize" + // (f4 #1381, Ubuntu 20.04, glibc 2.31). From 2.34 on both are stub + // objects that every glibc still installs and the functions live in + // libc.so.6, so preloading them there changes nothing. The loader's + // --preload list (glibc 2.30+) is delimited by spaces or colons. + glibcPreloadExtra = "libpthread.so.0 libdl.so.2" + + // 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. +// 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)) +} + +// 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 { + 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 +} + +// taggedAt reports where the value of a ":" entry starting at +// off begins, or -1 if the entry is not key, or is tagged with another pid. +// +//go:nosplit +func taggedAt(base unsafe.Pointer, off, length int, key string, pid uintptr) int { + if !matchAt(base, off, length, key) { + return -1 + } + p := off + len(key) + var v uintptr + digits := 0 + for p < length { + c := *(*byte)(unsafe.Add(base, p)) + if c < '0' || c > '9' { + break + } + v = v*10 + uintptr(c-'0') + p++ + digits++ + } + if digits == 0 || v != pid || p >= length || *(*byte)(unsafe.Add(base, p)) != ':' { + return -1 + } + return p + 1 +} + +// isBridgeVar reports whether the entry at off is one of the variables the +// bridge writes. The bridge drops every inherited one from the environment it +// hands on and writes its own, so they do not pile up across generations. +// +//go:nosplit +func isBridgeVar(base unsafe.Pointer, off, length int) bool { + return matchAt(base, off, length, guardKey) || matchAt(base, off, length, probeKey) || + matchAt(base, off, length, exeKey) || matchAt(base, off, length, argv0Key) +} + +// hasPrefixC reports whether the NUL-terminated C string p starts with s. +// +//go:nosplit +func hasPrefixC(p *byte, s string) bool { + if p == nil { + return false + } + for i := 0; i < len(s); i++ { + c := *(*byte)(unsafe.Add(unsafe.Pointer(p), i)) + if c != s[i] { + return false + } + } + return true +} + +// sameBase reports whether the last path element of the C string p equals the +// last path element of s. Used to recognise the host loader in +// /proc/self/exe, which is the loader's resolved path (on Debian +// /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 for /lib64/ld-linux-x86-64.so.2). +// +//go:nosplit +func sameBase(p *byte, s string) bool { + if p == nil { + return false + } + n, start := 0, 0 + for *(*byte)(unsafe.Add(unsafe.Pointer(p), n)) != 0 { + if *(*byte)(unsafe.Add(unsafe.Pointer(p), n)) == '/' { + start = n + 1 + } + n++ + } + sb := 0 + for i := 0; i < len(s); i++ { + if s[i] == '/' { + sb = i + 1 + } + } + if n-start != len(s)-sb { + return false + } + for i := 0; i < n-start; i++ { + if *(*byte)(unsafe.Add(unsafe.Pointer(p), start+i)) != s[sb+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) +} + +// 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 + } + // A copy whose PT_INTERP is already in place needs no patch: a child + // started from the memfd a parent was loaded from (os.Args[0] is + // /proc/self/fd/ on glibc) is exactly that image. + for i := uintptr(0); i < phnum; i++ { + if *(*uint32)(unsafe.Add(buf, phoff+i*phentsize)) == ptInterp { + return true + } + } + 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 + } + } +} + +// needsPreloadProbe reports whether something is preloaded into processes the +// host loader starts: a non-empty LD_PRELOAD in the environment, or a +// non-empty /etc/ld.so.preload. Neither is there on most hosts, and the probe +// costs a process start, so it is only paid where it can matter. +// +//go:nosplit +func needsPreloadProbe(envBase unsafe.Pointer, envLen int) bool { + for off := 0; off < envLen; { + if matchAt(envBase, off, envLen, ldPreloadKey) { + v := off + len(ldPreloadKey) + if v < envLen && *(*byte)(unsafe.Add(envBase, v)) != 0 { + return true + } + } + for off < envLen && *(*byte)(unsafe.Add(envBase, off)) != 0 { + off++ + } + off++ // skip NUL + } + buf := mmapAnon(preloadCap) + if buf == nil { + return false + } + return readAll(cstr(preloadFile), buf, preloadCap) > 0 +} + +// probeHostLoader starts the very launch the bridge is about to make -- same +// loader, same libc, same image, same environment -- in a forked child, with +// the probe variable added, and reports whether that child got as far as the +// bridge. It did if it exited 0; a child that died of a signal or exited with +// anything else was killed on the way, by the loader or by a constructor of a +// library preloaded next to the libc. +// +// The child stops as soon as it is the re-executed process (see the guard in +// reexecUniversal), so the probe costs one loader start and does not run any +// of the program. A probe that cannot be carried out at all -- no scratch +// memory, fork refused, wait failed -- says nothing about the launch, so it +// reports success and leaves the real one to happen as it always did. +// +// envpBase holds ei entries; the child's copy of it takes one more. +// +//go:nosplit +func probeHostLoader(loaderC *byte, argvBase, envpBase unsafe.Pointer, ei int, probe *byte) bool { + status := mmapAnon(4096) + if probe == nil || status == nil || ei+1 >= ptrArrCap { + return true + } + // clone with only SIGCHLD and no new stack is fork(2), and exists on both + // architectures, where fork(2) itself does not on arm64. + pid := rawsyscall6(sysClone, sigchld, 0, 0, 0, 0, 0) + if sysErr(pid) { + return true + } + if pid == 0 { + // In the child. It has its own copy of everything, so the probe + // variable never reaches the parent's environment. + setPtr(envpBase, ei, uintptr(unsafe.Pointer(probe))) + setPtr(envpBase, ei+1, 0) + rawsyscall6(sysExecve, uintptr(unsafe.Pointer(loaderC)), uintptr(argvBase), uintptr(envpBase), 0, 0, 0) + rawsyscall6(sysExitGroup, 127, 0, 0, 0, 0, 0) + } + for { + r := rawsyscall6(sysWait4, pid, uintptr(status), 0, 0, 0, 0) + if !sysErr(r) { + break + } + if 0-r != eintr { + return true + } + } + return *(*uint32)(status) == 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. +// +// 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 false + } + strBufBase = uintptr(sb) + strBufOff = 0 + + envBase := mmapAnon(envBufCap) + if envBase == nil { + return false + } + envLen := readAll(cstr("/proc/self/environ"), envBase, envBufCap) + if envLen < 0 { + return false + } + + // Guard: if we already re-executed, do nothing. The probe child (see + // probeHostLoader) has come through the host loader too, and that is all it + // was asked to prove: it has reached this point, so the loader mapped the + // libc and every library preloaded next to it initialised. It stops here, + // before the Go runtime does anything else. + // + // The guard counts only when tagged with this process's pid, the probe + // only when tagged with its parent's (the bridge that forked it). An + // inherited guard describes the parent: this process was started some + // other way (exec.Command(os.Args[0]) and the like) and still needs the + // bridge. parentExe is the parent's recorded executable, used below when + // this process was started from the parent's memfd. + pid := rawsyscall6(sysGetpid, 0, 0, 0, 0, 0, 0) + ppid := rawsyscall6(sysGetppid, 0, 0, 0, 0, 0, 0) + guarded, probing := false, false + parentExe := -1 + for off := 0; off < envLen; { + if taggedAt(envBase, off, envLen, guardKey, pid) >= 0 { + guarded = true + } + if taggedAt(envBase, off, envLen, probeKey, ppid) >= 0 { + probing = true + } + if v := taggedAt(envBase, off, envLen, exeKey, ppid); v >= 0 { + parentExe = v + } + for off < envLen && *(*byte)(unsafe.Add(envBase, off)) != 0 { + off++ + } + off++ // skip NUL + } + if probing { + rawsyscall6(sysExitGroup, 0, 0, 0, 0, 0, 0) + } + if guarded { + return true + } + + // Pick the host loader + libc preload list by probing known loader paths. + // Prefer glibc when both are present; fall back to musl. + var loaderC, libsC *byte + var isGlibc bool + if g := cstr(glibcLoader); fileExists(g) { + loaderC = g + libsC = cstr(glibcPreload) + isGlibc = true + } else if m := cstr(muslLoader); fileExists(m) { + loaderC = m + libsC = cstr(muslPreload) + } + if loaderC == nil || libsC == nil { + 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. 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, realExeC *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) + realExeC = exeC + } + } + if exeC == nil { + exeC = cstr("/proc/self/exe") + } + + // Started by the host loader as a program (" --preload + // ", the documented way to start another copy by hand): the + // loader has already bound the libc it was asked to preload. Re-execing + // would hand the loader the loader itself. + if sameBase(realExeC, glibcLoader) || sameBase(realExeC, muslLoader) { + return true + } + + // Started from the memfd image of a parent (on glibc its os.Args[0] is + // /proc/self/fd/): /proc/self/exe names a memfd, not a file. The + // parent recorded where the binary really lives; pass that on. + if hasPrefixC(realExeC, "/memfd:") { + if parentExe >= 0 { + realExeC = (*byte)(unsafe.Add(envBase, parentExe)) + } else { + realExeC = nil + } + } + + // 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 { + return false + } + cmdLen := readAll(cstr("/proc/self/cmdline"), cmdBase, cmdBufCap) + if cmdLen < 0 { + return false + } + + // Build argv: {loader, "--preload", libs, self, , NULL} + argvBase := mmapAnon(ptrArrSize) + envpBase := mmapAnon(ptrArrSize) + if argvBase == nil || envpBase == nil { + return false + } + preloadC := cstr("--preload") + if preloadC == nil { + return false + } + ai := 0 + setPtr(argvBase, ai, uintptr(unsafe.Pointer(loaderC))) + ai++ + setPtr(argvBase, ai, uintptr(unsafe.Pointer(preloadC))) + ai++ + setPtr(argvBase, ai, uintptr(unsafe.Pointer(libsC))) + 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. + // -4: room for the guard, the two recorded variables below, and the NULL. + ei := 0 + for off := 0; off < envLen && ei < ptrArrCap-4; { + if !isBridgeVar(envBase, off, envLen) { + setPtr(envpBase, ei, uintptr(unsafe.Add(envBase, off))) + ei++ + } + for off < envLen && *(*byte)(unsafe.Add(envBase, off)) != 0 { + off++ + } + off++ // skip NUL + } + if g := taggedEnv(guardKey, pid, cstr("1")); g != nil { + 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. + 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 + + // A library preloaded into every process can fail to initialise in the + // re-executed one, and a constructor that aborts kills it before main: + // there is no process left to notice, and none that could still go on + // without FFI (f4 #1213). So where something is preloaded, find out first. + if needsPreloadProbe(envBase, envLen) && + !probeHostLoader(loaderC, argvBase, envpBase, ei, taggedEnv(probeKey, pid, cstr("1"))) { + diag("goffi: universal build: the host loader could not start this binary (a library preloaded through /etc/ld.so.preload or LD_PRELOAD probably failed to initialise); continuing without FFI\n") + return false + } + + 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; continuing without FFI\n") + return false +} diff --git a/internal/fakecgo/setenv.go b/internal/fakecgo/setenv.go index c827d55..17f2e68 100644 --- a/internal/fakecgo/setenv.go +++ b/internal/fakecgo/setenv.go @@ -6,7 +6,7 @@ package fakecgo -import _ "unsafe" // for go:linkname +import "unsafe" // for go:linkname, and to clear the hooks below //go:linkname x_cgo_setenv_trampoline x_cgo_setenv_trampoline //go:linkname _cgo_setenv runtime._cgo_setenv @@ -17,3 +17,23 @@ var _cgo_setenv = &x_cgo_setenv_trampoline //go:linkname _cgo_unsetenv runtime._cgo_unsetenv var x_cgo_unsetenv_trampoline byte var _cgo_unsetenv = &x_cgo_unsetenv_trampoline + +// dropLibcEnvHooks clears _cgo_setenv and _cgo_unsetenv for a process that has +// no libc (hostlibc.Missing). +// +// The runtime's setenv_c and unsetenv_c ask only whether these hooks are set, +// not whether the process is cgo, so clearing runtime.iscgo does not stop +// os.Setenv from calling x_cgo_setenv -- and that calls libc's setenv, which is +// unbound there: a jump to address 0 (f4 #1213, on a host whose preloaded +// library aborted the re-executed process, so the fallback ran and the first +// os.Setenv killed it). With the hooks nil the runtime updates only its own +// copy of the environment, which is all a process with no libc has. +// +// It writes through unsafe.Pointer because it runs from x_cgo_init, before the +// heap and the write barrier exist. +// +//go:nosplit +func dropLibcEnvHooks() { + *(*uintptr)(unsafe.Pointer(&_cgo_setenv)) = 0 + *(*uintptr)(unsafe.Pointer(&_cgo_unsetenv)) = 0 +} diff --git a/internal/fakecgo/setup_universal_tls.go b/internal/fakecgo/setup_universal_tls.go new file mode 100644 index 0000000..c43fb5a --- /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_static && 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..7a69ef5 --- /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_static && 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..d944a97 --- /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_static && 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..abe78e6 --- /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_static && !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() {} diff --git a/internal/fakecgo/symbols_linux.go b/internal/fakecgo/symbols_linux.go index 9c8c3c3..f2da2f1 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_static +//go:build !cgo && !android && !goffi_static && !goffi_musl && !goffi_universal package fakecgo diff --git a/internal/fakecgo/symbols_musl_amd64.go b/internal/fakecgo/symbols_musl_amd64.go new file mode 100644 index 0000000..3d8ec65 --- /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_static && goffi_musl && !goffi_universal && 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..1c7e9bf --- /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_static && goffi_musl && !goffi_universal && 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/fakecgo/symbols_universal.go b/internal/fakecgo/symbols_universal.go new file mode 100644 index 0000000..dbde61e --- /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_static && 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/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{} diff --git a/internal/loader/loader.go b/internal/loader/loader.go new file mode 100644 index 0000000..4cb255e --- /dev/null +++ b/internal/loader/loader.go @@ -0,0 +1,73 @@ +// 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 + // Preload is passed bare to ` --preload ` to give a + // universal binary every symbol it imports: LibC on musl; on glibc LibC + // followed by libpthread.so.0 and libdl.so.2, which hold the pthread_* + // and dl* functions on glibc older than 2.34 and are stubs from 2.34 on. + Preload string + 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..f8fc562 --- /dev/null +++ b/internal/loader/loader_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package loader + +import ( + "strings" + "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 == "" || e.Preload == "" { + t.Fatalf("Detect returned kind %v with empty Loader/LibC/Preload: %+v", e.Kind, e) + } + // The libc itself comes first in the preload list: it is the one object + // every flavor needs, and the rest only add to it. + if !strings.HasPrefix(e.Preload+" ", e.LibC+" ") { + t.Errorf("Preload %q does not start with LibC %q", e.Preload, e.LibC) + } + 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..112941c --- /dev/null +++ b/internal/loader/table_amd64.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && amd64 + +package loader + +// Host loader, libc SONAME and preload list 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", + Preload: "libc.so.6 libpthread.so.0 libdl.so.2", + Kind: KindGlibc, + } + Musl = Entry{ + Loader: "/lib/ld-musl-x86_64.so.1", + LibC: "libc.musl-x86_64.so.1", + Preload: "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..f383163 --- /dev/null +++ b/internal/loader/table_arm64.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && arm64 + +package loader + +// Host loader, libc SONAME and preload list 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", + Preload: "libc.so.6 libpthread.so.0 libdl.so.2", + Kind: KindGlibc, + } + Musl = Entry{ + Loader: "/lib/ld-musl-aarch64.so.1", + LibC: "libc.musl-aarch64.so.1", + Preload: "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 +) diff --git a/internal/syscall/errno_linux.go b/internal/syscall/errno_linux.go index e2e38e9..db71974 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 +//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 new file mode 100644 index 0000000..082583b --- /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_universal && 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..43daca4 --- /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_universal && 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/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 "" 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" 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"