diff --git a/moli-protocol/src/conn/runtime_load.rs b/moli-protocol/src/conn/runtime_load.rs index 6b5109e783e..823df4d79e0 100644 --- a/moli-protocol/src/conn/runtime_load.rs +++ b/moli-protocol/src/conn/runtime_load.rs @@ -490,16 +490,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>, @@ -867,6 +881,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, @@ -1271,9 +1293,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 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( @@ -1299,7 +1324,8 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( // including XML charsets and BOMs. 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) @@ -3803,6 +3829,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 shared_body_cancellation_keeps_a_typed_cause_for_both_consumers() { let (completion_tx, completion_rx) = tokio::sync::oneshot::channel(); diff --git a/moli-protocol/src/conn/state/page_slot.rs b/moli-protocol/src/conn/state/page_slot.rs index f7adf205f0d..489ec064ed8 100644 --- a/moli-protocol/src/conn/state/page_slot.rs +++ b/moli-protocol/src/conn/state/page_slot.rs @@ -760,6 +760,14 @@ impl TargetPageSlot { .is_some_and(|request| request.background_work == BackgroundWorkState::Running) } + pub(crate) fn cancel_inflight_document_navigation(&mut self) { + if let Some(request) = self.pending_navigation_request.as_mut() { + // 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, @@ -1621,6 +1629,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 7a2833dcc2b..31ddf1e4e6e 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -573,6 +573,10 @@ impl TargetRuntimeSlot { self.page_slot.has_inflight_background_navigation() } + pub(crate) fn cancel_inflight_document_navigation(&mut 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 80ab431d03f..1b6b056a372 100644 --- a/moli-protocol/src/domains/network/main_document_progress/mod.rs +++ b/moli-protocol/src/domains/network/main_document_progress/mod.rs @@ -372,17 +372,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/page/termination.rs b/moli-protocol/src/domains/page/termination.rs index 3420cebe86f..f1c4c1e799f 100644 --- a/moli-protocol/src/domains/page/termination.rs +++ b/moli-protocol/src/domains/page/termination.rs @@ -396,11 +396,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-renderer-v8/src/document_runtime/mutation_commands.rs b/moli-renderer-v8/src/document_runtime/mutation_commands.rs index 57d817d346b..c352ad8f5c5 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -1812,7 +1812,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 8418fe31082..0086c5aee86 100644 --- a/moli-renderer-v8/src/frame_owner_model.rs +++ b/moli-renderer-v8/src/frame_owner_model.rs @@ -75,7 +75,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 f040b537496..55197a23310 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 6a624156a06..5d122d995f5 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 `