wasm-split-cli deletes a still-called main-module function under fat LTO - #5795
Open
tdymel wants to merge 1 commit into
Open
wasm-split-cli deletes a still-called main-module function under fat LTO#5795tdymel wants to merge 1 commit into
wasm-split-cli deletes a still-called main-module function under fat LTO#5795tdymel wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-splitbuild.Root-caused and patched by: Claude, during the resulting
investigation into why that exact profile combination crashes the build.
Environment
main(e2cc82e63), version0.8.0-alpha.1rustc 1.97.1 (8bab26f4f 2026-07-14)0.2.121Problem
dx build --platform web --release --wasm-splitcrashes deterministicallywhen the consuming crate's
[profile.release]setslto = trueandcodegen-units = 1(alongsideopt-level = "z"andstrip = true, thoughthose 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 = truebuild, no LTO, builds and splits fine.The panic is
walrus's own internal invariant check, not awasm-split-clierror message:
8: walrus::passes::gc::run9: <wasm_split_cli::Splitter>::emitRoot cause
main_roots()(packages/wasm-split/wasm-split-cli/src/lib.rs) - the setof functions
unused_main_symbols()/prune_main_symbols()treat asdefinitely-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 theold-module/new-module name reconciliation can't line up by itself:
(
recovered_children- see the related, already-fixed "dropped call-graph child edges" bug in this same function).
all (a
new_namesentry missing fromold_names) - primarily theper-signature
invoke*closure-invocation shims wasm-bindgen generates,which JS calls back into via a function-table slot
Closure::wrapcaptured, never via a traced
call/ref.funcinstruction anywhere inthe compiled Rust code.
Both cases are meant to be attached as children of
main- the code's owncomments 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"):
new_call_graphis a local variable, distinct fromself.call_graph(theone already fully assigned, earlier in the same function, from the
reconciled
original.call_graph, and the only onemain_graph/shared_symbols/pruning ever read from).new_call_graphis never readagain after being built, and never merged into
self.call_graph- theentire attachment mechanism, for both cases, is a silent no-op on every
build.
Concretely: an
invoke*shim with no incoming edge inself.call_graphhas 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 somesplit 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; theshim's own
callinstruction 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 thatstill-live
callto determine reachability during its own pass, indexesthe 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-outinto 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)inemit_main_module: scan everystill-live function's instructions (and every element-segment and global
RefFuncentry) for a reference to an id no longer present inout.funcs,and print it before the crash actually happens. Rebuilt
dxwith theinstrumentation, ran it against a real 40-route Dioxus web app with the
triggering profile:
Two distinct
wasm_bindgen::convert::closures::invoke*shims, both stillpresent in the module, both calling the same already-deleted function -
confirming the mechanism above rather than guessing at it.
Patch
(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 thefunction table pervasively, so it silently defeated route-level
wasm-splitentirely instead of fixing the gap (all 36 of the test app'sroute 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_graphis the only graphmain_graph/shared_symbols/pruningever read reachability from. Writing the recovered-children and truly-new-
symbol attachments there instead of the previously-discarded
new_call_graphmakes both cases actually reachmainin the graph thatmatters, instead of being computed and thrown away.
new_call_graphisremoved entirely, since nothing else ever read from it either.
Verification
dx build --platform web --release --wasm-splitagainstthe real 40-route app, with
opt-level = "z"+lto = true+codegen-units = 1+strip = truein[profile.release], panics onevery run, deterministically.
clean builds from an emptied output directory, byte-identical main-
bundle hash both times).
wasm-split-only measurement (1,177,438 bytes, noopt-level/lto/striptuning) and smaller than theopt-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.