refactor: drop dead code and refresh comments after the per-connection move

- Delete the now-unused CdpContext::new_full (its only caller switched to
  new_with_shared_context); have _new_inner build the shared BrowserContext and
  delegate, so the struct's field list lives in one place.
- Extract drive_module_eval, shared verbatim by load_module and
  load_inline_module.
- Update comments that still described the deleted process-wide V8 lock and the
  single-LocalSet model to the per-connection reality, drop the dangling
  reference to the removed obscura-js/src/v8_lock.rs, and soften the
  ISOLATE_CREATE_LOCK note now that the main-thread warmup is the real guard
  against the InitializeBuiltinJSDispatchTable segfault.

Refs #430.
This commit is contained in:
sycatle committed 2026-07-20 16:37:51 +02:00
1 parent 9d2f9f23ee
commit 1f5bffc4de
4 files changed
+69 -140

No files matched your search

+36 -62
View File
@@ -15,12 +15,15 @@ use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
/// Serializes V8 isolate construction across OS threads. The thread-per-
/// connection server (issue #430 "Option 2") builds isolates on many threads;
/// the V8 platform is process-global and concurrent first-time isolate setup is
/// not safe to run from several threads at once (it segfaults). Construction is
/// fast and rare (once per page), so serializing it costs nothing measurable.
/// Isolate *execution* stays fully parallel: once built, each isolate runs on
/// its own thread with no shared lock.
/// connection server (issue #430) builds isolates on many threads. The main
/// thread already warms up V8 once before any connection thread starts (see the
/// `ObscuraJsRuntime::new` warmup in `obscura-cdp` server startup), which is
/// what actually prevents the `InitializeBuiltinJSDispatchTable` segfault of a
/// first isolate built off the main thread. This lock is defense-in-depth: it
/// keeps two connections from running V8's isolate setup concurrently in case
/// any residual first-time process init races. Construction is rare and fast, so
/// serializing it costs nothing measurable; isolate *execution* stays fully
/// parallel, each isolate on its own thread with no shared lock.
static ISOLATE_CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Debug, Clone)]
@@ -55,7 +58,7 @@ pub struct WatchdogToken {
/// Arm a V8 termination watchdog directly from an isolate handle, with no
/// runtime borrow. The CDP dispatcher uses this to bound every command so a
/// hung page cannot hold the process-wide V8 lock forever. Pair with
/// hung page cannot hold this connection's V8 lock forever. Pair with
/// [`WatchdogToken::stop`]; if `stop` returns true, clear the termination flag
/// via [`ObscuraJsRuntime::cancel_termination`] before reusing the isolate.
pub fn spawn_watchdog(handle: IsolateHandle, budget: std::time::Duration) -> WatchdogToken {
@@ -690,20 +693,25 @@ impl ObscuraJsRuntime {
}
};
let mut result = Box::pin(self.runtime.mod_evaluate(module_id));
let deadline = tokio::time::Instant::now() + budget;
// Return as soon as the module finishes evaluating rather than waiting
// for the loop to go fully idle: a page timer (setInterval) keeps the
// loop busy forever and would otherwise burn the whole budget (#374).
self.drive_module_eval(module_id, budget_ms, &format!("Module {}", url))
.await;
Ok(())
}
// Return as soon as the module finishes evaluating instead of waiting
// for the event loop to go fully idle: a page timer (setInterval) keeps
// the loop busy forever and would otherwise burn the whole budget, so a
// module that had already evaluated got abandoned (issue #374).
//
// Drive it WITHOUT the #430 isolate leak: pump the event loop one
// self-contained tick at a time (poll_event_loop exits the isolate per
// call) and poll mod_evaluate alongside it, instead of cancelling a
// `run_event_loop` future with a tokio timeout — a fired timeout drops
// that future mid-poll and leaves the isolate entered on the thread,
// aborting the process once a second page enters its own isolate.
/// Drive a just-started module evaluation to completion, or up to
/// `budget_ms`, WITHOUT the #430 isolate leak: pump the event loop one
/// self-contained tick at a time (`poll_event_loop` exits the isolate per
/// call) and poll the `mod_evaluate` future alongside it, instead of
/// cancelling a `run_event_loop` future with a tokio timeout (a fired timeout
/// drops that future mid-poll and leaves the isolate entered on the thread).
/// A module eval error or timeout is logged under `what` and swallowed:
/// neither is fatal to rendering the rest of the page.
async fn drive_module_eval(&mut self, module_id: deno_core::ModuleId, budget_ms: u64, what: &str) {
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(budget_ms);
let mut result = Box::pin(self.runtime.mod_evaluate(module_id));
loop {
let done = std::future::poll_fn(|cx| {
let _ = self
@@ -718,19 +726,18 @@ impl ObscuraJsRuntime {
match done {
Some(Ok(())) => break,
Some(Err(e)) => {
tracing::warn!("Module eval error: {}", e);
tracing::warn!("{} eval error: {}", what, e);
break;
}
None => {
if tokio::time::Instant::now() >= deadline {
tracing::warn!("Module evaluation timed out after {}ms: {}", budget_ms, url);
tracing::warn!("{} evaluation timed out after {}ms", what, budget_ms);
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
}
}
}
Ok(())
}
pub async fn load_inline_module(&mut self, code: &str, base_url: &str, budget_ms: u64) -> Result<(), String> {
@@ -757,45 +764,12 @@ impl ObscuraJsRuntime {
}
};
let mut result = Box::pin(self.runtime.mod_evaluate(module_id));
let deadline = tokio::time::Instant::now() + budget;
// Drive the event loop, but return as soon as the module finishes
// evaluating rather than waiting for the loop to go fully idle. A page
// timer (Vite's HMR / React-Refresh client installs a setInterval) keeps
// the loop busy forever; waiting for idle burned the whole budget on this
// preamble module and starved the later module that mounts the app,
// leaving #root empty (issue #374).
//
// Cancellation-safe pump (see #430 / load_module): never cancel a
// `run_event_loop` future with a tokio timeout, which drops it mid-poll
// and leaves the isolate entered on the thread.
loop {
let done = std::future::poll_fn(|cx| {
let _ = self
.runtime
.poll_event_loop(cx, deno_core::PollEventLoopOptions::default());
std::task::Poll::Ready(match std::future::Future::poll(result.as_mut(), cx) {
std::task::Poll::Ready(r) => Some(r),
std::task::Poll::Pending => None,
})
})
.await;
match done {
Some(Ok(())) => break,
Some(Err(e)) => {
tracing::warn!("Inline module eval error: {}", e);
break;
}
None => {
if tokio::time::Instant::now() >= deadline {
tracing::warn!("Inline module timed out after {}ms", budget_ms);
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
}
}
}
// Return as soon as the module finishes evaluating rather than waiting
// for idle: Vite's HMR / React-Refresh client installs a setInterval that
// keeps the loop busy forever, and waiting for idle burned the whole
// budget on this preamble module and starved the module that mounts the
// app, leaving #root empty (issue #374).
self.drive_module_eval(module_id, budget_ms, "Inline module").await;
Ok(())
}