From 69d8f964e390fbd6bf296a9f9384b0ffd3db6051 Mon Sep 17 00:00:00 2001 From: unxed Date: Thu, 17 Sep 2026 11:16:41 +0000 Subject: [PATCH 1/2] ci: stop setup-android from requesting the removed `tools` package Both Android arm64 jobs now fail in "Set up Android SDK", before any goffi code runs: Warning: Failed to find package 'tools' Error: The process '/usr/local/lib/android/sdk/cmdline-tools/16.0/bin/sdkmanager' failed with exit code 1 android-actions/setup-android@v3 defaults its `packages` input to `tools platform-tools` and runs `sdkmanager ` for each entry. Google's SDK repository index (repository2-3.xml) no longer contains a `tools` package, while `platform-tools` and `cmdline-tools` are still listed, so the `tools` call exits 1. The same breakage is tracked upstream in android-actions/setup-android#537. The last green run of these jobs on main was for c8f74c6 on 2026-09-10; the workflow has not changed since, and the failure is identical on a docs-only PR. Nothing in this workflow or in scripts/check-android-arm64.sh uses the `tools` package. Pass `packages: platform-tools`, which keeps everything the step installed before except the package that no longer exists. The action still accepts licenses, exports ANDROID_HOME and puts sdkmanager on PATH, which the following NDK step relies on. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 954202a..536cb76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,8 +186,15 @@ jobs: go-version: ${{ matrix.go }} cache: true + # setup-android installs `tools platform-tools` by default. Google's SDK + # repository no longer lists the obsolete `tools` package, so + # `sdkmanager tools` exits 1 and fails the step + # (android-actions/setup-android#537). Nothing here uses `tools`; request + # only platform-tools. The NDK is installed explicitly below. - name: Set up Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Install Android NDK r29 shell: bash From 3f9133f538ff630efed6ad83dc0a86ceb96698c1 Mon Sep 17 00:00:00 2001 From: Ivan Sorokin Date: Thu, 17 Sep 2026 13:05:05 +0200 Subject: [PATCH 2/2] docs: record what callbacks accept per platform, and the arm64 aggregate gap NewCallback answers an unsupported signature with a panic, and two of the three limits behind those panics are undocumented, so they read as bugs: panic: ffi: unsupported callback argument type: struct panic: ffi: Windows callbacks require uintptr-sized return type, got int32 docs/CALLBACK_ABI.md puts the three rows in one table -- amd64 Unix takes struct-by-value arguments, arm64 does not, Windows takes uintptr-sized arguments and one uintptr-sized result and nothing else -- and separates the limit that is permanent from the one that is not. Windows is the platform's own contract: NewCallback delegates to syscall.NewCallback, which accepts nothing more without cgo, and purego documents the same restriction. arm64 is unfinished work. For that one the page carries what an implementer needs rather than a wish: where amd64 does the equivalent (callbackWrap's eightbyte classification in ffi/callback.go), the four AAPCS64 cases to cover, the two that are easy to get wrong -- a four-float32 HFA occupying S0-S3, i.e. the low halves of four V registers the frame stores as 64-bit slots, and register exhaustion sending the whole aggregate to the stack with no backfill -- and Apple's naturally aligned stack packing, which means any implementation has to be checked against both a Linux and an Apple toolchain. It also points at ffi/callback_struct_args_test.go as the model for testing classification with a hand-built frame, no C toolchain and no real foreign call needed. Callers get the workaround in one line: take a pointer instead of a value. The panic site in ffi/callback_arm64.go now points at the page, so the message someone actually sees leads to the explanation. --- docs/CALLBACK_ABI.md | 78 +++++++++++++++++++++++++++++++++++++++++++ ffi/callback_arm64.go | 6 ++++ 2 files changed, 84 insertions(+) create mode 100644 docs/CALLBACK_ABI.md diff --git a/docs/CALLBACK_ABI.md b/docs/CALLBACK_ABI.md new file mode 100644 index 0000000..8f6da4e --- /dev/null +++ b/docs/CALLBACK_ABI.md @@ -0,0 +1,78 @@ +# Callback argument support, per platform + +`ffi.NewCallback` turns a Go function into a C function pointer. What that +function may look like is narrower than what the calling direction supports, +and it is not the same everywhere. This page says where the limits are and why, +so a caller can tell an unimplemented case from a broken one. + +## What is supported today + +| | integers, pointers, bool | float32 / float64 | struct by value | +|---|---|---|---| +| linux / darwin / freebsd, **amd64** | yes | yes | **yes** | +| linux / darwin / freebsd, **arm64** | yes | yes | **no** — panics | +| **windows** (amd64, arm64) | uintptr-sized only | no | no | +| **android** arm64 | rejected by design (see `docs/ANDROID.md`) | | | + +Return values follow the same rows: amd64 and arm64 return integers, pointers, +bool and floats; Windows returns exactly one uintptr-sized value. + +Windows is not a gap to close. `ffi.NewCallback` there delegates to Go's +`syscall.NewCallback`, which accepts only uintptr-sized arguments and one +uintptr-sized result; that is the whole contract the platform offers without +cgo, and `purego` documents the same one. + +## The arm64 gap + +On arm64 `validateCallbackSignature` (`ffi/callback_arm64.go`) rejects any +struct argument: + +``` +panic: ffi: unsupported callback argument type: struct +``` + +The amd64 dispatcher classifies aggregates per the System V ABI — one eightbyte +or two, INTEGER or SSE per eightbyte, MEMORY above 16 bytes — in +`callbackWrap` (`ffi/callback.go`), with `classifyEightbyte` and +`isStructAllFloats` doing the work. The arm64 dispatcher has no equivalent: it +reads each argument from one D or X register slot, or one stack slot, and an +aggregate does not fit that shape. + +This is missing work, not a platform limit. AAPCS64 defines the classification +and nothing about it is out of reach: + +- **HFA / HVA** — an aggregate whose members are all the same floating-point + type, at most four of them, goes in that many consecutive V registers. A + struct of four `float32` is the awkward case: the members occupy S0–S3, i.e. + the low 32 bits of four consecutive V registers, while the dispatcher's frame + stores each V register as a 64-bit slot. +- **Small aggregates (≤ 16 bytes)** — passed in one or two X registers, + as if the struct were copied into 8-byte chunks. +- **Larger aggregates** — passed indirectly: the caller places a copy in memory + and passes its address in one X register. +- **Register exhaustion** — when the required registers are not all available, + the whole aggregate goes on the stack, and the remaining registers are *not* + backfilled by later arguments. +- **Apple** — darwin/arm64 packs stack arguments to their natural alignment + rather than 8-byte slots, so a stack-passed aggregate needs its own handling + there. Any implementation has to be checked against both a Linux and an Apple + toolchain, not one of them. + +The calling direction (`ffi.CallFunction` with a struct argument) already does +classify aggregates on arm64; only the callback direction is missing. + +## Working around it + +Take a pointer instead of a value. `func(r *Rect)` is one X register on every +platform in the table, costs nothing, and is what most C APIs that hand a +struct to a callback do anyway. + +## If you are implementing this + +`ffi/callback_struct_args_test.go` is the amd64 model: it drives `callbackWrap` +directly with a hand-built argument frame, so the classification can be tested +without a C toolchain and without a real foreign call. An arm64 counterpart +building an AAPCS64 frame is the way to start, with the four-`float32` HFA and +a > 16-byte aggregate as the first two cases. `unxed/pureffi`'s +`TestFullCircleFFIStruct` is a ready end-to-end check: it skips on arm64 today +and would start running. diff --git a/ffi/callback_arm64.go b/ffi/callback_arm64.go index d3edee8..53a710a 100644 --- a/ffi/callback_arm64.go +++ b/ffi/callback_arm64.go @@ -72,6 +72,12 @@ func validateCallbackSignature(typ reflect.Type) { reflect.Ptr, reflect.UnsafePointer, reflect.Bool: // Valid types default: + // Struct arguments land here. The amd64 dispatcher classifies + // aggregates (System V eightbytes); the arm64 one has no AAPCS64 + // equivalent yet, so the signature is rejected up front rather + // than silently misread. See docs/CALLBACK_ABI.md -- it describes + // what the implementation needs and how to test it, and the + // pointer workaround for callers. panic("ffi: unsupported callback argument type: " + argType.Kind().String()) } }