The keyDown/keyUp paths interpolated `key`/`code` into the generated
KeyboardEvent snippet with single-quote escaping only. Chrome sends
`key: "\"` (U+005C) for the backslash key, which became `key:'\'` — the
backslash escaped the closing quote, ran the string literal on, and made
the whole page.evaluate a syntax error, so the keydown was silently
dropped. Any key/code carrying a backslash broke dispatch.
Escape backslash before single-quote (backslash first, so the quote-escape
backslashes are not themselves doubled), matching the sibling text path in
the same function. Adds a regression test asserting the backslash key
arrives at a keydown listener alongside an ordinary key.
* fix(js): structuredClone cycles through Error.cause and clones own __proto__ (#419, #420)
The structuredClone polyfill had two correctness/parity gaps in _structuredClone:
- The Error branch recursed into `cause` before recording the clone in `seen`,
so a cycle through the error (e.cause === e) recursed until the stack
overflowed. Chrome clones such graphs and preserves identity. Fix: seen.set
the clone before recursing into cause. (#419)
- The generic-object branch built the clone on the source's prototype and
copied properties by assignment, so an own enumerable `__proto__` data
property (exactly what JSON.parse('{"__proto__":…}') produces) went through
the inherited __proto__ setter and reparented the clone instead of copying
the property. Fix: clone plain objects onto Object.prototype (like Chrome)
and define each property instead of assigning it. (#420)
Adds two parity tests to structured_clone_crypto_parity.rs; full obscura-cdp
suite (73) green.
* perf(js): keep structuredClone fast path for all keys but __proto__
The own-__proto__ fix copied every own property with Object.defineProperty
to avoid triggering the inherited prototype setter. That is ~3x slower
than plain assignment per key, so cloning a large object regressed
(~75ms -> ~228ms for a 1000-key object x200).
Only the __proto__ key needs defineProperty; every other key is safe to
assign. Special-case it so the fast path applies to all other keys.
Measured back to parity with main (85ms vs 85ms); the own-__proto__
correctness test still passes.
---------
Co-authored-by: SGavrl <[email protected]>
HTMLFormElement.requestSubmit(submitter) accepted any element and submitted
silently. Per the HTML spec (and Chrome) it must throw a TypeError when the
submitter is not a submit button, and a NotFoundError DOMException when the
submitter is not owned by the form; both checks run before the submit event.
Adds an _isSubmitButton predicate (a <button> whose type is submit — the
default, including a missing/invalid type — or an <input> of type submit/image)
and validates the argument at the top of requestSubmit. submit() is untouched:
it correctly bypasses the event (#395).
Element.click() now gates on the same predicate rather than its own looser
check, so an internal click can never hand requestSubmit a submitter it would
reject (e.g. <div type="submit">, which is not a submit button and no longer
submits — matching Chrome). That also aligns click() with the CDP click path in
input.rs, which already treats <input type=image> as a submit button. The
input.rs call sites are already guarded by equivalent conditions and pass no
submitter on the Enter-key path, so they are unaffected.
Constraint validation is deliberately out of scope: obscura has no validity
engine (checkValidity is a stub returning true), so wiring it here would be a
no-op. Noted in #424 as separate work.
Full obscura-cdp suite (72) green.
_structuredClone dispatches platform-object hooks as hook(value, seen), but the
CryptoKey hook took only `src` and never touched the memo, and hook branches
return before the generic seen.set. So one CryptoKey referenced twice in a graph
cloned into two distinct objects (and re-registered its key material each time),
where structuredClone preserves reference identity within a graph.
Consult and populate `seen` in the hook. Adds a parity test asserting a key
reached twice clones to one shared object that crypto.subtle still accepts.
Requests made by page JS (fetch/XHR/dynamic script and stylesheet loads)
run in the V8 op layer and never produced a NetworkEvent, so CDP emitted
Network.requestWillBeSent / responseReceived only for the static
navigation subresources. Puppeteer/Playwright page.on('request') and
on('response') therefore missed every XHR/JSON response, which breaks
response capture (and is the root cause of the Aviasales half of #394).
op_fetch_url now records a JsNetworkEvent keyed by the same fetch-{N} id
under which it already stores the response body. The Page drains these
into its network_events before the CDP layer emits, so they flow through
emit_navigation_events as requestWillBeSent / responseReceived /
loadingFinished alongside the document subresources, and
Network.getResponseBody resolves for them via the shared id. Applied to
both navigation paths (server process_navigation and in-process
do_navigate). The queue is capped at 4096 events per page.
Closes#406.
adoptNode was only stubbed on the createHTMLDocument shim object, never on
Document.prototype, so document.adoptNode(node) threw. importNode already had a
Document.prototype polyfill returning the node, so adoptNode mirrors it: with no
second document to transfer ownership from the node is already ours, return it.
toggleAttribute was missing entirely. Implement spec semantics: no force arg
toggles, force true adds, force false removes, returns the new presence. Lit,
Stencil, and several ad SDKs call it and die on the missing method today.
Fixes#393.
Runtime.executionContextsCleared on navigation told clients every prior
context was gone, but valid_context_ids was insert-only, so a
Runtime.callFunctionOn targeting a pre-navigation context id still ran
instead of being rejected with Chrome's 'Cannot find context with
specified id'. On a long-lived CDP connection the set also grew by one
id per navigation and was never pruned.
Clear valid_context_ids when executionContextsCleared is emitted, then
re-register the default world (id 2) and the isolated worlds as their
executionContextCreated events are emitted. Closes#407.
A <link rel=stylesheet href> appended via JS (createElement +
appendChild) was never fetched and fired neither load nor error, so any
Promise.all awaiting the link's onload (lazy CSS loaders, antd/bootstrap
fetchers) hung forever and the page stalled at its pre-load stage.
appendChild already drives this for <script src>; mirror it for links:
resolve the href against <base>/document URL, fetch it, and dispatch
load on success or error on failure. CSS is not applied (no layout
engine yet) but the load-event contract matches Chrome.
Read the stylesheet intent from both the rel attribute and the rel
property, since obscura does not yet reflect the rel IDL attribute back
to the content attribute. Closes#409.
* fix(js): real structuredClone so CryptoKey and buffers survive cloning
structuredClone was JSON.parse(JSON.stringify), which silently drops
ArrayBuffer and TypedArray to {}. A cloned CryptoKey also lost its WeakMap
entry, so crypto.subtle.sign/encrypt/deriveBits threw "Argument is not a
valid CryptoKey" on the clone.
Replace the JSON fallback with a recursive cloner covering ArrayBuffer,
TypedArray, DataView, Map, Set, Date, RegExp, Error, and plain objects,
with cycle and own symbol-keyed property handling. CryptoKey opts in via a
clone hook (keyed by Symbol.toStringTag) that rebuilds the key through
makeKey, so the clone re-enters the WeakMap and stays usable.
Fixes#389.
* fix(js): structuredClone handles DataView and rejects functions and symbols
The TypedArray branch called value.slice(), which DataView lacks, so
cloning any DataView threw TypeError, a new crash in the buffers
category this feature targets. Copy the underlying buffer slice over
the view range and wrap a fresh DataView.
The function/symbol rejection lived after the primitive early-return
and was dead code, so functions and symbols were passed through by
reference instead of throwing DataCloneError (HTML structured clone).
Check first and throw.
Register ArrayBuffer, TypedArray, and DataView in the seen map so
repeated references share one clone, and move the cycle check up.
---------
Co-authored-by: SGavrl <[email protected]>
* fix(js): HTMLFormElement.submit() must not fire a cancelable submit event; add requestSubmit()
Per the HTML standard, the submit() method submits without firing a submit
event and cannot be canceled by a page submit listener; only requestSubmit()
and user-initiated submits fire the cancelable event. bootstrap.js dispatched a
cancelable submit event from submit() and aborted navigation when canceled,
which breaks the common pattern where a submit listener preventDefault()s the
native submit and a callback later calls form.submit() to actually send it
(e.g. an invisible-reCAPTCHA data-callback): the listener also canceled the
programmatic submit, so no navigation was ever issued.
- submit(): navigate unconditionally, no submit event
- requestSubmit(): fire the cancelable submit event, navigate only if not canceled
- submit-button clicks route through requestSubmit() (a real click fires the event)
Adds a regression test; full obscura-cdp suite (56 tests) passes.
* fix(cdp): route synthetic submit-button clicks and Enter through requestSubmit
A CDP Input.dispatchMouseEvent on a submit button and an Enter key in a
form field are user-initiated submits, so per the HTML spec they must
fire the cancelable submit event. input.rs called form.submit() (the
method) directly, bypassing any preventDefault() submit listener, which
mirrors the bootstrap.js bug fixed in the parent commit. Use
requestSubmit() with a submit() fallback so the listener can veto.
---------
Co-authored-by: SGavrl <[email protected]>
CDP / navigation:
- Route all Page.navigate through process_with_interception so the
parent dispatcher stays responsive during long navs (fixes
Target.createTarget timeouts after a handful of newPage calls).
- Default waitUntil to domcontentloaded. Puppeteer and Playwright
don't pass it server-side and wait client-side via lifecycleEvent,
so the old Load default blocked nearly every nav on slow sites.
- 30s navigation hard ceiling, OBSCURA_NAV_TIMEOUT_MS to override.
- execute_scripts loop now checks wall-clock deadline per iteration.
- Emit Network.requestWillBeSent before Page.frameNavigated so
Playwright's FrameManager attaches the navigation response (#189).
- Use loaderId as the Document request id so page.goto() resolves
to a Response instead of null (#189).
- Fresh executionContextId per navigation, no more 'Cannot find
context with specified id' after the first nav (#192).
- POST navigation params plumbed through the intercept path.
DOM / Web API surface:
- MutationObserver: subtree filter walks parent chain, fires on
textContent / CharacterData.data / innerHTML, type filter respected,
oldValue passed.
- IntersectionObserver: multi-fire (initial + 120/500/1500ms),
disconnect / unobserve / takeRecords, root / rootMargin / thresholds.
- ResizeObserver: per-target firing from getBoundingClientRect, drop
the no-op override that was clobbering the real class.
- crypto.subtle.digest: real SHA-1/256/384/512 via op_subtle_digest.
- DOMParser / document.implementation.createHTMLDocument /
createDocument: detached doc with body, head, documentElement,
querySelector*, getElementById, getElementsByTagName.
- WebSocket / EventSource / BroadcastChannel: real EventTarget,
open fires on microtask.
- indexedDB: request, object store, transaction, database with
async onsuccess and in-memory Map storage.
- getComputedStyle: width/height/top/left from bbox so React
virtualization libs render items.
Site compatibility went from 3/40 to 175/175 reachable sites in
the sweep. Perf is equal or faster than pristine main on every
site tested (DCL default removes the block on long-tail subresources).
28/28 integration tests pass.
fix(cdp): unblock HTTP control plane during synchronous V8 execution
Fixes#62.
Previously, the TCP accept loop and CDP processor shared a single-threaded
tokio LocalSet. Long synchronous JS evaluations starved the accept loop,
causing incoming connections to hang in the kernel backlog and effectively
killing the HTTP control plane.
This moves the TCP listener and `/json/*` endpoints to a dedicated OS
thread using `std::net::TcpListener`. HTTP requests are now handled
directly via blocking I/O on this thread, completely bypassing V8.
WebSocket connections are handed off to the LocalSet via a bounded mpsc
channel (capacity 128). If V8 stalls and the queue fills, new WS
connections are dropped (ECONNRESET) rather than buffering unboundedly
to prevent OOMs.
Includes a new integration test to verify the control plane stays
responsive during a 5s JS loop, plus inline documentation clarifying
thread lifetime semantics and a minor test TOCTOU race.
* fix(#19): serialize V8 work across pages with process-wide async lock
Concurrent CDP clients (≥5) trip V8's
`heap->isolate() == Isolate::TryGetCurrent()` invariant and abort the
process via V8_Fatal — no Rust panic, just abort(3). Each Page owns its
own JsRuntime/Isolate and they all run on a single OS thread via
LocalSet+spawn_local; when two pages' V8-touching futures interleave
across an .await, V8 trips the check.
This adds a process-wide tokio::sync::Mutex (obscura_js::v8_lock) that
is acquired:
- At the top of obscura_cdp::dispatch::dispatch (covers every CDP
handler that may call into a JsRuntime).
- In server.rs's process_with_interception spawn_local that runs
page.navigate_with_wait + execute_preload_script (this path
bypasses dispatch).
The guard keeps each V8-touching block contiguous on the thread: V8
fully exits one Isolate before another page is allowed in. Trade-off:
non-V8 handlers (Browser.*, Storage.*, Emulation.*) also serialize,
but those are cheap and the safety win dominates.
The properly concurrent fix is to pin each JsRuntime to its own OS
thread and message-pass; tracked as the larger #19 follow-up.
Adds an opt-in smoke test (`--ignored`) that boots the CDP server and
runs 5 parallel clients through createTarget+navigate. Note: the
deterministic abort needs subresource-fetch yields (page.rs:285
join_all) which data: URLs skip — full repro requires JS-heavy real
traffic (issue author used scrapegraphai.com).
* fix(#19): suspend other Isolates and defer foreign CDP during interception nav
Address maintainer feedback on PR #36 (comment 4328238087): the V8 fatal
still reproduces with `Fetch.enable patterns:["*"]` + 5 concurrent
`Page.navigate`s. The `obscura_js::v8_lock` mutex was insufficient
because V8's actual invariant is "only one Isolate may be entered per
OS thread", and the mutex can't enforce that across `.await` points
inside V8 ops.
Two distinct paths violated the invariant:
1. Foreign CDP messages dispatched while a nav task was running.
`process_with_interception`'s `select!` routed every non-Fetch
message through `process_cdp_message → dispatch → page::handle::*`.
`get_session_page_mut` then `suspend_js`'es other pages (drops
their `JsRuntime` → `Isolate::Exit`) while the spawned nav task is
parked at `op_fetch_url`'s `resolve_rx.await` with its Isolate
still entered. Drop trips
`heap->isolate() == Isolate::TryGetCurrent()`.
2. Subsequent navs through `process_with_interception` did not
suspend prior pages. After nav-1 completes (page-1's JsRuntime
stays alive in `ctx.pages`), nav-2's `init_js` constructs
Isolate-2 while Isolate-1 is still current. The next V8 scope
unwind aborts via `Context::Exit`.
Fix (3 changes in `crates/obscura-cdp/src/server.rs`):
- `cdp_processor`: add a `VecDeque<ServerMessage> deferred` drained
before each new `rx.recv()`, so deferred messages are processed
with no nav task in flight.
- `process_with_interception` `select!`: only handle `NewConnection`
and `Fetch.{continue,fulfill,fail}Request` inline (safe — no V8
enter on this side); push everything else into `deferred`.
- `process_with_interception` entry: explicitly `suspend_js()` every
other page in `ctx.pages` before spawning the nav task, mirroring
the invariant the regular dispatch path already enforces via
`get_session_page_mut`.
Verification:
- New test `crates/obscura-cdp/tests/concurrent_navigations_with_fetch.rs`
is the maintainer's exact repro (5 clients, `Fetch.enable patterns:["*"]`,
concurrent `Page.navigate` to https://example.com, auto-reply
`Fetch.continueRequest`). Aborts on PR #36 head, passes after fix
(5/5 stable runs, ~1.2s each).
- Existing data-URL smoke test (`concurrent_navigations`) still passes.
- Full workspace test suite (90 tests) still passes.
Run with:
cargo test -p obscura-cdp --test concurrent_navigations_with_fetch \
-- --nocapture --ignored
Honest caveat. This is still the lock/serialize approach — it converts
the abort into latency. The properly concurrent fix (Option 2 in
issue #19: pin each `JsRuntime` to its own OS thread and message-pass)
remains the right long-term direction.
* fix(cdp): bound deferred message queue to prevent OOM during stalled navigation
PR #36 comment 4341743194: process_with_interception deferred foreign
CDP messages into an unbounded VecDeque while a navigation task was in
flight. A stalled navigation (e.g. waiting for Fetch interception
resolution) could accumulate arbitrary queued messages and OOM the
process.
Add MAX_DEFERRED_MESSAGES = 256. When the cap is reached, return an
explicit CDP error (-32000) to the client instead of silently dropping
or growing without bound.
All 90 workspace tests pass.