Skip to content

Fix wasm-split runtime crashes in client side rendered apps#5668

Open
TristenHarr wants to merge 2 commits into
DioxusLabs:mainfrom
Brahmastra-Labs:fix/wasm-split-csr
Open

Fix wasm-split runtime crashes in client side rendered apps#5668
TristenHarr wants to merge 2 commits into
DioxusLabs:mainfrom
Brahmastra-Labs:fix/wasm-split-csr

Conversation

@TristenHarr

Copy link
Copy Markdown

Turning on --wasm-split on a large client-rendered app (no fullstack or hydration, ~30 split modules, fat LTO) crashed on every route resolution with Cannot read properties of undefined (reading 'listening') from the interpreter. That error is a ReplaceWith pointing at a node id that was never registered. It came down to two unrelated bugs, one in the splitter and one in core suspense.

wasm-split-cli

Two things were quietly dropping symbols out of the call graph.

Functions were resolved by name against the name section. LTO folds identical functions and drops the folded names, so those lookups missed and the symbols fell out of the graph. Resolving by the symbol's function index avoids that (I kept the name lookup as a fallback).

The bigger one: build_call_graph matched old-module names to new-module names by string equality, but dx runs wasm-bindgen with demangling, so the new names are demangled and the old ones aren't. Almost nothing matched (58 of ~11,700 functions in my app). The code was supposed to fall back and keep the unmatched symbols in main, but it wrote them into a map it never read. So data reachable from a split got pruned out of main and shipped as active data segments in the chunk instead, which left main's own statics (down to template node_paths) reading as zero until a chunk load happened to overwrite that memory. Demangling the old names before matching fixes it. I also keep any data symbol whose byte range overlaps one main keeps, since the linker tail-merges constants and pruning one symbol would zero another.

core suspense

A scope rendered while suspended gets no writer, so its mounts keep placeholder element ids. Two paths let that state reach a real writer later.

Boundaries weren't gated the way regular scopes are in run_and_diff_scope, and scope_should_render only looked at the nearest boundary. So a nested boundary that resolved while an ancestor was still suspended would diff its own never-mounted fallback and emit ReplaceWith(MAX). Fixed by gating boundary diffs the same way and walking the whole parent chain.

Separately, when an outer boundary re-creates its subtree, SuspenseBoundaryProps::create rebuilt inner boundaries from props.children, whose mount cell goes stale after memoize (VNode::clone copies the Cell by value). That minted a fresh set of child scopes and orphaned the live ones, which later re-rendered against unmounted state. Fixed by reusing the boundary's existing render and scopes when it already exists.

Each fix has a test that reproduces the exact bad mutation before it.

Checked with cargo test on dioxus-core and dioxus-ssr, and against the real app (46 routes) which went from crashing on every split route to clean.

The two halves are independent if it's easier to review or merge them separately.

… main

Two related resolution bugs corrupted split builds of a large real world
app (pure CSR dioxus-web, 30+ split modules, fat LTO):

1. Function symbols were resolved by name against the module's name
   section. The linker folds identical functions into one body, and the
   folded names no longer exist in the name section, so those symbols
   were silently dropped from the call graph. Resolve by the symbol's
   function index instead, which survives folding, and keep the name
   lookup as a fallback.

2. build_call_graph translated old module names to new module names by
   string equality, but dx runs wasm-bindgen with demangling, so the
   post bindgen name section is demangled while the pre bindgen module
   is mangled. In the test app only 58 of ~11700 functions matched. The
   conservative recovery that attaches lost nodes to main was written
   to a map that was never merged into the real graph. Data symbols
   reachable from split modules were then pruned from the main module
   and shipped as chunk load time active segments, which left main
   statics reading as zeros until a chunk load overwrote memory under
   live code. In the app this corrupted template node_paths and crashed
   route resolution with ReplaceWith on an unregistered node.

   Match names via rustc_demangle, whose Display output is byte
   identical to what wasm-bindgen writes back, and merge the recovery
   into the call graph. Also keep any data symbol whose byte range
   overlaps a retained symbol, since the linker tail merges constants
   and pruning one would zero bytes inside a kept symbol.

A unit test pins the demangling equivalence so a future rustc-demangle
or wasm-bindgen change cannot silently regress matching.
Scopes rendered while suspended get no writer, so their mounts hold
placeholder element ids. Two paths let that state reach a real writer:

- run_and_diff_scope passed the raw writer to SuspenseBoundary diffs,
  and scope_should_render only checked the nearest boundary, so a
  nested boundary resolving under a still suspended ancestor diffed
  its never mounted fallback and emitted ReplaceWith(u32::MAX).
- SuspenseBoundaryProps::create re-created existing boundary scopes
  from props.children whose mount cell had gone stale after memoize
  (VNode::clone copies the Cell by value). That minted fresh child
  scopes and orphaned the live ones, which later re-rendered against
  unmounted state.

Gate boundary diffs like regular scopes, walk all ancestors in
scope_should_render, and reuse the stored render plus existing scopes
when re-creating a live boundary. The regression tests fail before the
fix with ReplaceWith { id: ElementId(MAX) } in the mutation stream. The
web interpreter then dies with: TypeError: Cannot read properties of
undefined (reading 'listening').
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