Skip to content

QVAC-25104 fix: pin consumers' C++ runtime with a named ELF version node - #4514

Merged
jpgaribotti merged 4 commits into
mainfrom
fix/qvac-25104-fabric-cxx-abi-version
Sep 17, 2026
Merged

jpgaribotti merged 4 commits into
mainfrom
fix/qvac-25104-fabric-cxx-abi-version

Conversation

@jpgaribotti

@jpgaribotti jpgaribotti commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Part 1 of 2. This is the runtime half; it must be released to npm before the addon-template half (#4519) can land. Rationale, alternatives and measurements: arch/qips/linux-fabric-libcxx-ownership.md (updated here).

🎯 What problem does this PR solve?

  • On Linux, -nostdlib++ did not actually give a consumer addon fabric's C++ runtime. bare's executable links GNU libstdc++.so.6, so libstdc++ sits in the process' global lookup scope, which the dynamic linker searches before a dlopen'd module's own DT_NEEDED chain.
  • Addons therefore resolved __cxa_throw, __gxx_personality_v0 and the std:: typeinfo objects from libstdc++, while still resolving the libc++-only names from fabric. std::exception_ptr splits along exactly that seam, because std::current_exception and std::rethrow_exception have identical mangled names in both libraries: libstdc++ captures the throw, libc++abi re-raises it stamped CLNGC++, and GNU's personality routine will only match a foreign exception class against catch (...).
  • So every typed handler across the boundary was skipped and JSCATCH reported "Unknown error". That is not limited to the three known config-validation cases — every native error out of load came back as INTERNAL_ERROR / "Unknown error", including genuine load failures.
  • Fabric 0.15.0 (QVAC-25104 feat: make the Linux fabric module the process' one C++ runtime #4468, QVAC-25104 fix: let Linux addons catch exceptions thrown by fabric #4477) exported the right symbol set, but nothing bound consumers to it, so the exports were unreachable in practice.

📝 How does it solve it?

  • Give fabric's version node a name, QVAC_FABRIC_ABI_1. Consumers then record a DT_VERNEED on it, and libstdc++ cannot satisfy a versioned reference to a version it does not define. This one change is the fix; it also pins fabric's own internal references, which were being interposed the same way.
  • The node has to cover the whole export surface: an anonymous node cannot coexist with a named one (anonymous version tag cannot be combined with other version tags), and the spliced C++ ABI block must stay inside the same global: list to keep precedence over local: *;.
  • Nothing on the consumer's link line changes. The linker records the DT_VERNEED on its own, so the in-tree consumers pick the fix up simply by being rebuilt against this fabric — which is what CI does here.
  • Publish QVAC_FABRIC_ABI_VERSION and QVAC_FABRIC_OWNS_CXX_RUNTIME through qvac-fabricConfig.cmake, and fail configure if symbols.map does not declare the node fabric advertises, so the two cannot drift. QVAC_FABRIC_OWNS_CXX_RUNTIME is what lets a consumer's build tell a deliberately shared libc++ (Android's libc++_shared.so, the ASan build) apart from a fabric that is simply too old to pin; the template half in QVAC-25104 fix: assert addons pin fabric's C++ runtime and narrow exports #4519 uses both to assert the pin at build time.
  • Rewrite the rationale in symbols.map, symbols-linux-cxx-runtime.map and the QIP. All three described the cause as two static libc++ copies compared by typeinfo address, which is true but omits the interposition that actually shipped. Also corrects the claim that the dlopen'd ggml backends resolve ggml_* from fabric: all 16 linux-x64 and all 9 android-arm64 backends link ggml statically and import zero ggml_*, which is why the version node cannot affect them.
  • The release bump is folded in. package.json goes to 0.16.0 and the changelog entry is written as ## [0.16.0], so the release can be cut straight from a release-fabric-0.16.0 branch instead of via a separate release PR as in QVAC-25104 chore: release @qvac/fabric v0.15.0 #4481: release-merge-guard only requires package.json to match the branch version, and create-github-release reads that changelog section for the release notes.
  • The consumer ranges stay at ^0.15.0 here and move in QVAC-25104 fix: assert addons pin fabric's C++ runtime and narrow exports #4519 after the release is on npm, which is the ordering QVAC-25104 fix: let Linux addons catch exceptions thrown by fabric #4477 used for the 0.15.0 floor. Moving them in this PR was tried and does not work: the pr-head-ts-checks jobs install each package standalone with npm rather than through the pnpm workspace, so ^0.16.0 fails resolution with ETARGET — no matching version found while 0.16.0 is unpublished, taking six jobs down.
  • That has one consequence worth naming. linkWorkspacePackages: true stops linking the workspace fabric into those six, because 0.16.0 no longer satisfies ^0.15.0, so pnpm-lock.yaml is regenerated to record the published 0.15.0 for them. This is the same in-between state main sat in after QVAC-25104 chore: release @qvac/fabric v0.15.0 #4481 and before QVAC-25104 fix: let Linux addons catch exceptions thrown by fabric #4477, and it is content-identical for those builds — the published 0.15.0 is the fabric they link today. Native CI is unaffected regardless, since overlay-local-fabric stages this branch's own fabric into consumer node_modules when the fabric stack changes, which is what the prebuilds label drives. The window closes when QVAC-25104 fix: assert addons pin fabric's C++ runtime and narrow exports #4519 lands.
  • pnpm-lock.yaml is regenerated with the pinned pnpm 11.17.0 rather than hand-edited, so it also drops two orphaned @qvac/diffusion-cpp@0.23.0 entries that nothing has referenced since QVAC-24821 fix[bc]: integrate Fabric 10549.1.0 consumers in SDK #4439 moved sdk and inference onto the workspace copy.

🧪 How was it tested?

Locally on linux-x64, with fabric built from this branch and llm-llamacpp (the migration in #4454) built against it:

  • readelf on the rebuilt addon: DT_VERNEED on QVAC_FABRIC_ABI_1, 378 C++ runtime imports pinned to it, current_exception and rethrow_exception both resolving to fabric, and fabric still self-contained (no DT_NEEDED on libc++.so.1).
  • Under bare, the three previously failing scenarios surface their real messages instead of "Unknown error":
    • load-mode: bogus -> [ LLM :: InvalidArgument ] load-mode must be one of 'auto', 'none', ...
    • repeat_penalty: -1.1 -> [ LLM :: InvalidArgument ] commonParamsParse: error while handling argument "--repeat-penalty" ...
    • device: invalid -> [ General :: InvalidArgument ] preferredDeviceFromString: wrong device specified ...
    • and a genuine load failure now reports [ LLM :: UnableToLoadModel ].
  • dlsym on the plain names still resolves fabric's now-versioned bare_* entry points, which is how bare locates a module.
  • A minimal two-library repro confirmed the mechanism in isolation, and is also where the mixed-pairing result under Breaking changes was measured: with an unversioned provider the exception_ptr round-trip prints UNKNOWN ERROR; with a named version node the typed catch matches.

This PR needs the prebuilds label so on-pr-fabric publishes this branch's fabric and the consumers build against it. CI then covers the six already-migrated consumers (classification-ggml, embed-llamacpp, model-fit, ocr-ggml, translation-nmtcpp, vla-ggml) on the platforms I cannot build locally.

💥 Breaking Changes

No JS/TypeScript API change. This is a change to fabric's Linux native ABI:

  • Consumers must be rebuilt against this release, so it has to ship as a minor (0.16.0), never a patch. A mixed pairing is worse than either half, and silent. Measured on the minimal repro (a host linking libstdc++ as bare does, dlopening fabric RTLD_GLOBAL and the module RTLD_LOCAL):

    fabric consumer direct throw from fabric exception_ptr round-trip
    unversioned (0.15.0) unversioned caught by type catch (...)
    versioned unversioned, not rebuilt catch (...) catch (...)
    versioned versioned caught by type caught by type

    Before the node, both sides resolved the runtime from libstdc++ and so accidentally agreed on one, which is why a direct throw was catchable and only the exception_ptr path failed. Pinning fabric's internal references removes that accident, so a consumer that is not rebuilt now genuinely disagrees with fabric and loses the typed catch it used to get. It still loads — an unversioned reference binds to a default-versioned definition — so the regression would be silent. A caret on a 0.x version locks the minor, so ^0.15.0 is what keeps already-published consumers away from it; a 0.15.1 would reach them and degrade them.

  • The node name QVAC_FABRIC_ABI_1 is itself part of the contract: renaming it is a rebuild of every consumer, and a consumer built against the wrong node does not fail to load, it reverts to the host runtime.

  • Android exports the same set, now version-stamped. Its consumers share libc++_shared.so rather than importing the runtime from fabric, so they are unaffected. Darwin, iOS and Windows use no version script and are untouched.

  • The in-tree consumers are built from source in CI and so are covered here. Their npm ranges move to ^0.16.0 in QVAC-25104 fix: assert addons pin fabric's C++ runtime and narrow exports #4519, once this release is published — see above for why they cannot move before then.

@jpgaribotti
jpgaribotti requested review from a team as code owners September 16, 2026 17:54
@jpgaribotti jpgaribotti added prebuilds CI: run only prebuild/cache stage (requires verified) run-cpp-addon-tests CI: run C++ addon tests (requires verified) run-desktop-addon-tests CI: run desktop integration tests (requires verified) labels Sep 16, 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

@jpgaribotti jpgaribotti self-assigned this Sep 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🧪 C++ Test Coverage Report

Coverage:

📊 Detailed Coverage
Filename                         Regions    Missed Regions     Cover   Functions  Missed Functions  Executed       Lines      Missed Lines     Cover    Branches   Missed Branches     Cover
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
NmtLazyInitializeBackend.cpp          99                19    80.81%          11                 1    90.91%         157                34    78.34%          66                20    69.70%
NmtLazyInitializeBackend.hpp           2                 0   100.00%           1                 0   100.00%           1                 0   100.00%           0                 0         -
TranslationModel.cpp                 296               168    43.24%          28                 8    71.43%         506               213    57.91%         181               122    32.60%
TranslationModel.hpp                   1                 0   100.00%           1                 0   100.00%           1                 0   100.00%           0                 0         -
nmt.cpp                               72                22    69.44%           9                 1    88.89%         137                28    79.56%          44                16    63.64%
nmt.hpp                               51                 4    92.16%          11                 2    81.82%          53                 4    92.45%          28                 0   100.00%
nmt_beam_search.cpp                  116                25    78.45%          10                 3    70.00%         254                32    87.40%          76                19    75.00%
nmt_graph_decoder.cpp                164                78    52.44%          15                 7    53.33%         540               161    70.19%         112                69    38.39%
nmt_graph_encoder.cpp                 54                13    75.93%           3                 0   100.00%         268                33    87.69%          37                16    56.76%
nmt_loader.cpp                       270                67    75.19%          14                 0   100.00%         774                97    87.47%         161                67    58.39%
nmt_state_backend.cpp                188                46    75.53%          20                 0   100.00%         424                79    81.37%         121                51    57.85%
nmt_tokenization.cpp                  88                21    76.14%           8                 0   100.00%         135                36    73.33%          61                26    57.38%
nmt_utils.cpp                        211                34    83.89%          21                 0   100.00%         314                38    87.90%         143                45    68.53%
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
TOTAL                               1612               497    69.17%         152                22    85.53%        3564               755    78.82%        1030               451    56.21%

@github-actions

Copy link
Copy Markdown
Contributor

🧪 C++ Test Coverage Report

Coverage:

📊 Detailed Coverage
Filename                         Regions    Missed Regions     Cover   Functions  Missed Functions  Executed       Lines      Missed Lines     Cover    Branches   Missed Branches     Cover
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
NmtLazyInitializeBackend.cpp          99                19    80.81%          11                 1    90.91%         157                34    78.34%          66                20    69.70%
NmtLazyInitializeBackend.hpp           2                 0   100.00%           1                 0   100.00%           1                 0   100.00%           0                 0         -
TranslationModel.cpp                 296               168    43.24%          28                 8    71.43%         506               213    57.91%         181               122    32.60%
TranslationModel.hpp                   1                 0   100.00%           1                 0   100.00%           1                 0   100.00%           0                 0         -
nmt.cpp                               72                22    69.44%           9                 1    88.89%         137                28    79.56%          44                16    63.64%
nmt.hpp                               51                 4    92.16%          11                 2    81.82%          53                 4    92.45%          28                 0   100.00%
nmt_beam_search.cpp                  116                25    78.45%          10                 3    70.00%         254                32    87.40%          76                19    75.00%
nmt_graph_decoder.cpp                164                78    52.44%          15                 7    53.33%         540               161    70.19%         112                69    38.39%
nmt_graph_encoder.cpp                 54                13    75.93%           3                 0   100.00%         268                33    87.69%          37                16    56.76%
nmt_loader.cpp                       270                67    75.19%          14                 0   100.00%         774                97    87.47%         161                67    58.39%
nmt_state_backend.cpp                188                46    75.53%          20                 0   100.00%         424                79    81.37%         121                51    57.85%
nmt_tokenization.cpp                  88                21    76.14%           8                 0   100.00%         135                36    73.33%          61                26    57.38%
nmt_utils.cpp                        211                34    83.89%          21                 0   100.00%         314                38    87.90%         143                45    68.53%
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
TOTAL                               1612               497    69.17%         152                22    85.53%        3564               755    78.82%        1030               451    56.21%

gianni-cor
gianni-cor previously approved these changes Sep 16, 2026
0.15.0 gave fabric the C++ runtime to export, but nothing reached it.
bare's executable links GNU libstdc++.so.6, so libstdc++ sits in the
process' global lookup scope, which the dynamic linker searches before a
dlopen'd module's own DT_NEEDED chain. A consumer linked with -nostdlib++
resolved __cxa_throw, the personality routine and the std:: typeinfo
objects from libstdc++ while taking only the libc++-only names from
fabric, and std::exception_ptr split down that seam: a captured exception
was re-raised with a foreign exception class that GNU's personality
routine can only match against catch (...). Every native error from load
reached JS as "Unknown error", not only the three config-validation cases.

Name the version node QVAC_FABRIC_ABI_1, so consumers record a DT_VERNEED
that libstdc++ cannot satisfy. The node covers the whole export surface:
an anonymous node cannot coexist with a named one, and the spliced C++ ABI
block has to stay inside the same global list to keep its precedence over
`local: *;`. It pins fabric's own internal references too, which were
being interposed the same way.

Publish QVAC_FABRIC_ABI_VERSION and QVAC_FABRIC_OWNS_CXX_RUNTIME through
the package config, so a consumer's build can assert it pinned the runtime
instead of trusting its link line, and can tell a deliberately shared
libc++ (Android, ASan) from a fabric too old to pin at all. Configure
fails if symbols.map and the declared node name drift apart.

Consumers must be rebuilt, and a mixed pairing is worse than either half
and silent: pinning fabric's internal references removes the accident that
both sides previously resolved the runtime from libstdc++ and so agreed on
one, so a consumer that is not rebuilt loses the typed catch it used to
get -- and still loads, because an unversioned reference binds to a
default-versioned definition. Ship as a minor; a caret on 0.x locks the
minor, so ^0.15.0 keeps already-published consumers away from it.

Verified on linux-x64 against a locally built fabric: a rebuilt
llm-llamacpp pins 378 imports to QVAC_FABRIC_ABI_1, and the load-mode,
repeat-penalty and device errors surface their real messages under bare.
The addon-template half, which asserts the pin at build time and narrows
addon exports to the bare C entry points, lands separately after this is
released to npm.
Folds the version bump into the fix PR so the release can be cut straight
from a release-fabric-0.16.0 branch: release-merge-guard only requires
package.json to match the branch version, and create-github-release reads
the [0.16.0] section out of the changelog.

Minor, not patch: a consumer that is not rebuilt against the version node
loses a typed catch it used to get and still loads, so ^0.15.0 has to keep
already-published consumers away from this release.

The six in-tree consumer ranges move in the same commit. pnpm-workspace's
linkWorkspacePackages resolves from the registry once the workspace
version stops satisfying the range, so bumping fabric alone flips all six
from link:../fabric to the published 0.15.0 -- which is both a lockfile
change and a silent downgrade of what CI builds them against. Their own
versions are untouched; nothing here releases a consumer.

pnpm-lock.yaml is regenerated with the pinned pnpm 11.17.0 rather than
hand-edited, so it also drops two orphaned @qvac/diffusion-cpp@0.23.0
entries that nothing has referenced since #4439 moved sdk and inference to
the workspace copy.
…hips

The six consumer ranges cannot move in the same commit as the bump after
all. The pr-head-ts-checks jobs run a standalone npm install per package
rather than through the pnpm workspace, so an unpublished ^0.16.0 fails
resolution outright -- ETARGET, no matching version -- and took six jobs
down with it. #4477 hit the same ordering constraint for the 0.15.0 floor:
ranges move once the release is on npm, which is what #4519 does.

The cost is that pnpm stops linking the workspace fabric into the six
consumers, because 0.16.0 no longer satisfies ^0.15.0, so the regenerated
lockfile records the published 0.15.0 for them. That is the same in-between
state main sat in after #4481 and before #4477, and it is content-identical
for those builds: the published 0.15.0 is the fabric they link today.
Native CI is unaffected either way, since overlay-local-fabric stages this
branch's own fabric into consumer node_modules when the fabric stack
changes, which is what the prebuilds label drives. The window closes when
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants