Skip to content
Merged
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
61 changes: 60 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,68 @@ jobs:
echo "✅ All coverage thresholds met"
echo "🎯 Production platforms (AMD64): 85%+ coverage"

# ELF linking contract: default dynamic FFI vs -tags goffi_static
# (gogpu#474 / goffi#74). Runs on Linux so file(1)/readelf are available.
elf-linking:
name: ELF Linking (linux/${{ matrix.arch }})
runs-on: ubuntu-latest
needs: [lint, formatting]
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
env:
CGO_ENABLED: "0"
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true

- name: Build probe binaries
run: |
mkdir -p /tmp/goffi-elf
cat > /tmp/goffi-elf/main.go <<'EOF'
package main

import (
"fmt"

"github.com/go-webgpu/goffi/ffi"
)

func main() {
_, err := ffi.LoadLibrary("libc.so.6")
fmt.Println(err)
}
EOF
# Strip leading indentation from the heredoc body for valid Go.
sed -i 's/^ //' /tmp/goffi-elf/main.go
cd /tmp/goffi-elf
go mod init goffielfprobe
go mod edit -replace=github.com/go-webgpu/goffi="${{ github.workspace }}"
go get github.com/go-webgpu/goffi@v0.0.0
GOARCH=${{ matrix.arch }} go build -o default .
GOARCH=${{ matrix.arch }} go build -tags goffi_static -o static .

- name: Assert dynamic vs static ELF
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq binutils
"${{ github.workspace }}/scripts/check-elf-linking.sh" --dynamic /tmp/goffi-elf/default
"${{ github.workspace }}/scripts/check-elf-linking.sh" --static /tmp/goffi-elf/static

- name: Static-profile unit tests
if: matrix.arch == 'amd64'
run: go test -tags goffi_static ./ffi -count=1 -run 'StaticBuild'

