fix(cdp): run each connection on its own thread to confine V8 isolates

The CDP server ran every connection's pages on one LocalSet thread, so two
pages' V8 isolates shared an OS thread. Under concurrent navigation a cancelled
event-loop future could leave one isolate entered while another page's
execute_script ran, tripping V8's heap->isolate() == Isolate::TryGetCurrent()
check and aborting the process (#430). No single-thread serialization fixes
this reliably: the cancellation-leak pattern is pervasive and partly inside
deno_core.

Give each WebSocket connection its own OS thread with its own current_thread
runtime, LocalSet, and CdpContext, so every page's isolate is confined to one
thread. V8's invariant is per-thread, so two connections' isolates can never
collide. This is the "Option 2" fix named in obscura-js/src/v8_lock.rs, and it
also lets connections run in parallel instead of serializing on one thread.

- CdpContext::new_with_shared_context reuses one Arc<BrowserContext> across all
  connections, so they share one cookie jar and one HTTP client.
- A single signal watcher flips the accept flag and wakes every connection's
  processor via notify_waiters(); the accept side persists cookies once on
  shutdown.
- Serialize isolate construction with a process-wide lock: the leaptiering
  JSDispatchTable setup is not safe to run from several threads at once.
- Warm up V8 once on the main thread before any connection thread creates an
  isolate; creating the first isolate off the main thread segfaults in
  InitializeBuiltinJSDispatchTable.

obstacle course 33/33, obscura-cdp 75/75 including the concurrent-navigation and
control-plane tests. Concurrency 2 and 4 against heavy pages no longer abort.

Fixes #430.
This commit is contained in:
sycatle committed 2026-07-20 16:37:50 +02:00
1 parent 45df429d99
commit 76fc3b9cae
3 files changed
+216 -110

No files matched your search

+32 -14
View File
@@ -14,6 +14,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.
static ISOLATE_CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[derive(Debug, Clone)]
pub struct RemoteObjectInfo {
pub js_type: String,
@@ -118,23 +127,32 @@ impl ObscuraJsRuntime {
let module_loader = Rc::new(ObscuraModuleLoader::with_proxy(base_url, proxy_url));
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![build_extension()],
module_loader: Some(module_loader),
startup_snapshot: Some(SNAPSHOT),
..Default::default()
});
// Build the isolate under the process-wide creation lock so two
// connection threads never construct isolates concurrently (#430).
let (runtime, isolate_handle) = {
let _create_guard = ISOLATE_CREATE_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
runtime.op_state().borrow_mut().put(state_clone);
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![build_extension()],
module_loader: Some(module_loader),
startup_snapshot: Some(SNAPSHOT),
..Default::default()
});
runtime
.execute_script(
"<obscura:init>",
"globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0;".to_string(),
)
.expect("init should not fail");
runtime.op_state().borrow_mut().put(state_clone);
let isolate_handle = runtime.v8_isolate().thread_safe_handle();
runtime
.execute_script(
"<obscura:init>",
"globalThis.__obscura_objects = {}; globalThis.__obscura_oid = 0;".to_string(),
)
.expect("init should not fail");
let isolate_handle = runtime.v8_isolate().thread_safe_handle();
(runtime, isolate_handle)
};
ObscuraJsRuntime {
runtime,