#5769: wasm-split-cli truncates a live data symbol that overlaps a pruned dead one - #5772
Open
tdymel wants to merge 1 commit into
Open
#5769: wasm-split-cli truncates a live data symbol that overlaps a pruned dead one#5772tdymel wants to merge 1 commit into
tdymel wants to merge 1 commit into
Conversation
tdymel
force-pushed
the
fix/data-symbol-overlap
branch
2 times, most recently
from
August 29, 2026 08:39
239f931 to
6a6579e
Compare
…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
force-pushed
the
fix/data-symbol-overlap
branch
from
August 29, 2026 09:13
6a6579e to
fb78865
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. Itturned out to be three separate bugs, so there's one PR per fix:
.unwrap()on a table that can lack a maxEnvironment
0.7.10(tag57d6794), wasm-bindgen-cli0.2.127rustc 1.99.0-nightly (1a98b1e13 2026-08-07)- the exactcore::fmt::Argumentslayout below is specific to a recent nightly redesign,but the underlying overlap bug is not (see "Verified on stable" below)
Problem
Under
--wasm-split, aformat!()call with 2+ arguments where the last oneneeds a non-
Displayformatter (e.g.format!("{:x}-{:x}", a, b)) silentlyreturns only its first argument's output - not garbled, just missing everything
from that point on. Separately,
dioxus_core::diff::VirtualDom::get_mounted_dyn_attrtraps withunreachableonevery page load.
Root cause
Recent nightly
stdencodesformat!()'s template as a single NUL-terminatedbytecode stream (
core::fmt::Arguments::template: NonNull<u8>), not the classicpieces: &[&str]array - literal text is length-prefixed, a placeholder is a0xC0-0xFFbyte, and a bare0x00marks the end. It's NUL-terminatedspecifically so
wasm-ldcan tail-merge identical suffixes of differenttemplates 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 byemit_main_moduleto strip data belonging to unreachable symbols, zeroes eachdead symbol's own declared byte range:
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
0x00andstops right after the first placeholder, exactly the observed symptom.
Confirmed directly by dumping the compiled bytes at the
templatepointer:[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 thelinking 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 thesplit get pruned at all), plus a multi-placeholder
format!()reachable frommain. Theformat!()shape is necessary but not sufficient on its own - whatflips 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_symbolsalready has everything it needs (self.data_symbols,unused_symbols) to know which bytes a still-reachable symbol claims - it justwasn'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: unpatchedwasm-split-clitruncatesformat!()output the same way, and the patched buildresolves it the same way - byte-identical
Argumentslayout (8 bytes, sameNUL-terminated
templateencoding) on both toolchains. Not a nightly-only edgecase.