Skip to content

feat(component): #239 Phase B — component-level reachability GC (drops the unreachable cabi_realloc, #303) - #324

Merged
avrabe merged 1 commit into
mainfrom
feat/239-phase-b-reachability-gc
Aug 13, 2026
Merged

feat(component): #239 Phase B — component-level reachability GC (drops the unreachable cabi_realloc, #303)#324
avrabe merged 1 commit into
mainfrom
feat/239-phase-b-reachability-gc

Conversation

@avrabe

@avrabe avrabe commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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/bool in and out — nothing lifted or lowered can allocate) 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 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 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 from the core module, and the existing fused_optimizer::eliminate_dead_functions performs 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:

  #239 Phase B: pruned 1 unreferenced core export(s), removed 1 unreachable function(s)
  Reachability GC:    1 unreferenced core export(s), 1 unreachable function(s) removed

…and the resulting component still passes wasm-tools validate --features all, with cabi_realloc gone from the shipped module.

On by default; --no-reachability-gc exists 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), a canon lift's core func, or a core FromExports instance 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 marked All and never pruned. A module with no instance at all is also All. Within the core module, eliminate_dead_functions keeps 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_wasm re-emits global_section_bytes verbatim as a RawSection (lib.rs, "Phase 14"), while eliminate_dead_functions renumbers the function index space. A ref.func N frozen 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.func in 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_globals pins 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; any alias outer; a canonical entry other than lift/lower; an unrecognized canonical option; and any index that does not resolve in the space we tracked. Consequence worth stating: a future wasmparser that grows a canonical variant makes us bail, not mis-count an index space.

Authoritative validate-and-revert. If the reconstructed component fails wasmparser::validate for 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-component emits 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 what it proves
test_239b_removes_unreachable_cabi_realloc (1) An unreachable cabi_realloc on 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_realloc (2) The same function is preserved when a canon lift names 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_target (3) A core-exported function that the component never references but an element segment targets is preserved — and the whole module comes through byte-identical (#196).
test_239b_analysis_bails_on_unanalyzable_input (4) Empty input, garbage, a core module presented as a component, a truncated component, and a nested component all bail; a valid-but-unmodelled component (canon resource.drop) no-ops end to end — optimization still succeeds, output validates, nothing removed, reachability_gc_applied == false.
test_239b_revert_on_bad_liveness (5) Fed a liveness map that lies (marks a genuinely aliased export dead), the output is byte-identical to a run with Phase B off and still validates. An untested revert path is not a safety net.
test_239b_tracks_aliased_core_module_index_space The core module index space is tracked across a module aliased out of an imported component instance (that alias occupies a slot; miscounting it would attribute the wrong live set to the wrong module). Asserts the correct result, not merely a bail.
test_239b_config_can_disable_the_pass reachability_gc: false really disables it — cabi_realloc survives, reachability_gc_applied == false. (Test 1 covers the ON direction, so an inverted flag is caught there.)
test_239b_mark_phase_resolves_scalar_interface The mark phase resolves the #303 shape exactly.
test_239b_rejects_reference_typed_globals The funcref-global gate rejects; a numeric global does not.
test_239b_eligibility_rejects_element_table The element-table gate rejects independently of the whole-module #196 skip.
test_239b_existing_fixtures_unharmed The two component fixtures already in the tree lose no functions.

Evidence I actually ran:

  • cargo test --release -p loom-core --features verification --lib492 passed, 0 failed, 2 ignored (baseline on origin/main measured in the same worktree: 481 passed / 0 failed / 2 ignored — none lost).
  • The verification artifact's exact command, 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 validatePASS (43 warnings); baseline on main is PASS (42). The single new warning is id 'TEST-239B-COMPONENT-REACHABILITY-GC' can't be used as a commit-trailer reference, which all nine existing TEST-* artifacts also carry — the new artifact follows the file's established TEST-<feature-name> convention. No new errors.

Verification artifact TEST-239B-COMPONENT-REACHABILITY-GC links to REQ-3, REQ-5, REQ-11, REQ-12.

What I could NOT do — stated plainly

  1. 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_component and 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.

  2. No behavioral differential. Evidence here is structural: wasmparser validation 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.

  3. 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 dead cabi_realloc in the component shape.

  4. Pre-existing bug found, not fixed (out of scope). reconstruct_component cannot rebuild a component that nests core modules inside a ComponentSection: it rewrites the inner module section without updating the enclosing component section's LEB128 size, so optimize_component fails its own post-pass validation with Optimized 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 the NESTED_COMPONENT fixture. Worth its own issue.

  5. 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.

  6. eliminate_dead_functions has a pre-existing liveness hole I did not fix: collect_function_refs matches only Call, not RefFunc, 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

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
@avrabe

avrabe commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Two pre-existing bugs surfaced while building this and filed separately, so they are not lost in the PR body:

@avrabe
avrabe merged commit 801d3f2 into main Aug 13, 2026
23 of 25 checks passed
@avrabe
avrabe deleted the feat/239-phase-b-reachability-gc branch August 13, 2026 04:18
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.

1 participant