Nishi Browser — JS Lane Unblocked

2026-07-27 compiler root-cause dt_parse 206KB / 23ms JS2 wired into the daily driver

What was actually wrong (two sessions called it "memory corruption" — it never was)

The mutable-DOM parser dt_parse hung on any element with an attribute (<a b> = a 10-byte reproducer). Prior sessions diagnosed heap-layout-dependent memory corruption in the nx runtime. The truth, proven mechanically from the emitted assembly: nx_cc left TWO terminators in any if-arm containing break/continue/return (the parser appended its merge-branch after the statement's branch). Every CFG pass then read the dead branch as the block's terminator, and the 07-16 emit-time branch-inlining made the dead branch reachable — control fell back into the loop body with the index unchanged. An infinite spin that moved whenever you added a debug print — because prints change block structure, not heap layout.

The fix — one guard at the IR root

ir_bb_sealed in nx_ir.nx: a block already ending in an unconditional terminator refuses further branches. Nothing after a terminator, ever. The self-hosted compiler was rebuilt through a 3-stage bootstrap to a byte-identical fixpoint (542,070 B), and the fix removed dead branches from the compiler's own body (it shrank 3 KB).

Evidence ladder (all measured 2026-07-27, laptop-local, NAS-independent)

CheckBeforeAfter
break-form probe corpus (P0–P5)P4 spins foreverALL-DONE rc=0
Oracle agreement (C-bootstrap known-good)disagreesexact match (rc=3)
nx_domtree_gateHANGS before first check11/11 GREEN
dt_parse, real Wikipedia (206,630 B)infinite hang3,922 nodes in 23 ms
parse→serialize round-trip"empties real pages"99% survival, marker kept, 22 ms
nx_browser_bg_gate16/0 GREEN16/0 GREEN (no regression)
nx_js_conformance92/9292/92 (no regression)

Regression-proof, not regression-hoped

nx_cc_health (the standing compiler witness) grew a break-semantics tooth: it builds the probe corpus and runs it under a child-side alarm, so a future regression hang becomes a clean RED. Negative control ran and passed: with the pre-fix compiler swapped back in, the witness went RED (probe killed by SIGALRM at 8 s); with the fixed compiler, GREEN.

The JS engine got a real budget

The per-loop iteration cap (10M) multiplies under nesting and never bounded recursion. A global execution fuel now counts every js_eval dispatch; exhaustion unwinds as an ordinary error and the page renders script-less. Proven with a nested-infinite-loop page: terminates in 221 ms, fuel_hit=1, static content survives. Conformance unchanged at 92/92. Fuel is sized by memory, not time: eval temporaries currently cost one page per step (no arena yet) — the arena is the next engine-hardening rung.

Wired into the daily driver (R-NISH-JS2)

The fetcher's HTML path now runs inline scripts against the parsed DOM and emits the mutated serialization — fail-safe by construction (script-less pages take the untouched verbatim path; any suspicious shrink falls back to the raw body; fuel bounds hostile scripts). The exact wiring that had to be reverted on 07-23 (Wikipedia hung the fetcher):

Live test (rebuilt nishi_fetch.exe)Result
example.com (no scripts → verbatim path)byte-perfect passthrough
Wikipedia, full JS2 path (fetch+parse+run scripts+serialize)205,796 B in 598 ms wall (was: infinite hang)
facebook shellrendered, no hang, no fallback

Second leg — the foundational allocator, and two bugs measurement found

Eval-cell arena: 328× less memory, 31× faster

Every eval step allocated a 16-byte value box via its own mmap — 204 call sites — and the kernel rounds each to a resident 4096-byte page. That 256× amplification is what OOM-killed the VM and forced the fuel down to 1M steps. Since the engine never frees a value box, a bump arena is lifetime-identical by construction. Measured result:

Fuel budgetArenaWallOld scheme (page/cell)
200,000 steps2,048 KiB6 ms~781 MiB, 221 ms
1,000,000 steps12,288 KiB32 ms~3.8 GiB (OOM)
5,000,000 steps62,464 KiB159 ms~19 GiB (OOM)

Linear at 12.5 bytes/step, so the production fuel ceiling was raised 1M → 10M steps — a number derived from that table (~125 MiB, ~320 ms worst case), not chosen by taste. The gate asserts bytes-per-step ≤ 64: 5× headroom over measured, 64× under a page-per-cell regression, so it cannot pass by luck.

A NULL-pointer crash on real Wikipedia

js_render_page_pending_begin documents "returns 1 on parse error" and leaves the global environment NULL. The external-bundle consumer ignored that return and marched a NULL into the pending-fetch loop; the accessors guarded genv[5]==0 — which dereferences genv to check it — faulting at address 0x28 (5×8). Wikipedia's concatenated page scripts hit a parse error and the process dumped core. Fixed at both ends: null-guard before the dereference in all four accessors (a browser must never crash on unparseable page JS) and an honest rc check at the caller that degrades to a static render. Same page now yields 24,568 bytes of DOM text instead of a core dump.

Cross-origin bundles: 0 of 2 → 2 of 2

The consumer only resolved root-relative script paths. Modern SPAs host bundles on CDNs, so Netflix referenced 2 bundles and fetched zero. Script URLs now resolve the three browser forms (absolute, protocol-relative, root-relative), https-only so a script we are about to execute can never be downgraded to cleartext, and bounded at 8 bundles per page against fetch storms. Netflix now delivers 2 of 2 bundles = 3.9 MB of JavaScript into the engine. Its hydrated DOM is still 3,376 bytes — bundle delivery is solved; hydration is not. That is the next rung, stated honestly rather than claimed.

Regression sweep — one command, one verdict

_ops/browser_regress_all.sh runs the compiler witness, every DOM/render/JS gate, and the real-page timing and round-trip benches: 7/7 GREEN. It caught its own harness bug first — gates fork sibling organs by relative path, and running them from the wrong directory produced a false compiler-regression RED. A gate's working directory is part of its contract.

GateVerdict
nx_cc_health (compiler + break semantics)GREEN
nx_domtree_gate11/11
nx_browser_bg_gate17/17
nx_js_conformance92/92
nx_jsfuel_probe (fuel + arena, 6 teeth)6/6
dt_parse + round-trip on real WikipediaGREEN

Daily driver rebuilt and re-verified end to end: example.com 563 B byte-perfect, Wikipedia 205,796 B in 525 ms, BBC 624,871 B in 701 ms — all through the live JS path, no hangs, no crashes.

Third leg — walking the parser into a real 3.9 MB bundle

With bundles finally delivered, Netflix and Wikipedia both failed at the same place: the JavaScript parser. Rather than guess, each failure was turned into a named construct with a controlled reproducer — a probe where ASCII/known-good rows must pass, so a failing row is a real gap and not a broken probe. Each fix moved the parse frontier measurably deeper into the bundle:

#Construct the bundle usedWhy it appears everywhereParse frontier
1{À:"A",Á:"A",à:"a"} — non-ASCII identifier keyslodash deburr map, in nearly every bundle— → 289,669
2[0,4,,5] — array elision (hole)TypeScript's downlevel async helper trys.push([0,4,,5])289,669 → 582,104
3x?.5:1 — ternary vs optional chainingminifiers emit .5 for 0.5; ?. was taken greedily582,104 → 860,389

Fix 3 is the spec rule OptionalChainingPunctuator :: ?. [lookahead ∉ DecimalDigit] — one comparison in the lexer. Note that .5 alone and a?.b alone both already worked; only the combination failed, which is exactly why a controlled probe beats reading code. The regression sweep stayed 7/7 GREEN and conformance 92/92 after every fix.

The next blocker is now function*(e){…}generator functions. That is a real language feature, not a lexer one-liner, so the fix loop stops here rather than pretending otherwise.

What this unblocks, and what it does not

The census's biggest rock is the SPA/JS platform gap — five daily-driver sites at 0‰ reading-recall. The language engine was already at 92/92; what was missing was a parser that survives real HTML, a memory model that survives real scripts, a budget, and non-destructive serialization. Those landed. What has not landed: hydration — running a 3.9 MB React bundle does not yet produce content, and Wikipedia's own page scripts do not parse in our engine. Those two gaps, named precisely, are the next rungs. No SPA site is claimed as fixed, because none is.

Method note: the diagnosis used strace as a layout-neutral breadcrumb trail (each tiny allocation is a visible mmap), a free instruction-pointer sample from strace -i at timeout-SIGTERM, raw-bytes disassembly of the sovereign ELF, a 6-variant probe corpus to pin the exact broken form, and the C-bootstrap compiler as a disagreeing oracle. No debug prints in the guest — prints were what kept the bug invisible for two sessions.