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:
1 parent
282b4646d3
commit
89692f3cb7
2 files changed
+73
-2
No files matched your search
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user