Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 110 additions & 13 deletions moli-protocol/src/conn/runtime_load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavigationLoadOutcome> {
if error
.downcast_ref::<NetworkFetchFailureContext>()
.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<Vec<u8>>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")?;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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::<std::io::Error>().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();
Expand Down
33 changes: 33 additions & 0 deletions moli-protocol/src/conn/state/page_slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions moli-protocol/src/conn/state/runtime_slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion moli-protocol/src/domains/network/main_document_progress/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions moli-protocol/src/domains/network/main_document_progress/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
12 changes: 7 additions & 5 deletions moli-protocol/src/domains/page/termination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading