DioxusLabs / DioxusLabs/dioxus

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

Open
#5,768 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
Rust
Stars
39.1k
Forks
1.9k
Avg merge
4d 10h
Merged PRs (30d)
4

Description

# 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`):

```rust
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.

```rust
// 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

```diff
+ fn descend(lost_children: &mut HashSet, old_graph: &HashMap>, 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`.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start in packages/wasm-split/wasm-split-cli/src/lib.rs at build_call_graph(), and compare child resolution with the existing parent recovery path. Reproduce the issue with the documented wasm-split build if available; done means unresolved child edges are accounted for rather than silently discarded, with the reported 228-edge case recovered.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust, wasm
Domain
build-system, cli
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.