Skip to content

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

Description

@avrabe

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

  1. Add RefFunc to collect_function_refs_recursive (needs Instruction::RefFunc to exist first — today ref.func fails parsing).
  2. Scan the raw global section for ref.func operands and add them as liveness roots.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions