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

### Changed
- **Documented: a Go address must stay pointer-typed all the way to `avalue`.** The compiler keeps a `uintptr`'s target alive and unmoved only in the argument list of a call to a function implemented in assembly or marked `//go:uintptrescapes`; `CallFunction` and a binding's own wrapper functions are ordinary Go functions. A local whose address travels as a `uintptr` — for example through a wrapper parameter `ppData uintptr` — can stay on the goroutine stack, and when the stack moves during the call C writes into the old stack (found in wgpu's Vulkan `vkMapMemory` binding). The `CallFunctionContext` documentation and `SECURITY.md` now state the rule, the out-pointer pattern and when `runtime.Pinner` is needed, and that memory reachable from `rvalue` and `avalue` through pointer-typed values stays in place for the call. A new test lets C write through an out-pointer after a callback has moved the goroutine stack.
- **Documentation brought in line with the code.** The `ffi` package documentation now has a compiling example (`CallFunction` returns the errno first) and states that a prepared `CallInterface` may be shared by goroutines; it no longer starts with a file comment from `callback.go` (three callback files carried `// Package ffi` comments). The `types` package has a package comment. README, `docs/ARCHITECTURE.md`, `docs/PERFORMANCE.md`, `CONTRIBUTING.md`, `SECURITY.md` and `ROADMAP.md` now describe the current state: the Linux/FreeBSD `CGO_ENABLED=1` link limitation with other cgo code and its workarounds ([#72](https://github.com/go-webgpu/goffi/issues/72)) — README previously suggested `CGO_ENABLED=1` for exactly that case; the FreeBSD `-gcflags` needed with `CGO_ENABLED=0`; Windows amd64 calls through `syscall.SyscallN` rather than goffi assembly; callback signature rules per platform (the README callback example panicked on Windows); `avalue` slices escape, so building one per call costs one allocation; default builds are dynamically linked, not static; the GitHub Flow contribution workflow (there is no `develop` branch), license headers, and lint on every target; supported versions and reporting in `SECURITY.md`; the v0.6.8 and v0.7.0 plans and the libvips stress-test target ([#71](https://github.com/go-webgpu/goffi/issues/71)) in `ROADMAP.md`.

### Fixed
- **System V amd64 (Linux, macOS, FreeBSD): a struct argument over 72 bytes panicked.** goffi copied stack arguments into a fixed area of 9 words and checked its size only after writing, so a by-value struct of more than 72 bytes (webgpu.h's 96-byte `WGPUAdapterInfo`), or several large structs together, ended in `index out of range [15] with length 15` inside `CallFunction`. `PrepareCallInterface` did not catch it either: it counted a struct over 16 bytes as using no registers and no stack. The stack area is now sized from the arguments, as libffi does, up to 64 KiB (`amd64.MaxStackBytes`; libffi has no limit, the bound keeps the copy well inside the system stack the call runs on). An argument list that needs more is an error at `PrepareCallInterface` and, for a `CallInterface` filled in by hand, at the call — never a panic. The same call-time check now guards the Windows amd64 call path. ([#112](https://github.com/go-webgpu/goffi/issues/112))
- **System V amd64: a 9–16 byte struct argument was split between a register and the stack.** When fewer registers were left than the struct needs — a `{int64, int64}` after five integer arguments, a `{double, double}` after seven doubles, an `{int64, double}` after six integers — its first eightbyte went into the last register and the rest onto the stack, and the arguments after it were shifted. The ABI passes such an argument on the stack whole and leaves the register to later arguments (psABI §3.2.3; libffi `src/x86/ffi64.c`). The callee received wrong values without any error. Calls and Unix amd64 callbacks now follow the ABI; one argument layout (`internal/arch/amd64/layout_sysv.go`) serves `PrepareCallInterface`, the call and the callback decoder. ([#112](https://github.com/go-webgpu/goffi/issues/112))
- **System V amd64: nested struct members were classified as INTEGER.** An eightbyte holding floating-point members of a nested struct — `struct { struct { float x, y; } a, b; }`, arrays of floats in callbacks — was passed in a general-purpose register instead of XMM, and such a struct result was read from RAX:RDX instead of XMM0:XMM1. Members are now classified by their offsets at any depth, as libffi does. ([#112](https://github.com/go-webgpu/goffi/issues/112))
- **Unix amd64 callbacks: arguments past 114 stack words indexed past the frame.** `NewCallback` now counts the stack words of the signature and panics with a clear message past the same 64 KiB bound as calls.
- **Unix amd64: crash when a goroutine exits locked to its thread after a call.** `syscallN` saved its argument pointer above its own frame, into its caller's frame on the g0 stack. A goroutine that made a goffi call and then exited without `runtime.UnlockOSThread` crashed the process with SIGSEGV when its thread exited. Linux, macOS and FreeBSD amd64 were affected; arm64 and Windows were not. ([#108](https://github.com/go-webgpu/goffi/issues/108), [#109](https://github.com/go-webgpu/goffi/pull/109), reported and fixed by @aardwulf89)

## [0.6.7] - 2026-10-18
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ func main() {
}

// Call strlen — avalue elements are pointers TO argument values.
// Keep pointer arguments in unsafe.Pointer variables, never uintptr.
// Keep pointer arguments in unsafe.Pointer variables, never uintptr —
// in your binding wrappers' parameters too (see SECURITY.md).
testStr := "Hello, goffi!\x00"
strPtr := unsafe.Pointer(unsafe.StringData(testStr))
var length uint64
Expand Down
26 changes: 25 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,34 @@ p := unsafe.Pointer(&buf[0])
_, _ = ffi.CallFunction(&cif, fn, nil, []unsafe.Pointer{unsafe.Pointer(&p)})
runtime.KeepAlive(buf)

// ❌ BAD - a binding wrapper with a uintptr parameter. Writing the conversion
// in the caller's argument list does not help: only calls to functions
// implemented in assembly (or marked //go:uintptrescapes) keep a uintptr's
// target in place, and MapMemory is an ordinary Go function. mapped stays on
// the stack, the stack moves, and C writes the mapping into the old stack.
func MapMemory(cif *types.CallInterface, fn unsafe.Pointer, ppData uintptr) {
_, _ = ffi.CallFunction(cif, fn, nil, []unsafe.Pointer{unsafe.Pointer(&ppData)})
}
var mapped unsafe.Pointer
MapMemory(&cif, fn, uintptr(unsafe.Pointer(&mapped)))

// ✅ GOOD - out-pointer (C type void **pp): keep &mapped in an
// unsafe.Pointer slot and take the parameter as unsafe.Pointer.
func MapMemory(cif *types.CallInterface, fn unsafe.Pointer, ppData unsafe.Pointer) {
_, _ = ffi.CallFunction(cif, fn, nil, []unsafe.Pointer{unsafe.Pointer(&ppData)})
}
var mapped unsafe.Pointer
MapMemory(&cif, fn, unsafe.Pointer(&mapped))

// ✅ If C keeps the pointer after the call returns, or a callback reads it
// later, pin the memory (runtime.Pinner) or allocate it on the C side.
// later, or the Go address must go into a uintptr-typed C struct field,
// pin the memory (runtime.Pinner) or allocate it on the C side.
```

goffi keeps memory reachable from `rvalue` and `avalue` through pointer-typed values alive and in place for the
call (it is heap memory: `avalue` escapes). A `uintptr` breaks that chain wherever it appears between the address
and `avalue`.

C strings must be NUL-terminated (`"hello\x00"`); Go strings are not.

### 2. Race Conditions
Expand Down
24 changes: 12 additions & 12 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ Hand-written assembly for System V AMD64 and AAPCS64. The function receives a st

```asm
TEXT syscallN(SB), NOSPLIT|NOFRAME, $0
// R11 = args struct pointer
// Load 6 GP registers: RDI, RSI, RDX, RCX, R8, R9
// R12 = args struct pointer (callee-saved across the call)
// Reserve nstack words (rounded to 16 bytes) and copy the stack arguments
// Load 8 SSE registers: XMM0-XMM7
// Push stack-spill args if needed
// Load 6 GP registers: RDI, RSI, RDX, RCX, R8, R9
CALL R10 // call target function
// Save RAX (int return), RDX (second return), XMM0 (float return)
// Save RAX (int return), RDX (second return), XMM0/XMM1 (float returns)
```

**Win64** — no goffi assembly. `internal/arch/amd64/call_windows.go` packs the arguments into `uintptr` slots (a hidden result pointer first when the result needs one) and calls `syscall.SyscallN`, which loads RCX, RDX, R8, R9 and XMM0–XMM3 from those slots and provides the 32-byte shadow space.
Expand Down Expand Up @@ -141,13 +141,11 @@ ABI rules for passing structs as arguments depend on size and platform:

Per §3.2.3, each struct is classified by its eightbytes (8-byte chunks):

- **≤ 8 bytes**: single eightbyte. If all fields are float/double → SSE (XMM register). Otherwise → INTEGER (GP register). INTEGER wins over SSE within the same eightbyte (merge rule).
- **9-16 bytes**: two eightbytes, each classified independently. First 8 bytes → GP or XMM. Remaining bytes → GP or XMM. Both classifications use the same INTEGER-wins merge rule.
- **\> 16 bytes**: MEMORY class. Caller copies struct bytes onto the stack in 8-byte chunks.
- **≤ 8 bytes**: single eightbyte. If all fields in it are float/double → SSE (XMM register). Otherwise → INTEGER (GP register). INTEGER wins over SSE within the same eightbyte (merge rule). Fields of nested structs are classified by their offsets, at any depth.
- **9-16 bytes**: two eightbytes, each classified independently with the same rule.
- **\> 16 bytes**: MEMORY class. Caller copies struct bytes onto the stack in 8-byte words.

Implementation in `internal/arch/amd64/call_unix.go`, helpers in `classification.go`:
- `isStructAllFloats(t)` — returns true if all members are float/double
- `classifyEightbyte(t, startOff, endOff)` — per-eightbyte SSE classification with merge rule
`internal/arch/amd64/layout_sysv.go` assigns every argument its registers or stack words, as libffi does (`ffi_prep_cif_machdep` and `ffi_call_int` in `src/x86/ffi64.c`): an argument goes in registers only if every one of its eightbytes gets a register of its class; otherwise the whole argument goes on the stack and the registers left stay free for later arguments. `PrepareCallInterface`, the call (`call_unix.go`) and the callback decoder (`ffi/callback.go`) use the same allocator. The stack area is sized from the arguments, up to 64 KiB (`amd64.MaxStackBytes`); a call that needs more returns `ErrTooManyArguments` at prepare time, or an error at call time for a hand-built `CallInterface`. Eightbyte classification: `classifyEightbyte` in `classification.go`.

### Win64 (Windows AMD64)

Expand All @@ -172,6 +170,7 @@ The ABI is verified against real C callers and callees: `TestMain` in `ffi/struc
- byte structs of 1–9, 12, 15, 16, 17, 24 and 32 bytes, as argument and as result, including a nil result buffer where a hidden result pointer is needed (`struct_sizes_e2e_test.go`);
- 9–16 byte results in each register pair (`struct_return_e2e_amd64_test.go`) and struct results of a hand-built `CallInterface` (`unprepared_cif_e2e_test.go`);
- stack arguments, narrow and `int` integers, variadic calls, the one-value-per-argument rule (`stack_args_e2e_test.go`, `narrow_int_e2e_test.go`, `int_type_e2e_test.go`, `variadic_e2e_test.go`, `avalue_count_e2e_test.go`);
- the stack layout: by-value structs of 40, 64 and 96 bytes (webgpu.h shapes), 24 stack words, 9–16 byte structs that must go to the stack whole, nested float structs, too many arguments as an error, in calls and callbacks (`stack_layout_e2e_test.go`); an out-pointer written after the goroutine stack moved (`pointer_contract_e2e_test.go`);
- callbacks called from C: float arguments and results, stack arguments, foreign threads (`callback_float_e2e_test.go`, `callback_stack_e2e_test.go`, `callback_cthread_test.go`).

The test library is built with `$CC` (default `gcc`; the FreeBSD CI job uses the base system `cc`). The tests skip only on Android (test binaries run on the device), in a `goffi_static` build, or when there is no C compiler for the test's architecture; with `GOFFI_REQUIRE_TESTLIB=1`, which CI sets wherever such a compiler exists, a missing compiler fails the run as well. A library that fails to build or load always fails the run. CI runs them on Linux, macOS and Windows (amd64 and arm64) and on FreeBSD amd64; on windows-11-arm they skip, because the runner only has an x86-64 MinGW compiler.
Expand All @@ -182,10 +181,11 @@ The test library is built with `$CC` (default `gcc`; the FreeBSD CI job uses the

When C code calls a goffi callback with struct arguments, the callback dispatch (`callbackWrap` in `callback.go`) reconstructs struct values from CPU registers and stack using `reflect.Type`:

- **≤ 8 bytes**: single eightbyte in GP or XMM register. `isStructAllFloats()` determines classification (recursive, supports nested structs).
- **9-16 bytes**: two eightbytes, each classified independently via `classifyEightbyte()` using `reflect.StructField.Offset`.
- **≤ 16 bytes**: one or two eightbytes, each classified by `classifyEightbyte()` from the fields' offsets (nested structs and arrays included). If the registers left cannot take every eightbyte, the C caller put the whole struct on the stack, and the callback reads it from there.
- **\> 16 bytes**: MEMORY class — C caller copies bytes onto stack. Callback reads consecutive stack slots directly. No assembly changes needed.

Registers and stack words are assigned by the allocator the call path uses (`internal/arch/amd64/layout_sysv.go`). `NewCallback` counts the signature's stack words and panics past 64 KiB.

Classification uses `reflect.Type` (not `types.TypeDescriptor`) since callback signatures are Go functions registered via `NewCallback()`.

**Limitations**: callback struct args supported on AMD64 Unix only. ARM64 and Windows callbacks do not yet support struct arguments.
Expand Down
7 changes: 7 additions & 0 deletions ffi/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,12 @@ func executeFunction(
// 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()
// The interface call makes rvalue and avalue escape ("leaking param" in
// -gcflags=-m), so everything reachable from them through pointer-typed
// values is heap memory that does not move while C uses it: the contract
// the CallFunctionContext documentation states. A change that lets the
// compiler see through this call (devirtualization, inlining Execute)
// must keep that escape, or the arch code would read stack addresses that
// a stack move invalidates.
return arch.Registry.Caller.Execute(cif, fn, rvalue, avalue, errnoFn)
}
Loading
Loading