Fix CDP execution context replay on reconnect

This commit is contained in:
ldm0
2026-08-19 07:05:43 +08:00
committed by Donough Liu
parent 509b70f370
commit 9800a2b091
4 changed files with 411 additions and 3 deletions
+11 -1
View File
@@ -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.
@@ -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;
@@ -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<tokio::net::TcpStream>,
>,
command_id: u64,
session_id: Option<&str>,
label: &str,
) -> Vec<serde_json::Value> {
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<tokio::net::TcpStream>,
>,
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;
@@ -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(),