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
42 changes: 39 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ jobs:
fi
echo "All files are properly formatted ✓"

# Cross-compilation - Verify all 7 supported platforms compile
# Cross-compilation - Verify all 8 supported desktop platforms compile
cross-compile:
name: Cross-Compile
runs-on: ubuntu-latest
Expand Down Expand Up @@ -164,6 +164,41 @@ jobs:
fi
echo "✅ All examples compile successfully"

# Android arm64/API 29+ source, ABI, cgo-mode, and ELF regression gates.
# Keep both supported Go patch lines: runtime/cgo startup and TLS details
# are part of this platform contract, so a single floating toolchain is not
# sufficient evidence.
android-cross:
name: Android arm64 (Go ${{ matrix.go }})
runs-on: ubuntu-latest
needs: [lint, formatting]
strategy:
fail-fast: false
matrix:
go: ['1.25.12', '1.26.5']
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: Set up Android SDK
uses: android-actions/setup-android@v3

- name: Install Android NDK r29
shell: bash
run: |
yes | sdkmanager --licenses >/dev/null || true
sdkmanager "ndk;29.0.14206865"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"

- name: Check Android arm64
run: scripts/check-android-arm64.sh

# Unit tests - Platform-specific (Linux + Windows + macOS AMD64)
# Tested under both CGO_ENABLED=0 (fakecgo path) and CGO_ENABLED=1 (real
# runtime/cgo path). Both modes must pass identically; CGO_ENABLED=0 is the
Expand Down Expand Up @@ -363,7 +398,7 @@ jobs:
# Final status - All checks passed
ci-success:
name: CI Success
needs: [lint, formatting, cross-compile, test, benchmarks, quality-gate]
needs: [lint, formatting, cross-compile, android-cross, test, benchmarks, quality-gate]
runs-on: ubuntu-latest
if: success()
steps:
Expand All @@ -372,7 +407,8 @@ jobs:
echo "✅ All CI checks passed!"
echo "✅ Lint: PASSED"
echo "✅ Formatting: PASSED"
echo "✅ Cross-Compile: PASSED (7 platforms)"
echo "✅ Cross-Compile: PASSED (8 desktop targets)"
echo "✅ Android arm64: PASSED (API 29+, Go 1.25/1.26, cgo=0/1)"
echo "✅ Tests: PASSED (CGO_ENABLED=0 and CGO_ENABLED=1)"
echo " - Linux AMD64 (ubuntu-latest)"
echo " - Windows AMD64 (windows-latest)"
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ _, _ = ffi.CallFunction(cif, sym, unsafe.Pointer(&result), args)
|---|---------|---------|
| **Zero CGO** | Pure Go | No C compiler needed. `go get` and build. |
| **Fast** | 88–114 ns/op | Pre-computed CIF, zero per-call allocations |
| **Cross-platform** | 8 targets | Windows, Linux, macOS, FreeBSD × AMD64 + ARM64 |
| **Callbacks** | C→Go safe | `crosscall2` integration, struct args, works from any C thread |
| **Cross-platform** | 8 desktop targets + Android preview | Windows, Linux, macOS, FreeBSD × AMD64 + ARM64; Android arm64/API 29+ candidate pending physical-device startup proof |
| **Callbacks** | C→Go safe where validated | `crosscall2` integration on desktop targets; Android callbacks fail explicitly until a physical-thread proof exists |
| **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 |
Expand All @@ -46,6 +46,11 @@ _, _ = ffi.CallFunction(cif, sym, unsafe.Pointer(&result), args)

## Quick Start

Android arm64/API 29+ is a preview candidate in both CGO modes. Cross-build,
ABI, and ELF probes pass, but physical-device startup proof is still pending;
see [docs/ANDROID.md](docs/ANDROID.md) for the runtime ABI, NDK probe, and the
intentional callback limitation.

### Installation

