#!/usr/bin/env nu # Split the debug info out of the built wasm. # # The bundle is built WITH DWARF line tables so a crash can be traced back to a # source line. Shipping those sections would add ~20 MB to every visit, so this # writes two files instead: # # public/assets/wiki_bg-.wasm stripped — the one that ships # public/symbols/.debug.wasm the same module, DWARF intact # # Stripping only removes custom sections, so the code section keeps its offset # and its function indices. That is what makes the split safe: a stack frame # means the same thing in both files, so the symbols resolve against exactly the # binary the reader was running. # # NOTHING MAY REWRITE THE MODULE AFTER THIS POINT. A `wasm-opt -Oz` pass used to # run here, on the shipped copy only. It inlines, merges and drops functions and # prunes unused imports, so the pair silently stopped corresponding (12148 vs # 10707 functions, 425 vs 416 imports) and every report came back resolved to a # real source line in an unrelated crate. It was removed rather than reordered: # binaryen aborts on a module that still holds DWARF, even with `-g`. It cost # 9 KB gzipped (0.5% of what a reader downloads), which is not worth a crash # report that lies. `assert_pair` below fails the build if it happens again. # # The pair is keyed by dx's own content hash, already in the filename and already # in the wasm URL the browser reports, so no build-id section is needed. The # backend fetches /symbols/.debug.wasm when a report arrives; see # backend/src/symbolicate.rs. const PUBLIC = "target/dx/wiki/release/web/public" # How many builds' worth of symbols to keep. # # dx never removes a superseded asset, so without this the directory gains ~26 MB # per build forever and every deploy uploads the lot. # # Six, not three. Three assumed a reader reloads within a deploy or two, and a # day of thirteen deploys disproved it: a crash arrived from a tab whose build # was four deploys old, its symbols already pruned, and came back as raw offsets. # What bounds this is how long a tab stays open, not how often we deploy. Six # costs about 160 MB on the site and roughly a minute of upload. # # Losing an older one is not a failure: the backend fetches the sidecar, gets the # site's SPA fallback (HTML, not a wasm module) instead, recognises it by the # missing magic bytes and leaves the raw offsets alone. Unresolved beats wrong, # which is why an older build is never resolved against a newer one's symbols — # the offsets would land in whatever function now occupies that address. const KEEP = 6 # Drop all but the newest KEEP sidecars, never the one this build just produced. # # What it removes is printed. A prune that stayed quiet would read as "everything # is still there", which is the one thing it is not. def prune_symbols [symbols_dir: string, current: string] { let sidecars = ( ls $symbols_dir | where name =~ '\.debug\.wasm$' | sort-by modified --reverse ) if ($sidecars | length) <= $KEEP { return } for stale in ($sidecars | skip $KEEP | where name != $current) { rm $stale.name print $"pruned ($stale.name) ($stale.size)" } } # The wasm this build actually serves, followed from index.html. # # NOT the newest file matching a glob: dx never removes superseded assets, so # that directory accumulates wasm files from every previous build and the wrong # one would be split — leaving the shipped binary fat and the sidecar useless. def current_wasm [] { let entry = ( open $"($PUBLIC)/index.html" | parse --regex 'wiki-(?[0-9a-z]+)\.js' | get h.0 ) let name = ( open $"($PUBLIC)/assets/wiki-($entry).js" | parse --regex 'wiki_bg-(?[0-9a-z]+)\.wasm' | get h.0 ) { hash: $name, path: $"($PUBLIC)/assets/wiki_bg-($name).wasm" } } # The imports and code sections as objdump reports them: offsets, sizes, counts. def section_shape [path: string] { ^wasm-tools objdump $path | lines | each {|l| $l | str replace --all --regex '\s+' ' ' | str trim } | where {|l| ($l | str starts-with "code ") or ($l | str starts-with "imports ") } | sort | str join " // " } # A frame carries a byte offset (Chrome, Firefox) or a function index (Safari). # Both are positions in the code section, and both are meaningless against a # module whose code section moved. Refuse to publish a pair that cannot resolve. def assert_pair [shipped: string, sidecar: string] { let a = (section_shape $shipped) let b = (section_shape $sidecar) if $a == $b { return } print "ABORT: the shipped wasm and its sidecar are different modules." print $" shipped ($a)" print $" sidecar ($b)" print "A reported frame would resolve to whatever function now sits at that" print "address. Something rewrote the module after the split; move it before." exit 1 } def main [] { let target = (current_wasm) if not ($target.path | path exists) { print $"referenced wasm missing: ($target.path)" exit 1 } let symbols_dir = $"($PUBLIC)/symbols" mkdir $symbols_dir let sidecar = $"($symbols_dir)/($target.hash).debug.wasm" # This rewrites the build output in place, and dx reuses an unchanged wasm # rather than regenerating it — so a second `just build` finds the binary # already stripped. That is success, not failure, as long as the sidecar from # the first run is still there. Without this check the split would overwrite # a good sidecar with a copy of the stripped binary, quietly destroying the # symbols it exists to keep. let has_debug = (^wasm-tools objdump $target.path | str contains ".debug_") if not $has_debug { if ($sidecar | path exists) { # Check the pair we are keeping, not just the one we just made: a # rebuilt-but-unchanged wasm takes this path every time. assert_pair $target.path $sidecar print $"already split ($sidecar)" prune_symbols $symbols_dir $sidecar return } print "shipped wasm has no debug sections and no sidecar exists —" print "was it built without --debug-symbols?" exit 1 } cp $target.path $sidecar # `-d` takes a regex over section names. The DWARF sections are the only ones # worth megabytes; everything else stays, so nothing else about the module # changes. ^wasm-tools strip -d '^\.debug_' $sidecar -o $target.path assert_pair $target.path $sidecar let symbols_size = (ls $sidecar | get size.0) let shipped_size = (ls $target.path | get size.0) print $"symbols ($sidecar) ($symbols_size)" print $"shipped ($target.path) ($shipped_size)" if $shipped_size >= $symbols_size { print "WARNING: stripping freed nothing — was the build made without debug info?" exit 1 } prune_symbols $symbols_dir $sidecar }