Skip to content

feat: add goffi_musl build tag for Alpine and other musl systems - #87

Open
unxed wants to merge 2 commits into
go-webgpu:mainfrom
unxed:feature/goffi-musl
Open

unxed wants to merge 2 commits into
go-webgpu:mainfrom
unxed:feature/goffi-musl

Conversation

@unxed

@unxed unxed commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Step 3 of the split agreed in #76, built on the goffi_static from #78.

A default goffi binary does not start on Alpine. Its PT_INTERP names the glibc loader, and its DT_NEEDED entries name libdl.so.2, libc.so.6 and libpthread.so.0. musl has neither: it ships one loader, /lib/ld-musl-<arch>.so.1, and one libc, libc.musl-<arch>.so.1. Both are fixed at link time, so the libc flavor has to be chosen at build time.

CGO_ENABLED=0 go build -tags goffi_musl \
    -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./...

What changes (linux/amd64 and linux/arm64 only):

  • internal/dl, internal/syscall and internal/fakecgo get musl flavors of their cgo_import_dynamic directives. The glibc libdl imports move from dl_linux.go to dl_linux_glibc.go, so the RTLD_* constants stay shared.
  • internal/dl sets the musl loader with //go:cgo_dynamic_linker. The compiler accepts that directive only in cgo-generated code, which is why the build needs the -gcflags line. If you leave the flag out, the compile fails and names the directive.
  • pthread_get_stacksize_np is left out of the musl set. It is a Darwin-only API. glibc binds lazily and never calls it, but musl binds every import at load time and would refuse to start the binary. The linker already drops its trampoline on Linux.
  • gen.go generates the fakecgo musl files from the same symbol tables as the glibc ones.
  • When both tags are set, goffi_static takes precedence. goffi_musl has no effect off Linux. FFI is fully available under goffi_musl.

Tests:

  • TestMuslDirectiveParity checks that the glibc and musl symbol sets match, apart from the one intended exclusion. It also checks the per-arch SONAMEs and the interpreter.
  • TestMuslLinkArtifacts builds linux/amd64 and linux/arm64 probes and reads PT_INTERP and DT_NEEDED with debug/elf.
  • cmd/musl-probe runs in an Alpine container (scripts/check-musl.sh, new musl-build CI job on Go 1.25 and 1.26). It covers dlopen/dlsym, integer and float calls, errno, a qsort callback, and a goroutine hammer that makes the runtime start OS threads through fakecgo's pthread imports.

Docs: docs/MUSL.md, the README linking-modes table and CHANGELOG.

The Android arm64 jobs fail in "Set up Android SDK" before any goffi code runs. That failure is unrelated to this PR and is fixed by #85.

