From 69d8f964e390fbd6bf296a9f9384b0ffd3db6051 Mon Sep 17 00:00:00 2001 From: unxed Date: Thu, 17 Sep 2026 11:16:41 +0000 Subject: [PATCH 1/2] ci: stop setup-android from requesting the removed `tools` package Both Android arm64 jobs now fail in "Set up Android SDK", before any goffi code runs: Warning: Failed to find package 'tools' Error: The process '/usr/local/lib/android/sdk/cmdline-tools/16.0/bin/sdkmanager' failed with exit code 1 android-actions/setup-android@v3 defaults its `packages` input to `tools platform-tools` and runs `sdkmanager ` for each entry. Google's SDK repository index (repository2-3.xml) no longer contains a `tools` package, while `platform-tools` and `cmdline-tools` are still listed, so the `tools` call exits 1. The same breakage is tracked upstream in android-actions/setup-android#537. The last green run of these jobs on main was for c8f74c6 on 2026-09-10; the workflow has not changed since, and the failure is identical on a docs-only PR. Nothing in this workflow or in scripts/check-android-arm64.sh uses the `tools` package. Pass `packages: platform-tools`, which keeps everything the step installed before except the package that no longer exists. The action still accepts licenses, exports ANDROID_HOME and puts sdkmanager on PATH, which the following NDK step relies on. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 954202a..536cb76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,8 +186,15 @@ jobs: go-version: ${{ matrix.go }} cache: true + # setup-android installs `tools platform-tools` by default. Google's SDK + # repository no longer lists the obsolete `tools` package, so + # `sdkmanager tools` exits 1 and fails the step + # (android-actions/setup-android#537). Nothing here uses `tools`; request + # only platform-tools. The NDK is installed explicitly below. - name: Set up Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Install Android NDK r29 shell: bash From 1e5844ae8ee0f8fbcaab7dc49c7cdd4c4b4d2c66 Mon Sep 17 00:00:00 2001 From: Ivan Sorokin Date: Fri, 25 Sep 2026 13:53:30 +0000 Subject: [PATCH 2/2] feat: add goffi_musl build tag for Alpine and other musl systems A default goffi binary cannot start on Alpine: PT_INTERP names the glibc loader, which musl systems do not have, and the cgo_import_dynamic directives name libdl.so.2, libc.so.6 and libpthread.so.0, none of which musl ships. Its whole POSIX surface lives in one arch-named object, libc.musl-.so.1. Both facts are baked into the ELF at link time, so the libc flavor is a build-time choice. The goffi_musl tag selects musl flavors of the three directive groups (internal/dl, internal/syscall, internal/fakecgo) and bakes the musl loader path into PT_INTERP via //go:cgo_dynamic_linker. That directive is restricted to cgo-generated code, so musl builds pass -gcflags=github.com/go-webgpu/goffi/internal/dl=-std. Forgetting the flag is a compile error naming the directive, not a binary that dies at startup with a confusing ENOENT. The glibc libdl imports move out of dl_linux.go into dl_linux_glibc.go so the RTLD_* constants stay shared by both flavors. One symbol is dropped from the musl set: pthread_get_stacksize_np is a Darwin-only API that glibc's lazy PLT tolerates but musl's immediate binding would reject at load time. Its trampoline is only reachable from the Darwin thread-entry path, so the linker drops it on Linux. The fakecgo musl files are generated by gen.go from the same symbol tables as the glibc ones. goffi_static wins over goffi_musl; the tag is inert off Linux. Verification: - TestMuslDirectiveParity pins glibc/musl symbol-set parity (including the one intentional exclusion), per-arch SONAMEs and the interpreter. - TestMuslLinkArtifacts builds linux/{amd64,arm64} probes and checks PT_INTERP and DT_NEEDED with debug/elf. - cmd/musl-probe runs against a real musl libc: dlopen/dlsym, float and integer calls, errno capture, qsort with a Go callback, and a 64-goroutine hammer that makes the runtime create OS threads through fakecgo's pthread imports. scripts/check-musl.sh runs it inside Alpine and is wired into CI. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 30 +++- CHANGELOG.md | 3 + README.md | 6 +- cmd/musl-probe/main.go | 214 +++++++++++++++++++++++++ docs/MUSL.md | 98 +++++++++++ ffi/musl_directives_test.go | 140 ++++++++++++++++ ffi/musl_link_test.go | 135 ++++++++++++++++ internal/dl/dl_linux.go | 23 +-- internal/dl/dl_linux_glibc.go | 28 ++++ internal/dl/dl_musl_amd64.go | 44 +++++ internal/dl/dl_musl_arm64.go | 44 +++++ internal/fakecgo/gen.go | 42 ++++- internal/fakecgo/symbols_linux.go | 2 +- internal/fakecgo/symbols_musl_amd64.go | 30 ++++ internal/fakecgo/symbols_musl_arm64.go | 30 ++++ internal/syscall/errno_linux.go | 2 +- internal/syscall/errno_musl_amd64.go | 14 ++ internal/syscall/errno_musl_arm64.go | 14 ++ scripts/check-musl.sh | 78 +++++++++ 19 files changed, 951 insertions(+), 26 deletions(-) create mode 100644 cmd/musl-probe/main.go create mode 100644 docs/MUSL.md create mode 100644 ffi/musl_directives_test.go create mode 100644 ffi/musl_link_test.go create mode 100644 internal/dl/dl_linux_glibc.go create mode 100644 internal/dl/dl_musl_amd64.go create mode 100644 internal/dl/dl_musl_arm64.go create mode 100644 internal/fakecgo/symbols_musl_amd64.go create mode 100644 internal/fakecgo/symbols_musl_arm64.go create mode 100644 internal/syscall/errno_musl_amd64.go create mode 100644 internal/syscall/errno_musl_arm64.go create mode 100755 scripts/check-musl.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 536cb76..e7f800b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,10 +460,37 @@ jobs: if: matrix.arch == 'amd64' run: go test -tags goffi_static ./ffi -count=1 -run 'StaticBuild' + # musl builds: -tags goffi_musl must replace the glibc SONAMEs and the ELF + # interpreter with their musl equivalents, otherwise the binary cannot start + # on Alpine. Link-time checks cover amd64 and arm64; the runtime probe runs + # inside a real Alpine userland. See docs/MUSL.md. + musl-build: + name: musl Build (goffi_musl, Go ${{ matrix.go }}) + runs-on: ubuntu-latest + needs: [lint, formatting] + strategy: + fail-fast: false + matrix: + go: ['1.25', '1.26'] + env: + CGO_ENABLED: "0" + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + cache: true + + - name: Check musl build mode + run: scripts/check-musl.sh + # Final status - All checks passed ci-success: name: CI Success - needs: [lint, formatting, cross-compile, android-cross, test, benchmarks, quality-gate, elf-linking] + needs: [lint, formatting, cross-compile, android-cross, test, benchmarks, quality-gate, elf-linking, musl-build] runs-on: ubuntu-latest if: success() steps: @@ -479,6 +506,7 @@ jobs: echo " - Windows AMD64 (windows-latest)" echo " - macOS ARM64 (macos-latest)" echo "✅ ELF Linking: PASSED (default dynamic + goffi_static)" + echo "✅ musl: PASSED (goffi_musl, Alpine runtime probe)" echo "✅ Benchmarks: PASSED" echo "✅ Quality Gate: PASSED" echo "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7618146..1de0360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`-tags goffi_musl`** — CGO-free binaries for Alpine and other musl systems (linux/amd64, linux/arm64): the dynamic imports name `libc.musl-.so.1` and `PT_INTERP` is `/lib/ld-musl-.so.1`. FFI stays fully available. Needs `-gcflags=github.com/go-webgpu/goffi/internal/dl=-std`. See `docs/MUSL.md`. + ## [0.6.4] - 2026-09-10 ### Added diff --git a/README.md b/README.md index 7f07b8a..66e067d 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ CGO_ENABLED=1 go build ./... | Mode | How | ELF shape | `LoadLibrary` | Typical use | |------|-----|-----------|---------------|-------------| | **Dynamic FFI** (default) | `CGO_ENABLED=0 go build` | dynamic + `libdl`/`libc` | yes | desktop GPU/GUI | -| **Musl dynamic** | build on Alpine / `CC=musl-gcc` | dynamic vs musl | yes | Alpine containers with GPU/GUI | +| **Musl dynamic** | `CGO_ENABLED=0 go build -tags goffi_musl -gcflags=github.com/go-webgpu/goffi/internal/dl=-std` | dynamic vs musl (`/lib/ld-musl-.so.1`, `libc.musl-.so.1`) | yes | Alpine containers with GPU/GUI | | **Static no-FFI** | `CGO_ENABLED=0 go build -tags goffi_static` | fully static (no `PT_INTERP`, no `NEEDED`) | no (`errors.Is(err, ffi.ErrStaticBuild)`) | `FROM scratch`, air-gapped CLI | ```bash @@ -92,6 +92,8 @@ scripts/check-elf-linking.sh --static ./app Under `-tags goffi_static`, errno capture is unavailable (always returns 0): `ErrnoFnAddr()` is a no-op so the assembly trampoline skips `__errno_location` / `__error`, which need dynamic libc. +A default binary does not start on Alpine: its `PT_INTERP` and `DT_NEEDED` name the glibc loader and SONAMEs. `-tags goffi_musl` (linux/amd64 and linux/arm64) names the musl ones instead; FFI stays fully available. See [docs/MUSL.md](docs/MUSL.md). + `FROM scratch` + Vulkan/Wayland/libX11 via host `dlopen` is not possible without either `ld.so` or a userspace ELF loader (see [docs/ADR-001-userspace-elf-loader.md](docs/ADR-001-userspace-elf-loader.md)). Windows is unaffected (`LoadLibraryW` via ntdll). ### Example: Calling strlen @@ -403,7 +405,7 @@ if err != nil { ## Known Limitations **Linux: default builds are dynamically linked** ([#74](https://github.com/go-webgpu/goffi/issues/74)) -- Importing goffi records `libdl`/`libc` via `cgo_import_dynamic` even with `CGO_ENABLED=0`. Use `-tags goffi_static` for a fully static ELF (no runtime `.so` loading), or build against musl for Alpine. See [Linking modes](#linking-modes-linux). +- Importing goffi records `libdl`/`libc` via `cgo_import_dynamic` even with `CGO_ENABLED=0`. Use `-tags goffi_static` for a fully static ELF (no runtime `.so` loading), or `-tags goffi_musl` for Alpine. See [Linking modes](#linking-modes-linux). **Windows: C++ exceptions may crash the program** ([#12516](https://github.com/golang/go/issues/12516)) - Go runtime limitation, not goffi-specific. Go 1.22+ added partial SEH support ([#58542](https://github.com/golang/go/issues/58542)), but edge cases remain. diff --git a/cmd/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/docs/MUSL.md b/docs/MUSL.md new file mode 100644 index 0000000..6bd1ace --- /dev/null +++ b/docs/MUSL.md @@ -0,0 +1,98 @@ +# musl / Alpine Builds (`-tags goffi_musl`) + +## The problem + +A default goffi binary does not start on Alpine, and it fails twice before +`main` ever runs: + +1. **The interpreter is wrong.** The Go linker writes + `PT_INTERP = /lib64/ld-linux-x86-64.so.2` — the glibc loader path. Alpine + has no such file, so `execve` fails with a `no such file or directory` + that misleadingly appears to be about the binary itself. + +2. **The SONAMEs are wrong.** goffi's `//go:cgo_import_dynamic` directives + name `libdl.so.2`, `libc.so.6` and `libpthread.so.0`. musl ships none of + them: its entire POSIX surface — dlopen, pthreads, libm, errno — lives in + one arch-named object, `libc.musl-x86_64.so.1` (or `-aarch64`). The musl + dynamic linker refuses to start a process whose `DT_NEEDED` it cannot + satisfy. + +Both are baked into the ELF at link time, so no runtime cleverness can fix a +binary built for the wrong libc. The flavor is a build-time choice. + +## Usage + +```bash +CGO_ENABLED=0 go build -tags goffi_musl \ + -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... +``` + +The same command works for `GOARCH=amd64` and `GOARCH=arm64`; the +architecture-specific loader path and SONAME are selected by build +constraints inside goffi. + +The `-gcflags` part deserves a word. The musl interpreter path is baked in +with a `//go:cgo_dynamic_linker` directive, which the compiler restricts to +cgo-generated code; the flag relaxes that check for the one package that +carries it (`internal/dl`). Forgetting the flag is a loud compile error that +names the directive — deliberately preferable to the silent alternative, a +binary carrying the glibc interpreter that dies at startup on Alpine with a +confusing error. (This is the same mechanism some projects already use for +goffi's FreeBSD `fakecgo` shim.) + +**Everything works in this mode.** Unlike `goffi_static`, which trades FFI +away for a static binary, `goffi_musl` is full-featured: `LoadLibrary`, +`GetSymbol`, `CallFunction`, callbacks, errno capture — all of it, resolved +from musl's libc. + +## What changes under the hood + +| Package | glibc build | `goffi_musl` build | +|---|---|---| +| `internal/dl` | `dlopen` … from `libdl.so.2` | from `libc.musl-.so.1` | +| `internal/syscall` | `__errno_location` from `libc.so.6` | from `libc.musl-.so.1` | +| `internal/fakecgo` | `malloc`, `pthread_*` from `libc.so.6` / `libpthread.so.0` | from `libc.musl-.so.1` | +| ELF interpreter | `/lib64/ld-linux-x86-64.so.2` (linker default) | `/lib/ld-musl-.so.1` (directive) | + +One symbol is intentionally absent from the musl set: +`pthread_get_stacksize_np` is a Darwin-only API that neither glibc nor musl +exports. The glibc build gets away with importing it because glibc binds +functions lazily and nobody calls the stub on Linux; musl binds every import +immediately at load time and would abort startup. Its trampoline is only +reachable from the Darwin thread-entry path, so on Linux the linker +dead-code-eliminates it. `TestMuslDirectiveParity` pins this exact +asymmetry, and keeps the glibc and musl symbol sets from drifting apart in +general. + +The `internal/fakecgo` musl files are generated: `gen.go` produces them from +the same symbol tables as the glibc ones, filtered as described above. + +## Tag interplay + +- `goffi_musl` is meaningful only on Linux; elsewhere it selects nothing. +- `goffi_static` wins over `goffi_musl`: with both tags set, every dynamic + import is compiled out and FFI is disabled, exactly as in a plain + `goffi_static` build. A static binary is already libc-agnostic, so there + is no musl flavor of it to want. + +## Verification + +`scripts/check-musl.sh` compiles both architectures, asserts the interpreter +and `DT_NEEDED` with `debug/elf` (`TestMuslLinkArtifacts`), and then executes +`cmd/musl-probe` inside a real Alpine userland — via `docker run alpine` on +CI, via a checksummed Alpine minirootfs and `chroot` when running as root +without docker, or via the musl loader invoked directly as a last resort. +The probe exercises every directive group the tag replaces: dlopen/dlsym, +integer and floating-point calls, errno capture through +`__errno_location`, C-to-Go callbacks (`qsort` with a Go comparator), and a +64-goroutine hammer that forces the Go runtime to create OS threads through +fakecgo's pthread imports. + +## Choosing a Linux flavor + +| You are shipping to | Build | +|---|---| +| glibc distros (Debian, Fedora, …) | default (no tags) | +| Alpine, postmarketOS, other musl distros | `-tags goffi_musl` + the `-gcflags` line above | +| `scratch` / distroless containers, no libc at all | `-tags goffi_static` (FFI off — there are no `.so` files to load there anyway) | + diff --git a/ffi/musl_directives_test.go b/ffi/musl_directives_test.go new file mode 100644 index 0000000..8d64def --- /dev/null +++ b/ffi/musl_directives_test.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +package ffi_test + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "testing" +) + +// The musl directive files mirror hand-picked glibc originals, and the two +// must not drift: a symbol added to the glibc side and forgotten on the musl +// side would fail only at load time on Alpine, far from the change that +// caused it. This test pins the invariant at go-test time. +// +// One asymmetry is intentional and encoded below: musl's dynamic linker +// binds every import immediately at load and aborts on an unresolved one, +// so pthread_get_stacksize_np -- a Darwin-only API that glibc's lazy PLT +// silently tolerates -- must be absent from the musl set. + +var importDirective = regexp.MustCompile( + `(?m)^//go:cgo_import_dynamic\s+(\S+)\s+(\S+)\s+"([^"]+)"`) + +var interpDirective = regexp.MustCompile( + `(?m)^//go:cgo_dynamic_linker\s+"([^"]+)"`) + +// symbolSet returns the imported C symbol names in a file, skipping the +// "_ _" force-dependency entries, plus the set of SONAMEs referenced. +func symbolSet(t *testing.T, path string) (map[string]bool, map[string]bool) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + syms := map[string]bool{} + sos := map[string]bool{} + for _, m := range importDirective.FindAllStringSubmatch(string(data), -1) { + sos[m[3]] = true + if m[2] == "_" { + continue + } + syms[m[2]] = true + } + return syms, sos +} + +func TestMuslDirectiveParity(t *testing.T) { + root, err := filepath.Abs("..") + if err != nil { + t.Fatalf("locate module root: %v", err) + } + join := func(elem ...string) string { + return filepath.Join(append([]string{root}, elem...)...) + } + + muslArches := map[string]string{ + "amd64": "x86_64", + "arm64": "aarch64", + } + + // Package -> glibc source of truth and the symbols musl must not carry. + cases := []struct { + name string + glibc string + muslFmt string // per-arch musl file, %s = goarch + exclude map[string]bool + }{ + { + name: "fakecgo", + glibc: join("internal", "fakecgo", "symbols_linux.go"), + muslFmt: join("internal", "fakecgo", "symbols_musl_%s.go"), + exclude: map[string]bool{"pthread_get_stacksize_np": true}, + }, + { + name: "dl", + glibc: join("internal", "dl", "dl_linux_glibc.go"), + muslFmt: join("internal", "dl", "dl_musl_%s.go"), + }, + { + name: "syscall", + glibc: join("internal", "syscall", "errno_linux.go"), + muslFmt: join("internal", "syscall", "errno_musl_%s.go"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + want, _ := symbolSet(t, tc.glibc) + for s := range tc.exclude { + if !want[s] { + t.Errorf("exclusion list mentions %s, but the glibc file does not import it; update this test", s) + } + delete(want, s) + } + + for goarch, musl := range muslArches { + path := fmt.Sprintf(tc.muslFmt, goarch) + got, sos := symbolSet(t, path) + + for s := range want { + if !got[s] { + t.Errorf("%s: glibc imports %s but the musl file does not", path, s) + } + } + for s := range got { + if !want[s] { + t.Errorf("%s: imports %s, which the glibc file does not (or which musl must not import)", path, s) + } + } + + wantSO := "libc.musl-" + musl + ".so.1" + for so := range sos { + if so != wantSO { + t.Errorf("%s: imports from %q, want only %q", path, so, wantSO) + } + } + } + }) + } + + // The interpreter directive lives in internal/dl and must name the + // matching musl loader on each architecture. + for goarch, musl := range muslArches { + path := join("internal", "dl", fmt.Sprintf("dl_musl_%s.go", goarch)) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + m := interpDirective.FindStringSubmatch(string(data)) + want := "/lib/ld-musl-" + musl + ".so.1" + if m == nil { + t.Errorf("%s: missing //go:cgo_dynamic_linker directive", path) + } else if m[1] != want { + t.Errorf("%s: interpreter %q, want %q", path, m[1], want) + } + } +} diff --git a/ffi/musl_link_test.go b/ffi/musl_link_test.go new file mode 100644 index 0000000..84137e7 --- /dev/null +++ b/ffi/musl_link_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && (amd64 || arm64) + +package ffi_test + +import ( + "bytes" + "debug/elf" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// muslNames maps GOARCH to the musl architecture that appears in both the +// loader path and the libc SONAME. +var muslNames = map[string]string{ + "amd64": "x86_64", + "arm64": "aarch64", +} + +// TestMuslLinkArtifacts is the link-time half of the goffi_musl verification +// (the runtime half is cmd/musl-probe, driven by scripts/check-musl.sh). +// +// A goffi binary is unusable on Alpine for two independent reasons, and each +// gets its own assertion here: the glibc SONAMEs in DT_NEEDED (musl ships no +// libdl.so.2, libc.so.6 or libpthread.so.0, so the dynamic linker refuses to +// start the process), and PT_INTERP, which the Go linker defaults to the +// glibc loader path (so on Alpine execve fails before a single instruction +// runs). The goffi_musl tag must fix both, on both architectures. +func TestMuslLinkArtifacts(t *testing.T) { + if testing.Short() { + t.Skip("skipping: builds two binaries") + } + + root, err := filepath.Abs("..") + if err != nil { + t.Fatalf("locate module root: %v", err) + } + + for goarch, musl := range muslNames { + t.Run(goarch, func(t *testing.T) { + bin := filepath.Join(t.TempDir(), "musl-probe-"+goarch) + buildMuslProbe(t, root, goarch, bin) + + f, err := elf.Open(bin) + if err != nil { + t.Fatalf("open ELF: %v", err) + } + defer f.Close() + + wantInterp := "/lib/ld-musl-" + musl + ".so.1" + interp := readInterp(t, f) + if interp != wantInterp { + t.Errorf("PT_INTERP = %q, want %q", interp, wantInterp) + } + + wantLibc := "libc.musl-" + musl + ".so.1" + needed, err := f.DynString(elf.DT_NEEDED) + if err != nil { + t.Fatalf("read DT_NEEDED: %v", err) + } + if len(needed) != 1 || needed[0] != wantLibc { + t.Errorf("DT_NEEDED = %v, want exactly [%s]", needed, wantLibc) + } + for _, glibc := range []string{"libdl.so.2", "libc.so.6", "libpthread.so.0"} { + for _, n := range needed { + if n == glibc { + t.Errorf("glibc SONAME %s leaked into the musl build", glibc) + } + } + } + }) + } +} + +func readInterp(t *testing.T, f *elf.File) string { + t.Helper() + sec := f.Section(".interp") + if sec == nil { + t.Fatal("binary has no .interp section") + } + data, err := sec.Data() + if err != nil { + t.Fatalf("read .interp: %v", err) + } + return string(bytes.TrimRight(data, "\x00")) +} + +func buildMuslProbe(t *testing.T, root, goarch, out string) { + t.Helper() + + goTool := goToolPath() + + cmd := exec.Command(goTool, "build", + "-tags", "goffi_musl", + // //go:cgo_dynamic_linker is restricted to cgo-generated code; the + // musl interpreter directive lives in internal/dl, so that one + // package is compiled with the check relaxed. + "-gcflags=github.com/go-webgpu/goffi/internal/dl=-std", + "-o", out, "./cmd/musl-probe") + cmd.Dir = root + cmd.Env = append(os.Environ(), + "CGO_ENABLED=0", + "GOOS=linux", + "GOARCH="+goarch, + "GOFLAGS=-mod=mod", + ) + if outBytes, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build linux/%s with -tags goffi_musl: %v\n%s", goarch, err, outBytes) + } +} + +// goToolPath returns the go command from the active GOROOT (as reported by +// "go env GOROOT"), falling back to whatever "go" resolves to on PATH. +// runtime.GOROOT is deprecated since Go 1.24, so the value is queried from +// the tool at run time instead. +func goToolPath() string { + out, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + return "go" + } + root := strings.TrimSpace(string(out)) + if root == "" { + return "go" + } + tool := filepath.Join(root, "bin", "go") + if _, statErr := os.Stat(tool); statErr != nil { + return "go" + } + return tool +} diff --git a/internal/dl/dl_linux.go b/internal/dl/dl_linux.go index b507c45..c877927 100644 --- a/internal/dl/dl_linux.go +++ b/internal/dl/dl_linux.go @@ -9,26 +9,9 @@ package dl -// Link to libdl.so.2 functions using cgo_import_dynamic. -// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) -// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). -// -// Note on glibc >= 2.34: libdl.so.2 is a stub (an empty .so with a versioned -// symlink to libc.so.6). dlopen/dlsym/dlerror/dlclose all live in libc.so.6 -// itself. We still ask the dynamic linker for "libdl.so.2" because -// (a) the stub exists on every glibc release shipped with that version, so -// SONAME-based lookups keep working, and -// (b) older glibc (< 2.34) and musl still ship the real libdl.so.2. -// Either way, ld.so resolves the symbols via the normal scope rules and the -// caller never has to care which .so they ended up in. - -//go:cgo_import_dynamic goffi_dlopen dlopen "libdl.so.2" -//go:cgo_import_dynamic goffi_dlsym dlsym "libdl.so.2" -//go:cgo_import_dynamic goffi_dlerror dlerror "libdl.so.2" -//go:cgo_import_dynamic goffi_dlclose dlclose "libdl.so.2" - -// Force dependency on libdl.so.2 -//go:cgo_import_dynamic _ _ "libdl.so.2" +// The libdl imports live in dl_linux_glibc.go (default) or +// dl_musl_.go (-tags goffi_musl), so each libc flavor can name its own +// SONAMEs while these constants stay shared. // RTLD constants from for dynamic library loading on Linux. const ( diff --git a/internal/dl/dl_linux_glibc.go b/internal/dl/dl_linux_glibc.go new file mode 100644 index 0000000..fd93ff6 --- /dev/null +++ b/internal/dl/dl_linux_glibc.go @@ -0,0 +1,28 @@ +//go:build linux && !android && !goffi_static && !goffi_musl + +// Dynamic symbol imports for Linux/glibc. The musl flavor lives in +// dl_musl_amd64.go / dl_musl_arm64.go behind the goffi_musl build tag. + +package dl + +// Link to libdl.so.2 functions using cgo_import_dynamic. +// This works under both CGO_ENABLED=0 (where fakecgo provides the cgo runtime) +// and CGO_ENABLED=1 (where the standard runtime/cgo is linked, see cgo.go). +// +// Note on glibc >= 2.34: libdl.so.2 is a stub (an empty .so with a versioned +// symlink to libc.so.6). dlopen/dlsym/dlerror/dlclose all live in libc.so.6 +// itself. We still ask the dynamic linker for "libdl.so.2" because +// (a) the stub exists on every glibc release shipped with that version, so +// SONAME-based lookups keep working, and +// (b) older glibc (< 2.34) still ships the real libdl.so.2. +// musl has no libdl.so.2 at all; see dl_musl_.go. +// Either way, ld.so resolves the symbols via the normal scope rules and the +// caller never has to care which .so they ended up in. + +//go:cgo_import_dynamic goffi_dlopen dlopen "libdl.so.2" +//go:cgo_import_dynamic goffi_dlsym dlsym "libdl.so.2" +//go:cgo_import_dynamic goffi_dlerror dlerror "libdl.so.2" +//go:cgo_import_dynamic goffi_dlclose dlclose "libdl.so.2" + +// Force dependency on libdl.so.2 +//go:cgo_import_dynamic _ _ "libdl.so.2" diff --git a/internal/dl/dl_musl_amd64.go b/internal/dl/dl_musl_amd64.go new file mode 100644 index 0000000..2bef0db --- /dev/null +++ b/internal/dl/dl_musl_amd64.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && amd64 + +// Dynamic symbol imports for Linux/musl (Alpine and friends). +// +// musl ships the entire POSIX surface -- dlopen, pthreads, libm, errno -- +// in one object whose name embeds the architecture: libc.musl-x86_64.so.1. +// There is no libdl.so.2 and no libc.so.6 on a musl system, so the glibc +// directives in dl_linux_glibc.go make the process fail to start ("Error +// loading shared library libdl.so.2: No such file or directory"). This file +// replaces them under the goffi_musl build tag. +// +// The interpreter is part of the same story: the Go linker defaults +// PT_INTERP to the glibc loader path, which does not exist on Alpine, so +// the binary would die in execve before a single instruction runs. The +// //go:cgo_dynamic_linker directive below bakes the musl loader path into +// every binary built with this tag. The compiler restricts that directive +// to cgo-generated code, so musl builds must relax the check for this one +// package: +// +// CGO_ENABLED=0 go build -tags goffi_musl \ +// -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... +// +// Forgetting the flag is a loud compile error naming this directive, which +// beats the silent alternative: a binary that carries the wrong interpreter +// and fails at startup with a misleading "no such file" about itself. +// +// Tag interplay: goffi_static wins over goffi_musl -- with both set, all +// dynamic imports are compiled out and FFI is disabled, same as plain +// goffi_static. + +package dl + +//go:cgo_import_dynamic goffi_dlopen dlopen "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_dlsym dlsym "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_dlerror dlerror "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic goffi_dlclose dlclose "libc.musl-x86_64.so.1" + +// Force dependency on musl libc +//go:cgo_import_dynamic _ _ "libc.musl-x86_64.so.1" + +//go:cgo_dynamic_linker "/lib/ld-musl-x86_64.so.1" diff --git a/internal/dl/dl_musl_arm64.go b/internal/dl/dl_musl_arm64.go new file mode 100644 index 0000000..bdb1643 --- /dev/null +++ b/internal/dl/dl_musl_arm64.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && arm64 + +// Dynamic symbol imports for Linux/musl (Alpine and friends). +// +// musl ships the entire POSIX surface -- dlopen, pthreads, libm, errno -- +// in one object whose name embeds the architecture: libc.musl-aarch64.so.1. +// There is no libdl.so.2 and no libc.so.6 on a musl system, so the glibc +// directives in dl_linux_glibc.go make the process fail to start ("Error +// loading shared library libdl.so.2: No such file or directory"). This file +// replaces them under the goffi_musl build tag. +// +// The interpreter is part of the same story: the Go linker defaults +// PT_INTERP to the glibc loader path, which does not exist on Alpine, so +// the binary would die in execve before a single instruction runs. The +// //go:cgo_dynamic_linker directive below bakes the musl loader path into +// every binary built with this tag. The compiler restricts that directive +// to cgo-generated code, so musl builds must relax the check for this one +// package: +// +// CGO_ENABLED=0 go build -tags goffi_musl \ +// -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./... +// +// Forgetting the flag is a loud compile error naming this directive, which +// beats the silent alternative: a binary that carries the wrong interpreter +// and fails at startup with a misleading "no such file" about itself. +// +// Tag interplay: goffi_static wins over goffi_musl -- with both set, all +// dynamic imports are compiled out and FFI is disabled, same as plain +// goffi_static. + +package dl + +//go:cgo_import_dynamic goffi_dlopen dlopen "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_dlsym dlsym "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_dlerror dlerror "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_dlclose dlclose "libc.musl-aarch64.so.1" + +// Force dependency on musl libc +//go:cgo_import_dynamic _ _ "libc.musl-aarch64.so.1" + +//go:cgo_dynamic_linker "/lib/ld-musl-aarch64.so.1" diff --git a/internal/fakecgo/gen.go b/internal/fakecgo/gen.go index c0e655a..fb82fd0 100644 --- a/internal/fakecgo/gen.go +++ b/internal/fakecgo/gen.go @@ -263,7 +263,7 @@ func run() error { case "linux": // The go command also satisfies the linux build tag on Android, // including for _linux.go files. Keep glibc imports out of Bionic. - goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo && !goffi_static", "//go:build !cgo && !android && !goffi_static", 1))) + goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo && !goffi_static", "//go:build !cgo && !android && !goffi_static && !goffi_musl", 1))) case "android": // Android uses a distinct build selector and symbol set. Keep the // generated generic Linux imports out of the Android ELF. @@ -286,6 +286,46 @@ func run() error { } } + // musl (Alpine and friends): the whole POSIX surface lives in one + // arch-named libc.so, so both symbol groups point at the same object, + // and the object name embeds the musl architecture, so the file is + // generated per GOARCH. One symbol is dropped: musl's dynamic linker + // binds every import immediately at load time and aborts startup on an + // unresolved one, unlike glibc's lazy PLT which forgives stubs nobody + // calls -- and pthread_get_stacksize_np is a Darwin-only API that + // neither glibc nor musl exports. Its trampoline is only reachable from + // the Darwin threadentry, so on Linux the linker dead-code-eliminates + // it and the missing import is never referenced. + muslPthread := make([]Symbol, 0, len(pthreadSymbols)) + for _, s := range pthreadSymbols { + if s.Name == "pthread_get_stacksize_np" { + continue + } + muslPthread = append(muslPthread, s) + } + for _, mc := range []struct{ arch, so string }{ + {"amd64", "libc.musl-x86_64.so.1"}, + {"arm64", "libc.musl-aarch64.so.1"}, + } { + tag := "//go:build !cgo && !android && !goffi_static && goffi_musl && " + mc.arch + mt := template.Must(template.New("symbols_musl.go").Parse( + strings.Replace(templateSymbolsGoos, "//go:build !cgo && !goffi_static", tag, 1))) + mb := &bytes.Buffer{} + if merr := mt.Execute(mb, []LocatedSymbols{ + {SharedObject: mc.so, Symbols: libcSymbols}, + {SharedObject: mc.so, Symbols: muslPthread}, + }); merr != nil { + return merr + } + msrc, merr := format.Source(mb.Bytes()) + if merr != nil { + return merr + } + if merr := os.WriteFile(fmt.Sprintf("symbols_musl_%s.go", mc.arch), msrc, 0o644); merr != nil { + return merr + } + } + // The Android wrappers and assembly stubs are generated from the same // restricted symbol set as the imports above. androidSymbols := append(append([]Symbol{}, androidLibcSymbols...), androidPthreadSymbols...) diff --git a/internal/fakecgo/symbols_linux.go b/internal/fakecgo/symbols_linux.go index 9c8c3c3..c9beed0 100644 --- a/internal/fakecgo/symbols_linux.go +++ b/internal/fakecgo/symbols_linux.go @@ -4,7 +4,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && !android && !goffi_static +//go:build !cgo && !android && !goffi_static && !goffi_musl package fakecgo diff --git a/internal/fakecgo/symbols_musl_amd64.go b/internal/fakecgo/symbols_musl_amd64.go new file mode 100644 index 0000000..8a128c3 --- /dev/null +++ b/internal/fakecgo/symbols_musl_amd64.go @@ -0,0 +1,30 @@ +// Code generated by 'go generate' with gen.go. DO NOT EDIT. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && !android && !goffi_static && goffi_musl && 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..168c2dc --- /dev/null +++ b/internal/fakecgo/symbols_musl_arm64.go @@ -0,0 +1,30 @@ +// Code generated by 'go generate' with gen.go. DO NOT EDIT. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && !android && !goffi_static && goffi_musl && arm64 + +package fakecgo + +//go:cgo_import_dynamic goffi_malloc malloc "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_free free "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_setenv setenv "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_unsetenv unsetenv "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_sigfillset sigfillset "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_nanosleep nanosleep "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_abort abort "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_sigaltstack sigaltstack "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_init pthread_attr_init "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_create pthread_create "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_detach pthread_detach "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_sigmask pthread_sigmask "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_self pthread_self "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_getstacksize pthread_attr_getstacksize "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_setstacksize pthread_attr_setstacksize "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_attr_destroy pthread_attr_destroy "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_mutex_lock pthread_mutex_lock "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_mutex_unlock pthread_mutex_unlock "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_cond_broadcast pthread_cond_broadcast "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic goffi_pthread_setspecific pthread_setspecific "libc.musl-aarch64.so.1" diff --git a/internal/syscall/errno_linux.go b/internal/syscall/errno_linux.go index e2e38e9..a99b589 100644 --- a/internal/syscall/errno_linux.go +++ b/internal/syscall/errno_linux.go @@ -1,4 +1,4 @@ -//go:build linux && !android && (amd64 || arm64) && !goffi_static +//go:build linux && !android && (amd64 || arm64) && !goffi_static && !goffi_musl package syscall diff --git a/internal/syscall/errno_musl_amd64.go b/internal/syscall/errno_musl_amd64.go new file mode 100644 index 0000000..6df94dd --- /dev/null +++ b/internal/syscall/errno_musl_amd64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && amd64 + +// musl flavor of errno_linux.go: same symbol, different SONAME. musl +// exports __errno_location from its single libc object (errno itself lives +// in the thread control block, but the accessor is a plain exported +// function), so only the library name changes. + +package syscall + +//go:cgo_import_dynamic goffi_errno_location __errno_location "libc.musl-x86_64.so.1" +//go:cgo_import_dynamic _ _ "libc.musl-x86_64.so.1" diff --git a/internal/syscall/errno_musl_arm64.go b/internal/syscall/errno_musl_arm64.go new file mode 100644 index 0000000..99ca728 --- /dev/null +++ b/internal/syscall/errno_musl_arm64.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors + +//go:build linux && !android && !goffi_static && goffi_musl && arm64 + +// musl flavor of errno_linux.go: same symbol, different SONAME. musl +// exports __errno_location from its single libc object (errno itself lives +// in the thread control block, but the accessor is a plain exported +// function), so only the library name changes. + +package syscall + +//go:cgo_import_dynamic goffi_errno_location __errno_location "libc.musl-aarch64.so.1" +//go:cgo_import_dynamic _ _ "libc.musl-aarch64.so.1" diff --git a/scripts/check-musl.sh b/scripts/check-musl.sh new file mode 100755 index 0000000..81059c1 --- /dev/null +++ b/scripts/check-musl.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verify the goffi_musl build mode against a real musl userland. +# +# A default goffi binary cannot start on Alpine for two independent reasons: +# PT_INTERP names the glibc loader (execve fails with a misleading ENOENT +# about the binary itself), and DT_NEEDED names glibc SONAMEs that musl does +# not ship. The goffi_musl tag fixes both. This script checks the artifacts +# statically, then executes the probe (cmd/musl-probe) inside an Alpine +# userland, picking the strongest execution mechanism available: +# +# 1. docker run alpine (CI runners) - kernel resolves PT_INTERP +# 2. chroot into a minirootfs (root) - kernel resolves PT_INTERP +# 3. ld-musl invoked directly (fallback) - bypasses PT_INTERP, still +# runs every musl code path +# +# The probe covers each directive group the tag replaces: dlopen/dlsym, +# integer and floating-point calls, errno capture, C-to-Go callbacks, and a +# goroutine hammer that forces the runtime to create OS threads through +# fakecgo's pthread imports. See docs/MUSL.md. + +ALPINE_IMAGE=alpine:3.24 +ROOTFS_VERSION=3.24.1 +ROOTFS_URL="https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/x86_64/alpine-minirootfs-${ROOTFS_VERSION}-x86_64.tar.gz" +ROOTFS_SHA256=41f73e3cf5fa919b8aa5ca6b30dc48f0da2720776d7423e2a7748211456fe081 + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" + +export CGO_ENABLED=0 +MUSL_FLAGS=(-tags goffi_musl -gcflags=github.com/go-webgpu/goffi/internal/dl=-std) + +echo "==> Compiling with -tags goffi_musl" +for arch in amd64 arm64; do + if GOOS=linux GOARCH="$arch" go build "${MUSL_FLAGS[@]}" ./...; then + echo " ok linux/${arch}" + else + echo " FAIL linux/${arch}" >&2 + exit 1 + fi +done + +echo "==> Verifying interpreter and SONAMEs (amd64 and arm64)" +go test -run TestMuslLinkArtifacts -v ./ffi + +echo "==> Building the runtime probe" +probe=$(mktemp -d) +trap 'rm -rf "$probe"' EXIT +GOOS=linux GOARCH=amd64 go build "${MUSL_FLAGS[@]}" -o "$probe/musl-probe" ./cmd/musl-probe + +run_probe() { + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + echo "==> Running probe in ${ALPINE_IMAGE} (docker)" + docker run --rm -v "$probe:/p:ro" "$ALPINE_IMAGE" /p/musl-probe + return + fi + + echo "==> No docker; fetching Alpine minirootfs ${ROOTFS_VERSION}" + curl -fsSL "$ROOTFS_URL" -o "$probe/rootfs.tar.gz" + echo "${ROOTFS_SHA256} $probe/rootfs.tar.gz" | sha256sum -c - + mkdir -p "$probe/rootfs" + tar xzf "$probe/rootfs.tar.gz" -C "$probe/rootfs" + + if [ "$(id -u)" = 0 ]; then + echo "==> Running probe via chroot (kernel resolves PT_INTERP)" + cp "$probe/musl-probe" "$probe/rootfs/musl-probe" + chroot "$probe/rootfs" /musl-probe + else + echo "==> Running probe via ld-musl directly (no root)" + LD_LIBRARY_PATH="$probe/rootfs/lib" \ + "$probe/rootfs/lib/ld-musl-x86_64.so.1" "$probe/musl-probe" + fi +} + +run_probe + +echo "==> musl build mode OK"