Skip to content

#5769: wasm-split-cli truncates a live data symbol that overlaps a pruned dead one - #5772

Open
tdymel wants to merge 1 commit into
DioxusLabs:mainfrom
tdymel:fix/data-symbol-overlap
Open

#5769: wasm-split-cli truncates a live data symbol that overlaps a pruned dead one#5772
tdymel wants to merge 1 commit into
DioxusLabs:mainfrom
tdymel:fix/data-symbol-overlap

Conversation

@tdymel

@tdymel tdymel commented Aug 22, 2026

Copy link
Copy Markdown

Claude authored fixes for the issue described in #5769.

This took Claude over 8 hours to track down. The underlying issue is pretty
interesting, and only shows up on nightly because of their format! changes.

As this is code from Claude, please take it with a big grain of salt. I verified
it against my project and it fixed the issue.

I hit this in my own component library while building with --wasm-split. It
turned out to be three separate bugs, so there's one PR per fix:

Bug Fix Relation to the corruption
#5764 growable table panic Unconditional .unwrap() on a table that can lack a max Blocked builds; not the corruption itself
#5768 dropped child edges One unresolvable call-graph child edge silently discarded Real and measurable (228/run), confirmed not the cause
#5769 data symbol overlap Pruning a dead symbol zeroes bytes a live one still owns This is it - the actual truncation/crash root cause

Environment

  • Dioxus 0.7.10 (tag 57d6794), wasm-bindgen-cli 0.2.127
  • rustc 1.99.0-nightly (1a98b1e13 2026-08-07) - the exact
    core::fmt::Arguments layout below is specific to a recent nightly redesign,
    but the underlying overlap bug is not (see "Verified on stable" below)
  • Arch Linux, 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<u8>), 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. It's NUL-terminated
specifically so wasm-ld can tail-merge identical suffixes of different
templates to save space - the 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 unreachable symbols, zeroes each
dead symbol's own declared byte range:

for i in symbol.segment_offset..symbol.segment_offset + symbol.symbol_size {
    data.value[i] = 0;
}

That 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. The truncated template's interpreter then hits the now-premature 0x00 and
stops right after the first placeholder, exactly the observed symptom.

Confirmed directly by dumping the compiled bytes at the template pointer:
[0xC0, 0x01, 0x2D, 0xC0, 0x00] (correct, 5 bytes) in the pre-split object versus
[0xC0, 0x00, 0x00, 0x00, 0x00] in the actual served module - and a scan of the
linking section's symbol table shows a second, unrelated, 4-byte dead symbol
declared at exactly [1048875, 1048879), a byte-for-byte suffix of our own live
[1048874, 1048879).

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!() reachable from
main. The format!() shape is necessary but not sufficient on its own - what
flips it from passing to failing is some other, differently shaped template
becoming dead code in the same build, which is what creates the overlapping pair.
#5764's repro script
is fully self-contained and exercises this bug too - it checks the returned string
value, not just the exit code.

Patch

     fn prune_main_symbols(&self, out: &mut Module, unused_symbols: &HashSet<Node>) -> Result<()> {
         for split in self.split_points.iter() {
             out.exports.delete(split.export_id);
         }

+        // Data symbols can overlap: wasm-ld tail-merges identical suffixes of byte constants, so a
+        // short dead blob can be a byte-for-byte suffix of a longer, still-live one and share its
+        // storage. Zeroing a dead symbol's declared range is only safe where no live symbol also
+        // claims those bytes, so mark what's live first (segment 0 only - the only one zeroed).
+        let segment_len = out.data.iter().next().map(|d| d.value.len()).unwrap_or(0);
+        let mut live_bytes = vec![false; segment_len];
+        for (id, symbol) in self.data_symbols.iter() {
+            if symbol.which_data_segment != 0 || unused_symbols.contains(&Node::DataSymbol(*id)) {
+                continue;
+            }
+
+            let start = symbol.segment_offset.min(segment_len);
+            let end = (symbol.segment_offset + symbol.symbol_size).min(segment_len);
+            live_bytes[start..end].fill(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 {
+                            // Don't stomp bytes a live, overlapping symbol shares with this one.
+                            if live_bytes.get(i).copied().unwrap_or(false) {
+                                continue;
+                            }
                             data.value[i] = 0;
                         }
                     }
                 }
             }
         }
         Ok(())
     }

(full diff on fix/data-symbol-overlap)

Why this fixes it

prune_main_symbols already has everything it needs (self.data_symbols,
unused_symbols) to know which bytes a still-reachable symbol claims - it just
wasn't cross-referencing that before zeroing. A dead symbol's range still gets
zeroed everywhere it doesn't intersect a live one, but never stomps shared bytes.
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 on 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. Not a nightly-only edge
case.

@tdymel
tdymel force-pushed the fix/data-symbol-overlap branch 2 times, most recently from 239f931 to 6a6579e Compare August 29, 2026 08:39
…data symbols

prune_main_symbols() zeroes each unreachable data symbol's own declared
byte range in the main module's data segment. LLVM/wasm-ld tail-merges
identical *suffixes* of NUL-terminated byte constants to save space - so a
short, dead symbol's declared range can be a byte-for-byte suffix of a
longer, still-live symbol's range, sharing the same storage. Zeroing the
dead symbol's range then silently truncates the live symbol's tail, since
the code has no notion that data symbols can overlap.

This specifically corrupts core::fmt::Arguments::template - a NUL-
terminated bytecode encoding of a format!() call's literal pieces and
placeholders (new in recent nightly std, replacing the classic
`pieces: &[&str]` slice) - precisely because it's NUL-terminated to make
this kind of tail-merging possible. Losing the tail means the
core::fmt::write interpreter reads a premature 0x00 and stops right after
the first placeholder, so a format!() call's output silently comes out
truncated after its first argument.

Fixed by computing which bytes are still claimed by a live (reachable)
data symbol before zeroing anything, and skipping those bytes specifically
when zeroing a dead symbol's range.
@tdymel
tdymel force-pushed the fix/data-symbol-overlap branch from 6a6579e to fb78865 Compare August 29, 2026 09:13
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