Repository navigation
ABI stability: exported FFI symbol signatures must not change — add versioned exports instead #19
Description
Activity
The rule is right. The trigger is wrong.
Nothing has shipped yet:
gh release listis empty and the remote carries no tags. The0.1.0heading inCHANGELOG.mdis unreleased. So no binary anywhere was compiled against a five-argumentnodedb_graph_insert_edge, and the wild-write scenario has no caller to happen to.That is why f9c520c on #15 collapses
nodedb_graph_insert_edge_with_idback into a single six-argumentnodedb_graph_insert_edge. A legacy alias kept for zero existing callers is permanent surface: two exports, two doc blocks, two test paths, and a name that says_with_idlong after everyone uses it.Proposed wording for the rule:
- Exported
#[no_mangle]signatures freeze at the first tagged release. - Before that tag, change the signature in place and regenerate
include/nodedb_lite.h. - After that tag, add a separately named export; the legacy entry point delegates and discards the new output.
The follow-ups stand on their own and are worth doing:
- The CONTRIBUTING.md paragraph, with the freeze point stated explicitly.
- The
nm -D --defined-only libnodedb_lite_ffi.so | sortdiff in CI, comparing against the previous release tag. It is a no-op until the first tag exists, which is the correct behaviour. - Bumping
nodedb_abi_versionon any signature change, so bindings fail fast.
- Exported
Agreed — the trigger was wrong. Nothing has shipped (no releases, no tags; 0.1.0 unreleased), so there is no five-argument caller to protect. Rule corrected: signatures freeze at the first tagged release; before that tag, change in place and regenerate the header. Issue body updated to the corrected wording. Follow-ups (CONTRIBUTING paragraph, nm -D CI diff vs previous tag, nodedb_abi_version bump) stand as proposed.
All three follow-ups shipped in
b491ebaonmain.What changed
CONTRIBUTING.md— freeze point stated: signatures freeze at the first tagged release.nodedb-lite-ffi/abi/surface.txt— every export recorded as a full declaration, C and JNI..github/workflows/test.yml—nm -Ddiff against that record, plusgit diff --exit-codeon the generated header.nodedb-lite-ffi/src/version.rs— bump rule documented. Adding an export is compatible. Changing or removing one is breaking.nodedb-lite-ffi/tests/abi_surface.rs— the record carriesabi_version; a test fails when it disagrees withnodedb_abi_version().
The
nm -Dcheck runs on every CI run, not only at release. It compares against the committed record, so it works before the first tag — the period the surface changes most.Full declarations, not names and arity. A return widening from
uint32_ttouint64_tkeeps the name and the argument count.Two things the audit found
- Two prefixes in the C API: 27
nodedb_*against 6ndb_array_*. Renamed tonodedb_array_*. Names freeze with signatures, so that window closes at the first tag. - 22 JNI exports against 14 Kotlin declarations.
nativeGenerateId,nativeGenerateIdTypedand sixnativeArray*had no Kotlin side. Declarations and wrappers added; two parity tests hold both sides together.
How to check
cargo nextest run -p nodedb-lite-ffi -E 'binary(abi_surface)'Re-record after a deliberate surface change:
UPDATE_ABI_SNAPSHOT=1 cargo nextest run -p nodedb-lite-ffi -E 'binary(abi_surface)'Each drift class was injected and the failure confirmed:
Fault Caught by Kotlin extern loses a parameter kotlin_arity_matches_every_jni_exportKotlin declaration deleted kotlin_declares_every_jni_exportnodedb_abi_versionwidened tou64surface_matches_snapshotThe first is the crash class from #15.
What none of this catches
Ownership changes keep every signature identical. A returned pointer changing owner breaks every caller silently.
docs/ffi-abi.mdstates the current rules; review is the only guard.docs/ffi-abi.mdis written for adapter authors: the freeze point, the equality check to write againstnodedb_abi_version(), and the memory rules.abi/surface.txtships in the Linux, Android and iOS release artifacts, so an adapter author diffs two releases without cloning.
Summary
Exported FFI symbol signatures freeze at the first tagged release. Before that tag, change the signature in place and regenerate
include/nodedb_lite.h. After that tag, add a separately named export; the legacy entry point delegates and discards the new output.Context
PR #15 initially added an
out_edge_idparameter to the existingnodedb_graph_insert_edgeexport. For a shipped binary this is an ABI break: a caller compiled against the old declaration still resolves the same symbol, but the callee reads a sixth register/stack argument the old caller never initialized — if it happens to be non-null,write_c_stringcan dereference an arbitrary address.Nothing has shipped yet (no releases, no tags; the
0.1.0heading in CHANGELOG.md is unreleased), so no binary anywhere was compiled against a five-argumentnodedb_graph_insert_edge. Per the pre-release rule, #15 was merged with a single six-argumentnodedb_graph_insert_edge(f9c520c) — no legacy alias for zero existing callers.Follow-ups
nm -D --defined-only libnodedb_lite_ffi.so | sortagainst the previous release tag. No-op until the first tag exists, which is the correct behaviour.nodedb_abi_versionon any signature change so bindings fail fast.