Skip to content

QVAC-25104 fix: name fabric's ELF version node only where it owns the runtime - #4566

Merged
gianni-cor merged 2 commits into
mainfrom
fix/qvac-25104-fabric-android-version-node
Sep 18, 2026
Merged

gianni-cor merged 2 commits into
mainfrom
fix/qvac-25104-fabric-android-version-node

Conversation

@jpgaribotti

@jpgaribotti jpgaribotti commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Regression in 0.16.0 (#4514), Android only. The six consumers released against 0.16.0 today (classification-ggml, embed-llamacpp, model-fit, ocr-ggml, translation-nmtcpp, vla-ggml) need a rebuild and a patch release once this is on npm — see Breaking changes.

🎯 What problem does this PR solve?

  • Every addon fails to load on Android against fabric 0.16.0. bare reports AddonError: ADDON_NOT_FOUND: Cannot find addon '.' from the addon's binding.js, before any model work, so the failure hits the first load in every suite.
  • 0.16.0 named the version node for every ELF target, not just the Linux link the name was for. The Android module therefore defines QVAC_FABRIC_ABI_1 and stamps it on all 4296 exports, and a consumer linked against it records a DT_VERNEED on that version — 153 pinned imports in llm-llamacpp's case. With that DT_VERNEED present the addon's dlopen fails, and bare's resolver reports the addon as missing.
  • Not a gap in bionic's symbol versioning, which has been there since API 23. Bionic resolves a version need through the DT_SONAME of the dependency that declares it, and on device an addon's fabric dependency is not a file at all: the APK stages fabric as libqvac__fabric.0.16.0.so while the DT_NEEDED and the verneed both name qvac__fabric@0.bare, which resolves only because bare has already loaded it. Either bionic finds no dependency matching the verneed and fails the dlopen outright, or it finds no such version there and demotes the requirement to unversioned definitions — which, under a named node, no export has. Both end in a failed dlopen. Bare walks its candidates and discards each dlerror, so the loader's own objection is not recoverable from the run: the full logcat has no linker diagnostic for our libraries, only ADDON_NOT_FOUND. tts-ggml 0.2.2 hit the same signature from unresolvable UND symbols, which is what fixes its meaning as "the dlopen failed".
  • Confirmed as a fabric regression rather than anything in a consumer: @qvac/embed-llamacpp@0.41.1 (fabric 0.15.0) passes the Android suite, and 0.41.2 (fabric 0.16.0, no source change to the addon's load path) fails identically to the llm-llamacpp migration branch in QVAC-22415 feat[api]: migrate @qvac/llm-llamacpp to the shared @qvac/fabric runtime #4454, which is where this surfaced. The node's name is the only ELF-visible difference between the two fabrics on Android: diffed with comments stripped, 0.15.0's and 0.16.0's version scripts differ by one line, { against QVAC_FABRIC_ABI_1 {, with an identical export list.
  • Desktop is unaffected, which is why it got through: Linux, macOS and Windows all pass on 0.16.0, and Android is not part of on-pr-fabric.

📝 How does it solve it?

  • The name exists for one reason — to keep the host's libstdc++ from answering for the C++ ABI fabric exports — so it now follows the same condition as the ABI block it protects: the one Linux link that embeds libc++. Android links libc++_shared.so and the ASan build links libc++.so.1; neither exports a runtime for a consumer to pin to, so there the DT_VERNEED guarded an export set that does not exist and only cost the dlopen. The fix rests on that — the name buys nothing on those links — rather than on which of the two loader paths above we hit.
  • symbols.map ships the node anonymous, and those links pass it to the linker unchanged. The Linux link is the one that transforms it: the ABI block is spliced in at its marker and the name stamped at its own, both plain string replacements with a fail-on-miss guard, so dropping either is a configure error rather than a module that links and runs with nothing pinned.
  • This inverts 0.16.0's arrangement, where the file carried the name and QVAC_FABRIC_ABI_1 was declared in two places that a configure-time assertion kept in agreement. CMakeLists.txt is now its only definition — the script and the package config are both stamped from it — so there is nothing left to drift.
  • The export surface and its local: *; narrowing are unchanged on every platform: only the node header differs between the two links. The generated Linux script is byte-identical to 0.16.0's, so nothing about the Linux fix moves.
  • Also registers the version script as a LINK_DEPENDS on every ELF path, matching how qvac-addon.cmake handles addon-symbols.map. It was only tracked on the path that generates a script, so editing symbols.map did not relink the Android or ASan module — which is how I noticed, doing the A/B below.
  • The release bump is folded in, as in QVAC-25104 fix: pin consumers' C++ runtime with a named ELF version node #4514: package.json goes to 0.16.1 and the changelog entry is written as ## [0.16.1], so the release can be cut straight from a release-fabric-0.16.1 branch. The lockfile needs no regeneration — consumers resolve fabric as link:../fabric against ^0.16.0, which 0.16.1 satisfies.

🧪 How was it tested?

A/B on a local arm64-android link (NDK 29, ANDROID_STL=c++_shared), same tree and toolchain, differing only in this commit:

build .gnu.version_d versioned exports
main (0.16.0 shape) defines QVAC_FABRIC_ABI_1 4296
this branch no section at all none
  • The fixed module's only version needs are bionic's own LIBC / LIBC_N from libc.so, libm.so and libdl.so — the shape 0.15.0 had, and the shape that loads on device. With no version defined, a consumer records no DT_VERNEED on fabric, so none of the machinery above runs.
  • Export surface unchanged at 4750 global/weak symbols, 3354 of them llama_* / ggml_*.
  • Configure takes the intended branch: no symbols-linux.map is generated in the Android tree, the link line points straight at packages/fabric/symbols.map, and qvac-fabricConfig.cmake reports QVAC_FABRIC_OWNS_CXX_RUNTIME "OFF" with an empty QVAC_FABRIC_ABI_VERSION.

I did not re-link Linux locally — its vcpkg configure wants the ROCm SDK for hip-backend. The generated Linux script is byte-identical (modulo comments) to the one that built and passed #4514's end-to-end validation, so CI's Linux leg is the check there.

This PR needs the prebuilds label so on-pr-fabric publishes this branch's fabric and the six consumers build against it. The on-device confirmation is the Android suite for #4454 re-run against a consumer built on this fabric; I'll post the run here.

💥 Breaking Changes

No JS/TypeScript API change, and no change to fabric's Linux ABI: QVAC_FABRIC_ABI_1 and the DT_VERNEED consumers record on it are exactly as 0.16.0 shipped them, so Linux binaries built against 0.16.0 keep working.

Android consumers must be rebuilt, which is what makes this a real upgrade rather than a drop-in: an Android module built against 0.16.0 carries versioned imports of QVAC_FABRIC_ABI_1, and this fabric no longer defines that version. Whether such a module happens to load against it is exactly the loader question above that the run cannot answer, so it is not something to rely on either way — the six packages released against 0.16.0 today need a patch release off this one regardless.

… runtime

0.16.0 named the version node for every ELF target, so the Android module
defined QVAC_FABRIC_ABI_1 and stamped it on every export. Consumers then
recorded a DT_VERNEED on it, which bionic refuses on dlopen: every addon failed
to load with `ADDON_NOT_FOUND: Cannot find addon '.'` before any model work.

The name exists to keep the host's libstdc++ from answering for the C++ ABI
this module exports, so it now follows the same condition as the ABI block it
protects -- the one Linux link that embeds libc++. Android links
libc++_shared.so and the ASan build links libc++.so.1, and both export no
runtime for a consumer to pin to.

symbols.map therefore ships the node anonymous and those links pass it to the
linker unchanged; the Linux link splices in the ABI block and stamps the name
from CMakeLists.txt, which is now its only definition. The export surface and
its `local: *;` narrowing are unchanged on every platform, and the generated
Linux script is byte-identical to 0.16.0's.

Verified against a local arm64-android link: no .gnu.version_d section, and
only bionic's own LIBC version needs, matching 0.15.0. The same tree built from
main defines the node on 4296 exports.

Also registers the script as a LINK_DEPENDS on every ELF path. It was only
tracked on the path that generates one, so editing symbols.map did not relink
the Android or ASan module.
@jpgaribotti
jpgaribotti requested review from a team as code owners September 17, 2026 20:56
@jpgaribotti jpgaribotti added the prebuilds CI: run only prebuild/cache stage (requires verified) label Sep 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Review Status

Current Status: ❌ PENDING
Approvals so far: none

Pending reviews: Needs 1 Management or Team Lead, and 1 more from Management, Team Lead, or Member.

@github-actions

Copy link
Copy Markdown
Contributor

License compliance — clean

No new dependency license findings in this PR.

Warn-only (shadow) mode — this check does not block merges yet.

Updated automatically by the canonical license compliance workflow.

NOTICE presence (advisory)

Missing NOTICE (advisory, does not block):

  • ./docs/website
  • ./packages/fabric/test/integration
  • ./packages/llm-llamacpp/benchmarks/server
  • ./packages/llm-llamacpp/benchmarks/performance
  • ./packages/inference-addon-cpp/mobile
  • ./packages/asr-ggml/benchmarks/server
  • ./packages/embed-llamacpp/benchmarks/server
  • ./packages/embed-llamacpp/benchmarks/performance
  • ./packages/sdk/e2e
  • ./packages/vla-ggml/sim/server
  • ./.github/actions/release-merge-guard

gianni-cor
gianni-cor previously approved these changes Sep 17, 2026
GustavoA1604
GustavoA1604 previously approved these changes Sep 17, 2026
The changelog and symbols.map claimed Android's loader refuses versioned
imports. It does not: bionic has had symbol versioning since API 23, and the
app targets SDK 36.

What the run establishes is that the node's name is the only ELF difference
between a consumer that loads and one that does not. The loader's own objection
is not in evidence -- bare walks its addon candidates and discards each
dlerror, so the failing run's logcat carries no linker diagnostic for our
libraries, only ADDON_NOT_FOUND.

Both plausible bionic paths are recorded instead, since either ends in a failed
dlopen: it resolves a version need through the DT_SONAME of the dependency that
declares it, and on device the addon's fabric dependency is not a file -- the
APK stages it under another name and it resolves only because bare already
loaded it. So bionic either finds no dependency matching the verneed, or finds
no such version there and demotes the requirement to unversioned definitions,
which no export has under a named node.

The fix is unchanged and rests on the name being inert on those links.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prebuilds CI: run only prebuild/cache stage (requires verified)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants