Skip to content

wasm-split-cli deletes a still-called main-module function under fat LTO - #5795

Open
tdymel wants to merge 1 commit into
DioxusLabs:mainfrom
tdymel:fix/lto-callgraph-name-gap
Open

wasm-split-cli deletes a still-called main-module function under fat LTO#5795
tdymel wants to merge 1 commit into
DioxusLabs:mainfrom
tdymel:fix/lto-callgraph-name-gap

Conversation

@tdymel

@tdymel tdymel commented Aug 29, 2026

Copy link
Copy Markdown

Found while measuring the effect of a size-optimized release
profile (opt-level = "z", lto = true, codegen-units = 1, strip = true) on a real Dioxus web app's --wasm-split build.
Root-caused and patched by: Claude, during the resulting
investigation into why that exact profile combination crashes the build.

Environment

  • Dioxus: main (e2cc82e63), version 0.8.0-alpha.1
  • Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
  • wasm-bindgen: 0.2.121
  • OS: Arch Linux (rolling), Linux 7.1.9, x86_64

Problem

dx build --platform web --release --wasm-split crashes deterministically
when the consuming crate's [profile.release] sets lto = true and
codegen-units = 1 (alongside opt-level = "z" and strip = true, though
those two are incidental - the crash reproduces with just the LTO/codegen-
units pair added to an otherwise-default release profile). A plain
opt-level = "z" + strip = true build, no LTO, builds and splits fine.

The panic is walrus's own internal invariant check, not a wasm-split-cli
error message:

thread 'tokio-rt-worker' panicked at .../walrus-0.23.3/src/module/functions/mod.rs:186:20:
assertion failed: !self.dead.contains(&id)
  • 8: walrus::passes::gc::run
  • 9: <wasm_split_cli::Splitter>::emit

Root cause

main_roots() (packages/wasm-split/wasm-split-cli/src/lib.rs) - the set
of functions unused_main_symbols()/prune_main_symbols() treat as
definitely-reachable-from-main - only considers the module's exports, its
start function, and its imports.

build_call_graph() separately computes a fallback for two cases the
old-module/new-module name reconciliation can't line up by itself:

  1. Call-graph edges recovered after being dropped mid-graph
    (recovered_children - see the related, already-fixed "dropped call-
    graph child edges" bug in this same function).
  2. wasm-bindgen-synthesized functions with no old-module counterpart at
    all (a new_names entry missing from old_names) - primarily the
    per-signature invoke* closure-invocation shims wasm-bindgen generates,
    which JS calls back into via a function-table slot Closure::wrap
    captured, never via a traced call/ref.func instruction anywhere in
    the compiled Rust code.

Both cases are meant to be attached as children of main - the code's own
comments say so ("we're going to attach the recovered children to the main
function" / "attach any truly new symbols to the main function. Usually
these are the shim functions"):

let main_fn = self.source_module.funcs.by_name("main")...?;
let main_fn_entry = new_call_graph.entry(Node::Function(main_fn)).or_default();
main_fn_entry.extend(recovered_children);

for (name, new) in new_names.iter() {
    if !old_names.contains_key(name) {
        main_fn_entry.insert(Node::Function(*new));
    }
}

new_call_graph is a local variable, distinct from self.call_graph (the
one already fully assigned, earlier in the same function, from the
reconciled original.call_graph, and the only one main_graph/
shared_symbols/pruning ever read from). new_call_graph is never read
again after being built, and never merged into self.call_graph - the
entire attachment mechanism, for both cases, is a silent no-op on every
build.

Concretely: an invoke* shim with no incoming edge in self.call_graph
has no path from any root in the graph reachability is actually computed
from - it's a graph island. It survives pruning only by luck:
unused_main_symbols() only considers functions reachable from some
split point
, so a function nothing in the graph points to is simply never
a deletion candidate, not proven live by anything. A real, still-
referenced closure body the shim calls, though, can be reachable from a
split point's own subtree (an unrelated route's component tree, also
registering its own event-handler closures) - and with nothing recording
that the shim also keeps it alive from main, unused_main_symbols()
wrongly classifies it as deletable. prune_main_symbols() deletes it; the
shim's own call instruction into it is left untouched (delete()
doesn't rewrite references pointing at what it deletes - documented as the
caller's responsibility). walrus::passes::gc::run, walking that
still-live call to determine reachability during its own pass, indexes
the already-dead function id and hits its internal assertion.

Fat LTO + a single codegen unit doesn't introduce this gap - it's present
in every build, including ones that never hit it - but makes actually
triggering it far more likely: whole-program visibility lets the linker
collapse many originally-distinct closures onto far fewer, far-more-
widely-shared invoke* monomorphizations, each with much higher fan-out
into real closure bodies, raising the odds that at least one such callee
also happens to be reachable from some split point's subtree. A normal
multi-codegen-unit, non-LTO build has the exact same graph-modeling gap;
it just tends to produce many more, much narrower-fan-out shims, keeping
the odds of a collision low in practice (not zero - a build-shape
probability, not something LTO specifically causes).

Minimal reproduction

Added temporary diagnostic instrumentation directly before
walrus::passes::gc::run(&mut out) in emit_main_module: scan every
still-live function's instructions (and every element-segment and global
RefFunc entry) for a reference to an id no longer present in out.funcs,
and print it before the crash actually happens. Rebuilt dx with the
instrumentation, ran it against a real 40-route Dioxus web app with the
triggering profile:

