diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1606c4..1b308c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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: @@ -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)" diff --git a/README.md b/README.md index 0398abf..c366b9f 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 @@ -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. diff --git a/docs/ANDROID.md b/docs/ANDROID.md new file mode 100644 index 0000000..530cc08 --- /dev/null +++ b/docs/ANDROID.md @@ -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. diff --git a/ffi/callback_android_arm64.go b/ffi/callback_android_arm64.go new file mode 100644 index 0000000..29f2d8b --- /dev/null +++ b/ffi/callback_android_arm64.go @@ -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") +} diff --git a/ffi/callback_android_arm64_test.go b/ffi/callback_android_arm64_test.go new file mode 100644 index 0000000..eb0c4ea --- /dev/null +++ b/ffi/callback_android_arm64_test.go @@ -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() {}) +} diff --git a/ffi/callback_arm64.go b/ffi/callback_arm64.go index 931324f..d3edee8 100644 --- a/ffi/callback_arm64.go +++ b/ffi/callback_arm64.go @@ -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 diff --git a/ffi/callback_arm64.s b/ffi/callback_arm64.s index 81740d9..81821a7 100644 --- a/ffi/callback_arm64.s +++ b/ffi/callback_arm64.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 #include "textflag.h" #include "go_asm.h" diff --git a/ffi/callback_cthread_test.go b/ffi/callback_cthread_test.go index 6ef7069..4bd79c2 100644 --- a/ffi/callback_cthread_test.go +++ b/ffi/callback_cthread_test.go @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && (amd64 || arm64) +//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) package ffi diff --git a/ffi/callback_struct_args_test.go b/ffi/callback_struct_args_test.go index 7c99067..82c0201 100644 --- a/ffi/callback_struct_args_test.go +++ b/ffi/callback_struct_args_test.go @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && amd64 +//go:build ((linux && !android) || darwin || freebsd) && amd64 package ffi diff --git a/ffi/callback_test.go b/ffi/callback_test.go index d80904b..9b89b08 100644 --- a/ffi/callback_test.go +++ b/ffi/callback_test.go @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && (amd64 || arm64) +//go:build ((linux && !android) || darwin || freebsd) && (amd64 || arm64) package ffi diff --git a/ffi/dl_android.go b/ffi/dl_android.go new file mode 100644 index 0000000..c957e24 --- /dev/null +++ b/ffi/dl_android.go @@ -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)) +} diff --git a/ffi/dl_unix.go b/ffi/dl_unix.go index 9e92913..f30ceee 100644 --- a/ffi/dl_unix.go +++ b/ffi/dl_unix.go @@ -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!) // diff --git a/ffi/struct_e2e_test.go b/ffi/struct_e2e_test.go index a80d240..3e98234 100644 --- a/ffi/struct_e2e_test.go +++ b/ffi/struct_e2e_test.go @@ -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 { diff --git a/internal/arch/arm64/abi_capture_test.go b/internal/arch/arm64/abi_capture_test.go index 5275eea..b29bab8 100644 --- a/internal/arch/arm64/abi_capture_test.go +++ b/internal/arch/arm64/abi_capture_test.go @@ -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) } diff --git a/internal/dl/dl_android.go b/internal/dl/dl_android.go new file mode 100644 index 0000000..dbcdc57 --- /dev/null +++ b/internal/dl/dl_android.go @@ -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 diff --git a/internal/dl/dl_android_cgo.go b/internal/dl/dl_android_cgo.go new file mode 100644 index 0000000..8adfa8c --- /dev/null +++ b/internal/dl/dl_android_cgo.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build android && cgo && arm64 + +package dl + +/* +#cgo android LDFLAGS: -ldl +#include +#include +#include +#include + +typedef struct { + void* value; + char* error; +} goffi_android_dl_result; + +static void goffi_android_capture_error(goffi_android_dl_result* result) { + const char* message = dlerror(); + result->error = message == NULL ? NULL : strdup(message); +} + +static void goffi_android_dlopen(const char* path, int mode, goffi_android_dl_result* result) { + result->value = dlopen(path, mode); + result->error = NULL; + if (result->value == NULL) { + goffi_android_capture_error(result); + } +} + +static void goffi_android_dlsym(uintptr_t handle, const char* name, goffi_android_dl_result* result) { + // Clear a prior loader error before resolving this symbol. + dlerror(); + result->value = dlsym((void*)handle, name); + result->error = NULL; + if (result->value == NULL) { + goffi_android_capture_error(result); + } +} + +static void goffi_android_free(char* value) { + free(value); +} + +static uintptr_t goffi_android_errno_addr(void) { + return (uintptr_t)dlsym(RTLD_DEFAULT, "__errno"); +} +*/ +import "C" + +import "fmt" + +// Dlopen uses an ordinary cgo call on Android when cgo is enabled. This +// avoids passing an external-linker SDYNIMPORT through a hand-written branch +// relocation while retaining the same API and error semantics. +func Dlopen(path string, mode int) (uintptr, error) { + cpath := C.CString(path) + defer C.goffi_android_free(cpath) + + var result C.goffi_android_dl_result + C.goffi_android_dlopen(cpath, C.int(mode), &result) + if result.value == nil { + return 0, fmt.Errorf("dlopen failed: %s", takeAndroidDlerror(result.error)) + } + return uintptr(result.value), nil +} + +// Dlsym returns the address of a symbol in a loaded Android library. +func Dlsym(handle uintptr, name string) (uintptr, error) { + cname := C.CString(name) + defer C.goffi_android_free(cname) + + var result C.goffi_android_dl_result + C.goffi_android_dlsym(C.uintptr_t(handle), cname, &result) + if result.value == nil { + return 0, fmt.Errorf("dlsym failed: %s", takeAndroidDlerror(result.error)) + } + return uintptr(result.value), nil +} + +// Dlclose intentionally retains process-lifetime mappings, matching the +// no-cgo implementation's RTLD_NODELETE policy. +func Dlclose(uintptr) error { return nil } + +func takeAndroidDlerror(msg *C.char) string { + if msg == nil { + return "unknown error" + } + defer C.goffi_android_free(msg) + return C.GoString(msg) +} + +// AndroidErrnoAddr resolves Bionic's __errno accessor for the syscall package. +// The returned address is called by syscallN immediately after the target C +// function, while execution remains on the same OS thread. +func AndroidErrnoAddr() uintptr { + return uintptr(C.goffi_android_errno_addr()) +} diff --git a/internal/dl/dl_android_nocgo.go b/internal/dl/dl_android_nocgo.go new file mode 100644 index 0000000..758182d --- /dev/null +++ b/internal/dl/dl_android_nocgo.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build android && !cgo && arm64 + +package dl + +import ( + "fmt" + "structs" + "unsafe" +) + +// Keep the no-cgo path on the Go linker's dynamic-import route. The cgo path +// uses ordinary C wrappers instead; external Android linking cannot resolve +// AAPCS64 branch relocations directly to SDYNIMPORT symbols. +// +//go:cgo_import_dynamic goffi_android_dlopen dlopen "libdl.so" +//go:cgo_import_dynamic goffi_android_dlsym dlsym "libdl.so" +//go:cgo_import_dynamic goffi_android_dlerror dlerror "libdl.so" +//go:cgo_import_dynamic goffi_android_strdup strdup "libc.so" +//go:cgo_import_dynamic goffi_android_free free "libc.so" + +// Force dependencies on Android's public loader and C libraries. +//go:cgo_import_dynamic _ _ "libdl.so" +//go:cgo_import_dynamic _ _ "libc.so" + +//go:linkname runtime_cgocall runtime.cgocall +//go:noescape +func runtime_cgocall(fn uintptr, arg unsafe.Pointer) int32 + +// The loader wrappers call dlerror and strdup before returning from the same +// runtime.cgocall. dlerror state is thread-local, so querying it from a second +// cgocall could observe a different OS thread. +type androidDlopenArgs struct { + _ structs.HostLayout + fn uintptr + errorFn uintptr + strdupFn uintptr + path *byte + mode int + result uintptr + error uintptr +} + +type androidDlsymArgs struct { + _ structs.HostLayout + fn uintptr + errorFn uintptr + strdupFn uintptr + handle uintptr + name *byte + result uintptr + error uintptr +} + +type androidFreeArgs struct { + _ structs.HostLayout + fn uintptr + ptr uintptr +} + +//go:linkname android_dlopen_stub android_dlopen_stub +var android_dlopen_stub byte + +//go:linkname android_dlsym_stub android_dlsym_stub +var android_dlsym_stub byte + +//go:linkname android_dlerror_stub android_dlerror_stub +var android_dlerror_stub byte + +//go:linkname android_strdup_stub android_strdup_stub +var android_strdup_stub byte + +//go:linkname android_free_stub android_free_stub +var android_free_stub byte + +var ( + androidDlopenStubABI0 = uintptr(unsafe.Pointer(&android_dlopen_stub)) + androidDlsymStubABI0 = uintptr(unsafe.Pointer(&android_dlsym_stub)) + androidDlerrorStubABI0 = uintptr(unsafe.Pointer(&android_dlerror_stub)) + androidStrdupStubABI0 = uintptr(unsafe.Pointer(&android_strdup_stub)) + androidFreeStubABI0 = uintptr(unsafe.Pointer(&android_free_stub)) +) + +func androidDlopenWrapper(unsafe.Pointer) +func androidDlsymWrapper(unsafe.Pointer) +func androidFreeWrapper(unsafe.Pointer) + +var ( + androidDlopenWrapperABI0 uintptr + androidDlsymWrapperABI0 uintptr + androidFreeWrapperABI0 uintptr +) + +func Dlopen(path string, mode int) (uintptr, error) { + pathBytes := append([]byte(path), 0) + args := androidDlopenArgs{ + fn: androidDlopenStubABI0, + errorFn: androidDlerrorStubABI0, + strdupFn: androidStrdupStubABI0, + path: &pathBytes[0], + mode: mode, + } + runtime_cgocall(androidDlopenWrapperABI0, unsafe.Pointer(&args)) + if args.result == 0 { + return 0, fmt.Errorf("dlopen failed: %s", takeAndroidDlerror(args.error)) + } + return args.result, nil +} + +func Dlsym(handle uintptr, name string) (uintptr, error) { + nameBytes := append([]byte(name), 0) + args := androidDlsymArgs{ + fn: androidDlsymStubABI0, + errorFn: androidDlerrorStubABI0, + strdupFn: androidStrdupStubABI0, + handle: handle, + name: &nameBytes[0], + } + runtime_cgocall(androidDlsymWrapperABI0, unsafe.Pointer(&args)) + if args.result == 0 { + return 0, fmt.Errorf("dlsym failed: %s", takeAndroidDlerror(args.error)) + } + return args.result, nil +} + +// Dlclose deliberately retains mappings for process-lifetime function +// pointers, matching the RTLD_NODELETE mode used by ffi.LoadLibrary. +func Dlclose(uintptr) error { return nil } + +func takeAndroidDlerror(ptr uintptr) string { + if ptr == 0 { + return "unknown error" + } + // ptr names strdup-owned native memory. Reinterpret its bits without a + // uintptr-to-pointer conversion so vet does not mistake it for a hidden Go + // heap pointer. + messagePtr := *(*unsafe.Pointer)(unsafe.Pointer(&ptr)) + length := 0 + for *(*byte)(unsafe.Add(messagePtr, length)) != 0 { + length++ + } + message := string(unsafe.Slice((*byte)(messagePtr), length)) + args := androidFreeArgs{fn: androidFreeStubABI0, ptr: ptr} + runtime_cgocall(androidFreeWrapperABI0, unsafe.Pointer(&args)) + return message +} diff --git a/internal/dl/dl_android_nocgo_arm64.s b/internal/dl/dl_android_nocgo_arm64.s new file mode 100644 index 0000000..868e5f1 --- /dev/null +++ b/internal/dl/dl_android_nocgo_arm64.s @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build android && !cgo && arm64 + +#include "textflag.h" + +TEXT android_dlopen_stub(SB), NOSPLIT|NOFRAME, $0-0 + B goffi_android_dlopen(SB) + +TEXT android_dlsym_stub(SB), NOSPLIT|NOFRAME, $0-0 + B goffi_android_dlsym(SB) + +TEXT android_dlerror_stub(SB), NOSPLIT|NOFRAME, $0-0 + B goffi_android_dlerror(SB) + +TEXT android_strdup_stub(SB), NOSPLIT|NOFRAME, $0-0 + B goffi_android_strdup(SB) + +TEXT android_free_stub(SB), NOSPLIT|NOFRAME, $0-0 + B goffi_android_free(SB) + +// androidDlopenArgs offsets: fn=0, errorFn=8, strdupFn=16, path=24, +// mode=32, result=40, error=48. +GLOBL ·androidDlopenWrapperABI0(SB), NOPTR|RODATA, $8 +DATA ·androidDlopenWrapperABI0(SB)/8, $androidDlopenWrapper(SB) + +TEXT androidDlopenWrapper(SB), NOSPLIT|NOFRAME, $0 + SUB $32, RSP, RSP + MOVD R29, 0(RSP) + MOVD R30, 8(RSP) + MOVD R0, 16(RSP) + MOVD RSP, R29 + + MOVD R0, R9 + MOVD 24(R9), R0 + MOVD 32(R9), R1 + MOVD 0(R9), R10 + BL (R10) + MOVD 16(RSP), R9 + MOVD R0, 40(R9) + CBNZ R0, android_dlopen_done + + MOVD 8(R9), R10 + BL (R10) + CBZ R0, android_dlopen_done + MOVD 16(RSP), R9 + MOVD 16(R9), R10 + BL (R10) + MOVD 16(RSP), R9 + MOVD R0, 48(R9) + +android_dlopen_done: + MOVD 8(RSP), R30 + MOVD 0(RSP), R29 + ADD $32, RSP, RSP + MOVD $0, R0 + RET + +// androidDlsymArgs offsets: fn=0, errorFn=8, strdupFn=16, handle=24, +// name=32, result=40, error=48. +GLOBL ·androidDlsymWrapperABI0(SB), NOPTR|RODATA, $8 +DATA ·androidDlsymWrapperABI0(SB)/8, $androidDlsymWrapper(SB) + +TEXT androidDlsymWrapper(SB), NOSPLIT|NOFRAME, $0 + SUB $32, RSP, RSP + MOVD R29, 0(RSP) + MOVD R30, 8(RSP) + MOVD R0, 16(RSP) + MOVD RSP, R29 + + // Clear the caller thread's prior loader error before dlsym. + MOVD R0, R9 + MOVD 8(R9), R10 + BL (R10) + MOVD 16(RSP), R9 + + MOVD 24(R9), R0 + MOVD 32(R9), R1 + MOVD 0(R9), R10 + BL (R10) + MOVD 16(RSP), R9 + MOVD R0, 40(R9) + CBNZ R0, android_dlsym_done + + MOVD 8(R9), R10 + BL (R10) + CBZ R0, android_dlsym_done + MOVD 16(RSP), R9 + MOVD 16(R9), R10 + BL (R10) + MOVD 16(RSP), R9 + MOVD R0, 48(R9) + +android_dlsym_done: + MOVD 8(RSP), R30 + MOVD 0(RSP), R29 + ADD $32, RSP, RSP + MOVD $0, R0 + RET + +// androidFreeArgs offsets: fn=0, ptr=8. +GLOBL ·androidFreeWrapperABI0(SB), NOPTR|RODATA, $8 +DATA ·androidFreeWrapperABI0(SB)/8, $androidFreeWrapper(SB) + +TEXT androidFreeWrapper(SB), NOSPLIT|NOFRAME, $0 + SUB $32, RSP, RSP + MOVD R29, 0(RSP) + MOVD R30, 8(RSP) + MOVD R0, 16(RSP) + MOVD RSP, R29 + + MOVD R0, R9 + MOVD 8(R9), R0 + MOVD 0(R9), R10 + BL (R10) + + MOVD 8(RSP), R30 + MOVD 0(RSP), R29 + ADD $32, RSP, RSP + MOVD $0, R0 + RET diff --git a/internal/dl/dl_linux.go b/internal/dl/dl_linux.go index ee1b6f9..bec6a9b 100644 --- a/internal/dl/dl_linux.go +++ b/internal/dl/dl_linux.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux && !android // Linux-specific constants for dynamic library loading. // diff --git a/internal/dl/dl_stubs_arm64.s b/internal/dl/dl_stubs_arm64.s index 3547a04..cec205d 100644 --- a/internal/dl/dl_stubs_arm64.s +++ b/internal/dl/dl_stubs_arm64.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 #include "textflag.h" diff --git a/internal/dl/dl_unix.go b/internal/dl/dl_unix.go index 3308796..7299aed 100644 --- a/internal/dl/dl_unix.go +++ b/internal/dl/dl_unix.go @@ -1,4 +1,4 @@ -//go:build linux || darwin || freebsd +//go:build (linux && !android) || darwin || freebsd // OUR OWN Dlopen/Dlsym implementation - NO dependencies! // Uses runtime.cgocall approach similar to syscall6. diff --git a/internal/dl/dl_wrappers_arm64.s b/internal/dl/dl_wrappers_arm64.s index f49a987..d90a72f 100644 --- a/internal/dl/dl_wrappers_arm64.s +++ b/internal/dl/dl_wrappers_arm64.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && arm64 +//go:build ((linux && !android) || darwin || freebsd) && arm64 #include "textflag.h" diff --git a/internal/fakecgo/android_abi_test.go b/internal/fakecgo/android_abi_test.go new file mode 100644 index 0000000..08b4613 --- /dev/null +++ b/internal/fakecgo/android_abi_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build android && arm64 && !cgo + +package fakecgo + +import ( + "testing" + "unsafe" +) + +// These compile-time array pairs turn an ABI drift into a build failure. The +// runtime assertions below keep the expected layout visible in test output +// when a physical Android runner is available. +var ( + _ [56 - unsafe.Sizeof(pthread_attr_t{})]byte + _ [unsafe.Sizeof(pthread_attr_t{}) - 56]byte + _ [48 - unsafe.Sizeof(pthread_cond_t{})]byte + _ [unsafe.Sizeof(pthread_cond_t{}) - 48]byte + _ [40 - unsafe.Sizeof(pthread_mutex_t{})]byte + _ [unsafe.Sizeof(pthread_mutex_t{}) - 40]byte + _ [8 - unsafe.Sizeof(sigset_t(0))]byte + _ [unsafe.Sizeof(sigset_t(0)) - 8]byte + _ [24 - unsafe.Sizeof(stack_t{})]byte + _ [unsafe.Sizeof(stack_t{}) - 24]byte +) + +func TestAndroidBionicABI(t *testing.T) { + tests := []struct { + name string + got uintptr + want uintptr + }{ + {"pthread_attr_t size", unsafe.Sizeof(pthread_attr_t{}), 56}, + {"pthread_attr_t align", unsafe.Alignof(pthread_attr_t{}), 8}, + {"pthread_cond_t size", unsafe.Sizeof(pthread_cond_t{}), 48}, + {"pthread_cond_t align", unsafe.Alignof(pthread_cond_t{}), 4}, + {"pthread_mutex_t size", unsafe.Sizeof(pthread_mutex_t{}), 40}, + {"pthread_mutex_t align", unsafe.Alignof(pthread_mutex_t{}), 4}, + {"sigset_t size", unsafe.Sizeof(sigset_t(0)), 8}, + {"sigset_t align", unsafe.Alignof(sigset_t(0)), 8}, + {"stack_t size", unsafe.Sizeof(stack_t{}), 24}, + {"stack_t ss_size offset", unsafe.Offsetof(stack_t{}.ss_size), 16}, + {"pthread_t size", unsafe.Sizeof(pthread_t(0)), 8}, + {"pthread_key_t size", unsafe.Sizeof(pthread_key_t(0)), 4}, + {"tls_g offset", androidTLSGOffset, 16}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("got %d, want %d", tt.got, tt.want) + } + }) + } +} + +func TestAndroidFakeCGORuntimeWiring(t *testing.T) { + // GOOS=android also satisfies linux build constraints. These references + // deliberately prove that iscgo.go, callbacks.go, and setenv.go are part + // of the Android build even though ffi callbacks remain unsupported. + if !_iscgo { + t.Fatal("runtime.iscgo must be true for outbound runtime.cgocall") + } + + requiredHooks := []struct { + name string + ptr *byte + }{ + {"_cgo_init", _cgo_init}, + {"_cgo_thread_start", _cgo_thread_start}, + {"_cgo_notify_runtime_init_done", _cgo_notify_runtime_init_done}, + {"_cgo_bindm", _cgo_bindm}, + {"_cgo_setenv", _cgo_setenv}, + {"_cgo_unsetenv", _cgo_unsetenv}, + } + for _, hook := range requiredHooks { + if hook.ptr == nil { + t.Errorf("%s runtime hook is nil", hook.name) + } + } + if _cgo_pthread_key_created == nil { + t.Error("_cgo_pthread_key_created runtime hook is nil") + } +} diff --git a/internal/fakecgo/android_dl.go b/internal/fakecgo/android_dl.go new file mode 100644 index 0000000..40fe8e1 --- /dev/null +++ b/internal/fakecgo/android_dl.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build !cgo && android && arm64 + +package fakecgo + +import "unsafe" + +// x_cgo_init runs before the Go runtime has enabled runtime.cgocall. Resolve +// the API-level marker with direct AAPCS64 calls instead of internal/dl so the +// pre-Q guard cannot recurse through an uninitialized cgocall path. +// +//go:cgo_import_dynamic goffi_android_dlopen dlopen "libdl.so" +//go:cgo_import_dynamic goffi_android_dlsym dlsym "libdl.so" +//go:cgo_import_dynamic _ _ "libdl.so" + +// The assembly stubs are kept separate from the generated libc wrappers: the +// latter intentionally contain only the runtime symbols needed after startup. +// +//go:linkname _android_dlopen _android_dlopen +var _android_dlopen byte + +//go:linkname _android_dlsym _android_dlsym +var _android_dlsym byte + +var ( + androidDlopenABI0 = uintptr(unsafe.Pointer(&_android_dlopen)) + androidDlsymABI0 = uintptr(unsafe.Pointer(&_android_dlsym)) +) + +var ( + androidLibcName = [...]byte{'l', 'i', 'b', 'c', '.', 's', 'o', 0} + androidAPISymbol = [...]byte{ + 'a', 'n', 'd', 'r', 'o', 'i', 'd', '_', 'g', 'e', 't', '_', + 'd', 'e', 'v', 'i', 'c', 'e', '_', 'a', 'p', 'i', '_', 'l', + 'e', 'v', 'e', 'l', 0, + } +) + +const androidRTLDNow = uintptr(0x00002) +const androidMinAPI = uintptr(29) + +// androidAPI29 reports whether libc exposes the API-29 marker and returns a +// sufficiently new level. It uses only direct call5/assembly calls and is +// safe to invoke from x_cgo_init before ordinary runtime.cgocall exists. +// +//go:nosplit +func androidAPI29() bool { + handle := call5(androidDlopenABI0, + uintptr(unsafe.Pointer(&androidLibcName[0])), androidRTLDNow, 0, 0, 0) + if handle == 0 { + return false + } + apiFn := call5(androidDlsymABI0, handle, + uintptr(unsafe.Pointer(&androidAPISymbol[0])), 0, 0, 0) + if apiFn == 0 { + return false + } + api := call5(apiFn, 0, 0, 0, 0, 0) + // Do not close libc here. The runtime's Android path keeps libc resident, + // and avoiding a second loader transition keeps this pre-runtime probe + // deterministic. + return api >= androidMinAPI +} diff --git a/internal/fakecgo/android_dl_stubs_arm64.s b/internal/fakecgo/android_dl_stubs_arm64.s new file mode 100644 index 0000000..6e3f292 --- /dev/null +++ b/internal/fakecgo/android_dl_stubs_arm64.s @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build !cgo && android && arm64 + +#include "textflag.h" + +TEXT _android_dlopen(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_android_dlopen(SB) + RET + +TEXT _android_dlsym(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_android_dlsym(SB) + RET diff --git a/internal/fakecgo/doc.go b/internal/fakecgo/doc.go index 968df4f..b7e3e14 100644 --- a/internal/fakecgo/doc.go +++ b/internal/fakecgo/doc.go @@ -16,9 +16,10 @@ // // # Support // -// Currently, fakecgo supports Linux, macOS, FreeBSD, and NetBSD on amd64 & arm64. -// It cannot be used with -buildmode=c-archive because that requires special -// initialization that fakecgo does not implement at the moment. +// Currently, fakecgo supports Linux, macOS, FreeBSD, and NetBSD on amd64 & arm64, +// plus Android arm64/API 29+ as a guarded preview. It cannot be used with +// -buildmode=c-archive because that requires special initialization that fakecgo +// does not implement at the moment. // // # Usage // diff --git a/internal/fakecgo/gen.go b/internal/fakecgo/gen.go index 5407e79..1a80e7d 100644 --- a/internal/fakecgo/gen.go +++ b/internal/fakecgo/gen.go @@ -22,7 +22,7 @@ const templateSymbols = `// Code generated by 'go generate' with gen.go. DO NOT // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && (darwin || freebsd || linux || netbsd) +//go:build !cgo && (darwin || freebsd || linux || netbsd) && !android package fakecgo @@ -90,7 +90,7 @@ const templateTrampolinesStubs = `// Code generated by 'go generate' with gen.go // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && (darwin || freebsd || linux || netbsd) +//go:build !cgo && (darwin || freebsd || linux || netbsd) && !android #include "textflag.h" @@ -135,6 +135,20 @@ type LocatedSymbols struct { Symbols []Symbol } +func writeFormatted(path string, source []byte) error { + formatted, err := format.Source(source) + if err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(formatted) + return err +} + var ( libcSymbols = []Symbol{ {"malloc", [5]Arg{{"size", "uintptr"}}, "unsafe.Pointer"}, @@ -146,6 +160,11 @@ var ( {"abort", [5]Arg{}, ""}, {"sigaltstack", [5]Arg{{"ss", "*stack_t"}, {"old_ss", "*stack_t"}}, "int32"}, } + androidLibcSymbols = append(append([]Symbol{}, libcSymbols...), Symbol{ + "write", + [5]Arg{{"fd", "int32"}, {"buf", "unsafe.Pointer"}, {"count", "size_t"}}, + "int", + }) pthreadSymbols = []Symbol{ {"pthread_attr_init", [5]Arg{{"attr", "*pthread_attr_t"}}, "int32"}, {"pthread_create", [5]Arg{{"thread", "*pthread_t"}, {"attr", "*pthread_attr_t"}, {"start", "unsafe.Pointer"}, {"arg", "unsafe.Pointer"}}, "int32"}, @@ -161,6 +180,18 @@ var ( {"pthread_cond_broadcast", [5]Arg{{"cond", "*pthread_cond_t"}}, "int32"}, {"pthread_setspecific", [5]Arg{{"key", "pthread_key_t"}, {"value", "unsafe.Pointer"}}, "int32"}, } + androidPthreadSymbols = []Symbol{ + {"pthread_attr_init", [5]Arg{{"attr", "*pthread_attr_t"}}, "int32"}, + {"pthread_create", [5]Arg{{"thread", "*pthread_t"}, {"attr", "*pthread_attr_t"}, {"start", "unsafe.Pointer"}, {"arg", "unsafe.Pointer"}}, "int32"}, + {"pthread_detach", [5]Arg{{"thread", "pthread_t"}}, "int32"}, + {"pthread_sigmask", [5]Arg{{"how", "sighow"}, {"ign", "*sigset_t"}, {"oset", "*sigset_t"}}, "int32"}, + {"pthread_attr_getstacksize", [5]Arg{{"attr", "*pthread_attr_t"}, {"stacksize", "*size_t"}}, "int32"}, + {"pthread_attr_destroy", [5]Arg{{"attr", "*pthread_attr_t"}}, "int32"}, + {"pthread_mutex_lock", [5]Arg{{"mutex", "*pthread_mutex_t"}}, "int32"}, + {"pthread_mutex_unlock", [5]Arg{{"mutex", "*pthread_mutex_t"}}, "int32"}, + {"pthread_cond_broadcast", [5]Arg{{"cond", "*pthread_cond_t"}}, "int32"}, + {"pthread_setspecific", [5]Arg{{"key", "pthread_key_t"}, {"value", "unsafe.Pointer"}}, "int32"}, + } ) var funcs = map[string]any{ @@ -172,45 +203,30 @@ func run() error { if err != nil { return err } - f, err := os.Create("symbols.go") - defer f.Close() - if err != nil { - return err - } allSymbols := append(append([]Symbol{}, libcSymbols...), pthreadSymbols...) buf := new(bytes.Buffer) if err := t.Execute(buf, allSymbols); err != nil { return err } - source, err := format.Source(buf.Bytes()) - if err != nil { - return err - } - if _, err = f.Write(source); err != nil { + if err := writeFormatted("symbols.go", buf.Bytes()); err != nil { return err } t, err = template.New("trampolines_stubs.s").Funcs(funcs).Parse(templateTrampolinesStubs) if err != nil { return err } - f, err = os.Create("trampolines_stubs.s") - defer f.Close() - if err != nil { + buf.Reset() + if err := t.Execute(buf, allSymbols); err != nil { return err } - if err := t.Execute(f, allSymbols); err != nil { + if err := os.WriteFile("trampolines_stubs.s", buf.Bytes(), 0o644); err != nil { return err } t, err = template.New("symbols_goos.go").Parse(templateSymbolsGoos) if err != nil { return err } - for _, goos := range []string{"darwin", "freebsd", "linux", "netbsd"} { - f, err = os.Create(fmt.Sprintf("symbols_%s.go", goos)) - defer f.Close() - if err != nil { - return err - } + for _, goos := range []string{"darwin", "freebsd", "linux", "netbsd", "android"} { b := &bytes.Buffer{} var libcSO, pthreadSO string switch goos { @@ -226,14 +242,34 @@ func run() error { case "netbsd": libcSO = "libc.so" pthreadSO = "libpthread.so" + case "android": + libcSO = "libc.so" + pthreadSO = "libc.so" default: return fmt.Errorf("unsupported OS: %s", goos) } + pthread := pthreadSymbols + libc := libcSymbols + if goos == "android" { + libc = androidLibcSymbols + pthread = androidPthreadSymbols + } located := []LocatedSymbols{ - {SharedObject: libcSO, Symbols: libcSymbols}, - {SharedObject: pthreadSO, Symbols: pthreadSymbols}, + {SharedObject: libcSO, Symbols: libc}, + {SharedObject: pthreadSO, Symbols: pthread}, + } + goosTemplate := t + switch goos { + case "linux": + // The go command also satisfies the linux build tag on Android, + // including for _linux.go files. Keep glibc imports out of Bionic. + goosTemplate = template.Must(template.New("symbols_linux.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo", "//go:build !cgo && !android", 1))) + case "android": + // Android uses a distinct build selector and symbol set. Keep the + // generated generic Linux imports out of the Android ELF. + goosTemplate = template.Must(template.New("symbols_android.go").Parse(strings.Replace(templateSymbolsGoos, "//go:build !cgo", "//go:build !cgo && android && arm64", 1))) } - if err = t.Execute(b, located); err != nil { + if err = goosTemplate.Execute(b, located); err != nil { return err } var src []byte @@ -241,10 +277,42 @@ func run() error { if err != nil { return err } - if _, err = f.Write(src); err != nil { + name := fmt.Sprintf("symbols_%s.go", goos) + if goos == "android" { + name = "symbols_android_imports.go" + } + if err = os.WriteFile(name, src, 0o644); err != nil { return err } } + + // The Android wrappers and assembly stubs are generated from the same + // restricted symbol set as the imports above. + androidSymbols := append(append([]Symbol{}, androidLibcSymbols...), androidPthreadSymbols...) + androidTemplate := strings.Replace(templateSymbols, "//go:build !cgo && (darwin || freebsd || linux || netbsd) && !android", "//go:build !cgo && android && arm64", 1) + t, err = template.New("symbols_android.go").Funcs(funcs).Parse(androidTemplate) + if err != nil { + return err + } + buf.Reset() + if err := t.Execute(buf, androidSymbols); err != nil { + return err + } + if err := writeFormatted("symbols_android.go", buf.Bytes()); err != nil { + return err + } + androidStubTemplate := strings.Replace(templateTrampolinesStubs, "//go:build !cgo && (darwin || freebsd || linux || netbsd) && !android", "//go:build !cgo && android && arm64", 1) + t, err = template.New("trampolines_stubs_android.s").Funcs(funcs).Parse(androidStubTemplate) + if err != nil { + return err + } + buf.Reset() + if err := t.Execute(buf, androidSymbols); err != nil { + return err + } + if err := os.WriteFile("trampolines_stubs_android.s", buf.Bytes(), 0o644); err != nil { + return err + } return nil } diff --git a/internal/fakecgo/go_android_arm64.go b/internal/fakecgo/go_android_arm64.go new file mode 100644 index 0000000..7c92736 --- /dev/null +++ b/internal/fakecgo/go_android_arm64.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: BSD-3-Clause +// SPDX-FileCopyrightText: 2011 The Go Authors +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build !cgo && android && arm64 + +package fakecgo + +import "unsafe" + +// Android reserves TLS_SLOT_APP (slot 2) for the runtime's g pointer on +// API-29+ arm64. Keep this as a package constant so the ABI test and startup +// guard cannot silently drift apart. +const androidTLSGOffset = uintptr(2 * unsafe.Sizeof(uintptr(0))) + +// _cgo_sys_thread_start is the Android arm64 half of the fakecgo thread +// startup path. It intentionally mirrors runtime/cgo/gcc_linux_arm64.c while +// using Bionic's libc-only pthread symbols and Android-verified layouts. +// +//go:nosplit +func _cgo_sys_thread_start(ts *ThreadStart) { + var attr pthread_attr_t + var ign, oset sigset_t + var p pthread_t + var size size_t + var err int + + sigfillset(&ign) + pthread_sigmask(SIG_SETMASK, &ign, &oset) + + if pthread_attr_init(&attr) != 0 { + androidFatal("fakecgo: pthread_attr_init failed on Android") + } + if pthread_attr_getstacksize(&attr, &size) != 0 { + androidFatal("fakecgo: pthread_attr_getstacksize failed on Android") + } + // Leave stacklo=0 and set stackhi=size; mstart will do the rest. + ts.g.stackhi = uintptr(size) + + err = _cgo_try_pthread_create(&p, &attr, unsafe.Pointer(threadentry_trampolineABI0), ts) + + pthread_sigmask(SIG_SETMASK, &oset, nil) + + if err != 0 { + androidFatal("fakecgo: pthread_create failed on Android") + } +} + +// threadentry_trampolineABI0 maps the C ABI to Go ABI and then calls the Go +// function. The trampoline is supplied by the shared fakecgo arm64 assembly. +// +//go:linkname x_threadentry_trampoline threadentry_trampoline +var x_threadentry_trampoline byte +var threadentry_trampolineABI0 = &x_threadentry_trampoline + +//go:nosplit +func threadentry(v unsafe.Pointer) unsafe.Pointer { + ts := *(*ThreadStart)(v) + free(v) + + setg_trampoline(setg_func, uintptr(unsafe.Pointer(ts.g))) + + fn := uintptr(unsafe.Pointer(&ts.fn)) + (*(*func())(unsafe.Pointer(&fn)))() + + return nil +} + +// setg_func stores the runtime-provided setg_gcc callback for new pthreads. +var setg_func uintptr + +// androidFatal is deliberately direct and nosplit: x_cgo_init may call it +// before normal runtime initialization, when panic, Go printing, and ordinary +// cgocall are not safe. It writes directly through Bionic and then aborts. +var androidFatalNewline = [1]byte{'\n'} + +//go:nosplit +func androidFatal(message string) { + if len(message) != 0 { + write(2, unsafe.Pointer(unsafe.StringData(message)), size_t(len(message))) + } + write(2, unsafe.Pointer(&androidFatalNewline[0]), 1) + abort() +} + +// x_cgo_inittls validates the API-29+ Android arm64 TLS contract used by the +// pinned Go runtime. Android Q reserves TLS_SLOT_APP (slot 2), so runtime.tls_g +// must contain 2*sizeof(void*) == 16. The API-level guard runs first and uses +// direct dlsym/call5 calls; this function never enters runtime.cgocall. +// +//go:nosplit +func x_cgo_inittls(tlsg *uintptr, tlsbase unsafe.Pointer) { + _ = tlsbase // The runtime supplies the base for the C implementation; slot 2 is fixed. + if !androidAPI29() { + androidFatal("fakecgo: Android API 29 or newer is required") + } + if tlsg == nil { + androidFatal("fakecgo: Android runtime did not provide runtime.tls_g") + } + if *tlsg != androidTLSGOffset { + androidFatal("fakecgo: Android runtime.tls_g offset mismatch (want 16)") + } +} + +// x_cgo_init matches the four-argument Android arm64 entry point in the +// pinned Go runtime: (G*, setg_gcc, &runtime.tls_g, TLS base). +// +//go:nosplit +func x_cgo_init(g *G, setg uintptr, tlsg *uintptr, tlsbase unsafe.Pointer) { + setg_func = setg + + // The API/TLS guard must execute before touching the slot or entering any + // ordinary cgocall path. + x_cgo_inittls(tlsg, tlsbase) + + var size size_t + var attr *pthread_attr_t + + attr = (*pthread_attr_t)(malloc(unsafe.Sizeof(*attr))) + if attr == nil { + androidFatal("fakecgo: malloc failed while initializing Android cgo") + } + if pthread_attr_init(attr) != 0 || pthread_attr_getstacksize(attr, &size) != 0 { + androidFatal("fakecgo: pthread stack initialization failed on Android") + } + // runtime/cgo uses _cgo_set_stacklo with a malloc-backed probe. The + // fakecgo path keeps the established Linux calculation, but applies it only + // after the Bionic attr layout has been validated above. + g.stacklo = uintptr(unsafe.Pointer(&size)) - uintptr(size) + 4096 + pthread_attr_destroy(attr) + free(unsafe.Pointer(attr)) +} diff --git a/internal/fakecgo/go_linux_amd64.go b/internal/fakecgo/go_linux_amd64.go index c9ff715..198f2dc 100644 --- a/internal/fakecgo/go_linux_amd64.go +++ b/internal/fakecgo/go_linux_amd64.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build !cgo +//go:build !cgo && linux && !android package fakecgo diff --git a/internal/fakecgo/go_linux_arm64.go b/internal/fakecgo/go_linux_arm64.go index a3b1cca..fb45f64 100644 --- a/internal/fakecgo/go_linux_arm64.go +++ b/internal/fakecgo/go_linux_arm64.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build !cgo +//go:build !cgo && linux && !android package fakecgo diff --git a/internal/fakecgo/iscgo.go b/internal/fakecgo/iscgo.go index 12e5214..32e4372 100644 --- a/internal/fakecgo/iscgo.go +++ b/internal/fakecgo/iscgo.go @@ -10,6 +10,10 @@ // but those depend on dynamic linker magic to get initialized // correctly, and sometimes they break. This variable is a // backup: it depends only on old C style static linking rules. +// +// Android builds also satisfy the linux build term, so this declaration is +// intentionally selected for Android/arm64. Outbound runtime.cgocall requires +// iscgo even though goffi rejects Android C-to-Go callbacks at its public API. package fakecgo diff --git a/internal/fakecgo/libcgo.go b/internal/fakecgo/libcgo.go index f50e835..d78d6d0 100644 --- a/internal/fakecgo/libcgo.go +++ b/internal/fakecgo/libcgo.go @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && (darwin || freebsd || linux || netbsd) +//go:build !cgo && (darwin || freebsd || linux || netbsd) && !android package fakecgo diff --git a/internal/fakecgo/libcgo_android.go b/internal/fakecgo/libcgo_android.go new file mode 100644 index 0000000..f3d9def --- /dev/null +++ b/internal/fakecgo/libcgo_android.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build !cgo && android && arm64 + +package fakecgo + +import "structs" + +// These layouts mirror Android NDK LP64 pthread_types.h. They intentionally +// do not reuse the glibc Linux definitions: Android's pthread_attr_t is 56 +// bytes and pthread_mutex_t is 40 bytes on arm64, while pthread_key_t is a +// 32-bit int and pthread_t is a C long. +type ( + size_t uintptr + // Bionic LP64 uses the asm-generic sigset_t typedef: one unsigned long. + // Keep its natural 8-byte alignment because pthread_sigmask receives this + // object by pointer and the ABI probe asserts the native layout. + sigset_t uint64 + // Use word arrays to preserve Bionic's native alignment as well as size. + // pthread_attr_t contains pointers/size_t and is 8-byte aligned on LP64. + pthread_attr_t [7]uint64 + pthread_t int64 + pthread_key_t int32 + + // bionic's stack_t is struct { void *ss_sp; int ss_flags; size_t ss_size; } + stack_t struct { + ss_sp uintptr + ss_flags int32 + _pad int32 + ss_size size_t + } + + pthread_cond_t [12]uint32 // Bionic's int32[12], align 4. + pthread_mutex_t [10]uint32 // Bionic's int32[10], align 4. +) + +var ( + PTHREAD_COND_INITIALIZER = pthread_cond_t{} + PTHREAD_MUTEX_INITIALIZER = pthread_mutex_t{} +) + +type sighow int32 + +const ( + SIG_BLOCK sighow = 0 + SIG_UNBLOCK sighow = 1 + SIG_SETMASK sighow = 2 +) + +type G struct { + _ structs.HostLayout + stacklo uintptr + stackhi uintptr +} + +type ThreadStart struct { + _ structs.HostLayout + g *G + tls *uintptr + fn uintptr +} diff --git a/internal/fakecgo/libcgo_linux.go b/internal/fakecgo/libcgo_linux.go index 78cd999..503a15b 100644 --- a/internal/fakecgo/libcgo_linux.go +++ b/internal/fakecgo/libcgo_linux.go @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo +//go:build !cgo && linux && !android package fakecgo diff --git a/internal/fakecgo/symbols.go b/internal/fakecgo/symbols.go index cb7668a..1a6a353 100644 --- a/internal/fakecgo/symbols.go +++ b/internal/fakecgo/symbols.go @@ -4,7 +4,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && (darwin || freebsd || linux || netbsd) +//go:build !cgo && (darwin || freebsd || linux || netbsd) && !android package fakecgo diff --git a/internal/fakecgo/symbols_android.go b/internal/fakecgo/symbols_android.go new file mode 100644 index 0000000..0899800 --- /dev/null +++ b/internal/fakecgo/symbols_android.go @@ -0,0 +1,212 @@ +// Code generated by 'go generate' with gen.go. DO NOT EDIT. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && android && arm64 + +package fakecgo + +import ( + "syscall" + "unsafe" +) + +// setg_trampoline calls setg with the G provided +func setg_trampoline(setg uintptr, G uintptr) + +// call5 takes fn the C function and 5 arguments and calls the function with those arguments +func call5(fn, a1, a2, a3, a4, a5 uintptr) uintptr + +//go:nosplit +//go:norace +func malloc(size uintptr) unsafe.Pointer { + ret := call5(mallocABI0, uintptr(size), 0, 0, 0, 0) + // this indirection is to avoid go vet complaining about possible misuse of unsafe.Pointer + return *(*unsafe.Pointer)(unsafe.Pointer(&ret)) +} + +//go:nosplit +//go:norace +func free(ptr unsafe.Pointer) { + call5(freeABI0, uintptr(ptr), 0, 0, 0, 0) +} + +//go:nosplit +//go:norace +func setenv(name *byte, value *byte, overwrite int32) int32 { + return int32(call5(setenvABI0, uintptr(unsafe.Pointer(name)), uintptr(unsafe.Pointer(value)), uintptr(overwrite), 0, 0)) +} + +//go:nosplit +//go:norace +func unsetenv(name *byte) int32 { + return int32(call5(unsetenvABI0, uintptr(unsafe.Pointer(name)), 0, 0, 0, 0)) +} + +//go:nosplit +//go:norace +func sigfillset(set *sigset_t) int32 { + return int32(call5(sigfillsetABI0, uintptr(unsafe.Pointer(set)), 0, 0, 0, 0)) +} + +//go:nosplit +//go:norace +func nanosleep(ts *syscall.Timespec, rem *syscall.Timespec) int32 { + return int32(call5(nanosleepABI0, uintptr(unsafe.Pointer(ts)), uintptr(unsafe.Pointer(rem)), 0, 0, 0)) +} + +//go:nosplit +//go:norace +func abort() { + call5(abortABI0, 0, 0, 0, 0, 0) +} + +//go:nosplit +//go:norace +func sigaltstack(ss *stack_t, old_ss *stack_t) int32 { + return int32(call5(sigaltstackABI0, uintptr(unsafe.Pointer(ss)), uintptr(unsafe.Pointer(old_ss)), 0, 0, 0)) +} + +//go:nosplit +//go:norace +func write(fd int32, buf unsafe.Pointer, count size_t) int { + return int(call5(writeABI0, uintptr(fd), uintptr(buf), uintptr(count), 0, 0)) +} + +//go:nosplit +//go:norace +func pthread_attr_init(attr *pthread_attr_t) int32 { + return int32(call5(pthread_attr_initABI0, uintptr(unsafe.Pointer(attr)), 0, 0, 0, 0)) +} + +//go:nosplit +//go:norace +func pthread_create(thread *pthread_t, attr *pthread_attr_t, start unsafe.Pointer, arg unsafe.Pointer) int32 { + return int32(call5(pthread_createABI0, uintptr(unsafe.Pointer(thread)), uintptr(unsafe.Pointer(attr)), uintptr(start), uintptr(arg), 0)) +} + +//go:nosplit +//go:norace +func pthread_detach(thread pthread_t) int32 { + return int32(call5(pthread_detachABI0, uintptr(thread), 0, 0, 0, 0)) +} + +//go:nosplit +//go:norace +func pthread_sigmask(how sighow, ign *sigset_t, oset *sigset_t) int32 { + return int32(call5(pthread_sigmaskABI0, uintptr(how), uintptr(unsafe.Pointer(ign)), uintptr(unsafe.Pointer(oset)), 0, 0)) +} + +//go:nosplit +//go:norace +func pthread_attr_getstacksize(attr *pthread_attr_t, stacksize *size_t) int32 { + return int32(call5(pthread_attr_getstacksizeABI0, uintptr(unsafe.Pointer(attr)), uintptr(unsafe.Pointer(stacksize)), 0, 0, 0)) +} + +//go:nosplit +//go:norace +func pthread_attr_destroy(attr *pthread_attr_t) int32 { + return int32(call5(pthread_attr_destroyABI0, uintptr(unsafe.Pointer(attr)), 0, 0, 0, 0)) +} + +//go:nosplit +//go:norace +func pthread_mutex_lock(mutex *pthread_mutex_t) int32 { + return int32(call5(pthread_mutex_lockABI0, uintptr(unsafe.Pointer(mutex)), 0, 0, 0, 0)) +} + +//go:nosplit +//go:norace +func pthread_mutex_unlock(mutex *pthread_mutex_t) int32 { + return int32(call5(pthread_mutex_unlockABI0, uintptr(unsafe.Pointer(mutex)), 0, 0, 0, 0)) +} + +//go:nosplit +//go:norace +func pthread_cond_broadcast(cond *pthread_cond_t) int32 { + return int32(call5(pthread_cond_broadcastABI0, uintptr(unsafe.Pointer(cond)), 0, 0, 0, 0)) +} + +//go:nosplit +//go:norace +func pthread_setspecific(key pthread_key_t, value unsafe.Pointer) int32 { + return int32(call5(pthread_setspecificABI0, uintptr(key), uintptr(value), 0, 0, 0)) +} + +//go:linkname _malloc _malloc +var _malloc uint8 +var mallocABI0 = uintptr(unsafe.Pointer(&_malloc)) + +//go:linkname _free _free +var _free uint8 +var freeABI0 = uintptr(unsafe.Pointer(&_free)) + +//go:linkname _setenv _setenv +var _setenv uint8 +var setenvABI0 = uintptr(unsafe.Pointer(&_setenv)) + +//go:linkname _unsetenv _unsetenv +var _unsetenv uint8 +var unsetenvABI0 = uintptr(unsafe.Pointer(&_unsetenv)) + +//go:linkname _sigfillset _sigfillset +var _sigfillset uint8 +var sigfillsetABI0 = uintptr(unsafe.Pointer(&_sigfillset)) + +//go:linkname _nanosleep _nanosleep +var _nanosleep uint8 +var nanosleepABI0 = uintptr(unsafe.Pointer(&_nanosleep)) + +//go:linkname _abort _abort +var _abort uint8 +var abortABI0 = uintptr(unsafe.Pointer(&_abort)) + +//go:linkname _sigaltstack _sigaltstack +var _sigaltstack uint8 +var sigaltstackABI0 = uintptr(unsafe.Pointer(&_sigaltstack)) + +//go:linkname _write _write +var _write uint8 +var writeABI0 = uintptr(unsafe.Pointer(&_write)) + +//go:linkname _pthread_attr_init _pthread_attr_init +var _pthread_attr_init uint8 +var pthread_attr_initABI0 = uintptr(unsafe.Pointer(&_pthread_attr_init)) + +//go:linkname _pthread_create _pthread_create +var _pthread_create uint8 +var pthread_createABI0 = uintptr(unsafe.Pointer(&_pthread_create)) + +//go:linkname _pthread_detach _pthread_detach +var _pthread_detach uint8 +var pthread_detachABI0 = uintptr(unsafe.Pointer(&_pthread_detach)) + +//go:linkname _pthread_sigmask _pthread_sigmask +var _pthread_sigmask uint8 +var pthread_sigmaskABI0 = uintptr(unsafe.Pointer(&_pthread_sigmask)) + +//go:linkname _pthread_attr_getstacksize _pthread_attr_getstacksize +var _pthread_attr_getstacksize uint8 +var pthread_attr_getstacksizeABI0 = uintptr(unsafe.Pointer(&_pthread_attr_getstacksize)) + +//go:linkname _pthread_attr_destroy _pthread_attr_destroy +var _pthread_attr_destroy uint8 +var pthread_attr_destroyABI0 = uintptr(unsafe.Pointer(&_pthread_attr_destroy)) + +//go:linkname _pthread_mutex_lock _pthread_mutex_lock +var _pthread_mutex_lock uint8 +var pthread_mutex_lockABI0 = uintptr(unsafe.Pointer(&_pthread_mutex_lock)) + +//go:linkname _pthread_mutex_unlock _pthread_mutex_unlock +var _pthread_mutex_unlock uint8 +var pthread_mutex_unlockABI0 = uintptr(unsafe.Pointer(&_pthread_mutex_unlock)) + +//go:linkname _pthread_cond_broadcast _pthread_cond_broadcast +var _pthread_cond_broadcast uint8 +var pthread_cond_broadcastABI0 = uintptr(unsafe.Pointer(&_pthread_cond_broadcast)) + +//go:linkname _pthread_setspecific _pthread_setspecific +var _pthread_setspecific uint8 +var pthread_setspecificABI0 = uintptr(unsafe.Pointer(&_pthread_setspecific)) diff --git a/internal/fakecgo/symbols_android_imports.go b/internal/fakecgo/symbols_android_imports.go new file mode 100644 index 0000000..8e298dc --- /dev/null +++ b/internal/fakecgo/symbols_android_imports.go @@ -0,0 +1,29 @@ +// Code generated by 'go generate' with gen.go. DO NOT EDIT. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && android && arm64 + +package fakecgo + +//go:cgo_import_dynamic goffi_malloc malloc "libc.so" +//go:cgo_import_dynamic goffi_free free "libc.so" +//go:cgo_import_dynamic goffi_setenv setenv "libc.so" +//go:cgo_import_dynamic goffi_unsetenv unsetenv "libc.so" +//go:cgo_import_dynamic goffi_sigfillset sigfillset "libc.so" +//go:cgo_import_dynamic goffi_nanosleep nanosleep "libc.so" +//go:cgo_import_dynamic goffi_abort abort "libc.so" +//go:cgo_import_dynamic goffi_sigaltstack sigaltstack "libc.so" +//go:cgo_import_dynamic goffi_write write "libc.so" +//go:cgo_import_dynamic goffi_pthread_attr_init pthread_attr_init "libc.so" +//go:cgo_import_dynamic goffi_pthread_create pthread_create "libc.so" +//go:cgo_import_dynamic goffi_pthread_detach pthread_detach "libc.so" +//go:cgo_import_dynamic goffi_pthread_sigmask pthread_sigmask "libc.so" +//go:cgo_import_dynamic goffi_pthread_attr_getstacksize pthread_attr_getstacksize "libc.so" +//go:cgo_import_dynamic goffi_pthread_attr_destroy pthread_attr_destroy "libc.so" +//go:cgo_import_dynamic goffi_pthread_mutex_lock pthread_mutex_lock "libc.so" +//go:cgo_import_dynamic goffi_pthread_mutex_unlock pthread_mutex_unlock "libc.so" +//go:cgo_import_dynamic goffi_pthread_cond_broadcast pthread_cond_broadcast "libc.so" +//go:cgo_import_dynamic goffi_pthread_setspecific pthread_setspecific "libc.so" diff --git a/internal/fakecgo/symbols_linux.go b/internal/fakecgo/symbols_linux.go index 5d35a76..b5c7dfa 100644 --- a/internal/fakecgo/symbols_linux.go +++ b/internal/fakecgo/symbols_linux.go @@ -4,7 +4,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo +//go:build !cgo && !android package fakecgo diff --git a/internal/fakecgo/trampolines_stubs.s b/internal/fakecgo/trampolines_stubs.s index 4bbeb89..9bfcccb 100644 --- a/internal/fakecgo/trampolines_stubs.s +++ b/internal/fakecgo/trampolines_stubs.s @@ -4,7 +4,7 @@ // SPDX-FileCopyrightText: 2022 The Ebitengine Authors // SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors -//go:build !cgo && (darwin || freebsd || linux || netbsd) +//go:build !cgo && (darwin || freebsd || linux || netbsd) && !android #include "textflag.h" diff --git a/internal/fakecgo/trampolines_stubs_android.s b/internal/fakecgo/trampolines_stubs_android.s new file mode 100644 index 0000000..7a50243 --- /dev/null +++ b/internal/fakecgo/trampolines_stubs_android.s @@ -0,0 +1,87 @@ +// Code generated by 'go generate' with gen.go. DO NOT EDIT. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2022 The Ebitengine Authors +// SPDX-FileCopyrightText: 2025-2026 Andrey Kolkov and GoGPU Contributors + +//go:build !cgo && android && arm64 + +#include "textflag.h" + +// these stubs are here because it is not possible to go:linkname directly the C functions on darwin arm64 + +TEXT _malloc(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_malloc(SB) + RET + +TEXT _free(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_free(SB) + RET + +TEXT _setenv(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_setenv(SB) + RET + +TEXT _unsetenv(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_unsetenv(SB) + RET + +TEXT _sigfillset(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_sigfillset(SB) + RET + +TEXT _nanosleep(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_nanosleep(SB) + RET + +TEXT _abort(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_abort(SB) + RET + +TEXT _sigaltstack(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_sigaltstack(SB) + RET + +TEXT _write(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_write(SB) + RET + +TEXT _pthread_attr_init(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_attr_init(SB) + RET + +TEXT _pthread_create(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_create(SB) + RET + +TEXT _pthread_detach(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_detach(SB) + RET + +TEXT _pthread_sigmask(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_sigmask(SB) + RET + +TEXT _pthread_attr_getstacksize(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_attr_getstacksize(SB) + RET + +TEXT _pthread_attr_destroy(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_attr_destroy(SB) + RET + +TEXT _pthread_mutex_lock(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_mutex_lock(SB) + RET + +TEXT _pthread_mutex_unlock(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_mutex_unlock(SB) + RET + +TEXT _pthread_cond_broadcast(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_cond_broadcast(SB) + RET + +TEXT _pthread_setspecific(SB), NOSPLIT|NOFRAME, $0-0 + JMP goffi_pthread_setspecific(SB) + RET diff --git a/internal/syscall/errno_android.go b/internal/syscall/errno_android.go new file mode 100644 index 0000000..ff59c46 --- /dev/null +++ b/internal/syscall/errno_android.go @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build android && !cgo && arm64 + +package syscall + +// Bionic exports __errno from libc.so. __errno_location is a glibc name and +// must never appear in an Android artifact. +// +//go:cgo_import_dynamic goffi_errno_location __errno "libc.so" +//go:cgo_import_dynamic _ _ "libc.so" diff --git a/internal/syscall/errno_android_cgo.go b/internal/syscall/errno_android_cgo.go new file mode 100644 index 0000000..5cf3ce4 --- /dev/null +++ b/internal/syscall/errno_android_cgo.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The Goffi Authors + +//go:build android && cgo && arm64 + +package syscall + +import "github.com/go-webgpu/goffi/internal/dl" + +// ErrnoFnAddr returns Bionic's __errno function address. A zero result means +// that the platform did not expose the accessor; syscallN then skips capture. +func ErrnoFnAddr() uintptr { + return dl.AndroidErrnoAddr() +} diff --git a/internal/syscall/errno_linux.go b/internal/syscall/errno_linux.go index 87f969e..5836294 100644 --- a/internal/syscall/errno_linux.go +++ b/internal/syscall/errno_linux.go @@ -1,4 +1,4 @@ -//go:build linux && (amd64 || arm64) +//go:build linux && !android && (amd64 || arm64) package syscall diff --git a/internal/syscall/errno_stubs_amd64.s b/internal/syscall/errno_stubs_amd64.s index 04c4632..bf45d73 100644 --- a/internal/syscall/errno_stubs_amd64.s +++ b/internal/syscall/errno_stubs_amd64.s @@ -1,4 +1,4 @@ -//go:build (linux || darwin || freebsd) && amd64 +//go:build ((linux && !android) || darwin || freebsd) && amd64 #include "textflag.h" diff --git a/internal/syscall/errno_stubs_arm64.s b/internal/syscall/errno_stubs_arm64.s index 4e4eacc..24f9ba8 100644 --- a/internal/syscall/errno_stubs_arm64.s +++ b/internal/syscall/errno_stubs_arm64.s @@ -1,9 +1,10 @@ -//go:build (linux || darwin || freebsd) && arm64 +//go:build ((linux && !android) || (android && !cgo) || darwin || freebsd) && arm64 #include "textflag.h" // goffi_errno_location_stub: B to the dynamically linked errno function. -// On Linux/FreeBSD: __errno_location (from libc.so.6 / libc.so.7) +// On glibc Linux: __errno_location (from libc.so.6); on Android: __errno +// (from libc.so); on FreeBSD: __error (from libc.so.7). // On macOS: __error (from libSystem.B.dylib) // In all cases the dynamic symbol is imported as goffi_errno_location. TEXT goffi_errno_location_stub(SB), NOSPLIT|NOFRAME, $0-0 diff --git a/internal/syscall/errno_unix.go b/internal/syscall/errno_unix.go index cde7b61..f815301 100644 --- a/internal/syscall/errno_unix.go +++ b/internal/syscall/errno_unix.go @@ -1,11 +1,12 @@ -//go:build (linux || darwin || freebsd) && (amd64 || arm64) +//go:build ((linux && !android) || (android && !cgo) || darwin || freebsd) && (amd64 || arm64) package syscall import "unsafe" // goffi_errno_location_stub is the JMP trampoline that forwards calls to the -// dynamically linked __errno_location (Linux/FreeBSD) or __error (macOS). +// dynamically linked __errno_location (glibc Linux), __errno (Bionic), or +// __error (macOS/FreeBSD). // The symbol is defined in errno_stubs_amd64.s / errno_stubs_arm64.s and // jumps to the goffi_errno_location dynamic symbol imported via // //go:cgo_import_dynamic in errno_linux.go / errno_darwin.go / errno_freebsd.go. @@ -20,7 +21,8 @@ var goffi_errno_location_stub byte var errnoFnABI0 = uintptr(unsafe.Pointer(&goffi_errno_location_stub)) //nolint:govet // unsafe.Pointer-to-uintptr is intentional: this is an assembly stub address, not a GC-managed pointer. // ErrnoFnAddr returns the address of the platform's errno-location function -// (__errno_location on Linux/FreeBSD, __error on macOS). This address is +// (__errno_location on glibc Linux, __errno on Bionic, __error on +// macOS/FreeBSD). This address is // passed to CallNFloatErrno to enable in-trampoline errno capture. // // Returns 0 if errno capture is not supported on the current platform. diff --git a/scripts/check-android-arm64.sh b/scripts/check-android-arm64.sh new file mode 100755 index 0000000..df0e392 --- /dev/null +++ b/scripts/check-android-arm64.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cross-build and inspect Android arm64/API 29+ artifacts. The script is +# intentionally device-free: compile-time Go/C ABI checks and ELF dependency +# checks catch the portability regressions that a host runner can observe. + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +NDK=${ANDROID_NDK_HOME:-} +if [[ -z "$NDK" ]]; then + cat >&2 <<'EOF' +ANDROID_NDK_HOME must point at an Android NDK r29 installation. +EOF + exit 2 +fi + +case "$(uname -s)-$(uname -m)" in + Darwin-arm64|Darwin-x86_64) host_tag=darwin-x86_64 ;; + Linux-x86_64) host_tag=linux-x86_64 ;; + *) echo "unsupported NDK host: $(uname -s)-$(uname -m)" >&2; exit 2 ;; +esac + +toolchain="$NDK/toolchains/llvm/prebuilt/$host_tag/bin" +cc="$toolchain/aarch64-linux-android29-clang" +readelf="$toolchain/llvm-readelf" +[[ -x "$cc" ]] || { echo "missing Android compiler: $cc" >&2; exit 2; } +[[ -x "$readelf" ]] || { echo "missing llvm-readelf: $readelf" >&2; exit 2; } + +# This port deliberately depends on the Android arm64 startup ABI in the Go +# runtime. Fail closed when the selected toolchain is outside the audited +# lines, or when any audited source invariant changes. +go_version=$(go env GOVERSION) +case "$go_version" in + go1.25.12|go1.26.5) ;; + *) + echo "unsupported Go runtime source for Android fakecgo: $go_version" >&2 + echo "audit the new runtime/cgo Android arm64 startup ABI before extending this gate" >&2 + exit 2 + ;; +esac + +goroot=$(go env GOROOT) +runtime_asm="$goroot/src/runtime/asm_arm64.s" +runtime_tls="$goroot/src/runtime/tls_arm64.s" +runtime_android_cgo="$goroot/src/runtime/cgo/gcc_android.c" + +require_source_pattern() { + local file=$1 + local pattern=$2 + local description=$3 + grep -Eq "$pattern" "$file" || { + echo "Go $go_version runtime drift: missing $description in $file" >&2 + exit 1 + } +} + +android_callsite=$(awk ' + /^#ifdef GOOS_android$/ { capture = 1 } + capture { print } + capture && /BL[[:space:]]+\(R12\)/ { exit } +' "$runtime_asm") +for pattern in \ + 'MRS_TPIDR_R0' \ + 'MOVD[[:space:]]+R0,[[:space:]]*R3' \ + 'MOVD[[:space:]]+\$runtime·tls_g\(SB\),[[:space:]]*R2' \ + 'MOVD[[:space:]]+\$setg_gcc<>\(SB\),[[:space:]]*R1' \ + 'MOVD[[:space:]]+g,[[:space:]]*R0' \ + 'BL[[:space:]]+\(R12\)' +do + grep -Eq "$pattern" <<<"$android_callsite" || { + echo "Go $go_version runtime drift in Android _cgo_init callsite: $pattern" >&2 + exit 1 + } +done +require_source_pattern "$runtime_tls" 'DATA runtime·tls_g\+0\(SB\)/8,[[:space:]]*\$16' 'API-29 TLS slot-2 offset' +require_source_pattern "$runtime_android_cgo" '^#define TLS_SLOT_APP 2$' 'Bionic TLS_SLOT_APP contract' +require_source_pattern "$runtime_android_cgo" 'dlsym\(handle, "android_get_device_api_level"\)' 'Android API-level startup probe' + +# GOOS=android also satisfies linux build constraints. The shared fakecgo +# wiring must therefore remain selected: iscgo=true admits outbound cgocall, +# while the hooks satisfy the runtime paths enabled by that state. Public +# Android callbacks are rejected separately in ffi. +echo "Checking Android fakecgo runtime wiring" +android_fakecgo_files=$( + GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ + go list -f '{{range .GoFiles}}{{println .}}{{end}}' ./internal/fakecgo +) +for required in callbacks.go iscgo.go setenv.go; do + grep -Fxq "$required" <<<"$android_fakecgo_files" || { + echo "Android fakecgo runtime wiring omitted $required" >&2 + exit 1 + } +done + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +echo "Checking generated Android fakecgo sources" +generator_dir="$tmp/fakecgo-generator" +mkdir -p "$generator_dir" +cp "$ROOT/internal/fakecgo/gen.go" "$generator_dir/gen.go" +( + cd "$generator_dir" + go run gen.go +) +for generated in \ + symbols.go \ + symbols_android.go \ + symbols_android_imports.go \ + symbols_darwin.go \ + symbols_freebsd.go \ + symbols_linux.go \ + symbols_netbsd.go \ + trampolines_stubs.s \ + trampolines_stubs_android.s +do + cmp "$ROOT/internal/fakecgo/$generated" "$generator_dir/$generated" || { + echo "generated fakecgo source is stale: $generated" >&2 + exit 1 + } +done + +echo "Checking NDK C ABI headers" +"$cc" -std=c11 -Wall -Werror -fsyntax-only "$ROOT/testdata/android_abi_probe.c" + +for cgo in 0 1; do + echo "Building Android arm64 (CGO_ENABLED=$cgo)" + if [[ "$cgo" == 1 ]]; then + CC="$cc" GOOS=android GOARCH=arm64 CGO_ENABLED=1 \ + go test -exec=true ./... 2>&1 + CC="$cc" GOOS=android GOARCH=arm64 CGO_ENABLED=1 \ + go vet ./... + CC="$cc" GOOS=android GOARCH=arm64 CGO_ENABLED=1 \ + go test -c -o "$tmp/ffi-cgo.test" ./ffi + else + GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ + go test -exec=true ./... 2>&1 + GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ + go vet ./... + GOOS=android GOARCH=arm64 CGO_ENABLED=0 \ + go test -c -o "$tmp/ffi-nocgo.test" ./ffi + fi + + artifact="$tmp/ffi-$([[ "$cgo" == 1 ]] && echo cgo || echo nocgo).test" + file_header=$("$readelf" -h "$artifact") + grep -q 'Machine:.*AArch64' <<<"$file_header" || { + echo "unexpected machine in $artifact" >&2 + exit 1 + } + dynamic=$("$readelf" -d "$artifact") + if grep -Eiq 'GLIBC|libpthread|\.so\.2|libc\.so\.6|libdl\.so\.2|__errno_location' <<<"$dynamic"; then + echo "forbidden non-Bionic dependency in $artifact" >&2 + grep -Ei 'GLIBC|libpthread|\.so\.2|libc\.so\.6|libdl\.so\.2|__errno_location' <<<"$dynamic" >&2 + exit 1 + fi + dynsyms=$("$readelf" --dyn-syms "$artifact") + if grep -Eiq '__errno_location|callback|trampoline' <<<"$dynsyms"; then + echo "forbidden callback/glibc symbol in $artifact" >&2 + grep -Ein '__errno_location|callback|trampoline' <<<"$dynsyms" >&2 + exit 1 + fi + grep -q 'Shared library: \[libc\.so\]' <<<"$dynamic" || { + echo "missing Bionic libc dependency in $artifact" >&2 + exit 1 + } + if [[ "$cgo" == 0 ]]; then + # The fakecgo startup trampoline must preserve the four AAPCS64 input + # registers and tail-call the Go implementation indirectly. The Go + # implementation must enter the API/TLS guard before any ordinary cgo + # path; fakecgo x_cgo_init must not call runtime.cgocall itself. + trampoline=$(go tool objdump -s '^x_cgo_init_trampoline$' "$artifact") + grep -q 'CALL (R9)' <<<"$trampoline" || { + echo "x_cgo_init trampoline is not an indirect AAPCS64 call" >&2 + exit 1 + } + if awk ' + /MOVD/ { + line = $0 + gsub(/[^[:alnum:]]/, " ", line) + count = split(line, fields) + for (i = 1; i <= count; i++) { + if (fields[i] ~ /^R[0-3]$/) found = 1 + } + } + END { exit found ? 0 : 1 } + ' <<<"$trampoline"; then + echo "x_cgo_init trampoline clobbers an AAPCS64 input register" >&2 + exit 1 + fi + startup=$(go tool objdump -s '^github.com/go-webgpu/goffi/internal/fakecgo.x_cgo_init$' "$artifact") + grep -q 'CALL github.com/go-webgpu/goffi/internal/fakecgo.x_cgo_inittls' <<<"$startup" || { + echo "x_cgo_init does not enter the Android TLS guard" >&2 + exit 1 + } + guard_line=$(grep -n 'CALL github.com/go-webgpu/goffi/internal/fakecgo.x_cgo_inittls' <<<"$startup" | head -n1 | cut -d: -f1) + call5_line=$(grep -n 'CALL github.com/go-webgpu/goffi/internal/fakecgo.call5.abi0' <<<"$startup" | head -n1 | cut -d: -f1 || true) + if [[ -n "$call5_line" && "$guard_line" -ge "$call5_line" ]]; then + echo "x_cgo_init reaches a Bionic call before the Android TLS guard" >&2 + exit 1 + fi + if grep -q 'runtime.cgocall' <<<"$startup"; then + echo "x_cgo_init reached runtime.cgocall before startup guard" >&2 + exit 1 + fi + + # Loader failures must capture and duplicate dlerror before the one + # runtime.cgocall returns; dlerror state is local to that OS thread. + open_wrapper=$(go tool objdump -s '^androidDlopenWrapper$' "$artifact") + sym_wrapper=$(go tool objdump -s '^androidDlsymWrapper$' "$artifact") + open_calls=$(grep -c 'CALL (R10)' <<<"$open_wrapper") + sym_calls=$(grep -c 'CALL (R10)' <<<"$sym_wrapper") + if [[ "$open_calls" -ne 3 || "$sym_calls" -ne 4 ]]; then + echo "Android loader wrapper no longer captures dlerror in-call" >&2 + exit 1 + fi + fi + done + +echo "Android arm64/API 29+ checks passed" diff --git a/testdata/android_abi_probe.c b/testdata/android_abi_probe.c new file mode 100644 index 0000000..f073813 --- /dev/null +++ b/testdata/android_abi_probe.c @@ -0,0 +1,28 @@ +/* + * Compile-only ABI probe for Android arm64/API 29+. + * Keep this independent from Go so NDK header drift fails before a device run. + */ +#include +#include +#include +#include + +_Static_assert(sizeof(sigset_t) == 8, "Bionic LP64 sigset_t must be one word"); +_Static_assert(_Alignof(sigset_t) == 8, "Bionic LP64 sigset_t alignment changed"); +_Static_assert(sizeof(pthread_attr_t) == 56, "Bionic LP64 pthread_attr_t changed"); +_Static_assert(_Alignof(pthread_attr_t) == 8, "Bionic LP64 pthread_attr_t alignment changed"); +_Static_assert(sizeof(pthread_cond_t) == 48, "Bionic LP64 pthread_cond_t changed"); +_Static_assert(_Alignof(pthread_cond_t) == 4, "Bionic LP64 pthread_cond_t alignment changed"); +_Static_assert(sizeof(pthread_mutex_t) == 40, "Bionic LP64 pthread_mutex_t changed"); +_Static_assert(_Alignof(pthread_mutex_t) == 4, "Bionic LP64 pthread_mutex_t alignment changed"); +_Static_assert(sizeof(pthread_t) == 8, "Bionic LP64 pthread_t changed"); +_Static_assert(sizeof(pthread_key_t) == 4, "Bionic pthread_key_t changed"); +_Static_assert(sizeof(stack_t) == 24, "Bionic LP64 stack_t changed"); +_Static_assert(offsetof(stack_t, ss_size) == 16, "Bionic stack_t layout changed"); +_Static_assert(RTLD_NOW == 2, "Bionic RTLD_NOW changed"); +_Static_assert(RTLD_LOCAL == 0, "Bionic RTLD_LOCAL changed"); +_Static_assert(RTLD_NODELETE == 0x1000, "Bionic RTLD_NODELETE changed"); + +int main(void) { + return pthread_detach((pthread_t)0) == 0; +}