Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -453,10 +460,37 @@ jobs:
if: matrix.arch == 'amd64'
run: go test -tags goffi_static ./ffi -count=1 -run 'StaticBuild'

# musl builds: -tags goffi_musl must replace the glibc SONAMEs and the ELF
# interpreter with their musl equivalents, otherwise the binary cannot start
# on Alpine. Link-time checks cover amd64 and arm64; the runtime probe runs
# inside a real Alpine userland. See docs/MUSL.md.
musl-build:
name: musl Build (goffi_musl, Go ${{ matrix.go }})
runs-on: ubuntu-latest
needs: [lint, formatting]
strategy:
fail-fast: false
matrix:
go: ['1.25', '1.26']
env:
CGO_ENABLED: "0"
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
cache: true

- name: Check musl build mode
run: scripts/check-musl.sh

# Final status - All checks passed
ci-success:
name: CI Success
needs: [lint, formatting, cross-compile, android-cross, test, benchmarks, quality-gate, elf-linking]
needs: [lint, formatting, cross-compile, android-cross, test, benchmarks, quality-gate, elf-linking, musl-build]
runs-on: ubuntu-latest
if: success()
steps:
Expand All @@ -472,6 +506,7 @@ jobs:
echo " - Windows AMD64 (windows-latest)"
echo " - macOS ARM64 (macos-latest)"
echo "✅ ELF Linking: PASSED (default dynamic + goffi_static)"
echo "✅ musl: PASSED (goffi_musl, Alpine runtime probe)"
echo "✅ Benchmarks: PASSED"
echo "✅ Quality Gate: PASSED"
echo ""
Expand Down
183 changes: 183 additions & 0 deletions .github/workflows/universal.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
name: universal

on:
push:
branches: [ "**" ]
pull_request:

permissions:
contents: read

jobs:
build:
name: build + Profile U contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
# Pinned to match .github/workflows/ci.yml: the goffi_musl build below
# depends on the toolchain's cgo_import_dynamic restrictions, which
# the -gcflags=...=-std workaround is calibrated against.
go-version: '1.26.x'
- name: Build every mode, vet and unit tests
run: |
set -euo pipefail
CGO_ENABLED=0 go build ./...
CGO_ENABLED=0 go build -tags goffi_universal ./...
CGO_ENABLED=0 go build -tags goffi_static ./...
CGO_ENABLED=0 go build -tags goffi_musl -gcflags=github.com/go-webgpu/goffi/internal/dl=-std ./...
# -unsafeptr=false: goffi reconstructs pointers from native call
# registers and stack slots by design (see ffi/callback_pointer.go's
# //go:nocheckptr contract). ci.yml runs vet through golangci-lint,
# which excludes govet on exactly those paths (.golangci.yml); this
# flag is the bare-vet equivalent of that exclusion.
CGO_ENABLED=0 go vet -unsafeptr=false ./...
CGO_ENABLED=0 go test ./...
CGO_ENABLED=0 go test -tags goffi_universal ./internal/...
- name: Build the universal probe and check the ELF contract
run: |
set -euo pipefail
bash scripts/build-universal.sh -o universal-probe ./cmd/universal-probe
go run ./cmd/goffi-audit universal-probe
bash scripts/build-universal.sh -o universal-respawn ./cmd/universal-respawn
go run ./cmd/goffi-audit universal-respawn
- name: A universal binary starts copies of itself (runner host)
run: |
set -euo pipefail
./universal-respawn | tee out.txt
grep -q RESPAWN-PROBE-OK out.txt
- name: Upload universal probe
uses: actions/upload-artifact@v4
with:
name: universal-probe
path: |
universal-probe
universal-respawn

run-glibc:
name: run on glibc (${{ matrix.image }})
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
# bullseye and 20.04 carry glibc 2.31, which still keeps the pthread_*
# and dl* functions in libpthread.so.0 and libdl.so.2 rather than in
# libc.so.6 (merged in 2.34): the images above cannot catch a bridge
# that preloads libc alone (f4 #1381).
image: [ "debian:stable-slim", "ubuntu:24.04", "debian:bullseye-slim", "ubuntu:20.04" ]
container:
image: ${{ matrix.image }}
steps:
- uses: actions/download-artifact@v4
with:
name: universal-probe
- name: Run the same universal binary on glibc
run: |
set -eu
chmod +x universal-probe
./universal-probe | tee out.txt
grep -q UNIVERSAL-PROBE-OK out.txt
- name: The same binary starts copies of itself
run: |
set -eu
chmod +x universal-respawn
./universal-respawn | tee out.txt
grep -q RESPAWN-PROBE-OK out.txt

