diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b308c0..ffab80b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI # Testing Strategy: # - Tests run on Linux and Windows (AMD64 only - macOS planned for v0.5.0) # - goffi uses platform-specific assembly (System V vs Win64 ABI) -# - Go 1.25+ required (matches go.mod requirement) +# - Go 1.26+ required (matches go.mod requirement) # - Race detector critical for low-level FFI code # # Branch Strategy (Git Flow): @@ -41,7 +41,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true - name: Download dependencies @@ -69,7 +69,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true - name: Check formatting @@ -97,7 +97,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true - name: Cross-compile all platforms @@ -168,6 +168,52 @@ jobs: # Keep both supported Go patch lines: runtime/cgo startup and TLS details # are part of this platform contract, so a single floating toolchain is not # sufficient evidence. + # Static builds - the goffi_static tag must strip every + # //go:cgo_import_dynamic directive, otherwise the linker keeps emitting an + # ELF interpreter and the artifact will not run on Alpine or scratch. + # See docs/STATIC_BUILDS.md and unxed/f4#693. + static-build: + name: Static Build (goffi_static) + runs-on: ubuntu-latest + needs: [lint, formatting] + env: + CGO_ENABLED: 0 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + cache: true + + - name: Check static build mode + run: scripts/check-static.sh + + # musl builds - the goffi_musl tag must swap the glibc SONAMEs and the + # ELF interpreter for their musl equivalents, otherwise the binary cannot + # start on Alpine. Link-time checks run for amd64 and arm64; the runtime + # probe executes inside a real Alpine userland. See docs/MUSL.md. + musl-build: + name: musl Build (goffi_musl) + runs-on: ubuntu-latest + needs: [lint, formatting] + env: + CGO_ENABLED: 0 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + cache: true + + - name: Check musl build mode + run: scripts/check-musl.sh + android-cross: name: Android arm64 (Go ${{ matrix.go }}) runs-on: ubuntu-latest @@ -175,7 +221,7 @@ jobs: strategy: fail-fast: false matrix: - go: ['1.25.12', '1.26.5'] + go: ['1.25.12', '1.26.5', '1.26.x'] steps: - name: Checkout code uses: actions/checkout@v4 @@ -229,7 +275,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true cache-dependency-path: go.sum @@ -298,7 +344,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' cache: true - name: Download dependencies @@ -340,7 +386,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26.x' - name: Download coverage reports uses: actions/download-artifact@v4 @@ -408,7 +454,7 @@ jobs: echo "✅ Lint: PASSED" echo "✅ Formatting: PASSED" echo "✅ Cross-Compile: PASSED (8 desktop targets)" - echo "✅ Android arm64: PASSED (API 29+, Go 1.25/1.26, cgo=0/1)" + echo "✅ Android arm64: PASSED (API 29+, Go 1.26, cgo=0/1)" echo "✅ Tests: PASSED (CGO_ENABLED=0 and CGO_ENABLED=1)" echo " - Linux AMD64 (ubuntu-latest)" echo " - Windows AMD64 (windows-latest)" diff --git a/.github/workflows/universal.yml b/.github/workflows/universal.yml new file mode 100644 index 0000000..8b1d901 --- /dev/null +++ b/.github/workflows/universal.yml @@ -0,0 +1,117 @@ +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 + - name: Upload universal probe + uses: actions/upload-artifact@v4 + with: + name: universal-probe + path: universal-probe + + run-glibc: + name: run on glibc (${{ matrix.image }}) + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + image: [ "debian:stable-slim", "ubuntu:24.04" ] + container: + image: ${{ matrix.image }} + steps: + - uses: actions/download-artifact@v4 + with: + name: universal-probe + - name: Run the same universal binary on glibc + run: | + set -eu + chmod +x universal-probe + ./universal-probe | tee out.txt + grep -q UNIVERSAL-PROBE-OK out.txt + + run-musl: + name: run on musl (alpine) + needs: build + runs-on: ubuntu-latest + container: + image: alpine:latest + steps: + - uses: actions/download-artifact@v4 + with: + name: universal-probe + - name: Run the same universal binary on musl + shell: sh + run: | + set -eu + chmod +x universal-probe + ./universal-probe | tee out.txt + grep -q UNIVERSAL-PROBE-OK out.txt + + purego-coexistence: + name: purego coexistence (CGO-free, -tags nofakecgo) + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: goffi + - uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + - name: Build and run a program importing goffi and purego together + run: | + set -euo pipefail + mkdir coexist && cd coexist + printf '%s\n' \ + 'package main' \ + '' \ + 'import (' \ + ' _ "github.com/ebitengine/purego"' \ + ' "github.com/go-webgpu/goffi/ffi"' \ + ')' \ + '' \ + 'func main() { println("coexist ffi.Available =", ffi.Available()) }' \ + > main.go + go mod init coexist + go mod edit -replace github.com/go-webgpu/goffi=../goffi + GOFLAGS=-mod=mod go get github.com/go-webgpu/goffi/ffi + GOFLAGS=-mod=mod go get github.com/ebitengine/purego + CGO_ENABLED=0 go build -tags nofakecgo -o coexist . + ./coexist diff --git a/.gitignore b/.gitignore index 6dff94e..d9f455f 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ RESEARCH*.md # Go modules (don't ignore go.mod and go.sum) !go.mod !go.sum +/goffi-audit +/def_probe diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b167fbf..5134e50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -225,7 +225,7 @@ experiment: prototype macOS Apple Silicon support ### Prerequisites -- **Go 1.25 or later** (required for latest runtime.cgocall features) +- **Go 1.26 or later** (required for latest runtime.cgocall features) - **golangci-lint** (code quality) - **GCC or Clang** (for race detector, optional but recommended) - **Platform access**: @@ -241,7 +241,7 @@ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest # Verify installation golangci-lint --version -go version # Should be 1.25+ +go version # Should be 1.26+ ``` ### Running Tests diff --git a/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 d15430e..8ee9448 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,47 @@ if err != nil { --- +## Static Builds + +Binaries that import goffi are dynamically linked by default: the +`//go:cgo_import_dynamic` directives behind `dlopen`/`dlsym` make the Go linker +emit an ELF interpreter and `DT_NEEDED` entries even under `CGO_ENABLED=0`, and +`-extldflags '-static'` cannot change that because no external linker runs. + +Build with `-tags goffi_static` to drop those directives: + +```bash +CGO_ENABLED=0 go build -tags goffi_static ./... +``` + +The result is a genuinely static binary that runs on Alpine and in `scratch` +containers. The trade is unavoidable — `dlopen` is a service of the dynamic +loader, which a static executable does not have — so in that mode `LoadLibrary`, +`GetSymbol` and `CallFunction` return an error wrapping `ffi.ErrStaticBuild`. + +The API is identical in both modes, so one source tree can produce both +artifacts. Branch on `ffi.Available()`, a compile-time constant, to keep the +pure-Go path and let the linker drop the rest: + +```go +if ffi.Available() { + backend = newAcceleratedBackend() +} else { + backend = newPureGoBackend() +} +``` + +The tag is a no-op on Windows and Android, which are dynamically linked by +construction; `Available()` stays `true` there, so a cross-platform build matrix +can pass the tag everywhere. See [docs/STATIC_BUILDS.md](docs/STATIC_BUILDS.md). + +For Alpine and other musl-based distros there is a third flavor: the default +build hardcodes glibc SONAMEs and the glibc loader path, so it cannot start +under musl at all. Build with `-tags goffi_musl` (plus one `-gcflags` line) to +target musl with **full FFI** — see [docs/MUSL.md](docs/MUSL.md). + +--- + ## Platform Support | Platform | Arch | ABI | Since | CI | @@ -483,3 +524,9 @@ MIT — see [LICENSE](LICENSE). --- *goffi v0.4.1 | [GitHub](https://github.com/go-webgpu/goffi) | [pkg.go.dev](https://pkg.go.dev/github.com/go-webgpu/goffi) | [Dev.to](https://dev.to/kolkov/goffi-zero-cgo-foreign-function-interface-for-go-how-we-call-c-libraries-without-a-c-compiler-ca5)* + +## Universal build (glibc + musl) + +One CGO-free binary can do FFI on both glibc and musl systems — see +[docs/PROFILE_U.md](docs/PROFILE_U.md). Attribution for the Profile U concept +(unxed/static-everywhere, pg83/solo) is in [NOTICE](NOTICE). diff --git a/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-probe/main.go b/cmd/universal-probe/main.go new file mode 100644 index 0000000..4a0f78a --- /dev/null +++ b/cmd/universal-probe/main.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Command universal-probe is the runtime half of the goffi_universal +// verification. One binary, built once with -tags goffi_universal and its +// PT_INTERP stripped, is expected to pass identically on a glibc host and on a +// musl host -- proving the re-exec-through-host-loader bridge really does bring +// up whichever libc is present. +// +// The checks mirror cmd/musl-probe: LoadLibrary (internal/dl), a float and an +// integer call (the call path), getpid against ground truth, a qsort callback +// (crosscall2), and a goroutine hammer that forces the runtime to spawn OS +// threads through fakecgo's pthread_create. The libc is loaded by whichever +// SONAME matches the host, discovered the same way the re-exec bridge does. +// +// Exit status 0 and a final UNIVERSAL-PROBE-OK line mean every check passed. +package main + +import ( + "fmt" + "math" + "os" + "runtime" + "sort" + "sync" + "syscall" + "unsafe" + + "github.com/go-webgpu/goffi/ffi" + "github.com/go-webgpu/goffi/types" +) + +// hostLibc returns the libc SONAME for the running host, chosen exactly like +// the re-exec bridge: prefer glibc if its loader is present, else musl. +func hostLibc() string { + type pair struct{ loader, soname string } + var glibc, musl pair + switch runtime.GOARCH { + case "amd64": + glibc = pair{"/lib64/ld-linux-x86-64.so.2", "libc.so.6"} + musl = pair{"/lib/ld-musl-x86_64.so.1", "libc.musl-x86_64.so.1"} + case "arm64": + glibc = pair{"/lib/ld-linux-aarch64.so.1", "libc.so.6"} + musl = pair{"/lib/ld-musl-aarch64.so.1", "libc.musl-aarch64.so.1"} + default: + return "" + } + if _, err := os.Stat(glibc.loader); err == nil { + return glibc.soname + } + if _, err := os.Stat(musl.loader); err == nil { + return musl.soname + } + return "" +} + +var failed bool + +func check(name string, ok bool, detail string) { + if ok { + fmt.Printf("ok %-20s %s\n", name, detail) + return + } + failed = true + fmt.Printf("FAIL %-20s %s\n", name, detail) +} + +func mustSym(handle unsafe.Pointer, name string) unsafe.Pointer { + sym, err := ffi.GetSymbol(handle, name) + if err != nil { + fmt.Printf("FAIL GetSymbol(%s): %v\n", name, err) + os.Exit(1) + } + return sym +} + +func mustCIF(ret *types.TypeDescriptor, args ...*types.TypeDescriptor) *types.CallInterface { + cif := &types.CallInterface{} + if err := ffi.PrepareCallInterface(cif, types.DefaultCall, ret, args); err != nil { + fmt.Printf("FAIL PrepareCallInterface: %v\n", err) + os.Exit(1) + } + return cif +} + +func main() { + reexeced := os.Getenv("GOFFI_UNIVERSAL_REEXEC") == "1" + fmt.Printf("info re-exec bridge active: %v\n", reexeced) + + lib := hostLibc() + if lib == "" { + fmt.Printf("FAIL no known host libc for GOARCH %s\n", runtime.GOARCH) + os.Exit(1) + } + + handle, err := ffi.LoadLibrary(lib) + if err != nil { + fmt.Printf("FAIL LoadLibrary(%s): %v\n", lib, err) + os.Exit(1) + } + defer 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/docs/ANDROID.md b/docs/ANDROID.md index 530cc08..737efc3 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -56,7 +56,8 @@ are reproducible without a device: ANDROID_NDK_HOME=/path/to/android-ndk-r29 scripts/check-android-arm64.sh ``` -The audited source/ABI matrix is Go 1.25.12 and Go 1.26.5 with Android NDK -r29 (`29.0.14206865`). Keep both Go lines in CI when runtime startup files or -TLS offsets change upstream. Passing this probe is not physical-device +The audited source/ABI matrix is Go 1.26.5 and the current 1.26 patch release +with Android NDK r29 (`29.0.14206865`). go.mod requires 1.26, so the older 1.25 +line is gone. Keep both Go lines in CI when runtime startup files or TLS +offsets change upstream. Passing this probe is not physical-device startup evidence. diff --git a/docs/MUSL.md b/docs/MUSL.md new file mode 100644 index 0000000..b26ae50 --- /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. `ffi.Available()` reports `true`. + +## What changes under the hood + +| Package | glibc build | `goffi_musl` build | +|---|---|---| +| `internal/dl` | `dlopen` … from `libdl.so.2` | from `libc.musl-.so.1` | +| `internal/syscall` | `__errno_location` from `libc.so.6` | from `libc.musl-.so.1` | +| `internal/fakecgo` | `malloc`, `pthread_*` from `libc.so.6` / `libpthread.so.0` | from `libc.musl-.so.1` | +| ELF interpreter | `/lib64/ld-linux-x86-64.so.2` (linker default) | `/lib/ld-musl-.so.1` (directive) | + +One symbol is intentionally absent from the musl set: +`pthread_get_stacksize_np` is a Darwin-only API that neither glibc nor musl +exports. The glibc build gets away with importing it because glibc binds +functions lazily and nobody calls the stub on Linux; musl binds every import +immediately at load time and would abort startup. Its trampoline is only +reachable from the Darwin thread-entry path, so on Linux the linker +dead-code-eliminates it. `TestMuslDirectiveParity` pins this exact +asymmetry, and keeps the glibc and musl symbol sets from drifting apart in +general. + +The `internal/fakecgo` musl files are generated: `gen.go` produces them from +the same symbol tables as the glibc ones, filtered as described above. + +## Tag interplay + +- `goffi_musl` is meaningful only on Linux; elsewhere it selects nothing. +- `goffi_static` wins over `goffi_musl`: with both tags set, every dynamic + import is compiled out and FFI is disabled, exactly as in a plain + `goffi_static` build. A static binary is already libc-agnostic, so there + is no musl flavor of it to want. + +## Verification + +`scripts/check-musl.sh` compiles both architectures, asserts the interpreter +and `DT_NEEDED` with `debug/elf` (`TestMuslLinkArtifacts`), and then executes +`cmd/musl-probe` inside a real Alpine userland — via `docker run alpine` on +CI, via a checksummed Alpine minirootfs and `chroot` when running as root +without docker, or via the musl loader invoked directly as a last resort. +The probe exercises every directive group the tag replaces: dlopen/dlsym, +integer and floating-point calls, errno capture through +`__errno_location`, C-to-Go callbacks (`qsort` with a Go comparator), and a +64-goroutine hammer that forces the Go runtime to create OS threads through +fakecgo's pthread imports. + +## Choosing a Linux flavor + +| You are shipping to | Build | +|---|---| +| glibc distros (Debian, Fedora, …) | default (no tags) | +| Alpine, postmarketOS, other musl distros | `-tags goffi_musl` + the `-gcflags` line above | +| `scratch` / distroless containers, no libc at all | `-tags goffi_static` (FFI off — there are no `.so` files to load there anyway) | + +## 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/PERFORMANCE.md b/docs/PERFORMANCE.md index 376e133..41e3fec 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -2,7 +2,7 @@ > **Comprehensive performance analysis, benchmarks, and usage guidelines** > **Platform**: Windows AMD64, 12th Gen Intel Core i7-1255U -> **Go Version**: 1.25+ +> **Go Version**: 1.26+ --- diff --git a/docs/PROFILE_U.md b/docs/PROFILE_U.md new file mode 100644 index 0000000..76c4e22 --- /dev/null +++ b/docs/PROFILE_U.md @@ -0,0 +1,108 @@ +# Profile U — one CGO-free binary for both glibc and musl + +The universal ("Profile U") build produces a **single** `CGO_ENABLED=0` binary +that does live, in-process FFI on **both** glibc and musl systems (Debian, +Ubuntu, Alpine, …) — no C toolchain, no per-distro rebuild. + +## Build + +```sh +bash scripts/build-universal.sh -o app ./path/to/your/main +go run ./cmd/goffi-audit app # asserts: no PT_INTERP, no DT_NEEDED +``` + +`scripts/build-universal.sh` builds with `-tags goffi_universal CGO_ENABLED=0` +and then strips the program interpreter. The universal build needs **no** +`-gcflags` (unlike the `goffi_musl` build). + +## How it works + +1. **No `DT_NEEDED`.** Every libc symbol is imported with an empty SONAME, so + the linker records undefined symbols but pins the binary to no specific libc. +2. **No `PT_INTERP`.** The interpreter is stripped after linking, so the kernel + loads the binary directly on any distribution (as it does a static binary). +3. **Re-exec through the host loader.** At the very top of startup — before any + libc symbol is touched — the process re-execs itself through the host's own + dynamic loader with the host libc pre-loaded. musl's loader binds the + empty-SONAME symbols of a no-interp binary directly; glibc's loader only + binds a main object that carries a `PT_INTERP`, so on glibc the binary hands + the loader an in-memory copy of itself with the interpreter header restored. + Either way the symbols bind against whichever libc the host ships. + +The host loader and libc are discovered from an auditable table +(`internal/loader`), also exposed publicly: + +```go +ffi.HostLoader() // e.g. "/lib64/ld-linux-x86-64.so.2" or "/lib/ld-musl-x86_64.so.1" +ffi.HostLibC() // e.g. "libc.so.6" or "libc.musl-x86_64.so.1" +ffi.LibcKind() // "glibc" | "musl" | "unknown" +``` + +## Limitations + +- **Linux only.** amd64 is run-tested; arm64 is cross-compile-verified. +- A host whose dynamic loader goffi does not recognise cannot do FFI: 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` (see `docs/MUSL.md` and the CI `purego-coexistence` job). + `-tags nofakecgo` is *not* an option in universal mode: dropping goffi's + fakecgo also drops the re-exec bridge, which lives at the top of its + `x_cgo_init`. + + If a dependency needs the purego *API*, prefer + [`unxed/pureffi`](https://github.com/unxed/pureffi) (a drop-in replacement + installed through a `replace` directive, already listed in the README): it + implements that API entirely on top of goffi and carries **no fakecgo of its + own**, so it needs no build tags and works unchanged in universal mode. No + pureffi change is required for Profile U — this branch only *adds* public API + (`HostLoader`/`HostLibC`/`LibcKind`) and build-tag-gated files. + +## Starting another copy of yourself + +`GOFFI_UNIVERSAL_REEXEC` is inherited, and the bridge honours it: a child that +inherits it concludes it already came through the loader and binds no libc. +Under `exec.Command(os.Args[0], ...)` — or any other plain respawn — the child +therefore dies before `main`, as `symbol lookup error: undefined symbol: malloc` +on glibc, or as a jump to an unbound symbol on musl. + +Start the copy the way the bridge would have: + +```go +exec.Command(ffi.HostLoader(), append( + []string{"--preload", ffi.HostLibC(), os.Args[0]}, args...)...) +``` + +`os.Args[0]`, not `ffi.Executable()`: the loader has to be handed the image this +process was loaded from, which on glibc is the memfd and not the file on disk. +The loader shifts `argv` as usual, so the child sees the image as its `argv[0]` +and its own arguments from `argv[1]`. + +## Attribution + +The Profile U concept — an auditable "no `PT_INTERP`, no `DT_NEEDED`, reach the +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/docs/PROFILE_U_PLAN.md b/docs/PROFILE_U_PLAN.md new file mode 100644 index 0000000..2569fe5 --- /dev/null +++ b/docs/PROFILE_U_PLAN.md @@ -0,0 +1,444 @@ +# Profile U — universal (glibc + musl) FFI: task, plan, and status + +> This document is a self-contained hand-off. It is written so that a new +> contributor (or a smaller model) can continue the work without access to the +> original conversation. It states the goal, the full end-to-end plan, exactly +> what is already done, the one blocker currently in the way (with its designed +> fix), and the precise next steps with commands. +> +> **Status: the plan below is complete.** The universal path works end to end on +> both glibc and musl (§5.1/§5.1b), the loader package, audit tool, tests, CI and +> docs (§5.2-§5.7) have landed. Section 3 is kept as the historical record of the +> blocker that was fixed. See "START HERE" for what is left. + +--- + +## 1. The task (what we are building and why) + +goffi is a pure-Go, CGO-free FFI library (a fork of ebitengine/purego, module +path `github.com/go-webgpu/goffi`). On Linux it makes C calls by importing libc +symbols via `//go:cgo_import_dynamic`, which makes the Go linker emit a +`DT_NEEDED` for a specific libc SONAME plus a fixed `PT_INTERP`. That pins a +binary to one libc: the default build needs glibc (`libc.so.6`, +`/lib64/ld-linux-x86-64.so.2`), and the `goffi_musl` build is a *separate* +binary for musl (`libc.musl-.so.1`, `/lib/ld-musl-.so.1`). + +**Goal:** port the "Profile U" (universal) idea from `unxed/static-everywhere` +into this fork so that a *single* binary does live, in-process FFI on **both** +glibc and musl systems, with **no C toolchain** (CGO_ENABLED=0 is the whole +point — the result must be fully portable). Concretely the deliverables are: + +1. A `goffi_universal` build that yields one binary running FFI on glibc *and* + musl. +2. Port "the loader" — the Profile U loader concept — in a form appropriate to + goffi (see §4; we do **not** vendor a full in-process ELF loader). +3. Ensure goffi does **not conflict with purego** — specifically the + CGO_ENABLED=0 fakecgo symbol collision (`duplicated definition of symbol + _cgo_init`). See §7. +4. Tests **and CI that exercise both libc** on GitHub. +5. Deliver on a branch of the fork (`feature/profile-u-musl`). + +Non-negotiable constraint from the user: **no cgo.** Center everything on the +portable CGO_ENABLED=0 path. The purego-coexistence guarantee that matters is +therefore the CGO_ENABLED=0 one. + +### What "Profile U" means here (scope decision) + +static-everywhere's Profile U is defined by an auditable ELF contract — +"ET_DYN/EXEC, **no PT_INTERP, no DT_NEEDED**; host libraries reached only +through a carried loader/ABI bridge" — and its own docs endorse, as the +*practical* mechanism, "launch with the host's own loader, found at runtime — +`/lib64/ld-linux-x86-64.so.2`, else `/lib/ld-musl-x86_64.so.1` … one binary +works on either libc." The full in-process foreign-libc loader (`pg83/solo`) is +explicitly declared out of scope there ("multi-year, bad safety story"). + +goffi's need is smaller than static-everywhere's: goffi only needs the *host's +own* libc (glibc on a glibc box, musl on a musl box), not to load glibc +libraries on a musl host. So we adopt exactly the endorsed mechanism and do +**not** vendor SoLo. This is faithful to the source and is what makes the +problem tractable. + +--- + +## 2. The validated design (proven on real glibc and musl) + +Key fact: glibc and musl export the **same libc symbol names** (`dlopen`, +`dlsym`, `__errno_location`, `malloc`, `pthread_*`, …). Only the SONAME strings +and the interpreter path differ. So one binary can bind against either libc if +it (a) names no specific SONAME and (b) is launched so that *some* libc is +mapped and its symbols are visible. + +The mechanism, each part empirically validated in the dev sandbox: + +1. **Empty-SONAME imports → no `DT_NEEDED`.** + `//go:cgo_import_dynamic goffi_dlopen dlopen ""` (empty remote library) emits + an *undefined* dynamic symbol with **no** `DT_NEEDED`. Omitting the + `//go:cgo_import_dynamic _ _ "libc.so.6"` "force-line" is what removes the + `DT_NEEDED`. Verified: the resulting binary names no libc SONAME. + +2. **Strip `PT_INTERP`.** The Go linker still writes the default glibc + interpreter even with empty-SONAME imports. A musl-only box has no + `/lib64/ld-linux-x86-64.so.2`, so the kernel would refuse to exec the binary + there and our code would never run. We therefore flip the `PT_INTERP` + program header to `PT_NULL` after linking (`cmd/goffi-strip-interp`). With no + interpreter the kernel loads the binary directly on every distro (as it does + for fully static binaries), and the Go runtime starts fine (verified: a + no-interp Go program that does not touch libc runs cleanly). + +3. **Early re-exec through the host loader with libc pre-loaded.** A no-DT_NEEDED + binary has unbound libc symbols; something must map a libc. We re-exec the + process through the host's *own* dynamic loader with the host libc + pre-loaded: + + ``` + execve(, + {, "--preload", , , }, + ) + ``` + + The host loader maps its libc; the global symbol scope then contains + `malloc`, `dlopen`, `pthread_*`, `__errno_location`; and the re-executed + process binds them from whichever libc the host ships. **Verified on both + libc:** `ld.so --preload libc.so.6 ./bin` (glibc) and + `ld-musl-x86_64.so.1 --preload libc.musl-x86_64.so.1 ./bin` (musl) both bind + the undefined no-DT_NEEDED symbols and a real libc call returns correctly. + Bare SONAMEs work on both (each loader resolves its own libc via its default + search path). The musl loader supports `--preload`. + + After the re-exec, goffi's existing FFI machinery + fakecgo works unchanged + (independently verified earlier: the default glibc goffi binary ran the full + FFI probe — including a thread hammer — under the musl loader). + +This needs **no in-process ELF loader** (no SoLo): we reuse the host's own +`ld.so`, which is present on every distro. The "carried loader" is just a small +discovery table + the re-exec bridge. + +### Where the re-exec must happen (timing) + +`x_cgo_init` (fakecgo's cgo init) is called from `rt0_go` +(`runtime/asm_amd64.s:190`, `asm_arm64.s:124`) **before** `runtime·args` +(line 338) and **before** the scheduler/sysmon start. Under `iscgo=true`, new +OS threads are created via `_cgo_thread_start` → `pthread_create`, which is +unbound on the first (libc-less) launch. The first libc touch is the `malloc` +at the top of `x_cgo_init`. **Therefore the re-exec must run at the very top of +`x_cgo_init`, before `malloc`,** using only raw syscalls (no libc, no Go heap). +argc/argv are not yet in runtime globals there, so argv/envp are read from +`/proc/self/cmdline` and `/proc/self/environ` (both NUL-delimited) and +pointer-ified in place; scratch memory comes from raw `mmap`. + +--- + +## 3. The startup blocker and its fix (RESOLVED - historical record) + +### Symptom +A universal binary (empty-SONAME + stripped interp + re-exec at top of +`x_cgo_init`) **segfaults at startup on the first launch**, before reaching +`execve`. A single early `write(2)` diagnostic prints, then it dies. + +### Root cause (confirmed) +When `_cgo_init` is present, `rt0_go` **skips** the runtime's own TLS setup and +delegates it to `_cgo_init`. In `runtime/asm_amd64.s`, the `JZ needtls` (jump to +the TLS-setup path) is taken **only when `_cgo_init == nil`**. With fakecgo, +`_cgo_init` is non-nil, so `%fs`/TLS is **not** set up when `x_cgo_init` runs. + +The Go compiler emits `MOVQ FS:0xfffffff8, R14` (reload the `g` register from +TLS at `%fs-8`) **after every call to an ABI0 assembly function** (e.g. our +`rawsyscall6`, or fakecgo's `call5`). On a no-interpreter binary the kernel +loaded directly, `%fs` is unset (0), so that TLS read faults. This is why the +first `write` succeeds (the syscall runs) but the instruction right after it +(the g-reload) crashes. In the default/musl builds this never happens because +the host `ld.so` sets up `%fs` before the Go entry point runs. + +Verified with `go tool objdump` on the built binary: the fault is exactly the +`MOVQ FS:-8, R14` after the first ABI0 call. + +### The fix (implemented; landed in `setup_universal_tls_*.s`) +Add a tiny per-arch **assembly** shim `setupUniversalTLS()` and call it as the +**very first statement** of `x_cgo_init` in the universal build, *before* +`maybeReexecUniversal()`. Because it is the first `CALL` in `x_cgo_init` and it +sets `%fs` *before returning*, the compiler's post-call g-reload then reads +mapped memory. The fake `g` value is never dereferenced before `execve` (our +re-exec code uses only raw syscalls and mmap/rodata memory; write barriers are +gated on `runtime.writeBarrier.enabled`, which is false this early — and we +already store the staging-buffer base as `uintptr` to avoid a barrier anyway). +On the **re-executed** launch the host loader sets up real TLS, so the shim is a +no-op. + +Implementation sketch: + +- **amd64** (`setup_universal_tls_amd64.s`, build `... && goffi_universal && amd64`): + ``` + SYS_arch_prctl = 158 ; ARCH_GET_FS = 0x1003 ; ARCH_SET_FS = 0x1002 + SYS_mmap = 9 ; PROT_RW = 3 ; MAP_PRIVATE|ANON = 0x22 ; fd = -1 + // read current fsbase into a stack local via arch_prctl(ARCH_GET_FS, &local) + // if local != 0 -> return (real TLS already set up: re-executed launch) + // else: p = mmap(0, 4096, 3, 0x22, -1, 0); arch_prctl(ARCH_SET_FS, p+2048) + ``` + Do NOT touch R14 (g) or BP. SYSCALL clobbers RCX/R11 (fine here). + +- **arm64** (`setup_universal_tls_arm64.s`): the thread pointer is `TPIDR_EL0`. + `MRS Rx, TPIDR_EL0`; if 0, `mmap` then `MSR TPIDR_EL0, Rx` (no syscall needed + to set it). arm64 is cross-compile-verified only in this environment; mark it + as such. + +- **no-op** (`setup_universal_tls_noop.go`, build `!cgo && linux && !android && + !goffi_universal`): `func setupUniversalTLS() {}`. + +- **call site:** in `internal/fakecgo/go_linux_amd64.go` and `go_linux_arm64.go`, + make `setupUniversalTLS()` the first line of `x_cgo_init`, immediately before + the existing `maybeReexecUniversal()` call. + +### How to verify the fix +``` +export PATH=$PATH:/usr/local/go/bin +bash scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe +# glibc host: +/tmp/uprobe # expect: ... UNIVERSAL-PROBE-OK +# musl: run the SAME /tmp/uprobe inside an Alpine userland (chroot/container) +# expect: "info re-exec bridge active: true" then UNIVERSAL-PROBE-OK +``` +Both must print `UNIVERSAL-PROBE-OK`. `cmd/universal-probe` already auto-detects +the host libc the same way the bridge does. + +--- + +## 4. What is already done (file-by-file) + +All of the following compiles under CGO_ENABLED=0 in every mode (default / +`goffi_universal` / `goffi_static`; `goffi_musl` with its usual +`-gcflags=github.com/go-webgpu/goffi/internal/dl=-std`). It is committed on +branch `feature/profile-u-musl` as two WIP commits. + +**Retagged to be mutually exclusive with `goffi_universal`** (added +`&& !goffi_universal` to their build constraints), so the universal empty-SONAME +files take over cleanly: +- `internal/dl/dl_linux_dynamic.go`, `internal/dl/dl_musl_amd64.go`, + `internal/dl/dl_musl_arm64.go` +- `internal/syscall/errno_linux.go`, `internal/syscall/errno_musl_amd64.go`, + `internal/syscall/errno_musl_arm64.go` +- `internal/fakecgo/symbols_linux.go`, `internal/fakecgo/symbols_musl_amd64.go`, + `internal/fakecgo/symbols_musl_arm64.go` + +**New — universal empty-SONAME imports (no DT_NEEDED):** +- `internal/dl/dl_universal.go` — `dlopen/dlsym/dlerror/dlclose` with `""`. +- `internal/syscall/errno_universal.go` — `__errno_location` with `""`. +- `internal/fakecgo/symbols_universal.go` — `malloc/free/setenv/unsetenv/` + `sigfillset/nanosleep/abort/sigaltstack/pthread_*` with `""`. **Matches the + musl symbol set** (i.e. **no** `pthread_get_stacksize_np`, which is Darwin-only + and musl binds eagerly; it would fail to resolve). + +**New — the re-exec bridge:** +- `internal/fakecgo/reexec_syscall_amd64.s`, `reexec_syscall_arm64.s` — + `func rawsyscall6(trap,a1..a6 uintptr) (r1 uintptr)`, libc-free raw syscall. +- `internal/fakecgo/reexec_table_amd64.go`, `reexec_table_arm64.go` — per-arch + syscall numbers (the `*at` forms only, so the same Go compiles on both arches) + and the loader/libc table: + - amd64: glibc `/lib64/ld-linux-x86-64.so.2` + `libc.so.6`; musl + `/lib/ld-musl-x86_64.so.1` + `libc.musl-x86_64.so.1`. + - arm64: glibc `/lib/ld-linux-aarch64.so.1` + `libc.so.6`; musl + `/lib/ld-musl-aarch64.so.1` + `libc.musl-aarch64.so.1`. +- `internal/fakecgo/reexec_universal_linux.go` — the full nosplit, + heap-free, libc-free re-exec: mmap scratch, read `/proc/self/environ` and + `/proc/self/cmdline`, guard check (`GOFFI_UNIVERSAL_REEXEC=1`), discover the + host loader (glibc first, musl fallback, via raw `faccessat`), build argv/envp + pointer arrays (with a `cstr` bump-allocator that copies rodata strings + + NUL into mmap), and `execve`. Contains a prominent **KNOWN ISSUE** comment + describing the TLS blocker in §3. +- `internal/fakecgo/reexec_noop.go` — no-op `maybeReexecUniversal()` for + non-universal linux builds. + +**Changed — call site:** +- `internal/fakecgo/go_linux_amd64.go`, `go_linux_arm64.go` — call + `maybeReexecUniversal()` at the very top of `x_cgo_init` (before `malloc`). + *(The `setupUniversalTLS()` call from §3 must be added immediately before it.)* + +**New — tooling:** +- `cmd/goffi-strip-interp/main.go` — flips `PT_INTERP` → `PT_NULL` (tiny, + dependency-free ELF edit; handles ELFCLASS32/64). +- `cmd/universal-probe/main.go` — runtime FFI probe (LoadLibrary, sqrt, strlen, + getpid, qsort callback, 32-goroutine thread hammer); auto-detects the host + libc; prints `UNIVERSAL-PROBE-OK`. +- `scripts/build-universal.sh` — `go build -tags goffi_universal + CGO_ENABLED=0` then strip the interpreter. + +`ffi.Available()` returns `!static.Enabled`; universal is **not** static, so FFI +is enabled (`Available()==true`) automatically — no change needed there. + +--- + +## 5. Remaining work (ordered, concrete) + +1. **[DONE] `setupUniversalTLS` shim** (amd64 asm + arm64 asm + no-op stub + + call site) — see §3. Landed; the first launch now sets up TLS and re-execs. + +1b. **[DONE] glibc no-interp binding.** glibc's `ld.so` only binds a re-exec'd + main object that carries a `PT_INTERP`; musl binds a no-interp binary + directly. The bridge now branches on the detected libc: on musl it re-execs + the on-disk binary as-is; on glibc it copies `/proc/self/exe` into a memfd + with the `PT_INTERP` header restored and re-execs that. **Result: + `UNIVERSAL-PROBE-OK` on both glibc and musl end-to-end (same binary, + autonomous re-exec).** The universal probe's FP check was switched from + `sqrt` (libm on glibc) to `atof` (libc on both). + +2. **[DONE] `internal/loader` package** — the auditable "loader" port. A per-arch table + (glibc/musl × amd64/arm64) of loader path + libc SONAME, plus runtime + probing: detect the host libc flavor (filesystem existence of the loader + paths; optionally read `PT_INTERP` from `/proc/self/exe` and/or auxv + `AT_BASE`). Expose via the public `ffi` package: + - `ffi.HostLoader() string`, `ffi.HostLibC() string`, `ffi.LibcKind()` + (glibc/musl/unknown). Keep the table in sync with + `reexec_table_*.go` (or have that file reference this package — but note the + re-exec path is nosplit/libc-free and cannot import general code, so a small + duplicated table there is acceptable; add a test asserting they match). + +3. **[DONE] `cmd/goffi-audit`** — Profile-U contract checker: open a binary with + `debug/elf` and assert it has **no `PT_INTERP`** and **no `DT_NEEDED`**. Exit + non-zero otherwise. This is the portable essence of static-everywhere's + onebin `c_profile.c`. + +4. **[DONE] purego coexistence (CGO_ENABLED=0)** — goffi already has a `nofakecgo` + build tag (`ffi/fakecgo_unix.go` is `//go:build ... && !nofakecgo ...`) that + drops goffi's fakecgo so purego's provides the cgo-runtime symbols. Verified: + `-tags nofakecgo` makes goffi+purego build **and** run under CGO_ENABLED=0. + Harden + document this, and add a CI job that builds and runs a tiny + goffi+purego program with `-tags nofakecgo`. Document that the `goffi_universal` + build owns the cgo runtime and must not be combined with purego's fakecgo + (using `nofakecgo` there would disable the re-exec bridge). + +5. **[DONE] Tests:** + - `internal/loader` unit tests (table correctness; flavor detection). + - `ffi/universal_link_test.go`: build a `-tags goffi_universal` binary and + assert **no `DT_NEEDED`** via `debug/elf` (interp-strip is a separate build + step; the audit tool covers the interp check). + - Confirm the existing `ffi/musl_directives_test.go` (`TestMuslDirectiveParity`) + still passes; extend only if it needs to know about the universal files. + +6. **[DONE] CI** — landed as a separate `.github/workflows/universal.yml` + rather than inside `ci.yml`, mirroring the existing musl pattern + (`scripts/check-musl.sh` + `cmd/musl-probe`). Add: + - a `universal-build` job that builds the probe, strips the interp, and + asserts the ELF contract (no PT_INTERP, no DT_NEEDED); + - runs of `cmd/universal-probe` under **both** an Alpine (musl) container and + a Debian/Ubuntu (glibc) container — the *same* built binary in both; + - a `goffi-audit` job; + - a `purego-coexistence` job (CGO_ENABLED=0, `-tags nofakecgo`). + Existing CI pins a specific Go toolchain; the universal build needs **no** + `-gcflags` (empty-SONAME `cgo_import_dynamic` is allowed in non-cgo code; only + `//go:cgo_dynamic_linker`, which universal does not use, was restricted). + Landed with all four jobs. Two notes for anyone editing that workflow: + - it pins `go-version: '1.26.x'` like `ci.yml`, because the `goffi_musl` + build's `-gcflags=...=-std` workaround is calibrated against that toolchain; + - it runs `go vet -unsafeptr=false`. A bare `go vet ./...` fails on + `ffi/callback_pointer.go` (the deliberate `//go:nocheckptr` helper that + rebuilds a pointer from a native callback register). `ci.yml` never saw + this because it runs vet through golangci-lint, whose `.golangci.yml` + excludes `govet` on exactly those paths; the flag is the bare-vet + equivalent of that exclusion. Do not "fix" it by rewriting the helper. + +7. **[DONE] Docs + attribution:** + - Update `docs/MUSL.md` (mention the universal build as the single-binary + alternative). + - New `docs/PROFILE_U.md` (user-facing): how to build a universal binary, the + runtime re-exec behavior, and the limitations (Linux-only; a system whose + loader we do not recognise cannot do FFI; argv[0] becomes the resolved exe + path after re-exec). + - Attribute `unxed/static-everywhere` and `pg83/solo` in `NOTICE`/`README`. + `docs/PROFILE_U.md` also records the purego/pureffi situation: `-tags + nofakecgo` cannot be used in universal mode (it removes the re-exec bridge + along with fakecgo), so a purego-API dependency should go through + `unxed/pureffi`, which carries no fakecgo of its own. pureffi needs **no** + adaptation for this branch: Profile U only adds build-tag-gated files plus + the new `ffi.HostLoader`/`HostLibC`/`LibcKind` helpers, and pureffi touches + neither. + +--- + +## 6. How to build and test (reference) + +``` +export PATH=$PATH:/usr/local/go/bin + +# Compile checks (all must pass): +CGO_ENABLED=0 go build ./... # default (glibc) +CGO_ENABLED=0 go build -tags goffi_universal ./... # universal +CGO_ENABLED=0 go build -tags goffi_static ./... # static (FFI off) +CGO_ENABLED=0 go build -tags goffi_musl \ + -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... # musl + +# Build + strip a universal binary: +bash scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe + +# ELF contract (after strip): expect NO interpreter, NO NEEDED: +readelf -lW /tmp/uprobe | grep -i "Requesting" # -> nothing +readelf -dW /tmp/uprobe | grep NEEDED # -> nothing + +# Run on glibc (the dev host) and on musl (Alpine userland via chroot/container): +/tmp/uprobe # -> UNIVERSAL-PROBE-OK (after §3 fix) + +# purego coexistence (CGO-free): +# build a small program importing both goffi and purego with -tags nofakecgo, +# CGO_ENABLED=0, and run it. +``` + +Notes for the dev sandbox specifically (may not exist in other environments): +Go is at `/usr/local/go`; an Alpine musl rootfs was extracted at +`/tmp/u/rootfs` (loader `/tmp/u/rootfs/lib/ld-musl-x86_64.so.1`), usable via +`chroot /tmp/u/rootfs …`. In CI, use an official Alpine container instead. + +--- + +## 7. Honesty notes / decisions (do not silently reverse) + +- **No SoLo.** A full in-process foreign-libc loader is intentionally *not* + vendored, matching static-everywhere's own scoping. The universal build reuses + the host's own `ld.so` via `--preload`. +- **Linux-only.** The universal mode targets Linux. On a system whose dynamic + loader we do not recognise, FFI is impossible (the process cannot bind libc); + that is a documented limitation. A program that needs FFI on such a system + cannot run in universal mode. +- **arm64 is cross-compile-verified only** in this environment; state that in + docs/commits. amd64 is the run-tested arch. +- **purego coexistence is guaranteed for the default/musl/static modes** + (CGO_ENABLED=0, `-tags nofakecgo`). The universal build owns the cgo runtime + and should not be combined with purego's fakecgo. +- **argv[0]** of a universal binary becomes the resolved executable path after + the re-exec (we pass `/proc/self/exe` or its readlink target as the program). + Acceptable; document it. + +--- + +## 8. Delivery / pushing + +Development happened in a sandbox with **no GitHub credentials** (the fork remote +is read-only over HTTPS there), so commits are handed off as `git format-patch` +series (and/or a repo tarball) to be applied and pushed by the maintainer: + +``` +git checkout -b feature/profile-u-musl # base: origin/main +git am 0001-*.patch 0002-*.patch [0003-...] # in order +git push -u origin feature/profile-u-musl +``` + +From here on, each hand-off is an **incremental** patch (only the delta over the +previous commit). + +--- + +## START HERE (immediate next action) + +Everything in §5 has landed. What remains is verification and release hygiene: + +1. **Watch the `universal` workflow go green.** Until the vet fix above, the + build job died before `go test`, so the unit-test and both-libc run jobs on + this branch are still **unverified in CI**. Read that run before trusting + the tests. +2. **arm64 is still cross-compile-verified only.** Run `cmd/universal-probe` on + a real aarch64 glibc host and an aarch64 Alpine host, then drop the caveat + from `docs/PROFILE_U.md` and this file. +3. **Release notes.** `CHANGELOG.md` / `ROADMAP.md` have no Profile U entry yet; + add one when the branch is proposed upstream. + +Quick check that the mechanism still works after any change: +`scripts/build-universal.sh -o /tmp/uprobe ./cmd/universal-probe`, then run +`/tmp/uprobe` on glibc and inside an Alpine (musl) userland with `/proc` +mounted; both must print `UNIVERSAL-PROBE-OK`. diff --git a/docs/STATIC_BUILDS.md b/docs/STATIC_BUILDS.md new file mode 100644 index 0000000..862a23e --- /dev/null +++ b/docs/STATIC_BUILDS.md @@ -0,0 +1,143 @@ +# Static Builds (`-tags goffi_static`) + +## The problem + +A binary that imports goffi is dynamically linked, even with `CGO_ENABLED=0` +and even with `-ldflags "-extldflags '-static'"`: + +```console +$ CGO_ENABLED=0 go build -ldflags '-extldflags "-static"' -o app . +$ file app +app: ELF 64-bit LSB executable, x86-64, dynamically linked, + interpreter /lib64/ld-linux-x86-64.so.2 +$ ldd app + libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 + libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 +``` + +Two things are going on: + +1. **`//go:cgo_import_dynamic` is what makes the binary dynamic.** The Go + linker writes a `PT_INTERP` header and `DT_NEEDED` entries as soon as any + package in the build carries one of those directives. This happens under + internal linking, with cgo disabled — the directives are the whole point of + the mechanism. + +2. **`-extldflags` is silently inert here.** It is passed to the *external* + linker, and `CGO_ENABLED=0` builds never invoke one. The flag is accepted + and ignored, which is why the comment in a build script can say "static" + for years while the artifact is not. + +goffi emits those directives from three places: + +| Package | Symbols | Library | +|---------|---------|---------| +| `internal/dl` | `dlopen`, `dlsym`, `dlerror`, `dlclose` | `libdl.so.2` | +| `internal/syscall` | `__errno_location` | `libc.so.6` | +| `internal/fakecgo` | `malloc`, `free`, `pthread_*`, `sigaltstack`, … | `libc.so.6`, `libpthread.so.0` | + +Removing any one of them is not enough; all three have to go. + +## The trade + +There is no way to keep FFI in a static binary on Linux. `dlopen` is a service +of the dynamic loader, and a static executable has no loader mapped into it — +glibc's static `dlopen` was always partial and was removed in 2.34, and musl +rejects it outright. Anything that resolves a symbol from a `.so` at runtime +needs `ld.so` in the process, and `ld.so` arrives only via `PT_INTERP`. + +So the choice is per-build, not per-call: + +| | Dynamic (default) | `-tags goffi_static` | +|--|-------------------|----------------------| +| ELF interpreter | required | none | +| Runs on Alpine / `scratch` | no | yes | +| `LoadLibrary`, `GetSymbol` | work | return `ErrStaticBuild` | +| `CallFunction` | works | returns `ErrStaticBuild` | +| `NewCallback` | works | panics | +| `Available()` | `true` | `false` | + +## Usage + +```bash +CGO_ENABLED=0 go build -tags goffi_static ./... +``` + +The tag changes no API. Code compiles both ways, so a project can ship a static +artifact and a dynamic one from one source tree, and pick at build time which +backends it wants: + +```go +if ffi.Available() { + backend = newAcceleratedBackend() // GPU, Wayland, anything behind dlopen +} else { + backend = newPureGoBackend() +} +``` + +`Available()` returns a compile-time constant, so the dead branch — and every +library binding reachable only from it — is eliminated by the linker rather +than shipped as unreachable code. + +At the call site the error is ordinary and wrapped as usual: + +```go +h, err := ffi.LoadLibrary("libvulkan.so.1") +if errors.Is(err, ffi.ErrStaticBuild) { + // built without dynamic loading; fall back +} +``` + +## Where the tag does nothing + +**Windows** resolves symbols through `LoadLibraryW`/`GetProcAddress`, not +through `cgo_import_dynamic`, and every Windows binary links `kernel32` +regardless. **Android** is dynamically linked by construction: Bionic loads the +app's `.so` files, and a static Android executable could not call into the +platform at all. + +On both, the tag is accepted and ignored: `Available()` stays `true` and FFI +keeps working. A cross-platform build matrix can therefore pass +`-tags goffi_static` everywhere without special-casing mobile and Windows +targets, and only the platforms that can be static become static. +`scripts/check-static.sh` asserts this. + +## Verifying + +`scripts/check-static.sh` compiles the tagged build for every supported +platform and checks the linker output. The check that matters lives in +`ffi/static_link_test.go`: it builds a real consumer binary for linux/amd64 and +linux/arm64, opens it with `debug/elf`, and fails if a `PT_INTERP` header or +any `DT_NEEDED` entry survived. Inspecting the test binary itself would prove +nothing, since the test harness always links the dynamic build. + +By hand: + +```console +$ CGO_ENABLED=0 go build -tags goffi_static -o app . +$ file app +app: ELF 64-bit LSB executable, x86-64, statically linked +$ readelf -d app | head -1 # no .dynamic section at all +``` + +## Notes for future work + +Two directions would narrow the trade-off above. Neither is implemented. + +**Loader-agnostic symbol resolution.** The SONAMEs are currently hardcoded +(`libdl.so.2`, `libc.so.6`, `libpthread.so.0`), which is a glibc assumption: a +*dynamically* linked goffi binary still fails to start on Alpine, because musl +ships none of those names. Walking the process's own link map instead — +`PT_DYNAMIC` → `DT_DEBUG` → `r_debug.r_map` → each object's `.dynsym` — finds +`dlopen` in whatever libc is actually loaded, in pure Go, with no directives at +all. That would drop `internal/dl`'s directives, make dynamic builds work +unmodified on glibc, musl and Bionic, and leave the static/dynamic decision to +the linker rather than a build tag. `internal/fakecgo` would need the same +treatment (its assembly trampolines would jump through resolved pointers rather +than linker-supplied symbols) before the last `DT_NEEDED` entry disappears. + +**A Go-native ELF loader.** Mapping a shared object with `mmap`, applying its +relocations and resolving its symbols — a minimal `ld.so` in Go — is the only +route to `dlopen` in a genuinely static binary. It is a large piece of work and +degrades quickly for libraries with deep dependency chains, but for a +self-contained `.so` it is tractable. diff --git a/examples/simple/go.mod b/examples/simple/go.mod index b9ecfe3..5a9cc67 100644 --- a/examples/simple/go.mod +++ b/examples/simple/go.mod @@ -1,6 +1,6 @@ module example -go 1.25 +go 1.25.0 require github.com/go-webgpu/goffi v0.0.0 diff --git a/examples/struct/structlib.c b/examples/struct/csrc/structlib.c similarity index 100% rename from examples/struct/structlib.c rename to examples/struct/csrc/structlib.c diff --git a/examples/struct/go.mod b/examples/struct/go.mod index 09011dd..a4f9cb1 100644 --- a/examples/struct/go.mod +++ b/examples/struct/go.mod @@ -1,6 +1,6 @@ module struct-example -go 1.25 +go 1.25.0 require github.com/go-webgpu/goffi v0.0.0 diff --git a/examples/struct/main.go b/examples/struct/main.go index 7a8e875..058389f 100644 --- a/examples/struct/main.go +++ b/examples/struct/main.go @@ -15,6 +15,7 @@ package main import ( + _ "embed" "fmt" "os" "os/exec" @@ -26,6 +27,15 @@ import ( "github.com/go-webgpu/goffi/types" ) +// structlibSource is the C library exercised by this example. It is embedded so +// the example builds as a normal Go package (a .c file in the package directory +// would otherwise be rejected as cgo input) and so it runs regardless of the +// working directory: buildLib writes it to a temp file and compiles it at run +// time. Building the shared library still requires a C compiler (gcc/clang). +// +//go:embed csrc/structlib.c +var structlibSource string + // Point mirrors C: typedef struct { int64_t x; int64_t y; } Point; type Point struct { X int64 @@ -251,10 +261,10 @@ func buildLib() (string, bool) { return "", false } - src := filepath.Join(filepath.Dir(os.Args[0]), "structlib.c") - if _, statErr := os.Stat(src); statErr != nil { - // When invoked via "go run .", the source directory is the working directory. - src = "structlib.c" + src := filepath.Join(dir, "structlib.c") + if err = os.WriteFile(src, []byte(structlibSource), 0o600); err != nil { + fmt.Println("write C source error:", err) + return "", false } var soPath string diff --git a/ffi/available.go b/ffi/available.go new file mode 100644 index 0000000..1e2c566 --- /dev/null +++ b/ffi/available.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import ( + "github.com/go-webgpu/goffi/internal/hostlibc" + "github.com/go-webgpu/goffi/internal/static" +) + +// Available reports whether this build of goffi can load shared libraries and +// call foreign functions. +// +// It returns false in two cases. The binary was built with -tags goffi_static, +// a mode that strips every //go:cgo_import_dynamic directive so the Go linker +// produces a fully static executable (no PT_INTERP, no DT_NEEDED); there +// LoadLibrary, GetSymbol and CallFunction return an error wrapping +// ErrStaticBuild instead of calling into libc. Or the binary is a universal +// ("Profile U") build running on a host whose dynamic loader goffi does not +// recognise, so it never bound a libc at startup; there the same three report +// ErrNoHostLibc. The second case is a property of the machine rather than of +// the build, so it can only be answered at run time -- which is the reason to +// ask this function rather than to reason about build tags. +// +// Callers that have a pure-Go fallback should branch on this at startup rather +// than treating the first LoadLibrary failure as fatal: +// +// if ffi.Available() { +// backend = newAcceleratedBackend() +// } else { +// backend = newPureGoBackend() +// } +// +// The goffi_static tag is ignored on Windows and Android, where library loading +// never went through cgo_import_dynamic, so Available reports true there even +// when the tag is set. +func Available() bool { + return !static.Enabled && !hostlibc.Missing +} diff --git a/ffi/call.go b/ffi/call.go index b6736fe..90e4ae0 100644 --- a/ffi/call.go +++ b/ffi/call.go @@ -4,6 +4,8 @@ import ( "unsafe" "github.com/go-webgpu/goffi/internal/arch" + "github.com/go-webgpu/goffi/internal/hostlibc" + "github.com/go-webgpu/goffi/internal/static" gosyscall "github.com/go-webgpu/goffi/internal/syscall" "github.com/go-webgpu/goffi/types" ) @@ -16,6 +18,19 @@ func executeFunction( rvalue unsafe.Pointer, avalue []unsafe.Pointer, ) (syscallErrno uintptr, err error) { + if static.Enabled { + // A static build has no libc and no cgo runtime: runtime.cgocall would + // abort the process with "cgocall unavailable". Fail with an error + // instead. There is no legitimate way to obtain fn here anyway, since + // LoadLibrary and GetSymbol also fail in this mode. + return 0, ErrStaticBuild + } + if hostlibc.Missing { + // Same reasoning as the static guard: no libc means the errno import + // and the callee itself are unbound. LoadLibrary and GetSymbol fail + // in this mode too, so fn cannot legitimately have come from goffi. + return 0, ErrNoHostLibc + } if arch.Registry.Caller == nil { return 0, types.ErrUnsupportedArchitecture } diff --git a/ffi/call_unsupported_arch_test.go b/ffi/call_unsupported_arch_test.go new file mode 100644 index 0000000..de52ad0 --- /dev/null +++ b/ffi/call_unsupported_arch_test.go @@ -0,0 +1,32 @@ +//go:build !goffi_static + +package ffi + +import ( + "errors" + "testing" + "unsafe" + + "github.com/go-webgpu/goffi/internal/arch" + "github.com/go-webgpu/goffi/types" +) + +// TestCallFunctionUnsupportedArchitecture covers executeFunction's guard that +// rejects a call when no architecture caller is registered, which is the state +// on a GOARCH goffi does not implement. arch.Registry is a process-global, so +// the caller is swapped out and restored within this one test; ffi tests run +// sequentially (none call t.Parallel), so no concurrent call observes the nil +// caller. A non-nil cif and fn are required only to pass CallFunctionContext's +// argument validation; the guard returns before either is dereferenced. +func TestCallFunctionUnsupportedArchitecture(t *testing.T) { + saved := arch.Registry.Caller + arch.Registry.Caller = nil + t.Cleanup(func() { arch.Registry.Caller = saved }) + + var dummy int + cif := &types.CallInterface{} + _, err := CallFunction(cif, unsafe.Pointer(&dummy), nil, nil) + if !errors.Is(err, types.ErrUnsupportedArchitecture) { + t.Fatalf("CallFunction with no registered caller: got %v, want ErrUnsupportedArchitecture", err) + } +} diff --git a/ffi/callback.go b/ffi/callback.go index ecaf632..00be491 100644 --- a/ffi/callback.go +++ b/ffi/callback.go @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && amd64 +//go:build (linux || darwin || freebsd) && amd64 && !goffi_static // Package ffi provides callback support for Foreign Function Interface (Unix version). // This file implements Go function registration as C callbacks using @@ -269,13 +269,11 @@ func callbackWrap(a *callbackArgs) { // 3. reflect.NewAt creates a proper typed pointer from the address if intIdx < numIntRegs { pos := numFloatRegs + intIdx - // Double-indirection: reinterpret uintptr bits as pointer without - // triggering checkptr arithmetic check (go.dev/issue/58625). - ptr := *(*unsafe.Pointer)(unsafe.Pointer(&frame[pos])) + ptr := pointerFromNative(frame[pos]) val = reflect.NewAt(argType.Elem(), ptr) intIdx++ } else { - ptr := *(*unsafe.Pointer)(unsafe.Pointer(&frame[stackIdx])) + ptr := pointerFromNative(frame[stackIdx]) val = reflect.NewAt(argType.Elem(), ptr) stackIdx++ } @@ -283,10 +281,10 @@ func callbackWrap(a *callbackArgs) { case reflect.UnsafePointer: if intIdx < numIntRegs { pos := numFloatRegs + intIdx - val = reflect.ValueOf(*(*unsafe.Pointer)(unsafe.Pointer(&frame[pos]))) + val = reflect.ValueOf(pointerFromNative(frame[pos])) intIdx++ } else { - val = reflect.ValueOf(*(*unsafe.Pointer)(unsafe.Pointer(&frame[stackIdx]))) + val = reflect.ValueOf(pointerFromNative(frame[stackIdx])) stackIdx++ } diff --git a/ffi/callback_amd64.s b/ffi/callback_amd64.s index 0c381d7..f3388ad 100644 --- a/ffi/callback_amd64.s +++ b/ffi/callback_amd64.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin) && amd64 +//go:build (linux || darwin) && amd64 && !goffi_static #include "textflag.h" #include "go_asm.h" diff --git a/ffi/callback_arm64.go b/ffi/callback_arm64.go index d3edee8..6bbfc9a 100644 --- a/ffi/callback_arm64.go +++ b/ffi/callback_arm64.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 && !goffi_static // Package ffi provides callback support for Foreign Function Interface (ARM64 Unix version). // This file implements Go function registration as C callbacks using @@ -185,13 +185,11 @@ func callbackWrap(a *callbackArgs) { case reflect.Ptr: if intIdx < numIntRegs { pos := numFloatRegs + intIdx - //nolint:govet,gosec // G103: FFI callback argument - ptr := unsafe.Pointer(frame[pos]) + ptr := pointerFromNative(frame[pos]) val = reflect.NewAt(argType.Elem(), ptr) intIdx++ } else { - //nolint:govet,gosec // G103: FFI callback argument - ptr := unsafe.Pointer(frame[stackIdx]) + ptr := pointerFromNative(frame[stackIdx]) val = reflect.NewAt(argType.Elem(), ptr) stackIdx++ } @@ -199,12 +197,10 @@ func callbackWrap(a *callbackArgs) { case reflect.UnsafePointer: if intIdx < numIntRegs { pos := numFloatRegs + intIdx - //nolint:govet,gosec // G103: FFI callback argument - val = reflect.ValueOf(unsafe.Pointer(frame[pos])) + val = reflect.ValueOf(pointerFromNative(frame[pos])) intIdx++ } else { - //nolint:govet,gosec // G103: FFI callback argument - val = reflect.ValueOf(unsafe.Pointer(frame[stackIdx])) + val = reflect.ValueOf(pointerFromNative(frame[stackIdx])) stackIdx++ } diff --git a/ffi/callback_arm64.s b/ffi/callback_arm64.s index 81821a7..e755254 100644 --- a/ffi/callback_arm64.s +++ b/ffi/callback_arm64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 && !goffi_static #include "textflag.h" #include "go_asm.h" diff --git a/ffi/callback_cthread_test.go b/ffi/callback_cthread_test.go index 4bd79c2..5b0619f 100644 --- a/ffi/callback_cthread_test.go +++ b/ffi/callback_cthread_test.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) +//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) && !goffi_static package ffi diff --git a/ffi/callback_pointer.go b/ffi/callback_pointer.go new file mode 100644 index 0000000..1ac9b10 --- /dev/null +++ b/ffi/callback_pointer.go @@ -0,0 +1,15 @@ +//go:build (linux || darwin || freebsd) && (amd64 || arm64) && !goffi_static + +package ffi + +import "unsafe" + +// pointerFromNative reconstructs a pointer from a native callback register or +// stack slot. The native caller owns the memory, so Go's garbage collector does +// not track or move it; the compiler cannot recover that provenance from uintptr. +// +//go:nocheckptr +func pointerFromNative(address uintptr) unsafe.Pointer { + //nolint:govet,gosec // Native address; see the function contract above. + return unsafe.Pointer(address) +} diff --git a/ffi/callback_pointer_args_test.go b/ffi/callback_pointer_args_test.go new file mode 100644 index 0000000..fb26107 --- /dev/null +++ b/ffi/callback_pointer_args_test.go @@ -0,0 +1,133 @@ +//go:build ((linux && !android) || darwin || freebsd) && amd64 && !goffi_static + +package ffi + +import ( + "runtime" + "testing" + "unsafe" +) + +// These tests drive callbackWrap directly with a hand-built System V AMD64 +// argument frame, exactly as the assembly trampoline would. They cover the +// pointer, unsafe.Pointer, and stack-spilled argument decode paths and the +// pointer return path, which the higher-level callback tests do not reach. +// +// Frame layout: [XMM0-7: 8 slots][RDI,RSI,RDX,RCX,R8,R9: 6 slots][stack...]. +const ( + cbFloatRegs = 8 // XMM0-XMM7 + cbIntRegs = 6 // RDI, RSI, RDX, RCX, R8, R9 + cbStackBase = cbFloatRegs + cbIntRegs // first stack argument slot +) + +// invokeCallback registers cb, drives callbackWrap with frame, and returns the +// marshaled return value (callbackArgs.result). +func invokeCallback(t *testing.T, cb any, frame *[128]uintptr) uintptr { + t.Helper() + ptr := NewCallback(cb) + if ptr == 0 { + t.Fatal("NewCallback returned nil pointer") + } + a := &callbackArgs{index: callbackIndex(ptr), args: unsafe.Pointer(frame)} + callbackWrap(a) + return a.result +} + +// fillIntRegs sets the six integer argument registers to distinct nonzero +// values so that the next argument is forced onto the stack. +func fillIntRegs(frame *[128]uintptr) { + for i := 0; i < cbIntRegs; i++ { + frame[cbFloatRegs+i] = uintptr(int64(i + 1)) + } +} + +func TestCallbackPointerArgInRegister(t *testing.T) { + want := new(int64) + *want = 0x0BADC0DE + var got *int64 + + var frame [128]uintptr + frame[cbFloatRegs] = uintptr(unsafe.Pointer(want)) // RDI + invokeCallback(t, func(p *int64) { got = p }, &frame) + runtime.KeepAlive(want) + + if got != want { + t.Fatalf("pointer arg: got %p, want %p", got, want) + } + if *got != *want { + t.Errorf("deref: got %#x, want %#x", *got, *want) + } +} + +func TestCallbackPointerArgOnStack(t *testing.T) { + want := new(int64) + *want = 0x0FACADE + var got *int64 + + var frame [128]uintptr + fillIntRegs(&frame) + frame[cbStackBase] = uintptr(unsafe.Pointer(want)) // seventh arg spills to stack + invokeCallback(t, func(_, _, _, _, _, _ int64, p *int64) { got = p }, &frame) + runtime.KeepAlive(want) + + if got != want { + t.Fatalf("stack pointer arg: got %p, want %p", got, want) + } + if *got != *want { + t.Errorf("deref: got %#x, want %#x", *got, *want) + } +} + +func TestCallbackUnsafePointerArgInRegister(t *testing.T) { + want := new(int64) + var got unsafe.Pointer + + var frame [128]uintptr + frame[cbFloatRegs] = uintptr(unsafe.Pointer(want)) // RDI + invokeCallback(t, func(p unsafe.Pointer) { got = p }, &frame) + runtime.KeepAlive(want) + + if got != unsafe.Pointer(want) { + t.Fatalf("unsafe.Pointer arg: got %p, want %p", got, unsafe.Pointer(want)) + } +} + +func TestCallbackUnsafePointerArgOnStack(t *testing.T) { + want := new(int64) + var got unsafe.Pointer + + var frame [128]uintptr + fillIntRegs(&frame) + frame[cbStackBase] = uintptr(unsafe.Pointer(want)) // seventh arg spills to stack + invokeCallback(t, func(_, _, _, _, _, _ int64, p unsafe.Pointer) { got = p }, &frame) + runtime.KeepAlive(want) + + if got != unsafe.Pointer(want) { + t.Fatalf("stack unsafe.Pointer arg: got %p, want %p", got, unsafe.Pointer(want)) + } +} + +func TestCallbackBoolArgOnStack(t *testing.T) { + var got bool + + var frame [128]uintptr + fillIntRegs(&frame) + frame[cbStackBase] = 1 // seventh arg (bool) spills to stack + invokeCallback(t, func(_, _, _, _, _, _ int64, b bool) { got = b }, &frame) + + if !got { + t.Error("stack bool arg: got false, want true") + } +} + +func TestCallbackPointerReturn(t *testing.T) { + want := new(int64) + + var frame [128]uintptr + res := invokeCallback(t, func() *int64 { return want }, &frame) + runtime.KeepAlive(want) + + if res != uintptr(unsafe.Pointer(want)) { + t.Fatalf("pointer return: got %#x, want %#x", res, uintptr(unsafe.Pointer(want))) + } +} diff --git a/ffi/callback_static.go b/ffi/callback_static.go new file mode 100644 index 0000000..dc73267 --- /dev/null +++ b/ffi/callback_static.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) && goffi_static + +// Static build: no callbacks. +// +// The real dispatcher (callback_amd64.s / callback_arm64.s) enters Go through +// crosscall2, which is supplied by internal/fakecgo. That package is not linked +// in a static build, so keeping the dispatcher would fail at link time with +// "relocation target crosscall2 not defined" as soon as anything referenced it. + +package ffi + +// NewCallback panics in a static build. +// +// It cannot return an error because its signature is fixed by the dynamic +// implementation, and it cannot return 0 either: a null function pointer handed +// to C would crash later, far from the cause. Check Available before creating +// callbacks in code that may be compiled with -tags goffi_static. +func NewCallback(fn any) uintptr { + _ = fn + panic(ErrStaticBuild) +} diff --git a/ffi/callback_struct_args_test.go b/ffi/callback_struct_args_test.go index 82c0201..e05a6dd 100644 --- a/ffi/callback_struct_args_test.go +++ b/ffi/callback_struct_args_test.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && amd64 +//go:build ((linux && !android) || darwin || freebsd) && amd64 && !goffi_static package ffi diff --git a/ffi/callback_test.go b/ffi/callback_test.go index 9b89b08..75d9e28 100644 --- a/ffi/callback_test.go +++ b/ffi/callback_test.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) +//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) && !goffi_static package ffi diff --git a/ffi/dl_unix_stubs.s b/ffi/dl_unix_stubs.s index d0c15fd..b1dcca9 100644 --- a/ffi/dl_unix_stubs.s +++ b/ffi/dl_unix_stubs.s @@ -1,4 +1,4 @@ -//go:build linux && amd64 +//go:build linux && amd64 && !goffi_static #include "textflag.h" diff --git a/ffi/errors.go b/ffi/errors.go index 3c6c216..0959c6e 100644 --- a/ffi/errors.go +++ b/ffi/errors.go @@ -2,6 +2,9 @@ package ffi import ( "fmt" + + "github.com/go-webgpu/goffi/internal/hostlibc" + "github.com/go-webgpu/goffi/internal/static" ) // InvalidCallInterfaceError indicates CallInterface preparation failed due to @@ -152,6 +155,24 @@ func (e *TypeValidationError) Is(target error) bool { return ok } +// ErrStaticBuild is returned, usually wrapped in a *LibraryError, by every +// operation that needs the dynamic loader when goffi was built with +// -tags goffi_static: LoadLibrary, GetSymbol and CallFunction. +// +// Detect the mode up front with Available, or at the call site with +// errors.Is(err, ffi.ErrStaticBuild). +var ErrStaticBuild = static.ErrDisabled + +// 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/fakecgo_unix.go b/ffi/fakecgo_unix.go index 34c7e51..1f0387a 100644 --- a/ffi/fakecgo_unix.go +++ b/ffi/fakecgo_unix.go @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && !cgo && !nofakecgo +//go:build (linux || darwin || freebsd) && !cgo && !nofakecgo && !goffi_static package ffi diff --git a/ffi/hostinfo.go b/ffi/hostinfo.go new file mode 100644 index 0000000..dffc9da --- /dev/null +++ b/ffi/hostinfo.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import "github.com/go-webgpu/goffi/internal/loader" + +// HostLoader returns the absolute path of the host's dynamic loader (ld.so) for +// the running libc flavor, or "" if it cannot be determined (an unsupported +// architecture, or a host running neither glibc nor musl). This is the loader a +// universal binary re-execs through. +func HostLoader() string { return loader.Detect().Loader } + +// HostLibC returns the host libc SONAME (e.g. "libc.so.6" on glibc or +// "libc.musl-x86_64.so.1" on musl), or "" if it cannot be determined. +func HostLibC() string { return loader.Detect().LibC } + +// LibcKind returns the host libc flavor as "glibc", "musl", or "unknown". +func LibcKind() string { return loader.Detect().Kind.String() } diff --git a/ffi/hostinfo_test.go b/ffi/hostinfo_test.go new file mode 100644 index 0000000..0783689 --- /dev/null +++ b/ffi/hostinfo_test.go @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import ( + "path/filepath" + "testing" +) + +// TestHostInfo exercises the public host-inspection helpers. The concrete +// values are host-dependent, so the test asserts the documented contract +// rather than a fixed string: LibcKind is always one of the three documented +// flavors, and the loader path and libc SONAME are populated together with a +// recognized libc and empty together with an unknown one. +func TestHostInfo(t *testing.T) { + loaderPath := HostLoader() + libc := HostLibC() + kind := LibcKind() + + switch kind { + case "glibc", "musl", "unknown": + // documented set + default: + t.Fatalf("LibcKind() = %q, want one of glibc/musl/unknown", kind) + } + + if kind == "unknown" { + // Nothing to re-exec through: both strings must be empty so callers + // can treat "" as "no universal loader available". + if loaderPath != "" || libc != "" { + t.Errorf("unknown libc but HostLoader()=%q HostLibC()=%q, want both empty", + loaderPath, libc) + } + return + } + + // A recognized libc must report a concrete, absolute loader path and a + // non-empty SONAME: a universal binary re-execs through exactly these. + if loaderPath == "" { + t.Errorf("HostLoader() is empty for kind %q", kind) + } else if !filepath.IsAbs(loaderPath) { + t.Errorf("HostLoader() = %q, want an absolute path", loaderPath) + } + if libc == "" { + t.Errorf("HostLibC() is empty for kind %q", kind) + } +} diff --git a/ffi/musl_directives_test.go b/ffi/musl_directives_test.go new file mode 100644 index 0000000..3e59909 --- /dev/null +++ b/ffi/musl_directives_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi_test + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "testing" +) + +// The musl directive files mirror hand-picked glibc originals, and the two +// must not drift: a symbol added to the glibc side and forgotten on the musl +// side would fail only at load time on Alpine, far from the change that +// caused it. This test pins the invariant at go-test time. +// +// One asymmetry is intentional and encoded below: musl's dynamic linker +// binds every import immediately at load and aborts on an unresolved one, +// so pthread_get_stacksize_np -- a Darwin-only API that glibc's lazy PLT +// silently tolerates -- must be absent from the musl set. + +var importDirective = regexp.MustCompile( + `(?m)^//go:cgo_import_dynamic\s+(\S+)\s+(\S+)\s+"([^"]+)"`) + +var interpDirective = regexp.MustCompile( + `(?m)^//go:cgo_dynamic_linker\s+"([^"]+)"`) + +// symbolSet returns the imported C symbol names in a file, skipping the +// "_ _" force-dependency entries, plus the set of SONAMEs referenced. +func symbolSet(t *testing.T, path string) (map[string]bool, map[string]bool) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + syms := map[string]bool{} + sos := map[string]bool{} + for _, m := range importDirective.FindAllStringSubmatch(string(data), -1) { + sos[m[3]] = true + if m[2] == "_" { + continue + } + syms[m[2]] = true + } + return syms, sos +} + +func TestMuslDirectiveParity(t *testing.T) { + root, err := filepath.Abs("..") + if err != nil { + t.Fatalf("locate module root: %v", err) + } + join := func(elem ...string) string { + return filepath.Join(append([]string{root}, elem...)...) + } + + muslArches := map[string]string{ + "amd64": "x86_64", + "arm64": "aarch64", + } + + // Package -> glibc source of truth and the symbols musl must not carry. + cases := []struct { + name string + glibc string + muslFmt string // per-arch musl file, %s = goarch + exclude map[string]bool + }{ + { + name: "fakecgo", + glibc: join("internal", "fakecgo", "symbols_linux.go"), + muslFmt: join("internal", "fakecgo", "symbols_musl_%s.go"), + exclude: map[string]bool{"pthread_get_stacksize_np": true}, + }, + { + name: "dl", + glibc: join("internal", "dl", "dl_linux_dynamic.go"), + muslFmt: join("internal", "dl", "dl_musl_%s.go"), + }, + { + name: "syscall", + glibc: join("internal", "syscall", "errno_linux.go"), + muslFmt: join("internal", "syscall", "errno_musl_%s.go"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + want, _ := symbolSet(t, tc.glibc) + for s := range tc.exclude { + if !want[s] { + t.Errorf("exclusion list mentions %s, but the glibc file does not import it; update this test", s) + } + delete(want, s) + } + + for goarch, musl := range muslArches { + path := fmt.Sprintf(tc.muslFmt, goarch) + got, sos := symbolSet(t, path) + + for s := range want { + if !got[s] { + t.Errorf("%s: glibc imports %s but the musl file does not", path, s) + } + } + for s := range got { + if !want[s] { + t.Errorf("%s: imports %s, which the glibc file does not (or which musl must not import)", path, s) + } + } + + wantSO := "libc.musl-" + musl + ".so.1" + for so := range sos { + if so != wantSO { + t.Errorf("%s: imports from %q, want only %q", path, so, wantSO) + } + } + } + }) + } + + // The interpreter directive lives in internal/dl and must name the + // matching musl loader on each architecture. + for goarch, musl := range muslArches { + path := join("internal", "dl", fmt.Sprintf("dl_musl_%s.go", goarch)) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + m := interpDirective.FindStringSubmatch(string(data)) + want := "/lib/ld-musl-" + musl + ".so.1" + if m == nil { + t.Errorf("%s: missing //go:cgo_dynamic_linker directive", path) + } else if m[1] != want { + t.Errorf("%s: interpreter %q, want %q", path, m[1], want) + } + } +} diff --git a/ffi/musl_link_test.go b/ffi/musl_link_test.go new file mode 100644 index 0000000..0fd948c --- /dev/null +++ b/ffi/musl_link_test.go @@ -0,0 +1,114 @@ +// 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" + "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) + } +} diff --git a/ffi/nohostlibc_test.go b/ffi/nohostlibc_test.go new file mode 100644 index 0000000..887964f --- /dev/null +++ b/ffi/nohostlibc_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static + +package ffi + +import ( + "errors" + "testing" + "unsafe" + + "github.com/go-webgpu/goffi/internal/hostlibc" + "github.com/go-webgpu/goffi/types" +) + +// TestNoHostLibcIsReportedNotFatal covers the state a universal binary lands +// in on a host with no dynamic loader goffi recognises: startup could not bind +// a libc, so every imported symbol is unbound and calling one would fault. +// +// The bridge sets hostlibc.Missing there, before the runtime starts, and the +// point of the flag is that the process then behaves like a build without FFI +// rather than crashing -- Available says so up front, and the three entry +// points that need libc return ErrNoHostLibc. Reaching that state for real +// needs a machine with no ld.so, so the flag is set directly here; ffi's tests +// run sequentially, so no concurrent call observes it. +func TestNoHostLibcIsReportedNotFatal(t *testing.T) { + saved := hostlibc.Missing + hostlibc.Missing = true + t.Cleanup(func() { hostlibc.Missing = saved }) + + if Available() { + t.Error("Available() = true with no host libc, want false") + } + + if _, err := LoadLibrary("libc.so.6"); !errors.Is(err, ErrNoHostLibc) { + t.Errorf("LoadLibrary error = %v, want one wrapping ErrNoHostLibc", err) + } + + if _, err := GetSymbol(nil, "strlen"); !errors.Is(err, ErrNoHostLibc) { + t.Errorf("GetSymbol error = %v, want one wrapping ErrNoHostLibc", err) + } + + // executeFunction is the guard CallFunction goes through. A caller cannot + // legitimately hold a foreign function pointer in this mode -- the two + // calls above are the only ways to get one, and both fail -- so this is + // belt and braces, checked with a pointer that must never be dereferenced. + var cif types.CallInterface + if _, err := executeFunction(&cif, unsafe.Pointer(uintptr(1)), nil, nil); !errors.Is(err, ErrNoHostLibc) { + t.Errorf("executeFunction error = %v, want ErrNoHostLibc", err) + } +} + +// TestHostLibcPresentByDefault guards the flag's default: every build that is +// not the universal one, and every universal build on a host goffi can bind a +// libc on, must leave it false. A stray set would silently disable FFI +// everywhere. +func TestHostLibcPresentByDefault(t *testing.T) { + if hostlibc.Missing { + t.Fatal("hostlibc.Missing is set on a host that has a libc") + } + if !Available() { + t.Error("Available() = false in a non-static build with a libc") + } +} diff --git a/ffi/selfinfo.go b/ffi/selfinfo.go new file mode 100644 index 0000000..1ce18ef --- /dev/null +++ b/ffi/selfinfo.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import ( + "os" + "strconv" + "strings" +) + +// Environment variables the universal ("Profile U") re-exec bridge writes for +// the process it re-execs; see internal/fakecgo/reexec_universal_linux.go. +// Each value is ":", where pid is the process the bridge described. +// The tag matters because the environment is inherited: execve keeps the pid, +// so the re-executed process still matches, while a child gets a new pid and +// is told nothing about itself by a variable that describes its parent. +const ( + envUniversalExe = "GOFFI_UNIVERSAL_EXE" + envUniversalArgv0 = "GOFFI_UNIVERSAL_ARGV0" +) + +// Executable returns the path of this program's executable file, the way +// os.Executable does, and stays right in a universal ("Profile U") build. +// +// Such a build has no PT_INTERP, so before main it re-execs itself through the +// host's dynamic loader with the host libc pre-loaded. After that execve +// /proc/self/exe -- what os.Executable reads on Linux -- names the loader, and +// on glibc, where the loader is handed a memfd copy of the binary, os.Args[0] +// names nothing that exists on disk. The path is therefore not recoverable +// from the running process; the bridge records it in the environment on the way +// through, and this returns what it recorded. +// +// Programs that re-run, update, or install themselves, or that look for files +// next to their own binary, want this rather than os.Executable. Everything +// else can keep calling os.Executable: outside a universal build the two are +// the same call. +func Executable() (string, error) { + if p, ok := recordedSelf(envUniversalExe); ok { + return p, nil + } + return os.Executable() +} + +// Argv0 returns the name this process was invoked with -- what os.Args[0] would +// have held had the universal bridge not re-execed the process. It returns +// os.Args[0] unchanged outside a universal build, and "" only if os.Args is +// empty. +// +// This is the invocation name, not a path: it can be relative, or a bare name +// resolved through PATH, exactly as the caller wrote it. Use Executable to find +// the file. +func Argv0() string { + if a, ok := recordedSelf(envUniversalArgv0); ok { + return a + } + if len(os.Args) == 0 { + return "" + } + return os.Args[0] +} + +// recordedSelf reads a ":" variable and reports its value if it +// describes this process. A non-empty value for another pid is a variable +// inherited from a parent, which says nothing about us. +func recordedSelf(key string) (string, bool) { + raw := os.Getenv(key) + if raw == "" { + return "", false + } + tag, value, found := strings.Cut(raw, ":") + if !found || value == "" { + return "", false + } + pid, err := strconv.Atoi(tag) + if err != nil || pid != os.Getpid() { + return "", false + } + return value, true +} diff --git a/ffi/selfinfo_test.go b/ffi/selfinfo_test.go new file mode 100644 index 0000000..14550ab --- /dev/null +++ b/ffi/selfinfo_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi + +import ( + "os" + "strconv" + "testing" +) + +func TestExecutableUsesRecordedPath(t *testing.T) { + t.Setenv(envUniversalExe, strconv.Itoa(os.Getpid())+":/opt/app/bin/app") + got, err := Executable() + if err != nil { + t.Fatalf("Executable() error: %v", err) + } + if want := "/opt/app/bin/app"; got != want { + t.Errorf("Executable() = %q, want %q", got, want) + } +} + +func TestExecutableIgnoresInheritedRecord(t *testing.T) { + // A value tagged with another pid was inherited from a parent and + // describes that parent's binary, not ours. + t.Setenv(envUniversalExe, strconv.Itoa(os.Getpid()+1)+":/opt/parent/bin/parent") + want, err := os.Executable() + if err != nil { + t.Skipf("os.Executable unavailable here: %v", err) + } + got, err := Executable() + if err != nil { + t.Fatalf("Executable() error: %v", err) + } + if got != want { + t.Errorf("Executable() = %q, want os.Executable() %q", got, want) + } +} + +func TestArgv0(t *testing.T) { + t.Setenv(envUniversalArgv0, strconv.Itoa(os.Getpid())+":f4") + if got, want := Argv0(), "f4"; got != want { + t.Errorf("Argv0() = %q, want %q", got, want) + } + + t.Setenv(envUniversalArgv0, "") + if got, want := Argv0(), os.Args[0]; got != want { + t.Errorf("Argv0() without a record = %q, want %q", got, want) + } +} + +func TestRecordedSelfRejectsMalformed(t *testing.T) { + for _, raw := range []string{ + "", // unset + "/opt/app/bin/app", // no pid tag + "notapid:/opt/app/bin/app", // unparsable tag + strconv.Itoa(os.Getpid()) + ":", // tagged, no value + strconv.Itoa(os.Getpid()+1) + ":/opt", // another process + } { + t.Setenv(envUniversalExe, raw) + if v, ok := recordedSelf(envUniversalExe); ok { + t.Errorf("recordedSelf(%q) = %q, true; want false", raw, v) + } + } +} diff --git a/ffi/static_link_test.go b/ffi/static_link_test.go new file mode 100644 index 0000000..3764bbd --- /dev/null +++ b/ffi/static_link_test.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && (amd64 || arm64) + +package ffi_test + +import ( + "debug/elf" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// probeSource is a throwaway program that links the ffi package the way a real +// consumer does. Building it from a test is the only way to observe what the +// linker emits: the test binary itself is always dynamic, because the test +// harness links the dynamic build of this package. +const probeSource = `package main + +import ( + "errors" + "fmt" + + "github.com/go-webgpu/goffi/ffi" +) + +func main() { + fmt.Printf("available=%v\n", ffi.Available()) + _, err := ffi.LoadLibrary("libm.so.6") + fmt.Printf("static_err=%v\n", errors.Is(err, ffi.ErrStaticBuild)) +} +` + +// TestStaticBuildProducesStaticBinary is the regression test for unxed/f4#693. +// +// Any //go:cgo_import_dynamic directive reachable from the binary makes the Go +// linker emit a PT_INTERP header and DT_NEEDED entries, even under +// CGO_ENABLED=0, and -extldflags '-static' cannot undo it because no external +// linker runs. The goffi_static tag must remove every one of those directives +// for both linux/amd64 and linux/arm64, otherwise the binary will not start on +// Alpine or in a scratch container. +func TestStaticBuildProducesStaticBinary(t *testing.T) { + if testing.Short() { + t.Skip("skipping: builds two binaries") + } + + dir := writeProbeModule(t) + + for _, goarch := range []string{"amd64", "arm64"} { + t.Run(goarch, func(t *testing.T) { + bin := filepath.Join(dir, "probe-"+goarch) + build(t, dir, goarch, bin) + + f, err := elf.Open(bin) + if err != nil { + t.Fatalf("open ELF: %v", err) + } + defer f.Close() + + for _, p := range f.Progs { + if p.Type == elf.PT_INTERP { + t.Error("binary has a PT_INTERP header: it still requires an ELF interpreter") + } + } + + // DynamicSection returns an error when there is no .dynamic + // section at all, which is exactly the outcome we want. + if needed, derr := f.DynString(elf.DT_NEEDED); derr == nil && len(needed) > 0 { + t.Errorf("binary still depends on shared libraries: %v", needed) + } + + if goarch != runtime.GOARCH { + return // cross-compiled, cannot execute it here + } + out, err := exec.Command(bin).CombinedOutput() + if err != nil { + t.Fatalf("run probe: %v\n%s", err, out) + } + got := string(out) + if !strings.Contains(got, "available=false") { + t.Errorf("ffi.Available() should report false in a static build, got:\n%s", got) + } + if !strings.Contains(got, "static_err=true") { + t.Errorf("LoadLibrary should fail with ErrStaticBuild, got:\n%s", got) + } + }) + } +} + +// writeProbeModule creates a throwaway module wired to this checkout, so the +// test needs no network access. +func writeProbeModule(t *testing.T) string { + t.Helper() + + root, err := filepath.Abs("..") + if err != nil { + t.Fatalf("locate module root: %v", err) + } + + dir := t.TempDir() + files := map[string]string{ + "main.go": probeSource, + "go.mod": "module goffiprobe\n\ngo 1.26.0\n\n" + + "require github.com/go-webgpu/goffi v0.0.0\n\n" + + "replace github.com/go-webgpu/goffi => " + root + "\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + return dir +} + +// goToolPath returns the path to the go command. It prefers the go binary in +// the active GOROOT (as reported by "go env GOROOT"), falling back to whatever +// "go" resolves to on PATH. runtime.GOROOT was deprecated in Go 1.24 because +// the build-time GOROOT is meaningless once a binary is moved, so the value is +// queried from the tool at run time instead. Shared with musl_link_test.go. +func goToolPath() string { + out, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + return "go" + } + root := strings.TrimSpace(string(out)) + if root == "" { + return "go" + } + tool := filepath.Join(root, "bin", "go") + if _, statErr := os.Stat(tool); statErr != nil { + return "go" + } + return tool +} + +func build(t *testing.T, dir, goarch, out string) { + t.Helper() + + goTool := goToolPath() + + cmd := exec.Command(goTool, "build", "-tags", "goffi_static", "-o", out, ".") + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "CGO_ENABLED=0", + "GOOS=linux", + "GOARCH="+goarch, + "GOFLAGS=-mod=mod", + ) + if outBytes, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build linux/%s with -tags goffi_static: %v\n%s", goarch, err, outBytes) + } +} diff --git a/ffi/struct_e2e_test.go b/ffi/struct_e2e_test.go index 3e98234..d585a3b 100644 --- a/ffi/struct_e2e_test.go +++ b/ffi/struct_e2e_test.go @@ -6,6 +6,7 @@ package ffi import ( + "fmt" "os" "os/exec" "path/filepath" @@ -19,6 +20,15 @@ import ( var structTestLib unsafe.Pointer func TestMain(m *testing.M) { + if !Available() { + // Built with -tags goffi_static: LoadLibrary, GetSymbol and + // CallFunction are stubs, so every behavioural test in this package + // would fail for the same uninteresting reason. That mode is covered + // from the outside by TestStaticBuildProducesStaticBinary. + fmt.Println("ffi: skipping tests, binary was built with -tags goffi_static") + os.Exit(0) + } + // Android test binaries run on-device, where invoking a host compiler is // neither meaningful nor available. Keep the pure validation tests active // and let only the host-built shared-library cases skip via requireStructLib. diff --git a/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/go.mod b/go.mod index 3d6633a..fc775db 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/go-webgpu/goffi -go 1.25 +go 1.25.0 diff --git a/internal/dl/cgo.go b/internal/dl/cgo.go index 5245f96..3c5a717 100644 --- a/internal/dl/cgo.go +++ b/internal/dl/cgo.go @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors -//go:build cgo && (darwin || freebsd || linux || netbsd) +//go:build cgo && (darwin || freebsd || linux || netbsd) && !goffi_static package dl diff --git a/internal/dl/dl_darwin.go b/internal/dl/dl_darwin.go index 1095464..7da2a21 100644 --- a/internal/dl/dl_darwin.go +++ b/internal/dl/dl_darwin.go @@ -9,21 +9,6 @@ package dl -// Link to libSystem.B.dylib functions using cgo_import_dynamic. -// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) -// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). -// -// On macOS, dlopen/dlsym/dlerror are part of libSystem.B.dylib -// (unlike Linux where they're in libdl.so.2). - -//go:cgo_import_dynamic goffi_dlopen dlopen "/usr/lib/libSystem.B.dylib" -//go:cgo_import_dynamic goffi_dlsym dlsym "/usr/lib/libSystem.B.dylib" -//go:cgo_import_dynamic goffi_dlerror dlerror "/usr/lib/libSystem.B.dylib" -//go:cgo_import_dynamic goffi_dlclose dlclose "/usr/lib/libSystem.B.dylib" - -// Force dependency on libSystem.B.dylib -//go:cgo_import_dynamic _ _ "/usr/lib/libSystem.B.dylib" - // RTLD constants from for dynamic library loading on macOS. const ( // RTLD_LAZY performs relocations at an implementation-dependent time. diff --git a/internal/dl/dl_darwin_dynamic.go b/internal/dl/dl_darwin_dynamic.go new file mode 100644 index 0000000..553a13a --- /dev/null +++ b/internal/dl/dl_darwin_dynamic.go @@ -0,0 +1,21 @@ +//go:build darwin && !goffi_static + +// Dynamic symbol imports for macOS. Kept separate from the RTLD_* constants so +// that the goffi_static build tag can drop them; see dl_linux_dynamic.go. + +package dl + +// Link to libSystem.B.dylib functions using cgo_import_dynamic. +// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) +// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). +// +// On macOS, dlopen/dlsym/dlerror are part of libSystem.B.dylib +// (unlike Linux where they're in libdl.so.2). + +//go:cgo_import_dynamic goffi_dlopen dlopen "/usr/lib/libSystem.B.dylib" +//go:cgo_import_dynamic goffi_dlsym dlsym "/usr/lib/libSystem.B.dylib" +//go:cgo_import_dynamic goffi_dlerror dlerror "/usr/lib/libSystem.B.dylib" +//go:cgo_import_dynamic goffi_dlclose dlclose "/usr/lib/libSystem.B.dylib" + +// Force dependency on libSystem.B.dylib +//go:cgo_import_dynamic _ _ "/usr/lib/libSystem.B.dylib" diff --git a/internal/dl/dl_freebsd.go b/internal/dl/dl_freebsd.go index 8fd0fdc..adbd763 100644 --- a/internal/dl/dl_freebsd.go +++ b/internal/dl/dl_freebsd.go @@ -10,21 +10,6 @@ package dl -// Link to libc.so.7 functions using cgo_import_dynamic. -// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) -// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). -// -// On FreeBSD, dlopen/dlsym/dlclose are part of libc directly -// (unlike Linux where they're in a separate libdl.so.2). - -//go:cgo_import_dynamic goffi_dlopen dlopen "libc.so.7" -//go:cgo_import_dynamic goffi_dlsym dlsym "libc.so.7" -//go:cgo_import_dynamic goffi_dlerror dlerror "libc.so.7" -//go:cgo_import_dynamic goffi_dlclose dlclose "libc.so.7" - -// Force dependency on libc.so.7 -//go:cgo_import_dynamic _ _ "libc.so.7" - // RTLD constants from for dynamic library loading on FreeBSD. const ( // RTLD_LAZY performs relocations at an implementation-dependent time. diff --git a/internal/dl/dl_freebsd_dynamic.go b/internal/dl/dl_freebsd_dynamic.go new file mode 100644 index 0000000..5d38371 --- /dev/null +++ b/internal/dl/dl_freebsd_dynamic.go @@ -0,0 +1,21 @@ +//go:build freebsd && !goffi_static + +// Dynamic symbol imports for FreeBSD. Kept separate from the RTLD_* constants +// so that the goffi_static build tag can drop them; see dl_linux_dynamic.go. + +package dl + +// Link to libc.so.7 functions using cgo_import_dynamic. +// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) +// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). +// +// On FreeBSD, dlopen/dlsym/dlclose are part of libc directly +// (unlike Linux where they're in a separate libdl.so.2). + +//go:cgo_import_dynamic goffi_dlopen dlopen "libc.so.7" +//go:cgo_import_dynamic goffi_dlsym dlsym "libc.so.7" +//go:cgo_import_dynamic goffi_dlerror dlerror "libc.so.7" +//go:cgo_import_dynamic goffi_dlclose dlclose "libc.so.7" + +// Force dependency on libc.so.7 +//go:cgo_import_dynamic _ _ "libc.so.7" diff --git a/internal/dl/dl_linux.go b/internal/dl/dl_linux.go index bec6a9b..c085cb1 100644 --- a/internal/dl/dl_linux.go +++ b/internal/dl/dl_linux.go @@ -9,27 +9,6 @@ package dl -// Link to libdl.so.2 functions using cgo_import_dynamic. -// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) -// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). -// -// Note on glibc >= 2.34: libdl.so.2 is a stub (an empty .so with a versioned -// symlink to libc.so.6). dlopen/dlsym/dlerror/dlclose all live in libc.so.6 -// itself. We still ask the dynamic linker for "libdl.so.2" because -// (a) the stub exists on every glibc release shipped with that version, so -// SONAME-based lookups keep working, and -// (b) older glibc (< 2.34) and musl still ship the real libdl.so.2. -// Either way, ld.so resolves the symbols via the normal scope rules and the -// caller never has to care which .so they ended up in. - -//go:cgo_import_dynamic goffi_dlopen dlopen "libdl.so.2" -//go:cgo_import_dynamic goffi_dlsym dlsym "libdl.so.2" -//go:cgo_import_dynamic goffi_dlerror dlerror "libdl.so.2" -//go:cgo_import_dynamic goffi_dlclose dlclose "libdl.so.2" - -// Force dependency on libdl.so.2 -//go:cgo_import_dynamic _ _ "libdl.so.2" - // RTLD constants from for dynamic library loading on Linux. const ( // RTLD_LAZY performs relocations at an implementation-dependent time. diff --git a/internal/dl/dl_linux_dynamic.go b/internal/dl/dl_linux_dynamic.go new file mode 100644 index 0000000..99918d1 --- /dev/null +++ b/internal/dl/dl_linux_dynamic.go @@ -0,0 +1,32 @@ +//go:build linux && !android && !goffi_static && !goffi_musl && !goffi_universal + +// Dynamic symbol imports for Linux/glibc. The musl flavor lives in +// dl_musl_amd64.go / dl_musl_arm64.go behind the goffi_musl build tag. +// +// These directives live in their own file so that the goffi_static build tag +// can drop them (and with them the ELF interpreter and DT_NEEDED entries the +// Go linker would otherwise emit) while dl_linux.go keeps exporting the RTLD_* +// constants for both build modes. + +package dl + +// Link to libdl.so.2 functions using cgo_import_dynamic. +// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) +// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). +// +// Note on glibc >= 2.34: libdl.so.2 is a stub (an empty .so with a versioned +// symlink to libc.so.6). dlopen/dlsym/dlerror/dlclose all live in libc.so.6 +// itself. We still ask the dynamic linker for "libdl.so.2" because +// (a) the stub exists on every glibc release shipped with that version, so +// SONAME-based lookups keep working, and +// (b) older glibc (< 2.34) and musl still ship the real libdl.so.2. +// Either way, ld.so resolves the symbols via the normal scope rules and the +// caller never has to care which .so they ended up in. + +//go:cgo_import_dynamic goffi_dlopen dlopen "libdl.so.2" +//go:cgo_import_dynamic goffi_dlsym dlsym "libdl.so.2" +//go:cgo_import_dynamic goffi_dlerror dlerror "libdl.so.2" +//go:cgo_import_dynamic goffi_dlclose dlclose "libdl.so.2" + +// Force dependency on libdl.so.2 +//go:cgo_import_dynamic _ _ "libdl.so.2" diff --git a/internal/dl/dl_musl_amd64.go b/internal/dl/dl_musl_amd64.go new file mode 100644 index 0000000..d5347a4 --- /dev/null +++ b/internal/dl/dl_musl_amd64.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && !goffi_universal && amd64 + +// Dynamic symbol imports for Linux/musl (Alpine and friends). +// +// musl ships the entire POSIX surface -- dlopen, pthreads, libm, errno -- +// in one object whose name embeds the architecture: libc.musl-x86_64.so.1. +// There is no libdl.so.2 and no libc.so.6 on a musl system, so the glibc +// directives in dl_linux_dynamic.go make the process fail to start ("Error +// loading shared library libdl.so.2: No such file or directory"). This file +// replaces them under the goffi_musl build tag. +// +// The interpreter is part of the same story: the Go linker defaults +// PT_INTERP to the glibc loader path, which does not exist on Alpine, so +// the binary would die in execve before a single instruction runs. The +// //go:cgo_dynamic_linker directive below bakes the musl loader path into +// every binary built with this tag. The compiler restricts that directive +// to cgo-generated code, so musl builds must relax the check for this one +// package: +// +// CGO_ENABLED=0 go build -tags goffi_musl \ +// -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... +// +// Forgetting the flag is a loud compile error naming this directive, which +// beats the silent alternative: a binary that carries the wrong interpreter +// and fails at startup with a misleading "no such file" about itself. +// +// Tag interplay: goffi_static wins over goffi_musl -- with both set, all +// dynamic imports are compiled out and FFI is disabled, same as plain +// goffi_static. + +package dl + +//go:cgo_import_dynamic goffi_dlopen dlopen "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_dlsym dlsym "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_dlerror dlerror "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_dlclose dlclose "libc.musl-x86_64.so.1" + +// Force dependency on musl libc +//go:cgo_import_dynamic _ _ "libc.musl-x86_64.so.1" + +//go:cgo_dynamic_linker "/lib/ld-musl-x86_64.so.1" diff --git a/internal/dl/dl_musl_arm64.go b/internal/dl/dl_musl_arm64.go new file mode 100644 index 0000000..948d74f --- /dev/null +++ b/internal/dl/dl_musl_arm64.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && !goffi_universal && arm64 + +// Dynamic symbol imports for Linux/musl (Alpine and friends). +// +// musl ships the entire POSIX surface -- dlopen, pthreads, libm, errno -- +// in one object whose name embeds the architecture: libc.musl-aarch64.so.1. +// There is no libdl.so.2 and no libc.so.6 on a musl system, so the glibc +// directives in dl_linux_dynamic.go make the process fail to start ("Error +// loading shared library libdl.so.2: No such file or directory"). This file +// replaces them under the goffi_musl build tag. +// +// The interpreter is part of the same story: the Go linker defaults +// PT_INTERP to the glibc loader path, which does not exist on Alpine, so +// the binary would die in execve before a single instruction runs. The +// //go:cgo_dynamic_linker directive below bakes the musl loader path into +// every binary built with this tag. The compiler restricts that directive +// to cgo-generated code, so musl builds must relax the check for this one +// package: +// +// CGO_ENABLED=0 go build -tags goffi_musl \ +// -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... +// +// Forgetting the flag is a loud compile error naming this directive, which +// beats the silent alternative: a binary that carries the wrong interpreter +// and fails at startup with a misleading "no such file" about itself. +// +// Tag interplay: goffi_static wins over goffi_musl -- with both set, all +// dynamic imports are compiled out and FFI is disabled, same as plain +// goffi_static. + +package dl + +//go:cgo_import_dynamic goffi_dlopen dlopen "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_dlsym dlsym "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_dlerror dlerror "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_dlclose dlclose "libc.musl-aarch64.so.1" + +// Force dependency on musl libc +//go:cgo_import_dynamic _ _ "libc.musl-aarch64.so.1" + +//go:cgo_dynamic_linker "/lib/ld-musl-aarch64.so.1" diff --git a/internal/dl/dl_static.go b/internal/dl/dl_static.go new file mode 100644 index 0000000..5235619 --- /dev/null +++ b/internal/dl/dl_static.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build ((linux && !android) || darwin || freebsd) && goffi_static + +// Static build: no dynamic loader. +// +// This file replaces dl_unix.go when the goffi_static build tag is set. Without +// the //go:cgo_import_dynamic directives there is no dlopen to jump to, so the +// three entry points fail cleanly instead of trapping in assembly. The +// signatures match dl_unix.go exactly, so every caller still compiles. + +package dl + +import "github.com/go-webgpu/goffi/internal/static" + +// Dlopen always fails in a static build. +func Dlopen(path string, mode int) (uintptr, error) { + _, _ = path, mode + return 0, static.ErrDisabled +} + +// Dlsym always fails in a static build. +func Dlsym(handle uintptr, name string) (uintptr, error) { + _, _ = handle, name + return 0, static.ErrDisabled +} + +// Dlclose is a no-op in a static build: no handle can have been opened. +func Dlclose(handle uintptr) error { + _ = handle + return nil +} diff --git a/internal/dl/dl_stubs_arm64.s b/internal/dl/dl_stubs_arm64.s index cec205d..29c1924 100644 --- a/internal/dl/dl_stubs_arm64.s +++ b/internal/dl/dl_stubs_arm64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 && !goffi_static #include "textflag.h" diff --git a/internal/dl/dl_stubs_unix.s b/internal/dl/dl_stubs_unix.s index 03cbf65..71c9cbf 100644 --- a/internal/dl/dl_stubs_unix.s +++ b/internal/dl/dl_stubs_unix.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && amd64 +//go:build (linux || darwin || freebsd) && amd64 && !goffi_static #include "textflag.h" diff --git a/internal/dl/dl_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 7299aed..154c2d2 100644 --- a/internal/dl/dl_unix.go +++ b/internal/dl/dl_unix.go @@ -1,4 +1,4 @@ -//go:build (linux && !android) || darwin || freebsd +//go:build ((linux && !android) || darwin || freebsd) && !goffi_static // OUR OWN Dlopen/Dlsym implementation - NO dependencies! // Uses runtime.cgocall approach similar to syscall6. @@ -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/dl/dl_wrappers_arm64.s b/internal/dl/dl_wrappers_arm64.s index d90a72f..8acbd70 100644 --- a/internal/dl/dl_wrappers_arm64.s +++ b/internal/dl/dl_wrappers_arm64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 && !goffi_static #include "textflag.h" diff --git a/internal/dl/dl_wrappers_unix.s b/internal/dl/dl_wrappers_unix.s index fe06718..b51c6ca 100644 --- a/internal/dl/dl_wrappers_unix.s +++ b/internal/dl/dl_wrappers_unix.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && amd64 +//go:build (linux || darwin || freebsd) && amd64 && !goffi_static #include "textflag.h" diff --git a/internal/fakecgo/gen.go b/internal/fakecgo/gen.go index 1a80e7d..c4f8d8c 100644 --- a/internal/fakecgo/gen.go +++ b/internal/fakecgo/gen.go @@ -263,7 +263,7 @@ func run() error { case "linux": // The go command also satisfies the linux build tag on Android, // including for _linux.go files. Keep glibc imports out of Bionic. - goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo", "//go:build !cgo && !android", 1))) + goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo", "//go:build !cgo && !android && !goffi_musl && !goffi_universal", 1))) case "android": // Android uses a distinct build selector and symbol set. Keep the // generated generic Linux imports out of the Android ELF. @@ -286,6 +286,46 @@ func run() error { } } + // musl (Alpine and friends): the whole POSIX surface lives in one + // arch-named libc.so, so both symbol groups point at the same object, + // and the object name embeds the musl architecture, so the file is + // generated per GOARCH. One symbol is dropped: musl's dynamic linker + // binds every import immediately at load time and aborts startup on an + // unresolved one, unlike glibc's lazy PLT which forgives stubs nobody + // calls -- and pthread_get_stacksize_np is a Darwin-only API that + // neither glibc nor musl exports. Its trampoline is only reachable from + // the Darwin threadentry, so on Linux the linker dead-code-eliminates + // it and the missing import is never referenced. + muslPthread := make([]Symbol, 0, len(pthreadSymbols)) + for _, s := range pthreadSymbols { + if s.Name == "pthread_get_stacksize_np" { + continue + } + muslPthread = append(muslPthread, s) + } + for _, mc := range []struct{ arch, so string }{ + {"amd64", "libc.musl-x86_64.so.1"}, + {"arm64", "libc.musl-aarch64.so.1"}, + } { + tag := "//go:build !cgo && !android && goffi_musl && !goffi_universal && " + mc.arch + mt := template.Must(template.New("symbols_musl.go").Parse( + strings.Replace(templateSymbolsGoos, "//go:build !cgo", tag, 1))) + mb := &bytes.Buffer{} + if merr := mt.Execute(mb, []LocatedSymbols{ + {SharedObject: mc.so, Symbols: libcSymbols}, + {SharedObject: mc.so, Symbols: muslPthread}, + }); merr != nil { + return merr + } + msrc, merr := format.Source(mb.Bytes()) + if merr != nil { + return merr + } + if merr := os.WriteFile(fmt.Sprintf("symbols_musl_%s.go", mc.arch), msrc, 0o644); merr != nil { + return merr + } + } + // The Android wrappers and assembly stubs are generated from the same // restricted symbol set as the imports above. androidSymbols := append(append([]Symbol{}, androidLibcSymbols...), androidPthreadSymbols...) diff --git a/internal/fakecgo/go_linux_amd64.go b/internal/fakecgo/go_linux_amd64.go index 198f2dc..591c8d9 100644 --- a/internal/fakecgo/go_linux_amd64.go +++ b/internal/fakecgo/go_linux_amd64.go @@ -6,7 +6,11 @@ package fakecgo -import "unsafe" +import ( + "unsafe" + + "github.com/go-webgpu/goffi/internal/hostlibc" +) //go:nosplit func _cgo_sys_thread_start(ts *ThreadStart) { @@ -61,6 +65,41 @@ 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 + 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 fb45f64..039af67 100644 --- a/internal/fakecgo/go_linux_arm64.go +++ b/internal/fakecgo/go_linux_arm64.go @@ -6,7 +6,11 @@ package fakecgo -import "unsafe" +import ( + "unsafe" + + "github.com/go-webgpu/goffi/internal/hostlibc" +) //go:nosplit func _cgo_sys_thread_start(ts *ThreadStart) { @@ -66,6 +70,41 @@ 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 + 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..8642e4c --- /dev/null +++ b/internal/fakecgo/reexec_noop.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && !goffi_universal + +package fakecgo + +// maybeReexecUniversal is a no-op outside the goffi_universal build. The +// default (glibc) and goffi_musl binaries name their libc via DT_NEEDED and +// are launched normally by the host loader, so there is nothing to bridge. +// +//go:nosplit +func maybeReexecUniversal() {} diff --git a/internal/fakecgo/reexec_syscall_amd64.s b/internal/fakecgo/reexec_syscall_amd64.s new file mode 100644 index 0000000..aa504fc --- /dev/null +++ b/internal/fakecgo/reexec_syscall_amd64.s @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && amd64 + +#include "textflag.h" + +// func rawsyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1 uintptr) +// +// A minimal, libc-free Linux syscall. Used only by the universal re-exec +// bridge, which runs inside x_cgo_init before any libc symbol is bound, so it +// must not route through the normal (libc-dependent) FFI path. Linux passes +// the 4th argument in R10 (not RCX); the return value comes back in AX. +TEXT ·rawsyscall6(SB), NOSPLIT|NOFRAME, $0-64 + MOVQ trap+0(FP), AX + MOVQ a1+8(FP), DI + MOVQ a2+16(FP), SI + MOVQ a3+24(FP), DX + MOVQ a4+32(FP), R10 + MOVQ a5+40(FP), R8 + MOVQ a6+48(FP), R9 + SYSCALL + MOVQ AX, r1+56(FP) + RET diff --git a/internal/fakecgo/reexec_syscall_arm64.s b/internal/fakecgo/reexec_syscall_arm64.s new file mode 100644 index 0000000..91d861a --- /dev/null +++ b/internal/fakecgo/reexec_syscall_arm64.s @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && arm64 + +#include "textflag.h" + +// func rawsyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1 uintptr) +// +// arm64 Linux syscall: number in R8, arguments in R0-R5, trap via SVC, result +// in R0. See the amd64 counterpart for why this exists. +TEXT ·rawsyscall6(SB), NOSPLIT|NOFRAME, $0-64 + MOVD trap+0(FP), R8 + MOVD a1+8(FP), R0 + MOVD a2+16(FP), R1 + MOVD a3+24(FP), R2 + MOVD a4+32(FP), R3 + MOVD a5+40(FP), R4 + MOVD a6+48(FP), R5 + SVC + MOVD R0, r1+56(FP) + RET diff --git a/internal/fakecgo/reexec_table_amd64.go b/internal/fakecgo/reexec_table_amd64.go new file mode 100644 index 0000000..40712e4 --- /dev/null +++ b/internal/fakecgo/reexec_table_amd64.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && amd64 + +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 + sysMemfdCreate = 319 +) + +// Host dynamic loader + libc SONAME, per libc flavor, for amd64. These are the +// only two ABIs goffi's universal build targets. The SONAMEs are passed bare +// to ` --preload `; each loader resolves its own libc through +// its default search path (verified on glibc and musl). +const ( + glibcLoader = "/lib64/ld-linux-x86-64.so.2" + glibcLibc = "libc.so.6" + muslLoader = "/lib/ld-musl-x86_64.so.1" + muslLibc = "libc.musl-x86_64.so.1" +) diff --git a/internal/fakecgo/reexec_table_arm64.go b/internal/fakecgo/reexec_table_arm64.go new file mode 100644 index 0000000..584caf8 --- /dev/null +++ b/internal/fakecgo/reexec_table_arm64.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && arm64 + +package fakecgo + +// Linux/arm64 syscall numbers used by the re-exec bridge. arm64 has no legacy +// open/access/readlink syscalls, so the bridge uses the *at forms everywhere +// (with AT_FDCWD); these numbers match the generic syscall table. +const ( + sysRead = 63 + sysWrite = 64 + sysClose = 57 + sysMmap = 222 + sysExecve = 221 + sysReadlinkat = 78 + sysOpenat = 56 + sysFaccessat = 48 + sysGetpid = 172 + sysMemfdCreate = 279 +) + +// Host dynamic loader + libc SONAME, per libc flavor, for arm64. +const ( + glibcLoader = "/lib/ld-linux-aarch64.so.1" + glibcLibc = "libc.so.6" + muslLoader = "/lib/ld-musl-aarch64.so.1" + muslLibc = "libc.musl-aarch64.so.1" +) diff --git a/internal/fakecgo/reexec_table_sync_test.go b/internal/fakecgo/reexec_table_sync_test.go new file mode 100644 index 0000000..26d7feb --- /dev/null +++ b/internal/fakecgo/reexec_table_sync_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && (amd64 || arm64) + +package fakecgo + +import ( + "testing" + + "github.com/go-webgpu/goffi/internal/loader" +) + +// The re-exec bridge (this package) and the public loader table must agree on +// the loader paths and libc SONAMEs, since both claim to describe the same host. +func TestReexecTableMatchesLoaderPackage(t *testing.T) { + cases := []struct { + name string + gotL, gotC string + wantL, wantC string + }{ + {"glibc", glibcLoader, glibcLibc, loader.Glibc.Loader, loader.Glibc.LibC}, + {"musl", muslLoader, muslLibc, loader.Musl.Loader, loader.Musl.LibC}, + } + for _, c := range cases { + if c.gotL != c.wantL { + t.Errorf("%s loader: bridge %q != loader pkg %q", c.name, c.gotL, c.wantL) + } + if c.gotC != c.wantC { + t.Errorf("%s libc: bridge %q != loader pkg %q", c.name, c.gotC, c.wantC) + } + } +} diff --git a/internal/fakecgo/reexec_universal_linux.go b/internal/fakecgo/reexec_universal_linux.go new file mode 100644 index 0000000..bae444d --- /dev/null +++ b/internal/fakecgo/reexec_universal_linux.go @@ -0,0 +1,594 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal + +// Portable "universal" re-exec bridge. +// +// A universal goffi binary imports every libc symbol with an empty SONAME, so +// it carries no DT_NEEDED and its PT_INTERP is stripped after linking. That +// makes it loadable by the kernel on any Linux distribution, but it also means +// nothing has mapped a libc into the process: the undefined dlopen/malloc/ +// pthread_* symbols are unbound. The first thing that would touch libc is the +// malloc() at the top of x_cgo_init, which runs from rt0_go before the Go +// scheduler, before runtime.args, and before any OS thread is created. +// +// So, at the very top of x_cgo_init, we re-exec the process through the host's +// own dynamic loader with the host libc pre-loaded: +// +// execve(, {, "--preload", , +// , }, +guard) +// +// The host loader maps its libc, the global symbol scope now contains malloc, +// dlopen, pthread_*, __errno_location, and the re-executed process binds them +// from whichever libc the host actually ships (glibc's libc.so.6 or musl's +// libc.musl-.so.1). A guard variable in the environment stops the second +// launch from re-execing again. +// +// Everything here runs before libc exists, so it uses only raw syscalls +// (rawsyscall6) and mmap'd scratch memory -- never the Go heap, never libc, +// never anything that can grow the stack. String constants live in rodata and +// are copied into an mmap staging buffer with NUL terminators as needed +// (cstr), because C wants NUL-terminated strings and Go string literals are +// not. Argv and envp for the re-exec are read verbatim from /proc/self/cmdline +// and /proc/self/environ (both already NUL-delimited) and merely pointer-ified +// in place. +// +// Failure is best-effort: if no known host loader is found, or execve fails, +// we return and let startup proceed. FFI then cannot work (there is no libc), +// which is the documented limitation of running a universal binary on a system +// whose loader we do not recognise. + +package fakecgo + +// 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 + + guardVar = "GOFFI_UNIVERSAL_REEXEC=1" + guardKey = "GOFFI_UNIVERSAL_REEXEC=" + + // What the re-exec destroys, recorded before it happens. Both values are + // prefixed with the pid they describe, because the environment they live + // in is inherited by every child: the pid survives execve, so the process + // the bridge re-execed still matches, and a child (a new pid) does not. + // ffi.Executable and ffi.Argv0 read these; see ffi/selfinfo.go. + exeKey = "GOFFI_UNIVERSAL_EXE=" + argv0Key = "GOFFI_UNIVERSAL_ARGV0=" + + // interp restore (glibc path) + mfdExec = 0x0010 // MFD_EXEC (kernel 6.3+); fall back to 0 on older kernels + ptNull = 0 // PT_NULL (what the interp header was stripped to) + 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 +} + +// 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 + } + for i := uintptr(0); i < phnum; i++ { + pe := phoff + i*phentsize + if *(*uint32)(unsafe.Add(buf, pe)) != ptNull { + continue + } + poff := uintptr(*(*uint64)(unsafe.Add(buf, pe+8))) // p_offset + if poff < n && *(*byte)(unsafe.Add(buf, poff)) == '/' { + *(*uint32)(unsafe.Add(buf, pe)) = ptInterp + return true + } + } + return false +} + +// restoreInterpToMemfd copies /proc/self/exe into a new memfd with the +// PT_INTERP header restored, and returns the memfd descriptor (an -errno-style +// value on failure, testable with sysErr). The memfd is created without +// MFD_CLOEXEC so it survives the upcoming execve and the loader can open it via +// /proc/self/fd/. Used only on the glibc path. +// +//go:nosplit +func restoreInterpToMemfd() uintptr { + nameC := cstr("goffi") + if nameC == nil { + return ^uintptr(0) + } + // No MFD_CLOEXEC: the fd must survive execve so the loader can open + // /proc/self/fd/. Prefer MFD_EXEC so the copy may be mapped + // executable; fall back to 0 on pre-6.3 kernels that reject the flag. + memfd := rawsyscall6(sysMemfdCreate, uintptr(unsafe.Pointer(nameC)), mfdExec, 0, 0, 0, 0) + if sysErr(memfd) { + memfd = rawsyscall6(sysMemfdCreate, uintptr(unsafe.Pointer(nameC)), 0, 0, 0, 0, 0) + if sysErr(memfd) { + return ^uintptr(0) + } + } + exeFd := rawsyscall6(sysOpenat, atFDCWD, + uintptr(unsafe.Pointer(cstr("/proc/self/exe"))), oRDONLY, 0, 0, 0) + if sysErr(exeFd) { + rawsyscall6(sysClose, memfd, 0, 0, 0, 0, 0) + return ^uintptr(0) + } + buf := mmapAnon(copyChunk) + ok := buf != nil && copyExeToMemfd(exeFd, memfd, buf) + rawsyscall6(sysClose, exeFd, 0, 0, 0, 0, 0) + if !ok { + rawsyscall6(sysClose, memfd, 0, 0, 0, 0, 0) + return ^uintptr(0) + } + return memfd +} + +// copyExeToMemfd streams exeFd into memfd through buf, restoring the PT_INTERP +// header in the first chunk. Returns false on any short or failed I/O. A plain +// helper (not a closure) so nothing heap-allocates in this pre-scheduler code. +// +//go:nosplit +func copyExeToMemfd(exeFd, memfd uintptr, buf unsafe.Pointer) bool { + first := true + for { + n := rawsyscall6(sysRead, exeFd, uintptr(buf), copyChunk, 0, 0, 0) + if sysErr(n) { + return false + } + if n == 0 { + return true + } + if first { + first = false + if !patchInterpInBuf(buf, n) { + return false + } + } + for off := uintptr(0); off < n; { + w := rawsyscall6(sysWrite, memfd, uintptr(unsafe.Add(buf, off)), n-off, 0, 0, 0) + if sysErr(w) || w == 0 { + return false + } + off += w + } + } +} + +// maybeReexecUniversal is invoked at the very top of x_cgo_init. On the first +// launch of a universal binary it re-execs through the host loader with libc +// pre-loaded; on the re-executed launch (guard present) it returns immediately. +// +// 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. + for off := 0; off < envLen; { + if matchAt(envBase, off, envLen, guardKey) { + return true + } + for off < envLen && *(*byte)(unsafe.Add(envBase, off)) != 0 { + off++ + } + off++ // skip NUL + } + + // Pick the host loader + libc SONAME by probing known loader paths. + // Prefer glibc when both are present; fall back to musl. + var loaderC, sonameC *byte + var isGlibc bool + if g := cstr(glibcLoader); fileExists(g) { + loaderC = g + sonameC = cstr(glibcLibc) + isGlibc = true + } else if m := cstr(muslLoader); fileExists(m) { + loaderC = m + sonameC = cstr(muslLibc) + } + if loaderC == nil || sonameC == 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") + } + + // 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", soname, 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(sonameC))) + ai++ + setPtr(argvBase, ai, uintptr(unsafe.Pointer(exeC))) + ai++ + // Append original args, skipping argv[0] (the program name) since exeC + // already occupies argv[0] of the re-executed program. + { + off := 0 + // skip argv[0] + for off < cmdLen && *(*byte)(unsafe.Add(cmdBase, off)) != 0 { + off++ + } + off++ // skip its NUL + for off < cmdLen && ai < ptrArrCap-1 { + setPtr(argvBase, ai, uintptr(unsafe.Add(cmdBase, off))) + ai++ + for off < cmdLen && *(*byte)(unsafe.Add(cmdBase, off)) != 0 { + off++ + } + off++ // skip NUL + } + } + setPtr(argvBase, ai, 0) // NULL-terminate argv + + // Build envp: + guard, NULL-terminated. + // -4: room for the guard, the two recorded variables below, and the NULL. + ei := 0 + for off := 0; off < envLen && ei < ptrArrCap-4; { + setPtr(envpBase, ei, uintptr(unsafe.Add(envBase, off))) + ei++ + for off < envLen && *(*byte)(unsafe.Add(envBase, off)) != 0 { + off++ + } + off++ // skip NUL + } + if g := cstr(guardVar); 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. + pid := rawsyscall6(sysGetpid, 0, 0, 0, 0, 0, 0) + if realExeC != nil && ei < ptrArrCap-2 { + if v := taggedEnv(exeKey, pid, realExeC); v != nil { + setPtr(envpBase, ei, uintptr(unsafe.Pointer(v))) + ei++ + } + } + if cmdLen > 0 && *(*byte)(cmdBase) != 0 && ei < ptrArrCap-2 { + if v := taggedEnv(argv0Key, pid, (*byte)(cmdBase)); v != nil { + setPtr(envpBase, ei, uintptr(unsafe.Pointer(v))) + ei++ + } + } + setPtr(envpBase, ei, 0) // NULL-terminate envp + + rawsyscall6(sysExecve, + 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/setup_universal_tls.go b/internal/fakecgo/setup_universal_tls.go new file mode 100644 index 0000000..4fdd57f --- /dev/null +++ b/internal/fakecgo/setup_universal_tls.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal + +package fakecgo + +// setupUniversalTLS makes the thread pointer usable before the Go code in +// x_cgo_init runs. +// +// When _cgo_init is present, rt0_go skips the runtime's own TLS setup and +// delegates it to _cgo_init (see runtime/asm_amd64.s: the "JZ needtls" branch +// is taken only when _cgo_init is nil). On a universal binary the kernel +// loaded directly -- no interpreter, so no host ld.so ran -- the %fs (amd64) / +// TPIDR_EL0 (arm64) thread pointer is therefore still zero. The Go compiler +// emits a g-register reload from thread-local storage (`MOVQ FS:-8, R14` on +// amd64) after every call to an ABI0 assembly function, and that read faults +// when the thread pointer is unset. +// +// This shim, called as the very first statement of x_cgo_init on the universal +// build, points the thread pointer at a scratch page *only when it is not yet +// set up* (the first launch). The fake g value it exposes is never +// dereferenced before the re-exec bridge calls execve. On the re-executed +// launch the host loader has configured real TLS, so arch_prctl(ARCH_GET_FS) +// / MRS TPIDR_EL0 returns non-zero and the shim does nothing. +// +// Implemented in assembly per architecture; it must not itself touch the g +// register or grow the stack. +func setupUniversalTLS() + +// utlsScratch is a scratch slot for arch_prctl(ARCH_GET_FS) on amd64 (arm64 +// reads TPIDR_EL0 straight into a register and does not use it). +var utlsScratch uintptr diff --git a/internal/fakecgo/setup_universal_tls_amd64.s b/internal/fakecgo/setup_universal_tls_amd64.s new file mode 100644 index 0000000..1bfc025 --- /dev/null +++ b/internal/fakecgo/setup_universal_tls_amd64.s @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && amd64 + +#include "textflag.h" + +// func setupUniversalTLS() +// +// If %fs is unset (first, kernel-direct launch), point it at a scratch page so +// the compiler's post-ABI0-call `MOVQ FS:-8, R14` g-reloads read mapped memory. +// No-op when %fs is already set (the re-executed launch). Never touches R14 (g) +// or BP; SYSCALL's clobber of RCX/R11 is irrelevant here. +TEXT ·setupUniversalTLS(SB), NOSPLIT, $0-0 + // current = arch_prctl(ARCH_GET_FS, &utlsScratch) + MOVQ $0, ·utlsScratch(SB) + MOVQ $158, AX // SYS_arch_prctl + MOVQ $0x1003, DI // ARCH_GET_FS + LEAQ ·utlsScratch(SB), SI + SYSCALL + MOVQ ·utlsScratch(SB), AX + TESTQ AX, AX + JNE tlsdone // TLS already set up + + // p = mmap(0, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0) + MOVQ $9, AX // SYS_mmap + MOVQ $0, DI + MOVQ $4096, SI + MOVQ $0x3, DX // PROT_READ|PROT_WRITE + MOVQ $0x22, R10 // MAP_PRIVATE|MAP_ANONYMOUS + MOVQ $-1, R8 + MOVQ $0, R9 + SYSCALL + CMPQ AX, $-4096 + JAE tlsdone // mmap failed; nothing better to do + + // arch_prctl(ARCH_SET_FS, p+2048) -- offset keeps FS:-8 inside the page + ADDQ $2048, AX + MOVQ AX, SI + MOVQ $158, AX // SYS_arch_prctl + MOVQ $0x1002, DI // ARCH_SET_FS + SYSCALL + +tlsdone: + RET diff --git a/internal/fakecgo/setup_universal_tls_arm64.s b/internal/fakecgo/setup_universal_tls_arm64.s new file mode 100644 index 0000000..1944238 --- /dev/null +++ b/internal/fakecgo/setup_universal_tls_arm64.s @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal && arm64 + +#include "textflag.h" + +// func setupUniversalTLS() +// +// arm64 counterpart of the amd64 shim. The thread pointer is TPIDR_EL0, which +// EL0 may read and write directly (no syscall). Go stores g at TPIDR_EL0+16 +// (runtime.tls_g). If TPIDR_EL0 is unset (first, kernel-direct launch), point +// it at a scratch page so the compiler's g-reloads read mapped memory. No-op +// when it is already set (the re-executed launch). +// +// NOTE: arm64 is cross-compile-verified only in the development environment; +// it has not been run-tested. The logic mirrors the amd64 path. +TEXT ·setupUniversalTLS(SB), NOSPLIT, $0-0 + MRS TPIDR_EL0, R0 + CBNZ R0, tlsdone // TLS already set up + + // p = mmap(0, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANON, -1, 0) + MOVD $0, R0 + MOVD $4096, R1 + MOVD $0x3, R2 // PROT_READ|PROT_WRITE + MOVD $0x22, R3 // MAP_PRIVATE|MAP_ANONYMOUS + MOVD $-1, R4 + MOVD $0, R5 + MOVD $222, R8 // SYS_mmap + SVC + TBNZ $63, R0, tlsdone // negative => -errno => mmap failed + + ADD $2048, R0 // keep TPIDR_EL0+16 inside the page + MSR R0, TPIDR_EL0 + +tlsdone: + RET diff --git a/internal/fakecgo/setup_universal_tls_noop.go b/internal/fakecgo/setup_universal_tls_noop.go new file mode 100644 index 0000000..0851bab --- /dev/null +++ b/internal/fakecgo/setup_universal_tls_noop.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && !goffi_universal + +package fakecgo + +// setupUniversalTLS is a no-op outside the goffi_universal build: the default +// and goffi_musl binaries are launched by the host loader, which sets up TLS +// before the Go entry point runs. +// +//go:nosplit +func setupUniversalTLS() {} diff --git a/internal/fakecgo/symbols_linux.go b/internal/fakecgo/symbols_linux.go index b5c7dfa..2cc4b96 100644 --- a/internal/fakecgo/symbols_linux.go +++ b/internal/fakecgo/symbols_linux.go @@ -4,7 +4,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && !android +//go:build !cgo && !android && !goffi_musl && !goffi_universal package fakecgo diff --git a/internal/fakecgo/symbols_musl_amd64.go b/internal/fakecgo/symbols_musl_amd64.go new file mode 100644 index 0000000..ca488ba --- /dev/null +++ b/internal/fakecgo/symbols_musl_amd64.go @@ -0,0 +1,30 @@ +// Code generated by 'go generate' with gen.go. DO NOT EDIT. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && !android && goffi_musl && !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..0addd2e --- /dev/null +++ b/internal/fakecgo/symbols_musl_arm64.go @@ -0,0 +1,30 @@ +// Code generated by 'go generate' with gen.go. DO NOT EDIT. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && !android && goffi_musl && !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..5f3479b --- /dev/null +++ b/internal/fakecgo/symbols_universal.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && linux && !android && goffi_universal + +// fakecgo dynamic imports for the portable "universal" Linux build. +// +// This mirrors symbols_linux.go but imports every libc entry point with an +// EMPTY SONAME, so the binary carries no DT_NEEDED and is not tied to glibc or +// musl. The symbols bind from the host libc after the re-exec bridge in +// reexec_universal_linux.go runs (before the Go runtime touches any of them). +// +// pthread_get_stacksize_np is intentionally absent: it is a Darwin-only API +// that neither glibc nor musl exports. glibc binds lazily, so the default +// build gets away with importing a stub nobody calls on Linux; musl binds +// eagerly. A universal binary must be loadable under either libc, so it must +// not name a symbol that musl cannot resolve. The stub's only caller is the +// Darwin thread-entry path, dead-code-eliminated on Linux. + +package fakecgo + +//go:cgo_import_dynamic goffi_malloc malloc "" +//go:cgo_import_dynamic goffi_free free "" +//go:cgo_import_dynamic goffi_setenv setenv "" +//go:cgo_import_dynamic goffi_unsetenv unsetenv "" +//go:cgo_import_dynamic goffi_sigfillset sigfillset "" +//go:cgo_import_dynamic goffi_nanosleep nanosleep "" +//go:cgo_import_dynamic goffi_abort abort "" +//go:cgo_import_dynamic goffi_sigaltstack sigaltstack "" +//go:cgo_import_dynamic goffi_pthread_attr_init pthread_attr_init "" +//go:cgo_import_dynamic goffi_pthread_create pthread_create "" +//go:cgo_import_dynamic goffi_pthread_detach pthread_detach "" +//go:cgo_import_dynamic goffi_pthread_sigmask pthread_sigmask "" +//go:cgo_import_dynamic goffi_pthread_self pthread_self "" +//go:cgo_import_dynamic goffi_pthread_attr_getstacksize pthread_attr_getstacksize "" +//go:cgo_import_dynamic goffi_pthread_attr_setstacksize pthread_attr_setstacksize "" +//go:cgo_import_dynamic goffi_pthread_attr_destroy pthread_attr_destroy "" +//go:cgo_import_dynamic goffi_pthread_mutex_lock pthread_mutex_lock "" +//go:cgo_import_dynamic goffi_pthread_mutex_unlock pthread_mutex_unlock "" +//go:cgo_import_dynamic goffi_pthread_cond_broadcast pthread_cond_broadcast "" +//go:cgo_import_dynamic goffi_pthread_setspecific pthread_setspecific "" + +// No DT_NEEDED force-line, by design (see internal/dl/dl_universal.go). diff --git a/internal/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..9296b58 --- /dev/null +++ b/internal/loader/loader.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Package loader is the auditable "Profile U" loader table: for each supported +// architecture it records the host dynamic loader path and libc SONAME of the +// two libc flavors goffi's universal build targets (glibc and musl), and probes +// the running host to report which is present. +// +// This mirrors the constants used by the universal re-exec bridge +// (internal/fakecgo/reexec_table_*.go); a test asserts the two stay in sync. +// Unlike the bridge, this package is plain Go usable from any build mode, and +// backs the public ffi.HostLoader / ffi.HostLibC / ffi.LibcKind helpers. +package loader + +import "os" + +// Kind identifies a libc flavor. +type Kind int + +const ( + KindUnknown Kind = iota + KindGlibc + KindMusl +) + +// String returns "glibc", "musl", or "unknown". +func (k Kind) String() string { + switch k { + case KindGlibc: + return "glibc" + case KindMusl: + return "musl" + default: + return "unknown" + } +} + +// Entry describes one libc: its dynamic loader path and its libc SONAME. +type Entry struct { + Loader string // absolute path to the dynamic loader (ld.so) + LibC string // libc SONAME, passed bare to ` --preload ` + Kind Kind +} + +// Detect reports the libc flavor of the running host by probing the known +// loader paths (glibc first, then musl), matching the re-exec bridge's choice. +// It returns an Entry with Kind == KindUnknown on architectures goffi's +// universal build does not target, or when neither loader is present. +func Detect() Entry { + if !Known { + return Entry{Kind: KindUnknown} + } + if fileExists(Glibc.Loader) { + return Glibc + } + if fileExists(Musl.Loader) { + return Musl + } + return Entry{Kind: KindUnknown} +} + +func fileExists(p string) bool { + if p == "" { + return false + } + _, err := os.Stat(p) + return err == nil +} diff --git a/internal/loader/loader_test.go b/internal/loader/loader_test.go new file mode 100644 index 0000000..1daf7c3 --- /dev/null +++ b/internal/loader/loader_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package loader + +import "testing" + +func TestKindString(t *testing.T) { + for k, want := range map[Kind]string{KindGlibc: "glibc", KindMusl: "musl", KindUnknown: "unknown"} { + if got := k.String(); got != want { + t.Errorf("Kind(%d).String() = %q, want %q", k, got, want) + } + } +} + +func TestDetectConsistent(t *testing.T) { + e := Detect() + if e.Kind == KindUnknown { + return // exotic host or non-target arch; nothing more to assert + } + if e.Loader == "" || e.LibC == "" { + t.Fatalf("Detect returned kind %v with empty Loader/LibC: %+v", e.Kind, e) + } + if !fileExists(e.Loader) { + t.Errorf("Detect chose %q but it does not exist", e.Loader) + } +} diff --git a/internal/loader/table_amd64.go b/internal/loader/table_amd64.go new file mode 100644 index 0000000..4f962b1 --- /dev/null +++ b/internal/loader/table_amd64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && amd64 + +package loader + +// Host loader + libc SONAME per libc flavor for linux/amd64. Keep in sync with +// internal/fakecgo/reexec_table_amd64.go (asserted by a test). +var ( + Glibc = Entry{Loader: "/lib64/ld-linux-x86-64.so.2", LibC: "libc.so.6", Kind: KindGlibc} + Musl = Entry{Loader: "/lib/ld-musl-x86_64.so.1", LibC: "libc.musl-x86_64.so.1", Kind: KindMusl} + Known = true +) diff --git a/internal/loader/table_arm64.go b/internal/loader/table_arm64.go new file mode 100644 index 0000000..22cb834 --- /dev/null +++ b/internal/loader/table_arm64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && arm64 + +package loader + +// Host loader + libc SONAME per libc flavor for linux/arm64. Keep in sync with +// internal/fakecgo/reexec_table_arm64.go (asserted by a test). +var ( + Glibc = Entry{Loader: "/lib/ld-linux-aarch64.so.1", LibC: "libc.so.6", Kind: KindGlibc} + Musl = Entry{Loader: "/lib/ld-musl-aarch64.so.1", LibC: "libc.musl-aarch64.so.1", Kind: KindMusl} + Known = true +) diff --git a/internal/loader/table_other.go b/internal/loader/table_other.go new file mode 100644 index 0000000..0f6923d --- /dev/null +++ b/internal/loader/table_other.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !linux || (!amd64 && !arm64) + +package loader + +// The universal build targets only linux/amd64 and linux/arm64; elsewhere the +// loader flavor is unknown. +var ( + Glibc = Entry{Kind: KindUnknown} + Musl = Entry{Kind: KindUnknown} + Known = false +) diff --git a/internal/static/enabled.go b/internal/static/enabled.go new file mode 100644 index 0000000..0bfc6d9 --- /dev/null +++ b/internal/static/enabled.go @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build goffi_static && !windows && !android + +package static + +// Enabled reports that this build has no dynamic symbol imports and therefore +// cannot load shared libraries or call foreign functions. +const Enabled = true diff --git a/internal/static/enabled_off.go b/internal/static/enabled_off.go new file mode 100644 index 0000000..b07df27 --- /dev/null +++ b/internal/static/enabled_off.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build !goffi_static || windows || android + +package static + +// Enabled reports that this build resolves symbols through the dynamic loader, +// which is the default. On Windows and Android the goffi_static tag is ignored: +// neither platform uses //go:cgo_import_dynamic for library loading. +const Enabled = false diff --git a/internal/static/static.go b/internal/static/static.go new file mode 100644 index 0000000..978431f --- /dev/null +++ b/internal/static/static.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +// Package static reports whether goffi was built in "static" mode, i.e. with +// every //go:cgo_import_dynamic directive compiled out. +// +// Background: the Go linker marks a binary as dynamically linked (it writes a +// PT_INTERP program header and DT_NEEDED entries) as soon as any package in the +// build carries a //go:cgo_import_dynamic directive. That happens even with +// CGO_ENABLED=0 and internal linking, and -extldflags '-static' cannot undo it +// because no external linker is involved. goffi needs those directives for +// dlopen/dlsym/__errno_location, so any binary that imports goffi is dynamic. +// +// The goffi_static build tag removes those directives. The FFI API keeps its +// shape, but LoadLibrary/GetSymbol/CallFunction return ErrDisabled instead of +// calling into libc, and the resulting binary runs on Alpine, in a scratch +// container, or anywhere else without an ELF interpreter. +// +// The tag is a no-op on Windows (which resolves symbols through kernel32 rather +// than cgo_import_dynamic) and on Android (whose Bionic runtime is always +// dynamically linked). +package static + +import "errors" + +// ErrDisabled is returned by every entry point that would otherwise need the +// dynamic loader when the goffi_static build tag is set. +var ErrDisabled = errors.New( + "goffi: FFI is disabled in this build (built with -tags goffi_static); " + + "rebuild without the tag to load shared libraries") diff --git a/internal/syscall/cgo.go b/internal/syscall/cgo.go index 4d0b95f..9b31903 100644 --- a/internal/syscall/cgo.go +++ b/internal/syscall/cgo.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors -//go:build cgo && (linux || darwin || freebsd) +//go:build cgo && (linux || darwin || freebsd) && !goffi_static package syscall diff --git a/internal/syscall/errno_darwin.go b/internal/syscall/errno_darwin.go index a4e1cf9..41d31a8 100644 --- a/internal/syscall/errno_darwin.go +++ b/internal/syscall/errno_darwin.go @@ -1,4 +1,4 @@ -//go:build darwin && (amd64 || arm64) +//go:build darwin && (amd64 || arm64) && !goffi_static package syscall diff --git a/internal/syscall/errno_freebsd.go b/internal/syscall/errno_freebsd.go index 194b210..f73db68 100644 --- a/internal/syscall/errno_freebsd.go +++ b/internal/syscall/errno_freebsd.go @@ -1,4 +1,4 @@ -//go:build freebsd && (amd64 || arm64) +//go:build freebsd && (amd64 || arm64) && !goffi_static package syscall diff --git a/internal/syscall/errno_linux.go b/internal/syscall/errno_linux.go index 5836294..123905a 100644 --- a/internal/syscall/errno_linux.go +++ b/internal/syscall/errno_linux.go @@ -1,10 +1,14 @@ -//go:build linux && !android && (amd64 || arm64) +//go:build linux && !android && (amd64 || arm64) && !goffi_static && !goffi_musl && !goffi_universal package syscall -// Link __errno_location from libc.so.6 (glibc and musl both export it). -// On glibc >= 2.34, libc.so.6 is the real library; libdl.so.2 is a stub. -// On musl, libc.so.6 is a symlink. Either way, __errno_location is available. +// Link __errno_location from glibc's libc.so.6. On glibc >= 2.34 this is +// where dlopen lives too; libdl.so.2 is a stub kept for SONAME lookups. +// +// musl exports __errno_location as well, but under a different SONAME +// (libc.musl-.so.1 -- there is no libc.so.6 on Alpine), so the musl +// flavor of this file is errno_musl_amd64.go / errno_musl_arm64.go behind +// the goffi_musl build tag. // //go:cgo_import_dynamic goffi_errno_location __errno_location "libc.so.6" //go:cgo_import_dynamic _ _ "libc.so.6" diff --git a/internal/syscall/errno_musl_amd64.go b/internal/syscall/errno_musl_amd64.go new file mode 100644 index 0000000..216387a --- /dev/null +++ b/internal/syscall/errno_musl_amd64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && !goffi_universal && amd64 + +// musl flavor of errno_linux.go: same symbol, different SONAME. musl +// exports __errno_location from its single libc object (errno itself lives +// 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..a70d504 --- /dev/null +++ b/internal/syscall/errno_musl_arm64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && !goffi_universal && arm64 + +// musl flavor of errno_linux.go: same symbol, different SONAME. musl +// exports __errno_location from its single libc object (errno itself lives +// 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_static.go b/internal/syscall/errno_static.go new file mode 100644 index 0000000..c8c7438 --- /dev/null +++ b/internal/syscall/errno_static.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build ((linux && !android) || darwin || freebsd) && goffi_static + +// Static build: no libc, hence no errno location to import. + +package syscall + +// ErrnoFnAddr returns 0 in a static build, which the assembly trampolines +// already treat as "skip errno capture" (the same path Windows takes). No C +// function can be called in this mode anyway, so there is no errno to read. +func ErrnoFnAddr() uintptr { + return 0 +} diff --git a/internal/syscall/errno_stubs_amd64.s b/internal/syscall/errno_stubs_amd64.s index bf45d73..f46bd45 100644 --- a/internal/syscall/errno_stubs_amd64.s +++ b/internal/syscall/errno_stubs_amd64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || darwin || freebsd) && amd64 +//go:build ((linux && !android) || darwin || freebsd) && amd64 && !goffi_static #include "textflag.h" diff --git a/internal/syscall/errno_stubs_arm64.s b/internal/syscall/errno_stubs_arm64.s index 24f9ba8..e6f0b66 100644 --- a/internal/syscall/errno_stubs_arm64.s +++ b/internal/syscall/errno_stubs_arm64.s @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || (android && !cgo) || darwin || freebsd) && arm64 +//go:build ((((linux && !android) || darwin || freebsd) && !goffi_static) || (android && !cgo)) && arm64 #include "textflag.h" diff --git a/internal/syscall/errno_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/internal/syscall/errno_unix.go b/internal/syscall/errno_unix.go index f815301..9eed7c4 100644 --- a/internal/syscall/errno_unix.go +++ b/internal/syscall/errno_unix.go @@ -1,4 +1,4 @@ -//go:build ((linux && !android) || (android && !cgo) || darwin || freebsd) && (amd64 || arm64) +//go:build ((((linux && !android) || darwin || freebsd) && !goffi_static) || (android && !cgo)) && (amd64 || arm64) package syscall diff --git a/scripts/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-android-arm64.sh b/scripts/check-android-arm64.sh index df0e392..ff5498a 100755 --- a/scripts/check-android-arm64.sh +++ b/scripts/check-android-arm64.sh @@ -31,7 +31,7 @@ readelf="$toolchain/llvm-readelf" # lines, or when any audited source invariant changes. go_version=$(go env GOVERSION) case "$go_version" in - go1.25.12|go1.26.5) ;; + go1.25.12|go1.26.5|go1.26.6|go1.26.7) ;; *) echo "unsupported Go runtime source for Android fakecgo: $go_version" >&2 echo "audit the new runtime/cgo Android arm64 startup ABI before extending this gate" >&2 @@ -123,20 +123,26 @@ done echo "Checking NDK C ABI headers" "$cc" -std=c11 -Wall -Werror -fsyntax-only "$ROOT/testdata/android_abi_probe.c" +# Full `go vet` here runs the complete analyzer set (unlike `go test`'s vet +# subset). Disable unsafeptr: goffi reconstructs pointers from native call +# registers and stack slots by design (see ffi/callback_pointer.go's +# //go:nocheckptr contract). ci.yml runs vet through golangci-lint, which +# excludes govet on exactly those paths (.golangci.yml); -unsafeptr=false is the +# bare-vet equivalent of that exclusion, matching universal.yml. for cgo in 0 1; do echo "Building Android arm64 (CGO_ENABLED=$cgo)" if [[ "$cgo" == 1 ]]; then CC="$cc" GOOS=android GOARCH=arm64 CGO_ENABLED=1 \ go test -exec=true ./... 2>&1 CC="$cc" GOOS=android GOARCH=arm64 CGO_ENABLED=1 \ - go vet ./... + go vet -unsafeptr=false ./... CC="$cc" GOOS=android GOARCH=arm64 CGO_ENABLED=1 \ go test -c -o "$tmp/ffi-cgo.test" ./ffi else GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ go test -exec=true ./... 2>&1 GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ - go vet ./... + go vet -unsafeptr=false ./... GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ go test -c -o "$tmp/ffi-nocgo.test" ./ffi fi 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" diff --git a/scripts/check-static.sh b/scripts/check-static.sh new file mode 100755 index 0000000..50f6e27 --- /dev/null +++ b/scripts/check-static.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verify the goffi_static build mode. +# +# Without the tag, goffi's //go:cgo_import_dynamic directives force the Go +# linker to emit a PT_INTERP header and DT_NEEDED entries even under +# CGO_ENABLED=0, so every binary importing goffi is dynamically linked and will +# not start on Alpine or in a scratch container. -extldflags '-static' cannot +# undo that: no external linker is involved. With -tags goffi_static those +# directives are gone and the linker produces a static executable. +# +# This script checks that (a) the tagged build still compiles on every +# platform, (b) the linker output really has no interpreter and no shared +# library dependencies, and (c) the tag stays a no-op on the platforms that are +# always dynamic (Windows, Android). See unxed/f4#693. + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" + +export CGO_ENABLED=0 + +echo "==> Compiling with -tags goffi_static" +for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 \ + windows/amd64 windows/arm64 android/arm64; do + os="${target%%/*}" + arch="${target##*/}" + if GOOS="$os" GOARCH="$arch" go build -tags goffi_static ./...; then + echo " ok ${target}" + else + echo " FAIL ${target}" >&2 + exit 1 + fi +done + +# FreeBSD needs the same -gcflags="-std" workaround as the cross-compile job: +# internal/fakecgo is not imported in a static build, but `go build ./...` +# still compiles the package, and its //go:cgo_export_dynamic directives are +# rejected outside cgo-generated code. +for arch in amd64 arm64; do + if GOOS=freebsd GOARCH="$arch" go build -tags goffi_static \ + -gcflags="github.com/go-webgpu/goffi/internal/fakecgo=-std" ./...; then + echo " ok freebsd/${arch}" + else + echo " FAIL freebsd/${arch}" >&2 + exit 1 + fi +done + +echo "==> Verifying linker output for linux/amd64 and linux/arm64" +# The test builds a real consumer binary and inspects it with debug/elf, so no +# binutils are needed on the runner. +go test -run TestStaticBuildProducesStaticBinary -v ./ffi + +echo "==> Checking that the tag is a no-op where it must be" +# Windows and Android are always dynamically linked (kernel32 / Bionic), so a +# binary built with the tag must keep full FFI there. Available() is a +# compile-time constant, so a failed assertion is a build failure. +probe=$(mktemp -d) +trap 'rm -rf "$probe"' EXIT +cat >"$probe/main.go" <<'PROBE' +package main + +import "github.com/go-webgpu/goffi/ffi" + +func main() { + if !ffi.Available() { + panic("goffi_static must not disable FFI on this platform") + } +} +PROBE +cat >"$probe/go.mod" < $ROOT +PROBE +for target in windows/amd64 android/arm64; do + os="${target%%/*}" + arch="${target##*/}" + if (cd "$probe" && GOOS="$os" GOARCH="$arch" GOFLAGS=-mod=mod \ + go build -tags goffi_static -o /dev/null .); then + echo " ok ${target} keeps FFI" + else + echo " FAIL ${target} lost FFI" >&2 + exit 1 + fi +done + +echo "==> Static build mode OK" diff --git a/scripts/pre-release-check.sh b/scripts/pre-release-check.sh index 58d7a1f..3dfb4de 100644 --- a/scripts/pre-release-check.sh +++ b/scripts/pre-release-check.sh @@ -44,7 +44,7 @@ WARNINGS=0 # 1. Check Go version log_info "Checking Go version..." GO_VERSION=$(go version | awk '{print $3}') -REQUIRED_VERSION="go1.25" +REQUIRED_VERSION="go1.26" if [[ "$GO_VERSION" < "$REQUIRED_VERSION" ]]; then log_error "Go version $REQUIRED_VERSION+ required, found $GO_VERSION" ERRORS=$((ERRORS + 1))