Skip to content

fix(#326): decline the dead-function sweep when a funcref global may hold ref.func - #327

Merged
avrabe merged 1 commit into
mainfrom
fix/326-reffunc-liveness-guard
Aug 13, 2026
Merged

fix(#326): decline the dead-function sweep when a funcref global may hold ref.func#327
avrabe merged 1 commit into
mainfrom
fix/326-reffunc-liveness-guard

Conversation

@avrabe

@avrabe avrabe commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Latent silent miscompile of the #196 class — pre-existing, and present in v1.3.0 as shipped. optimize_fused_module runs eliminate_dead_functions by default on every core module.

The hazard

Two facts combine:

  • collect_function_refs_recursive matches Instruction::Call onlyref.func is not in the liveness closure.
  • The encoder re-emits global_section_bytes verbatim; only numeric globals are lifted into module.globals, so a ref.func N in a funcref global's init expression is never renumbered.

A function referenced solely by a funcref global is therefore swept, the index space compacts, and the frozen ref.func N designates whichever function inherited index N. Valid wasm, wrong behaviour, invisible to structural validation.

Why this specific shape should have been caught

This is exactly what #196 shipped through the element section, and it cost a flight-control miscompile. The fix then was to remap the element section — which is in place today (remap_element_section_refs). The identical hazard in the global section was never addressed. The lesson was applied to the instance, not to the class.

Also worth recording rather than quietly correcting: the doc comment on eliminate_dead_functions has always listed "referenced by a call or ref.func instruction" among its liveness roots. The documentation asserted the property, the code never implemented it, and nothing compared the two. I annotated it instead of deleting the line, because the drift is the finding.

The fix — decline, don't guess

Until ref.func is a genuine liveness root and the global section is remapped (the element-section treatment), modules whose raw global section may carry ref.func are not swept. Unparseable section ⇒ treated as hazard-present. Skipping an optimization costs bytes; getting this wrong costs correctness — the charter is explicit about which way that trade goes.

Tests

  • t326_funcref_global_blocks_the_dead_function_sweep — pass declines, no function removed.
  • t326_numeric_only_globals_do_not_block_the_sweepthe control. Without it, a guard that always bails would pass the first test.

483 passed / 0 failed / 2 ignored (baseline 481, +2, none lost); fmt clean; full pre-commit suite passed.

Proper fix (ref.func as a root + global-section remapping) stays open as the follow-up; this closes the correctness exposure now.

Fixes #326
Refs #196, #239, #324

…hold ref.func

Latent silent-miscompile of the #196 class, pre-existing and present in v1.3.0
as shipped. `optimize_fused_module` runs `eliminate_dead_functions` by default
on every core module.

Two facts combine:

  * `collect_function_refs_recursive` matches `Instruction::Call` only, so
    `ref.func` is NOT part of the liveness closure;
  * the encoder re-emits `global_section_bytes` verbatim -- only numeric globals
    are lifted into `module.globals`, so a `ref.func N` in a funcref global's
    init expression is never renumbered.

So a function referenced only by a funcref global is swept, the index space is
compacted, and the frozen `ref.func N` now designates whichever function
inherited index N. Valid wasm, wrong behaviour, invisible to structural
validation. That is exactly what #196 shipped through the element section and
it cost a flight-control miscompile.

The element section was fixed after #196 by remapping it. The identical hazard
in the global section was never addressed -- the lesson was applied to the
instance, not to the class.

Until `ref.func` is a real liveness root AND the global section is remapped
(the element-section treatment), decline to sweep such modules. Skipping an
optimization costs bytes; getting this wrong costs correctness.

Worth recording: the doc comment on `eliminate_dead_functions` already listed
"referenced by a `call` or `ref.func` instruction" among the liveness roots.
The documentation asserted the property; the code never implemented it, and
nothing checked the two against each other. Annotated rather than quietly
corrected.

Tests: `t326_funcref_global_blocks_the_dead_function_sweep` (the pass declines,
no function removed) and `t326_numeric_only_globals_do_not_block_the_sweep` --
the control, without which a guard that always bails would pass the first test.

Verified: loom-core --features verification --lib = 483 passed, 0 failed,
2 ignored (baseline 481, +2, none lost). fmt clean.

Fixes #326
Refs #196, #239
@avrabe
avrabe merged commit 8f82eb0 into main Aug 13, 2026
23 of 25 checks passed
@avrabe
avrabe deleted the fix/326-reffunc-liveness-guard branch August 13, 2026 04:17
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.

eliminate_dead_functions: ref.func is not a liveness root (funcref globals can be silently re-pointed)

1 participant