0.1.2: fix warm-reuse leak + wrong output (#33), dependency refresh, security fixes - #35
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
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Commits](actions/checkout@v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](actions/upload-artifact@v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 7. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](codecov/codecov-action@v4...v7) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.49.40 to 2.81.11. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Commits](taiki-e/install-action@v2.49.40...v2.81.11) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.81.11 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.36.0 to 4.36.2. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](github/codeql-action@7211b7c...8aad20d) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.36.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Bumps [chrono](https://github.com/chronotope/chrono) from 0.4.44 to 0.4.45. - [Release notes](https://github.com/chronotope/chrono/releases) - [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md) - [Commits](chronotope/chrono@v0.4.44...v0.4.45) --- updated-dependencies: - dependency-name: chrono dependency-version: 0.4.45 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Bumps [http2](https://github.com/0x676e67/http2) from 0.5.17 to 0.5.19. - [Release notes](https://github.com/0x676e67/http2/releases) - [Changelog](https://github.com/0x676e67/http2/blob/master/CHANGELOG.md) - [Commits](0x676e67/http2@v0.5.17...v0.5.19) --- updated-dependencies: - dependency-name: http2 dependency-version: 0.5.19 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
deno_core 0.403 -> 0.408 (V8 149.2 -> 149.4). Also resolves the deno_error resolution failure in #32: that conflict came from a ^0.7.3 requirement, while the workspace uses ^0.7, which resolves to 0.7.1 alongside deno_core 0.408. adblock 0.12 -> 0.13 needed an API port (Engine::from_filter_set -> new_with_filter_set, Request::new gained a fourth argument, BlockerResult .matched -> should_block()). Ported by @Ran-Mewo in the SilvR-AI fork of this repo; adopted here with thanks. Closes #32 Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
The verify job checks pyproject.toml, crates/browser_oxide_py/Cargo.toml and the workspace version agree. pyproject.toml was missed in the version bump. Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
The earlier `cargo update` commit did not actually land in Cargo.lock -- it
captured only the workspace version line, so the tree was still on the old
versions and both CI security jobs stayed red. Re-applied and verified against
the lockfile this time.
Resolves two advisories:
quinn-proto 0.11.14 -> 0.11.16 RUSTSEC-2026-0185 (remote memory
exhaustion, unbounded out-of-order stream
reassembly -- in the HTTP/3 path)
crossbeam-epoch 0.9.18 -> 0.9.20 RUSTSEC-2026-0204 (invalid pointer deref)
anyhow 1.0.102 -> 1.0.104 RUSTSEC-2026-0190 (unsound downcast_mut)
deny.toml: add documented ignores for RUSTSEC-2026-0206 (rustybuzz) and
RUSTSEC-2026-0192 (ttf-parser). Both are unmaintained notices rather than
vulnerabilities, on the direct text-shaping stack, with no maintained pure-Rust
replacement -- the alternative is binding native HarfBuzz. Drop the stale adler
ignore, which the deno_core bump resolved.
cargo audit: 0 vulnerabilities. cargo deny: advisories ok, bans ok, licenses
ok, sources ok.
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
CI runs clippy with --all-targets, which reaches test targets the earlier
--lib check did not.
chrome_compat: sha2 0.11 (digest 0.11) returns `Array<u8, N>` instead of
`GenericArray`, and `Array` does not implement `LowerHex` -- so the
`format!({:x}, h.finalize())` canvas-fingerprint helper stopped compiling.
Hex-encode the bytes directly. Both canvas determinism tests pass.
debug_blocked: drop a redundant reference in a println! argument, newly flagged
by current stable clippy (the workflow floats via rust-toolchain@stable, so
this surfaced without any code change).
cargo clippy --all-targets --workspace -- -D warnings: clean.
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Two CI failures, both mine.
1. Wheel builds failed on every platform in ~10s:
Unknown character "65279" at row 1, col 2, pos 1
A UTF-8 BOM. I edited pyproject.toml and crates/browser_oxide/Cargo.toml
with PowerShell `Set-Content -Encoding utf8`, and Windows PowerShell 5.1
writes a BOM for that encoding. Cargo tolerates it; maturin's TOML parser
does not. Both files stripped, and every file changed on this branch was
scanned for the same corruption.
2. `verify (test + package)` aborted on Linux in DEBUG:
signal: 6, SIGABRT
in `--lib` (workers::tests::create_worker) and `--test basic`
(basic_js_execution, which only builds a runtime and evaluates 1 + 2).
Introduced by deno_core 0.408; 0.403 passes Linux debug CI. Release builds
are unaffected, which is why local verification missed it: the Windows
toolchain here cannot link debug builds at all (skia ships a release-built
lib against a release CRT), so debug is only reachable in CI.
Stepping back to 0.404, one minor from known-good, rather than fixing
blind. SIGABRT is abort(), not the SIGSEGV a stack overflow would produce,
so the obvious "V8 needs a bigger stack" theory does not fit the evidence
and would have been a guess. The 0.408 bump needs its own change with a
real debug repro behind it.
All other dependency updates are unaffected: taffy 0.11, sha1/sha2 0.11,
adblock 0.13, and the security fixes (quinn-proto, crossbeam-epoch, anyhow)
all stay. cargo audit remains at 0 vulnerabilities.
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Everything still behind a major, except the two called out below:
skia-safe 0.97 -> 0.99
tokio-tungstenite 0.27 -> 0.30
taffy 0.11 -> 0.12
webpki-root-certs 0.26 -> 1.0
brotli 7 -> 8
base64 0.22 -> 0.23
glow 0.17 -> 0.18 (webgl-render feature)
png HELD at 0.17 -- deliberately. 0.18 merges FilterType + AdaptiveFilterType
into a single Filter enum. Compression::Balanced does map back to the same
flate2 level, but Filter::Adaptive is NOT equivalent to the Paeth + adaptive
pair the canvas encoder uses: on the standard FingerprintJS canvas sequence
0.18 emits a 9,646-byte data URL where 0.17 emits 17,502. That is a different
canvas fingerprint on every page the engine renders -- a silent stealth
regression, not a cosmetic one. Adds examples/canvas_fp_probe.rs so this is
one command to check before any future png bump.
deno_core stays at 0.404 (0.408 aborts in Linux debug builds; see previous
commit).
Verified:
canvas fingerprint byte-identical to main (len=17502 fnv1a=5b1d42ee9bdc9713)
-- confirmed unchanged across the skia 0.97->0.99 bump,
which was the other candidate to perturb rasterization
real-site sweep 15 sites, cold + pool, vs main: zero regressions
(pool still 11/15 -> 12/15 on the #33 fix)
cargo audit 0 vulnerabilities
cargo deny advisories ok, bans ok, licenses ok, sources ok
clippy --all-targets --workspace -D warnings clean
tests 597 lib pass; 433 chrome_compat pass
Two Windows-only test failures are pre-existing clock artifacts, not
regressions: perf_ext::distribution_has_distinct_jitter_values and
chrome_compat::perf_origin_now_consistency. The latter is measurably flaky --
four consecutive runs gave drift 1.09 / 13.31 / 14.89 / 2.10 ms against a
10 ms threshold, because Windows Date.now() has ~15.6 ms granularity. Both
pass on Linux CI.
On #32: the reported deno_error conflict is a cargo-outdated artifact. The
tool synthesizes a manifest demanding the latest of everything at once, pairing
deno_core 0.409 (which pins deno_error =0.7.1) against deno_error 0.7.3 -- a
combination that cannot resolve upstream and does not exist in this workspace.
Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
`git log --pretty=full` indents the commit body by four spaces, so the `^Signed-off-by:` anchor never matches and the job fails even on correctly signed-off commits. Verified locally: with valid trailers on all 19 commits of this branch, the original expression still reported 18 as missing. Use `--format=%B`, which emits the raw body with no indentation. Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Protected / anti-bot corpus regression:
|
| site | main | HEAD | |
|---|---|---|---|
| browserleaks/canvas | L3-RENDERED 36,977 |
L3-RENDERED 36,977 |
byte-identical |
| creepjs | L3-RENDERED 57,416 |
L3-RENDERED 57,416 |
byte-identical |
| pixelscan | L3-RENDERED 103,510 |
L3-RENDERED 103,510 |
byte-identical |
| nowsecure.nl | L3-RENDERED 191,143 |
L3-RENDERED 191,143 |
byte-identical |
| fingerprint.com | L3-RENDERED 136,584 |
L3-RENDERED 136,584 |
byte-identical |
| douyin | L3-RENDERED 74,109 |
L3-RENDERED 74,109 |
byte-identical |
| adidas | L3-RENDERED 2,498 |
L3-RENDERED 2,498 |
byte-identical |
| canadagoose | ScriptChallenge-CHL 732 |
ScriptChallenge-CHL 732 |
identical |
| hyatt | ScriptChallenge-CHL 737 |
ScriptChallenge-CHL 737 |
identical |
| wildberries | ManagedChallenge-CHL 1,867 |
ManagedChallenge-CHL 1,867 |
identical |
| areyouheadless | THIN-BODY 164 |
THIN-BODY 164 |
identical |
| sannysoft | L3-RENDERED 25,883 |
L3-RENDERED 25,882 |
−1 B |
| fingerprintscan | L3-RENDERED 217,532 |
L3-RENDERED 217,534 |
+2 B |
| ozon | L3-RENDERED 10,600 |
L3-RENDERED 10,604 |
+4 B |
| zillow | L3-RENDERED 429,917 |
L3-RENDERED 429,507 |
−410 B |
10 of 15 are byte-for-byte identical; the rest differ by 1–410 bytes of dynamic content (timestamps, nonces, rotating front-page copy).
Why the scanner results are the important line
browserleaks/canvas, creepjs and pixelscan come back byte-identical. Those three read the canvas fingerprint, WebGL surface, and the full JS property namespace. Identical response bodies mean the engine presented an identical fingerprint across skia-safe 0.97 → 0.99, taffy 0.8 → 0.12 (layout geometry, which scanners read via getBoundingClientRect), deno_core 0.403 → 0.404, and sha1/sha2 0.10 → 0.11.
That independently corroborates examples/canvas_fp_probe.rs, which reports len=17502 fnv1a=5b1d42ee9bdc9713 on both main and HEAD.
It is also the check that caught the one change we did not ship: png 0.18 moved the canvas data URL from 17,502 to 9,646 bytes, so it is held at 0.17. See the CHANGELOG entry.
Caveats, stated plainly
- This is a regression measurement, not a capability benchmark. This repo ships no vendor solvers (
SCOPE.md), andPage::navigateregisters an empty solver set, so the three*-CHLresults are expected on both sides. The 9/15 here is not comparable toBENCHMARK.md's 118/126, which came from a cleanroom run on different infrastructure. - WAF lottery. These targets are deliberately excluded from per-PR CI in
canary.ymlfor exactly this reason. Both sides ran back-to-back from one IP to keep it as fair as possible, but a single-site flip here would not be conclusive on its own. Nothing flipped. - Run on Windows, cold path only. The pool path is covered by the open-site sweep (11/15 → 12/15) and the heap A/B.
Reproduce
cargo build --release -p browser_oxide --example sweep_metrics
target/release/examples/sweep_metrics chrome_148_macos protected_corpus.json out.jsonCorpus = the 15 URLs in crates/browser_oxide/tests/chl_sites.rs.
Protected corpus, pool path:
|
| site | main | HEAD | |
|---|---|---|---|
| browserleaks/canvas | L3-RENDERED 36,977 |
L3-RENDERED 36,977 |
byte-identical |
| creepjs | L3-RENDERED 57,416 |
L3-RENDERED 57,416 |
byte-identical |
| pixelscan | L3-RENDERED 103,510 |
L3-RENDERED 103,510 |
byte-identical |
| nowsecure.nl | L3-RENDERED 191,333 |
L3-RENDERED 191,333 |
byte-identical |
| fingerprint.com | L3-RENDERED 136,584 |
L3-RENDERED 136,584 |
byte-identical |
| sannysoft | L3-RENDERED 26,642 |
L3-RENDERED 26,642 |
byte-identical |
| douyin | L3-RENDERED 74,109 |
L3-RENDERED 74,109 |
byte-identical |
| ozon | L3-RENDERED 10,529 |
L3-RENDERED 10,529 |
byte-identical |
| adidas | L3-RENDERED 2,411 |
L3-RENDERED 2,411 |
byte-identical |
| wildberries | L3-RENDERED 1,247 |
L3-RENDERED 1,247 |
byte-identical |
| fingerprintscan | THIN-BODY 112 |
THIN-BODY 112 |
byte-identical |
| zillow | L3-RENDERED 423,888 |
L3-RENDERED 423,734 |
−154 B |
| canadagoose | ScriptChallenge-CHL 732 |
ScriptChallenge-CHL 790 |
+58 B |
| hyatt | ScriptChallenge-CHL 737 |
ScriptChallenge-CHL 795 |
+58 B |
| areyouheadless | THIN-BODY 9 |
THIN-BODY 164 |
+155 B — see below |
11 of 15 byte-for-byte identical. The canadagoose/hyatt +58 B are challenge-token payloads, which differ per issue by construction.
A second, independent instance of the corruption
areyouheadless is the interesting row:
| cold path (both sides) | pool on main |
pool on HEAD | |
|---|---|---|---|
areyouheadless |
164 B | 9 B | 164 B |
On main, warm reuse emits a 9-byte body where the cold path emits 164. On HEAD the pool output matches cold exactly.
That is the same 9-byte signature as hackernews in the open-site sweep — two unrelated sites, one a retail front-end and one a headless-detection probe, reproducing an identical failure mode, and both fixed by reset_for_reuse. It does not move the pass count (164 B is still under the 15 KB threshold, so THIN-BODY either way), which is precisely why a pass-rate-only view would have missed it and a per-site byte comparison does not.
This is the strongest evidence in the PR that #33 was corrupting output, not merely consuming memory.
Note on pool vs cold pass rate
Pool is 8/15 against cold's 9/15 on both sides — expected, not a regression. PagePool::navigate deliberately skips the cookie-diff / pending-nav iteration loop that the cold path runs for challenge documents, which the code documents and which is why challenge-protected origins should use Page::navigate. The relevant comparison is main-vs-HEAD within each path, and both are flat.
Re-applies the bump that was reverted from 0.1.2, plus the CI change needed to actually diagnose it. The blocker in #35 was not the abort itself but that its reason was invisible. libtest captures each test's stdout/stderr and replays it only on failure, so when a test *aborts* the process the captured output dies with it -- the log showed a bare `signal: 6, SIGABRT` against `workers::tests::create_worker` and `basic_js_execution`, with no message. Adding `--nocapture` streams output unbuffered, so the last thing printed before an abort is the abort's own diagnostic (a V8 FATAL line, or a panic that could not unwind out of a V8 callback). Ruled out so far: * Not a stack overflow. Rust detects those via its SIGSEGV handler and then calls abort(), so SIGABRT does not exclude it -- but the CI log contains no "has overflowed its stack", and `v8_recursion::test_thread_stack_is_at_ least_16mb` passes in the same debug run. `.cargo/config.toml` already sets RUST_MIN_STACK=67108864. * Not a new deno_core assertion: debug_assert counts in runtime/jsruntime.rs, runtime/mod.rs, runtime/bindings.rs and runtime/jsrealm.rs are unchanged between 0.404 and 0.408. * Not release-mode-reachable: on 0.408 in release the full suite passes, all 11 warm-reuse tests pass, the leak A/B is unchanged (1,040,662 -> 468 B per reuse) and examples/canvas_fp_probe.rs still reports len=17502 fnv1a=5b1d42ee9bdc9713. Transitive V8 moves 149.2.0 -> 149.4.0 with this bump, which is the other candidate and is not something deno_core's own source diff would show. The `--nocapture` change is worth keeping regardless of #37: any future abort-on-construction is otherwise undiagnosable from CI logs alone. Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
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
Five independent layers. Every risky change in this PR was re-verified after the fact rather than once at the start — the dependency batch alone triggered three full regression passes.
1. New unit tests — 11, all passing
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 consecutive 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.Writing these paid for itself immediately:
reset_clears_page_authored_globalsfailed on first run and exposed a real gap —window.onscroll = fnsurvived, becauseon*handlers already exist as own properties at bootstrap so a key-set diff cannot see the assignment. Fixing that surfaced a second trap: the engine installswindow.onerroronce on the cold build and never re-applies it on the warm path, so a blanketon*wipe would have silently disabled error capture for every pooled page after the first. Hence snapshot-and-restore, with a dedicated test.2. Live-heap A/B — the quantitative claim
reload_htmlwithoutreset_for_reuseis exactly what 0.1.0'sPagePool::acquiredid, so the control arm reproduces 0.1.0 and the other is HEAD. Measured after a forced full GC, so these are live/reachable objects — the crux, since the reporter observed a full GC reclaiming only 1–2%.Both arms are committed. The control arm asserts it does leak, so if it ever stops the positive test fails loudly instead of silently proving nothing.
3. Open-site regression,
main→ HEAD15 open sites, both engine paths, run from a
mainworktree and this branch on the same machine and network, viasweep_metrics(the harnesscanary.ymluses). Run four times — after the leak fix, after the security updates, after the dependency majors, and again on the final commit.THIN-BODY9 B →L3-RENDERED34,772 BOn
main, the second site through the pool renders a 9-byte body — reproduced deterministically,9, 9vs34772, 34772. hackernews is the first warm reuse in the corpus, so the previous document was contaminating the next. That reframes #33: it was not only a leak, warm reuse was silently producing wrong output, andcanary.ymlcould not catch it because it only exercises the cold path.4. Protected / anti-bot regression,
main→ HEADThe 15 protected + detection targets from
tests/chl_sites.rs, both paths, each side back-to-back from one IP. Full tables in the PR comments.The important line: browserleaks/canvas (36,977), creepjs (57,416) and pixelscan (103,510) come back byte-identical on both paths. Those read the canvas fingerprint, WebGL surface, and full JS property namespace — identical bodies mean an identical presented fingerprint across
skia-safe0.97→0.99,taffy0.8→0.12 (layout geometry, which scanners read viagetBoundingClientRect),deno_core0.403→0.404 andsha1/sha20.10→0.11.The pool run also turned up a second, independent instance of the corruption described above — on a detection site this time:
mainareyouheadlessSame 9-byte signature as hackernews. On
main, warm reuse emitted 9 bytes where the cold path emits 164; on HEAD the pool output matches cold exactly. Two unrelated sites reproducing the identical failure mode, and both fixed, is the strongest evidence in this PR that the bug was corrupting output rather than only consuming memory.5. Canvas fingerprint stability
New
examples/canvas_fp_probe.rsreportslen=17502 fnv1a=5b1d42ee9bdc9713— identical on main and HEAD.This is the check that caught the one upgrade deliberately not shipped.
png0.18 looked safe by inspection (Compression::Balancedmaps to the same flate2 level,Filter::Adaptiveappears to replace the oldPaeth+adaptive pair) but measurably is not: it emits a 9,646-byte canvas data URL where 0.17 emits 17,502. That is a different fingerprint on every page the engine renders, and nothing in the pre-existing suite would have caught it — the canvas tests check determinism within a run, not stability across versions. Held at 0.17, with the measurement recorded at the call site.Tooling
cargo clippy --all-targets --workspace -- -D warningsclean ·cargo fmt --all --checkclean ·cargo audit0 vulnerabilities ·cargo deny checkadvisories ok, bans ok, licenses ok, sources ok· 597 lib + 433chrome_compattests pass.Known-not-covered, stated plainly
SCOPE.md), so the three*-CHLresults are expected on both sides. 9/15 is not comparable toBENCHMARK.md's 118/126, which came from a cleanroom run on different infrastructure.canary.yml. Both sides ran back-to-back from one IP; nothing flipped, but a single-site flip would not have been conclusive alone.skiaships a release-built lib against a release CRT, so debug hits_ITERATOR_DEBUG_LEVELmismatches). That gap is why the deno_core 0.408 SIGABRT reached CI before being caught, and why 0.408 is deferred rather than patched blind.perf_ext::distribution_has_distinct_jitter_valuesandchrome_compat::perf_origin_now_consistency. The latter is measurably flaky: four consecutive runs gave drift 1.09 / 13.31 / 14.89 / 2.10 ms against a 10 ms threshold, because WindowsDate.now()has ~15.6 ms granularity. Both pass on Linux CI.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 protocol server, fixed here.Dependencies
Closes #32 and supersedes the open Dependabot PRs (#22-#31), whose commits are cherry-picked here with authorship preserved.
deno_core0.403 -> 0.408 (V8 149.2 -> 149.4)taffy0.8 -> 0.11,sha1+sha20.10 -> 0.11,adblock0.12 -> 0.13chrono,http2, five SHA-pinned CI actions, pluscargo updateacross the tree#32 resolves itself at 0.408. That report showed
deno_core0.405 demandingdeno_error=0.7.1against a^0.7.3requirement -- but the workspace declares^0.7, which resolves to 0.7.1 cleanly alongside 0.408.sha1andsha2must move together:sha20.11 pullsdigest0.11, which makes the in-scopeDigesttrait incompatible with asha1still ondigest0.10. That bump also changedfinalize()fromGenericArraytoArray<u8, N>, which does not implementLowerHex-- so the canvas-fingerprint helper'sformat!("{:x}", ...)had to be hex-encoded by hand.adblock0.13 needed an API port (Engine::from_filter_set->new_with_filter_set,Request::newgained a fourth argument,BlockerResult.matched->should_block()). That port was already written by @Ran-Mewo in the SilvR-AI fork -- adopted here with thanks and attribution.Security
cargo auditwent from 2 vulnerabilities -> 0:quinn-protocrossbeam-epochfmt::PointerforAtomic/Shared.anyhowError::downcast_mut().deny.toml: added documented ignores for RUSTSEC-2026-0206 (rustybuzz) and RUSTSEC-2026-0192 (ttf-parser) -- unmaintained notices, not vulnerabilities, on the direct text-shaping stack with no maintained pure-Rust replacement (the alternative is binding native HarfBuzz). Dropped the staleadlerignore, which thedeno_corebump resolved.cargo deny check:advisories ok, bans ok, licenses ok, sources ok.Real-site regression:
main→ HEADRan the
sweep_metricsharness (the same onecanary.ymluses) against 15 open, unprotected sites, on both engine paths, from amainworktree and from this branch on the same machine and network. This covers the dependency bumps as well as the #33 fix.Cold path (
Page::navigate)12/15 both sides. Byte-identical output on every site except hackernews (34,772 → 34,837, dynamic front page). No regressions — meaning
deno_core0.403→0.408,taffy0.8→0.11 andsha1/sha20.10→0.11 are behaviour-neutral on real content.Pool path (
PagePool, warm reuse) — 11/15 → 12/15THIN-BODYlen=9L3-RENDEREDlen=34,772On
main, the second site through the pool renders a 9-byte body. Reproduced deterministically — two runs per side,9, 9on main and34772, 34772on 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.ymlonly 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-RENDEREDon 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
Full per-site table
Unit tests
597 passed; 1 failedon Windows — the single failure isperf_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).Test (ubuntu-latest, nightly)passes on this PR, so it's Windows-specific.Mechanically: with a coarse Windows timer,
q = floor(raw_us/100)*100stays constant across the tight 500-iteration loop, so the monotonic clampcandidate.max(last_us)only advances on a new running maximum — roughly 6 distinct values against a>10assertion. Worth a separate issue to make the test clock-granularity-aware; out of scope here.Reported by @DemonMartin (#33). Fork pointer and
adblockport from @Ran-Mewo.