TEMP DEBUG: dangling call from Id { idx: 2477 } (Some("...12wasm_bindgen7convert8closuress6_1__6invoke...")) to dead Id { idx: 2219 }
TEMP DEBUG: dangling call from Id { idx: 2538 } (Some("...12wasm_bindgen7convert8closuress4_1__6invoke...")) to dead Id { idx: 2219 }

Two distinct wasm_bindgen::convert::closures::invoke* shims, both still
present in the module, both calling the same already-deleted function -
confirming the mechanism above rather than guessing at it.

Patch

-        let mut old_to_new = HashMap::new();
-        let mut new_call_graph: HashMap<Node, HashSet<Node>> = HashMap::new();
-
-        for (new_name, new_func) in new_names.iter() {
-            if let Some(old_func) = old_names.get(new_name) {
-                old_to_new.insert(*old_func, new_func);
-            } else {
-                new_call_graph.insert(Node::Function(*new_func), HashSet::new());
-            }
-        }
+        let mut old_to_new = HashMap::new();
+
+        for (new_name, new_func) in new_names.iter() {
+            if let Some(old_func) = old_names.get(new_name) {
+                old_to_new.insert(*old_func, new_func);
+            }
+        }

         ...

         let main_fn = self.source_module.funcs.by_name("main")...?;
-        let main_fn_entry = new_call_graph.entry(Node::Function(main_fn)).or_default();
+        let main_fn_entry = self.call_graph.entry(Node::Function(main_fn)).or_default();
         main_fn_entry.extend(recovered_children);

(full diff in fix/lto-callgraph-name-gap)

An earlier attempt fixed the symptom by rooting every function
referenced by any element (function table) segment at all, not just the
recovered/truly-new ones build_call_graph() already intended to handle.
That's far too broad for a real Dioxus app - the framework's own component
dispatch and every rsx! event-handler closure also go through the
function table pervasively, so it silently defeated route-level
wasm-split entirely instead of fixing the gap (all 36 of the test app's
route chunks collapsed to ~1KB stubs each, everything pulled back into a
much larger main bundle). Not applied - the fix above is scoped to the two
cases the code already intended to cover.

Why this fixes it

self.call_graph is the only graph main_graph/shared_symbols/pruning
ever read reachability from. Writing the recovered-children and truly-new-
symbol attachments there instead of the previously-discarded
new_call_graph makes both cases actually reach main in the graph that
matters, instead of being computed and thrown away. new_call_graph is
removed entirely, since nothing else ever read from it either.

Verification

  • Before the fix: dx build --platform web --release --wasm-split against
    the real 40-route app, with opt-level = "z" + lto = true +
    codegen-units = 1 + strip = true in [profile.release], panics on
    every run, deterministically.
  • After the fix: the same build succeeds, deterministically (2 consecutive
    clean builds from an emptied output directory, byte-identical main-
    bundle hash both times).
  • Main bundle: 1,121,978 bytes - smaller than this app's best prior
    wasm-split-only measurement (1,177,438 bytes, no opt-level/lto/
    strip tuning) and smaller than the opt-level = "z" + strip = true
    (no LTO) measurement that motivated trying LTO in the first place
    (1,150,875 bytes) - the full profile combination now works, and pays
    off, rather than merely compiling.

…o a discarded graph

build_call_graph() computes a fallback for functions the old<->new module
name reconciliation can't line up (recovered edges dropped mid-graph, and
wasm-bindgen-synthesized shims like the per-signature invoke* closure
trampolines that have no old-module counterpart at all) by attaching them
as children of `main` - "we're going to attach the recovered children to
the main function" / "attach any truly new symbols to the main function.
Usually these are the shim functions".

That attachment wrote into `new_call_graph`, a local variable distinct from
`self.call_graph` (which was already fully assigned earlier from
`original.call_graph`'s reconciliation). `new_call_graph` is never read
again and never merged into `self.call_graph` - the whole mechanism was a
silent no-op.

Concretely: an `invoke*` shim (JS calls back into it via a table slot
Closure::wrap captured, never via a traced call/ref.func instruction) had
no path from any root in the graph reachability is actually computed from.
It survived pruning only by luck - `unused_main_symbols()` only considers
things reachable from *some split point*, so a graph orphan is simply never
a deletion candidate, not proven live. Anything it called (a real,
still-referenced closure body) could still be wrongly deleted from the main
module if that callee also happened to be reachable from an unrelated split
point's subtree. The still-live shim's own `call` instruction into the
now-deleted callee survives untouched (delete() doesn't rewrite
references), so walrus's GC pass - walking that call to determine
reachability - indexes the dead function id and hits its own internal
assertion ("assertion failed: !self.dead.contains(&id)").

Reproduces reliably with `-C lto=fat -C codegen-units=1`: whole-program
visibility lets the linker collapse many originally-distinct closures onto
far fewer, far-more-widely-shared invoke* monomorphizations, each with much
higher fan-out into real closure bodies - raising the odds that one such
callee also happens to be reachable from some split point. A normal
multi-CGU, non-LTO build has the exact same gap, it just rarely produces a
shim with high enough fan-out to hit an overlapping callee in practice.

Fixed by writing to `self.call_graph` directly instead of the discarded
`new_call_graph` (which is now removed entirely - the HashSet<Node> it
mapped `new`-only names to was never read either).

Investigation note: an earlier attempt at this fix rooted every function
referenced by *any* element/table segment, not just recovered/truly-new
ones. That's far too broad for this codebase - dioxus's own component
dispatch and event-handler closures also go through the function table, so
it silently defeated route-level wasm-split entirely (36 route chunks
collapsed to ~1KB stubs each, everything pulled back into the main bundle).
Not applied.
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