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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.0] - 2026-07-12

### Added
- **errno capture in assembly trampoline** — `CallFunction` now always captures C `errno` inside the assembly trampoline immediately after the C function returns, before the Go runtime can migrate the goroutine to a different OS thread. This is the only thread-safe window for errno capture. goffi is the first pure-Go FFI with correct errno capture on Linux. ([#60](https://github.com/go-webgpu/goffi/issues/60))
- Platform-specific errno resolution: `__errno_location` (Linux glibc/musl), `__error` (macOS/FreeBSD) via `//go:cgo_import_dynamic`

### Changed
- **BREAKING: `CallFunction` returns `(syscall.Errno, error)`** — errno is always captured and returned. Callers that don't need errno use `_, err := ffi.CallFunction(...)`. This replaces the opt-in `CallFunctionErrno` which was a pit of failure (you don't know you need errno until the function fails)
- **BREAKING: `CallFunctionContext` returns `(syscall.Errno, error)`** — same change with context support
- **BREAKING: `FunctionCaller.Execute` interface changed** — now accepts `errnoFn uintptr` parameter and returns `(cerrno uintptr, err error)`
- Removed `CallFunctionErrno` / `CallFunctionErrnoContext` (superseded by always-capture)
- Removed `FunctionCallerErrno` interface (merged into `FunctionCaller`)
- Reduced code by 429 lines (eliminated Execute/ExecuteErrno duplication)

### Migration Guide

```go
// Before (v0.5.x):
err := ffi.CallFunction(cif, fn, &result, args)

// After (v0.6.0):
errno, err := ffi.CallFunction(cif, fn, &result, args)
// Or if errno not needed:
_, err := ffi.CallFunction(cif, fn, &result, args)
```

## [0.5.6] - 2026-07-05

### Fixed
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ sym, _ := ffi.GetSymbol(handle, "wgpuCreateInstance")

cif := &types.CallInterface{}
ffi.PrepareCallInterface(cif, types.DefaultCall, returnType, argTypes)
ffi.CallFunction(cif, sym, unsafe.Pointer(&result), args)
_, _ = ffi.CallFunction(cif, sym, unsafe.Pointer(&result), args)
```

---
Expand All @@ -37,6 +37,7 @@ ffi.CallFunction(cif, sym, unsafe.Pointer(&result), args)
| **Type-safe** | Runtime validation | 5 typed error types with `errors.As()` support |
| **Struct pass/return** | Full ABI | Args: INTEGER/SSE classification. Returns: ≤8B (RAX/XMM0), 9–16B (4 modes: RAX/XMM × RAX/XMM), >16B (sret) |
| **Variadic** | `printf`/`sprintf` | `PrepareVariadicCallInterface` — Apple ARM64 stack-force included |
| **errno** | Always captured | Thread-safe assembly-level capture — first pure-Go FFI on Linux |
| **Context** | Timeouts | `CallFunctionContext(ctx, ...)` cancellation |
| **Race detector** | `-race` compatible | `CGO_ENABLED=1 go test -race` works cleanly |
| **Tested** | 89% coverage | CI on Linux, Windows, macOS (CGO=0 and CGO=1) |
Expand Down Expand Up @@ -115,7 +116,7 @@ func main() {
strPtr := uintptr(unsafe.Pointer(unsafe.StringData(testStr)))
var length uint64

err = ffi.CallFunction(cif, strlen, unsafe.Pointer(&length), []unsafe.Pointer{unsafe.Pointer(&strPtr)})
_, err = ffi.CallFunction(cif, strlen, unsafe.Pointer(&length), []unsafe.Pointer{unsafe.Pointer(&strPtr)})
if err != nil {
panic(err)
}
Expand Down Expand Up @@ -153,7 +154,7 @@ err := ffi.PrepareVariadicCallInterface(
count := int64(3)
a1, a2, a3 := int64(10), int64(20), int64(30)
var result int64
ffi.CallFunction(&cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{
_, _ = ffi.CallFunction(&cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{
unsafe.Pointer(&count),
unsafe.Pointer(&a1),
unsafe.Pointer(&a2),
Expand Down Expand Up @@ -228,7 +229,7 @@ cb := ffi.NewCallback(func(status uint32, adapter uintptr, msg uintptr, ud uintp
close(done)
})

ffi.CallFunction(cif, wgpuRequestAdapter, nil, args)
_, _ = ffi.CallFunction(cif, wgpuRequestAdapter, nil, args)
<-done // Wait for GPU driver callback
```

Expand Down
30 changes: 23 additions & 7 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-05-25 | **Current Version**: v0.5.2 | **Strategy**: Benchmarks → Callbacks → ARM64 → Runtime → ABI → v1.0 LTS | **Milestone**: v0.5.2 (variadic) → v0.6.0 RegisterFunc/Builder → v1.0.0 LTS
**Last Updated**: 2026-07-12 | **Current Version**: v0.6.0 | **Strategy**: Benchmarks → Callbacks → ARM64 → Runtime → ABI → v1.0 LTS | **Milestone**: v0.6.0 (errno + stack-move fix) → v0.7.0 RegisterFunc/Builder → v1.0.0 LTS

---

Expand Down Expand Up @@ -141,11 +141,27 @@ v1.0.0 LTS → Long-term support release (2027 Q1)

**v0.5.2** = Variadic functions ✅ RELEASED (2026-05-25)
- **Variadic function support** — `PrepareVariadicCallInterface` with Apple ARM64 stack-force
- `go vet` clean — fixed dl_unix.go unsafe.Pointer warnings, syscall_linux_stub.s return signature
- `cmd/variadic-test` — standalone verification binary for Apple Silicon
- E2E variadic tests with gcc-compiled C test functions

**v0.6.0** = RegisterFunc + Builder API (2026 Q3)
**v0.5.3** = FreeBSD ARM64 ✅ RELEASED (2026-05-28)
- Build tag fix for FreeBSD ARM64 (8 platforms total)

**v0.5.4** = structs.HostLayout ✅ RELEASED (2026-06-15)
- ABI-safe struct layout for all assembly-interface structures

**v0.5.5** = Example fix + CI ✅ RELEASED (2026-06-15)
- Example avalue pointer bug fix, CI examples build verification

**v0.5.6** = Callback stack-move fix ✅ RELEASED (2026-07-05)
- Critical: `syscallArgs` moved to sync.Pool (goroutine stack-move safety)
- Discovered by @tie — `TestCallbackGrowStack` reproducer

**v0.6.0** = errno always-capture ✅ RELEASED (2026-07-12)
- **BREAKING**: `CallFunction` returns `(syscall.Errno, error)` — always captures C errno
- First pure-Go FFI with correct errno capture on Linux
- Assembly-level capture inside trampoline (thread-safe window)
- Platform support: `__errno_location` (Linux), `__error` (macOS/FreeBSD)

**v0.7.0** = RegisterFunc + Builder API (2026 Q3-Q4)
- RegisterFunc convenience API (ADR-008)
- Library struct + OpenLibraryBytes (ADR-009)
- NewFunc/Call/CallCtx ergonomic wrappers (ADR-009)
Expand All @@ -158,9 +174,9 @@ v1.0.0 LTS → Long-term support release (2027 Q1)

---

## 📊 Current Status (v0.5.2)
## 📊 Current Status (v0.6.0)

**Phase**: Variadic functions supported, go vet clean, planning v0.6.0 (RegisterFunc)
**Phase**: errno always-capture, stack-move fix, 8 platforms. Planning v0.7.0 (RegisterFunc)

**What Works**:
- ✅ Dynamic library loading (`LoadLibrary`, `GetSymbol`, `FreeLibrary`)
Expand Down
4 changes: 2 additions & 2 deletions cmd/variadic-test/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func testSumVariadic(lib unsafe.Pointer) bool {
}

var result int64
if err := ffi.CallFunction(&cif, sym, unsafe.Pointer(&result), avalue); err != nil {
if _, err := ffi.CallFunction(&cif, sym, unsafe.Pointer(&result), avalue); err != nil {
fmt.Fprintf(os.Stderr, "FAIL: CallFunction(sum_variadic): %v\n", err)
return false
}
Expand Down Expand Up @@ -188,7 +188,7 @@ func testTwoFixed(lib unsafe.Pointer) bool {
}

var result int64
if err := ffi.CallFunction(&cif, sym, unsafe.Pointer(&result), avalue); err != nil {
if _, err := ffi.CallFunction(&cif, sym, unsafe.Pointer(&result), avalue); err != nil {
fmt.Fprintf(os.Stderr, "FAIL: CallFunction(variadic_two_fixed): %v\n", err)
return false
}
Expand Down
5 changes: 4 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ Every goffi call traverses four layers:
┌──────────────────────────────────────────────┐
│ Layer 3: Assembly Wrapper │
│ Load registers per ABI (GP + SSE/FP) │
│ Call target function, save return values │
│ Call target function, capture errno, │
│ save return values │
└──────────────────┬───────────────────────────┘
Expand Down Expand Up @@ -74,6 +75,8 @@ ffi.PrepareCallInterface(cif, types.DefaultCall,
3. Calls our assembly wrapper

Since v0.5.6, `syscallArgs` is heap-allocated via `sync.Pool` — goroutine stacks may move during C→Go callbacks (`copystack`), and assembly on g0 holds the args pointer across the call. All ABI-boundary structs use `structs.HostLayout` (Go 1.23+) to guarantee C-compatible memory layout.

Since v0.6.0, `CallFunction` always captures C `errno` inside the assembly trampoline — the only thread-safe window (before `exitsyscall` can migrate the goroutine). Returns `(syscall.Errno, error)`. Uses `__errno_location` (Linux) / `__error` (macOS/FreeBSD) resolved via `//go:cgo_import_dynamic`.
4. Restores Go stack on return

We access it via `//go:linkname`:
Expand Down
1 change: 1 addition & 0 deletions docs/PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
- **Typical overhead**: ~100-115 ns (with arguments)
- **Overhead ratio**: ~400-500x vs direct Go call
- **Allocations**: 0 in steady state. `syscallArgs` is heap-allocated via `sync.Pool` for callback safety (goroutine stack may move during C→Go callbacks). Pool reuse eliminates per-call allocations after warmup.
- **errno capture**: +3-5 ns per call (always-on since v0.6.0). `CALL __errno_location` + `MOVL (AX), EAX` in assembly — captures errno before thread migration can lose it.

### 2. One-Time Costs

Expand Down
4 changes: 2 additions & 2 deletions examples/simple/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func main() {
args := []unsafe.Pointer{unsafe.Pointer(&cstr)}

var ret int32
if err := ffi.CallFunction(cif, sym, unsafe.Pointer(&ret), args); err != nil {
if _, err := ffi.CallFunction(cif, sym, unsafe.Pointer(&ret), args); err != nil {
fmt.Println("CallFunction error:", err)
return
}
Expand All @@ -80,7 +80,7 @@ func main() {
args := []unsafe.Pointer{unsafe.Pointer(&cstr)}

// Execute function call
err = ffi.CallFunction(cif, sym, nil, args)
_, err = ffi.CallFunction(cif, sym, nil, args)
if err != nil {
fmt.Println("CallFunction error:", err)
}
Expand Down
8 changes: 4 additions & 4 deletions ffi/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func BenchmarkGoffiOverhead(b *testing.B) {

b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = CallFunction(cif, sym, unsafe.Pointer(&result), nil)
_, _ = CallFunction(cif, sym, unsafe.Pointer(&result), nil)
}
}

Expand Down Expand Up @@ -96,7 +96,7 @@ func BenchmarkGoffiIntArgs(b *testing.B) {

b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = CallFunction(cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{
_, _ = CallFunction(cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{
unsafe.Pointer(&arg),
})
}
Expand Down Expand Up @@ -146,7 +146,7 @@ func BenchmarkGoffiStringOutput(b *testing.B) {

b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = CallFunction(cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{unsafe.Pointer(&strPtr)})
_, _ = CallFunction(cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{unsafe.Pointer(&strPtr)})
}
}

Expand Down Expand Up @@ -196,7 +196,7 @@ func BenchmarkGoffiMultipleArgs(b *testing.B) {

b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = CallFunction(cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{
_, _ = CallFunction(cif, sym, unsafe.Pointer(&result), []unsafe.Pointer{
unsafe.Pointer(&arg1),
unsafe.Pointer(&arg2),
})
Expand Down
14 changes: 10 additions & 4 deletions ffi/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@
"unsafe"

"github.com/go-webgpu/goffi/internal/arch"
gosyscall "github.com/go-webgpu/goffi/internal/syscall"
"github.com/go-webgpu/goffi/types"
)

// executeFunction calls a function through architecture-dependent mechanism
// executeFunction calls a function through the architecture-dependent mechanism,
// always capturing C errno inside the assembly trampoline.
func executeFunction(
cif *types.CallInterface,
fn unsafe.Pointer,
rvalue unsafe.Pointer,
avalue []unsafe.Pointer,
) error {
) (syscallErrno uintptr, err error) {
if arch.Registry.Caller == nil {
return types.ErrUnsupportedArchitecture
return 0, types.ErrUnsupportedArchitecture

Check warning on line 20 in ffi/call.go

View check run for this annotation

Codecov / codecov/patch

ffi/call.go#L20

Added line #L20 was not covered by tests
}
return arch.Registry.Caller.Execute(cif, fn, rvalue, avalue)
// ErrnoFnAddr returns the address of __errno_location/__error on Unix and 0
// on Windows. The assembly trampoline's conditional (TESTQ/CBZ) skips the
// errno capture when errnoFn is 0, so this is safe on all platforms.
errnoFn := gosyscall.ErrnoFnAddr()
return arch.Registry.Caller.Execute(cif, fn, rvalue, avalue, errnoFn)
}
4 changes: 2 additions & 2 deletions ffi/callback_cthread_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func TestCallback_FromCThread(t *testing.T) {
unsafe.Pointer(&argVal),
}
var rcCreate int32
if err := CallFunction(cifCreate, create, unsafe.Pointer(&rcCreate), avalueCreate); err != nil {
if _, err := CallFunction(cifCreate, create, unsafe.Pointer(&rcCreate), avalueCreate); err != nil {
t.Fatalf("CallFunction(pthread_create): %v", err)
}
if rcCreate != 0 {
Expand All @@ -153,7 +153,7 @@ func TestCallback_FromCThread(t *testing.T) {
unsafe.Pointer(&retvalAddr),
}
var rcJoin int32
if err := CallFunction(cifJoin, join, unsafe.Pointer(&rcJoin), avalueJoin); err != nil {
if _, err := CallFunction(cifJoin, join, unsafe.Pointer(&rcJoin), avalueJoin); err != nil {
t.Fatalf("CallFunction(pthread_join): %v", err)
}
if rcJoin != 0 {
Expand Down
2 changes: 1 addition & 1 deletion ffi/callback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ func TestCallbackGrowStack(t *testing.T) {
// trips vet's unsafeptr guard against conversions that hide a heap address from
// the GC, and the trampoline is code that is never GC-managed and never moved.
fn := *(*unsafe.Pointer)(unsafe.Pointer(&cb))
if err := CallFunction(&cif, fn, unsafe.Pointer(&ret), []unsafe.Pointer{unsafe.Pointer(&arg)}); err != nil {
if _, err := CallFunction(&cif, fn, unsafe.Pointer(&ret), []unsafe.Pointer{unsafe.Pointer(&arg)}); err != nil {
t.Error(err)
done <- ^uintptr(0)
return
Expand Down
10 changes: 5 additions & 5 deletions ffi/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func TestCallFunctionContext(t *testing.T) {

ctx := context.Background()
// IMPORTANT: avalue contains pointers TO the argument values
err := CallFunctionContext(ctx, cif, sym, unsafe.Pointer(&retVal), []unsafe.Pointer{unsafe.Pointer(&arg)})
_, err := CallFunctionContext(ctx, cif, sym, unsafe.Pointer(&retVal), []unsafe.Pointer{unsafe.Pointer(&arg)})
if err != nil {
t.Errorf("CallFunctionContext failed: %v", err)
}
Expand All @@ -101,7 +101,7 @@ func TestCallFunctionContext(t *testing.T) {
arg := unsafe.Pointer(unsafe.StringData(str))
var retVal int32

err := CallFunctionContext(ctx, cif, sym, unsafe.Pointer(&retVal), []unsafe.Pointer{unsafe.Pointer(&arg)})
_, err := CallFunctionContext(ctx, cif, sym, unsafe.Pointer(&retVal), []unsafe.Pointer{unsafe.Pointer(&arg)})
if err != context.Canceled {
t.Errorf("Expected context.Canceled, got %v", err)
}
Expand All @@ -116,14 +116,14 @@ func TestCallFunctionContext(t *testing.T) {
arg := unsafe.Pointer(unsafe.StringData(str))
var retVal int32

err := CallFunctionContext(ctx, cif, sym, unsafe.Pointer(&retVal), []unsafe.Pointer{unsafe.Pointer(&arg)})
_, err := CallFunctionContext(ctx, cif, sym, unsafe.Pointer(&retVal), []unsafe.Pointer{unsafe.Pointer(&arg)})
if err != context.DeadlineExceeded {
t.Errorf("Expected context.DeadlineExceeded, got %v", err)
}
})

t.Run("NilCIF", func(t *testing.T) {
err := CallFunctionContext(context.Background(), nil, sym, nil, nil)
_, err := CallFunctionContext(context.Background(), nil, sym, nil, nil)
var icErr *InvalidCallInterfaceError
if err == nil || err.(*InvalidCallInterfaceError).Field != "cif" {
t.Errorf("Expected InvalidCallInterfaceError for cif, got %v", err)
Expand All @@ -132,7 +132,7 @@ func TestCallFunctionContext(t *testing.T) {
})

t.Run("NilFunction", func(t *testing.T) {
err := CallFunctionContext(context.Background(), cif, nil, nil, nil)
_, err := CallFunctionContext(context.Background(), cif, nil, nil, nil)
if err == nil || err.(*InvalidCallInterfaceError).Field != "fn" {
t.Errorf("Expected InvalidCallInterfaceError for fn, got %v", err)
}
Expand Down
Loading
Loading