Skip to content

0.1.2: fix warm-reuse leak + wrong output (#33), dependency refresh, security fixes - #35

Merged
yfedoseev merged 20 commits into
mainfrom
release/0.1.2
Jul 27, 2026
Merged

0.1.2: fix warm-reuse leak + wrong output (#33), dependency refresh, security fixes#35
yfedoseev merged 20 commits into
mainfrom
release/0.1.2

Conversation

@yfedoseev

@yfedoseev yfedoseev commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Supersedes #34, which GitHub auto-closed when the branch was renamed
release/0.1.1 -> release/0.1.2 (0.1.1 had already shipped as a bugfix).
Same work, plus the dependency refresh, security fixes and the real-site
regression run.

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 the JsRuntime rather than to the document. On the cold path that distinction never mattered, because the runtime is the page. On the warm path replace_dom swaps the document underneath them and they accumulate forever.

Everything below was unpruned on reuse:

Registry File Why it retains
_objListeners event_bootstrap.js:298 WeakMap keyed by target object — but window-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.
_nodeListeners event_bootstrap.js:297 Strong Map, never pruned at all. The dominant source.
_nodeCache, _scrollState dom_bootstrap.js:5-6 Strong Maps keyed by nodeId. The WeakRef values don't help — an old wrapper stays alive as long as any listener closure references it.
_moObservers dom_bootstrap.js:1926 Only shrinks on disconnect(), which pages rarely call.
_appendedIframes, _frameRegistry dom_bootstrap.js:2034, 2395 Iframe element wrappers and child realm windows.
_customElementsRegistry, _whenDefinedPromises window_bootstrap.js:4579-4580 Page-supplied constructors, forever.
globalThis properties window.__APP_STATE = …, framework singletons. The reporter suspected this; it's real.
on* handler values window.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, and drain_owned_workers was already called from reset_warm_state on main (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):

Bundled behind a new public Page::reset_for_reuse() (the ticket's suggestion #2). Called by PagePool::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 clears window.onscroll = fn while preserving the engine's own window.onerror instrumentation — 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_html without reset_for_reuse is precisely what 0.1.0's PagePool::acquire did, 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 window listener 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.

Arm Live heap growth per reuse
Without reset (= 0.1.0) 1,040,662 B (~1.04 MB)
With reset (this PR) 468 B

≈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:

  1. The previous page's handlers fired on the new document. _nodeListeners and _nodeCache are keyed by nodeId, and node IDs restart at zero when replace_dom swaps 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.
  2. Custom elements could not be re-defined across a warm navigation. customElements.define() for a name the previous page registered was a silent no-op, so the new page's class never upgraded.
  3. __keepLongTimersRefed stayed set after a challenge page, pinning long timers on every later navigation of that Page.
  4. The CDP protocol server had the identical leak. Page.navigate swaps the document via reload_html on a Page the session keeps alive for its whole lifetime.

Also added

Page::v8_heap_used_bytes() and Page::collect_garbage() (also on BrowserJsRuntime), 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 _listeners registry in event_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

tests/warm_reuse_reset.rs ......... 9 passed
tests/warm_reuse_heap_growth.rs ... 2 passed

Covering: window-listener unbinding, node-listener cross-document misfire, page-global sweep, document.on* handlers, engine on* 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_globals failed on first run and exposed a real gap — window.onscroll = fn survived, because on* 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 installs window.onerror once on the cold build and never re-applies it on the warm path, so a blanket on* 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_html without reset_for_reuse is exactly what 0.1.0's PagePool::acquire did, 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%.

Arm Live heap growth per reuse
Without reset (= 0.1.0) 1,040,662 B
With reset (this PR) 468 B

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 → HEAD

15 open sites, both engine paths, run from a main worktree and this branch on the same machine and network, via sweep_metrics (the harness canary.yml uses). Run four times — after the leak fix, after the security updates, after the dependency majors, and again on the final commit.

Path main HEAD
Cold 12/15 12/15 byte-identical except dynamic front pages
Pool 11/15 12/15 hackernews THIN-BODY 9 B → L3-RENDERED 34,772 B

On main, the second site through the pool renders a 9-byte body — reproduced deterministically, 9, 9 vs 34772, 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, and canary.yml could not catch it because it only exercises the cold path.

4. Protected / anti-bot regression, main → HEAD

The 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.

Path main HEAD detail
Cold 9/15 9/15 identical tag on all 15; 11 byte-for-byte identical, rest 1–410 B of dynamic content
Pool 8/15 8/15 identical tag on all 15; 11 byte-for-byte identical

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-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.

The pool run also turned up a second, independent instance of the corruption described above — on a detection site this time:

site cold path (both sides) pool on main pool on HEAD
areyouheadless 164 B 9 B 164 B

Same 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.rs reports len=17502 fnv1a=5b1d42ee9bdc9713identical on main and HEAD.

This is the check that caught the one upgrade deliberately not shipped. png 0.18 looked safe by inspection (Compression::Balanced maps to the same flate2 level, Filter::Adaptive appears to replace the old Paeth+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 warnings clean · cargo fmt --all --check clean · cargo audit 0 vulnerabilities · cargo deny check advisories ok, bans ok, licenses ok, sources ok · 597 lib + 433 chrome_compat tests pass.

Known-not-covered, stated plainly

  • Absolute pass rates here are regression measurements, not capability benchmarks. This repo ships no vendor solvers (SCOPE.md), so the three *-CHL results are expected on both sides. 9/15 is not comparable to BENCHMARK.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.yml. Both sides ran back-to-back from one IP; nothing flipped, but a single-site flip would not have been conclusive alone.
  • Debug builds were never exercised locally — this dev machine cannot link them at all (skia ships a release-built lib against a release CRT, so debug hits _ITERATOR_DEBUG_LEVEL mismatches). 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.
  • Local runs are Windows-only; Linux and macOS are covered by CI. 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.

Bindings

Checked: neither browser_oxide_py nor browser_oxide_mcp exposes PagePool or 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_core 0.403 -> 0.408 (V8 149.2 -> 149.4)
  • taffy 0.8 -> 0.11, sha1+sha2 0.10 -> 0.11, adblock 0.12 -> 0.13
  • chrono, http2, five SHA-pinned CI actions, plus cargo update across the tree

#32 resolves itself at 0.408. That report showed deno_core 0.405 demanding deno_error =0.7.1 against a ^0.7.3 requirement -- but the workspace declares ^0.7, which resolves to 0.7.1 cleanly alongside 0.408.

sha1 and sha2 must move together: sha2 0.11 pulls digest 0.11, which makes the in-scope Digest trait incompatible with a sha1 still on digest 0.10. That bump also changed finalize() from GenericArray to Array<u8, N>, which does not implement LowerHex -- so the canvas-fingerprint helper's format!("{:x}", ...) had to be hex-encoded by hand.

adblock 0.13 needed an API port (Engine::from_filter_set -> new_with_filter_set, Request::new gained 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 audit went from 2 vulnerabilities -> 0:

crate advisory
quinn-proto 0.11.14 -> 0.11.16 RUSTSEC-2026-0185 -- remote memory exhaustion via unbounded out-of-order stream reassembly. In the HTTP/3 path, so reachable from a hostile server on an h3 connection.
crossbeam-epoch 0.9.18 -> 0.9.20 RUSTSEC-2026-0204 -- invalid pointer dereference in fmt::Pointer for Atomic/Shared.
anyhow 1.0.102 -> 1.0.104 RUSTSEC-2026-0190 -- unsoundness in Error::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 stale adler ignore, which the deno_core bump resolved.

cargo deny check: advisories ok, bans ok, licenses ok, sources ok.


Real-site regression: main → HEAD

Ran the sweep_metrics harness (the same one canary.yml uses) against 15 open, unprotected sites, on both engine paths, from a main worktree 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_core 0.403→0.408, taffy 0.8→0.11 and sha1/sha2 0.10→0.11 are behaviour-neutral on real content.

Pool path (PagePool, warm reuse) — 11/15 → 12/15

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.json
Full 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.rs is untouched by this branch (git diff main HEAD is 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.

Reported by @DemonMartin (#33). Fork pointer and adblock port from @Ran-Mewo.

yfedoseev and others added 20 commits July 27, 2026 14:52
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>
@yfedoseev

Copy link
Copy Markdown
Owner Author

Protected / anti-bot corpus regression: main → HEAD

The earlier sweep used 15 open sites, which is a weak stealth signal. This run uses the 15 protected + detection targets from tests/chl_sites.rs, cold path, both sides back-to-back from the same machine and IP.

Result: 9/15 both sides, identical classifier tag on every single site. Zero regressions.

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), and Page::navigate registers an empty solver set, so the three *-CHL results are expected on both sides. The 9/15 here is not comparable to BENCHMARK.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.yml for 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.json

Corpus = the 15 URLs in crates/browser_oxide/tests/chl_sites.rs.

@yfedoseev

Copy link
Copy Markdown
Owner Author

Protected corpus, pool path: main → HEAD

Completes the previous comment, which covered cold only. Same 15 targets from tests/chl_sites.rs, BROWSER_OXIDE_SWEEP_POOL=1, both sides back-to-back from one IP.

8/15 both sides. Identical classifier tag on all 15. Zero regressions.

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.

@yfedoseev
yfedoseev merged commit 218f3c2 into main Jul 27, 2026
28 checks passed
yfedoseev added a commit that referenced this pull request Jul 27, 2026
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>
@yfedoseev
yfedoseev deleted the release/0.1.2 branch July 28, 2026 03:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug]: PagePool leaks V8 heap memory chore: outdated dependencies (2026-07)

1 participant