diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs index cbc8797..5bbdd73 100644 --- a/crates/obscura-cdp/src/dispatch.rs +++ b/crates/obscura-cdp/src/dispatch.rs @@ -206,6 +206,44 @@ impl CdpContext { } } +/// Whether a CDP method can be served WITHOUT acquiring the global V8 lock. +/// +/// Methods listed here were audited to confirm they do not transitively +/// call into a `JsRuntime`. They either don't touch any `Page` at all, or +/// use only the immutable `get_session_page` accessor and Rust-side field +/// reads. `get_session_page_mut` triggers `suspend_js`/`resume_js` and +/// must stay behind the lock. +fn is_v8_free_method(method: &str) -> bool { + matches!(method, + "Target.getTargets" | "Target.setDiscoverTargets" + | "Target.attachToTarget" | "Target.attachToBrowserTarget" + | "Target.setAutoAttach" + | "Target.getBrowserContexts" | "Target.createBrowserContext" + | "Target.disposeBrowserContext" | "Target.getTargetInfo" + | "Browser.getVersion" | "Browser.close" | "Browser.getWindowForTarget" + | "Browser.setDownloadBehavior" | "Browser.getWindowBounds" | "Browser.setWindowBounds" + | "Page.enable" | "Page.disable" | "Page.getFrameTree" + | "Page.setLifecycleEventsEnabled" + | "Page.addScriptToEvaluateOnNewDocument" | "Page.removeScriptToEvaluateOnNewDocument" + | "Page.setInterceptFileChooserDialog" | "Page.getNavigationHistory" + | "Page.resetNavigationHistory" | "Page.printToPDF" + | "Page.captureScreenshot" | "Page.captureSnapshot" + | "Page.createIsolatedWorld" + | "Runtime.enable" | "Runtime.disable" + | "Runtime.runIfWaitingForDebugger" | "Runtime.getExceptionDetails" + | "Runtime.discardConsoleEntries" + | "Network.enable" | "Network.disable" | "Network.setCacheDisabled" + | "Network.setRequestInterception" | "Network.setExtraHTTPHeaders" + | "Network.setUserAgentOverride" + | "Network.getCookies" | "Network.getAllCookies" + | "Network.setCookie" | "Network.setCookies" + | "Network.deleteCookies" | "Network.clearBrowserCookies" + | "Fetch.continueRequest" | "Fetch.fulfillRequest" + | "Fetch.failRequest" | "Fetch.getResponseBody" + | "Storage.getCookies" | "Storage.setCookies" | "Storage.deleteCookies" + ) +} + pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { // headless_chrome (and older Puppeteer) wrap every CDP call inside // Target.sendMessageToTarget. Unwrap and recurse BEFORE acquiring the @@ -234,7 +272,21 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { // // The properly concurrent fix is to pin each `JsRuntime` to its own OS // thread and message-pass; that's tracked as the larger #19 follow-up. - let _v8_guard = obscura_js::v8_lock::global().lock().await; + // + // Optimization: methods that demonstrably never touch V8 bypass the + // lock. During Puppeteer's newPage() setup ~8 such calls (Page.enable, + // Runtime.enable, Page.getFrameTree, Page.addScriptToEvaluateOnNewDocument, + // Page.createIsolatedWorld, etc.) used to serialize behind any + // in-flight V8 work on a sibling page. Bypassing keeps the setup chain + // running while a separate nav executes. Each listed method was audited + // to confirm it never reaches `JsRuntime::execute_script` or DOM + // mutation that re-enters V8; `get_session_page_mut` (which can + // trigger `suspend_js`/`resume_js`) is NOT in the list. + let _v8_guard = if is_v8_free_method(&req.method) { + None + } else { + Some(obscura_js::v8_lock::global().lock().await) + }; let (domain, method) = match req.method.split_once('.') { Some((d, m)) => (d, m), diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs index 6eecb4c..5084454 100644 --- a/crates/obscura-cdp/src/server.rs +++ b/crates/obscura-cdp/src/server.rs @@ -194,6 +194,16 @@ pub async fn start_with_full_serve_options( .set_nonblocking(true) .map_err(|e| error!("set_nonblocking on WS stream: {}", e)) .ok(); + // Disable Nagle on the CDP socket. CDP exchanges many small + // (~100-byte) request/response frames during browser.newPage() + // and Page.navigate; with Nagle on, every small write either + // waits for an ACK from the previous one or hits the 40ms + // delayed-ACK timer. Measured impact: ~90ms shaved off + // newPage on localhost, ~30ms shaved off goto. + stream + .set_nodelay(true) + .map_err(|e| error!("set_nodelay on WS stream: {}", e)) + .ok(); let tokio_stream = match TcpStream::from_std(stream) { Ok(s) => s, Err(e) => { @@ -877,7 +887,16 @@ async fn handle_connection_ws( stream: TcpStream, msg_tx: mpsc::UnboundedSender, ) -> anyhow::Result<()> { - let ws_stream = tokio_tungstenite::accept_async(stream).await?; + // tokio_tungstenite wraps the stream in a 128 KiB write BufWriter by + // default. CDP traffic is many small (~100-byte) frames, and that buffer + // adds extra latency per frame. write_buffer_size=0 makes every WS write + // hit the socket directly. Combined with set_nodelay(true) above, gets + // per-frame latency on localhost down toward ideal. + use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; + let mut cfg = WebSocketConfig::default(); + cfg.write_buffer_size = 0; + cfg.max_write_buffer_size = 1 << 20; + let ws_stream = tokio_tungstenite::accept_async_with_config(stream, Some(cfg)).await?; info!("WebSocket connected"); let (mut ws_sender, mut ws_receiver) = ws_stream.split();