Perf/cdp network and lockfree (#214)

* perf(cdp): TCP_NODELAY + zero-buffer WS writes shave 130ms off newPage

CDP traffic during browser.newPage() and Page.navigate is many small
(~100-byte) request/response frames. Two pieces of buffering were
adding ~10-15ms latency per WS frame on localhost, which on the
~10 round-trips Puppeteer makes during newPage compounded to a
~130ms penalty.

1. TCP_NODELAY on every accepted WS stream. With Nagle on, each small
   write either waits for an ACK from the previous one or hits the
   40ms delayed-ACK timer, even on localhost.
2. WebSocketConfig.write_buffer_size = 0 on accept_async. tokio-
   tungstenite's default 128 KiB write BufWriter copies every frame
   into the buffer before flushing; setting it to 0 makes every
   small CDP reply hit the socket directly.

Measured impact on localhost (puppeteer-core 24, lower is better):

  operation           before    after   speedup
  browser.newPage()   ~190ms   ~50ms    ~4x
  goto(data: URL)      ~60ms   ~22ms    ~3x
  goto(repeat)         ~60ms   ~22ms    ~3x

Shared-page CDP scrape bench (9 scenarios): ~4000ms -> ~2900ms.

28/28 integration tests pass.

* perf(cdp): bypass v8_lock for read-only / setup CDP methods

Every CDP request unconditionally acquired the process-wide V8 lock,
which serializes all CDP traffic behind any in-flight V8 work on a
sibling page. During Puppeteer's browser.newPage() the ~8 setup
calls (Page.enable, Runtime.enable, Page.getFrameTree,
Page.addScriptToEvaluateOnNewDocument, Page.createIsolatedWorld, etc.)
all queued behind a concurrent nav, turning a sub-millisecond setup
into a multi-hundred-millisecond wait.

is_v8_free_method now allowlists methods that demonstrably never
touch a JsRuntime, either they don't touch any Page at all, or they
use only the immutable get_session_page accessor and Rust-side field
reads. get_session_page_mut (which can trigger suspend_js/resume_js
on other pages) stays behind the lock, as does Target.createTarget
(which calls page.navigate).

Allowlisted: most of Target/Browser/Storage, plus Page.enable,
Page.getFrameTree, Page.addScriptToEvaluateOnNewDocument,
Page.createIsolatedWorld, Runtime.enable, Network.* cookie ops,
Fetch.continue/fulfill/fail.

Measured on the puppeteer-core 24 CDP scrape bench:
  browser.newPage() warm: ~50ms -> 13-32ms
  goto() repeat:          ~22ms -> 14-48ms (variance)

28/28 integration tests pass. 18/18 realistic scrape scenarios pass.
This commit is contained in:
SGavrl authored and GitHub committed 2026-05-31 19:50:27 +00:00
1 parent 282b4646d3
commit 89692f3cb7
2 files changed
+73 -2

No files matched your search

+53 -1
View File
@@ -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 { pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
// headless_chrome (and older Puppeteer) wrap every CDP call inside // headless_chrome (and older Puppeteer) wrap every CDP call inside
// Target.sendMessageToTarget. Unwrap and recurse BEFORE acquiring the // 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 // 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. // 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('.') { let (domain, method) = match req.method.split_once('.') {
Some((d, m)) => (d, m), Some((d, m)) => (d, m),
+20 -1
View File
@@ -194,6 +194,16 @@ pub async fn start_with_full_serve_options(
.set_nonblocking(true) .set_nonblocking(true)
.map_err(|e| error!("set_nonblocking on WS stream: {}", e)) .map_err(|e| error!("set_nonblocking on WS stream: {}", e))
.ok(); .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) { let tokio_stream = match TcpStream::from_std(stream) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
@@ -877,7 +887,16 @@ async fn handle_connection_ws(
stream: TcpStream, stream: TcpStream,
msg_tx: mpsc::UnboundedSender<ServerMessage>, msg_tx: mpsc::UnboundedSender<ServerMessage>,
) -> anyhow::Result<()> { ) -> 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"); info!("WebSocket connected");
let (mut ws_sender, mut ws_receiver) = ws_stream.split(); let (mut ws_sender, mut ws_receiver) = ws_stream.split();