DioxusLabs / DioxusLabs/dioxus
`wasm-split-cli` truncates a live data symbol that overlaps a pruned dead one
- 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)` - the exact
`core::fmt::Arguments` layout below is specific to a recent nightly
redesign (see Root cause); the underlying data-symbol-overlap bug is not.
- wasm-bindgen-cli: `0.2.127`
- OS: Arch Linux (rolling), Linux 7.1.8, x86_64
## Problem
Under `--wasm-split`, a `format!()` call with 2+ arguments where the
*last* one needs a non-`Display` formatter (e.g. `format!("{:x}-{:x}", a,
b)`) silently returns only its first argument's output - not garbled,
just missing everything from that point on. Separately,
`dioxus_core::diff::VirtualDom::get_mounted_dyn_attr` traps with
`unreachable` on every page load.
## Root cause
Recent nightly `std` encodes `format!()`'s template as a single
NUL-terminated bytecode stream (`core::fmt::Arguments::template:
NonNull`), not the classic `pieces: &[&str]` array - literal text is
length-prefixed, a placeholder is a `0xC0`-`0xFF` byte, and a bare `0x00`
marks the end. This is specifically NUL-terminated so `wasm-ld` can
tail-merge identical *suffixes* of different templates to save space (a
standard linker trick for C-string-like constants).
`prune_main_symbols()` (`packages/wasm-split/wasm-split-cli/src/lib.rs`),
used by `emit_main_module` to strip data belonging to symbols unreachable
in the final build, zeroes each dead symbol's own declared byte range:
```rust
for i in symbol.segment_offset..symbol.segment_offset + symbol.symbol_size {
data.value[i] = 0;
}
```
This assumes symbols never overlap. They can: when a short, dead
template's bytes are a byte-for-byte suffix of a longer, still-*live*
template (sharing storage via the tail-merge above), zeroing the dead
symbol's range stomps the tail of the live one. A truncated template's
interpreter hits the now-premature `0x00` and stops right after the first
placeholder - exactly the observed symptom. The stray `Code`/`libero`
dependency that seemed load-bearing across earlier bisection rounds never
was: it just happened to shift *which other* symbols got dead-code-
eliminated in a way that exposed the same latent overlap bug.
## Minimal reproduction
A `#[wasm_split]` split point is required (only symbols made unreachable
*by the split* get pruned at all), plus a multi-placeholder `format!()`
call reachable from `main`:
```rust
// src/main.rs - dioxus 0.7.10, built with `dx build --platform web
// --release --wasm-split` (any Dioxus web app template works; a real
// libero::components::Code call was the smallest thing we found that
// reliably shares a tail-merged template with the corrupting call - see
// note below).
fn two_hex() -> String {
format!("{:x}-{:x}", 0xAAAAu64, 0xBBBBu64)
}
#[wasm_split::wasm_split(minimal_split)]
async fn split_page() -> Element {
rsx! { div { "split page content" } }
}
#[component]
fn App() -> Element {
use_effect(|| {
// drive `split_page` via `Future::poll` so it isn't dead-code-
// eliminated before wasm-split ever sees it, if never awaited
// elsewhere.
web_sys::console::log_1(&two_hex().into()); // prints "aaaa", not "aaaa-bbbb"
});
rsx! { /* anything that pulls in enough surrounding code to make some
*other*, differently-shaped template become dead code - in
our case, libero::components::Code - is what actually flips
this from passing to failing; the format!() shape above is
necessary but not sufficient on its own */ }
}
```
Confirmed directly (not just inferred) by dumping the compiled bytes at
the `template` pointer: `[0xC0, 0x01, 0x2D, 0xC0, 0x00]` (correct, 5 bytes)
in the pre-split object vs. `[0xC0, 0x00, 0x00, 0x00, 0x00]` (only the
first byte survives) in the actual served, corrupting module - and a
`SYMBOLS_AT`-style scan of the linking section's symbol table (see the
companion tooling writeup) shows a second, unrelated, 4-byte dead symbol
declared at exactly `[1048875, 1048879)` - a byte-for-byte suffix of our
own `[1048874, 1048879)` live one.
## Patch
```diff
fn prune_main_symbols(&self, out: &mut Module, unused_symbols: &HashSet) -> Result<()> {
for split in self.split_points.iter() {
out.exports.delete(split.export_id);
}
+ // Data symbols can overlap via wasm-ld's tail-merging of
+ // NUL-terminated byte constants - mark every byte a live
+ // (not-unused) symbol owns before zeroing anything.
+ let mut live_bytes: Vec = out.data.iter().nth(0)
+ .map(|data| vec![false; data.value.len()])
+ .unwrap_or_default();
+ for (id, symbol) in self.data_symbols.iter() {
+ if symbol.which_data_segment != 0 || unused_symbols.contains(&Node::DataSymbol(*id)) {
+ continue;
+ }
+ let end = (symbol.segment_offset + symbol.symbol_size).min(live_bytes.len());
+ for i in symbol.segment_offset.min(end)..end {
+ live_bytes[i] = true;
+ }
+ }
+
for symbol in unused_symbols.iter().cloned() {
match symbol {
Node::Function(id) => { out.funcs.delete(id); }
Node::DataSymbol(id) => {
let symbol = self.data_symbols.get(&id)?;
if symbol.which_data_segment == 0 {
let data = out.data.get_mut(/* ... */);
for i in symbol.segment_offset..symbol.segment_offset + symbol.symbol_size {
+ if live_bytes.get(i).copied().unwrap_or(false) {
+ continue;
+ }
data.value[i] = 0;
}
}
}
}
}
Ok(())
}
```
(full diff in `fix/data-symbol-overlap`)
## Why this fixes it
`prune_main_symbols` already has every piece of data it needs
(`self.data_symbols`, `unused_symbols`) to know which bytes are claimed by
a symbol that's *still reachable* - it just wasn't cross-referencing that
before zeroing. Computing `live_bytes` up front and skipping those
positions makes the zeroing pass respect symbol overlap: a dead symbol's
range still gets zeroed everywhere it doesn't intersect a live one, but
never stomps bytes a live symbol also claims. Verified end to end against
a real 26-route production app, both with and without an application-level
workaround that avoided the trigger shape entirely - clean either way,
confirming the fix alone is sufficient.
## Verified on stable Rust
Reproduced and fixed identically switching from nightly to stable `rustc
1.97.1`: unpatched `wasm-split-cli` truncates `format!()` output the same
way, and the patched build resolves it the same way - byte-identical
`Arguments` layout (8 bytes, same NUL-terminated `template` encoding) on
both toolchains. This isn't a nightly-only edge case.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in packages/wasm-split/wasm-split-cli/src/lib.rs at prune_main_symbols, then inspect data_symbols and the unused_symbols pruning flow. Reproduce with the minimal two-placeholder format! example and a wasm split point, comparing the pre-split and served module output. Done means live data symbols are not truncated or trapped when overlapping pruned symbols, on stable and nightly Rust.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, wasm
- Domain
- build-system, cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 65/100