```bash
Expand Down Expand Up @@ -276,7 +281,7 @@ if err != nil {
| Context support | Timeouts/cancellation | No | No |
| C-thread callbacks | crosscall2 | crosscall2 | Full |
| String/bool/slice args | Raw pointers only | Auto-marshaling | Full |
| Platform breadth | 8 targets | 8 GOARCH / 20+ OS×ARCH | All |
| Platform breadth | 8 desktop targets + Android preview | 8 GOARCH / 20+ OS×ARCH | All |
| AMD64 overhead | 88–114 ns | Not published | ~140 ns (Go 1.26 claims ~30% reduction) |

**Choose goffi** for GPU/real-time workloads: struct passing, zero per-call overhead, callback float returns, typed errors.
Expand Down
62 changes: 62 additions & 0 deletions docs/ANDROID.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Android arm64 preview candidate

goffi carries an Android arm64/API 29+ preview candidate. Cross-build, ABI,
and ELF probes pass, but physical-device startup validation is still required
before this can be described as released Android support.

The implementation follows the pinned Go runtime's Android AAPCS64 startup
contract. The four-argument `_cgo_init` entry point and TLS setup are
irreducible: the runtime passes `(g, setg_gcc, &runtime.tls_g, TLS base)` before
ordinary `runtime.cgocall` is available, then reads the Go `g` pointer from
Bionic's `TLS_SLOT_APP` (slot 2). The fakecgo trampoline must preserve all four
registers, validate API/TLS first, and use Bionic's LP64 pthread and signal
layouts; a generic Linux startup path would corrupt runtime state before Go
could report an error.

Both build modes are candidate build surfaces:

```sh
# No C compiler is needed for this mode.
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./...

