feat(component): #239 Phase B — component-level reachability GC (drops the unreachable cabi_realloc, #303) - #324
Merged
Conversation
A component whose whole interface is scalar still ships `cabi_realloc`. The core module exports it, so core-module DCE keeps it; nothing at the component level ever names it, so it can never be called. Downstream that is three permanently-unreached branches in an MC/DC report — a coverage row a safety argument can never close — once per fused component, so the cost scales with composition (#303). Phase B is a mark-and-sweep over the component's `exports -> instances -> aliases -> canon lift/lower -> core funcs` graph that answers exactly one question per nested core module: which of its FUNCTION exports does the enclosing component reference? The unreferenced ones are removed and `eliminate_dead_functions` performs the sweep, so the bodies that disappear are precisely those unreachable from the module's remaining exports, its start function and its element segments. Removal only. No adapter or canonical-ABI behaviour is altered, folded or "simplified" — the component's external interface and its lifting/lowering contract are identical before and after. Safety gates, all positive (prune only what we can prove): - #196. A module with a function-referencing element segment is refused outright. A function reachable only through an indirect-call table is exactly the v1.1.11 silent miscompile; such modules come through byte-identical. - Reference-typed globals are refused. This is the one remaining path by which a removed function could go unnoticed: `encode_wasm` re-emits `global_section_bytes` VERBATIM as a raw section while `eliminate_dead_functions` renumbers the function index space, so a `ref.func N` frozen in those bytes would silently re-point at a DIFFERENT surviving function — valid wasm, wrong behaviour. Every other path is already closed: `ref.func` in a body is a hard parse error in loom's parser, data-segment offsets are integer constant expressions, and element segments are gated above. - The mark phase BAILS rather than guesses: unparseable input, a core module presented as a component, a nested component, an `alias outer`, a canonical entry other than lift/lower, an unrecognized canonical option, or any index that does not resolve in the space we tracked. A bail makes the pass a no-op for that component and says so. A future wasmparser variant therefore bails; it does not silently mis-count an index space. - Authoritative post-pass validate-and-revert: if the reconstructed component fails validation for any reason, the whole optimization is re-run with the GC off and that result is returned. A mistake in the liveness graph can only cost the optimization, never correctness. Precision is a deliberate over-approximation: every component-level reference to a core-instance export is a root, not only those reachable from the component's exports. That can only keep MORE alive, and it removes the need to model the component function/instance index spaces. Scope, stated plainly: only core funcs behind unreferenced core-module exports are removed. Unreachable canon lifts, aliases and component type definitions are NOT pruned — that needs component index-space rewriting, which is incompatible with the byte-splicing reconstruction and would put the #196 class of index-scrambling bug back on the table. There is no behavioral differential; that rides #238. On by default, with `--no-reachability-gc` to bisect against it. The CLI size breakdown reports the pruned exports and removed functions. Tests (11, all `test_239b_*`, fixtures assembled in-tree from component text): unreachable `cabi_realloc` removed; the same function preserved when a canon lift names it via `(realloc ...)`; an element-table target preserved with its module byte-identical; the analysis bailing on five kinds of unanalyzable input; the revert path exercised with a liveness map that lies; the opt-out actually opting out; the core module index space tracked across a module aliased from an imported instance; the funcref-global and element-table eligibility gates; and the existing component fixtures unharmed. loom-core --lib: 481 -> 492 passed, 0 failed, 2 ignored. The artifact's own filter (`-- test_239b_`) resolves to 11 tests, not zero. rivet validate: PASS (43 warnings; baseline 42, the one addition is the TEST-* id-naming warning that all nine existing TEST artifacts also carry). rivet check verification-evidence does not flag the new artifact. Verifies: REQ-5 Verifies: REQ-11 Verifies: REQ-12 Refs #239 Refs #303 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZof1M5HMBSYVZPeoT4MEn
Contributor
Author
|
Two pre-existing bugs surfaced while building this and filed separately, so they are not lost in the PR body:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
#239 Phase B — component-level reachability GC. Removes functions from a WebAssembly component that are provably unreachable from the component's own graph.
The motivating case is #303: a component whose whole interface is scalar (
u32/boolin and out — nothing lifted or lowered can allocate) still shipscabi_realloc. The core module exports it, so core-module DCE keeps it; nothing at the component level ever names it, so it can never be called. Downstream it becomes three permanently-unreached branches in an MC/DC report — a coverage row a safety argument can never close — one dead copy per fused component, so the cost scales with composition.Phase A (custom-section GC) and Phase C (canon seam dissolution) are untouched.
What it does
A mark-and-sweep over the component's
exports → instances → aliases → canon lift/lower → core funcsgraph that answers exactly one question per nested core module:The unreferenced ones are removed from the core module, and the existing
fused_optimizer::eliminate_dead_functionsperforms the actual sweep — so the bodies that disappear are precisely those unreachable from the module's remaining exports, its start function and its element segments.Removal only. No adapter or canonical-ABI behaviour is altered, folded or "simplified". The component's external interface and its lifting/lowering contract are identical before and after.
Live CLI run on the in-tree scalar-interface fixture:
…and the resulting component still passes
wasm-tools validate --features all, withcabi_reallocgone from the shipped module.On by default;
--no-reachability-gcexists so an integrator can bisect against it.Safety constraints honoured
All gates are positive — we prune only what we can prove, rather than pruning unless we spot a problem.
Roots that stay live. Every core-instance export named by an alias, a canonical option (
realloc/post-return/callback), acanon lift's core func, or a coreFromExportsinstance is a root. A module that escapes the graph — exported from the component, handed to a component instantiation, or whose instance is passed wholesale as an instantiation argument (its exports are then matched by name against the importing module's imports) — is markedAlland never pruned. A module with no instance at all is alsoAll. Within the core module,eliminate_dead_functionskeeps the start function and every element-segment target live.#196. A module with a function-referencing element segment is refused outright — a function reachable only through an indirect-call table is exactly the v1.1.11 flight-control silent miscompile. Such modules come through byte-identical, which is what test 3 asserts.
The one silent-miscompile path I found, and closed.
encode_wasmre-emitsglobal_section_bytesverbatim as aRawSection(lib.rs, "Phase 14"), whileeliminate_dead_functionsrenumbers the function index space. Aref.func Nfrozen in those raw bytes would silently re-point at a different surviving function — valid wasm, wrong behaviour, i.e. the #196 failure class again. Reference-typed globals are therefore refused. Every other path is already closed:ref.funcin a function body is a hard parse error in loom's parser, data-segment offsets are integer constant expressions, and element segments are gated above.test_239b_rejects_reference_typed_globalspins this with a fixture whose funcref global would misfire if the gate were removed.Conservative on failure. The mark phase returns
None— the pass becomes a no-op for that component — on: unparseable input; a core module presented as a component; a truncated component; a nested component; anyalias outer; a canonical entry other thanlift/lower; an unrecognized canonical option; and any index that does not resolve in the space we tracked. Consequence worth stating: a futurewasmparserthat grows a canonical variant makes us bail, not mis-count an index space.Authoritative validate-and-revert. If the reconstructed component fails
wasmparser::validatefor any reason, the entire optimization is re-run with the GC switched off and that Phase-B-free result is returned. A mistake in the liveness graph can only cost us the optimization, never correctness. This path is tested, not assumed.Precision — a deliberate over-approximation
The mark phase treats every component-level reference to a core-instance export as a root, rather than only those reachable from the component's exports. That can only keep more alive, never less. It costs nothing in practice —
wit-componentemits an alias only in order to feed a canon lift/lower, and every canon lift it emits is exported — and it removes the need to model the component function/instance index spaces.Tests — 11, all
test_239b_*, fixtures assembled in-tree from the component text format (no binary blobs)test_239b_removes_unreachable_cabi_realloccabi_reallocon an all-scalar interface is removed — export and body (function count 2 → 1) — and the output validates. The fixture is asserted to contain it first, so the test cannot be vacuous.test_239b_preserves_reachable_cabi_realloccanon liftnames it via(realloc …). This is what makes test 1 mean something: a GC that removed everything would pass test 1 and fail this.test_239b_preserves_indirect_element_targettest_239b_analysis_bails_on_unanalyzable_inputcanon resource.drop) no-ops end to end — optimization still succeeds, output validates, nothing removed,reachability_gc_applied == false.test_239b_revert_on_bad_livenesstest_239b_tracks_aliased_core_module_index_spacetest_239b_config_can_disable_the_passreachability_gc: falsereally disables it —cabi_reallocsurvives,reachability_gc_applied == false. (Test 1 covers the ON direction, so an inverted flag is caught there.)test_239b_mark_phase_resolves_scalar_interfacetest_239b_rejects_reference_typed_globalstest_239b_eligibility_rejects_element_tabletest_239b_existing_fixtures_unharmedEvidence I actually ran:
cargo test --release -p loom-core --features verification --lib→ 492 passed, 0 failed, 2 ignored (baseline onorigin/mainmeasured in the same worktree: 481 passed / 0 failed / 2 ignored — none lost).cargo test --release --lib -p loom-core --features verification -- test_239b_→ 11 passed, 0 failed. Non-zero, so the filter resolves to real tests.cargo test --release --workspace --features loom-core/verification→ all suites green (loom-cli 8, loom-core lib, component_execution 12, component_tests 12, optimization_tests 89, verification 46, loom-shared 51, loom-testing 21, …).cargo fmt --all -- --check→ clean.cargo clippy --release --workspace --all-targets --features loom-core/verification→ no warnings.rivet check verification-evidence→ the new artifact is not flagged (5 pre-existing artifacts are).rivet validate→ PASS (43 warnings); baseline onmainis PASS (42). The single new warning isid 'TEST-239B-COMPONENT-REACHABILITY-GC' can't be used as a commit-trailer reference, which all nine existingTEST-*artifacts also carry — the new artifact follows the file's establishedTEST-<feature-name>convention. No new errors.Verification artifact
TEST-239B-COMPONENT-REACHABILITY-GClinks to REQ-3, REQ-5, REQ-11, REQ-12.What I could NOT do — stated plainly
Only core funcs are pruned. Unreachable canon lifts, aliases and component type definitions are not removed. Doing so requires rewriting the component's own index spaces, which is incompatible with the byte-splicing
reconstruct_componentand would put the v1.1.11 element remap scrambles the function-pointer table — valid-but-wrong code, falcon SIL gate fails (0.13m → 593.8m) #196 class of index-scrambling bug back on the table. Make components small: custom-section GC + component-level reachability GC + canon seam dissolution #239's Phase B acceptance line ("unreachable types/aliases/canon/core-funcs pruned") is therefore only partially met: core funcs yes, types/aliases/canon no.No behavioral differential. Evidence here is structural:
wasmparservalidation plus assertions on export names and function counts. Nothing executes the component before and after. That is Behavioral differential as an optimization gate (self-certify non-Z3-backstoppable transforms) #238's job, and Phase B rides it as a backstop rather than providing it.No large real-world component was exercised. The ~2.3 MB fixtures named in Make components small: custom-section GC + component-level reachability GC + canon seam dissolution #239 are not in-tree, and the corpus contains only core modules. The evidence is small in-tree fixtures plus the two existing component fixtures. The Canonical-ABI glue survives the dissolve: componentizing a scalar-only driver doubles .text, and cabi_realloc (x2) is never removed #303 repro itself was not reproduced end to end — that pipeline builds a core module (
wasm32-unknown-unknown), not a component, so this pass does not apply to it directly; what is fixed here is the same deadcabi_reallocin the component shape.Pre-existing bug found, not fixed (out of scope).
reconstruct_componentcannot rebuild a component that nests core modules inside aComponentSection: it rewrites the inner module section without updating the enclosing component section's LEB128 size, sooptimize_componentfails its own post-pass validation withOptimized component validation failed: core instance 0 has no export named 'add'. This is a loud failure, not a silent one, it predates this PR (Phase B bails on nested components, so it cannot make it worse), and it is recorded next to theNESTED_COMPONENTfixture. Worth its own issue.Non-function exports are never pruned. Memories, tables and globals are left alone — dropping a memory export interacts with data segments and canonical options in ways this pass does not model.
eliminate_dead_functionshas a pre-existing liveness hole I did not fix:collect_function_refsmatches onlyCall, notRefFunc, and reference-typed globals are not scanned as roots. Phase B does not widen it — the eligibility gates above refuse exactly the modules where it could bite — but the hole is in the shared sweep and deserves its own look.Refs #239, #303. Do not merge.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RZof1M5HMBSYVZPeoT4MEn