feat(cdp): bound every CDP command with a shared V8 watchdog (#250)

A hung page (a runaway Runtime.evaluate, a synchronous DOM op) holds the
process-wide V8 lock and wedges every other session forever. The one-shot CLI has
a process-level hard deadline for this; the long-running server cannot force-exit,
so terminate just the offending isolate instead.

dispatch() arms a watchdog on the session page's isolate after taking the V8 lock
and disarms it after the handler; on overrun it terminates the isolate and clears
the termination flag so the session keeps working. OBSCURA_CDP_COMMAND_TIMEOUT_MS
tunes the bound (default 60s, 0 disables); navigation already self-bounds well
under it.

The watchdog is a single long-lived shared thread (cdp_watchdog), not a thread
per command: the V8 lock serializes commands so at most one is ever armed, and
arm/disarm are a mutex plus notify with no measurable per-command cost. Also
fixes a lost-wakeup in the per-call watchdog (spawn_watchdog) where disarming a
fast call before the watchdog thread started blocked for the full budget.

Verified: a hung Runtime.evaluate is terminated at the budget and the session
recovers; CDP integration 37/37, obstacle course 33/33; per-command latency
unchanged (watchdog on equals off within noise).
This commit is contained in:
SGavrl authored and GitHub committed 2026-06-05 21:28:21 +00:00
1 parent 32ddf93885
commit 1a0644f7bc
5 files changed
+255 -34

No files matched your search

+16
View File
@@ -1131,6 +1131,22 @@ impl Page {
self.dom.as_ref()
}
/// V8 isolate handle for this page's runtime, if it has been initialized.
/// Lets the CDP dispatcher arm a per-command watchdog (which bounds any one
/// command so a hung page cannot hold the process-wide V8 lock forever)
/// without taking `&mut self`.
pub fn isolate_handle(&self) -> Option<obscura_js::runtime::IsolateHandle> {
self.js.as_ref().map(|js| js.isolate_handle())
}
/// Clear a V8 termination left by a per-command watchdog so the next command
/// on this page can run. No-op if the runtime is absent or not terminating.
pub fn cancel_v8_termination(&mut self) {
if let Some(js) = self.js.as_mut() {
js.cancel_termination();
}
}
/// Like [`Self::evaluate`] but bounded by a V8 watchdog so a runaway
/// expression cannot hang the process. A non-zero `timeout` of zero falls
/// back to the unbounded path.