Found while adding safety gates for #239 Phase B (PR #324). Pre-existing hole in the shared core-module sweep, which optimize_fused_module runs by default on every core module.
The hole
fused_optimizer::eliminate_dead_functions builds its liveness set from exports, the start function, and element segments, then closes it transitively with collect_function_refs. That helper matches only Instruction::Call (fused_optimizer.rs, collect_function_refs_recursive) — ref.func is not a root, and reference-typed globals are not scanned at all.
Separately, the encoder re-emits the raw global section verbatim:
// Phase 14: Build global section - use raw bytes if available
if let Some(global_bytes) = &module.global_section_bytes {
wasm_module.section(&RawSection { id: 6, data: global_bytes });
}
and the parser only lifts numeric-typed globals into module.globals — a funcref global is preserved as raw bytes and never remapped.
Consequence
If a module has (global funcref (ref.func $f)) and any function before $f is swept, $f shifts down but the frozen ref.func N in the raw global bytes does not. It now names a different surviving function. The module still validates. This is the #196 failure class: valid wasm, wrong behaviour.
Minimal shape:
(module
(func (export "unused") (result i32) i32.const 7) ;; index 0
(func $f (result i32) i32.const 1) ;; index 1
(func (export "used") (result i32) call $f)
(global funcref (ref.func $f)) ;; frozen "ref.func 1"
)
Drop index 0 and ref.func 1 points at used.
Mitigating factors (why this is not currently exploding)
So the trigger is narrow — but the sweep is on by default and the failure mode is silent.
What #324 does about it
Nothing to the shared sweep. #239 Phase B refuses to prune any module whose global section is unparseable or contains a reference-typed global (core_module_gc_eligibility / global_section_is_index_safe), with test_239b_rejects_reference_typed_globals pinning it. That fences the new pass off; it does not close the hole in eliminate_dead_functions.
Suggested fix
- Add
RefFunc to collect_function_refs_recursive (needs Instruction::RefFunc to exist first — today ref.func fails parsing).
- Scan the raw global section for
ref.func operands and add them as liveness roots.
- Remap
ref.func operands in the raw global section, or refuse to sweep when a reference-typed global is present.
(3) alone is the conservative one-liner and matches "skip rather than risk".
Refs #196
Found while adding safety gates for #239 Phase B (PR #324). Pre-existing hole in the shared core-module sweep, which
optimize_fused_moduleruns by default on every core module.The hole
fused_optimizer::eliminate_dead_functionsbuilds its liveness set from exports, the start function, and element segments, then closes it transitively withcollect_function_refs. That helper matches onlyInstruction::Call(fused_optimizer.rs,collect_function_refs_recursive) —ref.funcis not a root, and reference-typed globals are not scanned at all.Separately, the encoder re-emits the raw global section verbatim:
and the parser only lifts numeric-typed globals into
module.globals— afuncrefglobal is preserved as raw bytes and never remapped.Consequence
If a module has
(global funcref (ref.func $f))and any function before$fis swept,$fshifts down but the frozenref.func Nin the raw global bytes does not. It now names a different surviving function. The module still validates. This is the #196 failure class: valid wasm, wrong behaviour.Minimal shape:
Drop index 0 and
ref.func 1points atused.Mitigating factors (why this is not currently exploding)
ref.funcinside a function body is a hard parse error in loom's parser, so the module is skipped entirely.eliminate_dead_functionsonly removes a function when it is neither exported nor called, which on real modules is rare in combination with a funcref global.So the trigger is narrow — but the sweep is on by default and the failure mode is silent.
What #324 does about it
Nothing to the shared sweep. #239 Phase B refuses to prune any module whose global section is unparseable or contains a reference-typed global (
core_module_gc_eligibility/global_section_is_index_safe), withtest_239b_rejects_reference_typed_globalspinning it. That fences the new pass off; it does not close the hole ineliminate_dead_functions.Suggested fix
RefFunctocollect_function_refs_recursive(needsInstruction::RefFuncto exist first — todayref.funcfails parsing).ref.funcoperands and add them as liveness roots.ref.funcoperands in the raw global section, or refuse to sweep when a reference-typed global is present.(3) alone is the conservative one-liner and matches "skip rather than risk".
Refs #196