Skip to content

wasm-split-cli silently drops call-graph edges to individually-unresolvable children #5768

Description

@tdymel

Introduction

I am currently writing a component library for Dioxus.
I use wasm split during development, as I also use some third party libraries which are pretty heavy and are not supposed to defer the initial paint pipeline.

I wanted to add a "key" attribute to my style tags to keep the generated style tags more stable and prevent unneeded recalculations. However, I ran into an unusual crash.

I asked Claude to debug and fix it. Hope it helps!

Turns out the problem is multiple fold, and some other problems where discovered on the way. I decided to split these bugs into 3 separate reports. In the end it was traced back to format!(...).

Bug Fixes Relation to the actual corruption
growable table panic Unconditional .unwrap() on a table that can lack a max Blocked builds from completing; not the corruption itself
dropped child edges Single unresolved call-graph child edge silently discarded Real & measurable (228/run), confirmed not the cause
data symbol overlap Pruning a dead symbol zeroes bytes a live one still owns This is it — the actual truncation/crash root cause

Below I asked Claude to make a writeup for each issue. As I dont really understand the inner workings of this library, but I verified that the fixes actually solved the problem. So please look at it and verify it yourself. I am also going to make a separate pull request for each fix that Claude found.

Environment

  • Dioxus: 0.7.10 (tag 57d6794)
  • Rust: rustc 1.99.0-nightly (1a98b1e13 2026-08-07)
  • wasm-bindgen-cli: 0.2.127
  • OS: Arch Linux (rolling), Linux 7.1.8, x86_64

Problem

build_call_graph() correlates the pre- and post-wasm-bindgen modules by
function name. When a parent node has no name match in the new module,
its known children are recovered into main via a descend() walk - but
when a single child reference inside an otherwise-resolvable parent has
no name match, that one edge is dropped with no fallback and no log line.
An unresolvable child is exactly as reachable as the parent calling it, so
losing the edge can make wasm-split think a live subtree is dead code.

Root cause

build_call_graph() (packages/wasm-split/wasm-split-cli/src/lib.rs):

let mut new_children = HashSet::new();
for child in children {
    if let Some(new) = get_old(child) {
        new_children.insert(new);
    }
    // no `else` - a resolution miss here is just... gone.
}

vs. the parent-side handling a few lines above, which does have a
recovery path (descend() walking into lost_children, later reattached
to main) for exactly the same kind of resolution miss.

Minimal reproduction

Verified directly against wasm-split-cli's own logic on a real
production app (28-route Dioxus site, --wasm-split build): instrumented
the else-less branch above to count misses.

// Added temporarily at the site of the missing `else`:
} else if std::env::var("COUNT_DROPPED_CHILDREN").is_ok() {
    eprintln!("DROPPED_CHILD_EDGE: {child:?}");
}

Running Splitter::new(original, bindgened)?.emit() on the unpatched
code against this app's real (original, bindgened) pair: 228 child
edges dropped
, all silently. Applying the patch below and re-running the
same input with an equivalent counter on the recovery branch: 228
edges recovered, 0 dropped
- a 1:1 match, confirming the fix addresses
exactly the cases the bug loses.

This did not change the final main module's reachability set enough to
flip any function from present to unreachable-trap in this particular
app (A/B tested), but 228 silently-dropped edges on a single build is not
a hypothetical corner case - it's a routinely-hit code path that happens
to have had a safe landing spot in this app, not in general.

Patch

+        fn descend(lost_children: &mut HashSet<Node>, old_graph: &HashMap<Node, HashSet<Node>>, node: Node) {
+            if !lost_children.insert(node) { return; }
+            if let Some(children) = old_graph.get(&node) {
+                for child in children { descend(lost_children, old_graph, *child); }
+            }
+        }
+
         let mut lost_children = HashSet::new();
         self.call_graph = original.call_graph.iter().flat_map(|(old, children)| {
             let Some(new) = get_old(old) else {
                 for child in children {
-                    fn descend(...) { ... } // was defined inline here only
                     descend(&mut lost_children, &original.call_graph, *child);
                 }
                 return None;
             };

             let mut new_children = HashSet::new();
             for child in children {
-                if let Some(new) = get_old(child) {
-                    new_children.insert(new);
+                match get_old(child) {
+                    Some(new) => { new_children.insert(new); }
+                    // Same situation as a dropped parent above, just for
+                    // one edge instead of the whole node.
+                    None => descend(&mut lost_children, &original.call_graph, *child),
                 }
             }
             Some((new, new_children))
         }).collect();

(full diff in fix/dropped-callgraph-edges)

Why this fixes it

The descend() helper already existed and was already proven correct for
the "whole node unresolvable" case - it walks the original module's call
graph (which has complete, real edges) from a given node and marks
everything reachable from it as lost_children, later reattached to
main so it's never treated as dead code. This patch just hoists that
helper out of its single call site and reuses it for the "one unresolvable
child of an otherwise-resolvable parent" case too, since the two failure
modes need the identical recovery: an edge you can't map to the new module
by name is not evidence the target is dead, just that name-correlation
missed it.

Verified against dioxus main

wasm-split-cli's logic is unchanged between the 0.7.10 tag and current
main (24f6a829d at time of writing) - the only diff is cosmetic
import/macro reformatting. This patch cherry-picks cleanly onto main
with no conflicts. Re-ran the same 228-dropped/228-recovered counter check
against main's build of the same real app: identical counts, and
emit()'s output is byte-for-byte identical to the same run against
0.7.10.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions