fix: reap all cross-navigation state on warm Page reuse (#33) - #34
fix: reap all cross-navigation state on warm Page reuse (#33)#34yfedoseev wants to merge 1 commit into
Conversation
Reusing a Page across navigations grew V8's live heap by ~10 MB per page
without ceiling, OOMing long batches. Every reaper the engine had was wired
only to Page::drop -- which a pool by definition never reaches -- and several
bootstrap-JS registries are scoped to the JsRuntime rather than the document.
Sources of retention, all previously unpruned on reuse:
event_bootstrap.js _objListeners window-keyed, never collected
_nodeListeners strong Map, never pruned at all
dom_bootstrap.js _nodeCache, _scrollState, _moObservers,
_appendedIframes, _frameRegistry
window_bootstrap.js _customElementsRegistry, _whenDefinedPromises
globalThis page-assigned properties and on* handlers
New JS reset hooks (__cancelAllListeners, __resetDomRegistries,
__resetCustomElements, __resetPageGlobals), bundled behind a public
Page::reset_for_reuse(). PagePool::acquire and the CDP protocol server --
which had the identical bug on Page.navigate -- now call it.
Two correctness bugs fall out of the same fix. _nodeListeners and the node
wrapper cache are keyed by nodeId, and node IDs restart at zero when
replace_dom swaps the document, so the previous page's handler for node 42
fired on the new page's node 42. And re-define()ing a custom element name the
previous page had registered was a silent no-op.
on* handlers need value-level treatment: they already exist as own properties
at bootstrap, so a key-set diff cannot see `window.onscroll = fn`. Values are
snapshotted at baseline and restored, which clears page assignments while
preserving the engine's own window.onerror instrumentation.
A/B on live heap after a forced full GC, 25 warm reuses of a document
retaining ~1 MB behind a window listener closure:
without reset (0.1.0 behaviour) 1,040,662 B / reuse
with reset (this change) 468 B / reuse
Adds Page::v8_heap_used_bytes() and Page::collect_garbage() so operators can
verify pool health themselves; the A/B test above is built on them.
Closes #33
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ptn8MHsL8gwcsszwquKrfu
Real-site regression:
|
| site | main | HEAD | |
|---|---|---|---|
| hackernews | THIN-BODY len=9 |
L3-RENDERED len=34,772 |
fixed |
| other 14 | — | identical | no change |
On main, the second site through the pool renders a 9-byte body. Reproduced deterministically — two runs per side, 9, 9 on main and 34772, 34772 on HEAD. hackernews is the first warm reuse in the corpus (wikipedia is site 1), so this is the previous document's state contaminating the next one: exactly the class of bug this PR fixes, showing up on a real site rather than a synthetic one.
That also means the leak wasn't only a memory problem — warm reuse was silently producing wrong output, and the 15 KB canary threshold wouldn't have caught it because canary.yml only exercises the cold path.
Note on the three "both fail" sites
apache (10,485 B), crates_io (4,997 B) and nginx (13,694 B) are L3-RENDERED on both sides — they simply fall under the harness's arbitrary 15 KB "production" threshold because the pages are small. Unchanged between main and HEAD, so not regressions.
Reproduce
cargo build --release -p browser_oxide --example sweep_metrics
BROWSER_OXIDE_SWEEP_POOL=1 \
target/release/examples/sweep_metrics chrome_148_macos corpus.json out.jsonFull per-site table
COLD PATH (Page::navigate) main -> HEAD
site main tag main len HEAD tag HEAD len verdict
apache L3-RENDERED 10485 L3-RENDERED 10485 both under 15K
crates_io L3-RENDERED 4997 L3-RENDERED 4997 both under 15K
docs_rs L3-RENDERED 27974 L3-RENDERED 27974 same
gnu L3-RENDERED 1028663 L3-RENDERED 1028663 same
go_dev L3-RENDERED 47464 L3-RENDERED 47464 same
hackernews L3-RENDERED 34772 L3-RENDERED 34837 same
kernel_docs L3-RENDERED 16721 L3-RENDERED 16721 same
mdn L3-RENDERED 184209 L3-RENDERED 184209 same
nginx L3-RENDERED 13694 L3-RENDERED 13694 both under 15K
python_docs L3-RENDERED 112662 L3-RENDERED 112662 same
python_org L3-RENDERED 62413 L3-RENDERED 62413 same
rustlang L3-RENDERED 18417 L3-RENDERED 18417 same
w3c L3-RENDERED 166131 L3-RENDERED 166131 same
wikipedia L3-RENDERED 259433 L3-RENDERED 259433 same
wikipedia_main L3-RENDERED 259665 L3-RENDERED 259665 same
main pass: 12/15 HEAD pass: 12/15
POOL PATH (PagePool warm reuse) main -> HEAD
hackernews THIN-BODY 9 L3-RENDERED 34837 improved
(all 14 others identical)
main pass: 11/15 HEAD pass: 12/15
TOTAL REGRESSIONS: none
Unit tests
597 passed; 1 failed on Windows — the single failure is perf_ext::tests::distribution_has_distinct_jitter_values, which is pre-existing and environment-dependent, not a regression:
perf_ext.rsis untouched by this branch (git diff main HEADis empty for it).- It fails identically on the pre-dependency commit.
Test (ubuntu-latest, nightly)passes on this PR, so it's Windows-specific.
Mechanically: with a coarse Windows timer, q = floor(raw_us/100)*100 stays constant across the tight 500-iteration loop, so the monotonic clamp candidate.max(last_us) only advances on a new running maximum — roughly 6 distinct values against a >10 assertion. Worth a separate issue to make the test clock-granularity-aware; out of scope here.
|
Superseded by #35. GitHub auto-closed this PR when the branch was renamed |
Closes #33.
Root cause
The reporter's diagnosis was right about the mechanism but incomplete about the sources. The leak isn't one thing: every reaper the engine had was wired only to
Page::drop— which a pool by definition never reaches — and several bootstrap-JS registries are scoped to theJsRuntimerather than to the document. On the cold path that distinction never mattered, because the runtime is the page. On the warm pathreplace_domswaps the document underneath them and they accumulate forever.Everything below was unpruned on reuse:
_objListenersevent_bootstrap.js:298WeakMapkeyed by target object — butwindow-bound listeners are keyed against the one object that is never collected for the life of the isolate. Their closures pin the page's whole object graph._nodeListenersevent_bootstrap.js:297Map, never pruned at all. The dominant source._nodeCache,_scrollStatedom_bootstrap.js:5-6Maps keyed bynodeId. TheWeakRefvalues don't help — an old wrapper stays alive as long as any listener closure references it._moObserversdom_bootstrap.js:1926disconnect(), which pages rarely call._appendedIframes,_frameRegistrydom_bootstrap.js:2034, 2395_customElementsRegistry,_whenDefinedPromiseswindow_bootstrap.js:4579-4580globalThispropertieswindow.__APP_STATE = …, framework singletons. The reporter suspected this; it's real.on*handler valueswindow.onscroll = fn. These already exist as own properties at bootstrap, so a key-set diff cannot see the assignment.Confirming the two things the report ruled out:
__cancelAllTimers()is present on the warm path, anddrain_owned_workerswas already called fromreset_warm_stateonmain(page.rs:1560) — so suggestion #2 was partly landed already.Changes
New JS reset hooks, all non-enumerable so they don't widen
Object.getOwnPropertyNames(window):__cancelAllListeners()—event_bootstrap.js(the ticket's suggestion feat: profile-driven Client Hints + canonical plugins/mimeTypes rewrite #1)__resetDomRegistries()—dom_bootstrap.js__resetCustomElements()—window_bootstrap.js__markGlobalsBaseline()/__resetPageGlobals()—cleanup_bootstrap.jsBundled behind a new public
Page::reset_for_reuse()(the ticket's suggestion #2). Called byPagePool::acquire,Page::navigate_warm, and the CDP protocol server.I did not take suggestion #3 (
create_realm()per reuse) — it would discard most of the pooling benefit, and the numbers below show it isn't needed.The globals sweep is a diff, not a wipe
__resetPageGlobals()deletes only keys added since a baseline the engine marks after installing its post-bootstrap instrumentation and before any page script runs.on*handlers get value-level treatment: values are snapshotted at baseline and restored, which clearswindow.onscroll = fnwhile preserving the engine's ownwindow.onerrorinstrumentation — that one is installed once on the cold build and never re-applied on the warm path, so a blanket wipe would have silently disabled error capture for every pooled page after the first. There's a dedicated test for exactly this.Evidence: A/B on live heap
reload_htmlwithoutreset_for_reuseis precisely what 0.1.0'sPagePool::acquiredid, so the control arm reproduces 0.1.0 behaviour and the other arm is HEAD. Comparing them in one build is more meaningful than comparing against a 0.1.0 checkout, which has no heap-inspection API to measure with in the first place.25 warm reuses of a document retaining ~1 MB behind a
windowlistener closure. Measured after a forced full GC, so these are live/reachable objects — which is the crux: the reporter observed a full GC recovered only 1–2%, identifying the growth as live references rather than deferred garbage.≈2,200× reduction; the control arm recovers essentially the full ~1 MB the document retains, matching the reporter's ~10 MB/page on real product pages scaled to this synthetic doc.
Both arms are committed as
tests/warm_reuse_heap_growth.rs. The control arm asserts it does leak — if it ever stops, the positive test has gone vacuous and should fail loudly rather than silently prove nothing.Correctness bugs fixed by the same change
Two of these are not memory issues and are worth calling out separately:
_nodeListenersand_nodeCacheare keyed bynodeId, and node IDs restart at zero whenreplace_domswaps the document — so the old page's listener for node 42 fired on the new page's node 42, and the new page's node could be handed the old page's wrapper with its expandos.customElements.define()for a name the previous page registered was a silent no-op, so the new page's class never upgraded.__keepLongTimersRefedstayed set after a challenge page, pinning long timers on every later navigation of thatPage.Page.navigateswaps the document viareload_htmlon aPagethe session keeps alive for its whole lifetime.Also added
Page::v8_heap_used_bytes()andPage::collect_garbage()(also onBrowserJsRuntime), so operators can verify pool health directly — sample after each navigation and a healthy pool stays flat. The A/B test is built on them.Removed
Dead
_listenersregistry inevent_bootstrap.js— declared, never read.Testing
11 new tests across two files, all passing locally (release,
--test-threads=1):Covering: window-listener unbinding, node-listener cross-document misfire, page-global sweep,
document.on*handlers, engineon*handlers preserved, custom-element redefinition, timers still functional after reset, idempotency across 10 reuses, a guard that the sweep does not strip engine globals (document/fetch/setTimeout/… still defined and the DOM still usable), plus both A/B arms.cargo clippy --lib -- -D warningsandcargo fmt --all --checkare clean.Bindings
Checked: neither
browser_oxide_pynorbrowser_oxide_mcpexposesPagePoolor warm reuse, so there is no binding surface to propagate this to. The one other in-tree warm-reuse consumer was the CDP server, fixed here.🤖 Generated with Claude Code
https://claude.ai/code/session_01Ptn8MHsL8gwcsszwquKrfu