# For applications that already use cgo, point CC at the API-29 NDK driver.
CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android29-clang" \
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./...
```

The cgo=0 path uses direct Bionic `libc.so`/`libdl.so` dynamic imports and the
fakecgo startup path. The cgo=1 path uses small NDK C wrappers so external
linking never emits an AAPCS64 branch relocation to a `cgo_import_dynamic`
symbol. Both paths reject glibc sonames and `__errno_location`.

`runtime.iscgo` is intentionally true in the cgo=0 path. Android builds also
satisfy Go's `linux` build term, so the shared `iscgo.go`, `callbacks.go`, and
`setenv.go` wiring is selected. `runtime.cgocall` rejects ordinary Unix targets
when `iscgo` is false; when it is true, the runtime also selects its cgo-aware
thread, TLS, signal, traceback, and extra-M paths. Android fakecgo supplies the
init, thread-start, environment, pthread-key, and bind hooks those paths expect.
This runtime wiring is separate from goffi's public callback policy.

Android callback trampolines are deliberately unavailable. `ffi.NewCallback`
panics with a stable message instead of exposing a pointer whose foreign-thread
startup path has not been validated on a physical device. Vulkan/WebGPU users
should use polling or an application-owned native callback bridge until that
evidence exists.

Dynamic-library handles are retained for process lifetime. `RTLD_NOW | RTLD_LOCAL
| RTLD_NODELETE` makes that policy explicit, and `FreeLibrary` is safe to call
but does not unload code that may still have function pointers in use.

## Regression probe

The NDK header, Go layout, cgo=0/cgo=1 cross-build, and ELF dependency checks
are reproducible without a device:

```sh
ANDROID_NDK_HOME=/path/to/android-ndk-r29 scripts/check-android-arm64.sh
```

The audited source/ABI matrix is Go 1.25.12 and Go 1.26.5 with Android NDK
r29 (`29.0.14206865`). Keep both Go lines in CI when runtime startup files or
TLS offsets change upstream. Passing this probe is not physical-device
startup evidence.
14 changes: 14 additions & 0 deletions ffi/callback_android_arm64.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The Goffi Authors

//go:build android && arm64

package ffi

// NewCallback is intentionally unavailable on Android until the foreign
// thread callback path has physical arm64 evidence. Failing before inspecting
// or retaining fn prevents callers from accidentally passing a bogus pointer
// into a Vulkan driver.
func NewCallback(any) uintptr {
panic("ffi: callbacks are unsupported on Android")
}
18 changes: 18 additions & 0 deletions ffi/callback_android_arm64_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The Goffi Authors

//go:build android && arm64

package ffi

import "testing"

func TestNewCallbackFailsExplicitlyOnAndroid(t *testing.T) {
defer func() {
got := recover()
if got != "ffi: callbacks are unsupported on Android" {
t.Fatalf("NewCallback panic = %v, want stable unsupported message", got)
}
}()
NewCallback(func() {})
}
2 changes: 1 addition & 1 deletion ffi/callback_arm64.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build (linux || darwin || freebsd) && arm64
//go:build ((linux && !android) || darwin || freebsd) && arm64

// Package ffi provides callback support for Foreign Function Interface (ARM64 Unix version).
// This file implements Go function registration as C callbacks using
Expand Down
2 changes: 1 addition & 1 deletion ffi/callback_arm64.s
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build (linux || darwin || freebsd) && arm64
//go:build ((linux && !android) || darwin || freebsd) && arm64

#include "textflag.h"
#include "go_asm.h"
Expand Down
2 changes: 1 addition & 1 deletion ffi/callback_cthread_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//go:build (linux || darwin || freebsd) && (amd64 || arm64)
//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64)

package ffi

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

package ffi

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

package ffi

Expand Down
54 changes: 54 additions & 0 deletions ffi/dl_android.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The Goffi Authors

//go:build android && arm64

package ffi

import (
"fmt"
"unsafe"

"github.com/go-webgpu/goffi/internal/dl"
)

// LoadLibrary loads a public Android shared library with eager, private
// symbol resolution. Bionic keeps the object mapped after dlclose because
// goffi's function pointers have process lifetime. Android support is
// arm64/API 29+ only.
func LoadLibrary(name string) (unsafe.Pointer, error) {
handle, err := dl.Dlopen(name, dl.RTLD_NOW|dl.RTLD_LOCAL|dl.RTLD_NODELETE)
if err != nil {
return nil, &LibraryError{Operation: "load", Name: name, Err: err}
}
// Reinterpret the opaque loader value without a uintptr-to-pointer
// conversion, which would make go vet assume a hidden Go heap pointer.
return *(*unsafe.Pointer)(unsafe.Pointer(&handle)), nil
}

// GetSymbol retrieves a function or data pointer from an Android library.
func GetSymbol(handle unsafe.Pointer, name string) (unsafe.Pointer, error) {
fnPtr, err := dl.Dlsym(uintptr(handle), name)
if err != nil {
return nil, &LibraryError{Operation: "symbol", Name: name, Err: err}
}
if fnPtr == 0 {
return nil, &LibraryError{
Operation: "symbol",
Name: name,
Err: fmt.Errorf("symbol not found"),
}
}
// dlsym returns an address in native code, not a Go heap pointer. Preserve
// its bits through the same vet-safe representation used by the Unix path.
return *(*unsafe.Pointer)(unsafe.Pointer(&fnPtr)), nil
}

// FreeLibrary accepts a handle for API symmetry. internal/dl deliberately
// retains Android mappings for process lifetime, so this is safe to defer.
func FreeLibrary(handle unsafe.Pointer) error {
if handle == nil {
return nil
}
return dl.Dlclose(uintptr(handle))
}
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 || freebsd) && (amd64 || arm64)
//go:build ((linux && !android) || freebsd) && (amd64 || arm64)

// Unix library loading via dlopen - OUR OWN implementation (NO dependencies!)
//
Expand Down
13 changes: 9 additions & 4 deletions ffi/struct_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ import (
var structTestLib unsafe.Pointer

func TestMain(m *testing.M) {
if err := buildStructTestLib(); err != nil {
// If gcc not available, skip struct e2e tests gracefully.
// Other tests still run.
structTestLib = nil
// Android test binaries run on-device, where invoking a host compiler is
// neither meaningful nor available. Keep the pure validation tests active
// and let only the host-built shared-library cases skip via requireStructLib.
if runtime.GOOS != "android" {
if err := buildStructTestLib(); err != nil {
// If gcc is not available, skip struct e2e tests gracefully.
// Other tests still run.
structTestLib = nil
}
}
code := m.Run()
if structTestLib != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/arch/arm64/abi_capture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func captureCall(t *testing.T, argTypes []*types.TypeDescriptor, args []unsafe.P
}

var impl Implementation
if err := impl.Execute(cif, fnPtr, nil, args); err != nil {
if _, err := impl.Execute(cif, fnPtr, nil, args, 0); err != nil {
t.Fatalf("Execute failed: %v", err)
}

Expand Down
23 changes: 23 additions & 0 deletions internal/dl/dl_android.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 The Goffi Authors

//go:build android && arm64

// Android/Bionic dynamic-loader declarations.
package dl

// Android exposes the POSIX dlfcn entry points from libdl.so. It does not
// provide glibc's libdl.so.2 soname, and RTLD_NODELETE is the supported way to
// keep function pointers valid when the caller retains them for the process
// lifetime.

const (
RTLD_LAZY = 0x00001
RTLD_NOW = 0x00002
RTLD_GLOBAL = 0x00100
RTLD_LOCAL = 0x00000
RTLD_NODELETE = 0x01000
)

// RTLD_DEFAULT is Android's default lookup pseudo-handle.
const RTLD_DEFAULT = 0x00000
Loading
Loading