# Final status - All checks passed
ci-success:
name: CI Success
needs: [lint, formatting, cross-compile, android-cross, test, benchmarks, quality-gate]
needs: [lint, formatting, cross-compile, android-cross, test, benchmarks, quality-gate, elf-linking]
runs-on: ubuntu-latest
if: success()
steps:
Expand All @@ -413,6 +471,7 @@ jobs:
echo " - Linux AMD64 (ubuntu-latest)"
echo " - Windows AMD64 (windows-latest)"
echo " - macOS ARM64 (macos-latest)"
echo "✅ ELF Linking: PASSED (default dynamic + goffi_static)"
echo "✅ Benchmarks: PASSED"
echo "✅ Quality Gate: PASSED"
echo ""
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **`-tags goffi_static`** — fully static Linux amd64/arm64 binaries under `CGO_ENABLED=0` by excluding all `//go:cgo_import_dynamic` directives (`libdl`/`libc`/`libpthread`). `ffi.LoadLibrary` / `GetSymbol` return `ffi.ErrStaticBuild`. ([#74](https://github.com/go-webgpu/goffi/issues/74), [gogpu#474](https://github.com/gogpu/gogpu/issues/474))
- **Linking modes** documented in README (dynamic FFI, musl dynamic, static no-FFI)
- **`scripts/check-elf-linking.sh`** — CI/helper asserts `PT_INTERP` / `DT_NEEDED` for default vs static profiles
- **`docs/ADR-001-userspace-elf-loader.md`** — research track for optional pure-Go ELF `.so` loader (preview, not default)

## [0.6.3] - 2026-08-01

### Fixed
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@ CGO_ENABLED=1 go build ./...

> **How?** goffi uses Go's `cgo_import_dynamic` for dynamic library loading. Under `CGO_ENABLED=0` the cgo runtime is supplied by `internal/fakecgo`; under `CGO_ENABLED=1` the standard `runtime/cgo` is linked in. Both modes share the same FFI fast path and ABIs.

### Linking modes (Linux)

`CGO_ENABLED=0` does **not** imply a fully static ELF when goffi is imported. `//go:cgo_import_dynamic` for `dlopen` / libc still records `PT_INTERP` and `DT_NEEDED` (`libdl.so.2`, `libc.so.6`, `libpthread.so.0`). This matches purego and is required for host `dlopen` — the kernel only maps `ld.so` when `PT_INTERP` is present. See [goffi#74](https://github.com/go-webgpu/goffi/issues/74) and [gogpu#474](https://github.com/gogpu/gogpu/issues/474).

| 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 |
| **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
# Fully static Linux amd64/arm64 binary (FFI unavailable)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags goffi_static -o app .
file app # statically linked
# Verify: no INTERP / NEEDED
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.

`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

```go
Expand Down Expand Up @@ -380,6 +402,9 @@ 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).

**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.
- Workaround: build native libraries with `panic=abort`.
Expand Down
9 changes: 6 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> **Strategic Approach**: Build production-ready Zero-CGO FFI with benchmarked performance
> **Philosophy**: Performance first, usability second, platform coverage third

**Last Updated**: 2026-08-01 | **Current Version**: v0.6.3 | **Strategy**: Benchmarks → Callbacks → ARM64 → Runtime → ABI → v1.0 LTS | **Milestone**: v0.6.3 (HFA checkptr fix) → v0.7.0 RegisterFunc/Builder → v1.0.0 LTS
**Last Updated**: 2026-09-08 | **Current Version**: v0.6.3 | **Strategy**: Benchmarks → Callbacks → ARM64 → Runtime → ABI → v1.0 LTS | **Milestone**: v0.6.3 (HFA checkptr fix) → v0.7.0 `goffi_static` + RegisterFunc/Builder → v1.0.0 LTS

---

Expand Down Expand Up @@ -175,7 +175,10 @@ v1.0.0 LTS → Long-term support release (2027 Q1)
- ARM64 9-16B struct return proactive fix (copy pattern)
- Struct pass/return examples and README section (#58)

**v0.7.0** = RegisterFunc + Builder API (2026 Q3-Q4)
**v0.7.0** = Static linking + RegisterFunc + Builder API (2026 Q3-Q4)
- `-tags goffi_static` fully static Linux ELFs (#74, gogpu#474) — shipped in PR #78
- Linking-mode docs + ELF CI gates
- ADR-001 userspace ELF loader (research)
- RegisterFunc convenience API (ADR-008)
- Library struct + OpenLibraryBytes (ADR-009)
- NewFunc/Call/CallCtx ergonomic wrappers (ADR-009)
Expand All @@ -190,7 +193,7 @@ v1.0.0 LTS → Long-term support release (2027 Q1)

## 📊 Current Status (v0.6.3)

**Phase**: HFA checkptr fix, struct examples. 9 platforms. Planning v0.7.0 (RegisterFunc)
**Phase**: HFA checkptr fix, struct examples. 9 platforms. `goffi_static` in PR #78; planning RegisterFunc for v0.7.0

**What Works**:
- ✅ Dynamic library loading (`LoadLibrary`, `GetSymbol`, `FreeLibrary`)
Expand Down
90 changes: 90 additions & 0 deletions docs/ADR-001-userspace-elf-loader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# ADR-001: Userspace ELF loader for static binaries

**Status:** Proposed (research / preview)
**Date:** 2026-09-08
**Tracking:** [goffi#74](https://github.com/go-webgpu/goffi/issues/74), [gogpu#474](https://github.com/gogpu/gogpu/issues/474)
**Go baseline:** 1.25+ (`structs.HostLayout`, `CGO_ENABLED=0`)

## Context

Default goffi Linux builds use `//go:cgo_import_dynamic` to resolve `dlopen` /
`dlsym` from `libdl.so.2`. That forces `PT_INTERP` + `DT_NEEDED`, so binaries
are dynamically linked even with `CGO_ENABLED=0`.

`-tags goffi_static` removes those imports and yields a fully static ELF, but
`ffi.LoadLibrary` returns `ErrStaticBuild`. Host `dlopen` cannot work inside a
static ELF: the kernel only loads `ld.so` when `PT_INTERP` is present (SunOS
userspace-loader design). Windows differs because `ntdll` is always mapped.

Consumer apps (e.g. f4) want **static distribution** and, ideally, still load
system `.so` files (Wayland, Vulkan ICD, libX11) at runtime.

## Decision

Explore a **pure-Go userspace ELF `.so` loader** gated behind
`-tags goffi_elfloader` (experimental, not default). It is the only credible
path to “static binary + runtime LoadLibrary-like behavior” on Linux without
shipping `ld.so`.

Ship Track 1–2 (`goffi_static` + docs/CI) first. This ADR does **not** block
closing the documentation / static-profile side of #74 / gogpu#474.

## Goals

1. Map `ET_DYN` shared objects via `unix.Mmap` / `PROT_EXEC` without calling
host `dlopen`.
2. Apply relative / symbolic relocations needed for a small allowlisted set of
UI/GPU libraries.
3. Hand resolved symbol addresses to existing goffi `CallFunction` (no change
to the assembly call path once the function pointer is known).
4. Fail closed on TLS, IFUNC, GNU symbol versioning, and glibc-private ABI
that cannot be reproduced safely.

## Non-goals

- Full glibc / musl loader compatibility.
- Loading arbitrary untrusted `.so` from the network.
- Default-on behavior for release builds.
- Mach-O / PE loaders in v0 (Linux amd64 first, then arm64).

## Proposed design (v0)

```
LoadLibrary(path)
→ open + read ELF headers
→ mmap PT_LOAD segments (honor p_align)
→ apply R_*_RELATIVE / R_*_GLOB_DAT against local + allowlisted modules
→ return handle; GetSymbol walks .dynsym / hash
CallFunction(cif, sym, ...) // unchanged
```

Build tag: `goffi_elfloader` (implies or composes with static profile).
Public API stays `ffi.LoadLibrary` / `GetSymbol`; implementation switches
under the tag.

Threat model: load **system libraries by absolute path** only
(`/usr/lib/**`, `/lib/**`). Document dual-use concerns of in-memory loaders;
reject relative paths and `LD_LIBRARY_PATH` search in v0.

## Alternatives

| Alternative | Why not primary |
|-------------|-----------------|
| Document-only | Does not meet static + FFI consumer need |
| musl-dynamic Alpine | Valid container story; not `FROM scratch` |
| Embed `ld.so` | Not Pure Go; redistributes glibc/musl loader |
| External linker `-static` | Fights CGO_ENABLED=0; still no useful `dlopen` |

## Consequences

- Large engineering surface (relocations, TLS, IFUNC).
- Security review required before preview announcement.
- Success metric for spike: load a trivial self-built `.so` and call one
exported `int add(int,int)` via `CallFunction` from a static binary.

## Spike checklist

- [ ] Linux amd64: map toy `.so`, resolve one symbol, call via goffi
- [ ] Reject TLS / IFUNC with typed errors
- [ ] Absolute-path allowlist
- [ ] Document Go/No-Go for arm64 preview
2 changes: 1 addition & 1 deletion ffi/dl_android.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The Goffi Authors

//go:build android && arm64
//go:build android && arm64 && !goffi_static

package ffi

Expand Down
2 changes: 1 addition & 1 deletion ffi/dl_darwin.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build darwin && (amd64 || arm64)
//go:build darwin && (amd64 || arm64) && !goffi_static

// macOS library loading - OUR OWN implementation (NO dependencies!)
//
Expand Down
42 changes: 42 additions & 0 deletions ffi/dl_static.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//go:build goffi_static && ((linux && !android) || darwin || freebsd || (android && arm64)) && (amd64 || arm64)

package ffi

import "unsafe"

// RTLD constants match the dynamic profile so call sites compile unchanged.
// They are unused under goffi_static because LoadLibrary always fails.
const (
RTLD_NOW = 0x00002
RTLD_GLOBAL = 0x00100
)

// LoadLibrary always fails under -tags goffi_static.
func LoadLibrary(name string) (unsafe.Pointer, error) {
return nil, &LibraryError{
Operation: "load",
Name: name,
Err: ErrStaticBuild,
}
}

// GetSymbol always fails under -tags goffi_static.
func GetSymbol(handle unsafe.Pointer, name string) (unsafe.Pointer, error) {
return nil, &LibraryError{
Operation: "symbol",
Name: name,
Err: ErrStaticBuild,
}
}

// FreeLibrary is a no-op success for nil and returns ErrStaticBuild otherwise.
func FreeLibrary(handle unsafe.Pointer) error {
if handle == nil {
return nil
}
return &LibraryError{
Operation: "free",
Name: "<library handle>",
Err: ErrStaticBuild,
}
}
2 changes: 1 addition & 1 deletion ffi/dl_unix.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build ((linux && !android) || freebsd) && (amd64 || arm64)
//go:build ((linux && !android) || freebsd) && (amd64 || arm64) && !goffi_static

// Unix library loading via dlopen - OUR OWN implementation (NO dependencies!)
//
Expand Down
2 changes: 1 addition & 1 deletion ffi/dl_unix_stubs.s
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build linux && amd64
//go:build linux && amd64 && !goffi_static

#include "textflag.h"

Expand Down
11 changes: 11 additions & 0 deletions ffi/errors_static.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package ffi

import "errors"

// ErrStaticBuild is returned by LoadLibrary / GetSymbol / FreeLibrary when the
// binary was built with -tags goffi_static. That profile removes all
// //go:cgo_import_dynamic directives so the Go internal linker can emit a
// fully static ELF; dynamic library loading is intentionally unavailable.
//
// Use errors.Is(err, ErrStaticBuild) or unwrap through *LibraryError.
var ErrStaticBuild = errors.New("goffi: dynamic loading unavailable in static build (-tags goffi_static)")
8 changes: 8 additions & 0 deletions ffi/fakecgo_static.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//go:build goffi_static && (linux || darwin || freebsd) && !cgo

package ffi

// Pull in the static-profile fakecgo stubs (crosscall2 abort trampoline) so the
// ffi package links when callback assembly is present. Dynamic loading remains
// unavailable; see ErrStaticBuild.
import _ "github.com/go-webgpu/goffi/internal/fakecgo"
2 changes: 1 addition & 1 deletion ffi/fakecgo_unix.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build (linux || darwin || freebsd) && !cgo && !nofakecgo
//go:build (linux || darwin || freebsd) && !cgo && !nofakecgo && !goffi_static

package ffi

Expand Down
42 changes: 42 additions & 0 deletions ffi/static_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//go:build goffi_static

package ffi_test

import (
"errors"
"testing"
"unsafe"

"github.com/go-webgpu/goffi/ffi"
)

func TestStaticBuild_LoadLibraryReturnsErrStaticBuild(t *testing.T) {
handle, err := ffi.LoadLibrary("libc.so.6")
if handle != nil {
t.Fatalf("LoadLibrary handle = %v, want nil", handle)
}
if !errors.Is(err, ffi.ErrStaticBuild) {
t.Fatalf("LoadLibrary error = %v, want errors.Is(..., ErrStaticBuild)", err)
}

var libErr *ffi.LibraryError
if !errors.As(err, &libErr) {
t.Fatalf("LoadLibrary error type = %T, want *LibraryError", err)
}
if libErr.Operation != "load" {
t.Fatalf("Operation = %q, want load", libErr.Operation)
}
}

func TestStaticBuild_GetSymbolReturnsErrStaticBuild(t *testing.T) {
_, err := ffi.GetSymbol(unsafe.Pointer(uintptr(1)), "strlen")
if !errors.Is(err, ffi.ErrStaticBuild) {
t.Fatalf("GetSymbol error = %v, want errors.Is(..., ErrStaticBuild)", err)
}
}

func TestStaticBuild_FreeLibraryNilOK(t *testing.T) {
if err := ffi.FreeLibrary(nil); err != nil {
t.Fatalf("FreeLibrary(nil) = %v, want nil", err)
}
}
2 changes: 1 addition & 1 deletion internal/dl/cgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-FileCopyrightText: 2022 The Ebitengine Authors
// SPDX-FileCopyrightText: 2026 Andrey Kolkov and GoGPU Contributors

//go:build cgo && (darwin || freebsd || linux || netbsd)
//go:build cgo && (darwin || freebsd || linux || netbsd) && !goffi_static

package dl

Expand Down
Loading
Loading