From b43a213dc6b68a314dadcdf36d3b5fb4dc8d8d3b Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:00:53 +0800 Subject: [PATCH] fix(navigation): cancel loading without retiring the active document --- moli-protocol/src/conn/runtime_load.rs | 123 +++++- moli-protocol/src/conn/state/page_slot.rs | 33 ++ moli-protocol/src/conn/state/runtime_slot.rs | 4 + .../network/main_document_progress/mod.rs | 11 +- .../network/main_document_progress/tests.rs | 18 + moli-protocol/src/domains/page/termination.rs | 12 +- .../src/domains/page/tests/navigation.rs | 367 ++++++++++++++++++ .../src/document_runtime/mutation_commands.rs | 2 +- moli-renderer-v8/src/frame_owner_model.rs | 3 +- .../frame_owner_model/lifecycle_blockers.rs | 5 + .../src/frame_owner_model/lifecycle_tasks.rs | 7 + .../src/frame_owner_model/load_event_gate.rs | 91 ++++- .../src/frame_owner_model/records.rs | 2 +- .../src/frame_owner_model/store.rs | 33 +- .../src/frame_owner_model/store_tests.rs | 66 +++- moli-renderer-v8/src/live_document_parser.rs | 1 + .../context_host/host_environment.rs | 2 +- .../context_host/main_document_lifecycle.rs | 3 +- .../src/page_task_queue/stylesheet_task.rs | 3 + .../src/runtime/owner_local_store/entry.rs | 19 + .../src/runtime/owner_local_store/mod.rs | 3 + .../runtime/page_vm/tests/stylesheet_task.rs | 100 +++++ .../runtime/phase_one/pending_residence.rs | 8 + .../src/runtime/phase_one/state.rs | 6 + .../src/runtime/phase_one/streaming.rs | 21 + .../src/runtime/phase_one/streaming_input.rs | 56 ++- .../runtime/phase_one/streaming_residence.rs | 6 + .../src/runtime/tests/open_streaming.rs | 83 ++++ .../src/script_vm/stylesheet_page_tasks.rs | 7 +- .../src/stylesheet_runtime/connected.rs | 4 +- 30 files changed, 1051 insertions(+), 48 deletions(-) diff --git a/moli-protocol/src/conn/runtime_load.rs b/moli-protocol/src/conn/runtime_load.rs index 83f36a44eb..983c29297f 100644 --- a/moli-protocol/src/conn/runtime_load.rs +++ b/moli-protocol/src/conn/runtime_load.rs @@ -592,16 +592,30 @@ async fn first_nonempty_response_body_chunk( Some(chunk) if chunk.is_empty() => continue, Some(chunk) => return Ok(Some(chunk)), None => { - response - .finish() - .await - .context("failed to read page body from stream")?; + response.finish().await?; return Ok(None); } } } } +fn failed_provisional_body_load( + error: anyhow::Error, + context: &str, +) -> anyhow::Result { + if error + .downcast_ref::() + .is_some_and(|failure| { + failure.network_error_text() == moli_fetch::NET_ERR_ABORTED_ERROR_TEXT + }) + { + return Ok(NavigationLoadOutcome::network_failure( + moli_fetch::NET_ERR_ABORTED_ERROR_TEXT.to_owned(), + )); + } + Err(error.context(context.to_owned())) +} + fn spawn_streaming_body_capture( mut response: StreamingRawResponse, initial_chunk: Option>, @@ -959,6 +973,14 @@ impl BackgroundNavigationLoadJob { network_error_text = failure.network_error_text(), "main document transport failed before response metadata" ); + // An aborted provisional navigation never commits a replacement + // document. Retain its network failure for the command/event + // completion path, without creating a browser-owned error page. + if failure.network_error_text() == moli_fetch::NET_ERR_ABORTED_ERROR_TEXT { + return Ok(NavigationLoadOutcome::network_failure( + failure.network_error_text().to_owned(), + )); + } return prepare_network_error_page_navigation_with_engine_async( &mut engine, self.page_reservation, @@ -1355,9 +1377,12 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( body_progress_source.body_network_progress_for_completed_events(network_events); let body_progress_source_for_body_finish = body_progress_source.clone(); let mut initial_body_chunk = if response_status_may_use_http_error_page(response_status) { - match first_nonempty_response_body_chunk(&mut response).await? { - Some(chunk) => Some(chunk), - None => { + match first_nonempty_response_body_chunk(&mut response).await { + Err(error) => { + return failed_provisional_body_load(error, "failed to read page body from stream"); + } + Ok(Some(chunk)) => Some(chunk), + Ok(None) => { let body = CapturedBody::from_string(http_error_page_html(&final_url, response_status)); return prepare_browser_owned_error_page_navigation_with_engine_async( @@ -1392,10 +1417,9 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( .append(&chunk) .context("failed to capture XML page body")?; } - response - .finish() - .await - .context("failed to read XML page body from stream")?; + if let Err(error) = response.finish().await { + return failed_provisional_body_load(error, "failed to read XML page body from stream"); + } let captured_body = body_writer .finish() .context("failed to finish captured XML page body")?; @@ -1471,7 +1495,8 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( let (body_tx, body_rx) = mpsc::channel(EXTERNAL_RAW_BODY_CHANNEL_CAPACITY); let (completion_tx, completion_rx) = oneshot::channel(); - let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new(body_rx, completion_rx); + let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new(body_rx, completion_rx) + .with_stop_loading_cancellation(response.cancellation_handle()); let page_storage = load_inputs.page_storage_handles(); let main_document_commit = load_inputs .main_document_commit_for_final_url(&final_url, None) @@ -1694,7 +1719,8 @@ impl CdpConnection { let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new( renderer_body_rx, renderer_completion_rx, - ); + ) + .with_stop_loading_cancellation(response.cancellation_handle()); let shared_resource_runtime = self.shared_resource_runtime_for_navigation_load_inputs(&load_inputs); let mut engine = self.navigation_engine_handle_for_load_inputs(&load_inputs); @@ -4019,6 +4045,77 @@ mod tests { }; use serde_json::json; + #[test] + fn provisional_body_failure_preserves_typed_source_and_context() { + let source = anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "body transport reset", + )) + .context("transport completion failed"); + let error = + super::failed_provisional_body_load(source, "failed to read XML page body from stream") + .expect_err("transport failure must remain an error"); + assert_eq!( + error.downcast_ref::().unwrap().kind(), + std::io::ErrorKind::ConnectionReset + ); + let message = format!("{error:#}"); + assert!(message.contains("failed to read XML page body from stream")); + assert!(message.contains("transport completion failed")); + assert!(message.contains("body transport reset")); + } + + #[test] + fn provisional_body_failure_does_not_infer_cancellation_from_error_text() { + let error = super::failed_provisional_body_load( + anyhow::anyhow!(moli_fetch::NET_ERR_ABORTED_ERROR_TEXT), + "failed to read page body from stream", + ) + .expect_err("only a typed transport cancellation may become a network outcome"); + assert!(format!("{error:#}").contains(moli_fetch::NET_ERR_ABORTED_ERROR_TEXT)); + } + + #[tokio::test] + async fn response_capture_survives_ordinary_renderer_body_retirement() { + let cancellation = moli_fetch::FetchCancelHandle::new(); + let (source_tx, source_rx) = tokio::sync::mpsc::unbounded_channel(); + let (source_completion_tx, source_completion_rx) = tokio::sync::oneshot::channel(); + let response = moli_fetch::StreamingRawResponse::new( + url::Url::parse("https://capture.test/document").unwrap(), + 200, + Vec::new(), + None, + Vec::new(), + false, + Vec::new(), + source_rx, + cancellation.clone(), + source_completion_rx, + ); + let (renderer_tx, renderer_rx) = tokio::sync::mpsc::channel(1); + let (renderer_completion_tx, renderer_completion_rx) = tokio::sync::oneshot::channel(); + drop(renderer_rx); + drop(renderer_completion_rx); + let capture = super::spawn_streaming_body_capture( + response, + None, + renderer_tx, + renderer_completion_tx, + ); + source_tx.send(b"prefix".to_vec()).unwrap(); + source_tx + .send(b"captured after parser retirement".to_vec()) + .unwrap(); + drop(source_tx); + source_completion_tx.send(Ok(())).unwrap(); + let body = capture.await.unwrap().unwrap(); + assert_eq!( + body.materialize_bytes().unwrap().as_slice(), + b"prefixcaptured after parser retirement" + ); + assert!(!cancellation.is_cancelled()); + } + #[tokio::test] async fn streaming_body_failure_preserves_source_for_renderer_and_navigation() { let (chunks_tx, chunks_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/moli-protocol/src/conn/state/page_slot.rs b/moli-protocol/src/conn/state/page_slot.rs index 3ee27ba80a..61f7dd494a 100644 --- a/moli-protocol/src/conn/state/page_slot.rs +++ b/moli-protocol/src/conn/state/page_slot.rs @@ -722,6 +722,14 @@ impl TargetPageSlot { .is_some_and(|request| request.background_completion_pending) } + pub(crate) fn cancel_inflight_document_navigation(&self) { + if let Some(request) = self.pending_navigation_request.as_ref() { + // Keep the token installed: the existing completion path owns the + // aborted response and must still settle this exact navigation. + request.cancel(); + } + } + pub(crate) fn bind_pending_document_navigation_renderer_page( &mut self, token: &DocumentNavigationToken, @@ -1580,6 +1588,31 @@ mod pending_renderer_page_tests { ); } + #[test] + fn navigation_cancellation_preserves_completion_token_and_is_target_local() { + let mut slot = TargetPageSlot::default(); + let token = slot.start_document_navigation("TID-1".to_owned(), "LOADER-1".to_owned()); + let cancellation = slot + .document_navigation_cancellation_handle(&token) + .unwrap(); + let mut peer = TargetPageSlot::default(); + let peer_token = peer.start_document_navigation("TID-2".to_owned(), "LOADER-2".to_owned()); + let peer_cancellation = peer + .document_navigation_cancellation_handle(&peer_token) + .unwrap(); + slot.cancel_inflight_document_navigation(); + assert!(cancellation.is_cancelled()); + assert!(!peer_cancellation.is_cancelled()); + assert!(slot.accepts_pending_document_navigation_event(&token)); + let next = slot.start_document_navigation("TID-1".to_owned(), "LOADER-next".to_owned()); + assert!( + !slot + .document_navigation_cancellation_handle(&next) + .unwrap() + .is_cancelled() + ); + } + #[test] fn navigation_binding_cannot_follow_a_superseding_navigation() { let mut slot = TargetPageSlot::default(); diff --git a/moli-protocol/src/conn/state/runtime_slot.rs b/moli-protocol/src/conn/state/runtime_slot.rs index 665372e432..3bfb87ac8f 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -574,6 +574,10 @@ impl TargetRuntimeSlot { self.page_slot.has_inflight_background_navigation() } + pub(crate) fn cancel_inflight_document_navigation(&self) { + self.page_slot.cancel_inflight_document_navigation(); + } + pub(crate) fn accepts_document_body_completion_event( &self, token: &DocumentNavigationToken, diff --git a/moli-protocol/src/domains/network/main_document_progress/mod.rs b/moli-protocol/src/domains/network/main_document_progress/mod.rs index f2f14e723f..6b7bc70538 100644 --- a/moli-protocol/src/domains/network/main_document_progress/mod.rs +++ b/moli-protocol/src/domains/network/main_document_progress/mod.rs @@ -350,17 +350,26 @@ fn materialize_navigation_load_outcome( materialize_download_navigation_progress(conn, state, *navigation), ), NavigationLoadOutcome::NetworkFailure(error_text) => { + let document_policy = failed_navigation_document_policy(&error_text); MaterializedNavigationLoadOutcome::Failed(materialize_failed_navigation_progress( conn, state, error_text, - FailedNavigationDocumentPolicy::InvalidateCommittedDocument, + document_policy, FailedNavigationResponseMode::CdpErrorTextResult, )) } } } +fn failed_navigation_document_policy(error_text: &str) -> FailedNavigationDocumentPolicy { + if error_text == moli_fetch::NET_ERR_ABORTED_ERROR_TEXT { + FailedNavigationDocumentPolicy::PreserveCommittedDocument + } else { + FailedNavigationDocumentPolicy::InvalidateCommittedDocument + } +} + pub(crate) fn materialize_navigation_load_result( conn: &mut CdpConnection, state: &NavigationDispatchState, diff --git a/moli-protocol/src/domains/network/main_document_progress/tests.rs b/moli-protocol/src/domains/network/main_document_progress/tests.rs index 0e9922e880..6dbadedced 100644 --- a/moli-protocol/src/domains/network/main_document_progress/tests.rs +++ b/moli-protocol/src/domains/network/main_document_progress/tests.rs @@ -18,6 +18,24 @@ use super::gate::{ }; use super::*; +#[test] +fn aborted_navigation_preserves_document_without_changing_other_failure_policies() { + assert_eq!( + failed_navigation_document_policy(moli_fetch::NET_ERR_ABORTED_ERROR_TEXT), + FailedNavigationDocumentPolicy::PreserveCommittedDocument + ); + for error in [ + "net::ERR_CONNECTION_REFUSED", + "net::ERR_TIMED_OUT", + "net::ERR_HTTP_RESPONSE_CODE_FAILURE", + ] { + assert_eq!( + failed_navigation_document_policy(error), + FailedNavigationDocumentPolicy::InvalidateCommittedDocument + ); + } +} + fn completed_events() -> CompletedMainDocumentNetworkEvents { CompletedMainDocumentNetworkEvents::new( "GET".to_owned(), diff --git a/moli-protocol/src/domains/page/termination.rs b/moli-protocol/src/domains/page/termination.rs index 6fb233e3ac..a90b721ae5 100644 --- a/moli-protocol/src/domains/page/termination.rs +++ b/moli-protocol/src/domains/page/termination.rs @@ -395,11 +395,13 @@ pub(super) async fn complete_stop_loading_command_dispatch( owner: &CommandOwnerScope, ) -> PageCommandTaskStep { let mut out = Vec::new(); - if let Ok(slot) = conn.runtime_session_owner_slot_mut_for_owner(owner) - && let Some(page) = slot.loaded_page_mut() - && let Err(error) = page.stop_document_lifecycle_async().await - { - tracing::debug!(%error, "failed to stop renderer document lifecycle"); + if let Ok(slot) = conn.runtime_session_owner_slot_mut_for_owner(owner) { + slot.cancel_inflight_document_navigation(); + if let Some(page) = slot.loaded_page_mut() + && let Err(error) = page.stop_document_lifecycle_async().await + { + tracing::debug!(%error, "failed to stop renderer document lifecycle"); + } } let ( pending_navigations, diff --git a/moli-protocol/src/domains/page/tests/navigation.rs b/moli-protocol/src/domains/page/tests/navigation.rs index b6fca07522..df26041230 100644 --- a/moli-protocol/src/domains/page/tests/navigation.rs +++ b/moli-protocol/src/domains/page/tests/navigation.rs @@ -5464,6 +5464,373 @@ async fn stop_loading_aborts_paused_request_stage_navigation() { ); } } +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_cancels_inflight_unpaused_navigation_transport() { + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + let token = ctx + .conn + .browser_context + .as_mut() + .unwrap() + .start_document_navigation_for_active_target("LOADER-inflight-stop".to_owned()) + .unwrap(); + let cancellation = ctx + .conn + .document_navigation_cancellation_handle(&token) + .unwrap(); + ctx.conn.arm_background_navigation_completion(&token, None); + assert!(!cancellation.is_cancelled()); + + ctx.process_async(json!({ + "id": 901, + "method": "Page.stopLoading", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(901, json!({}), Some("SID-1")); + assert!( + cancellation.is_cancelled(), + "stopLoading must cancel an ordinary HTTP navigation, not only Fetch-paused requests" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_before_response_preserves_document_and_allows_next_navigation() { + assert_stopped_provisional_navigation_preserves_document(None).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_during_xml_body_preserves_document_and_allows_next_navigation() { + assert_stopped_provisional_navigation_preserves_document(Some((200, "application/xml"))).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_during_http_error_body_preserves_document_and_allows_next_navigation() { + assert_stopped_provisional_navigation_preserves_document(Some((500, "text/html"))).await; +} + +async fn assert_stopped_provisional_navigation_preserves_document( + response_head: Option<(u16, &'static str)>, +) { + let request_received = std::sync::Arc::new(tokio::sync::Notify::new()); + let release_response = std::sync::Arc::new(tokio::sync::Notify::new()); + let handler_received = request_received.clone(); + let handler_release = release_response.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let app = axum::Router::new() + .route( + "/held", + axum::routing::get(move || { + let received = handler_received.clone(); + let release = handler_release.clone(); + async move { + received.notify_one(); + let (status, content_type) = if let Some(head) = response_head { + head + } else { + release.notified().await; + return axum::http::Response::builder() + .header("content-type", "text/html") + .body(axum::body::Body::from("cancelled response")) + .unwrap(); + }; + let body = futures_util::stream::once(async move { + release.notified().await; + Ok::<_, std::convert::Infallible>(axum::body::Bytes::from_static( + b"cancelled response", + )) + }); + axum::http::Response::builder() + .status(status) + .header("content-type", content_type) + .body(axum::body::Body::from_stream(body)) + .unwrap() + } + }), + ) + .route( + "/ready", + axum::routing::get(|| async { + axum::response::Html("subsequent document") + }), + ); + axum::serve(listener, app).await.unwrap(); + }); + + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + let initial_url = "data:text/html,previous committed document"; + ctx.install_navigation_fixture_for_session_owner(initial_url, Some("SID-1")) + .await; + wait_until_renderer_document_load(&mut ctx, Some("SID-1"), "TID-1", LOADER_ID).await; + for (id, method) in [ + (910, "Page.enable"), + (911, "DOM.enable"), + (912, "Network.enable"), + (913, "Runtime.enable"), + ] { + ctx.process_async(json!({ + "id": id, + "method": method, + "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, id)["result"], json!({})); + } + let previous_html = loaded_page_html_for_test(&mut ctx).await; + ctx.sent.clear(); + ctx.enable_background_navigation_scheduler_for_test(); + + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_async(json!({ + "id": 914, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/held") } + })) + .await; + tokio::time::timeout( + std::time::Duration::from_secs(5), + request_received.notified(), + ) + .await + .expect("the ordinary HTTP navigation should reach the held response"); + if let Some((status, _)) = response_head { + wait_until_scheduler_message( + &mut ctx, + "held document response headers", + |message| { + message["method"] == json!("Network.responseReceived") + && message["params"]["type"] == json!("Document") + && message["params"]["response"]["status"] == json!(status) + }, + ) + .await; + } else { + assert!(ctx.sent.iter().all(|message| message["id"] != json!(914))); + } + + ctx.process_and_wait_for_response_async(json!({ + "id": 915, + "method": "Page.stopLoading", + "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 915)["result"], json!({})); + wait_until_scheduler_message(&mut ctx, "cancelled navigation response", |message| { + message["id"] == json!(914) + }) + .await; + let navigation = take_response_by_id(&mut ctx, 914); + assert_eq!(navigation["result"]["frameId"], json!("TID-1")); + if response_head.is_some_and(|(status, _)| status == 200) { + // Successful response headers acknowledge navigation before + // XML buffering completes; cancellation must not send a + // second reply or replace the still-committed old Document. + assert!(navigation["result"].get("errorText").is_none()); + } else { + assert_eq!(navigation["result"]["errorText"], json!("net::ERR_ABORTED")); + } + wait_until_scheduler_message( + &mut ctx, + "cancelled navigation network event", + |message| { + message["method"] == json!("Network.loadingFailed") + && message["params"]["errorText"] == json!("net::ERR_ABORTED") + }, + ) + .await; + let failure = ctx + .sent + .iter() + .find(|message| message["method"] == json!("Network.loadingFailed")) + .expect("cancelled request failure"); + assert_eq!(failure["params"]["canceled"], json!(true)); + assert_eq!(failure["params"]["type"], json!("Document")); + assert!(ctx.sent.iter().all(|message| message["id"] != json!(914))); + assert_eq!(loaded_page_html_for_test(&mut ctx).await, previous_html); + assert_eq!( + ctx.conn.browser_context.as_ref().unwrap().target_url(), + initial_url + ); + for method in [ + "Page.frameNavigated", + "DOM.documentUpdated", + "Runtime.executionContextsCleared", + "Runtime.executionContextCreated", + "Page.domContentEventFired", + "Page.loadEventFired", + ] { + assert!( + ctx.sent + .iter() + .all(|message| message["method"] != json!(method)), + "cancelled pre-response navigation must not replace the document: {method}" + ); + } + + // The response remains held until cancellation has completed. No + // server delay or timing race can masquerade as transport abort. + release_response.notify_one(); + ctx.sent.clear(); + ctx.process_and_wait_for_response_async(json!({ + "id": 916, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/ready") } + })) + .await; + let navigation = take_response_by_id(&mut ctx, 916); + assert!(navigation["result"].get("errorText").is_none()); + wait_until_frame_stopped_loading(&mut ctx, "TID-1").await; + let html = loaded_page_html_for_test(&mut ctx).await; + assert!(html.contains("subsequent document")); + assert!(!html.contains("cancelled response")); + assert_eq!( + ctx.conn.browser_context.as_ref().unwrap().target_url(), + format!("http://{addr}/ready") + ); + }) + .await; + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_after_commit_cancels_transport_without_replacing_partial_document() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let prefix_parsed = std::sync::Arc::new(tokio::sync::Notify::new()); + let transport_closed = std::sync::Arc::new(tokio::sync::Notify::new()); + let server_parsed = prefix_parsed.clone(); + let server_closed = transport_closed.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let parsed = server_parsed.clone(); + let closed = server_closed.clone(); + tokio::spawn(async move { + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + if socket.read(&mut byte).await.unwrap() == 0 { + return; + } + request.push(byte[0]); + } + if request.starts_with(b"GET /stream ") { + let prefix = b"
committed prefix
"; + let tail = b"
unreceived tail
"; + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + prefix.len() + tail.len() + ); + socket.write_all(headers.as_bytes()).await.unwrap(); + socket.write_all(prefix).await.unwrap(); + socket.flush().await.unwrap(); + // The server never supplies EOF or the tail. Completion + // here can only come from the client's transport close. + let read = socket.read(&mut byte).await; + assert!( + matches!(read, Ok(0)) || read.is_err(), + "client must close the held transfer: {read:?}" + ); + closed.notify_one(); + } else if request.starts_with(b"GET /parsed ") { + socket + .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + parsed.notify_one(); + } else { + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 26\r\nConnection: close\r\n\r\ncomplete page").await.unwrap(); + } + }); + } + }); + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + ctx.enable_page_events_for_test(Some("SID-1")); + ctx.enable_background_navigation_scheduler_for_test(); + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_and_wait_for_response_async(json!({ + "id": 920, "method": "Page.navigate", "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/stream") } + })) + .await; + assert!( + take_response_by_id(&mut ctx, 920)["result"] + .get("errorText") + .is_none() + ); + wait_until_scheduler_message(&mut ctx, "streaming document commit", |message| { + message["method"] == json!("Page.frameNavigated") + }) + .await; + tokio::time::timeout(std::time::Duration::from_secs(5), prefix_parsed.notified()) + .await + .expect("committed prefix script must execute before stopping"); + assert!( + loaded_page_html_for_test(&mut ctx) + .await + .contains("committed prefix") + ); + ctx.sent.clear(); + ctx.process_and_wait_for_response_async(json!({ + "id": 921, "method": "Page.stopLoading", "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 921)["result"], json!({})); + tokio::time::timeout( + std::time::Duration::from_secs(5), + transport_closed.notified(), + ) + .await + .expect("explicit document stop must cancel the committed response transport"); + let html = loaded_page_html_for_test(&mut ctx).await; + assert!(html.contains("committed prefix")); + assert!(!html.contains("unreceived tail")); + assert_eq!( + ctx.conn.browser_context.as_ref().unwrap().target_url(), + format!("http://{addr}/stream") + ); + assert!( + ctx.sent + .iter() + .all(|message| message["method"] != json!("Page.frameNavigated")) + ); + + ctx.sent.clear(); + ctx.process_and_wait_for_response_async(json!({ + "id": 922, "method": "Page.navigate", "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/complete") } + })) + .await; + assert!( + take_response_by_id(&mut ctx, 922)["result"] + .get("errorText") + .is_none() + ); + wait_until_frame_stopped_loading(&mut ctx, "TID-1").await; + let completed_html = loaded_page_html_for_test(&mut ctx).await; + assert!(completed_html.contains("complete page")); + ctx.process_and_wait_for_response_async(json!({ + "id": 923, "method": "Page.stopLoading", "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 923)["result"], json!({})); + assert_eq!(loaded_page_html_for_test(&mut ctx).await, completed_html); + }) + .await; + server.abort(); +} + #[tokio::test(flavor = "multi_thread")] async fn stop_loading_without_browser_context_returns_empty_result() { let mut ctx = TestContext::new(); diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands.rs b/moli-renderer-v8/src/document_runtime/mutation_commands.rs index 13ce9879e1..28ca83d04c 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -1826,7 +1826,7 @@ pub(super) fn finish_runtime_mutation_effects( owner = ?binding.owner(), element = ?binding.element(), load_delay_token = ?binding.load_delay_token(), - settled, + ?settled, "settled invalidated connected-style lease at mutation commit" ); } diff --git a/moli-renderer-v8/src/frame_owner_model.rs b/moli-renderer-v8/src/frame_owner_model.rs index f057450810..3304a765ed 100644 --- a/moli-renderer-v8/src/frame_owner_model.rs +++ b/moli-renderer-v8/src/frame_owner_model.rs @@ -78,7 +78,8 @@ pub(crate) use lifecycle_tasks::{ MainDocumentImageLoadDelayBinding, MainDocumentInteractiveLifecycleAction, MainDocumentMediaLoadDelayBinding, MainDocumentScriptLoadDelayKind, MainDocumentScriptLoadDelayLease, MainDocumentScriptLoadDelayRelease, - MainDocumentStyleLoadEventBinding, StylesheetSubresourceLoadDelayBinding, + MainDocumentStyleLoadEventBinding, MainDocumentStyleLoadEventSettlement, + StylesheetSubresourceLoadDelayBinding, }; pub(crate) use load_delivery_tasks::{ FrameDocumentLoadDeliveryAction, FrameDocumentLoadDeliveryAdmission, diff --git a/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs b/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs index f040b53749..55197a2331 100644 --- a/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs +++ b/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs @@ -94,6 +94,11 @@ impl DocumentLifecycleBlockers { self.window_load.clear(); } + pub(super) fn cancel_for_stop(&mut self) { + self.parser_deferred_scripts.clear(); + self.window_load.cancel(); + } + #[cfg(test)] pub(super) fn len(&self) -> usize { self.parser_deferred_scripts.len() + self.window_load.len() diff --git a/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs b/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs index 6a624156a0..5d122d995f 100644 --- a/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs +++ b/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs @@ -81,6 +81,13 @@ impl MainDocumentScriptLoadDelayLease { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MainDocumentStyleLoadEventSettlement { + NotOwned, + Released, + CancelledAfterStop, +} + /// Exact main-document ownership for a connected `