preload-failure:
name: a preloaded library that aborts (f4 #1213)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.x'
- name: Build a universal binary that reports whether FFI is available
run: |
set -euo pipefail
bash scripts/build-universal.sh -o universal-available ./cmd/universal-available
- name: Build a preloadable library that aborts only in the re-executed process
run: |
set -euo pipefail
# ESET's libesets_pac.so, preloaded into every process through
# /etc/ld.so.preload, aborts in its constructor when the universal
# binary is re-executed through the host loader (f4 #1213). This
# library does the same for exactly that launch -- a loader started
# with --preload and an image named /proc/self/fd/N -- and leaves
# every other process alone.
cat > abort.c <<'C'
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
__attribute__((constructor)) static void init(void) {
char buf[4096];
int fd = open("/proc/self/cmdline", O_RDONLY);
if (fd < 0) return;
ssize_t n = read(fd, buf, sizeof buf - 1);
close(fd);
if (n <= 0) return;
for (ssize_t i = 0; i < n; i++) if (buf[i] == 0) buf[i] = ' ';
buf[n] = 0;
if (strstr(buf, "--preload") && strstr(buf, " /proc/self/fd/")) abort();
}
C
echo 'static int unused;' > noop.c
sudo gcc -shared -fPIC -o /usr/local/lib/libabort.so abort.c
sudo gcc -shared -fPIC -o /usr/local/lib/libnoop.so noop.c
- name: Nothing preloaded, the bridge is unchanged
run: |
set -eu
./universal-available | tee out.txt
grep -q 'ffi.Available = true' out.txt
grep -q 'workload = ok' out.txt
- name: A harmless LD_PRELOAD still gets FFI
run: |
set -eu
LD_PRELOAD=/usr/local/lib/libnoop.so ./universal-available | tee out.txt
grep -q 'ffi.Available = true' out.txt
grep -q 'workload = ok' out.txt
- name: An aborting LD_PRELOAD library falls back to no FFI
run: |
set -eu
LD_PRELOAD=/usr/local/lib/libabort.so ./universal-available 2>err.txt | tee out.txt
cat err.txt
grep -q 'ffi.Available = false' out.txt
grep -q 'workload = ok' out.txt
grep -q 'continuing without FFI' err.txt
- name: An aborting /etc/ld.so.preload library falls back to no FFI
run: |
set -eu
echo /usr/local/lib/libabort.so | sudo tee /etc/ld.so.preload
trap 'sudo rm -f /etc/ld.so.preload' EXIT
./universal-available 2>err.txt | tee out.txt
cat err.txt
grep -q 'ffi.Available = false' out.txt
grep -q 'workload = ok' out.txt
grep -q 'continuing without FFI' err.txt

run-musl:
name: run on musl (alpine)
needs: build
runs-on: ubuntu-latest
container:
image: alpine:latest
steps:
- uses: actions/download-artifact@v4
with:
name: universal-probe
- name: Run the same universal binary on musl
shell: sh
run: |
set -eu
chmod +x universal-probe
./universal-probe | tee out.txt
grep -q UNIVERSAL-PROBE-OK out.txt
- name: The same binary starts copies of itself
shell: sh
run: |
set -eu
chmod +x universal-respawn
./universal-respawn | tee out.txt
grep -q RESPAWN-PROBE-OK out.txt
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Universal build ("Profile U", `-tags goffi_universal`)** — one `CGO_ENABLED=0` binary for linux/amd64 and linux/arm64 that does FFI on both glibc and musl hosts. Every libc symbol is imported with an empty SONAME and the ELF interpreter is stripped after linking (`scripts/build-universal.sh`, `cmd/goffi-strip-interp`), so the kernel loads the binary directly anywhere; before any libc symbol is touched the process re-execs itself through the host loader with the host libc preloaded. On a host with no loader goffi recognises, or where a preloaded library kills the re-executed process, the binary carries on without FFI. See `docs/PROFILE_U.md`.
- **`ffi.Available()`** — whether this build and host can do FFI: constant false under `goffi_static`, decided at startup under `goffi_universal`, true otherwise. **`ffi.ErrNoHostLibc`** — returned by `LoadLibrary`/`GetSymbol`/`CallFunction` in a universal binary that could not bind a libc.
- **`ffi.HostLoader()`, `HostLibC()`, `HostPreload()`, `LibcKind()`** — the host loader table the universal build uses (`internal/loader`).
- **`ffi.Executable()`, `ffi.Argv0()`** — `os.Executable`/`os.Args[0]` as they were before the universal re-exec replaced them; the same as `os` in every other build.
- A universal program can start copies of itself with plain `exec.Command(os.Args[0])`: the re-exec guard `GOFFI_UNIVERSAL_REEXEC` is tagged with the pid it was written for, so a child runs the bridge itself.
- CI: `universal.yml` builds one universal probe and runs the same binary on four glibc images (glibc 2.31 to current) and on Alpine, checks the ELF contract with `cmd/goffi-audit`, runs a respawn probe (`cmd/universal-respawn`) everywhere, and covers the preload-abort fallback.
- **`-tags goffi_musl`** — CGO-free binaries for Alpine and other musl systems (linux/amd64, linux/arm64): the dynamic imports name `libc.musl-<arch>.so.1` and `PT_INTERP` is `/lib/ld-musl-<arch>.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
Expand Down
10 changes: 10 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ CGO_ENABLED=1 go build ./...
| Mode | How | ELF shape | `LoadLibrary` | Typical use |
|------|-----|-----------|---------------|-------------|
| **Dynamic FFI** (default) | `CGO_ENABLED=0 go build` | dynamic + `libdl`/`libc` | yes | desktop GPU/GUI |
| **Musl dynamic** | build on Alpine / `CC=musl-gcc` | dynamic vs musl | yes | Alpine containers with GPU/GUI |
| **Musl dynamic** | `CGO_ENABLED=0 go build -tags goffi_musl -gcflags=github.com/go-webgpu/goffi/internal/dl=-std` | dynamic vs musl (`/lib/ld-musl-<arch>.so.1`, `libc.musl-<arch>.so.1`) | yes | Alpine containers with GPU/GUI |
| **Static no-FFI** | `CGO_ENABLED=0 go build -tags goffi_static` | fully static (no `PT_INTERP`, no `NEEDED`) | no (`errors.Is(err, ffi.ErrStaticBuild)`) | `FROM scratch`, air-gapped CLI |
| **Universal** | `scripts/build-universal.sh` (`-tags goffi_universal`) | no `PT_INTERP`, no `NEEDED`; re-execs through the host loader | yes, on glibc and musl hosts (`ffi.Available()`) | one binary for every Linux distro ([docs/PROFILE_U.md](docs/PROFILE_U.md)) |

```bash
# Fully static Linux amd64/arm64 binary (FFI unavailable)
Expand All @@ -92,6 +93,8 @@ scripts/check-elf-linking.sh --static ./app

Under `-tags goffi_static`, errno capture is unavailable (always returns 0): `ErrnoFnAddr()` is a no-op so the assembly trampoline skips `__errno_location` / `__error`, which need dynamic libc.

A default binary does not start on Alpine: its `PT_INTERP` and `DT_NEEDED` name the glibc loader and SONAMEs. `-tags goffi_musl` (linux/amd64 and linux/arm64) names the musl ones instead; FFI stays fully available. See [docs/MUSL.md](docs/MUSL.md). To ship one binary for both libcs, use the universal build instead: [docs/PROFILE_U.md](docs/PROFILE_U.md).

`FROM scratch` + Vulkan/Wayland/libX11 via host `dlopen` is not possible without either `ld.so` or a userspace ELF loader (see [docs/ADR-001-userspace-elf-loader.md](docs/ADR-001-userspace-elf-loader.md)). Windows is unaffected (`LoadLibraryW` via ntdll).

### Example: Calling strlen
Expand Down Expand Up @@ -403,7 +406,7 @@ if err != nil {
## Known Limitations

**Linux: default builds are dynamically linked** ([#74](https://github.com/go-webgpu/goffi/issues/74))
- Importing goffi records `libdl`/`libc` via `cgo_import_dynamic` even with `CGO_ENABLED=0`. Use `-tags goffi_static` for a fully static ELF (no runtime `.so` loading), or build against musl for Alpine. See [Linking modes](#linking-modes-linux).
- Importing goffi records `libdl`/`libc` via `cgo_import_dynamic` even with `CGO_ENABLED=0`. Use `-tags goffi_static` for a fully static ELF (no runtime `.so` loading), or `-tags goffi_musl` for Alpine. See [Linking modes](#linking-modes-linux).

**Windows: C++ exceptions may crash the program** ([#12516](https://github.com/golang/go/issues/12516))
- Go runtime limitation, not goffi-specific. Go 1.22+ added partial SEH support ([#58542](https://github.com/golang/go/issues/58542)), but edge cases remain.
Expand Down
59 changes: 59 additions & 0 deletions cmd/goffi-audit/main.go
Original file line number Diff line number Diff line change
@@ -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> [binary...]
package main

import (
"debug/elf"
"fmt"
"os"
"strings"
)

func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: goffi-audit <binary> [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
}
Loading
Loading