Skip to content

feat: add the universal build (goffi_universal, "Profile U") - #89

Open
unxed wants to merge 3 commits into
go-webgpu:mainfrom
unxed:feature/profile-u
Open

unxed wants to merge 3 commits into
go-webgpu:mainfrom
unxed:feature/profile-u

Conversation

@unxed

@unxed unxed commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Step 4 of the split agreed in #76. Depends on #87 (goffi_musl). The branch is stacked on it, so review only the last commit.

-tags goffi_universal builds one CGO_ENABLED=0 binary for linux/amd64 and linux/arm64 that does FFI on both glibc and musl hosts.

How it works

  • Every libc import has an empty SONAME, so the binary has no DT_NEEDED. scripts/build-universal.sh then removes PT_INTERP, and cmd/goffi-audit checks both. The kernel can load the binary directly on any distro.
  • At the top of x_cgo_init, before any libc symbol is used, the process re-execs itself through the host's dynamic loader with the host libc preloaded. On glibc it also preloads libpthread.so.0 and libdl.so.2, which older glibc needs. glibc's loader only binds a main object that has PT_INTERP, so on glibc it gets an in-memory copy with that header restored. This code uses only raw syscalls and mmap'd scratch memory.
  • Fallback. If there is no loader goffi recognises, or a library preloaded through LD_PRELOAD or /etc/ld.so.preload kills the re-executed process, the program keeps running as pure Go. LoadLibrary, GetSymbol and CallFunction then return ffi.ErrNoHostLibc. When something is preloaded, the launch is first tried in a forked child.

The exec.Command question from #76. I removed the footgun instead of wrapping it:

  • The re-exec guard GOFFI_UNIVERSAL_REEXEC is now <pid>:1, tagged the same way as GOFFI_UNIVERSAL_EXE and GOFFI_UNIVERSAL_ARGV0. The re-executed process keeps its pid, so it finds its own guard. A child has a new pid, so it runs the bridge itself.
  • cmd/universal-respawn starts itself via os.Args[0], via ffi.Executable(), and as a grandchild. Each child must call getpid and strlen through goffi and report the on-disk ffi.Executable().
  • CI runs it on the runner, on four glibc images (glibc 2.31 to current) and on Alpine. All pass.
  • It also tries starting the copy through the host loader by hand. That is reported but not required: glibc 2.31's loader rejects a /proc/self/fd/<n> image with loader cannot load itself. With the pid-tagged guard, that recipe is no longer needed anyway.

ffi.Available(). The godoc now spells out the three modes:

  • constant false under goffi_static;
  • decided once at startup under goffi_universal;
  • true otherwise.

This PR adds Available to upstream, along with:

  • ErrNoHostLibc;
  • HostLoader, HostLibC, HostPreload and LibcKind (from internal/loader);
  • Executable and Argv0, which return what os.Executable() and os.Args[0] gave before the re-exec.

Tags: goffi_static takes precedence over goffi_universal. Under goffi_universal, the glibc and goffi_musl import files are excluded.

CI: new universal.yml. It builds every mode, then runs vet and tests. It builds one probe binary and runs that same binary on glibc and musl, runs the respawn probe everywhere, and runs the preload-abort fallback jobs. Everything passes. In ci.yml only the Android arm64 jobs fail, in "Set up Android SDK", which #85 fixes.

Docs: docs/PROFILE_U.md, README, CHANGELOG, NOTICE (attribution to unxed/static-everywhere).

🤖 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 16:20
@codecov

codecov Bot commented Sep 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.57143% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
ffi/selfinfo.go 73.52% 5 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

unxed and others added 2 commits September 25, 2026 16:31
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>
One CGO_ENABLED=0 binary for linux/amd64 and linux/arm64 that does FFI on
both glibc and musl hosts.

- Every libc symbol is imported with an empty SONAME (dl_universal.go,
  errno_universal.go, fakecgo/symbols_universal.go), so the binary has
  no DT_NEEDED. scripts/build-universal.sh strips PT_INTERP after linking
  (cmd/goffi-strip-interp), so the kernel loads it directly anywhere;
  cmd/goffi-audit checks both.
- At the top of x_cgo_init, before any libc symbol is touched, the
  process re-execs itself through the host loader with the host libc
  preloaded (on glibc also libpthread.so.0 and libdl.so.2, for glibc
  before 2.34). glibc only binds a main object with PT_INTERP, so there
  the loader gets a memfd copy with the header restored. Raw syscalls and
  mmap'd scratch only; setupUniversalTLS gives the first launch a thread
  pointer.
- The guard GOFFI_UNIVERSAL_REEXEC is "<pid>:1", tagged like the
  recorded GOFFI_UNIVERSAL_EXE/ARGV0, so a child started with
  exec.Command(os.Args[0]) runs the bridge itself instead of trusting its
  parent's guard. A child started from the parent's memfd or by the host
  loader by hand works too.
- Where something is preloaded (LD_PRELOAD, /etc/ld.so.preload) the
  launch is probed in a forked child first. With no recognised loader, or
  a failed probe, the process carries on as pure Go: iscgo and the libc
  env hooks are cleared, and LoadLibrary/GetSymbol/CallFunction return
  ffi.ErrNoHostLibc.
- New API: ffi.Available (godoc spells out the three modes),
  ErrNoHostLibc, HostLoader/HostLibC/HostPreload/LibcKind (from
  internal/loader), Executable/Argv0.

goffi_static wins over goffi_universal. goffi_musl and the default glibc
imports are excluded under goffi_universal.

CI (universal.yml): build every mode, vet and test; build one probe and
run the same binary on the runner, four glibc images (2.31 to current)
and Alpine; a respawn probe (cmd/universal-respawn) on all of them; the
preload-abort fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant