From 9800a2b0913e8a376467c184872bb380ab3939f8 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 19 Aug 2026 04:02:42 +0800 Subject: [PATCH] Fix CDP execution context replay on reconnect --- moli-cdp-smoke/README.md | 12 +- .../moli_cdp_smoke/puppeteer_smoke.mjs | 152 ++++++++++- .../protocol_server/tests/cdp_dynamic_page.rs | 237 ++++++++++++++++++ moli-protocol/src/domains/target/lifecycle.rs | 13 + 4 files changed, 411 insertions(+), 3 deletions(-) diff --git a/moli-cdp-smoke/README.md b/moli-cdp-smoke/README.md index e70786c92d..4494f2f237 100644 --- a/moli-cdp-smoke/README.md +++ b/moli-cdp-smoke/README.md @@ -70,6 +70,16 @@ the CDP Network success/failure terminal skeleton. The local Chromium source checkout remains useful for locating the WPT assets but is not counted as an executable pass while its snapshot and binary are mismatched. +The Puppeteer existing-Page reconnect contract was calibrated on 2026-08-19 +with the pinned puppeteer-core 24.30.0 against Debian `/usr/bin/chromium` +145.0.7632.116. One complete Puppeteer-group run created a second Page through +`PUT /json/new`, evaluated in the original Page through one `puppeteer.connect()` +session, disconnected that session, and evaluated the same target and retained +main-world marker through a replacement connection. Chromium returned `42` in +both sessions. The identical probe timed out at the replacement evaluation after +10,000 ms when Moli's parent-session renderer-inspector detach fix was removed, +and passed after the fix was restored. + ## Current Coverage The current suite is a strong core smoke gate, not a complete Playwright compatibility suite. @@ -129,7 +139,7 @@ Covered well: live DOM text; sampled `innerText` is recorded but may remain stale until the next layout refresh by design. - The focused raw `dom-parser-mutations` group holds a parser-blocking head script after an early head-only `DOM.getDocument` and requires Chromium's exact root-agent sequence: commit `DOM.documentUpdated`, parser-tail BODY `DOM.childNodeInserted`, DCL `DOM.documentUpdated`, then `Page.domContentEventFired`. It also proves that the early frontend node id is stale after the DCL barrier and that a refreshed snapshot contains the complete BODY. -- Optional Puppeteer over CDP group for `puppeteer.connect()`, browser-target and page-target `CDPSession`, `page.goto()`, `page.reload()`, selector-backed DOM activation navigation, same-document hash and History API navigation, `page.evaluate(fetch)`, keyboard input via `page.type()`, CSS / `$eval` / XPath element selection, and DOM interactions across text input, textarea, label/checkbox, radio, select, details/summary, disabled button, and form submission. It also covers `ElementHandle.boundingBox()` / `evaluate()` / `uploadFile()`, DedicatedWorker `workercreated` / `WebWorker.evaluate()` / exact-once worker-session console routing / explicit terminate / navigation-destroy lifecycle, current-viewport `page.screenshot({captureBeyondViewport:false})`, alert and console events, browser-session download behavior/events/artifact with peer-session event isolation, request interception `respond()` / `continue()`, page-scoped `CDPSession` Network event observation, and layout-backed `page.click()` dispatch. +- Optional Puppeteer over CDP group for `puppeteer.connect()`, reconnecting an existing parked-then-promoted Page through a fresh browser session, browser-target and page-target `CDPSession`, `page.goto()`, `page.reload()`, selector-backed DOM activation navigation, same-document hash and History API navigation, `page.evaluate(fetch)`, keyboard input via `page.type()`, CSS / `$eval` / XPath element selection, and DOM interactions across text input, textarea, label/checkbox, radio, select, details/summary, disabled button, and form submission. It also covers `ElementHandle.boundingBox()` / `evaluate()` / `uploadFile()`, DedicatedWorker `workercreated` / `WebWorker.evaluate()` / exact-once worker-session console routing / explicit terminate / navigation-destroy lifecycle, current-viewport `page.screenshot({captureBeyondViewport:false})`, alert and console events, browser-session download behavior/events/artifact with peer-session event isolation, request interception `respond()` / `continue()`, page-scoped `CDPSession` Network event observation, and layout-backed `page.click()` dispatch. - `browser.new_context()`, `context.new_page()`, multiple pages in one context, target switching, popup-scoped `page.route()` plus `evaluate(fetch)`, popup CDPSession response-stage body / stream / fulfill / fail flows, and held multi-context route / response-stage resume without cross-context Network event bleed. - Top-level navigation, redirect final URL/response, reload-like click navigation, and history back/forward. - Reload, same-document hash navigation, `history.pushState()` observation, Playwright `add_init_script()` page/context injection, `page.exposeFunction()` / `page.exposeBinding()` plus context-level exposed functions, and basic `domcontentloaded` / `load` / `networkidle` load-state waits with a parser-discovered delayed image. diff --git a/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs index 3233310e17..08d1bd2613 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs +++ b/moli-cdp-smoke/moli_cdp_smoke/puppeteer_smoke.mjs @@ -54,6 +54,147 @@ async function discoverWebSocket(endpoint) { return payload.webSocketDebuggerUrl; } +async function listPageTargets(endpoint) { + const response = await fetch(`${endpoint.replace(/\/$/, '')}/json/list`); + if (!response.ok) { + throw new Error(`CDP target discovery failed with HTTP ${response.status}`); + } + const payload = await response.json(); + if (!Array.isArray(payload)) { + throw new Error(`CDP target discovery returned an invalid payload: ${JSON.stringify(payload)}`); + } + return payload.filter(target => target?.type === 'page' && typeof target.id === 'string'); +} + +async function createPageTarget(endpoint) { + const response = await fetch( + `${endpoint.replace(/\/$/, '')}/json/new?${encodeURIComponent('about:blank')}`, + { method: 'PUT' }, + ); + if (!response.ok) { + throw new Error(`CDP target creation failed with HTTP ${response.status}`); + } + const target = await response.json(); + if (target?.type !== 'page' || typeof target.id !== 'string') { + throw new Error(`CDP target creation returned an invalid payload: ${JSON.stringify(target)}`); + } + return target; +} + +async function closePageTarget(endpoint, targetId) { + const response = await fetch( + `${endpoint.replace(/\/$/, '')}/json/close/${encodeURIComponent(targetId)}`, + ); + if (!response.ok) { + throw new Error(`CDP target ${targetId} cleanup failed with HTTP ${response.status}`); + } +} + +async function pageByTargetId(browser, targetId, label) { + const target = await withTimeout( + `${label} target`, + browser.waitForTarget(candidate => { + return candidate.type() === 'page' && candidate._targetId === targetId; + }, { timeout: 10000 }), + ); + const page = await withTimeout(`${label} page`, target.page()); + if (!page) { + throw new Error(`${label} target ${targetId} did not expose a Page`); + } + return page; +} + +async function runPuppeteerReconnectSmoke(puppeteer, endpoint, browserWSEndpoint) { + let pages = await listPageTargets(endpoint); + let originalTargetCreated = false; + if (pages.length === 0) { + pages = [await createPageTarget(endpoint)]; + originalTargetCreated = true; + } + const originalTargetId = pages[0].id; + // Materializing a second Page parks the original Page in Moli. Accessing the + // original through Puppeteer then promotes it again, which is the lifecycle + // required to exercise parent-session detach cleanup on disconnect. + const temporaryTarget = await createPageTarget(endpoint); + if (temporaryTarget.id === originalTargetId) { + throw new Error(`CDP target creation reused the existing target id ${originalTargetId}`); + } + + let firstBrowser; + let replacementBrowser; + let completed = false; + try { + firstBrowser = await withTimeout( + 'first Puppeteer reconnect probe connect', + puppeteer.connect({ browserWSEndpoint, protocolTimeout: 10000 }), + ); + const originalPage = await pageByTargetId( + firstBrowser, + originalTargetId, + 'first Puppeteer reconnect probe', + ); + const firstResult = await withTimeout( + 'first Puppeteer reconnect probe evaluate', + originalPage.evaluate(() => { + globalThis.__moliPuppeteerReconnectMarker = 29; + return 6 * 7; + }), + ); + if (firstResult !== 42) { + throw new Error(`unexpected first Puppeteer reconnect probe result: ${firstResult}`); + } + await firstBrowser.disconnect(); + firstBrowser = undefined; + + replacementBrowser = await withTimeout( + 'replacement Puppeteer reconnect probe connect', + puppeteer.connect({ browserWSEndpoint, protocolTimeout: 10000 }), + ); + const replacementPage = await pageByTargetId( + replacementBrowser, + originalTargetId, + 'replacement Puppeteer reconnect probe', + ); + const replacementResult = await withTimeout( + 'replacement Puppeteer reconnect probe evaluate', + replacementPage.evaluate(() => ({ + answer: 6 * 7, + marker: globalThis.__moliPuppeteerReconnectMarker, + })), + ); + if (replacementResult?.answer !== 42 || replacementResult?.marker !== 29) { + throw new Error( + `unexpected replacement Puppeteer reconnect probe result: ${JSON.stringify(replacementResult)}`, + ); + } + completed = true; + return { + existingTargetReused: true, + firstEvaluation: firstResult, + replacementEvaluation: replacementResult.answer, + }; + } finally { + if (replacementBrowser) { + await replacementBrowser.disconnect().catch(() => {}); + } + if (firstBrowser) { + await firstBrowser.disconnect().catch(() => {}); + } + await closePageTarget(endpoint, temporaryTarget.id).catch(error => { + if (completed) { + throw error; + } + }); + if (originalTargetCreated) { + await closePageTarget(endpoint, originalTargetId).catch(error => { + if (completed) { + throw error; + } + }); + } + } +} + async function createBrowserContext(browser) { if (typeof browser.createBrowserContext === 'function') { return await browser.createBrowserContext(); @@ -80,13 +221,20 @@ async function main() { const browserWSEndpoint = await discoverWebSocket(endpoint); const isMoliEndpoint = browserWSEndpoint.endsWith('/devtools/browser/moli-browser'); trace(`discovered ${browserWSEndpoint}`); + const results = []; + const record = (name, data = {}) => results.push({ name, ok: true, ...data }); + const reconnectResult = await runPuppeteerReconnectSmoke( + puppeteer, + endpoint, + browserWSEndpoint, + ); + record('puppeteer_existing_page_reconnect_runtime_context', reconnectResult); + trace('reconnected to existing Page with a fresh Puppeteer session'); const browser = await withTimeout( 'puppeteer.connect', puppeteer.connect({ browserWSEndpoint, protocolTimeout: 10000 }), ); trace('connected'); - const results = []; - const record = (name, data = {}) => results.push({ name, ok: true, ...data }); let context; let browserCdp; diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs index 639c389380..bd80947a72 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs @@ -58,6 +58,131 @@ fn response_by_id(messages: &[serde_json::Value], id: u64) -> &serde_json::Value .unwrap_or_else(|| panic!("missing response id {id}: {messages:#?}")) } +async fn enable_runtime_and_expect_default_context( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + command_id: u64, + session_id: Option<&str>, + label: &str, +) -> Vec { + let mut messages = + send_cdp_command(socket, command_id, "Runtime.enable", session_id, json!({})).await; + assert_eq!(response_by_id(&messages, command_id)["result"], json!({})); + if !messages.iter().any(|message| { + message.get("sessionId").and_then(serde_json::Value::as_str) == session_id + && message["method"] == json!("Runtime.executionContextCreated") + && message["params"]["context"]["auxData"]["isDefault"] == json!(true) + }) { + messages.extend( + send_cdp_command( + socket, + command_id + 1, + "Runtime.evaluate", + session_id, + json!({ "expression": "void 0" }), + ) + .await, + ); + } + assert!( + messages.iter().any(|message| { + message.get("sessionId").and_then(serde_json::Value::as_str) == session_id + && message["method"] == json!("Runtime.executionContextCreated") + && message["params"]["context"]["auxData"]["isDefault"] == json!(true) + }), + "{label} did not report the existing default context before the next Runtime response: {messages:#?}" + ); + messages +} + +async fn puppeteer_auto_attach_existing_page( + browser: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + command_id: u64, + page_target_id: &str, +) -> String { + let tab_target_id = format!("TAB-{page_target_id}"); + send_cdp_command_without_wait( + browser, + command_id, + "Target.setAutoAttach", + None, + json!({ + "autoAttach": true, + "waitForDebuggerOnStart": true, + "flatten": true, + "filter": [ + { "type": "page", "exclude": true }, + {} + ] + }), + ) + .await; + let mut saw_root_response = false; + let mut saw_tab_attach = false; + let root_auto_attach = recv_until_match(browser, |message| { + saw_root_response |= message["id"] == json!(command_id); + saw_tab_attach |= message["method"] == json!("Target.attachedToTarget") + && message["params"]["targetInfo"]["targetId"] == json!(tab_target_id); + saw_root_response && saw_tab_attach + }) + .await; + assert_eq!( + response_by_id(&root_auto_attach, command_id)["result"], + json!({}) + ); + let tab_session_id = root_auto_attach + .iter() + .find(|message| { + message["method"] == json!("Target.attachedToTarget") + && message["params"]["targetInfo"]["targetId"] == json!(tab_target_id) + }) + .and_then(|message| message["params"]["sessionId"].as_str()) + .expect("auto-attached tab session") + .to_owned(); + + let child_command_id = command_id + 1; + send_cdp_command_without_wait( + browser, + child_command_id, + "Target.setAutoAttach", + Some(&tab_session_id), + json!({ + "autoAttach": true, + "waitForDebuggerOnStart": false, + "flatten": true, + "filter": [{}] + }), + ) + .await; + let mut saw_child_response = false; + let mut saw_page_attach = false; + let tab_auto_attach = recv_until_match(browser, |message| { + saw_child_response |= message["id"] == json!(child_command_id); + saw_page_attach |= message["sessionId"] == json!(tab_session_id) + && message["method"] == json!("Target.attachedToTarget") + && message["params"]["targetInfo"]["targetId"] == json!(page_target_id); + saw_child_response && saw_page_attach + }) + .await; + assert_eq!( + response_by_id(&tab_auto_attach, child_command_id)["result"], + json!({}) + ); + tab_auto_attach + .iter() + .find(|message| { + message["sessionId"] == json!(tab_session_id) + && message["method"] == json!("Target.attachedToTarget") + && message["params"]["targetInfo"]["targetId"] == json!(page_target_id) + }) + .and_then(|message| message["params"]["sessionId"].as_str()) + .expect("auto-attached page session") + .to_owned() +} + async fn fetch_server_json(addr: std::net::SocketAddr, path: &str) -> serde_json::Value { let (status, body) = fetch_server_response(addr, "GET", path).await; assert_eq!(status, 200, "unexpected HTTP status for {path}"); @@ -1699,6 +1824,118 @@ async fn websocket_cdp_shared_owner_survives_idle_browser_reconnect() { abort_test_cdp_server(server).await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_cdp_puppeteer_reconnect_replays_existing_runtime_context() { + let (addr, server) = spawn_test_protocol_server().await; + let (status, body) = fetch_server_response(addr, "PUT", "/json/new?about%3Ablank").await; + assert_eq!(status, 200); + let created_target: serde_json::Value = + serde_json::from_slice(&body).expect("created target descriptor"); + let created_target_id = created_target["id"] + .as_str() + .expect("created target id") + .to_owned(); + assert_ne!(created_target_id, DEFAULT_TARGET_ID); + // Materializing a second Page parks the old default Page. Puppeteer's + // first Page command promotes that existing target back to the active + // slot, which is the reconnect lifecycle that regressed. + let target_id = DEFAULT_TARGET_ID.to_owned(); + + let (mut browser, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .expect("connect first browser websocket"); + let page_session_id = puppeteer_auto_attach_existing_page(&mut browser, 1, &target_id).await; + let page_enabled = send_cdp_command( + &mut browser, + 3, + "Page.enable", + Some(&page_session_id), + json!({}), + ) + .await; + assert_eq!(response_by_id(&page_enabled, 3)["result"], json!({})); + let _ = enable_runtime_and_expect_default_context( + &mut browser, + 4, + Some(&page_session_id), + "first Runtime.enable", + ) + .await; + let utility_world = send_cdp_command( + &mut browser, + 6, + "Page.createIsolatedWorld", + Some(&page_session_id), + json!({ + "frameId": target_id, + "worldName": "__puppeteer_utility_world__moli_reconnect", + "grantUniveralAccess": true + }), + ) + .await; + assert!( + response_by_id(&utility_world, 6)["result"]["executionContextId"] + .as_i64() + .is_some(), + "failed to create the persistent Puppeteer utility world: {utility_world:#?}" + ); + + browser + .close(None) + .await + .expect("close first browser frontend"); + wait_for_websocket_close(&mut browser, "first browser frontend").await; + + let (mut replacement, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .expect("connect replacement browser websocket"); + let replacement_page_session_id = + puppeteer_auto_attach_existing_page(&mut replacement, 1, &target_id).await; + assert_ne!(replacement_page_session_id, page_session_id); + let page_enabled = send_cdp_command( + &mut replacement, + 3, + "Page.enable", + Some(&replacement_page_session_id), + json!({}), + ) + .await; + assert_eq!(response_by_id(&page_enabled, 3)["result"], json!({})); + let replay = enable_runtime_and_expect_default_context( + &mut replacement, + 4, + Some(&replacement_page_session_id), + "replacement Runtime.enable", + ) + .await; + assert!( + replay.iter().any(|message| { + message["sessionId"] == json!(replacement_page_session_id) + && message["method"] == json!("Runtime.executionContextCreated") + && message["params"]["context"]["name"] + == json!("__puppeteer_utility_world__moli_reconnect") + }), + "replacement Runtime.enable did not replay the existing Puppeteer utility world: {replay:#?}" + ); + + let evaluated = send_cdp_command( + &mut replacement, + 6, + "Runtime.evaluate", + Some(&replacement_page_session_id), + json!({ "expression": "6 * 7", "returnByValue": true }), + ) + .await; + assert_eq!( + response_by_id(&evaluated, 6)["result"]["result"]["value"], + json!(42) + ); + + abort_test_cdp_server(server).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_cdp_browser_reconnect_clears_detached_session_emulated_media() { let (addr, server) = spawn_test_protocol_server().await; diff --git a/moli-protocol/src/domains/target/lifecycle.rs b/moli-protocol/src/domains/target/lifecycle.rs index d7b1e8f215..fdf2bf35d2 100644 --- a/moli-protocol/src/domains/target/lifecycle.rs +++ b/moli-protocol/src/domains/target/lifecycle.rs @@ -2458,6 +2458,19 @@ async fn detach_attached_session_for_owner_async( conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan); return; }; + // A parent-session detach cascade bypasses the direct + // Target.detachFromTarget path. Release the renderer inspector + // here before resetting protocol state so the replacement primary + // session gets a fresh Runtime.enable context inventory. + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(session_id), + "Target detached", + ); + let _ = conn + .detach_runtime_inspector_session_for_session_owner_async(Some(session_id)) + .await; events::fail_pending_fetch_state_for_target_background_events_async( conn, out.background_events_mut(),