diff --git a/Cargo.lock b/Cargo.lock index 11acf193db..00f4483da4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2780,6 +2780,7 @@ dependencies = [ "serde_json", "static_assertions", "strip-ansi-escapes", + "thiserror", "tokio", "tracing", "url", diff --git a/moli-core/Cargo.toml b/moli-core/Cargo.toml index 0cfeaca1f0..040db2c562 100644 --- a/moli-core/Cargo.toml +++ b/moli-core/Cargo.toml @@ -32,6 +32,7 @@ parking_lot = "0.12" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" tokio = { version = "1.51.0", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +thiserror = "2.0.17" tracing = "0.1.41" url = "2.5.7" diff --git a/moli-core/src/lib.rs b/moli-core/src/lib.rs index bcda97685e..e36eacd4fc 100644 --- a/moli-core/src/lib.rs +++ b/moli-core/src/lib.rs @@ -38,4 +38,4 @@ pub use moli_renderer_v8::{ RendererRuntimeInspectorResponseChannel, RendererRuntimeInspectorResponseSender, RendererRuntimeRemoteObjectResolution, }; -pub use moli_renderer_v8::{RendererPageCommand, RendererPageReply}; +pub use moli_renderer_v8::{RendererPageAccessError, RendererPageCommand, RendererPageReply}; diff --git a/moli-core/src/page/command_dispatch.rs b/moli-core/src/page/command_dispatch.rs index f2a6357ad6..80f9648df4 100644 --- a/moli-core/src/page/command_dispatch.rs +++ b/moli-core/src/page/command_dispatch.rs @@ -7,12 +7,36 @@ use super::{ use crate::RendererOutputFence; use anyhow::{Result, bail}; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageDocumentCommandTarget(moli_renderer_v8::RendererDocumentCommandTarget); + +impl PageDocumentCommandTarget { + pub(crate) fn new(target: moli_renderer_v8::RendererDocumentCommandTarget) -> Self { + Self(target) + } + pub fn document(&self) -> crate::RendererDocumentLifecycleIdentity { + self.0.document() + } + pub fn start(&self, command: RendererPageCommand) -> Result { + self.0 + .enqueue(command) + .map(PendingPageCommand::from_renderer) + } +} + pub struct PendingPageCommand { pending: RendererPageCommandPending, renderer_agent_attachment_id: Option, } impl PendingPageCommand { + pub(crate) fn from_renderer(pending: RendererPageCommandPending) -> Self { + Self { + pending, + renderer_agent_attachment_id: None, + } + } + pub fn renderer_agent_attachment_id(&self) -> Option { self.renderer_agent_attachment_id } @@ -79,7 +103,42 @@ pub struct CompletedPageCommand { renderer_agent_attachment_id: Option, } +#[derive(Debug, thiserror::Error)] +#[error("Document command returned an unexpected renderer reply")] +struct UnexpectedDocumentCommandReply; + impl CompletedPageCommand { + /// Fetch settlement owns its exact renderer output; Page adoption is not + /// required to acknowledge the already-executed native operation. + pub fn into_unit_reply(self) -> Result<()> { + self.into_unit_page_command_turn().map(drop) + } + + pub fn into_subresource_continue_outcome( + self, + ) -> Result { + match self.output.into_reply_and_state().0 { + RendererPageReply::PendingSubresourceContinueOutcome(outcome) => Ok(outcome), + _ => Err(UnexpectedDocumentCommandReply.into()), + } + } + + pub fn into_document_bidi_node_shared_id( + self, + ) -> Result { + match self.output.into_reply_and_state().0 { + RendererPageReply::DocumentBidiNodeSharedId(result) => Ok(result), + _ => Err(UnexpectedDocumentCommandReply.into()), + } + } + + pub fn into_document_bidi_node_binding(self) -> Result<()> { + match self.output.into_reply_and_state().0 { + RendererPageReply::DocumentBidiNodeBindingRegistered => Ok(()), + _ => Err(UnexpectedDocumentCommandReply.into()), + } + } + /// Returns the exact renderer output position produced by this command. /// /// Protocol dispatch must capture this before a command-specific decoder @@ -136,15 +195,22 @@ impl CompletedPageCommand { /// replayed while that replacement Page is installed. pub fn into_unit_page_command_turn(self) -> Result { if !matches!(self.output.completion().reply(), RendererPageReply::Unit) { - return Err(anyhow::anyhow!( - "unit page command returned an unexpected renderer reply" - )); + return Err(UnexpectedDocumentCommandReply.into()); } Ok(self.output) } } impl Page { + pub fn document_command_target( + &self, + document: crate::RendererDocumentLifecycleIdentity, + ) -> Result { + self.handle + .document_command_target(document) + .map(PageDocumentCommandTarget::new) + } + pub(crate) fn start_page_command( &self, mut command: RendererPageCommand, diff --git a/moli-core/src/page/mod.rs b/moli-core/src/page/mod.rs index de51f8f49d..204b6ed94c 100644 --- a/moli-core/src/page/mod.rs +++ b/moli-core/src/page/mod.rs @@ -40,8 +40,8 @@ use crate::renderer::{ use anyhow::Result; pub use command_dispatch::{ CompletedDevToolsIoCommandDispatch, CompletedPageCommand, - CompletedRuntimeInspectorCommandDispatch, PendingDevToolsIoCommandDispatch, PendingPageCommand, - PendingRuntimeInspectorCommandDispatch, + CompletedRuntimeInspectorCommandDispatch, PageDocumentCommandTarget, + PendingDevToolsIoCommandDispatch, PendingPageCommand, PendingRuntimeInspectorCommandDispatch, }; #[cfg(all(test, debug_assertions))] diff --git a/moli-core/src/runtime/navigation_engine.rs b/moli-core/src/runtime/navigation_engine.rs index 572d4bd75a..7f51c837c8 100644 --- a/moli-core/src/runtime/navigation_engine.rs +++ b/moli-core/src/runtime/navigation_engine.rs @@ -595,6 +595,19 @@ impl Default for NavigationEngine { } impl NavigationEngine { + /// Captures a weak command target for one native Document, including the + /// interval before the navigation result installs its Page handle. + pub fn document_command_target( + &self, + renderer_owner: moli_renderer_v8::RendererOwnerLocalHostId, + document: moli_renderer_v8::RendererDocumentLifecycleIdentity, + ) -> Result { + self.js_runtime + .renderer_owner_handle() + .document_command_target(renderer_owner, document) + .map(crate::page::PageDocumentCommandTarget::new) + } + /// Reserves a renderer Page identity before the corresponding creation /// command is enqueued. /// diff --git a/moli-protocol-server/src/protocol_server/tests/websocket.rs b/moli-protocol-server/src/protocol_server/tests/websocket.rs index 06b160b230..24fadd58aa 100644 --- a/moli-protocol-server/src/protocol_server/tests/websocket.rs +++ b/moli-protocol-server/src/protocol_server/tests/websocket.rs @@ -5,6 +5,7 @@ mod browser_close; mod captured_document_navigation; mod child_navigation_wait; mod navigation_debugger; +mod prepared_document_actions; mod target_message; fn assert_cdp_event_precedes_response( diff --git a/moli-protocol-server/src/protocol_server/tests/websocket/prepared_document_actions.rs b/moli-protocol-server/src/protocol_server/tests/websocket/prepared_document_actions.rs new file mode 100644 index 0000000000..d37b089d91 --- /dev/null +++ b/moli-protocol-server/src/protocol_server/tests/websocket/prepared_document_actions.rs @@ -0,0 +1,1186 @@ +use super::*; +use serde_json::Value; + +type TestCdpSocket = + tokio_tungstenite::WebSocketStream>; + +#[derive(Clone, Copy, Debug)] +enum Action { + Continue, + Fulfill, + Fail, +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_request_actions_settle_before_adoption() { + for crashed in [false, true] { + for action in [Action::Continue, Action::Fulfill, Action::Fail] { + verify_fetch(crashed, false, action).await; + } + } +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_response_actions_settle_before_adoption() { + for crashed in [false, true] { + for action in [Action::Continue, Action::Fulfill, Action::Fail] { + verify_fetch(crashed, true, action).await; + } + } +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_error_commit_follows_network_failure() { + for crashed in [false, true] { + // Close every connection without HTTP headers, including network + // retries, so the navigation creates a browser-owned error Document. + let reset = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let failed_url = format!("http://{}/reset", reset.local_addr().unwrap()); + let reset_server = tokio::spawn(async move { + while let Ok((stream, _)) = reset.accept().await { + drop(stream); + } + }); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, crashed, false).await; + send_cdp_command_without_wait( + &mut socket, + 20, + "Page.navigate", + Some(&session.session_id), + json!({"url": failed_url}), + ) + .await; + let mut observed = recv_until_match(&mut socket, |message| { + message["method"] == "Page.loadEventFired" && message["sessionId"] == session.session_id + }) + .await; + if !observed.iter().any(|message| message["id"] == 20) { + observed.extend(recv_until_match(&mut socket, |message| message["id"] == 20).await); + } + let methods = [ + "Network.requestWillBeSent", + "Network.loadingFailed", + "Page.frameNavigated", + "Network.loadingFinished", + "Page.domContentEventFired", + "Page.loadEventFired", + ]; + let positions = methods.map(|method| { + let matching = observed + .iter() + .enumerate() + .filter(|(_, message)| { + message["method"] == method && message["sessionId"] == session.session_id + }) + .map(|(index, _)| index) + .collect::>(); + assert_eq!(matching.len(), 1, "{crashed}/{method}: {observed:?}"); + matching[0] + }); + assert!( + positions.windows(2).all(|pair| pair[0] < pair[1]), + "{crashed}: {observed:?}" + ); + let request = &observed[positions[0]]["params"]["requestId"]; + assert_eq!(&observed[positions[1]]["params"]["requestId"], request); + assert_eq!(&observed[positions[3]]["params"]["requestId"], request); + let navigation = &observed.iter().find(|message| message["id"] == 20).unwrap()["result"]; + assert_eq!( + navigation["errorText"], + observed[positions[1]]["params"]["errorText"] + ); + assert_eq!( + observed[positions[2]]["params"]["frame"]["unreachableUrl"], + failed_url + ); + assert_eq!( + observed[positions[2]]["params"]["frame"]["loaderId"], + navigation["loaderId"] + ); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; + reset_server.abort(); + let _ = reset_server.await; + } +} + +struct Fixture { + url: String, + requested: Arc, + release: Arc, + replacement_requested: Arc, + _server: DedicatedFixtureServer, +} + +impl Fixture { + fn new() -> Self { + let requested = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let request = Arc::clone(&requested); + let gate = Arc::clone(&release); + let replacement_requested = Arc::new(tokio::sync::Notify::new()); + let replacement = Arc::clone(&replacement_requested); + let router = Router::new() + .route( + "/page", + get(|| async { "document is fulfilled by the client" }), + ) + .route("/api", get(|| async { "network-api" })) + .route( + "/replacement", + get(move || { + let replacement = Arc::clone(&replacement); + async move { + replacement.notify_one(); + "replacement" + } + }), + ) + .route("/popup", get(|| async { "popup" })) + .route( + "/slow.js", + get(move || { + let request = Arc::clone(&request); + let gate = Arc::clone(&gate); + async move { + request.notify_one(); + gate.notified().await; + ( + [("Content-Type", "text/javascript")], + "globalThis.slowDone = true;", + ) + } + }), + ); + let (addr, server) = spawn_dedicated_fixture_server(router, "prepared-document-actions"); + Self { + url: format!("http://{addr}/page"), + requested, + release, + replacement_requested, + _server: server, + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.release.notify_one(); + } +} + +async fn start_document( + socket: &mut TestCdpSocket, + session_id: &str, + fixture: &Fixture, + inline: &str, +) -> Vec { + let observed = begin_document(socket, session_id, fixture, inline).await; + tokio::time::timeout(Duration::from_secs(10), fixture.requested.notified()) + .await + .unwrap(); + assert!( + !observed + .iter() + .any(|message| message["method"] == "Page.domContentEventFired") + ); + observed +} + +async fn begin_document( + socket: &mut TestCdpSocket, + session_id: &str, + fixture: &Fixture, + inline: &str, +) -> Vec { + send_cdp_command_without_wait( + socket, + 20, + "Page.navigate", + Some(session_id), + json!({"url": fixture.url}), + ) + .await; + let observed = recv_until_match(socket, |message| { + message["method"] == "Fetch.requestPaused" + && message["params"]["resourceType"] == "Document" + }) + .await; + let request = observed.last().unwrap()["params"]["requestId"].clone(); + use base64::Engine as _; + let html = format!(""); + send_cdp_command_without_wait( + socket, + 21, + "Fetch.fulfillRequest", + Some(session_id), + json!({ + "requestId": request, "responseCode": 200, + "responseHeaders": [{"name": "Content-Type", "value": "text/html"}], + "body": base64::engine::general_purpose::STANDARD.encode(html), + }), + ) + .await; + observed +} + +async fn configure(socket: &mut TestCdpSocket, session_id: &str, crashed: bool, response: bool) { + for (id, method) in [ + (5, "Runtime.enable"), + (6, "Page.enable"), + (7, "Network.enable"), + ] { + send_cdp_command(socket, id, method, Some(session_id), json!({})).await; + } + if crashed { + let result = send_cdp_command(socket, 8, "Page.crash", Some(session_id), json!({})).await; + assert!( + result + .iter() + .any(|message| message["id"] == 8 && message.get("result").is_some()), + "{result:?}" + ); + } + send_cdp_command(socket, 9, "Fetch.enable", Some(session_id), json!({"patterns": [ + {"urlPattern": "*page", "resourceType": "Document", "requestStage": "Request"}, + {"urlPattern": "*api", "resourceType": "Fetch", "requestStage": if response { "Response" } else { "Request" }}, + ]})).await; +} + +async fn verify_fetch(crashed: bool, response: bool, action: Action) { + eprintln!("prepared Fetch {crashed}/{response}/{action:?}"); + let fixture = Fixture::new(); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, crashed, response).await; + let mut observed = start_document(&mut socket, &session.session_id, &fixture, + "fetch('/api').then(r=>r.text()).then(v=>console.log('api-settled:'+v),()=>console.log('api-settled:failed'));").await; + observed.extend( + recv_until_match(&mut socket, |message| { + message["method"] == "Fetch.requestPaused" + && message["params"]["request"]["url"] + .as_str() + .is_some_and(|url| url.ends_with("/api")) + }) + .await, + ); + let pause = observed.last().unwrap(); + assert_eq!(pause["params"]["resourceType"], "XHR"); + assert_eq!( + pause["params"].get("responseStatusCode").is_some(), + response + ); + let request = pause["params"]["requestId"].clone(); + let method = match (action, response) { + (Action::Continue, false) => "Fetch.continueRequest", + (Action::Continue, true) => "Fetch.continueResponse", + (Action::Fulfill, _) => "Fetch.fulfillRequest", + (Action::Fail, _) => "Fetch.failRequest", + }; + let mut params = json!({"requestId": request}); + match action { + Action::Fulfill => { + params["responseCode"] = json!(200); + params["body"] = json!("ZnVsZmlsbGVkLWFwaQ=="); + } + Action::Fail => params["errorReason"] = json!("Failed"), + Action::Continue => {} + } + let reply = send_cdp_command(&mut socket, 22, method, Some(&session.session_id), params).await; + assert!( + reply + .iter() + .any(|message| message["id"] == 22 && message.get("result").is_some()), + "{crashed}/{response}/{action:?}: {reply:?}" + ); + observed.extend(reply); + let expected = match action { + Action::Continue => "api-settled:network-api", + Action::Fulfill => "api-settled:fulfilled-api", + Action::Fail => "api-settled:failed", + }; + let settled = |message: &Value| { + message["method"] == "Runtime.consoleAPICalled" + && message["params"]["args"][0]["value"] == expected + }; + if !observed.iter().any(settled) { + observed.extend(recv_until_match(&mut socket, settled).await); + } + assert!( + !observed + .iter() + .any(|message| message["method"] == "Page.domContentEventFired"), + "{observed:?}" + ); + fixture.release.notify_one(); + if !observed + .iter() + .any(|message| message["method"] == "Page.loadEventFired") + { + observed.extend( + recv_until_match(&mut socket, |message| { + message["method"] == "Page.loadEventFired" + }) + .await, + ); + } + let duplicate = send_cdp_command( + &mut socket, + 23, + method, + Some(&session.session_id), + json!({"requestId": request, "responseCode": 200, "errorReason": "Failed"}), + ) + .await; + assert!( + duplicate + .iter() + .any(|message| message["id"] == 23 && message["error"]["message"] == "RequestNotFound"), + "{duplicate:?}" + ); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_worker_is_discovered_attached_and_retired() { + verify_worker(false).await; +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_worker_is_retired_when_navigation_is_replaced() { + verify_worker(true).await; +} + +async fn verify_worker(replace_before_adoption: bool) { + let fixture = Fixture::new(); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, false, false).await; + send_cdp_command( + &mut socket, + 10, + "Target.setDiscoverTargets", + None, + json!({"discover": true}), + ) + .await; + let policy = send_cdp_command(&mut socket, 11, "Target.setAutoAttach", Some(&session.session_id), json!({ + "autoAttach": true, "waitForDebuggerOnStart": false, "flatten": true, "filter": [{"type": "worker"}], + })).await; + assert!( + policy + .iter() + .any(|message| message["id"] == 11 && message.get("result").is_some()), + "{policy:?}" + ); + let crashed = send_cdp_command( + &mut socket, + 12, + "Page.crash", + Some(&session.session_id), + json!({}), + ) + .await; + assert!( + crashed + .iter() + .any(|message| message["id"] == 12 && message.get("result").is_some()), + "{crashed:?}" + ); + let mut observed = start_document( + &mut socket, + &session.session_id, + &fixture, + "window.w=new Worker(URL.createObjectURL(new Blob(['setInterval(()=>{},1000)'])));", + ) + .await; + let attached = |message: &Value| { + message["method"] == "Target.attachedToTarget" + && message["params"]["targetInfo"]["type"] == "worker" + }; + if !observed.iter().any(attached) { + observed.extend(recv_until_match(&mut socket, attached).await); + } + let worker = &observed.iter().find(|message| attached(message)).unwrap()["params"]; + let worker_id = worker["targetInfo"]["targetId"].clone(); + let worker_session = worker["sessionId"].as_str().unwrap().to_owned(); + assert!( + observed + .iter() + .any(|message| message["method"] == "Target.targetCreated" + && message["params"]["targetInfo"]["targetId"] == worker_id), + "{observed:?}" + ); + assert!( + !observed + .iter() + .any(|message| message["method"] == "Page.domContentEventFired") + ); + let targets = send_cdp_command(&mut socket, 22, "Target.getTargets", None, json!({})).await; + assert!( + targets.last().unwrap()["result"]["targetInfos"] + .as_array() + .unwrap() + .iter() + .any(|target| target["targetId"] == worker_id), + "{targets:?}" + ); + let enabled = send_cdp_command( + &mut socket, + 23, + "Runtime.enable", + Some(&worker_session), + json!({}), + ) + .await; + assert!( + enabled + .iter() + .any(|message| message["id"] == 23 && message.get("result").is_some()), + "{enabled:?}" + ); + if !replace_before_adoption { + fixture.release.notify_one(); + recv_until_match(&mut socket, |message| { + message["method"] == "Page.loadEventFired" + }) + .await; + } + let replacement = fixture.url.replace("/page", "/replacement"); + let mut navigation = send_cdp_command( + &mut socket, + 24, + "Page.navigate", + Some(&session.session_id), + json!({"url": replacement}), + ) + .await; + let result = &navigation.last().unwrap()["result"]; + assert!( + result.get("frameId").is_some() && result.get("errorText").is_none(), + "{navigation:?}" + ); + let destroyed = |message: &Value| { + message["method"] == "Target.targetDestroyed" && message["params"]["targetId"] == worker_id + }; + let loaded = |message: &Value| { + message["method"] == "Page.loadEventFired" && message["sessionId"] == session.session_id + }; + // The held request's native timeout is thirty seconds. Retirement must + // finish independently of that timeout and before the fixture is released. + tokio::time::timeout(Duration::from_secs(10), async { + while !navigation.iter().any(destroyed) || !navigation.iter().any(loaded) { + navigation.extend( + recv_until_match(&mut socket, |message| destroyed(message) || loaded(message)) + .await, + ); + } + }) + .await + .expect("replacement must load and retire the original Worker while its parser is held"); + let targets = send_cdp_command(&mut socket, 25, "Target.getTargets", None, json!({})).await; + assert!( + !targets.last().unwrap()["result"]["targetInfos"] + .as_array() + .unwrap() + .iter() + .any(|target| target["targetId"] == worker_id) + ); + let native = send_cdp_command( + &mut socket, + 26, + "Runtime.evaluate", + Some(&session.session_id), + json!({"expression": "location.href", "returnByValue": true}), + ) + .await; + assert_eq!( + native.last().unwrap()["result"]["result"]["value"], + replacement + ); + // Retirement was observed while the old parser was held; release its + // network fixture before waiting for whole-server teardown. + fixture.release.notify_one(); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_dialog_can_be_answered_before_adoption() { + let fixture = Fixture::new(); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, true, false).await; + send_cdp_command_without_wait( + &mut socket, + 20, + "Page.navigate", + Some(&session.session_id), + json!({"url": fixture.url}), + ) + .await; + let paused = recv_until_match(&mut socket, |message| { + message["method"] == "Fetch.requestPaused" + && message["params"]["resourceType"] == "Document" + }) + .await; + use base64::Engine as _; + send_cdp_command_without_wait(&mut socket, 21, "Fetch.fulfillRequest", Some(&session.session_id), json!({ + "requestId": paused.last().unwrap()["params"]["requestId"], "responseCode": 200, + "responseHeaders": [{"name": "Content-Type", "value": "text/html"}], + "body": base64::engine::general_purpose::STANDARD.encode(""), + })).await; + let opened = recv_until_match(&mut socket, |message| { + message["method"] == "Page.javascriptDialogOpening" + }) + .await; + assert_eq!( + opened.last().unwrap()["params"]["message"], + "prepared-dialog" + ); + let answered = send_cdp_command( + &mut socket, + 22, + "Page.handleJavaScriptDialog", + Some(&session.session_id), + json!({"accept": true}), + ) + .await; + assert!( + answered + .iter() + .any(|message| message["id"] == 22 && message.get("result").is_some()), + "{answered:?}" + ); + tokio::time::timeout(Duration::from_secs(10), fixture.requested.notified()) + .await + .unwrap(); + fixture.release.notify_one(); + recv_until_match(&mut socket, |message| { + message["method"] == "Page.loadEventFired" + }) + .await; + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_popup_keeps_accepted_target_creation() { + let fixture = Fixture::new(); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, true, false).await; + send_cdp_command( + &mut socket, + 10, + "Target.setDiscoverTargets", + None, + json!({"discover": true}), + ) + .await; + let mut observed = start_document( + &mut socket, + &session.session_id, + &fixture, + "window.w=window.open('/popup');", + ) + .await; + let created = |message: &Value| { + message["method"] == "Target.targetCreated" + && message["params"]["targetInfo"]["openerId"] == session.target_id + }; + if !observed.iter().any(created) { + observed.extend(recv_until_match(&mut socket, created).await); + } + let popup_id = observed.iter().find(|message| created(message)).unwrap()["params"]["targetInfo"]["targetId"].clone(); + assert!( + !observed + .iter() + .any(|message| message["method"] == "Page.domContentEventFired") + ); + fixture.release.notify_one(); + recv_until_match(&mut socket, |message| { + message["method"] == "Page.loadEventFired" && message["sessionId"] == session.session_id + }) + .await; + let targets = send_cdp_command(&mut socket, 22, "Target.getTargets", None, json!({})).await; + assert!( + targets.last().unwrap()["result"]["targetInfos"] + .as_array() + .unwrap() + .iter() + .any(|target| target["targetId"] == popup_id), + "{targets:?}" + ); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_fetch_is_revoked_by_replacement_disable_and_detach() { + for crashed in [false, true] { + for cancellation in ["replace", "disable", "detach"] { + let fixture = Fixture::new(); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, crashed, false).await; + let mut observed = start_document( + &mut socket, + &session.session_id, + &fixture, + "fetch('/api').then(r=>r.text()).catch(()=>{});", + ) + .await; + observed.extend( + recv_until_match(&mut socket, |message| { + message["method"] == "Fetch.requestPaused" + && message["params"]["request"]["url"] + .as_str() + .is_some_and(|url| url.ends_with("/api")) + }) + .await, + ); + let request = observed.last().unwrap()["params"]["requestId"].clone(); + let (method, session_id, params) = match cancellation { + "replace" => ( + "Page.navigate", + Some(session.session_id.as_str()), + json!({"url": fixture.url.replace("/page", "/replacement")}), + ), + "disable" => ( + "Fetch.disable", + Some(session.session_id.as_str()), + json!({}), + ), + "detach" => ( + "Target.detachFromTarget", + None, + json!({"sessionId": session.session_id}), + ), + _ => unreachable!(), + }; + let canceled = send_cdp_command(&mut socket, 22, method, session_id, params).await; + assert!( + canceled + .iter() + .any(|message| message["id"] == 22 && message.get("result").is_some()), + "{crashed}/{cancellation}: {canceled:?}" + ); + let stale = send_cdp_command( + &mut socket, + 23, + "Fetch.fulfillRequest", + Some(&session.session_id), + json!({"requestId": request, "responseCode": 200}), + ) + .await; + assert!( + stale + .iter() + .any(|message| message["id"] == 23 && message.get("error").is_some()), + "{crashed}/{cancellation}: {stale:?}" + ); + fixture.release.notify_one(); + let alive = + send_cdp_command(&mut socket, 24, "Target.getTargets", None, json!({})).await; + assert!( + alive + .iter() + .any(|message| message["id"] == 24 && message.get("result").is_some()) + ); + let live_session = if cancellation == "detach" { + let attached = send_cdp_command( + &mut socket, + 25, + "Target.attachToTarget", + None, + json!({"targetId": session.target_id, "flatten": true}), + ) + .await; + attached.last().unwrap()["result"]["sessionId"] + .as_str() + .unwrap() + .to_owned() + } else { + session.session_id.clone() + }; + let replacement = fixture.url.replace("/page", "/replacement"); + let recovered = send_cdp_command( + &mut socket, + 26, + "Page.navigate", + Some(&live_session), + json!({"url": replacement}), + ) + .await; + let recovered = &recovered.last().unwrap(); + assert!( + recovered.get("result").is_some() && recovered["result"].get("errorText").is_none(), + "{crashed}/{cancellation}: {recovered:?}" + ); + let native = send_cdp_command( + &mut socket, + 27, + "Runtime.evaluate", + Some(&live_session), + json!({"expression": "location.href", "returnByValue": true}), + ) + .await; + assert_eq!( + native.last().unwrap()["result"]["result"]["value"], + replacement, + "{crashed}/{cancellation}: {native:?}" + ); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; + } + } +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_history_updates_survive_adoption_without_duplicate_entries() + { + for crashed in [false, true] { + for action in ["replaceState", "pushState"] { + let fixture = Fixture::new(); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, false, false).await; + let previous_url = "data:text/html,previous title"; + let initial = send_cdp_command( + &mut socket, + 13, + "Page.navigate", + Some(&session.session_id), + json!({"url": previous_url}), + ) + .await; + if !initial + .iter() + .any(|message| message["method"] == "Page.loadEventFired") + { + recv_until_match(&mut socket, |message| { + message["method"] == "Page.loadEventFired" + }) + .await; + } + if crashed { + let result = send_cdp_command( + &mut socket, + 14, + "Page.crash", + Some(&session.session_id), + json!({}), + ) + .await; + assert!(result.last().unwrap().get("result").is_some(), "{result:?}"); + } + let changed = fixture.url.replace("/page", "/changed"); + let mut observed = start_document( + &mut socket, + &session.session_id, + &fixture, + &format!("history.{action}(null,'','/changed');"), + ) + .await; + let changed_event = |message: &Value| { + message["method"] == "Page.navigatedWithinDocument" + && message["params"]["url"] == changed + }; + if !observed.iter().any(changed_event) { + observed.extend(recv_until_match(&mut socket, changed_event).await); + } + assert!( + !observed + .iter() + .any(|message| message["method"] == "Page.domContentEventFired"), + "{observed:?}" + ); + let targets = + send_cdp_command(&mut socket, 22, "Target.getTargets", None, json!({})).await; + assert!( + targets.last().unwrap()["result"]["targetInfos"] + .as_array() + .unwrap() + .iter() + .any(|target| target["targetId"] == session.target_id + && target["url"] == changed), + "{targets:?}" + ); + let before = send_cdp_command( + &mut socket, + 23, + "Page.getNavigationHistory", + Some(&session.session_id), + json!({}), + ) + .await; + let before = &before.last().unwrap()["result"]; + let entries = before["entries"].as_array().unwrap(); + assert_eq!(entries.last().unwrap()["url"], changed, "{before:?}"); + assert_eq!(entries.last().unwrap()["title"], "", "{before:?}"); + if !crashed { + assert_eq!( + entries + .iter() + .find(|entry| entry["url"] == previous_url) + .expect("ordinary navigation retains the preceding entry")["title"], + "previous title", + "{before:?}" + ); + } + assert_eq!( + entries + .iter() + .filter(|entry| entry["url"] == fixture.url) + .count(), + usize::from(action == "pushState"), + "{before:?}" + ); + fixture.release.notify_one(); + recv_until_match(&mut socket, |message| { + message["method"] == "Page.loadEventFired" + }) + .await; + let after = send_cdp_command( + &mut socket, + 24, + "Page.getNavigationHistory", + Some(&session.session_id), + json!({}), + ) + .await; + assert_eq!( + &after.last().unwrap()["result"], + before, + "{crashed}/{action}" + ); + let native = send_cdp_command( + &mut socket, + 25, + "Runtime.evaluate", + Some(&session.session_id), + json!({"expression": "location.href", "returnByValue": true}), + ) + .await; + assert_eq!(native.last().unwrap()["result"]["result"]["value"], changed); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; + } + } +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_script_navigation_replaces_pending_adoption() { + for crashed in [false, true] { + for action in ["assign", "replace"] { + let fixture = Fixture::new(); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, crashed, false).await; + let mut observed = begin_document( + &mut socket, + &session.session_id, + &fixture, + &format!("location.{action}('/replacement');"), + ) + .await; + // The replacement must start while the original parser is still blocked. + tokio::time::timeout( + Duration::from_secs(10), + fixture.replacement_requested.notified(), + ) + .await + .unwrap(); + let replacement = fixture.url.replace("/page", "/replacement"); + let loaded = |message: &Value| { + message["method"] == "Page.loadEventFired" + && message["sessionId"] == session.session_id + }; + if !observed.iter().any(loaded) { + observed.extend(recv_until_match(&mut socket, loaded).await); + } + let history = send_cdp_command( + &mut socket, + 22, + "Page.getNavigationHistory", + Some(&session.session_id), + json!({}), + ) + .await; + observed.extend(history.clone()); + let entries = history.last().unwrap()["result"]["entries"] + .as_array() + .unwrap(); + assert_eq!( + entries.last().unwrap()["url"], + replacement, + "{crashed}/{action}: {history:?}" + ); + assert_eq!( + entries + .iter() + .filter(|entry| entry["url"] == fixture.url) + .count(), + usize::from(action == "assign"), + "{history:?}" + ); + for id in [20, 21] { + if !observed.iter().any(|message| message["id"] == id) { + observed + .extend(recv_until_match(&mut socket, |message| message["id"] == id).await); + } + assert_eq!( + observed + .iter() + .filter(|message| message["id"] == id) + .count(), + 1, + "{observed:?}" + ); + } + fixture.release.notify_one(); + let native = send_cdp_command( + &mut socket, + 23, + "Runtime.evaluate", + Some(&session.session_id), + json!({"expression": "location.href", "returnByValue": true}), + ) + .await; + assert_eq!( + native.last().unwrap()["result"]["result"]["value"], + replacement + ); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; + } + } +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_file_chooser_preserves_source_node() { + for crashed in [false, true] { + let fixture = Fixture::new(); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, false, false).await; + let enabled = send_cdp_command( + &mut socket, + 10, + "Page.setInterceptFileChooserDialog", + Some(&session.session_id), + json!({"enabled": true}), + ) + .await; + assert!( + enabled.last().unwrap().get("result").is_some(), + "{enabled:?}" + ); + if crashed { + send_cdp_command( + &mut socket, + 11, + "Page.crash", + Some(&session.session_id), + json!({}), + ) + .await; + } + let mut observed = start_document(&mut socket, &session.session_id, &fixture, + "var f=document.createElement('input');f.type='file';document.documentElement.appendChild(f);f.click();").await; + let opened = |message: &Value| message["method"] == "Page.fileChooserOpened"; + if !observed.iter().any(opened) { + observed.extend(recv_until_match(&mut socket, opened).await); + } + assert!( + !observed + .iter() + .any(|message| message["method"] == "Page.domContentEventFired"), + "{observed:?}" + ); + let chooser = observed.iter().find(|message| opened(message)).unwrap(); + let backend = chooser["params"]["backendNodeId"].clone(); + assert!(backend.as_u64().is_some_and(|id| id > 0), "{chooser:?}"); + fixture.release.notify_one(); + recv_until_match(&mut socket, |message| { + message["method"] == "Page.loadEventFired" + }) + .await; + let node = send_cdp_command( + &mut socket, + 22, + "DOM.describeNode", + Some(&session.session_id), + json!({"backendNodeId": backend}), + ) + .await; + assert_eq!( + node.last().unwrap()["result"]["node"]["nodeName"], + "INPUT", + "{node:?}" + ); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; + } +} + +#[tokio::test] +async fn websocket_cdp_prepared_document_popup_navigation_and_close_keep_their_source() { + for close in [false, true] { + let fixture = Fixture::new(); + let (addr, server) = spawn_test_protocol_server().await; + let (mut socket, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &context).await; + configure(&mut socket, &session.session_id, true, false).await; + send_cdp_command( + &mut socket, + 10, + "Target.setDiscoverTargets", + None, + json!({"discover": true}), + ) + .await; + let mut observed = start_document( + &mut socket, + &session.session_id, + &fixture, + if close { + "var w=window.open('/popup');fetch('/api').then(()=>w.close());" + } else { + "var w=window.open('/popup');fetch('/api').then(()=>{w.location.href='/replacement'});" + }, + ) + .await; + let created = |message: &Value| { + message["method"] == "Target.targetCreated" + && message["params"]["targetInfo"]["openerId"] == session.target_id + }; + if !observed.iter().any(created) { + observed.extend(recv_until_match(&mut socket, created).await); + } + let popup = observed.iter().find(|message| created(message)).unwrap()["params"]["targetInfo"]["targetId"].clone(); + let paused = |message: &Value| { + message["method"] == "Fetch.requestPaused" + && message["params"]["request"]["url"] + .as_str() + .is_some_and(|url| url.ends_with("/api")) + }; + if !observed.iter().any(paused) { + observed.extend(recv_until_match(&mut socket, paused).await); + } + let request = + observed.iter().find(|message| paused(message)).unwrap()["params"]["requestId"].clone(); + observed.extend( + send_cdp_command( + &mut socket, + 23, + "Fetch.fulfillRequest", + Some(&session.session_id), + json!({"requestId": request, "responseCode": 200}), + ) + .await, + ); + if close { + let destroyed = |message: &Value| { + message["method"] == "Target.targetDestroyed" + && message["params"]["targetId"] == popup + }; + if !observed.iter().any(destroyed) { + observed.extend(recv_until_match(&mut socket, destroyed).await); + } + } else { + tokio::time::timeout( + Duration::from_secs(10), + fixture.replacement_requested.notified(), + ) + .await + .unwrap(); + let replacement = fixture.url.replace("/page", "/replacement"); + let navigated = |message: &Value| { + message["method"] == "Target.targetInfoChanged" + && message["params"]["targetInfo"]["targetId"] == popup + && message["params"]["targetInfo"]["url"] == replacement + }; + if !observed.iter().any(navigated) { + observed.extend(recv_until_match(&mut socket, navigated).await); + } + } + assert!( + !observed + .iter() + .any(|message| message["method"] == "Page.domContentEventFired" + && message["sessionId"] == session.session_id), + "{observed:?}" + ); + fixture.release.notify_one(); + recv_until_match(&mut socket, |message| { + message["method"] == "Page.loadEventFired" && message["sessionId"] == session.session_id + }) + .await; + let targets = send_cdp_command(&mut socket, 22, "Target.getTargets", None, json!({})).await; + assert_eq!( + targets.last().unwrap()["result"]["targetInfos"] + .as_array() + .unwrap() + .iter() + .any(|target| target["targetId"] == popup), + !close, + "{targets:?}" + ); + let _ = socket.close(None).await; + abort_test_cdp_server(server).await; + } +} diff --git a/moli-protocol/src/conn.rs b/moli-protocol/src/conn.rs index 49f978faac..0e74ea9ce0 100644 --- a/moli-protocol/src/conn.rs +++ b/moli-protocol/src/conn.rs @@ -1991,10 +1991,28 @@ impl CdpConnection { else { return Some(false); }; - Some( - self.commit_loaded_navigation_target_identity_for_owner(owner, commit, &target_url) - .is_some(), - ) + if self + .commit_loaded_navigation_target_identity_for_owner(owner, commit, &target_url) + .is_none() + { + return Some(false); + } + if let Some((renderer_page, _)) = self + .runtime_session_owner_slot_for_owner(owner)? + .page_slot() + .prepared_document_output_binding() + && let Some(attachment) = + self.target_page_output_attachment_for_renderer_page(owner, renderer_page) + && let Some(target_id) = attachment.page_owner().target_id() + && let Some(context) = + self.browser_context_by_id(attachment.page_owner().browser_context_id()) + { + context.close_dedicated_workers_outside_navigation_page( + target_id, + Some(attachment.page_owner()), + ); + } + Some(true) } #[cfg(test)] diff --git a/moli-protocol/src/conn/browser_context/fetch_owner.rs b/moli-protocol/src/conn/browser_context/fetch_owner.rs index b5461fe01e..9353e670a5 100644 --- a/moli-protocol/src/conn/browser_context/fetch_owner.rs +++ b/moli-protocol/src/conn/browser_context/fetch_owner.rs @@ -15,6 +15,10 @@ use crate::conn::{ PendingSubresourceFetchRequest, PendingSubresourceFetchResponseRequest, TargetRuntimeSlot, }; +#[derive(Debug, thiserror::Error)] +#[error("RequestNotFound")] +struct SubresourceRequestNotFound; + pub(crate) type SessionOwnerPendingFetchState = ( Vec, Vec, @@ -449,22 +453,57 @@ impl CdpConnection { .find_map(|browser_context| pending_fetch_request_route(browser_context, request_id)) } + pub(crate) fn start_subresource_document_command( + &self, + owner: &CommandOwnerScope, + page_owner: &crate::conn::TargetPageResidenceIdentity, + target: Option<&moli_core::page::PageDocumentCommandTarget>, + command: moli_core::RendererPageCommand, + ) -> Result { + let target = target.context(SubresourceRequestNotFound)?; + if !self.subresource_document_is_current_for_owner( + owner, + page_owner, + Some(target.document()), + ) { + return Err(SubresourceRequestNotFound.into()); + } + target.start(command) + } + + pub(crate) fn subresource_document_is_current( + &self, + page_owner: &crate::conn::TargetPageResidenceIdentity, + document: Option, + ) -> bool { + self.target_page_output_residence_is_current(page_owner) + && document.is_none_or(|document| { + let owner = CommandOwnerScope::for_page_residence(page_owner); + self.target_root_document_output_identity_for_owner(&owner, document) + .is_some_and(|binding| binding.attachment().page_owner() == page_owner) + }) + } + + pub(crate) fn subresource_document_is_current_for_owner( + &self, + owner: &CommandOwnerScope, + page: &crate::conn::TargetPageResidenceIdentity, + document: Option, + ) -> bool { + self.target_owner_identity_for_owner(owner) + .is_some_and(|(context, target)| { + context == page.browser_context_id() && target.as_deref() == page.target_id() + }) + && self.subresource_document_is_current(page, document) + } + pub(crate) fn pending_subresource_fetch_request_residence_is_current( &self, pending: &PendingSubresourceFetchRequest, ) -> bool { - pending - .page_owner() - .is_none_or(|owner| self.target_page_output_residence_is_current(owner)) - } - - fn installed_subresource_fetch_request_is_current( - &self, - pending: &PendingSubresourceFetchRequest, - ) -> bool { - pending - .page_owner() - .is_some_and(|owner| self.target_page_residence_identity_is_current(owner)) + pending.page_owner().is_none_or(|page| { + self.subresource_document_is_current(page, pending.source_document()) + }) } pub(crate) fn claim_subresource_continue_request_for_owner( @@ -474,7 +513,7 @@ impl CdpConnection { internal_id: u64, allow_pending_completion: bool, ) -> Option { - if !self.target_page_residence_identity_is_current(expected_page_owner) { + if !self.target_page_output_residence_is_current(expected_page_owner) { return None; } self.target_session_owner_mut_for_owner(owner)? @@ -808,7 +847,7 @@ impl CdpConnection { let pending = self .target_session_owner_mut_for_owner(owner)? .take_pending_subresource_fetch_auth_request(request_id, action_session_id)?; - self.target_page_residence_identity_is_current(&pending.page_owner) + self.subresource_document_is_current(&pending.page_owner, pending.source_document()) .then_some(pending) } @@ -821,7 +860,7 @@ impl CdpConnection { let pending = self .target_session_owner_mut_for_owner(owner)? .take_pending_subresource_fetch_response_request(request_id, action_session_id)?; - self.target_page_residence_identity_is_current(&pending.page_owner) + self.subresource_document_is_current(&pending.page_owner, pending.source_document()) .then_some(pending) } @@ -882,7 +921,7 @@ impl CdpConnection { request_id: Option, pending: PendingSubresourceFetchRequest, ) -> bool { - if !self.installed_subresource_fetch_request_is_current(&pending) { + if !self.pending_subresource_fetch_request_residence_is_current(&pending) { return false; } self.record_pending_subresource_network_request_identity_for_owner(owner, &pending); @@ -899,7 +938,7 @@ impl CdpConnection { pending: PendingSubresourceFetchRequest, response_stage_blocked_intercepts: Vec, ) -> bool { - if !self.installed_subresource_fetch_request_is_current(&pending) { + if !self.pending_subresource_fetch_request_residence_is_current(&pending) { return false; } self.record_pending_subresource_network_request_identity_for_owner(owner, &pending); @@ -920,7 +959,7 @@ impl CdpConnection { request_id: Option, pending: PendingSubresourceFetchRequest, ) -> bool { - if !self.installed_subresource_fetch_request_is_current(&pending) { + if !self.pending_subresource_fetch_request_residence_is_current(&pending) { return false; } self.record_pending_subresource_network_request_identity_for_owner(owner, &pending); @@ -950,7 +989,7 @@ impl CdpConnection { request_id: String, pending: PendingSubresourceFetchAuthRequest, ) -> bool { - if !self.target_page_residence_identity_is_current(&pending.page_owner) { + if !self.subresource_document_is_current(&pending.page_owner, pending.source_document()) { return false; } self.record_pending_subresource_auth_network_request_identity_for_owner(owner, &pending); @@ -978,7 +1017,7 @@ impl CdpConnection { request_id: String, pending: PendingSubresourceFetchResponseRequest, ) -> bool { - if !self.target_page_residence_identity_is_current(&pending.page_owner) { + if !self.subresource_document_is_current(&pending.page_owner, pending.source_document()) { return false; } self.record_pending_subresource_response_network_request_identity_for_owner( @@ -1170,21 +1209,12 @@ impl CdpConnection { SessionOwnerPendingFetchState, Option>, )> { - let mut owner = self.target_session_owner_mut_for_owner(command_owner)?; - let (pending, (subresource_enabled, subresource_resource_type), page_update_required) = - owner - .reset_fetch_config_for_session_and_drain_pending_state(command_owner.session_id()); - let page_command = if page_update_required - && let Some(page) = owner.runtime_slot_mut().loaded_page_mut() - { - Some(page.start_set_fetch_subresource_interception( - subresource_enabled, - subresource_resource_type, - )) - } else { - None - }; - Some((pending, page_command)) + let (pending, (enabled, resource_type), update_required) = self + .target_session_owner_mut_for_owner(command_owner)? + .reset_fetch_config_for_session_and_drain_pending_state(command_owner.session_id()); + let policy = + self.start_fetch_cleanup_policy(command_owner, update_required, enabled, resource_type); + Some((pending, policy)) } pub(crate) fn start_disable_target_fetch_for_owner( @@ -1194,18 +1224,54 @@ impl CdpConnection { SessionOwnerPendingFetchState, Option>, )> { - let mut owner = self.target_session_owner_mut_for_owner(command_owner)?; - let (pending, (enabled, resource_type), update_required) = - owner.disable_target_fetch_and_drain_pending_state(); + let (pending, (enabled, resource_type), update_required) = self + .target_session_owner_mut_for_owner(command_owner)? + .disable_target_fetch_and_drain_pending_state(); let policy = - if update_required && let Some(page) = owner.runtime_slot_mut().loaded_page_mut() { - Some(page.start_set_fetch_subresource_interception(enabled, resource_type)) - } else { - None - }; + self.start_fetch_cleanup_policy(command_owner, update_required, enabled, resource_type); Some((pending, policy)) } + fn start_fetch_cleanup_policy( + &mut self, + owner: &CommandOwnerScope, + update_required: bool, + enabled: bool, + resource_type: Option, + ) -> Option> { + if !update_required { + return None; + } + let source = self + .runtime_session_owner_slot_for_owner(owner)? + .page_slot() + .prepared_document_output_binding() + .map(|(page, binding)| (page, binding.renderer_document_identity())); + if let Some((renderer_page, document)) = source { + let target: Result<_> = (|| { + let attachment = self + .target_page_output_attachment_for_renderer_page(owner, renderer_page) + .ok_or(crate::conn::NoDocumentLoaded)?; + self.capture_document_output_command_target( + owner, + attachment.page_owner(), + renderer_page, + document, + ) + })(); + return Some(target.and_then(|target| { + target.start( + moli_core::RendererPageCommand::SetFetchSubresourceInterception { + enabled, + resource_type, + }, + ) + })); + } + self.loaded_page_mut_for_owner(owner) + .map(|page| page.start_set_fetch_subresource_interception(enabled, resource_type)) + } + pub(crate) fn take_pending_fetch_state_for_owner( &mut self, owner: &CommandOwnerScope, diff --git a/moli-protocol/src/conn/browser_context/target_session_owner.rs b/moli-protocol/src/conn/browser_context/target_session_owner.rs index 15490d4b17..af7bf415a6 100644 --- a/moli-protocol/src/conn/browser_context/target_session_owner.rs +++ b/moli-protocol/src/conn/browser_context/target_session_owner.rs @@ -754,6 +754,16 @@ impl<'a> TargetSessionOwnerMut<'a> { fn page_snapshot(&self) -> Option<(String, String)> { let target = self.target(); + // Native history already describes the new Document. Until adoption, + // the loaded Page's cached title still belongs to its predecessor. + if target + .runtime_slot() + .page_slot() + .prepared_document_output_binding() + .is_some() + { + return None; + } target .runtime_slot() .loaded_page() @@ -896,6 +906,7 @@ impl<'a> TargetSessionOwnerMut<'a> { let target = self.target_mut(); target.owner_state.target_crash_state.mark_crashed(); target.owner_state.navigation_history_state.clear(); + target.owner_state.navigation_history_document = None; target.owner_state.clear_loaded_document_context_state(); clear_page_loaded_document_session_state(target); target.fetch_owner.clear_pending(); @@ -1141,13 +1152,63 @@ impl<'a> TargetSessionOwnerMut<'a> { let security_origin = main_document_commit.security_origin.clone(); let secure_context_type = main_document_commit.secure_context_type.clone(); let target = self.target_mut(); - target.set_target_url(next_url); + let native_source = target + .runtime_slot + .page_slot() + .prepared_document_output_binding() + .map(|(page, binding)| (page, binding.renderer_document_identity())) + .or_else(|| { + Some(( + crate::conn::RendererPageResidenceIdentity::from_page( + target.runtime_slot.loaded_page()?, + ), + target + .runtime_slot + .page_slot() + .renderer_document_lifecycle_binding()? + .renderer_document_identity(), + )) + }); + if native_source.is_none() + || target.owner_state.navigation_history_document != native_source + { + target.set_target_url(next_url); + } target.set_target_security_origin(security_origin); target.set_target_secure_context_type(secure_context_type); target.owner_state.mark_initial_empty_document_exited(); Some(()) } + pub(super) fn record_native_navigation_history( + &mut self, + renderer_page: crate::conn::RendererPageResidenceIdentity, + document: moli_core::RendererDocumentLifecycleIdentity, + url: &Url, + ) { + let group = self + .browser_context + .renderer_runtime() + .browsing_context_name(&self.target_id) + .group(); + let target = self.target_mut(); + if target.owner_state.navigation_history_document == Some((renderer_page, document)) { + return; + } + if let Some(previous_title) = target.owner_state.committed_document_title.take() { + target + .owner_state + .refresh_current_navigation_history_title(previous_title); + } + target + .owner_state + .record_loaded_page_navigation_history_with_group( + (url.to_string(), String::new()), + Some(group), + ); + target.owner_state.navigation_history_document = Some((renderer_page, document)); + } + pub(super) fn clear_pending_navigation_history_update(&mut self) -> Option<()> { self.target_mut() .owner_state @@ -1355,8 +1416,17 @@ impl CdpConnection { main_document_commit: &RendererMainDocumentCommit, target_url: &Url, ) -> Option<()> { - self.target_session_owner_mut_for_owner(owner)? - .commit_loaded_navigation_target_identity(main_document_commit, target_url) + let prepared_source = self + .runtime_session_owner_slot_for_owner(owner)? + .page_slot() + .prepared_document_output_binding() + .map(|(page, binding)| (page, binding.renderer_document_identity())); + let mut target = self.target_session_owner_mut_for_owner(owner)?; + target.commit_loaded_navigation_target_identity(main_document_commit, target_url)?; + if let Some((page, document)) = prepared_source { + target.record_native_navigation_history(page, document, target_url); + } + Some(()) } pub(crate) fn install_loaded_navigation_page_for_owner( @@ -1958,19 +2028,41 @@ impl CdpConnection { &self, expected: &TargetPageResidenceIdentity, ) -> bool { - if self.target_page_residence_identity_is_current(expected) { - return true; - } - let owner = CommandOwnerScope::for_page_residence(expected); - self.pending_target_page_residence_identity_for_owner(&owner) - .as_ref() - == Some(expected) - && self - .runtime_session_owner_slot_for_owner(&owner) - .and_then(|slot| slot.page_slot().prepared_document_output_binding()) - .is_some_and(|(_, binding)| { - binding.page_attachment_id == expected.page_attachment_id() + self.browser_context_by_id(expected.browser_context_id()) + .is_some_and(|context| context.target_page_output_residence_is_current(expected)) + } + + /// Captures a revocable native capability only after the publication's + /// original Page identity has been admitted. Current Page access remains + /// guarded separately while a navigation is awaiting adoption. + pub(crate) fn capture_document_output_command_target( + &mut self, + owner: &CommandOwnerScope, + page_owner: &crate::conn::TargetPageResidenceIdentity, + renderer_page: crate::conn::RendererPageResidenceIdentity, + document: moli_core::RendererDocumentLifecycleIdentity, + ) -> Result { + if document.frame.page_id != renderer_page.page_id() + || !self + .runtime_session_owner_slot_for_owner(owner) + .is_some_and(|slot| { + slot.routes_current_renderer_page_owner( + renderer_page, + page_owner.page_attachment_id(), + ) }) + { + return Err(crate::conn::NoDocumentLoaded.into()); + } + if let Some(page) = self.loaded_page_mut_for_owner(owner) + && crate::conn::RendererPageResidenceIdentity::from_page(page) == renderer_page + { + return page.document_command_target(document); + } + let inputs = self.navigation_load_inputs_for_owner(owner); + self.navigation_engine_for_load_inputs_mut(&inputs) + .ok_or(crate::conn::NoDocumentLoaded)? + .document_command_target(renderer_page.owner_local_host_id(), document) } pub(crate) fn target_page_output_attachment_for_renderer_page( @@ -2243,6 +2335,20 @@ impl CdpConnection { browser_context_id: &str, target_id: &str, ) -> Option { + let owner = self.target_page_output_owner_for_target(browser_context_id, target_id)?; + let attachment = self.target_page_protocol_attachment_identity_for_owner(&owner)?; + (attachment.page_owner().browser_context_id() == browser_context_id + && attachment.page_owner().target_id() == Some(target_id)) + .then_some(attachment) + } + + /// Selects a frontend destination independently of Page-handle adoption. + /// This resolves session configuration and output routing, not Page access. + pub(crate) fn target_page_output_owner_for_target( + &self, + browser_context_id: &str, + target_id: &str, + ) -> Option { let browser_context = self.browser_context_by_id(browser_context_id)?; let primary_session_id = if browser_context.is_active_target(target_id) { browser_context.active_session_id_owned() @@ -2268,11 +2374,21 @@ impl CdpConnection { .is_some_and(|active| active.id == browser_context_id)) .then_some(None) })?; - let attachment = - self.target_page_protocol_attachment_identity_for_session(session_id.as_deref())?; - (attachment.page_owner().browser_context_id() == browser_context_id - && attachment.page_owner().target_id() == Some(target_id)) - .then_some(attachment) + let owner = session_id + .as_deref() + .map(CommandOwnerScope::for_session) + .unwrap_or_else(|| { + CommandOwnerScope::for_route(CdpSessionRoute::PageTarget { + browser_context_id: browser_context_id.to_owned(), + target_id: target_id.to_owned(), + session_key: DevToolsSessionKey::Primary, + }) + }); + self.target_owner_identity_for_owner(&owner) + .is_some_and(|(context, target)| { + context == browser_context_id && target.as_deref() == Some(target_id) + }) + .then_some(owner) } pub(crate) fn runtime_context_owner_identity_for_session( @@ -2821,6 +2937,56 @@ mod tests { use moli_core::page::SubresourceResourceType; use url::Url; + #[tokio::test] + async fn native_commit_reusing_initial_document_keeps_navigation_history() { + let mut ctx = TestContext::new(); + let mut browser = BrowserContext::new("BID-initial-history".into()); + browser.set_active_target_id("TID-initial-history"); + browser.attach_active_session("SID-initial-history"); + ctx.with_fixture(|conn| conn.install_browser_context_fixture_for_test(browser)); + ctx.install_navigation_fixture_for_session_owner( + "about:blank", + Some("SID-initial-history"), + ) + .await; + ctx.with_fixture(|conn| { + let owner = CommandOwnerScope::for_session("SID-initial-history"); + // An initial auxiliary Document seeds history without a navigation + // commit. Preserve its real Page/Document, not a fake native id. + conn.target_session_owner_mut_for_owner(&owner) + .unwrap() + .target_mut() + .owner_state + .navigation_history_document = None; + let before = conn + .target_session_owner_navigation_history_snapshot_for_owner(&owner) + .unwrap(); + let page = conn + .renderer_page_residence_identity_for_owner(&owner) + .unwrap(); + let document = conn + .target_root_document_lifecycle_identity_for_owner(&owner) + .unwrap(); + conn.start_document_navigation_for_owner(&owner, "LID-reused-blank".into()) + .unwrap(); + // Force publication before protocol adopts a navigation result. + conn.target_session_owner_mut_for_owner(&owner) + .unwrap() + .record_native_navigation_history( + page, + document, + &Url::parse("about:blank").unwrap(), + ); + let after = conn + .target_session_owner_navigation_history_snapshot_for_owner(&owner) + .unwrap(); + assert_eq!( + after, before, + "reusing the initial native Document keeps its existing entry" + ); + }); + } + #[test] fn generated_session_id_skips_caller_supplied_live_session() { let mut conn = CdpConnection::default(); diff --git a/moli-protocol/src/conn/fetch_support.rs b/moli-protocol/src/conn/fetch_support.rs index 9a9bce176e..625123302b 100644 --- a/moli-protocol/src/conn/fetch_support.rs +++ b/moli-protocol/src/conn/fetch_support.rs @@ -1369,6 +1369,10 @@ async fn capture_streaming_raw_response( #[derive(Debug, Clone, PartialEq)] pub(crate) enum PendingSubresourceFetchResidence { Page(super::state::TargetPageResidenceIdentity), + Document { + page_owner: super::state::TargetPageResidenceIdentity, + command_target: moli_core::page::PageDocumentCommandTarget, + }, DetachedParserScript(DetachedParserScriptFetchContinuation), } @@ -1391,16 +1395,40 @@ pub struct PendingSubresourceFetchRequest { impl PendingSubresourceFetchRequest { pub(crate) fn page_owner(&self) -> Option<&super::state::TargetPageResidenceIdentity> { match &self.residence { - PendingSubresourceFetchResidence::Page(owner) => Some(owner), + PendingSubresourceFetchResidence::Page(owner) + | PendingSubresourceFetchResidence::Document { + page_owner: owner, .. + } => Some(owner), PendingSubresourceFetchResidence::DetachedParserScript(_) => None, } } + pub(crate) fn document_command_target( + &self, + ) -> Option<&moli_core::page::PageDocumentCommandTarget> { + match &self.residence { + PendingSubresourceFetchResidence::Document { command_target, .. } => { + Some(command_target) + } + _ => None, + } + } + + pub(crate) fn source_document(&self) -> Option { + match &self.residence { + PendingSubresourceFetchResidence::Document { command_target, .. } => { + Some(command_target.document()) + } + _ => None, + } + } + pub(crate) fn detached_parser_script_fetch_continuation( &self, ) -> Option<&DetachedParserScriptFetchContinuation> { match &self.residence { - PendingSubresourceFetchResidence::Page(_) => None, + PendingSubresourceFetchResidence::Page(_) + | PendingSubresourceFetchResidence::Document { .. } => None, PendingSubresourceFetchResidence::DetachedParserScript(continuation) => { Some(continuation) } @@ -1514,6 +1542,7 @@ pub(crate) enum ClaimedSubresourceContinueRequest { pub struct PendingSubresourceFetchAuthRequest { /// Page residence that owns the paused renderer authentication request. pub(crate) page_owner: super::state::TargetPageResidenceIdentity, + pub(crate) command_target: Option, pub owner_session_id: Option, pub action_session_id: Option, pub owner_kind: PendingSubresourceFetchOwnerKind, @@ -1535,6 +1564,10 @@ pub struct PendingSubresourceFetchAuthRequest { } impl PendingSubresourceFetchAuthRequest { + pub(crate) fn source_document(&self) -> Option { + self.command_target.as_ref().map(|target| target.document()) + } + pub fn pop_next_auth_required_pause(&mut self) -> Option { let chain = self.auth_stage_chain.as_mut()?; let next = chain.remaining_sessions.first().cloned()?; @@ -1564,6 +1597,7 @@ pub struct PendingSubresourceFetchAuthStage { pub struct PendingSubresourceFetchResponseRequest { /// Page residence that owns the paused renderer response request. pub(crate) page_owner: super::state::TargetPageResidenceIdentity, + pub(crate) command_target: Option, pub owner_session_id: Option, pub action_session_id: Option, pub owner_kind: PendingSubresourceFetchOwnerKind, @@ -1589,6 +1623,10 @@ pub struct PendingSubresourceFetchResponseRequest { } impl PendingSubresourceFetchResponseRequest { + pub(crate) fn source_document(&self) -> Option { + self.command_target.as_ref().map(|target| target.document()) + } + pub fn pop_next_response_stage_pause( &mut self, ) -> Option { diff --git a/moli-protocol/src/conn/page_state/loaded.rs b/moli-protocol/src/conn/page_state/loaded.rs index b004aed3e4..2ded06328e 100644 --- a/moli-protocol/src/conn/page_state/loaded.rs +++ b/moli-protocol/src/conn/page_state/loaded.rs @@ -216,20 +216,37 @@ impl PageTargetHost { browsing_context_group: moli_core::page::RendererBrowsingContextGroup, ) { self.owner_state.mark_initial_empty_document_exited(); - if let Some(previous_title) = previous_title { - self.owner_state - .refresh_current_navigation_history_title(previous_title); - } - self.owner_state - .record_loaded_page_navigation_history_with_group( - (history_url.to_string(), committed_document_title.clone()), - Some(browsing_context_group), - ); - let native_document = self + let native_source = self .runtime_slot .page_slot() .prepared_document_output_binding() - .map(|(_, binding)| binding.renderer_document_identity()); + .map(|(page, binding)| (page, binding.renderer_document_identity())); + let already_committed = native_source.is_some() + && self.owner_state.navigation_history_document == native_source; + let committed_document_title = if already_committed { + self.owner_state + .committed_document_title + .clone() + .unwrap_or(committed_document_title) + } else { + committed_document_title + }; + if already_committed { + self.owner_state + .refresh_current_navigation_history_title(committed_document_title.clone()); + } else { + if let Some(previous_title) = previous_title { + self.owner_state + .refresh_current_navigation_history_title(previous_title); + } + self.owner_state + .record_loaded_page_navigation_history_with_group( + (history_url.to_string(), committed_document_title.clone()), + Some(browsing_context_group), + ); + self.owner_state.navigation_history_document = native_source; + } + let native_document = native_source.map(|(_, document)| document); // Concrete child output may precede Page-handle adoption. Retain only // the tree already observed for this exact Native Document. self.owner_state diff --git a/moli-protocol/src/conn/page_state/page_targets.rs b/moli-protocol/src/conn/page_state/page_targets.rs index 40357256d4..e124c3d974 100644 --- a/moli-protocol/src/conn/page_state/page_targets.rs +++ b/moli-protocol/src/conn/page_state/page_targets.rs @@ -984,6 +984,56 @@ impl BrowserContext { current_attachment == Some(expected.page_attachment_id()) } + pub(crate) fn target_page_output_residence_is_current( + &self, + expected: &crate::conn::TargetPageResidenceIdentity, + ) -> bool { + if self.target_page_residence_is_current(expected) { + return true; + } + if expected.browser_context_id() != self.id { + return false; + } + let target = match expected.target_id() { + Some(id) => self.page_target(id), + None if self.active_target_id().is_none() => Some(self.active_page_target()), + None => None, + }; + target.is_some_and(|target| { + let slot = &target.runtime_slot; + slot.pending_page_attachment_id() == Some(expected.page_attachment_id()) + && slot + .page_slot() + .prepared_document_output_binding() + .is_some_and(|(_, binding)| { + binding.page_attachment_id == expected.page_attachment_id() + }) + }) + } + + /// Revoke workers of abandoned Page sources without waiting for their + /// parsers to finish. A native commit also retires the previous loaded + /// Page, which may still accept late network output until adoption. + pub(crate) fn close_dedicated_workers_outside_navigation_page( + &self, + target_id: &str, + committed_page: Option<&crate::conn::TargetPageResidenceIdentity>, + ) { + for worker in self.dedicated_worker_targets.values().filter(|worker| { + worker.owner_page.target_id() == Some(target_id) + && !worker.is_retiring() + && match committed_page { + Some(page) => &worker.owner_page != page, + None => !self.target_page_output_residence_is_current(&worker.owner_page), + } + }) { + self.renderer_runtime() + .close_dedicated_worker_for_devtools(worker.renderer_instance_id); + } + // The original Page journal publishes Destroyed. Its retiring-output + // route owns session disposal and the target's terminal events. + } + pub(crate) fn dedicated_worker_target_id_for_renderer_instance( &self, renderer_instance_id: u64, @@ -1021,7 +1071,8 @@ impl BrowserContext { let Some(renderer_instance_id) = self .dedicated_worker_target(target_id) .filter(|target| { - !target.is_retiring() && self.target_page_residence_is_current(&target.owner_page) + !target.is_retiring() + && self.target_page_output_residence_is_current(&target.owner_page) }) .map(|target| target.renderer_instance_id) else { diff --git a/moli-protocol/src/conn/state/browser_context.rs b/moli-protocol/src/conn/state/browser_context.rs index 0074dd11b4..bfde4b9f59 100644 --- a/moli-protocol/src/conn/state/browser_context.rs +++ b/moli-protocol/src/conn/state/browser_context.rs @@ -1195,9 +1195,31 @@ impl BrowserContext { loader_id: String, ) -> Option { let target = self.page_target_mut(target_id)?; + // Initial Documents already own their placeholder history entry. + // Freeze that native identity before a new handoff can replace the + // installed binding. Reusing the same Document must not append it. + if !target.owner_state.navigation_history_state.is_empty() + && target + .runtime_slot + .page_slot() + .prepared_document_output_binding() + .is_none() + && let Some((page, binding)) = target.runtime_slot.loaded_page().zip( + target + .runtime_slot + .page_slot() + .renderer_document_lifecycle_binding(), + ) + { + target.owner_state.navigation_history_document = Some(( + super::RendererPageResidenceIdentity::from_page(page), + binding.renderer_document_identity(), + )); + } let token = target .runtime_slot .start_document_navigation(target_id.to_owned(), loader_id); + self.close_dedicated_workers_outside_navigation_page(target_id, None); self.mark_target_initial_empty_document_pending_cross_document_navigation(target_id); Some(token) } @@ -1283,6 +1305,7 @@ impl BrowserContext { .clear_pending_document_navigation_if_loader_matches(loader_id) }); if cleared { + self.close_dedicated_workers_outside_navigation_page(target_id, None); self.clear_target_initial_empty_document_pending_cross_document_navigation(target_id); } } diff --git a/moli-protocol/src/conn/state/fetch.rs b/moli-protocol/src/conn/state/fetch.rs index 37105bf688..3b9fee3253 100644 --- a/moli-protocol/src/conn/state/fetch.rs +++ b/moli-protocol/src/conn/state/fetch.rs @@ -2428,6 +2428,7 @@ mod tests { owner_kind: PendingSubresourceFetchOwnerKind, ) -> PendingSubresourceFetchAuthRequest { PendingSubresourceFetchAuthRequest { + command_target: None, page_owner: test_page_owner(), owner_session_id: owner_session_id.map(str::to_owned), action_session_id: owner_session_id.map(str::to_owned), @@ -2472,6 +2473,7 @@ mod tests { owner_kind: PendingSubresourceFetchOwnerKind, ) -> PendingSubresourceFetchResponseRequest { PendingSubresourceFetchResponseRequest { + command_target: None, page_owner: test_page_owner(), owner_session_id: owner_session_id.map(str::to_owned), action_session_id: owner_session_id.map(str::to_owned), diff --git a/moli-protocol/src/conn/state/runtime_slot.rs b/moli-protocol/src/conn/state/runtime_slot.rs index 8b7174e401..27d5895da8 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -753,6 +753,15 @@ impl TargetRuntimeSlot { } } + pub(crate) fn prepared_document_output_is_from( + &self, + source: moli_core::RendererDocumentLifecycleIdentity, + ) -> bool { + self.prepared_document_output + .as_ref() + .is_some_and(|output| output.binding.renderer_document_identity() == source) + } + pub(crate) fn complete_prepared_document_output_adoption(&mut self) { if self .prepared_document_output diff --git a/moli-protocol/src/conn/state/target_state.rs b/moli-protocol/src/conn/state/target_state.rs index c1d6a15ee6..7e91d231d9 100644 --- a/moli-protocol/src/conn/state/target_state.rs +++ b/moli-protocol/src/conn/state/target_state.rs @@ -528,6 +528,10 @@ pub(crate) struct TargetOwnerState { pub(crate) next_document_start_script_id: u32, pub(crate) document_start_scripts: Vec<(String, DocumentStartScript)>, pub(crate) navigation_history_state: TargetNavigationHistoryState, + pub(crate) navigation_history_document: Option<( + crate::conn::RendererPageResidenceIdentity, + moli_core::RendererDocumentLifecycleIdentity, + )>, pub(crate) page_resource_store: TargetPageResourceStore, pub(crate) runtime_observable_state: TargetRuntimeObservableState, pub(crate) console_output_state: TargetConsoleOutputState, diff --git a/moli-protocol/src/domains/activity/main_document.rs b/moli-protocol/src/domains/activity/main_document.rs index bdd2a4adb7..238bc01b8f 100644 --- a/moli-protocol/src/domains/activity/main_document.rs +++ b/moli-protocol/src/domains/activity/main_document.rs @@ -33,7 +33,10 @@ pub(crate) struct MainDocumentNavigationActivity { enum LoadedNavigationResultMode { Success, - NetworkErrorPage { error_text: String }, + NetworkErrorPage { + error_text: String, + commit: std::sync::Arc, + }, } pub(crate) struct MainDocumentFailedNavigationActivity { @@ -86,8 +89,12 @@ impl MainDocumentNavigationActivity { } } - pub(crate) fn with_network_error_page_result(mut self, error_text: String) -> Self { - self.result_mode = LoadedNavigationResultMode::NetworkErrorPage { error_text }; + pub(crate) fn with_network_error_page_result( + mut self, + error_text: String, + commit: std::sync::Arc, + ) -> Self { + self.result_mode = LoadedNavigationResultMode::NetworkErrorPage { error_text, commit }; self } @@ -130,7 +137,9 @@ impl MainDocumentNavigationActivity { } let network_error_text = match &self.result_mode { LoadedNavigationResultMode::Success => None, - LoadedNavigationResultMode::NetworkErrorPage { error_text } => Some(error_text.clone()), + LoadedNavigationResultMode::NetworkErrorPage { error_text, .. } => { + Some(error_text.clone()) + } }; if let Some(error_text) = network_error_text.as_deref() { let mut failure_events = Vec::new(); @@ -161,6 +170,11 @@ impl MainDocumentNavigationActivity { self.expose_loaded_response_metadata(&mut response_metadata_events); out.extend_background_events_after_messages(response_metadata_events); } + if let LoadedNavigationResultMode::NetworkErrorPage { commit, .. } = &self.result_mode { + out.extend_background_events_after_messages( + page::main_document_commit_background_events(conn, &self.state.owner, commit), + ); + } if let Some(renderer_output_boundary) = renderer_output_boundary { // Chromium can send Page.navigate/Fetch command responses and the // main-resource response metadata before Blink exposes the new diff --git a/moli-protocol/src/domains/activity/output_ingress/prepared_outputs.rs b/moli-protocol/src/domains/activity/output_ingress/prepared_outputs.rs index 0f8e2e86b0..4f2216a4cc 100644 --- a/moli-protocol/src/domains/activity/output_ingress/prepared_outputs.rs +++ b/moli-protocol/src/domains/activity/output_ingress/prepared_outputs.rs @@ -375,12 +375,19 @@ impl PreparedProtocolOutputs { conn: &mut CdpConnection, owner: &CommandOwnerScope, action: RendererOwnerAction, + page_source: Option<( + crate::conn::TargetPageResidenceIdentity, + crate::conn::RendererPageResidenceIdentity, + )>, ) -> Self { let mut prepared = Self::empty(); match action { RendererOwnerAction::FileChooser(activation) => { crate::domains::input::InputPreparedOutputs::from_renderer_file_chooser_activation( - conn, owner, activation, + conn, + owner, + page_source, + activation, ) .append_to_output_sink(&mut prepared); } @@ -392,13 +399,17 @@ impl PreparedProtocolOutputs { } RendererOwnerAction::JavaScriptDialog(dialog) => { crate::domains::page::PagePreparedOutputs::from_renderer_javascript_dialog( - conn, owner, dialog, + conn, + owner, + page_source.map(|(page, _)| page), + dialog, ) .append_to_javascript_dialog_output_sink(&mut prepared); } RendererOwnerAction::CloseAuxiliaryWindow(window) => { crate::domains::page::PagePreparedOutputs::from_renderer_auxiliary_window_close( - conn, owner, window, + page_source.map(|(page, _)| page), + window, ) .append_to_auxiliary_window_close_output_sink(&mut prepared); } @@ -409,13 +420,14 @@ impl PreparedProtocolOutputs { document_response, } => { crate::domains::page::PagePreparedOutputs::from_renderer_auxiliary_window_navigation( - conn, owner, window, url, kind, document_response, + page_source.map(|(page, _)| page), window, url, kind, document_response, ) .append_to_auxiliary_window_navigation_output_sink(&mut prepared); } RendererOwnerAction::Popup(activation) => { crate::domains::page::PagePreparedOutputs::from_renderer_popup_activation( - conn, owner, activation, + page_source.map(|(page, _)| page), + activation, ) .append_to_popup_output_sink(&mut prepared); } @@ -474,8 +486,7 @@ impl PreparedProtocolOutputs { update, } => { crate::domains::page::PagePreparedOutputs::from_renderer_session_history_update( - conn, - owner, + page_source.map(|(page, _)| page), source_document, update, ) @@ -483,22 +494,21 @@ impl PreparedProtocolOutputs { } RendererOwnerAction::SameDocumentNavigation(navigation) => { crate::domains::page::PagePreparedOutputs::from_renderer_same_document_navigation( - conn, owner, navigation, + page_source.map(|(page, _)| page), + navigation, ) .append_to_same_document_navigation_output_sink(&mut prepared); } RendererOwnerAction::TopLevelLocationNavigation(navigation) => { crate::domains::page::PagePreparedOutputs:: from_renderer_top_level_location_navigation( - conn, - owner, - navigation, + page_source.map(|(page, _)| page), navigation, ) .append_to_top_level_location_navigation_output_sink(&mut prepared); } RendererOwnerAction::TopLevelHistoryTraversal(traversal) => { crate::domains::page::PagePreparedOutputs:: - from_renderer_top_level_history_traversal(conn, owner, traversal) + from_renderer_top_level_history_traversal(page_source.map(|(page, _)| page), owner, traversal) .append_to_top_level_history_traversal_output_sink(&mut prepared); } RendererOwnerAction::SubresourceFetchPause { @@ -511,6 +521,7 @@ impl PreparedProtocolOutputs { owner, source_document, *info, + page_source, ) .append_to_output_sink(&mut prepared); } @@ -573,6 +584,7 @@ impl PreparedProtocolOutputs { dedicated_worker_target_lifecycle_prepared_outputs_for_event( conn, owner, + page_source, event, ) .append_to_dedicated_worker_target_lifecycle_output_sink(&mut prepared); diff --git a/moli-protocol/src/domains/activity/output_ingress/projection.rs b/moli-protocol/src/domains/activity/output_ingress/projection.rs index d6f80876eb..5414deff6d 100644 --- a/moli-protocol/src/domains/activity/output_ingress/projection.rs +++ b/moli-protocol/src/domains/activity/output_ingress/projection.rs @@ -102,6 +102,14 @@ impl RendererOutputProjection { record: RendererOutputRecord, context: &mut CommandWorkflowContext, ) -> Option { + let page_source = match &publication_owner { + RendererPublicationOwner::PageTarget { + page_owner, + renderer_page, + .. + } => Some((page_owner.clone(), *renderer_page)), + _ => None, + }; let route = publication_owner.resolve(conn); let (owner, projection) = match route { Some(RendererPublicationRoute::AttachedSession { @@ -116,11 +124,21 @@ impl RendererOutputProjection { projection, }) => (Some(CommandOwnerScope::for_route(owner_route)), projection), None => ( - None, + page_source + .as_ref() + .map(|(page, _)| CommandOwnerScope::for_page_residence(page)), RendererPublicationProjection::RetiringNetworkAndResponses, ), }; - Self::prepare_record_for_route(conn, owner, projection, cursor, record, context) + Self::prepare_record_for_route( + conn, + owner, + projection, + cursor, + record, + context, + page_source, + ) } pub(super) fn prepare_record_for_route( @@ -130,6 +148,10 @@ impl RendererOutputProjection { cursor: moli_core::RendererOutputCursor, record: RendererOutputRecord, context: &mut CommandWorkflowContext, + page_source: Option<( + crate::conn::TargetPageResidenceIdentity, + crate::conn::RendererPageResidenceIdentity, + )>, ) -> Option { let (cause, mut item) = record.into_parts(); if !projection.admit_record(&mut item) { @@ -149,8 +171,12 @@ impl RendererOutputProjection { RendererOutputItem::OwnerAction(action) => { let owner = super::renderer::renderer_owner_action_owner(conn, &owner, cause.as_ref()); - let outputs = - PreparedProtocolOutputs::from_renderer_owner_action(conn, &owner, action); + let outputs = PreparedProtocolOutputs::from_renderer_owner_action( + conn, + &owner, + action, + page_source, + ); (owner, outputs) } RendererOutputItem::Observation(observation) => { diff --git a/moli-protocol/src/domains/activity/output_ingress/renderer.rs b/moli-protocol/src/domains/activity/output_ingress/renderer.rs index b7e3af6b30..1e2ba821e5 100644 --- a/moli-protocol/src/domains/activity/output_ingress/renderer.rs +++ b/moli-protocol/src/domains/activity/output_ingress/renderer.rs @@ -25,9 +25,7 @@ pub(super) fn renderer_owner_action_owner( let Some(target_id) = target_id else { return publication_owner.clone(); }; - conn.target_page_protocol_attachment_identity_for_target(&browser_context_id, &target_id) - .as_ref() - .map(CommandOwnerScope::for_page_attachment) + conn.target_page_output_owner_for_target(&browser_context_id, &target_id) .unwrap_or_else(|| publication_owner.clone()) } @@ -75,6 +73,7 @@ mod tests { cursor, record, &mut context, + None, ) }) }) diff --git a/moli-protocol/src/domains/activity/publication_route.rs b/moli-protocol/src/domains/activity/publication_route.rs index ef19f66ada..18ea71bebf 100644 --- a/moli-protocol/src/domains/activity/publication_route.rs +++ b/moli-protocol/src/domains/activity/publication_route.rs @@ -63,6 +63,11 @@ impl RendererPublicationProjection { } match item { RendererOutputItem::NativeTerminal(_) => true, + RendererOutputItem::OwnerAction( + moli_core::RendererOwnerAction::DedicatedWorkerTargetLifecycle( + moli_core::page::RendererDedicatedWorkerTargetEvent::Destroyed { .. }, + ), + ) => true, RendererOutputItem::Observation(RendererProtocolObservation::Network { .. }) => true, RendererOutputItem::Observation(RendererProtocolObservation::RuntimeInspector( batch, diff --git a/moli-protocol/src/domains/activity/runtime_command_barrier.rs b/moli-protocol/src/domains/activity/runtime_command_barrier.rs index b3933a9c44..4f4ee1c9b8 100644 --- a/moli-protocol/src/domains/activity/runtime_command_barrier.rs +++ b/moli-protocol/src/domains/activity/runtime_command_barrier.rs @@ -785,6 +785,12 @@ mod tests { let (command_owner, renderer_cause, outputs) = ctx.with_fixture(|conn| { let command_owner = CommandOwnerScope::for_session(SESSION_ID); let source_document = loaded_page_source_document(conn); + let page_source = Some(( + conn.target_page_residence_identity_for_owner(&command_owner) + .expect("test publication Page attachment"), + conn.renderer_page_residence_identity_for_owner(&command_owner) + .expect("test publication renderer Page"), + )); let outputs = super::super::output_ingress::PreparedProtocolOutputs::from_renderer_owner_action( conn, @@ -798,6 +804,7 @@ mod tests { }, ), ), + page_source, ); let renderer_cause = permit.map(|permit| renderer_cause_for_permit(conn, permit)); diff --git a/moli-protocol/src/domains/activity/subresource.rs b/moli-protocol/src/domains/activity/subresource.rs index ceaa5411ec..362990d920 100644 --- a/moli-protocol/src/domains/activity/subresource.rs +++ b/moli-protocol/src/domains/activity/subresource.rs @@ -8,7 +8,7 @@ use crate::conn::{ PendingSubresourceFetchResponseStageChain, TargetPageResidenceIdentity, }; use crate::domains::fetch; -use anyhow::{Context as _, Result}; +use anyhow::Result; use moli_core::page::{ PendingSubresourceAuthInfo, PendingSubresourceContinueEvent, PendingSubresourceResponseInfo, SubresourceNetworkRequestHandle, @@ -75,12 +75,9 @@ pub(in crate::domains) fn prepare_subresource_continue_action_for_renderer_recor source_document: moli_core::RendererDocumentLifecycleIdentity, event: PendingSubresourceContinueEvent, ) -> Option { - if conn.target_root_document_lifecycle_identity_for_owner(command_owner) - != Some(source_document) - { - return None; - } - let owner = conn.target_page_residence_identity_for_owner(command_owner)?; + let binding = + conn.target_root_document_output_identity_for_owner(command_owner, source_document)?; + let owner = binding.attachment().page_owner().clone(); Some(PreparedSubresourceContinueAction::capture( conn, command_owner, @@ -95,7 +92,7 @@ pub(crate) fn prepare_subresource_continue_action( command_owner: &CommandOwnerScope, action: PreparedSubresourceContinueAction, ) -> Option { - if !conn.target_page_residence_identity_is_current(&action.owner) { + if !conn.target_page_output_residence_is_current(&action.owner) { return None; } let session_id = command_owner.session_id(); @@ -388,9 +385,10 @@ fn pending_auth_request( auth_info: &PendingSubresourceAuthInfo, ) -> PendingSubresourceFetchAuthRequest { PendingSubresourceFetchAuthRequest { + command_target: pending.document_command_target().cloned(), page_owner: pending .page_owner() - .expect("an in-flight auth continuation must belong to an installed Page") + .expect("an in-flight auth continuation must retain its native Page residence") .clone(), owner_session_id: pending.owner_session_id.clone(), action_session_id: pending.action_session_id.clone(), @@ -423,9 +421,10 @@ fn pending_response_request( response_info: &PendingSubresourceResponseInfo, ) -> PendingSubresourceFetchResponseRequest { PendingSubresourceFetchResponseRequest { + command_target: pending.document_command_target().cloned(), page_owner: pending .page_owner() - .expect("an in-flight response continuation must belong to an installed Page") + .expect("an in-flight response continuation must retain its native Page residence") .clone(), owner_session_id: pending.owner_session_id.clone(), action_session_id: pending.action_session_id.clone(), @@ -471,6 +470,11 @@ pub(crate) fn flush_post_subresource_fetch_request_activity_for_owner_background owner: &CommandOwnerScope, pending: &PendingSubresourceFetchRequest, ) { + if pending.page_owner().is_some_and(|page| { + !conn.subresource_document_is_current_for_owner(owner, page, pending.source_document()) + }) { + return; + } record_subresource_network_request_handle( conn, owner, @@ -504,6 +508,13 @@ pub(crate) fn flush_post_subresource_response_activity_for_owner_background_even owner: &CommandOwnerScope, pending: &PendingSubresourceFetchResponseRequest, ) { + if !conn.subresource_document_is_current_for_owner( + owner, + &pending.page_owner, + pending.source_document(), + ) { + return; + } record_subresource_network_request_handle( conn, owner, @@ -535,6 +546,13 @@ pub(crate) fn flush_post_subresource_auth_activity_for_owner_background_events( owner: &CommandOwnerScope, pending: &PendingSubresourceFetchAuthRequest, ) { + if !conn.subresource_document_is_current_for_owner( + owner, + &pending.page_owner, + pending.source_document(), + ) { + return; + } record_subresource_network_request_handle( conn, owner, @@ -618,7 +636,6 @@ pub(crate) struct PreparedSubresourceContinueWait { } pub(crate) struct SubresourceContinueCompletion { - kind: SubresourceContinuationKind, owner: CommandOwnerScope, binding: crate::conn::TargetRootDocumentProtocolAttachmentIdentity, request: PendingSubresourceFetchRequest, @@ -632,27 +649,35 @@ impl PreparedSubresourceContinueWait { request: PendingSubresourceFetchRequest, kind: SubresourceContinuationKind, ) -> Option { - let document = conn.target_root_document_lifecycle_identity_for_owner(owner)?; - let binding = - conn.target_root_document_protocol_attachment_identity_for_owner(owner, document)?; - let pending = conn - .loaded_page_mut_for_owner(owner) - .context(crate::conn::NoDocumentLoaded) - .and_then(|page| match kind { - SubresourceContinuationKind::Response(internal_id) => { - page.start_continue_pending_subresource_response(internal_id, None, None) + let document = request + .source_document() + .or_else(|| conn.target_root_document_lifecycle_identity_for_owner(owner))?; + let binding = conn.target_root_document_output_identity_for_owner(owner, document)?; + let native = match kind { + SubresourceContinuationKind::Response(internal_id) => { + moli_core::RendererPageCommand::ContinuePendingSubresourceResponse { + internal_id, + response_code: None, + response_headers: None, } - SubresourceContinuationKind::FailAuth(internal_id) => page - .start_fail_pending_subresource_auth( - internal_id, - "Fetch auth challenge has no DevTools request".to_owned(), - ), - }); + } + SubresourceContinuationKind::FailAuth(internal_id) => { + moli_core::RendererPageCommand::FailPendingSubresourceAuth { + internal_id, + error_text: "Fetch auth challenge has no DevTools request".to_owned(), + } + } + }; + let pending = conn.start_subresource_document_command( + owner, + request.page_owner()?, + request.document_command_target(), + native, + ); match pending { Ok(pending) => Some(Self { pending, completion: SubresourceContinueCompletion { - kind, owner: owner.clone(), binding, request, @@ -686,22 +711,10 @@ impl SubresourceContinueCompletion { completed: Result, ) -> Vec { let mut events = Vec::new(); - if !conn.target_root_document_protocol_attachment_identity_is_current(&self.binding) { + if !conn.target_root_document_output_identity_is_current(&self.binding) { return events; } - let result = completed.and_then(|completed| { - let page = conn - .loaded_page_mut_for_owner(&self.owner) - .context(crate::conn::NoDocumentLoaded)?; - match self.kind { - SubresourceContinuationKind::Response(_) => { - page.finish_continue_pending_subresource_response(completed) - } - SubresourceContinuationKind::FailAuth(_) => page - .finish_fail_pending_subresource_auth(completed) - .map(|_| ()), - } - }); + let result = completed.and_then(moli_core::page::CompletedPageCommand::into_unit_reply); if let Err(error) = result { tracing::debug!(%error, "automatic subresource response failed"); } diff --git a/moli-protocol/src/domains/fetch.rs b/moli-protocol/src/domains/fetch.rs index 234f5fdc7e..3a622ea0cb 100644 --- a/moli-protocol/src/domains/fetch.rs +++ b/moli-protocol/src/domains/fetch.rs @@ -777,7 +777,6 @@ fn complete_finite_fetch_command( PendingFetchCommandKind::ContinueRequest { state } => { commands::complete_continue_request_command( conn, - &owner, completed.completed.into_page_completion(), *state, &mut output, diff --git a/moli-protocol/src/domains/fetch/auth.rs b/moli-protocol/src/domains/fetch/auth.rs index fb22d12c43..eb4c325b40 100644 --- a/moli-protocol/src/domains/fetch/auth.rs +++ b/moli-protocol/src/domains/fetch/auth.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use moli_core::RendererPageCommand; use crate::automation::{ AutomationCommand, DevToolsAuthChallengeAction, DevToolsContinueWithAuthCommand, @@ -214,20 +215,26 @@ pub(super) fn start_devtools_continue_with_auth_command_for_pending( } else { None }; - let pending_page = match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => match command.action { - DevToolsAuthChallengeAction::Default => page - .start_fail_pending_subresource_auth( - pending.internal_id, - "Fetch auth challenge aborted".to_owned(), - ), - DevToolsAuthChallengeAction::Cancel => { - page.start_cancel_pending_subresource_auth(pending.internal_id) + let native = match command.action { + DevToolsAuthChallengeAction::Default => { + RendererPageCommand::FailPendingSubresourceAuth { + internal_id: pending.internal_id, + error_text: "Fetch auth challenge aborted".to_owned(), } - DevToolsAuthChallengeAction::ProvideCredentials => unreachable!(), - }, - Err(message) => Err(anyhow::Error::msg(message)), + } + DevToolsAuthChallengeAction::Cancel => { + RendererPageCommand::CancelPendingSubresourceAuth { + internal_id: pending.internal_id, + } + } + DevToolsAuthChallengeAction::ProvideCredentials => unreachable!(), }; + let pending_page = conn.start_subresource_document_command( + owner, + &pending.page_owner, + pending.command_target.as_ref(), + native, + ); let pending_page = match pending_page { Ok(pending_page) => pending_page, Err(error) => { @@ -303,12 +310,15 @@ pub(super) fn start_devtools_continue_with_auth_command_for_pending( ))); } }; - let pending_page = match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => page - .start_continue_pending_subresource_auth(pending.internal_id, auth) - .context("subresource auth continue failed"), - Err(message) => Err(anyhow::Error::msg(message)), - }; + let pending_page = conn.start_subresource_document_command( + owner, + &pending.page_owner, + pending.command_target.as_ref(), + RendererPageCommand::ContinuePendingSubresourceAuth { + internal_id: pending.internal_id, + auth, + }, + ); let pending_page = match pending_page { Ok(pending_page) => pending_page, Err(message) => { @@ -422,7 +432,13 @@ fn continued_subresource_request( pending: &PendingSubresourceFetchAuthRequest, ) -> PendingSubresourceFetchRequest { PendingSubresourceFetchRequest { - residence: crate::conn::PendingSubresourceFetchResidence::Page(pending.page_owner.clone()), + residence: match pending.command_target.clone() { + Some(command_target) => crate::conn::PendingSubresourceFetchResidence::Document { + page_owner: pending.page_owner.clone(), + command_target, + }, + None => crate::conn::PendingSubresourceFetchResidence::Page(pending.page_owner.clone()), + }, owner_session_id: pending.owner_session_id.clone(), action_session_id: pending.action_session_id.clone(), owner_kind: pending.owner_kind, @@ -551,21 +567,13 @@ pub(super) fn complete_continue_with_auth_command( pending, correlation, } => { - complete_subresource_auth_terminal( - conn, - owner, - completed, - *pending, - true, - correlation, - out, - ); + complete_subresource_auth_terminal(conn, owner, completed, *pending, correlation, out); } PendingContinueWithAuthState::SubresourceAuthFail { pending } => { - complete_subresource_auth_terminal(conn, owner, completed, *pending, false, None, out); + complete_subresource_auth_terminal(conn, owner, completed, *pending, None, out); } PendingContinueWithAuthState::SubresourceAuthContinue { correlation } => { - if let Err(error) = finish_continue_subresource_auth(conn, owner, completed) { + if let Err(error) = finish_continue_subresource_auth(completed) { correlation.rollback(conn); out.push_error(-32000, format!("{error:#}")); return; @@ -580,14 +588,10 @@ pub(super) fn complete_subresource_auth_terminal( owner: &CommandOwnerScope, completed: Option>, pending: crate::conn::PendingSubresourceFetchAuthRequest, - expose_challenged_response: bool, correlation: Option, out: &mut FetchCommandOutput, ) { let activity_session_id = pending.owner_session_id.as_deref().or(owner.session_id()); - let activity_owner = activity_session_id - .map(CommandOwnerScope::for_session) - .unwrap_or_else(|| owner.clone()); let Some(completed) = completed else { if let Some(correlation) = correlation { correlation.rollback(conn); @@ -605,21 +609,7 @@ pub(super) fn complete_subresource_auth_terminal( return; } }; - let result = match conn.loaded_page_mut_for_protocol_access_for_owner(&activity_owner) { - Ok(page) if expose_challenged_response => page - .finish_cancel_pending_subresource_auth(completion) - .map(|_| ()), - Ok(page) => page - .finish_fail_pending_subresource_auth(completion) - .map(|_| ()), - Err(message) => { - if let Some(correlation) = correlation { - correlation.rollback(conn); - } - out.push_error(-32000, message); - return; - } - }; + let result = completion.into_unit_reply(); if let Err(error) = result { if let Some(correlation) = correlation { correlation.rollback(conn); @@ -641,14 +631,10 @@ pub(super) fn complete_subresource_auth_terminal( out.extend_background_events(events); } -fn finish_continue_subresource_auth( - conn: &mut CdpConnection, - owner: &CommandOwnerScope, - completed: Option>, -) -> Result<()> { +fn finish_continue_subresource_auth(completed: Option>) -> Result<()> { let completion = completed.context("Missing renderer completion")??; - let page = conn.loaded_page_mut_for_protocol_access_for_owner(owner)?; - page.finish_continue_pending_subresource_auth(completion) + completion + .into_subresource_continue_outcome() .map(|_| ()) .context("subresource auth continue failed") } diff --git a/moli-protocol/src/domains/fetch/cleanup.rs b/moli-protocol/src/domains/fetch/cleanup.rs index 4877214da6..b38fae7424 100644 --- a/moli-protocol/src/domains/fetch/cleanup.rs +++ b/moli-protocol/src/domains/fetch/cleanup.rs @@ -213,8 +213,21 @@ impl PreparedFetchCleanup { output.plan.set_renderer_output_predecessor(predecessor); } output.error = completed - .and_then(|completed| completed.into_unit_page_command_turn().map(drop)) - .err(); + .and_then(|completed| completed.into_unit_reply()) + .err() + // Cancellation may retire the original native Page before + // its already-admitted policy update runs. It has no policy + // left to clear; failures on a live Page remain errors. + .filter(|error| { + !matches!( + error.downcast_ref::(), + Some( + moli_core::RendererPageAccessError::Missing { .. } + | moli_core::RendererPageAccessError::Retiring { .. } + | moli_core::RendererPageAccessError::DocumentReplaced + ) + ) + }); } output } @@ -282,7 +295,7 @@ enum CleanupSubresource { struct PreparedSubresourceFailure { pending: PendingPageCommand, owner: CommandOwnerScope, - binding: TargetRootDocumentProtocolAttachmentIdentity, + binding: Option, resource: CleanupSubresource, } @@ -292,35 +305,41 @@ fn admit_subresource_failure( resource: CleanupSubresource, reason: &str, ) -> Option { - let current = match &resource { - CleanupSubresource::Request(pending) => { - conn.pending_subresource_fetch_request_residence_is_current(pending) - } - CleanupSubresource::Auth(pending) => { - conn.target_page_residence_identity_is_current(&pending.page_owner) - } - CleanupSubresource::Response(pending) => { - conn.target_page_residence_identity_is_current(&pending.page_owner) + use moli_core::RendererPageCommand; + let (target, command) = match &resource { + CleanupSubresource::Request(request) => { + if let Some(continuation) = request.detached_parser_script_fetch_continuation() { + continuation.fail(reason.to_owned()); + return None; + } + ( + request.document_command_target(), + RendererPageCommand::FailPendingSubresourceFetch { + internal_id: request.internal_id, + error_text: reason.to_owned(), + }, + ) } + CleanupSubresource::Auth(request) => ( + request.command_target.as_ref(), + RendererPageCommand::FailPendingSubresourceAuth { + internal_id: request.internal_id, + error_text: reason.to_owned(), + }, + ), + CleanupSubresource::Response(request) => ( + request.command_target.as_ref(), + RendererPageCommand::FailPendingSubresourceResponse { + internal_id: request.internal_id, + error_text: reason.to_owned(), + }, + ), }; - if !current { - return None; - } - let root = conn.target_root_document_lifecycle_identity_for_owner(owner)?; - let binding = conn.target_root_document_protocol_attachment_identity_for_owner(owner, root)?; - let page = conn.loaded_page_mut_for_owner(owner)?; - let pending = match &resource { - CleanupSubresource::Request(pending) => { - page.start_fail_pending_subresource_fetch(pending.internal_id, reason.to_owned()) - } - CleanupSubresource::Auth(pending) => { - page.start_fail_pending_subresource_auth(pending.internal_id, reason.to_owned()) - } - CleanupSubresource::Response(pending) => { - page.start_fail_pending_subresource_response(pending.internal_id, reason.to_owned()) - } - } - .ok()?; + let target = target?; + // Cleanup owns this exact request even after session Closing or Page + // retirement. The native Document guard prevents touching a replacement. + let pending = target.start(command).ok()?; + let binding = conn.target_root_document_output_identity_for_owner(owner, target.document()); Some(PreparedSubresourceFailure { pending, owner: owner.clone(), @@ -346,7 +365,7 @@ impl PreparedSubresourceFailure { } pub(crate) struct CompletedSubresourceFailure { - binding: TargetRootDocumentProtocolAttachmentIdentity, + binding: Option, owner: CommandOwnerScope, resource: CleanupSubresource, completed: moli_core::page::CompletedPageCommand, @@ -361,14 +380,17 @@ impl CompletedSubresourceFailure { completed, } = self; let predecessor = completed.renderer_output_predecessor(); - completed.into_unit_page_command_turn().ok()?; + completed.into_unit_reply().ok()?; let mut plan = CommandOutputPlan::default(); if let Some(predecessor) = predecessor { plan.set_renderer_output_predecessor(predecessor); } // A unit ACK belongs to the admitted native turn, never to a replacement // Page's mutable snapshot or network correlation table. - if !conn.target_root_document_protocol_attachment_identity_is_current(&binding) { + if binding + .as_ref() + .is_none_or(|binding| !conn.target_root_document_output_identity_is_current(binding)) + { return Some(plan); } let mut events = Vec::new(); diff --git a/moli-protocol/src/domains/fetch/commands.rs b/moli-protocol/src/domains/fetch/commands.rs index ee3cfffca2..02ef59c9cf 100644 --- a/moli-protocol/src/domains/fetch/commands.rs +++ b/moli-protocol/src/domains/fetch/commands.rs @@ -14,6 +14,7 @@ use crate::conn::{ use crate::domains::command_output::CommandOutputPlan; use crate::domains::{activity, network, page}; use anyhow::Context as _; +use moli_core::RendererPageCommand; use moli_core::page::{ CompletedPageCommand, RendererSyntheticResponseBody, SubresourceResourceType, }; @@ -326,20 +327,22 @@ fn start_devtools_continue_intercepted_request_command( )); } }; - let pending_page = match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => page - .start_continue_pending_subresource_fetch( - pending.internal_id, - parsed_url, - method, - post_data, - headers, - intercept_response, - handle_auth_requests, - ) - .context("subresource fetch continue failed"), - Err(message) => Err(anyhow::Error::msg(message)), - }; + let pending_page = conn.start_subresource_document_command( + owner, + pending + .page_owner() + .expect("native request has Page residence"), + pending.document_command_target(), + RendererPageCommand::ContinuePendingSubresourceFetch { + internal_id: pending.internal_id, + url: parsed_url, + method, + body: post_data, + headers, + intercept_response, + handle_auth_requests, + }, + ); let pending_page = match pending_page { Ok(pending_page) => pending_page, Err(message) => { @@ -434,14 +437,13 @@ fn start_devtools_continue_intercepted_request_command( pub(super) fn complete_continue_request_command( conn: &mut CdpConnection, - owner: &CommandOwnerScope, completed: Option>, state: PendingContinueRequestState, out: &mut FetchCommandOutput, ) { match state { PendingContinueRequestState::SubresourceFetch { correlation } => { - if let Err(error) = finish_continue_subresource_request(conn, owner, completed) { + if let Err(error) = finish_continue_subresource_request(completed) { correlation.rollback(conn); out.push_error(-32000, format!("{error:#}")); return; @@ -452,13 +454,11 @@ pub(super) fn complete_continue_request_command( } fn finish_continue_subresource_request( - conn: &mut CdpConnection, - owner: &CommandOwnerScope, completed: Option>, ) -> Result<()> { let completion = completed.context("Missing renderer completion")??; - let page = conn.loaded_page_mut_for_protocol_access_for_owner(owner)?; - page.finish_continue_pending_subresource_fetch(completion) + completion + .into_subresource_continue_outcome() .map(|_| ()) .context("subresource fetch continue failed") } @@ -579,29 +579,25 @@ fn start_devtools_fail_intercepted_request_command( plan.extend_background_events(events); return FetchCommandTaskStep::Complete(plan); } - let page = match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => page, - Err(error) if error.is::() => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - "NoDocumentLoaded", - )); - } - Err(message) => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - format!("{message:#}"), - )); - } - }; - let pending_page = match page - .start_fail_pending_subresource_fetch(pending.internal_id, error_text.clone()) - { + let pending_page = match conn.start_subresource_document_command( + owner, + pending + .page_owner() + .expect("native request has Page residence"), + pending.document_command_target(), + RendererPageCommand::FailPendingSubresourceFetch { + internal_id: pending.internal_id, + error_text: error_text.clone(), + }, + ) { Ok(pending_page) => pending_page, Err(error) => { + conn.register_pending_subresource_fetch_request_for_owner( + owner, request_id, pending, + ); return FetchCommandTaskStep::Complete(CommandOutputPlan::error( -32000, - format!("subresource fetch fail failed: {error}"), + format!("subresource fetch fail failed: {error:#}"), )); } }; @@ -623,29 +619,23 @@ fn start_devtools_fail_intercepted_request_command( action_session_id, &request_id, ) { - let page = match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => page, - Err(error) if error.is::() => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - "NoDocumentLoaded", - )); - } - Err(message) => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - format!("{message:#}"), - )); - } - }; - let pending_page = match page - .start_fail_pending_subresource_response(pending.internal_id, error_text.clone()) - { + let pending_page = match conn.start_subresource_document_command( + owner, + &pending.page_owner, + pending.command_target.as_ref(), + RendererPageCommand::FailPendingSubresourceResponse { + internal_id: pending.internal_id, + error_text: error_text.clone(), + }, + ) { Ok(pending_page) => pending_page, Err(error) => { + conn.register_pending_subresource_fetch_response_request_for_owner( + owner, request_id, pending, + ); return FetchCommandTaskStep::Complete(CommandOutputPlan::error( -32000, - format!("subresource response fail failed: {error}"), + format!("subresource response fail failed: {error:#}"), )); } }; @@ -705,21 +695,9 @@ pub(super) fn complete_fail_request_command( return; } }; - match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => { - if let Err(error) = page.finish_fail_pending_subresource_fetch(completion) { - out.push_error(-32000, format!("subresource fetch fail failed: {error}")); - return; - } - } - Err(error) if error.is::() => { - out.push_error(-32000, "NoDocumentLoaded"); - return; - } - Err(message) => { - out.push_error(-32000, message); - return; - } + if let Err(error) = completion.into_unit_reply() { + out.push_error(-32000, format!("{error:#}")); + return; } emit_devtools_empty_success(out); let mut events = Vec::new(); @@ -743,24 +721,9 @@ pub(super) fn complete_fail_request_command( return; } }; - match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => { - if let Err(error) = page.finish_fail_pending_subresource_response(completion) { - out.push_error( - -32000, - format!("subresource response fail failed: {error}"), - ); - return; - } - } - Err(error) if error.is::() => { - out.push_error(-32000, "NoDocumentLoaded"); - return; - } - Err(message) => { - out.push_error(-32000, message); - return; - } + if let Err(error) = completion.into_unit_reply() { + out.push_error(-32000, format!("{error:#}")); + return; } emit_devtools_empty_success(out); let mut events = Vec::new(); @@ -912,32 +875,27 @@ fn start_devtools_fulfill_intercepted_request_command( let websocket_socket_id = pending.websocket_socket_id; let register_synthetic_websocket = pending.resource_type == SubresourceResourceType::WebSocket && response_code == 101; - let page = match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => page, - Err(error) if error.is::() => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - "NoDocumentLoaded", - )); - } - Err(message) => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - format!("{message:#}"), - )); - } - }; - let pending_page = match page.start_fulfill_pending_subresource_fetch( - pending.internal_id, - response_code, - response_headers.clone(), - decoded_body.unwrap_or_else(RendererSyntheticResponseBody::empty), + let pending_page = match conn.start_subresource_document_command( + owner, + pending + .page_owner() + .expect("native request has Page residence"), + pending.document_command_target(), + RendererPageCommand::FulfillPendingSubresourceFetch { + internal_id: pending.internal_id, + response_code, + response_headers: response_headers.clone(), + response_body: decoded_body.unwrap_or_else(RendererSyntheticResponseBody::empty), + }, ) { Ok(pending_page) => pending_page, Err(error) => { + conn.register_pending_subresource_fetch_request_for_owner( + owner, request_id, pending, + ); return FetchCommandTaskStep::Complete(CommandOutputPlan::error( -32000, - format!("subresource fetch fulfill failed: {error}"), + format!("subresource fetch fulfill failed: {error:#}"), )); } }; @@ -963,32 +921,25 @@ fn start_devtools_fulfill_intercepted_request_command( action_session_id, &request_id, ) { - let page = match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => page, - Err(error) if error.is::() => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - "NoDocumentLoaded", - )); - } - Err(message) => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - format!("{message:#}"), - )); - } - }; - let pending_page = match page.start_fulfill_pending_subresource_response( - pending.internal_id, - response_code, - response_headers.clone(), - decoded_body.unwrap_or_else(RendererSyntheticResponseBody::empty), + let pending_page = match conn.start_subresource_document_command( + owner, + &pending.page_owner, + pending.command_target.as_ref(), + RendererPageCommand::FulfillPendingSubresourceResponse { + internal_id: pending.internal_id, + response_code, + response_headers: response_headers.clone(), + response_body: decoded_body.unwrap_or_else(RendererSyntheticResponseBody::empty), + }, ) { Ok(pending_page) => pending_page, Err(error) => { + conn.register_pending_subresource_fetch_response_request_for_owner( + owner, request_id, pending, + ); return FetchCommandTaskStep::Complete(CommandOutputPlan::error( -32000, - format!("subresource response fulfill failed: {error}"), + format!("subresource response fulfill failed: {error:#}"), )); } }; @@ -1066,26 +1017,20 @@ pub(super) fn complete_fulfill_request_command( return; } }; - match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => { - if let Err(error) = page.finish_fulfill_pending_subresource_fetch(completion) { - out.push_error( - -32000, - format!("subresource fetch fulfill failed: {error}"), - ); - return; - } - } - Err(error) if error.is::() => { - out.push_error(-32000, "NoDocumentLoaded"); - return; - } - Err(message) => { - out.push_error(-32000, message); - return; - } + if let Err(error) = completion.into_unit_reply() { + out.push_error(-32000, format!("{error:#}")); + return; } - if register_synthetic_websocket && let Some(socket_id) = websocket_socket_id { + if register_synthetic_websocket + && pending.page_owner().is_some_and(|page| { + conn.subresource_document_is_current_for_owner( + owner, + page, + pending.source_document(), + ) + }) + && let Some(socket_id) = websocket_socket_id + { conn.register_synthetic_websocket_request_for_owner( owner, request_id, @@ -1115,25 +1060,9 @@ pub(super) fn complete_fulfill_request_command( return; } }; - match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => { - if let Err(error) = page.finish_fulfill_pending_subresource_response(completion) - { - out.push_error( - -32000, - format!("subresource response fulfill failed: {error}"), - ); - return; - } - } - Err(error) if error.is::() => { - out.push_error(-32000, "NoDocumentLoaded"); - return; - } - Err(message) => { - out.push_error(-32000, message); - return; - } + if let Err(error) = completion.into_unit_reply() { + out.push_error(-32000, format!("{error:#}")); + return; } emit_devtools_empty_success(out); let mut events = Vec::new(); @@ -1556,32 +1485,24 @@ fn start_devtools_continue_intercepted_response_command( } else { response_headers }; - let pending_internal_id = pending.internal_id; - let page = match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => page, - Err(error) if error.is::() => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - "NoDocumentLoaded", - )); - } - Err(message) => { - return FetchCommandTaskStep::Complete(CommandOutputPlan::error( - -32000, - format!("{message:#}"), - )); - } - }; - let pending_page = match page.start_continue_pending_subresource_response( - pending_internal_id, - continue_response_code, - continue_response_headers, + let pending_page = match conn.start_subresource_document_command( + owner, + &pending.page_owner, + pending.command_target.as_ref(), + RendererPageCommand::ContinuePendingSubresourceResponse { + internal_id: pending.internal_id, + response_code: continue_response_code, + response_headers: continue_response_headers, + }, ) { Ok(pending_page) => pending_page, Err(error) => { + conn.register_pending_subresource_fetch_response_request_for_owner( + owner, request_id, pending, + ); return FetchCommandTaskStep::Complete(CommandOutputPlan::error( -32000, - format!("subresource response continue failed: {error}"), + format!("subresource response continue failed: {error:#}"), )); } }; @@ -1658,26 +1579,9 @@ pub(super) fn complete_continue_response_command( return; } }; - match conn.loaded_page_mut_for_protocol_access_for_owner(owner) { - Ok(page) => { - if let Err(error) = - page.finish_continue_pending_subresource_response(completion) - { - out.push_error( - -32000, - format!("subresource response continue failed: {error}"), - ); - return; - } - } - Err(error) if error.is::() => { - out.push_error(-32000, "NoDocumentLoaded"); - return; - } - Err(message) => { - out.push_error(-32000, message); - return; - } + if let Err(error) = completion.into_unit_reply() { + out.push_error(-32000, format!("{error:#}")); + return; } emit_devtools_empty_success(out); let mut events = Vec::new(); @@ -2075,6 +1979,7 @@ mod protocol_neutral_tests { .target_page_residence_identity_for_session(Some("SID-primary")) .expect("test target should expose a Page residence identity"); let pending = PendingSubresourceFetchResponseRequest { + command_target: None, page_owner, owner_session_id: Some("SID-primary".to_owned()), action_session_id: Some("SID-primary".to_owned()), diff --git a/moli-protocol/src/domains/fetch/subresource.rs b/moli-protocol/src/domains/fetch/subresource.rs index cab96d5026..e4f2a7e9d7 100644 --- a/moli-protocol/src/domains/fetch/subresource.rs +++ b/moli-protocol/src/domains/fetch/subresource.rs @@ -59,17 +59,38 @@ pub(crate) fn subresource_fetch_pause_prepared_outputs_for_renderer_record( owner: &CommandOwnerScope, source_document: moli_core::RendererDocumentLifecycleIdentity, info: moli_core::page::PendingSubresourceFetchInfo, + page_source: Option<( + crate::conn::TargetPageResidenceIdentity, + crate::conn::RendererPageResidenceIdentity, + )>, ) -> network::NetworkPreparedOutputs { let Some(source) = conn.target_root_document_output_identity_for_owner(owner, source_document) else { return network::NetworkPreparedOutputs::default(); }; + let Some((page_owner, renderer_page)) = page_source else { + return network::NetworkPreparedOutputs::default(); + }; + if source.attachment().page_owner() != &page_owner { + return network::NetworkPreparedOutputs::default(); + } + let Ok(command_target) = conn.capture_document_output_command_target( + owner, + &page_owner, + renderer_page, + source_document, + ) else { + return network::NetworkPreparedOutputs::default(); + }; prepare_subresource_fetch_pause_sources( conn, owner, None, None, - Some(source.attachment().page_owner().clone()), + Some(crate::conn::PendingSubresourceFetchResidence::Document { + page_owner, + command_target, + }), vec![PendingSubresourceFetchPauseSource { info, detached_parser_script_fetch_continuation: None, @@ -108,13 +129,16 @@ fn prepare_subresource_fetch_pause_sources( owner: &CommandOwnerScope, frame_id: Option<&str>, document_url: Option<&Url>, - page_owner: Option, + residence: Option, sources: Vec, ) -> network::NetworkPreparedOutputs { let mut outputs = Vec::new(); let mut automatic = Vec::new(); let session_id = owner.session_id(); - let page_owner = page_owner.or_else(|| conn.target_page_residence_identity_for_owner(owner)); + let residence = residence.or_else(|| { + conn.target_page_residence_identity_for_owner(owner) + .map(crate::conn::PendingSubresourceFetchResidence::Page) + }); let Some((fetch_snapshot, frame_id)) = (|| { let target_frame_id = conn .target_session_owner_frame_tree_identity_for_owner(owner) @@ -157,11 +181,11 @@ fn prepare_subresource_fetch_pause_sources( continuation.continue_request(None); continue; } - let Some(page_owner) = page_owner.clone() else { + let Some(residence) = residence.clone() else { continue; }; let pending = PendingSubresourceFetchRequest { - residence: crate::conn::PendingSubresourceFetchResidence::Page(page_owner.clone()), + residence, owner_session_id: None, action_session_id: None, owner_kind: PendingSubresourceFetchOwnerKind::NetworkOrBidi, @@ -273,10 +297,10 @@ fn prepare_subresource_fetch_pause_sources( crate::conn::PendingSubresourceFetchResidence::DetachedParserScript(continuation) } None => { - let Some(page_owner) = page_owner.clone() else { + let Some(residence) = residence.clone() else { continue; }; - crate::conn::PendingSubresourceFetchResidence::Page(page_owner) + residence } }; let pending = PendingSubresourceFetchRequest { diff --git a/moli-protocol/src/domains/fetch/subresource_continuation.rs b/moli-protocol/src/domains/fetch/subresource_continuation.rs index 99459baa26..2d63864aa3 100644 --- a/moli-protocol/src/domains/fetch/subresource_continuation.rs +++ b/moli-protocol/src/domains/fetch/subresource_continuation.rs @@ -1,4 +1,4 @@ -use anyhow::{Context as _, Result}; +use anyhow::Result; use moli_core::page::{CompletedPageCommand, PendingPageCommand}; use super::state::PreparedSubresourceCorrelation; @@ -17,7 +17,6 @@ pub(super) enum SubresourceResponseInterception { pub(crate) struct PreparedSubresourceAutoContinue { pending: PendingPageCommand, correlation: PreparedSubresourceCorrelation, - owner: CommandOwnerScope, binding: TargetRootDocumentProtocolAttachmentIdentity, } @@ -30,36 +29,35 @@ impl PreparedSubresourceAutoContinue { handle_auth_requests: bool, response: SubresourceResponseInterception, ) -> Option { - let document = conn.target_root_document_lifecycle_identity_for_owner(owner)?; - let binding = - conn.target_root_document_protocol_attachment_identity_for_owner(owner, document)?; + let target = request.document_command_target()?.clone(); + let document = target.document(); + let binding = conn.target_root_document_output_identity_for_owner(owner, document)?; let intercept_response = !matches!(response, SubresourceResponseInterception::None); let internal_id = request.internal_id; + let page_owner = request.page_owner()?.clone(); // The renderer may publish a continuation before its command reply is // consumed. Freeze its original correlation before native dispatch. let correlation = PreparedSubresourceCorrelation::prepare_automatic( conn, owner, request_id, request, response, )?; - let pending = conn - .loaded_page_mut_for_owner(owner) - .context(crate::conn::NoDocumentLoaded) - .and_then(|page| { - page.start_continue_pending_subresource_fetch( - internal_id, - None, - None, - None, - None, - intercept_response, - handle_auth_requests, - ) - .context("subresource fetch continue failed") - }); + let pending = conn.start_subresource_document_command( + owner, + &page_owner, + Some(&target), + moli_core::RendererPageCommand::ContinuePendingSubresourceFetch { + internal_id, + url: None, + method: None, + body: None, + headers: None, + intercept_response, + handle_auth_requests, + }, + ); match pending { Ok(pending) => Some(Self { pending, correlation, - owner: owner.clone(), binding, }), Err(error) => { @@ -74,7 +72,6 @@ impl PreparedSubresourceAutoContinue { let completed = self.pending.wait().await; let completion = SubresourceAutoContinueCompletion { correlation: self.correlation, - owner: self.owner, binding: self.binding, }; driver.with_state(move |state| { @@ -94,22 +91,16 @@ impl std::fmt::Debug for PreparedSubresourceAutoContinue { pub(crate) struct SubresourceAutoContinueCompletion { correlation: PreparedSubresourceCorrelation, - owner: CommandOwnerScope, binding: TargetRootDocumentProtocolAttachmentIdentity, } impl SubresourceAutoContinueCompletion { pub(crate) fn finish(self, conn: &mut CdpConnection, completed: Result) { - if !conn.target_root_document_protocol_attachment_identity_is_current(&self.binding) { + if !conn.target_root_document_output_identity_is_current(&self.binding) { self.correlation.rollback(conn); return; } - let result = completed.and_then(|completed| { - conn.loaded_page_mut_for_owner(&self.owner) - .context(crate::conn::NoDocumentLoaded)? - .finish_continue_pending_subresource_fetch(completed) - .context("subresource fetch continue failed") - }); + let result = completed.and_then(CompletedPageCommand::into_subresource_continue_outcome); if let Err(error) = result { self.correlation.rollback(conn); tracing::debug!(%error, "automatic subresource continuation failed"); diff --git a/moli-protocol/src/domains/fetch/tests/basics.rs b/moli-protocol/src/domains/fetch/tests/basics.rs index 35d33318b4..ba13e12fbd 100644 --- a/moli-protocol/src/domains/fetch/tests/basics.rs +++ b/moli-protocol/src/domains/fetch/tests/basics.rs @@ -50,6 +50,7 @@ fn pending_subresource_fetch_auth( owner_session_id: &str, ) -> PendingSubresourceFetchAuthRequest { PendingSubresourceFetchAuthRequest { + command_target: None, page_owner: crate::conn::TargetPageResidenceIdentity::new_for_test( "BID-session-fetch".to_owned(), Some("TID-session-fetch".to_owned()), diff --git a/moli-protocol/src/domains/fetch/tests/command_correlation.rs b/moli-protocol/src/domains/fetch/tests/command_correlation.rs index aebf0f7e3d..6462076302 100644 --- a/moli-protocol/src/domains/fetch/tests/command_correlation.rs +++ b/moli-protocol/src/domains/fetch/tests/command_correlation.rs @@ -8,19 +8,33 @@ use moli_core::page::SubresourceResourceType; async fn context_with_loaded_fetch_page() -> TestContext { let mut ctx = TestContext::new(); - let page = ctx - .build_fixture_page("data:text/html,fetch correlation") - .await - .expect("fetch correlation page should load"); - let mut browser_context = attached_browser_context(); - browser_context - .active_page_target_mut() - .runtime_slot - .replace_loaded_page(Some(page)); - ctx.with_fixture(|conn| conn.install_browser_context_fixture_for_test(browser_context)); + ctx.with_fixture(|conn| { + conn.install_browser_context_fixture_for_test(attached_browser_context()) + }); + ctx.install_buffered_navigation_fixture_for_session_owner( + Url::parse("https://example.test/page").unwrap(), + "fetch correlation".to_owned(), + Some("SID-1"), + ) + .await; ctx } +fn command_target( + conn: &mut crate::conn::CdpConnection, + page: &crate::conn::TargetPageResidenceIdentity, +) -> moli_core::page::PageDocumentCommandTarget { + let owner = crate::conn::CommandOwnerScope::for_session("SID-1"); + let renderer = conn + .renderer_page_residence_identity_for_owner(&owner) + .unwrap(); + let document = conn + .target_root_document_lifecycle_identity_for_owner(&owner) + .unwrap(); + conn.capture_document_output_command_target(&owner, page, renderer, document) + .unwrap() +} + fn pending_request( page_owner: &crate::conn::TargetPageResidenceIdentity, internal_id: u64, @@ -51,6 +65,7 @@ fn pending_auth( .expect("test request should belong to an installed Page") .clone(); PendingSubresourceFetchAuthRequest { + command_target: None, page_owner, owner_session_id: pending.owner_session_id, action_session_id: pending.action_session_id, @@ -88,6 +103,7 @@ fn pending_response( .expect("test request should belong to an installed Page") .clone(); PendingSubresourceFetchResponseRequest { + command_target: None, page_owner, owner_session_id: pending.owner_session_id, action_session_id: pending.action_session_id, @@ -247,11 +263,16 @@ async fn continue_request_registers_correlation_before_renderer_completion() { .expect("test Page residence should exist") }); ctx.with_fixture(|conn| { + let mut pending = pending_request(&page_owner, 61); + pending.residence = crate::conn::PendingSubresourceFetchResidence::Document { + page_owner: page_owner.clone(), + command_target: command_target(conn, &page_owner), + }; assert!({ conn.register_pending_subresource_fetch_request_for_session_owner( Some("SID-1"), "INT-61".to_owned(), - pending_request(&page_owner, 61), + pending, ) }) }); @@ -657,11 +678,13 @@ async fn continue_with_auth_registers_retry_correlation_before_renderer_completi .expect("test Page residence should exist") }); ctx.with_fixture(|conn| { + let mut pending = pending_auth(&page_owner, 62); + pending.command_target = Some(command_target(conn, &page_owner)); assert!({ conn.register_pending_subresource_fetch_auth_request_for_session_owner( Some("SID-1"), "INT-62".to_owned(), - pending_auth(&page_owner, 62), + pending, ) }) }); diff --git a/moli-protocol/src/domains/input.rs b/moli-protocol/src/domains/input.rs index f893d5dd75..2fa31e4755 100644 --- a/moli-protocol/src/domains/input.rs +++ b/moli-protocol/src/domains/input.rs @@ -1512,16 +1512,26 @@ impl InputPreparedOutputs { } pub(crate) fn from_renderer_file_chooser_activation( - conn: &CdpConnection, + conn: &mut CdpConnection, owner: &CommandOwnerScope, + page_source: Option<( + TargetPageResidenceIdentity, + crate::conn::RendererPageResidenceIdentity, + )>, activation: RendererPendingFileChooserActivation, ) -> Self { - let Some(page_owner) = conn.target_page_residence_identity_for_owner(owner) else { + let Some((page_owner, renderer_page)) = page_source else { return Self::default(); }; - let Some(activation) = file_chooser::PreparedFileChooserActivation::capture( - conn, owner, page_owner, activation, - ) else { + let Some(activation) = + file_chooser::PreparedFileChooserActivation::capture_for_renderer_record( + conn, + owner, + page_owner, + renderer_page, + activation, + ) + else { return Self::default(); }; Self { @@ -2143,7 +2153,7 @@ mod producer_tests { let mut out: Vec = Vec::new(); let prepared = super::file_chooser::PreparedFileChooserActivation::capture( - &conn, + &mut conn, &CommandOwnerScope::for_session("SID-typed"), owner, renderer_file_chooser_for_test(source_document, Some("FRAME-typed"), 77, false), @@ -2201,7 +2211,7 @@ mod producer_tests { let owner = page_residence_identity_for_test(&conn, "SID-root-capture"); let prepared = super::file_chooser::PreparedFileChooserActivation::capture( - &conn, + &mut conn, &CommandOwnerScope::for_session("SID-root-capture"), owner, renderer_file_chooser_for_test(source_document, None, 41, false), diff --git a/moli-protocol/src/domains/input/file_chooser.rs b/moli-protocol/src/domains/input/file_chooser.rs index 3a2812f270..ff23efd287 100644 --- a/moli-protocol/src/domains/input/file_chooser.rs +++ b/moli-protocol/src/domains/input/file_chooser.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context as _, Result}; use moli_core::page::{ CompletedPageCommand, PendingPageCommand, RendererDocumentLifecycleIdentity, @@ -29,23 +29,37 @@ pub(crate) struct PreparedFileChooserActivation { source_frame_id: String, backend_node_id: u32, allow_multiple: bool, + command_target: Option, } impl PreparedFileChooserActivation { pub(crate) fn capture( - conn: &CdpConnection, + conn: &mut CdpConnection, owner: &CommandOwnerScope, page_owner: TargetPageResidenceIdentity, activation: RendererPendingFileChooserActivation, ) -> Option { - if !conn.target_page_residence_identity_is_current(&page_owner) - || conn - .target_page_residence_identity_for_owner(owner) - .as_ref() - != Some(&page_owner) - { - return None; - } + let renderer_page = conn.renderer_page_residence_identity_for_owner(owner); + Self::capture_from_source(conn, owner, page_owner, renderer_page, activation) + } + + pub(crate) fn capture_for_renderer_record( + conn: &mut CdpConnection, + owner: &CommandOwnerScope, + page_owner: TargetPageResidenceIdentity, + renderer_page: crate::conn::RendererPageResidenceIdentity, + activation: RendererPendingFileChooserActivation, + ) -> Option { + Self::capture_from_source(conn, owner, page_owner, Some(renderer_page), activation) + } + + fn capture_from_source( + conn: &mut CdpConnection, + owner: &CommandOwnerScope, + page_owner: TargetPageResidenceIdentity, + renderer_page: Option, + activation: RendererPendingFileChooserActivation, + ) -> Option { let source_document = activation.source_document(); let source_frame_id = activation .source_frame_id() @@ -54,7 +68,17 @@ impl PreparedFileChooserActivation { conn.target_session_owner_frame_tree_identity_for_owner(owner) .map(|(frame_id, _, _, _)| frame_id) })?; + let command_target = renderer_page.and_then(|renderer_page| { + conn.capture_document_output_command_target( + owner, + &page_owner, + renderer_page, + source_document, + ) + .ok() + }); Some(Self { + command_target, page_owner, source_document, source_frame_id, @@ -75,6 +99,7 @@ impl PreparedFileChooserActivation { .unwrap_or(root_frame_id) .to_owned(); Self { + command_target: None, page_owner, source_document, source_frame_id, @@ -185,12 +210,7 @@ impl PreparedFileChooserActivation { conn: &mut CdpConnection, owner: &CommandOwnerScope, ) -> Option> { - if !activation_is_current(conn, self) - || conn - .target_page_residence_identity_for_owner(owner) - .as_ref() - != Some(&self.page_owner) - { + if !activation_is_current(conn, self) || !owner_matches_activation(conn, owner, self) { trace_stale_activation(owner.session_id(), self); return None; } @@ -204,12 +224,19 @@ impl PreparedFileChooserActivation { return None; } let inspector = conn.target_renderer_runtime_inspector_session_id_for_owner(owner); - Some(self.page(conn).and_then(|page| { - page.start_document_bidi_node_shared_id_for_backend_node_id( - inspector, - self.backend_node_id, - ) - })) + Some( + self.command_target + .as_ref() + .context(NoDocumentLoaded) + .and_then(|target| { + target.start( + moli_core::RendererPageCommand::DocumentBidiNodeSharedIdForBackendNodeId { + inspector_session_id: inspector, + backend_node_id: self.backend_node_id, + }, + ) + }), + ) } pub(crate) fn finish_query( @@ -217,8 +244,10 @@ impl PreparedFileChooserActivation { conn: &mut CdpConnection, completed: CompletedPageCommand, ) -> Result { - self.page(conn)? - .finish_document_bidi_node_shared_id_for_backend_node_id(completed) + if !activation_is_current(conn, self) { + return Err(NoDocumentLoaded.into()); + } + completed.into_document_bidi_node_shared_id() } pub(crate) fn start_binding( @@ -227,19 +256,21 @@ impl PreparedFileChooserActivation { owner: &CommandOwnerScope, shared_id: String, ) -> Result { - if conn - .target_page_residence_identity_for_owner(owner) - .as_ref() - != Some(&self.page_owner) - { - return Err(anyhow::Error::new(NoDocumentLoaded)); + if !activation_is_current(conn, self) || !owner_matches_activation(conn, owner, self) { + return Err(NoDocumentLoaded.into()); } - let inspector = conn.target_renderer_runtime_inspector_session_id_for_owner(owner); - self.page(conn)?.start_register_document_bidi_node_binding( - inspector, - shared_id, - self.backend_node_id, - ) + let inspector_session_id = + conn.target_renderer_runtime_inspector_session_id_for_owner(owner); + self.command_target + .as_ref() + .context(NoDocumentLoaded)? + .start( + moli_core::RendererPageCommand::RegisterDocumentBidiNodeBinding { + inspector_session_id, + shared_id, + backend_node_id: self.backend_node_id, + }, + ) } pub(crate) fn finish_binding( @@ -247,17 +278,10 @@ impl PreparedFileChooserActivation { conn: &mut CdpConnection, completed: CompletedPageCommand, ) -> Result<()> { - self.page(conn)? - .finish_register_document_bidi_node_binding(completed) - } - - fn page<'a>(&self, conn: &'a mut CdpConnection) -> Result<&'a mut moli_core::page::Page> { if !activation_is_current(conn, self) { - return Err(anyhow::Error::new(NoDocumentLoaded)); + return Err(NoDocumentLoaded.into()); } - conn.loaded_page_mut_for_protocol_access_for_owner(&CommandOwnerScope::for_page_residence( - &self.page_owner, - )) + completed.into_document_bidi_node_binding() } pub(crate) fn finish_event( @@ -267,10 +291,8 @@ impl PreparedFileChooserActivation { shared_id: DevToolsRemoteHandleId, ) -> Option { if !activation_is_current(conn, &self) - || session_id.is_some_and(|session_id| { - conn.target_page_residence_identity_for_session(Some(session_id)) - .as_ref() - != Some(&self.page_owner) + || session_id.is_some_and(|session| { + !owner_matches_activation(conn, &CommandOwnerScope::for_session(session), &self) }) { trace_stale_activation(session_id, &self); @@ -291,7 +313,19 @@ impl PreparedFileChooserActivation { } fn activation_is_current(conn: &CdpConnection, activation: &PreparedFileChooserActivation) -> bool { - conn.target_page_residence_identity_is_current(&activation.page_owner) + conn.target_page_output_residence_is_current(&activation.page_owner) +} + +fn owner_matches_activation( + conn: &CdpConnection, + owner: &CommandOwnerScope, + activation: &PreparedFileChooserActivation, +) -> bool { + conn.target_owner_identity_for_owner(owner) + .is_some_and(|(context, target)| { + context == activation.page_owner.browser_context_id() + && target.as_deref() == activation.page_owner.target_id() + }) } fn trace_stale_activation(session_id: Option<&str>, activation: &PreparedFileChooserActivation) { diff --git a/moli-protocol/src/domains/page.rs b/moli-protocol/src/domains/page.rs index 1d83e5b50f..fb450363c5 100644 --- a/moli-protocol/src/domains/page.rs +++ b/moli-protocol/src/domains/page.rs @@ -128,7 +128,7 @@ pub(crate) use lifecycle::{ }; pub(in crate::domains) use main_document_commit::{ MainDocumentCommitPreparedOutput, append_renderer_main_document_commit_to_output_sink, - project_main_document_commit, + main_document_commit_background_events, project_main_document_commit, }; pub use navigation::BackgroundNavigationCompletion; #[cfg(test)] diff --git a/moli-protocol/src/domains/page/javascript_dialog.rs b/moli-protocol/src/domains/page/javascript_dialog.rs index 04ce8d36fb..c942490b76 100644 --- a/moli-protocol/src/domains/page/javascript_dialog.rs +++ b/moli-protocol/src/domains/page/javascript_dialog.rs @@ -87,7 +87,9 @@ pub(super) fn finish_devtools_get_javascript_dialog_command( conn: &CdpConnection, owner: &CommandOwnerScope, ) -> Result { - let current_page_owner = conn.target_page_residence_identity_for_owner(owner); + let current_page_owner = conn + .pending_target_page_residence_identity_for_owner(owner) + .or_else(|| conn.target_page_residence_identity_for_owner(owner)); let Some(dialog) = conn .target_page_session_state_for_owner(owner) .and_then(|page_state| page_state.javascript_dialog_state.peek_next()) @@ -110,7 +112,9 @@ pub(super) fn finish_devtools_set_javascript_dialog_prompt_text_command( command: DevToolsSetJavaScriptDialogPromptTextCommand, owner: &CommandOwnerScope, ) -> Result { - let current_page_owner = conn.target_page_residence_identity_for_owner(owner); + let current_page_owner = conn + .pending_target_page_residence_identity_for_owner(owner) + .or_else(|| conn.target_page_residence_identity_for_owner(owner)); let Some(result) = conn.with_target_devtools_session_state_for_owner_mut(owner, |state| { let dialog_state = &mut state.page_session_state.javascript_dialog_state; let Some(dialog) = dialog_state @@ -151,7 +155,9 @@ pub(super) fn finish_devtools_handle_javascript_dialog_command( ) -> Result { let session_id = command.context.session_id.as_ref().map(|id| id.as_str()); let command_prompt_text = command.prompt_text; - let current_page_owner = conn.target_page_residence_identity_for_owner(owner); + let current_page_owner = conn + .pending_target_page_residence_identity_for_owner(owner) + .or_else(|| conn.target_page_residence_identity_for_owner(owner)); let Some(dialog) = conn .with_target_devtools_session_state_for_owner_mut(owner, |state| { let dialog_state = &mut state.page_session_state.javascript_dialog_state; @@ -308,7 +314,7 @@ fn emit_one( } fn source_is_current(conn: &CdpConnection, dialog: &PreparedJavaScriptDialog) -> bool { - conn.target_page_protocol_attachment_identity_is_current(dialog.source_attachment()) + conn.target_page_output_attachment_is_current(dialog.source_attachment()) && conn .runtime_session_owner_slot(dialog.source_attachment().session_id()) .is_some_and(|slot| slot.observes_javascript_dialog_scope(dialog.source_dialog_scope())) @@ -336,7 +342,7 @@ fn emit_to_attachment( dialog: PreparedJavaScriptDialog, ) { if !source_is_current(conn, &dialog) - || !conn.target_page_protocol_attachment_identity_is_current(&destination) + || !conn.target_page_output_attachment_is_current(&destination) { trace_stale_source(&dialog); dialog.dismiss(); diff --git a/moli-protocol/src/domains/page/main_document_commit.rs b/moli-protocol/src/domains/page/main_document_commit.rs index 8abe8ca170..c618ebeb7e 100644 --- a/moli-protocol/src/domains/page/main_document_commit.rs +++ b/moli-protocol/src/domains/page/main_document_commit.rs @@ -78,79 +78,98 @@ pub(in crate::domains) fn project_main_document_commit( // commit fact from being projected after replacement. continue; } - - let session_ids = conn.page_event_session_ids_for_owner(owner); - let mut events = Vec::new(); - let Some(frame) = super::lifecycle::frame_record_for_event( - conn, - owner, - &commit.frame_id, - None, - &commit.loader_id, - &commit.url, - ) else { + // Error Documents have no author work to unblock before adoption. + // Their navigation activity owns the loadingFailed prefix and must + // publish it before the frame commit. Keep the native source identity + // above, but leave public frame/lifecycle observations to that activity. + if commit.unreachable_url.is_some() { continue; - }; - super::lifecycle::push_navigation_frame_fact( - &mut events, - &frame, - crate::automation::NavigationFrameEventKind::Navigated, - ); - super::lifecycle::push_document_lifecycle_fact( - &mut events, - &frame, - crate::automation::DocumentLifecyclePhase::Init, - commit.timestamp, - ); - for session_id in session_ids { - let event_owner = session_id - .as_deref() - .map(CommandOwnerScope::for_session) - .unwrap_or_else(|| owner.clone()); - let lifecycle_enabled = conn - .target_page_session_state_for_owner(&event_owner) - .is_some_and(|state| state.page_lifecycle_events); - let dom_enabled = crate::domains::dom::dom_agent_enabled_for_owner(conn, &event_owner); - if !conn - .target_page_session_state_for_owner(&event_owner) - .is_some_and(|state| state.page_domain_enabled) - { - if dom_enabled { - events.push(crate::conn::BackgroundProtocolEvent::page_navigation_frame( - session_id.as_deref(), - super::lifecycle::navigation_frame_fact( - &frame, - crate::automation::NavigationFrameEventKind::DocumentUpdated, - ), - )); - } - continue; - } - - super::emit_navigation_lifecycle_init_background_events( - &mut events, - session_id.as_deref(), - lifecycle_enabled, - &commit.frame_id, - &commit.loader_id, - commit.timestamp, - ); - super::emit_navigation_frame_commit_background_events( - &mut events, - session_id.as_deref(), - dom_enabled, - &commit.frame_id, - &commit.loader_id, - &commit.url, - commit.unreachable_url.as_deref(), - &commit.security_origin, - &commit.secure_context_type, - ); } + + let events = main_document_commit_background_events(conn, owner, &commit); context.command.protocol_events_mut().extend(events); } } +/// Project one frozen commit at the output boundary owned by its navigation. +/// Ordinary Documents use native ingress; error Documents use the activity +/// which first publishes their network failure. +pub(in crate::domains) fn main_document_commit_background_events( + conn: &CdpConnection, + owner: &CommandOwnerScope, + commit: &RendererMainDocumentCommit, +) -> Vec { + let session_ids = conn.page_event_session_ids_for_owner(owner); + let mut events = Vec::new(); + let Some(frame) = super::lifecycle::frame_record_for_event( + conn, + owner, + &commit.frame_id, + None, + &commit.loader_id, + &commit.url, + ) else { + return events; + }; + super::lifecycle::push_navigation_frame_fact( + &mut events, + &frame, + crate::automation::NavigationFrameEventKind::Navigated, + ); + super::lifecycle::push_document_lifecycle_fact( + &mut events, + &frame, + crate::automation::DocumentLifecyclePhase::Init, + commit.timestamp, + ); + for session_id in session_ids { + let event_owner = session_id + .as_deref() + .map(CommandOwnerScope::for_session) + .unwrap_or_else(|| owner.clone()); + let lifecycle_enabled = conn + .target_page_session_state_for_owner(&event_owner) + .is_some_and(|state| state.page_lifecycle_events); + let dom_enabled = crate::domains::dom::dom_agent_enabled_for_owner(conn, &event_owner); + if !conn + .target_page_session_state_for_owner(&event_owner) + .is_some_and(|state| state.page_domain_enabled) + { + if dom_enabled { + events.push(crate::conn::BackgroundProtocolEvent::page_navigation_frame( + session_id.as_deref(), + super::lifecycle::navigation_frame_fact( + &frame, + crate::automation::NavigationFrameEventKind::DocumentUpdated, + ), + )); + } + continue; + } + + super::emit_navigation_lifecycle_init_background_events( + &mut events, + session_id.as_deref(), + lifecycle_enabled, + &commit.frame_id, + &commit.loader_id, + commit.timestamp, + ); + super::emit_navigation_frame_commit_background_events( + &mut events, + session_id.as_deref(), + dom_enabled, + &commit.frame_id, + &commit.loader_id, + &commit.url, + commit.unreachable_url.as_deref(), + &commit.security_origin, + &commit.secure_context_type, + ); + } + events +} + pub(in crate::domains) const SLOT_MAIN_DOCUMENT_COMMIT: ProtocolOutputSlot = ProtocolOutputSlot::MainDocumentCommit; diff --git a/moli-protocol/src/domains/page/navigation.rs b/moli-protocol/src/domains/page/navigation.rs index 5dbf51cc46..4797076adc 100644 --- a/moli-protocol/src/domains/page/navigation.rs +++ b/moli-protocol/src/domains/page/navigation.rs @@ -3051,7 +3051,7 @@ pub(crate) fn emit_same_document_navigation_background_events( for navigation in navigations { let session_id = owner.session_id(); let source_document = navigation.source_document(); - if !conn.target_page_residence_identity_is_current(navigation.owner()) { + if !conn.target_page_output_residence_is_current(navigation.owner()) { tracing::debug!( session_id, ?source_document, @@ -3066,7 +3066,10 @@ pub(crate) fn emit_same_document_navigation_background_events( let Ok(url) = Url::parse(&navigation.url) else { continue; }; - if conn.has_pending_document_navigation_for_owner(owner) { + let source_is_prepared = conn + .runtime_session_owner_slot_for_owner(owner) + .is_some_and(|slot| slot.prepared_document_output_is_from(source_document)); + if conn.has_pending_document_navigation_for_owner(owner) && !source_is_prepared { tracing::debug!( session_id, url = url.as_str(), diff --git a/moli-protocol/src/domains/page/navigation_commit.rs b/moli-protocol/src/domains/page/navigation_commit.rs index 1162dbf332..08bc9861b4 100644 --- a/moli-protocol/src/domains/page/navigation_commit.rs +++ b/moli-protocol/src/domains/page/navigation_commit.rs @@ -36,6 +36,7 @@ struct LoadedNavigationPublication { renderer_output_predecessor: Option, progress_gate: crate::domains::network::MainDocumentProgressGate, network_error_page: Option, + main_document_commit: std::sync::Arc, } impl LoadedNavigationPublication { @@ -77,6 +78,7 @@ impl LoadedNavigationPublication { renderer_output_predecessor, progress_gate, network_error_page, + main_document_commit: commit.clone(), }, page, initial_runtime_realms, @@ -126,6 +128,7 @@ impl LoadedNavigationPublication { renderer_output_predecessor, progress_gate, network_error_page, + main_document_commit, } = self; let is_network_error_page = network_error_page.is_some(); let (page_creation_artifacts, mut deferred_initial_renderer_document_lifecycle_events) = @@ -137,8 +140,10 @@ impl LoadedNavigationPublication { token.clone(), ); if let Some(error) = network_error_page { - navigation_activity = - navigation_activity.with_network_error_page_result(error.error_text().to_owned()); + navigation_activity = navigation_activity.with_network_error_page_result( + error.error_text().to_owned(), + main_document_commit, + ); } if !is_network_error_page { let _ = conn.commit_main_document_resource_for_owner( diff --git a/moli-protocol/src/domains/page/output.rs b/moli-protocol/src/domains/page/output.rs index 49d282a2e4..189b393ccd 100644 --- a/moli-protocol/src/domains/page/output.rs +++ b/moli-protocol/src/domains/page/output.rs @@ -3,14 +3,13 @@ use anyhow::Result; impl PagePreparedOutputs { pub(crate) fn from_renderer_auxiliary_window_navigation( - conn: &CdpConnection, - owner: &CommandOwnerScope, + page_source: Option, window: moli_core::page::RendererAuxiliaryWindow, url: String, kind: moli_core::page::RendererAuxiliaryNavigationKind, document_response: Option, ) -> Self { - let Some(residence) = conn.target_page_residence_identity_for_owner(owner) else { + let Some(residence) = page_source else { return Self::default(); }; Self { @@ -38,11 +37,10 @@ impl PagePreparedOutputs { } pub(crate) fn from_renderer_auxiliary_window_close( - conn: &CdpConnection, - owner: &CommandOwnerScope, + page_source: Option, window: moli_core::page::RendererAuxiliaryWindow, ) -> Self { - let Some(residence) = conn.target_page_residence_identity_for_owner(owner) else { + let Some(residence) = page_source else { return Self::default(); }; Self { @@ -64,14 +62,17 @@ impl PagePreparedOutputs { pub(crate) fn from_renderer_javascript_dialog( conn: &CdpConnection, owner: &CommandOwnerScope, + page_source: Option, dialog: moli_core::page::RendererPendingJavaScriptDialog, ) -> Self { - let Some(source_attachment) = - conn.target_page_protocol_attachment_identity_for_owner(owner) - else { + let Some(page_owner) = page_source else { let _ = dialog.finish(false, String::new()); return Self::default(); }; + let source_attachment = crate::conn::TargetPageProtocolAttachmentIdentity::new( + page_owner, + owner.session_id().map(str::to_owned), + ); let Some((root_frame_id, _, _, _)) = conn.target_session_owner_frame_tree_identity_for_owner(owner) else { @@ -94,11 +95,10 @@ impl PagePreparedOutputs { } pub(crate) fn from_renderer_popup_activation( - conn: &CdpConnection, - owner: &CommandOwnerScope, + page_owner: Option, activation: moli_core::page::RendererPendingPopupActivation, ) -> Self { - let Some(page_owner) = conn.target_page_residence_identity_for_owner(owner) else { + let Some(page_owner) = page_owner else { return Self::default(); }; Self { @@ -124,12 +124,11 @@ impl PagePreparedOutputs { } pub(crate) fn from_renderer_session_history_update( - conn: &CdpConnection, - owner: &CommandOwnerScope, + page_source: Option, source_document: RendererDocumentLifecycleIdentity, update: moli_page_types::SessionHistoryUpdate, ) -> Self { - let Some(residence) = conn.target_page_residence_identity_for_owner(owner) else { + let Some(residence) = page_source else { return Self::default(); }; Self { @@ -149,11 +148,10 @@ impl PagePreparedOutputs { } pub(crate) fn from_renderer_same_document_navigation( - conn: &CdpConnection, - owner: &CommandOwnerScope, + page_source: Option, navigation: RendererDocumentSourcedSameDocumentNavigation, ) -> Self { - let Some(page_owner) = conn.target_page_residence_identity_for_owner(owner) else { + let Some(page_owner) = page_source else { return Self::default(); }; Self { @@ -165,11 +163,10 @@ impl PagePreparedOutputs { } pub(crate) fn from_renderer_top_level_location_navigation( - conn: &CdpConnection, - owner: &CommandOwnerScope, + page_source: Option, navigation: RendererDocumentSourcedTopLevelLocationNavigation, ) -> Self { - let Some(page_owner) = conn.target_page_residence_identity_for_owner(owner) else { + let Some(page_owner) = page_source else { return Self::default(); }; Self { @@ -181,11 +178,11 @@ impl PagePreparedOutputs { } pub(crate) fn from_renderer_top_level_history_traversal( - conn: &CdpConnection, + page_source: Option, owner: &CommandOwnerScope, traversal: RendererPendingTopLevelHistoryTraversal, ) -> Self { - let Some(page_owner) = conn.target_page_residence_identity_for_owner(owner) else { + let Some(page_owner) = page_source else { return Self::default(); }; Self { @@ -866,7 +863,7 @@ pub(in crate::domains) fn project_page_output_finite( { // document.open() does not undo committed history. Page // residence, not current Document identity, is authority. - if conn.target_page_residence_identity_is_current(&residence) { + if conn.target_page_output_residence_is_current(&residence) { conn.record_session_history_update_for_owner(&owner, &update); } } diff --git a/moli-protocol/src/domains/page/renderer_navigation.rs b/moli-protocol/src/domains/page/renderer_navigation.rs index 11e81f0c6f..9209202d2a 100644 --- a/moli-protocol/src/domains/page/renderer_navigation.rs +++ b/moli-protocol/src/domains/page/renderer_navigation.rs @@ -160,7 +160,7 @@ impl PreparedRendererNavigation { action: crate::conn::TopLevelNavigationOwnerAction, ) -> Option { let (owner, page, request) = action.into_parts(); - if !conn.target_page_residence_identity_is_current(&page) { + if !conn.target_page_output_residence_is_current(&page) { return None; } let step = match request { diff --git a/moli-protocol/src/domains/target/worker_target/dedicated_worker.rs b/moli-protocol/src/domains/target/worker_target/dedicated_worker.rs index fae1eb59ae..83dd1900c3 100644 --- a/moli-protocol/src/domains/target/worker_target/dedicated_worker.rs +++ b/moli-protocol/src/domains/target/worker_target/dedicated_worker.rs @@ -14,7 +14,7 @@ pub(super) fn dedicated_worker_target_is_current( .get(&renderer_instance_id) .is_some_and(|target| { target.target_id == target_id - && context.target_page_residence_is_current(&target.owner_page) + && conn.target_page_output_residence_is_current(&target.owner_page) }) } @@ -282,9 +282,16 @@ pub(in crate::domains::target) fn dedicated_worker_auto_attach_owner_session_all let Some(owner_session_id) = owner_session_id else { return false; }; - conn.target_page_residence_identity_for_session(Some(owner_session_id)) + let owner = CommandOwnerScope::for_session(owner_session_id); + (conn + .target_page_residence_identity_for_owner(&owner) .as_ref() == Some(owner_page) + || conn + .pending_target_page_residence_identity_for_owner(&owner) + .as_ref() + == Some(owner_page)) + && conn.target_page_output_residence_is_current(owner_page) } pub(super) fn shared_worker_auto_attach_owner_sessions( diff --git a/moli-protocol/src/domains/target/worker_target/lifecycle_projection.rs b/moli-protocol/src/domains/target/worker_target/lifecycle_projection.rs index 6e0397e4ee..b5e9355e8b 100644 --- a/moli-protocol/src/domains/target/worker_target/lifecycle_projection.rs +++ b/moli-protocol/src/domains/target/worker_target/lifecycle_projection.rs @@ -122,14 +122,35 @@ pub(in crate::domains) fn service_worker_target_lifecycle_prepared_outputs_for_e pub(in crate::domains) fn dedicated_worker_target_lifecycle_prepared_outputs_for_event( conn: &mut CdpConnection, owner: &CommandOwnerScope, + page_source: Option<(TargetPageResidenceIdentity, RendererPageResidenceIdentity)>, event: RendererDedicatedWorkerTargetEvent, ) -> TargetPreparedOutputs { - let Some(owner_page) = conn.target_page_residence_identity_for_owner(owner) else { + let Some((owner_page, owner_renderer_page)) = page_source else { return TargetPreparedOutputs::default(); }; - let Some(owner_renderer_page) = conn.renderer_page_residence_identity_for_owner(owner) else { + if let RendererDedicatedWorkerTargetEvent::Destroyed { instance_id } = event { + let context_id = owner_page.browser_context_id(); + if conn + .browser_context_by_id(context_id) + .and_then(|context| context.dedicated_worker_targets.get(&instance_id)) + .is_none_or(|target| { + target.owner_page != owner_page + || target.renderer_owner_local_host_id + != owner_renderer_page.owner_local_host_id() + }) + { + return TargetPreparedOutputs::default(); + } + return prepare_dedicated_worker_target_retirement( + conn, + context_id, + instance_id, + DedicatedWorkerRetirementCause::RendererDestroyed, + ); + } + if !conn.target_page_output_residence_is_current(&owner_page) { return TargetPreparedOutputs::default(); - }; + } let browser_context_id = owner_page.browser_context_id().to_owned(); let owner_page_network_sessions = conn.network_event_session_ids_for_owner(owner); dedicated_worker_target_lifecycle_outputs_for_events( diff --git a/moli-renderer-v8/src/devtools/command/nested.rs b/moli-renderer-v8/src/devtools/command/nested.rs index 61fe1d12e1..4c38ae7447 100644 --- a/moli-renderer-v8/src/devtools/command/nested.rs +++ b/moli-renderer-v8/src/devtools/command/nested.rs @@ -9,6 +9,7 @@ impl RendererPageCommand { use RendererDevToolsMainNestedDispatch::{InspectorSession, OwnerOnly, PageAgent}; match self { Self::WebMcp { .. } => OwnerOnly, + Self::ForDocument { command, .. } => command.nested_dispatch(), Self::Native(command) => command.operation.nested_dispatch(), Self::Inspector(envelope) if envelope.can_dispatch_at_nested_inspector_session_boundary() => diff --git a/moli-renderer-v8/src/lib.rs b/moli-renderer-v8/src/lib.rs index 6d0ac3d696..cabebd4eb6 100644 --- a/moli-renderer-v8/src/lib.rs +++ b/moli-renderer-v8/src/lib.rs @@ -248,17 +248,17 @@ pub use runtime::{ RendererDedicatedWorkerTargetEvent, RendererDedicatedWorkerTargetInfo, RendererDevToolsAgentToken, RendererDevToolsMainCommandEnvelope, RendererDocumentBoxModel, RendererDocumentChildNodeSnapshotEvent, RendererDocumentChildNodeSnapshotEvents, - RendererDocumentChildNodeSnapshots, RendererDocumentCommitPermit, - RendererDocumentFrontendNodeIdsResolution, RendererDocumentHitTestResult, - RendererDocumentIsolateAccountingDiagnostics, RendererDocumentLifecycleEvent, - RendererDocumentLifecycleEventKind, RendererDocumentLifecycleIdentity, - RendererDocumentLifecycleMilestone, RendererDocumentLifecycleSnapshot, - RendererDocumentLifecycleWaitOutcome, RendererDocumentLifecycleWaiter, - RendererDocumentNodeAttributesResolution, RendererDocumentNodeClientRect, - RendererDocumentNodeGeometry, RendererDocumentNodePropertyResolution, - RendererDocumentNodeReference, RendererDocumentNodeTextResolution, RendererDocumentOptions, - RendererDocumentPreparationTarget, RendererDocumentQuerySelectorNode, - RendererDocumentQuerySelectorResolution, + RendererDocumentChildNodeSnapshots, RendererDocumentCommandTarget, + RendererDocumentCommitPermit, RendererDocumentFrontendNodeIdsResolution, + RendererDocumentHitTestResult, RendererDocumentIsolateAccountingDiagnostics, + RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, + RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, + RendererDocumentLifecycleSnapshot, RendererDocumentLifecycleWaitOutcome, + RendererDocumentLifecycleWaiter, RendererDocumentNodeAttributesResolution, + RendererDocumentNodeClientRect, RendererDocumentNodeGeometry, + RendererDocumentNodePropertyResolution, RendererDocumentNodeReference, + RendererDocumentNodeTextResolution, RendererDocumentOptions, RendererDocumentPreparationTarget, + RendererDocumentQuerySelectorNode, RendererDocumentQuerySelectorResolution, RendererDocumentQuerySelectorWithChildNodeSnapshotEvents, RendererDocumentSourcedSameDocumentNavigation, RendererDocumentSourcedTopLevelLocationNavigation, RendererDocumentTerminationReason, @@ -295,22 +295,22 @@ pub use runtime::{ RendererOutputTransportMessage, RendererOutputTransportReceiver, RendererOutputTransportSendError, RendererOutputTransportSender, RendererOwnerAction, RendererOwnerCommand, RendererOwnerHandle, RendererOwnerLocalHostId, RendererOwnerReply, - RendererOwnerResourceActivitySource, RendererOwnerRuntimeActivitySource, RendererPageCommand, - RendererPageCommandPending, RendererPageCommandPostResponseContinuation, - RendererPageCookieFacadeSnapshotReply, RendererPageCreationArtifacts, - RendererPageCreationDiagnostics, RendererPageCreationPhase, RendererPageCreationProgress, - RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, RendererPageDumpOptions, - RendererPageDumpStripOptions, RendererPageHandle, RendererPageReplacementCommit, - RendererPageReplacementError, RendererPageReplacementFailureDisposition, - RendererPageReplacementReservationRequest, RendererPageReplacementTarget, RendererPageReply, - RendererPageReservationToken, RendererPageState, RendererPageStateRevision, - RendererPageTestingHandle, RendererPageView, RendererPdfOptions, RendererPendingAuxiliaryPage, - RendererPendingDownloadActivation, RendererPendingDownloadResponse, - RendererPendingFileChooserActivation, RendererPendingJavaScriptDialog, - RendererPendingPopupActivation, RendererPendingSameDocumentNavigation, - RendererPendingTopLevelHistoryTraversal, RendererPendingWindowOpenEvent, - RendererPerformanceMetricSnapshot, RendererPointerEventProperties, - RendererPopupActivationSource, RendererPopupDisposition, + RendererOwnerResourceActivitySource, RendererOwnerRuntimeActivitySource, + RendererPageAccessError, RendererPageCommand, RendererPageCommandPending, + RendererPageCommandPostResponseContinuation, RendererPageCookieFacadeSnapshotReply, + RendererPageCreationArtifacts, RendererPageCreationDiagnostics, RendererPageCreationPhase, + RendererPageCreationProgress, RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, + RendererPageDumpOptions, RendererPageDumpStripOptions, RendererPageHandle, + RendererPageReplacementCommit, RendererPageReplacementError, + RendererPageReplacementFailureDisposition, RendererPageReplacementReservationRequest, + RendererPageReplacementTarget, RendererPageReply, RendererPageReservationToken, + RendererPageState, RendererPageStateRevision, RendererPageTestingHandle, RendererPageView, + RendererPdfOptions, RendererPendingAuxiliaryPage, RendererPendingDownloadActivation, + RendererPendingDownloadResponse, RendererPendingFileChooserActivation, + RendererPendingJavaScriptDialog, RendererPendingPopupActivation, + RendererPendingSameDocumentNavigation, RendererPendingTopLevelHistoryTraversal, + RendererPendingWindowOpenEvent, RendererPerformanceMetricSnapshot, + RendererPointerEventProperties, RendererPopupActivationSource, RendererPopupDisposition, RendererPreparedDocumentCommitConfiguration, RendererPrintToPdfReply, RendererPrintToPdfRequest, RendererProtocolObservation, RendererReservedServiceWorkerClient, RendererResourceContentBody, RendererResourceSearchRequest, RendererResourceTextSearchOutcome, diff --git a/moli-renderer-v8/src/runtime/mod.rs b/moli-renderer-v8/src/runtime/mod.rs index 5bea836814..8776e974bd 100644 --- a/moli-renderer-v8/src/runtime/mod.rs +++ b/moli-renderer-v8/src/runtime/mod.rs @@ -312,7 +312,8 @@ pub use self::owner::{ pub use self::owner_local::RendererPageReplacementCommit; pub use self::owner_local::RendererPageTestingHandle; pub use self::owner_local::{ - RendererPageCommandPending, RendererPageHandle, RendererRuntimeInspectorSessionDetachGuard, + RendererDocumentCommandTarget, RendererPageAccessError, RendererPageCommandPending, + RendererPageHandle, RendererRuntimeInspectorSessionDetachGuard, }; pub(crate) use self::owner_local_store::RendererPageToken; pub use self::page::{ diff --git a/moli-renderer-v8/src/runtime/owner/owner_commands.rs b/moli-renderer-v8/src/runtime/owner/owner_commands.rs index 7af4193415..d217db2372 100644 --- a/moli-renderer-v8/src/runtime/owner/owner_commands.rs +++ b/moli-renderer-v8/src/runtime/owner/owner_commands.rs @@ -176,6 +176,31 @@ impl RendererOwnerHandle { self.state.page_table.remove(page_id); } + /// Uses the renderer owner rather than an adopted Page handle. The queued + /// command rechecks the captured Document before touching native requests. + pub fn document_command_target( + &self, + owner_local_host_id: RendererOwnerLocalHostId, + source_document: super::RendererDocumentLifecycleIdentity, + ) -> Result { + use crate::runtime::page_commands::DocumentCommandError; + if owner_local_host_id != self.state.owner_local_host_id { + return Err(DocumentCommandError::WrongOwner.into()); + } + let token = RendererPageToken { + local_host_id: owner_local_host_id, + #[cfg(debug_assertions)] + local_thread_id: (*self.state.owner_local_thread_id.lock()) + .ok_or(DocumentCommandError::OwnerUnavailable)?, + page_id: source_document.frame.page_id, + }; + Ok(super::RendererDocumentCommandTarget { + render_runtime: self.render_runtime.clone(), + token, + document: source_document, + }) + } + pub fn allocate_page_id(&self) -> PageId { PageId::new(self.state.next_page_id.fetch_add(1, Ordering::Relaxed)) } diff --git a/moli-renderer-v8/src/runtime/owner_local.rs b/moli-renderer-v8/src/runtime/owner_local.rs index a51bcfbfc1..9793974673 100644 --- a/moli-renderer-v8/src/runtime/owner_local.rs +++ b/moli-renderer-v8/src/runtime/owner_local.rs @@ -140,11 +140,68 @@ pub struct RendererPageHandle { _not_send: PhantomData>, } +#[derive(Clone, Copy, Debug, thiserror::Error)] +pub enum RendererPageAccessError { + #[error("Renderer Document is no longer current")] + DocumentReplaced, + #[error("renderer local host page {} is already running an owner turn", .page_id.as_u64())] + Busy { page_id: super::PageId }, + #[error("renderer local host no longer tracks page {}", .page_id.as_u64())] + Missing { page_id: super::PageId }, + #[error("renderer local host page {} is retiring", .page_id.as_u64())] + Retiring { page_id: super::PageId }, +} + +/// A revocable command capability for one exact native Document. It keeps +/// neither a Page handle nor the renderer runtime owner alive. +#[derive(Clone, Debug)] +pub struct RendererDocumentCommandTarget { + pub(super) render_runtime: RenderRuntimeHandle, + pub(super) token: RendererPageToken, + pub(super) document: RendererDocumentLifecycleIdentity, +} + +impl PartialEq for RendererDocumentCommandTarget { + fn eq(&self, other: &Self) -> bool { + self.token == other.token && self.document == other.document + } +} + +impl Eq for RendererDocumentCommandTarget {} + +impl RendererDocumentCommandTarget { + pub fn document(&self) -> RendererDocumentLifecycleIdentity { + self.document + } + + pub fn enqueue(&self, command: RendererPageCommand) -> Result { + let reply = self + .render_runtime + .enqueue(RendererOwnerCommand::RunProtocolPageCommand { + token: self.token, + command: RendererPageCommand::ForDocument { + source_document: self.document, + command: Box::new(command), + }, + })?; + Ok(RendererPageCommandPending::from_owner_reply(reply)) + } +} + pub struct RendererPageCommandPending { dispatch: RendererPageCommandPendingDispatch, javascript_dialog_watch: Option, } +impl RendererPageCommandPending { + pub(super) fn from_owner_reply(reply: oneshot::Receiver>) -> Self { + Self { + dispatch: RendererPageCommandPendingDispatch::Owner(reply), + javascript_dialog_watch: None, + } + } +} + enum RendererPageCommandPendingDispatch { Owner(oneshot::Receiver>), InspectorMain(Box), @@ -210,6 +267,20 @@ impl RendererPageHandle { .expect("renderer page handle should remain open while in use") } + pub fn document_command_target( + &self, + document: RendererDocumentLifecycleIdentity, + ) -> Result { + if self.token().page_id != document.frame.page_id { + return Err(super::page_commands::DocumentCommandError::WrongPage.into()); + } + Ok(RendererDocumentCommandTarget { + render_runtime: self.render_runtime.clone(), + token: self.token(), + document, + }) + } + pub fn page_id(&self) -> u64 { self.token().page_id.as_u64() } diff --git a/moli-renderer-v8/src/runtime/owner_local_store/mod.rs b/moli-renderer-v8/src/runtime/owner_local_store/mod.rs index dc58fbc3af..047e37eb80 100644 --- a/moli-renderer-v8/src/runtime/owner_local_store/mod.rs +++ b/moli-renderer-v8/src/runtime/owner_local_store/mod.rs @@ -1396,18 +1396,22 @@ impl RendererOwnerLocalStore { fn take_entry_for_command(&mut self, token: RendererPageToken) -> Result { match self.checkout_entry_for_owner_turn(token) { Ok(entry) => Ok(entry), - Err(LivePageEntryCheckoutError::Busy) => Err(anyhow!( - "renderer local host page {} is already running an owner turn", - token.page_id.as_u64() - )), - Err(LivePageEntryCheckoutError::Retired) => Err(anyhow!( - "renderer local host page {} is retiring", - token.page_id.as_u64() - )), - Err(LivePageEntryCheckoutError::Missing) => Err(anyhow!( - "renderer local host no longer tracks page {}", - token.page_id.as_u64() - )), + Err(LivePageEntryCheckoutError::Busy) => Err(super::RendererPageAccessError::Busy { + page_id: token.page_id, + } + .into()), + Err(LivePageEntryCheckoutError::Retired) => { + Err(super::RendererPageAccessError::Retiring { + page_id: token.page_id, + } + .into()) + } + Err(LivePageEntryCheckoutError::Missing) => { + Err(super::RendererPageAccessError::Missing { + page_id: token.page_id, + } + .into()) + } } } diff --git a/moli-renderer-v8/src/runtime/page_commands.rs b/moli-renderer-v8/src/runtime/page_commands.rs index 51f6507128..493ef1eb9f 100644 --- a/moli-renderer-v8/src/runtime/page_commands.rs +++ b/moli-renderer-v8/src/runtime/page_commands.rs @@ -1,15 +1,42 @@ use super::page_surface::RendererInspectorPageCommand; use super::*; +#[derive(Debug, thiserror::Error)] +pub(super) enum DocumentCommandError { + #[error("Document command belongs to another renderer owner")] + WrongOwner, + #[error("Document command belongs to another renderer Page")] + WrongPage, + #[cfg(debug_assertions)] + #[error("Renderer owner is unavailable")] + OwnerUnavailable, +} + impl PageVm { pub(in crate::runtime) fn dispatch_renderer_page_command( &mut self, command: RendererPageCommand, ) -> Result { + let (source_document, command) = match command { + RendererPageCommand::ForDocument { + source_document, + command, + } => (Some(source_document), *command), + command => (None, command), + }; + if source_document.is_some_and(|source| self.document_lifecycle.identity() != source) { + return Err(RendererPageAccessError::DocumentReplaced.into()); + } if let Some(barrier) = renderer_page_command_action_barrier(&command) { self.flush_page_action_window(barrier)?; } + // Flushing queued input can run script, including document.open(). + // Recheck immediately before touching a Document's native request IDs. + if source_document.is_some_and(|source| self.document_lifecycle.identity() != source) { + return Err(RendererPageAccessError::DocumentReplaced.into()); + } match command { + command @ RendererPageCommand::ForDocument { .. } => self.dispatch_renderer_page_command(command), RendererPageCommand::Native(command) => { Ok(RendererPageReply::NativeCommandReady(Box::new(command.dispatch(self)))) } diff --git a/moli-renderer-v8/src/runtime/page_surface.rs b/moli-renderer-v8/src/runtime/page_surface.rs index 13b6a9b462..0bbe352eb3 100644 --- a/moli-renderer-v8/src/runtime/page_surface.rs +++ b/moli-renderer-v8/src/runtime/page_surface.rs @@ -4750,6 +4750,12 @@ pub enum RendererPageCommand { }, // The call fits inline; its operation boxes the recursively contained page command. Native(RendererCdpCall), + /// Checks the original root Document on the renderer before executing an + /// operation admitted while its browser-side Page is still being adopted. + ForDocument { + source_document: RendererDocumentLifecycleIdentity, + command: Box, + }, Inspector(RendererInspectorCommandEnvelope), EvaluateExpression { expression: String,