🤖 Generated with Claude Code

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 <pkg>` 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.
@unxed
unxed requested a review from kolkov as a code owner September 25, 2026 14:15
@codecov

codecov Bot commented Sep 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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-<arch>.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) <noreply@anthropic.com>
@unxed
unxed force-pushed the feature/goffi-musl branch from 34042b8 to 1e5844a Compare September 25, 2026 16:31
@kolkov kolkov added type: feature New capability platform: linux Linux-specific area: dl Dynamic library loading area: fakecgo Fake CGO runtime integration area: ci CI workflows, check scripts, branch protection labels Oct 15, 2026

@kolkov kolkov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for splitting this out. The runtime probe and the Alpine CI job are exactly the kind of verification this area has been missing.

What I verified (WSL Ubuntu 22.04, Go 1.27.1):

  • scripts/check-musl.sh passes. Without root it falls back to running the probe through ld-musl-x86_64.so.1 directly: dlopen, sqrt, strlen, getpid, errno, the qsort callback and the 64-goroutine hammer are all OK, MUSL-PROBE-OK.
  • go vet ./... and go test -count=1 ./... pass.

The main point: the SONAME rewrite isn't needed

The PR's premise is that musl's loader refuses a binary whose DT_NEEDED names libc.so.6, libdl.so.2 and libpthread.so.0. It doesn't. musl's dynamic linker treats every lib{c,pthread,rt,m,dl,util,xnet}.* name as reserved and resolves it to itself (ldso/dynlink.c ~L1074, "Catch and block attempts to reload the implementation itself"). So on Alpine the only thing actually wrong with a default goffi binary is PT_INTERP.

To check, I built the probe from this branch without the tag and without -std, changing only the interpreter with the linker's standard -I flag:

CGO_ENABLED=0 go build -ldflags="-I /lib/ld-musl-x86_64.so.1" ./cmd/musl-probe
[Requesting program interpreter: /lib/ld-musl-x86_64.so.1]
NEEDED  libc.so.6
NEEDED  libdl.so.2
NEEDED  libpthread.so.0

Then I ran it in an Alpine 3.24 chroot (the kernel resolves PT_INTERP, no loader invoked by hand). Every check passes, ending in MUSL-PROBE-OK.

I re-ran it against current main, after #92. That PR added fakecgo hooks for syscall.Setuid and friends, so fakecgo now also imports the set*id functions and __errno_location from libc.so.6. The -ldflags=-I build still ends in MUSL-PROBE-OK. A separate probe calls syscall.Setgroups, Setgid and Setuid as root in the same chroot, and the new IDs show up on every thread (/proc/self/task/*/status). So the reserved-name path covers those imports too.

So the same result is available with:

  • no new build tag and no new cgo_import_dynamic files;
  • no -gcflags=.../internal/dl=-std. Asking every downstream user to compile one of our packages as if it were the standard library is something I'd rather not put in the README;
  • no generated musl variants in gen.go that have to be kept in sync.

The flag's ergonomics are no worse. Forgetting -tags goffi_musl and forgetting -ldflags=-I fail the same way (ENOENT on Alpine). The only real snag with -ldflags is that it has to be merged with any other -ldflags the user passes (-X, -s -w), which is worth a line in the docs.

Proposal: shrink this PR to:

  • docs/MUSL.md and the README linking-modes row documenting -ldflags="-I /lib/ld-musl-<arch>.so.1", plus the reserved-name explanation (it's non-obvious and worth recording);
  • cmd/musl-probe, scripts/check-musl.sh and the musl-build job, built with that flag instead of the tag;
  • TestMuslLinkArtifacts reduced to asserting PT_INTERP.

If you know of a musl setup where the reserved-name path doesn't apply, I'm happy to be corrected.

If the tag stays anyway, these need fixing

  1. CGO_ENABLED=1 -tags goffi_musl on a glibc host (with the documented -gcflags=…/internal/dl=-std) fails in the linker with an interpreter conflict followed by a list of undefined runtime/cgo relocations, which doesn't tell the user that the tag simply doesn't support cgo:
    link: conflict dynlinker: /lib/ld-musl-x86_64.so.1 and /lib64/ld-linux-x86-64.so.2
    runtime/cgo(.text): relocation target stderr not defined
    runtime/cgo(.text): relocation target fwrite not defined
    ...
    
    Either constrain the musl files with !cgo (under cgo the libc comes from the host C toolchain anyway) or fail with a clear message.
  2. TestMuslLinkArtifacts appends GOFLAGS=-mod=mod to the environment, which replaces the caller's GOFLAGS (it dropped my -buildvcs=false and the test failed for an unrelated reason). Prefer passing -mod=mod as a go build argument.
  3. After the rebase, the musl tables need the imports #92 added. symbols_linux.go on main now also imports __errno_location and nine set*id functions (setuid, setgid, setgroups, …) for the hooks in internal/fakecgo/linux.go. That file builds under goffi_musl as well, but symbols_musl_*.go don't import those symbols. Please regenerate them from gen.go rather than merging the generated files by hand.
  4. Small inaccuracy in the pthread_get_stacksize_np rationale. On Linux the import is never emitted, because its only user is dead-code-eliminated, so musl "refusing to start" over it can't happen. Dropping it is harmless, but the comment and docs/MUSL.md overstate why.

License headers

goffi is MIT-licensed (LICENSE). Apache-2.0 headers are only correct on files that are actually derived from purego. NOTICE covers those, and they carry SPDX-FileCopyrightText: 2022 The Ebitengine Authors. The new files written for this PR (internal/dl/dl_musl_*.go, internal/syscall/errno_musl_*.go, ffi/musl_*_test.go, cmd/musl-probe/main.go) are original code, so they should be:

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors

The generated internal/fakecgo/symbols_musl_*.go are fine as they are: the generator template comes from purego and stamps the Apache + Ebitengine header.

To be fair: you copied the pattern from our own files. Several original files on main carry Apache headers by mistake, and we're fixing that separately. Mixed headers in an MIT project are exactly the kind of thing that trips corporate license scanners, which is why I'd like new code to start out right.


Heads-up: #92 (merged) touched internal/fakecgo/gen.go, symbols_linux.go, README, CHANGELOG and ci.yml, so this branch now conflicts with main. The smaller shape above would avoid the fakecgo conflicts entirely. #89 is stacked on this branch, so whatever shape this PR takes, #89 will need a rebase as well.

@kolkov kolkov added status: review In code review priority: medium Normal priority labels Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ci CI workflows, check scripts, branch protection area: dl Dynamic library loading area: fakecgo Fake CGO runtime integration platform: linux Linux-specific priority: medium Normal priority status: review In code review type: feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants