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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<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 |

```bash
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
214 changes: 214 additions & 0 deletions cmd/musl-probe/main.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading