feat(popup): adopt stable renderer-owned auxiliary pages

Create auxiliary contexts as real Pages with synchronous initial realms, stable WindowProxy handoff and one navigation owner. Preserve the Page residence across redirects, no-content responses and transport error documents.
This commit is contained in:
ldm0
2026-08-28 23:03:24 +08:00
parent 4726f89911
commit fa43124ff7
131 changed files with 16642 additions and 1221 deletions
File diff suppressed because it is too large Load Diff
+50 -3
View File
@@ -35,7 +35,6 @@ use std::sync::Arc;
use crate::renderer::{
RendererPageCommand, RendererPageCommandPending, RendererPageHandle, RendererPageReply,
RendererPageState,
};
use anyhow::Result;
pub use command_dispatch::{
@@ -88,7 +87,8 @@ pub use moli_renderer_v8::{
RendererCaptureScreencastFrameRequest, RendererCaptureScreenshotReply,
RendererCaptureScreenshotRequest, RendererCommandTurnCompletion, RendererCommandTurnOutput,
RendererDedicatedWorkerTargetEvent, RendererDedicatedWorkerTargetInfo,
RendererDevToolsAgentToken, RendererDocumentHitTestResult,
RendererDevToolsAgentToken, RendererDocumentContinuationCompletion,
RendererDocumentContinuationObserver, RendererDocumentHitTestResult,
RendererDocumentIsolateAccountingDiagnostics, RendererDocumentLifecycleEvent,
RendererDocumentLifecycleEventKind, RendererDocumentLifecycleIdentity,
RendererDocumentLifecycleMilestone, RendererDocumentLifecycleSnapshot,
@@ -108,7 +108,9 @@ pub use moli_renderer_v8::{
RendererMainDocumentCommit, RendererPageCommandPostResponseContinuation,
RendererPageCreationArtifacts, RendererPageCreationDiagnostics,
RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, RendererPageDumpOptions,
RendererPageDumpStripOptions, RendererPendingDownloadActivation,
RendererPageDumpStripOptions, RendererPageReplacementCommitError,
RendererPageReplacementCommitFailureDisposition, RendererPageReplacementReservationPending,
RendererPageState, RendererPendingAuxiliaryPage, RendererPendingDownloadActivation,
RendererPendingDownloadResponse, RendererPendingFileChooserActivation,
RendererPendingJavaScriptDialog, RendererPendingPopupActivation,
RendererPendingSameDocumentNavigation, RendererPendingTopLevelHistoryTraversal,
@@ -207,6 +209,14 @@ impl fmt::Debug for Page {
}
impl Page {
#[doc(hidden)]
pub fn apply_renderer_document_continuation_state(
&mut self,
page_state: Arc<RendererPageState>,
) {
self.replace_page_state(page_state);
}
pub(crate) fn from_attached_handle(
handle: RendererPageHandle,
page_state: Arc<RendererPageState>,
@@ -234,6 +244,27 @@ impl Page {
}
}
pub(crate) fn adopt_renderer_page_replacement(
&mut self,
replacement: moli_renderer_v8::RendererPageReplacementCommit,
) -> Result<(
RendererPageCreationDiagnostics,
RendererPageCreationArtifacts,
Option<RendererPendingDownloadActivation>,
)> {
self.handle.adopt_page_replacement(&replacement)?;
let (
_renderer_devtools_agent_token,
page_state,
creation_diagnostics,
creation_artifacts,
pending_download,
) = replacement.into_parts();
self.page_state.replace(page_state);
self.page_creation_artifacts = None;
Ok((creation_diagnostics, creation_artifacts, pending_download))
}
/// Takes the renderer lifecycle facts captured while this page was created.
///
/// Direct-fetch callers can retain these facts until the page is handed to
@@ -264,6 +295,22 @@ impl Page {
self.renderer_devtools_command_session_id = session_id;
}
#[doc(hidden)]
pub async fn reserve_renderer_document_replacement(
&self,
) -> Result<moli_renderer_v8::RendererPageReservationToken> {
self.handle
.reserve_replacement_document_for_navigation()
.await
}
#[doc(hidden)]
pub fn start_renderer_document_replacement_reservation(
&self,
) -> RendererPageReplacementReservationPending {
self.handle.start_replacement_document_reservation()
}
#[doc(hidden)]
pub fn renderer_agent_attachment_id(&self) -> Option<RendererAgentAttachmentId> {
self.renderer_agent_attachment_id
+6 -4
View File
@@ -44,10 +44,10 @@ pub use moli_renderer_v8::{
RendererServiceWorkerMainResourceFetch, RendererSharedWorkerRuntimeDiagnostics,
};
pub use navigation_engine::{
BuiltDocumentPage, CommittedDocumentResourceSource, NavigationEngine,
NavigationPageStorageHandles, NavigationResourceStorageHandles, NavigationRuntimeConfig,
PendingBuiltDocumentPage, PreparedDocumentPage, PreparedDocumentPageCommitConfiguration,
PreparedDocumentPageCommitPermit,
BuiltDocumentPage, CommittedDocumentPageReplacement, CommittedDocumentResourceSource,
NavigationEngine, NavigationPageStorageHandles, NavigationResourceStorageHandles,
NavigationRuntimeConfig, PendingBuiltDocumentPage, PreparedDocumentPage,
PreparedDocumentPageCommitConfiguration, PreparedDocumentPageCommitPermit,
};
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
@@ -1102,6 +1102,7 @@ impl Browser {
source,
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
})
.collect::<Vec<_>>();
@@ -1230,6 +1231,7 @@ impl Browser {
source,
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
})
.collect::<Vec<_>>();
+196 -1
View File
@@ -188,6 +188,12 @@ pub struct BuiltDocumentPage {
pub pending_download: Option<RendererPendingDownloadActivation>,
}
pub struct CommittedDocumentPageReplacement {
pub page_creation_diagnostics: RendererPageCreationDiagnostics,
pub page_creation_artifacts: RendererPageCreationArtifacts,
pub pending_download: Option<RendererPendingDownloadActivation>,
}
/// A renderer document whose isolate and DevTools agent are reserved, but
/// whose parser and execution contexts have not started.
pub struct PreparedDocumentPage {
@@ -310,6 +316,41 @@ impl PreparedDocumentPage {
})
}
/// Commits the prepared Document into `page` without replacing its owning
/// handle. The Page identity is checked before the renderer crosses the
/// old-Document teardown boundary.
pub async fn commit_page_replacement(
self,
permit: PreparedDocumentPageCommitPermit,
page: &mut Page,
) -> std::result::Result<
CommittedDocumentPageReplacement,
moli_renderer_v8::RendererPageReplacementCommitError,
> {
if self.renderer_owner_local_host_id() != page.renderer_owner_local_host_id()
|| self.renderer_page_id() != page.renderer_page_id()
{
return Err(
moli_renderer_v8::RendererPageReplacementCommitError::page_preserved(anyhow!(
"prepared Document belongs to a different live Page"
)),
);
}
let replacement = self.prepared.commit_page_replacement(permit.permit).await?;
let (page_creation_diagnostics, page_creation_artifacts, pending_download) = page
.adopt_renderer_page_replacement(replacement)
.map_err(|error| {
moli_renderer_v8::RendererPageReplacementCommitError::page_retired(
error.context("failed to adopt committed live Page replacement"),
)
})?;
Ok(CommittedDocumentPageReplacement {
page_creation_diagnostics,
page_creation_artifacts,
pending_download,
})
}
pub async fn cancel(self) -> Result<()> {
self.prepared.cancel().await
}
@@ -458,6 +499,13 @@ impl NavigationEngine {
self.js_runtime.reserve_page_for_creation()
}
pub fn owns_page_reservation(
&self,
reservation: moli_renderer_v8::RendererPageReservationToken,
) -> bool {
self.js_runtime.owns_page_reservation(reservation)
}
pub fn new() -> Self {
Self::new_with_runtime_config(NavigationRuntimeConfig::default())
}
@@ -663,6 +711,10 @@ impl NavigationEngine {
self.js_runtime.renderer_owner_id_for_diagnostics()
}
pub fn renderer_owner_local_host_id(&self) -> moli_renderer_v8::RendererOwnerLocalHostId {
self.js_runtime.renderer_owner_local_host_id()
}
pub fn shares_renderer_owner_with(&self, other: &Self) -> bool {
self.js_runtime
.shares_renderer_owner_with(&other.js_runtime)
@@ -2435,7 +2487,10 @@ impl NavigationEngine {
#[cfg(test)]
mod tests {
use super::{CommittedDocumentResourceSource, NavigationEngine, NavigationRuntimeConfig};
use super::{
CommittedDocumentResourceSource, NavigationEngine, NavigationRuntimeConfig,
PageVmInitStage, PreparedDocumentPage,
};
use crate::{
LayoutPolicy, OptionalResourceFetchMask,
network::ResourceRequestClient,
@@ -2443,6 +2498,7 @@ mod tests {
};
use moli_cookie_jar::new_shared_browser_cookie_store;
use moli_fetch::FetchConfig;
use tokio::sync::oneshot;
use url::Url;
static_assertions::assert_not_impl_any!(Browser: Send, Sync);
@@ -2737,4 +2793,143 @@ mod tests {
.contains("does not belong to the NavigationEngine browser resource runtime")
);
}
#[tokio::test(flavor = "current_thread")]
async fn core_page_adopts_prepared_renderer_replacement_without_replacing_ownership() {
let mut engine = NavigationEngine::new();
let initial_url =
Url::parse("https://example.test/core-replacement/initial").expect("initial URL");
let built = engine
.build_inline_html_document_page_best_effort_with_inspector_session_restores_async(
new_shared_browser_cookie_store(),
moli_renderer_v8::RendererWebStorageHandles::ephemeral(),
None,
None,
initial_url,
None,
"<!doctype html><body>initial core Page</body>".to_owned(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
None,
None,
false,
1.0,
Default::default(),
None,
false,
Vec::new(),
false,
None,
None,
None,
)
.await
.expect("initial core Page should build");
let mut page = built.page;
let page_id = page.renderer_page_id();
let owner_local_host_id = page.renderer_owner_local_host_id();
let initial_agent = page.renderer_devtools_agent_token();
let reservation = page
.reserve_renderer_document_replacement()
.await
.expect("core Page should reserve its live renderer generation");
let replacement_url =
Url::parse("https://example.test/core-replacement/next").expect("replacement URL");
let (completion_tx, completion_rx) = oneshot::channel();
let (body_tx, raw_body) =
moli_renderer_v8::ExternalRawDocumentBodyStream::channel(completion_rx);
body_tx
.send(
b"<!doctype html><body><main id='core-replacement'>replacement core Page</main></body>"
.to_vec(),
)
.await
.expect("replacement body should send");
drop(body_tx);
completion_tx
.send(Ok(()))
.expect("replacement body completion should send");
let loader = engine
.resource_request_client()
.expect("engine loader should exist");
let prepared = engine
.js_runtime
.prepare_streaming_raw_document_from_external_body_with_inspector_session_restores(
reservation,
replacement_url.clone(),
replacement_url.clone(),
None,
false,
0,
200,
vec![("content-type".to_owned(), "text/html".to_owned())],
&loader,
moli_renderer_v8::RendererWebStorageHandles::ephemeral(),
raw_body,
None,
None,
Vec::new(),
Vec::new(),
Vec::new(),
None,
None,
false,
false,
1.0,
Default::default(),
None,
false,
Vec::new(),
false,
None,
Vec::new(),
false,
PageVmInitStage::Load,
moli_renderer_v8::RendererReplyBoundary::Stage,
moli_renderer_v8::RendererTopLevelNavigationDispatch::DelegateToBrowser,
moli_renderer_v8::RendererNavigationReplyPolicy::ReturnWithPendingNavigation,
None,
None,
None,
None,
)
.await
.expect("core replacement should prepare");
let replacement_agent = prepared.renderer_devtools_agent_token();
assert_ne!(replacement_agent, initial_agent);
let prepared = PreparedDocumentPage { prepared };
let permit = prepared.issue_commit_permit();
let committed = prepared
.commit_page_replacement(permit, &mut page)
.await
.expect("core Page should adopt its renderer replacement");
assert_eq!(page.renderer_page_id(), page_id);
assert_eq!(page.renderer_owner_local_host_id(), owner_local_host_id);
assert_eq!(page.renderer_devtools_agent_token(), replacement_agent);
assert_eq!(page.final_url(), &replacement_url);
assert!(committed.pending_download.is_none());
assert!(
committed
.page_creation_artifacts
.lifecycle_snapshot
.load
.is_some()
);
let value = page
.evaluate_runtime_expression_async(
"document.querySelector('#core-replacement').textContent",
)
.await
.expect("replacement core Page should evaluate through its original owner");
assert_eq!(
value.get("value"),
Some(&serde_json::json!("replacement core Page"))
);
page.close_async()
.await
.expect("replacement core Page should close through its original owner");
}
}
+3
View File
@@ -1033,6 +1033,9 @@ pub struct DocumentStartScript {
pub source: String,
pub world_name: Option<String>,
pub has_bidi_channel_argument: bool,
/// Browser-owned realm bootstrap that must not appear as author script
/// execution to Inspector instrumentation breakpoints.
pub browser_internal: bool,
pub bidi_channel_handoffs: Vec<BidiPreloadChannelHandoff>,
}
+40 -1
View File
@@ -232,6 +232,7 @@ pub struct CdpRendererCommandPolicy {
renderer_replacement: CdpRendererCommandReplacement,
renderer_replay_dispatch: CdpRendererCommandReplayDispatch,
executes_page_javascript: bool,
waits_for_document_continuation: bool,
}
impl CdpRendererCommandPolicy {
@@ -254,6 +255,10 @@ impl CdpRendererCommandPolicy {
pub const fn executes_page_javascript(self) -> bool {
self.executes_page_javascript
}
pub const fn waits_for_document_continuation(self) -> bool {
self.waits_for_document_continuation
}
}
/// Parsed inbound CDP command plus its downstream JSON representation.
@@ -398,6 +403,18 @@ impl ParsedCdpCommand {
pub fn renderer_replay_dispatch(&self) -> CdpRendererCommandReplayDispatch {
self.renderer_policy.replay_dispatch()
}
/// Whether this command reads protocol-owned state that is finalized by
/// the committed Document's typed continuation.
///
/// A committed renderer attachment is usable while its parser is still
/// blocked: Runtime/DOM commands must be able to observe that `loading`
/// Document. Keep this narrower than the main-thread renderer access
/// classification, which protects commands from binding to the attachment
/// being replaced.
pub fn waits_for_document_continuation_to_finish(&self) -> bool {
self.renderer_policy.waits_for_document_continuation()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -472,6 +489,7 @@ impl RuntimeWireAction {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PageWireAction {
MainThread,
NavigationHistory,
Crash,
Other,
}
@@ -487,6 +505,7 @@ impl PageWireAction {
| "searchInResource"
| "getLayoutMetrics"
| "printToPDF" => Self::MainThread,
"getNavigationHistory" => Self::NavigationHistory,
"crash" => Self::Crash,
_ => Self::Other,
}
@@ -573,6 +592,7 @@ impl CdpRendererCommandPolicy {
renderer_replacement: CdpRendererCommandReplacement::Replay,
renderer_replay_dispatch: CdpRendererCommandReplayDispatch::Direct,
executes_page_javascript: false,
waits_for_document_continuation: false,
};
};
let domain = CdpMethodDomain::parse(domain);
@@ -612,7 +632,9 @@ impl CdpRendererCommandPolicy {
}
}
CdpMethodDomain::Page => match PageWireAction::parse(action) {
PageWireAction::MainThread => CdpRendererCommandAccess::MainThread,
PageWireAction::MainThread | PageWireAction::NavigationHistory => {
CdpRendererCommandAccess::MainThread
}
PageWireAction::Crash => CdpRendererCommandAccess::Io,
PageWireAction::Other => CdpRendererCommandAccess::OwnerIndependent,
},
@@ -650,6 +672,8 @@ impl CdpRendererCommandPolicy {
executes_page_javascript: runtime_action
.is_some_and(RuntimeWireAction::executes_page_javascript)
|| debugger_action.is_some_and(DebuggerWireAction::executes_page_javascript),
waits_for_document_continuation: domain == CdpMethodDomain::Page
&& PageWireAction::parse(action) == PageWireAction::NavigationHistory,
}
}
}
@@ -676,6 +700,7 @@ mod tests {
command.renderer_access(),
CdpRendererCommandAccess::MainThread
);
assert!(!command.waits_for_document_continuation_to_finish());
}
#[test]
@@ -690,6 +715,7 @@ mod tests {
command.renderer_access(),
CdpRendererCommandAccess::OwnerIndependent
);
assert!(!command.waits_for_document_continuation_to_finish());
}
#[test]
@@ -855,6 +881,7 @@ mod tests {
command.renderer_access(),
CdpRendererCommandAccess::MainThread
);
assert!(!command.waits_for_document_continuation_to_finish());
}
#[test]
@@ -1012,4 +1039,16 @@ mod tests {
})
);
}
#[test]
fn navigation_history_waits_for_committed_document_continuation() {
let command =
parse(r#"{"id":12,"method":"Page.getNavigationHistory","params":{},"sessionId":"s1"}"#);
assert_eq!(
command.renderer_access(),
CdpRendererCommandAccess::MainThread
);
assert!(command.waits_for_document_continuation_to_finish());
}
}
+90 -7
View File
@@ -1,5 +1,5 @@
use std::{
collections::{HashMap, VecDeque},
collections::{HashMap, HashSet, VecDeque},
sync::{Arc, atomic::AtomicU64},
time::{Duration, Instant},
};
@@ -11,7 +11,8 @@ use moli_core::{
runtime::NavigationRuntimeConfig,
};
use moli_protocol::{
BackgroundNavigationCompletion, BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection,
BackgroundDocumentContinuationCompletion, BackgroundNavigationCompletion,
BackgroundNavigationGateKey, BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection,
CdpInitialStoragePartition, CdpRendererCommandAccess, CdpSchedulerEvent,
CdpTargetHostLifecycleObserver, CommandDispatchContext, CompletedCdpCommandDispatch,
CompletedDeferredMainDocumentLoadCompletion, DeferredMainDocumentLoadCompletionOutputAction,
@@ -112,6 +113,7 @@ pub(crate) enum CommandStartAction {
pub(crate) struct CdpScheduler {
conn: CdpConnection,
document_continuation_gate: DocumentContinuationGate,
pending_navigation_background_events: VecDeque<PendingNavigationBackgroundEvent>,
runtime_command_output_barriers: RuntimeCommandOutputBarriers,
queues: SchedulerQueues,
@@ -166,6 +168,32 @@ fn append_unique_target_ids(target_ids: &mut Vec<String>, additional: Vec<String
}
}
#[derive(Debug, Default)]
struct DocumentContinuationGate {
pending: HashSet<BackgroundNavigationGateKey>,
}
impl DocumentContinuationGate {
fn note_navigation_started(&mut self, key: BackgroundNavigationGateKey) {
self.pending.insert(key);
}
fn note_navigation_completion_drained(&mut self, key: &BackgroundNavigationGateKey) {
if !self.pending.remove(key) {
tracing::debug!(
?key,
"document continuation completion did not match any pending gate"
);
}
}
fn has_inflight_navigation_for_session_owner(&self, session_id: Option<&str>) -> bool {
self.pending
.iter()
.any(|key| key.matches_session_owner(session_id))
}
}
#[derive(Debug, Default)]
struct ForegroundNavigationNetworkBarrier {
wait_for_document_response_started: bool,
@@ -220,10 +248,13 @@ impl ForegroundNavigationNetworkBarrier {
pub(crate) type CdpBackgroundEventReceiver = mpsc::UnboundedReceiver<BackgroundProtocolEvent>;
pub(crate) type CdpBackgroundNavigationCompletionReceiver =
mpsc::UnboundedReceiver<BackgroundNavigationCompletion>;
pub(crate) type CdpDocumentContinuationCompletionReceiver =
mpsc::UnboundedReceiver<BackgroundDocumentContinuationCompletion>;
pub(crate) type CdpRendererPublicationReceiver = moli_core::RendererOutputTransportReceiver;
pub(crate) struct CdpSchedulerEventReceivers {
pub(crate) background_event_rx: CdpBackgroundEventReceiver,
pub(crate) background_navigation_completion_rx: CdpBackgroundNavigationCompletionReceiver,
pub(crate) document_continuation_completion_rx: CdpDocumentContinuationCompletionReceiver,
pub(crate) renderer_publication_rx: CdpRendererPublicationReceiver,
}
@@ -244,9 +275,42 @@ pub(crate) enum CdpSchedulerInterleavedInput {
}
impl CdpSchedulerEventReceivers {
pub(crate) async fn recv_navigation_completion(
&mut self,
) -> Option<BackgroundNavigationCompletion> {
tokio::select! {
biased;
maybe_continuation = self.document_continuation_completion_rx.recv() => {
maybe_continuation.map(
BackgroundNavigationCompletion::document_continuation,
)
}
maybe_completion = self.background_navigation_completion_rx.recv() => {
maybe_completion
}
}
}
pub(crate) fn try_recv_navigation_completion(
&mut self,
) -> Option<BackgroundNavigationCompletion> {
self.document_continuation_completion_rx
.try_recv()
.ok()
.map(BackgroundNavigationCompletion::document_continuation)
.or_else(|| self.background_navigation_completion_rx.try_recv().ok())
}
pub(crate) async fn recv_interleaved_input(&mut self) -> Option<CdpSchedulerInterleavedInput> {
tokio::select! {
biased;
maybe_continuation = self.document_continuation_completion_rx.recv() => {
maybe_continuation.map(|completion| {
CdpSchedulerInterleavedInput::BackgroundNavigationCompletion(
BackgroundNavigationCompletion::document_continuation(completion),
)
})
}
maybe_completion = self.background_navigation_completion_rx.recv() => {
maybe_completion.map(
CdpSchedulerInterleavedInput::BackgroundNavigationCompletion,
@@ -692,6 +756,7 @@ impl CdpScheduler {
fn new(conn: CdpConnection) -> Self {
Self {
conn,
document_continuation_gate: DocumentContinuationGate::default(),
pending_navigation_background_events: VecDeque::new(),
runtime_command_output_barriers: RuntimeCommandOutputBarriers::default(),
queues: SchedulerQueues::default(),
@@ -735,6 +800,11 @@ impl CdpScheduler {
scheduler
.conn
.set_background_navigation_completion_sender(background_navigation_completion_tx);
let (document_continuation_completion_tx, document_continuation_completion_rx) =
mpsc::unbounded_channel();
scheduler
.conn
.set_document_continuation_completion_sender(document_continuation_completion_tx);
let (renderer_publication_tx, renderer_publication_rx) =
moli_core::renderer_output_transport_channel();
scheduler
@@ -745,6 +815,7 @@ impl CdpScheduler {
CdpSchedulerEventReceivers {
background_event_rx,
background_navigation_completion_rx,
document_continuation_completion_rx,
renderer_publication_rx,
},
)
@@ -1968,10 +2039,14 @@ impl CdpScheduler {
}
pub(crate) fn command_waits_for_navigation_flush(&self, command: &ParsedCdpCommand) -> bool {
command.renderer_access() == CdpRendererCommandAccess::MainThread
(command.renderer_access() == CdpRendererCommandAccess::MainThread
&& self
.conn
.renderer_document_navigation_is_suspended_for_session_owner(command.session_id())
.renderer_document_navigation_is_suspended_for_session_owner(command.session_id()))
|| (command.waits_for_document_continuation_to_finish()
&& self
.document_continuation_gate
.has_inflight_navigation_for_session_owner(command.session_id()))
}
pub(crate) fn route_background_event_around_inflight_navigation(
@@ -2102,6 +2177,9 @@ impl CdpScheduler {
);
}
match event {
CdpSchedulerEvent::DocumentContinuationStarted { key } => {
self.document_continuation_gate.note_navigation_started(key);
}
CdpSchedulerEvent::ProtocolWorkPublished { work } => {
if moli_trace::cdp_nav_timing_enabled() {
tracing::info!(
@@ -2669,6 +2747,10 @@ impl CdpScheduler {
Option<moli_core::RendererOutputFence>,
) {
let trace_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now);
if let Some(key) = completion.document_continuation_gate_key() {
self.document_continuation_gate
.note_navigation_completion_drained(&key);
}
let outcome = self
.conn
.drain_background_navigation_completion_turn_async(completion)
@@ -2711,9 +2793,10 @@ impl CdpScheduler {
self.drain_background_navigation_completion(completion)
.await;
assert!(
renderer_output_predecessor.is_none(),
"navigation completion must use its exact insertion boundary, not a command predecessor"
renderer_output_boundary.is_none() || renderer_output_predecessor.is_none(),
"navigation completion cannot carry both an insertion boundary and a continuation predecessor"
);
let renderer_output_fence = renderer_output_boundary.or(renderer_output_predecessor);
prefix.append(completion_prefix);
// The completion can still carry a renderer insertion boundary. While
// that boundary is projected, later publications may contain the
@@ -2722,7 +2805,7 @@ impl CdpScheduler {
// prefix so those later publications cannot overtake it.
self.append_navigation_gate_release_before_renderer_boundary(&mut prefix);
suffix.append(self.drain_background_events_around_inflight_navigation(background_event_rx));
(prefix, suffix, renderer_output_boundary)
(prefix, suffix, renderer_output_fence)
}
/// Completes one navigation owner turn together with the exact concrete
@@ -23,11 +23,11 @@ use crate::{
use super::frontend_control::CdpFrontendControlState;
use super::{
CdpBackgroundEventReceiver, CdpBackgroundNavigationCompletionReceiver, CdpCookieSnapshot,
CdpOwnerActorLifecycle, CdpRendererPublicationReceiver, CdpScheduler,
CdpSchedulerEventReceivers, CommandDispatchState, CommandDispatchStepOutput,
CommandOutputReleasePermit, CommandStartAction, CommandTaskStep, CommandTurnOutput,
ProtocolAdapterScheduler, ProtocolAdapterSchedulerAdvance, ProtocolAdapterSchedulerInput,
ProtocolOutputSequence,
CdpDocumentContinuationCompletionReceiver, CdpOwnerActorLifecycle,
CdpRendererPublicationReceiver, CdpScheduler, CdpSchedulerEventReceivers, CommandDispatchState,
CommandDispatchStepOutput, CommandOutputReleasePermit, CommandStartAction, CommandTaskStep,
CommandTurnOutput, ProtocolAdapterScheduler, ProtocolAdapterSchedulerAdvance,
ProtocolAdapterSchedulerInput, ProtocolOutputSequence,
};
struct PendingRuntimeDeferredReplyState {
@@ -90,6 +90,7 @@ enum SchedulerInput {
struct SchedulerInputReceivers {
background_event_rx: CdpBackgroundEventReceiver,
background_navigation_completion_rx: CdpBackgroundNavigationCompletionReceiver,
document_continuation_completion_rx: CdpDocumentContinuationCompletionReceiver,
renderer_publication_rx: CdpRendererPublicationReceiver,
buffered_renderer_publications: VecDeque<RendererOutputTransportMessage>,
ready_background_inputs_before_runtime_response: VecDeque<SchedulerInput>,
@@ -100,6 +101,7 @@ impl SchedulerInputReceivers {
Self {
background_event_rx: receivers.background_event_rx,
background_navigation_completion_rx: receivers.background_navigation_completion_rx,
document_continuation_completion_rx: receivers.document_continuation_completion_rx,
renderer_publication_rx: receivers.renderer_publication_rx,
buffered_renderer_publications: VecDeque::new(),
ready_background_inputs_before_runtime_response: VecDeque::new(),
@@ -119,6 +121,12 @@ impl SchedulerInputReceivers {
// A correlated response carries an exact concrete output cursor; the
// cursor fence below consumes only concrete stream traffic until that
// position is admitted.
while let Ok(completion) = self.document_continuation_completion_rx.try_recv() {
self.ready_background_inputs_before_runtime_response
.push_back(SchedulerInput::BackgroundNavigationCompletion(
BackgroundNavigationCompletion::document_continuation(completion),
));
}
while let Ok(completion) = self.background_navigation_completion_rx.try_recv() {
self.ready_background_inputs_before_runtime_response
.push_back(SchedulerInput::BackgroundNavigationCompletion(completion));
@@ -177,6 +185,13 @@ impl SchedulerInputReceivers {
self.queue_ready_background_inputs_before_runtime_response(response);
self.ready_background_inputs_before_runtime_response.pop_front()
}
maybe_continuation = self.document_continuation_completion_rx.recv() => {
maybe_continuation.map(|completion| {
SchedulerInput::BackgroundNavigationCompletion(
BackgroundNavigationCompletion::document_continuation(completion),
)
})
}
maybe_completion = self.background_navigation_completion_rx.recv() => {
maybe_completion.map(SchedulerInput::BackgroundNavigationCompletion)
}
@@ -193,6 +208,13 @@ impl SchedulerInputReceivers {
} else {
tokio::select! {
biased;
maybe_continuation = self.document_continuation_completion_rx.recv() => {
maybe_continuation.map(|completion| {
SchedulerInput::BackgroundNavigationCompletion(
BackgroundNavigationCompletion::document_continuation(completion),
)
})
}
maybe_completion = self.background_navigation_completion_rx.recv() => {
maybe_completion.map(SchedulerInput::BackgroundNavigationCompletion)
}
@@ -2093,11 +2115,14 @@ mod tests {
let (background_event_tx, background_event_rx) = mpsc::unbounded_channel();
let (_background_navigation_tx, background_navigation_completion_rx) =
mpsc::unbounded_channel();
let (_document_continuation_tx, document_continuation_completion_rx) =
mpsc::unbounded_channel();
let (_renderer_publication_tx, renderer_publication_rx) =
moli_core::renderer_output_transport_channel();
let mut receivers = SchedulerInputReceivers::new(CdpSchedulerEventReceivers {
background_event_rx,
background_navigation_completion_rx,
document_continuation_completion_rx,
renderer_publication_rx,
});
let (runtime_response_tx, mut runtime_response_rx) = mpsc::unbounded_channel();
@@ -993,11 +993,14 @@ async fn closed_renderer_transport_fails_an_unprojected_command_fence() {
let (_background_event_tx, background_event_rx) = tokio::sync::mpsc::unbounded_channel();
let (_navigation_tx, background_navigation_completion_rx) =
tokio::sync::mpsc::unbounded_channel();
let (_document_continuation_tx, document_continuation_completion_rx) =
tokio::sync::mpsc::unbounded_channel();
let (renderer_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel();
drop(renderer_tx);
let mut receivers = super::CdpSchedulerEventReceivers {
background_event_rx,
background_navigation_completion_rx,
document_continuation_completion_rx,
renderer_publication_rx,
};
let stream =
@@ -1990,8 +1990,10 @@ async fn websocket_cdp_raw_client_runtime_evaluate_immediately_after_page_naviga
// Send Page.navigate then Runtime.evaluate back-to-back without
// waiting for any lifecycle event. This mirrors what raw-CDP clients
// do. The socket loop must drain the pending background navigation
// completion BEFORE dispatching Runtime.evaluate, otherwise evaluate
// would observe `NoDocumentLoaded`.
// attachment BEFORE dispatching Runtime.evaluate, otherwise evaluate
// would observe `NoDocumentLoaded`. The committed Document's parser
// continuation is deliberately independent, so its body may still be
// incomplete when evaluate runs.
socket
.send(WsMessage::Text(
json!({
@@ -2012,7 +2014,7 @@ async fn websocket_cdp_raw_client_runtime_evaluate_immediately_after_page_naviga
"method": "Runtime.evaluate",
"sessionId": session_id,
"params": {
"expression": "document.querySelector('#link') ? document.querySelector('#link').textContent : ''",
"expression": "JSON.stringify({ url: document.URL, ready: document.readyState, hasLink: document.querySelector('#link') !== null })",
"returnByValue": true
}
})
@@ -2034,10 +2036,27 @@ async fn websocket_cdp_raw_client_runtime_evaluate_immediately_after_page_naviga
let value = evaluate_response["result"]["result"]["value"]
.as_str()
.expect("string value from evaluate");
let observed: serde_json::Value =
serde_json::from_str(value).expect("Runtime.evaluate should return serialized JSON");
assert_eq!(
value, "link",
"evaluate must observe the new document body (got `{value}`)"
observed["url"],
json!(fixture_url),
"evaluate must observe the newly committed Document: {observed}"
);
assert!(
matches!(
observed["ready"].as_str(),
Some("loading" | "interactive" | "complete")
),
"the committed Document must expose a valid readyState: {observed}"
);
if observed["ready"] != json!("loading") {
assert_eq!(
observed["hasLink"],
json!(true),
"a parser-complete Document must contain its body link: {observed}"
);
}
let _ = socket.close(None).await;
abort_test_cdp_server(protocol_server).await;
@@ -2143,9 +2162,9 @@ async fn websocket_cdp_runtime_control_command_waits_for_navigation_attachment_c
let new_isolate_id = resumed["result"]["id"]
.as_str()
.expect("replacement Runtime.getIsolateId");
assert_ne!(
assert_eq!(
new_isolate_id, old_isolate_id,
"queued command must bind to the replacement document's renderer attachment"
"stable Page replacement must preserve its script-agent isolate while the queued command crosses the renderer attachment cutover"
);
let _ = socket.close(None).await;
@@ -3881,11 +3900,17 @@ document.addEventListener('readystatechange', () => {
.iter()
.find(|message| message["id"] == json!(7_u64))
.expect("Runtime.evaluate response");
assert_eq!(
response["result"]["result"]["value"],
json!(
"{\"marker\":\"committed-source\",\"ready\":\"interactive\",\"deferExecuted\":false}"
)
let observed: serde_json::Value = serde_json::from_str(
response["result"]["result"]["value"]
.as_str()
.expect("serialized Runtime.evaluate result"),
)
.expect("Runtime.evaluate should return serialized JSON");
assert_eq!(observed["marker"], json!("committed-source"));
assert_eq!(observed["deferExecuted"], json!(false));
assert!(
matches!(observed["ready"].as_str(), Some("loading" | "interactive")),
"the parser may race the command after starting the deferred fetch, but load must remain pending: {observed}"
);
release_defer.notify_one();
@@ -195,6 +195,18 @@ async fn handle_bidi_session_socket_local(
break;
}
}
maybe_continuation = receivers.document_continuation_completion_rx.recv() => {
let Some(continuation) = maybe_continuation else {
break;
};
if !actor.handle_background_navigation_completion(
&mut scheduler,
&mut receivers,
moli_protocol::BackgroundNavigationCompletion::document_continuation(continuation),
).await {
break;
}
}
maybe_completion = receivers.background_navigation_completion_rx.recv() => {
let Some(completion) = maybe_completion else {
break;
@@ -3173,7 +3185,7 @@ async fn drain_bidi_background_navigation_before_command(
) -> Result<BidiDevToolsEventSources, BidiRendererOutputTransportFailure> {
let mut event_sources = drain_ready_bidi_background_navigation(scheduler, receivers).await?;
while scheduler.has_inflight_background_navigation() {
let Some(completion) = receivers.background_navigation_completion_rx.recv().await else {
let Some(completion) = receivers.recv_navigation_completion().await else {
return Ok(event_sources);
};
match scheduler
@@ -3202,7 +3214,7 @@ async fn drain_ready_bidi_background_navigation(
scheduler
.drain_background_events_around_inflight_navigation(&mut receivers.background_event_rx),
);
while let Ok(completion) = receivers.background_navigation_completion_rx.try_recv() {
while let Some(completion) = receivers.try_recv_navigation_completion() {
match scheduler
.drain_background_navigation_completion_with_progress_barrier(completion, receivers)
.await
@@ -1362,6 +1362,19 @@ async fn classic_session_runtime_loop(
adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked);
tokio::select! {
biased;
continuation = receivers.document_continuation_completion_rx.recv() => {
let Some(continuation) = continuation else {
break;
};
if !attached.actor.handle_background_navigation_completion(
&mut scheduler,
&mut receivers,
moli_protocol::BackgroundNavigationCompletion::document_continuation(continuation),
).await
{
detach_bidi = true;
}
}
completion = receivers.background_navigation_completion_rx.recv() => {
let Some(completion) = completion else {
break;
@@ -1497,6 +1510,21 @@ async fn classic_session_runtime_loop(
adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked);
tokio::select! {
biased;
continuation = receivers.document_continuation_completion_rx.recv() => {
let Some(continuation) = continuation else {
break;
};
if scheduler
.drain_background_navigation_completion_with_progress_barrier(
moli_protocol::BackgroundNavigationCompletion::document_continuation(continuation),
&mut receivers,
)
.await
.is_err()
{
break;
}
}
completion = receivers.background_navigation_completion_rx.recv() => {
let Some(completion) = completion else {
break;
+97 -17
View File
@@ -506,6 +506,7 @@ pub(crate) use output::{
pub(crate) use page_state::{LoadedNavigationPageCommit, LoadedNavigationRendererAttachmentCommit};
pub(crate) use popup_activation_work::PopupTargetActivationAction;
pub(crate) use popup_navigation_work::{
PopupTargetNavigationAuthorityState, PopupTargetNavigationClaimIdentity,
PopupTargetNavigationKind, PopupTargetNavigationOwnerAction,
};
pub(crate) use runtime_eval::{
@@ -526,13 +527,16 @@ pub use runtime_eval::{
pub(crate) use runtime_load::decode_data_url_response;
pub(crate) use runtime_load::{
BackgroundNavigationBodyCompletionSink, BackgroundNavigationEarlyResult,
BackgroundNavigationLoadJob, CompletedInitialDocumentPageBuild, FailedInitialDocumentPageBuild,
InitialDocumentPageInstallResult, InitialDocumentPageOwner, PausedResponsePreparedDocument,
PendingInitialDocumentPageBuild, ResponseCommitReady,
BackgroundNavigationLoadJob, CommittedStablePageNavigation, CompletedInitialDocumentPageBuild,
FailedInitialDocumentPageBuild, InitialDocumentPageInstallResult, InitialDocumentPageOwner,
PausedResponsePreparedDocument, PendingInitialDocumentPageBuild, ResponseCommitReady,
StablePageNavigationCommitTarget,
};
use scheduler_hooks::CdpSchedulerHooks;
use scheduler_state::CdpConnectionSchedulerState;
pub use scheduler_state::{CdpRendererOwnerTurnOutcome, CdpSchedulerEvent, CdpTurnOutcome};
pub use scheduler_state::{
BackgroundNavigationGateKey, CdpRendererOwnerTurnOutcome, CdpSchedulerEvent, CdpTurnOutcome,
};
#[cfg(test)]
pub(crate) use site_data_manager_surface::{
BrowserContextReservedSiteDataOwnerState, BrowserContextSiteDataManagerOwnerState,
@@ -542,8 +546,9 @@ pub use state::{
DocumentStartScript, DownloadNavigation, EmulatedDeviceMetrics, EmulatedGeolocationOverride,
EmulatedGeolocationOverrideState, EmulatedMediaOverrides, IsolatedWorldDefinition,
LoadedNavigation, NavigationDispatchState, NavigationLoadOutcome, NavigationRequestLoadPolicy,
PageNavigationHistoryEntry, ParkedFetchState, ParkedNetworkArtifacts, ParkedPageSessionState,
PendingNavigationHistoryUpdate, RuntimeBindingDefinition, TargetInfo, URL_BASE,
NoCommitResponseNavigation, PageNavigationHistoryEntry, ParkedFetchState,
ParkedNetworkArtifacts, ParkedPageSessionState, PendingNavigationHistoryUpdate,
RuntimeBindingDefinition, TargetInfo, URL_BASE,
};
pub(crate) use state::{
BrowserContextPageStorageHandles, BrowserContextResourceStorageHandles,
@@ -1422,6 +1427,16 @@ impl CdpConnection {
.set_background_navigation_completion_sender(sender);
}
pub fn set_document_continuation_completion_sender(
&mut self,
sender: tokio::sync::mpsc::UnboundedSender<
crate::domains::page::BackgroundDocumentContinuationCompletion,
>,
) {
self.scheduler_hooks
.set_document_continuation_completion_sender(sender);
}
pub fn set_renderer_publication_sender(
&mut self,
sender: moli_core::RendererOutputTransportSender,
@@ -2321,9 +2336,13 @@ impl CdpConnection {
}
fn can_run_background_navigation_for_session_owner(&self, session_id: Option<&str>) -> bool {
if !self
.scheduler_hooks
.has_background_navigation_completion_sender()
if !self.scheduler_hooks.has_background_event_sender()
|| !self
.scheduler_hooks
.has_background_navigation_completion_sender()
|| !self
.scheduler_hooks
.has_document_continuation_completion_sender()
{
return false;
}
@@ -2332,9 +2351,13 @@ impl CdpConnection {
}
fn can_run_background_navigation_for_active_session(&self) -> bool {
if !self
.scheduler_hooks
.has_background_navigation_completion_sender()
if !self.scheduler_hooks.has_background_event_sender()
|| !self
.scheduler_hooks
.has_background_navigation_completion_sender()
|| !self
.scheduler_hooks
.has_document_continuation_completion_sender()
|| !self.inactive_browser_contexts.is_empty()
{
return false;
@@ -2401,6 +2424,32 @@ impl CdpConnection {
}
return command_context.take_protocol_events();
}
crate::domains::page::BackgroundNavigationCompletion::DocumentContinuation(
completion,
) => {
let gate_key = completion.document_continuation_gate_key();
let target_id = gate_key.target_id().map(str::to_owned);
let session_id = gate_key.session_id().map(str::to_owned);
let (renderer_output_predecessor, renderer_page_state) =
completion.into_renderer_completion_parts();
if let Some(predecessor) = renderer_output_predecessor {
command_context.set_renderer_output_predecessor(predecessor);
}
let page_state_applied = renderer_page_state.is_some_and(|page_state| {
self.apply_renderer_document_continuation_page_state(&gate_key, page_state)
});
if page_state_applied {
let _ = self
.target_session_owner_navigation_history_snapshot(session_id.as_deref());
if let Some(target_id) = target_id {
command_context.protocol_events_mut().extend(
self.exact_target_info_changed_event_plan_for_target_delta(&target_id)
.into_background_events(),
);
}
}
return command_context.take_protocol_events();
}
};
let previous_none_session_owner_route =
completion.navigate_session_id().is_none().then(|| {
@@ -2440,6 +2489,31 @@ impl CdpConnection {
protocol_events
}
pub(crate) fn schedule_background_document_continuation_completion(
&mut self,
completion: crate::domains::page::BackgroundDocumentContinuationCompletion,
observer: Option<moli_core::page::RendererDocumentContinuationObserver>,
) -> Option<moli_core::page::RendererDocumentContinuationObserver> {
let Some(sender) = self
.scheduler_hooks
.document_continuation_completion_sender()
else {
return observer;
};
let key = completion.document_continuation_gate_key();
self.scheduler_state
.push_scheduler_event(CdpSchedulerEvent::DocumentContinuationStarted { key });
let Some(observer) = observer else {
let _ = sender.send(completion);
return None;
};
tokio::spawn(async move {
let renderer_completion = observer.wait().await;
let _ = sender.send(completion.with_renderer_completion(renderer_completion));
});
None
}
pub(crate) fn replace_navigation_engine(&mut self, engine: NavigationEngine) {
let engine = engine;
self.apply_scheduler_senders_to_navigation_engine(&engine);
@@ -2850,9 +2924,13 @@ impl CdpConnection {
);
}
let is_current = completion.is_current_for_connection(self);
let (token, state, navigation, engine) = completion.into_parts();
let (token, state, navigation, engine, background_document_continuation) =
completion.into_parts();
if !is_current {
crate::domains::page::push_superseded_navigation_result(out, &state);
if let Some(completion) = background_document_continuation {
let _ = self.schedule_background_document_continuation_completion(completion, None);
}
return;
}
let navigation_session_id = state.navigate_session_id.clone();
@@ -2863,6 +2941,7 @@ impl CdpConnection {
state,
navigation,
command_context,
background_document_continuation,
)
.await;
if let Some(engine) = engine {
@@ -3098,11 +3177,12 @@ impl CdpConnection {
"documentIsolateAccounting": document_isolate_accounting,
"estimatedWorkerIsolateCount": estimated_worker_isolate_count,
"estimatedLiveV8IsolateCount": estimated_live_v8_isolate_count,
"runtimeGetHeapUsageV8HeapScope": "page-vm-document-isolate",
"runtimeGetHeapUsageV8HeapIsTargetLocal": true,
"runtimeGetHeapUsageV8HeapScope": "script-agent-document-isolate",
"runtimeGetHeapUsageV8HeapIsTargetLocal": false,
"runtimeGetHeapUsageV8HeapMayIncludeRelatedPages": true,
"runtimeGetHeapUsageMoliCountersScope": "target-document",
"runtimeCollectGarbageScope": "page-vm-document-isolate",
"v8ForegroundTaskWakeScope": "page-vm-document-isolate",
"runtimeCollectGarbageScope": "script-agent-document-isolate",
"v8ForegroundTaskWakeScope": "script-agent-document-isolate",
"v8ForegroundTaskWakeContextGroupIdAvailable": false,
"v8ForegroundTaskWakeInternalPolicy": "page-runtime-queue-and-owner-page-tick",
"v8ForegroundTaskWakeExternalPolicy": "page-owner-runtime-wake",
@@ -1,11 +1,11 @@
use super::session_owner::TargetSessionOwner;
use super::*;
use crate::conn::state::{
BrowserContextPageStorageHandles, BrowserContextResourceStorageHandles, DevToolsSessionState,
PageNavigationHistoryEntry, RendererMainDocumentCommitSeed, TargetFetchConfig,
TargetNetworkPolicyState, TargetOwnerState, TargetPageAbsenceReason,
TargetPageResidenceIdentity, TargetRuntimeSessionState, TargetRuntimeSlot,
page_bypass_csp_enabled_for_devtools_sessions,
BrowserContextPageStorageHandles, BrowserContextResourceStorageHandles,
CommittedRendererAgentAttachment, DevToolsSessionState, PageNavigationHistoryEntry,
RendererMainDocumentCommitSeed, TargetFetchConfig, TargetNetworkPolicyState, TargetOwnerState,
TargetPageAbsenceReason, TargetPageResidenceIdentity, TargetRuntimeSessionState,
TargetRuntimeSlot, page_bypass_csp_enabled_for_devtools_sessions,
prepare_renderer_call_replacements_for_devtools_sessions, runtime_bindings_for_renderer,
};
use crate::conn::{
@@ -205,6 +205,10 @@ pub(crate) struct TargetNavigationLoadInputs {
}
impl TargetNavigationLoadInputs {
pub(crate) fn has_main_document_commit_seed(&self) -> bool {
self.main_document_commit_seed.is_some()
}
pub(crate) fn with_main_document_commit_seed(
mut self,
seed: RendererMainDocumentCommitSeed,
@@ -2242,6 +2246,31 @@ impl<'a> TargetSessionOwnerMut<'a> {
}
}
pub(super) fn resolve_no_commit_response_navigation_history(&mut self) -> Option<()> {
match self {
Self::ActiveTarget {
browser_context, ..
} => {
browser_context
.active_target
.owner_state
.resolve_no_commit_response_navigation_history();
Some(())
}
Self::BackgroundTarget {
browser_context,
target_id,
..
} => {
browser_context.mutate_parked_target_owner_state(target_id, |owner_state| {
owner_state.resolve_no_commit_response_navigation_history()
});
Some(())
}
Self::NoLoadedBrowserContext => None,
}
}
pub(super) async fn commit_loaded_navigation_page_async(
&mut self,
mut page: Page,
@@ -2385,6 +2414,102 @@ impl<'a> TargetSessionOwnerMut<'a> {
Self::NoLoadedBrowserContext => None,
}
}
pub(super) fn commit_loaded_navigation_document_replacement(
&mut self,
renderer_attachment_commit: CommittedRendererAgentAttachment,
history_url: &Url,
) -> Option<anyhow::Result<LoadedNavigationPageCommit>> {
match self {
Self::ActiveTarget {
browser_context, ..
} => {
if let Some(target_id) = browser_context.active_target_id_owned() {
browser_context.mark_target_initial_empty_document_exited(&target_id);
}
Some(
browser_context.commit_loaded_navigation_document_replacement(
renderer_attachment_commit,
history_url,
),
)
}
Self::BackgroundTarget {
browser_context,
target_id,
..
} => {
let primary_session_id = browser_context
.background_target(target_id)?
.session_id()
.map(str::to_owned);
let page_snapshot = browser_context
.background_target(target_id)?
.loaded_page()
.map(|page| (history_url.to_string(), page.document_title()))?;
browser_context.mutate_parked_target_owner_state(target_id, |owner_state| {
owner_state.mark_initial_empty_document_exited();
owner_state.record_loaded_page_navigation_history(page_snapshot);
owner_state.clear_committed_document_navigation_state();
});
browser_context.mutate_parked_page_session_state(target_id, |state| {
clear_parked_page_loaded_document_session_state(state);
});
let (
previous_attachment,
new_attachment_id,
committed_document_post_response_continuation,
) = {
let target = browser_context.background_target_mut(target_id)?;
let previous_attachment = match target
.runtime_slot
.commit_loaded_navigation_document_replacement(&renderer_attachment_commit)
{
Ok(previous) => previous,
Err(error) => return Some(Err(error.into())),
};
let page = target
.runtime_slot
.loaded_page_mut()
.expect("committed stable navigation must retain its Page");
let new_attachment_id = page
.renderer_agent_attachment_id()
.expect("committed navigation Page must have a renderer attachment");
let continuation = page.take_committed_document_post_response_continuation();
(previous_attachment, new_attachment_id, continuation)
};
if let Some(previous_attachment) = previous_attachment
&& previous_attachment.id() != new_attachment_id
{
let replacements =
match browser_context.mutate_parked_page_session_state(target_id, |state| {
prepare_renderer_call_replacements_for_devtools_sessions(
primary_session_id.as_deref(),
&mut state.devtools_session_state,
&mut state.auxiliary_devtools_session_states,
previous_attachment.id(),
new_attachment_id,
)
}) {
Ok(replacements) => replacements,
Err(error) => return Some(Err(error.into())),
};
browser_context
.background_target_mut(target_id)?
.runtime_slot
.install_pending_renderer_call_replacements(replacements);
}
let target = browser_context.background_target_mut(target_id)?;
target.runtime_slot.reset_subresource_cursor();
target.runtime_slot.clear_websocket_artifacts();
Some(Ok(LoadedNavigationPageCommit {
replaced_page_owner: None,
committed_document_post_response_continuation,
}))
}
Self::NoLoadedBrowserContext => None,
}
}
}
impl CdpConnection {
@@ -2483,6 +2608,16 @@ impl CdpConnection {
.await
}
pub(crate) fn commit_loaded_navigation_document_replacement_for_session_owner(
&mut self,
session_id: Option<&str>,
renderer_attachment_commit: CommittedRendererAgentAttachment,
history_url: &Url,
) -> Option<anyhow::Result<LoadedNavigationPageCommit>> {
self.target_session_owner_mut(session_id)?
.commit_loaded_navigation_document_replacement(renderer_attachment_commit, history_url)
}
pub(crate) fn initial_document_page_owner_for_session(
&self,
session_id: Option<&str>,
@@ -2590,6 +2725,14 @@ impl CdpConnection {
.clear_pending_navigation_history_update()
}
pub(crate) fn resolve_no_commit_response_navigation_history_for_session_owner(
&mut self,
session_id: Option<&str>,
) -> Option<()> {
self.target_session_owner_mut(session_id)?
.resolve_no_commit_response_navigation_history()
}
pub(crate) async fn mark_target_crashed_for_session_owner_async(
&mut self,
session_id: Option<&str>,
@@ -3508,6 +3651,34 @@ impl CdpConnection {
.frame_tree_loader_id()
}
pub(crate) fn apply_renderer_document_continuation_page_state(
&mut self,
gate_key: &crate::conn::BackgroundNavigationGateKey,
page_state: std::sync::Arc<moli_core::page::RendererPageState>,
) -> bool {
let session_id = gate_key.session_id();
let Some((_, routed_target_id)) = self.target_owner_identity_for_session(session_id) else {
return false;
};
if gate_key.target_id() != routed_target_id.as_deref()
|| self
.target_session_owner_frame_tree_loader_id(session_id)
.as_deref()
!= Some(gate_key.loader_id())
{
return false;
}
let Some(page) = self
.target_session_owner_mut(session_id)
.and_then(TargetSessionOwnerMut::into_runtime_slot_mut)
.and_then(TargetRuntimeSlot::loaded_page_mut)
else {
return false;
};
page.apply_renderer_document_continuation_state(page_state);
true
}
pub(crate) fn target_session_owner_emulated_device_metrics(
&self,
session_id: Option<&str>,
@@ -4367,6 +4538,7 @@ mod tests {
source: "globalThis.fromParkedPreload = true;".to_owned(),
world_name: Some("utility".to_owned()),
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
+76 -42
View File
@@ -67,6 +67,22 @@ fn bidi_fetch_command_context() -> DevToolsCommandContext {
}
}
fn direct_navigation_result_loader_id_for_test(
result: &crate::devtools_runtime::DevToolsNavigateResult,
) -> Option<String> {
result
.loader_id
.as_ref()
.map(|loader_id| loader_id.as_str().to_owned())
.or_else(|| {
result
.navigation_id
.as_ref()
.and_then(|navigation_id| navigation_id.as_str().strip_prefix("navigation-"))
.map(str::to_owned)
})
}
async fn execute_direct_devtools_command_through_renderer_fence_for_test(
ctx: &mut crate::testing::TestContext,
command: DevToolsCommand,
@@ -3446,12 +3462,29 @@ async fn devtools_command_executes_child_frame_navigation_without_cdp_response_s
wait: DevToolsNavigationWait::Load,
}))
.await;
let (parent_result, _, _, parent_predecessor) = parent_outcome.into_complete_parts();
let (parent_result, parent_scheduler_events, parent_protocol_events, parent_predecessor) =
parent_outcome.into_complete_parts();
ctx.route_direct_command_output_for_test(Vec::new(), parent_scheduler_events)
.await;
if let Some(predecessor) = parent_predecessor {
ctx.route_direct_command_renderer_predecessor_for_test(predecessor)
.await;
}
parent_result.expect("parent navigation should succeed");
ctx.route_direct_command_output_for_test(parent_protocol_events, Vec::new())
.await;
let DevToolsCommandResult::Navigate(parent_navigation) =
parent_result.expect("parent navigation should succeed")
else {
panic!("expected parent navigation result");
};
let parent_loader_id = direct_navigation_result_loader_id_for_test(&parent_navigation)
.expect("parent navigation should expose a loader identity");
ctx.wait_for_direct_navigation_load_for_test(
&target_context,
&parent_loader_id,
"protocol-neutral parent frame navigation load",
)
.await;
let (frame_tree_result, _) = ctx
.conn
@@ -3492,8 +3525,10 @@ async fn devtools_command_executes_child_frame_navigation_without_cdp_response_s
},
))
.await;
let (navigate_result, _scheduler_events, protocol_events, child_predecessor) =
let (navigate_result, child_scheduler_events, protocol_events, child_predecessor) =
child_outcome.into_complete_parts();
ctx.route_direct_command_output_for_test(Vec::new(), child_scheduler_events)
.await;
if let Some(predecessor) = child_predecessor {
ctx.route_direct_command_renderer_predecessor_for_test(predecessor)
.await;
@@ -5512,57 +5547,56 @@ async fn devtools_command_executes_input_key_command_without_cdp_sidecar() {
target_id: Some(target_id.clone()),
..context.clone()
};
// Programmatic focus is part of parser execution. In contrast, `autofocus`
// is a post-DOMContentLoaded rendering update and can race the first input
// command sent to this intentionally inactive target.
let url = "data:text/html,<input id='field'><script>document.getElementById('field').focus()</script>";
let (navigate_result, scheduler_events, protocol_events) = ctx
.conn
.execute_devtools_command(DevToolsCommand::Navigate(DevToolsNavigateCommand {
let url = "data:text/html,<input id='field'>";
let navigate_result = execute_direct_devtools_command_through_renderer_fence_for_test(
&mut ctx,
DevToolsCommand::Navigate(DevToolsNavigateCommand {
context: target_context.clone(),
url: url.to_owned(),
referrer: None,
wait: DevToolsNavigationWait::Load,
}))
.await
.into_parts_with_protocol_events();
navigate_result.expect("navigation should succeed");
ctx.sent
.extend(crate::testing::protocol_events_into_internal_messages(
protocol_events,
));
ctx.route_direct_command_output_for_test(Vec::new(), scheduler_events)
.await;
ctx.wait_for_direct_command_work_completion_for_test(
"protocol-neutral navigation load owner action",
}),
)
.await;
navigate_result.expect("navigation should succeed");
execute_direct_devtools_command_through_renderer_fence_for_test(
&mut ctx,
DevToolsCommand::EvaluateScript(DevToolsEvaluateScriptCommand {
context: target_context.clone(),
realm_id: None,
world_name: None,
expression: "document.getElementById('field').focus()".to_owned(),
await_promise: false,
user_gesture: false,
webdriver_bidi_file_prompt_handler: None,
result_ownership: DevToolsResultOwnership::None,
preserve_remote_metadata: false,
materialize_bidi_script_result: false,
serialization_options: None,
}),
)
.await
.expect("input focus setup should succeed");
ctx.sent.clear();
let (key_result, _scheduler_events, protocol_events) = ctx
.conn
.execute_devtools_command(DevToolsCommand::DispatchKeyEvent(
DevToolsDispatchKeyEventCommand {
context: target_context.clone(),
event_type: DevToolsKeyEventType::KeyPress,
key: "Z".to_owned(),
code: "KeyZ".to_owned(),
text: "Z".to_owned(),
modifiers: 0,
auto_repeat: false,
should_insert_text: true,
},
))
.await
.into_parts_with_protocol_events();
let key_result = execute_direct_devtools_command_through_renderer_fence_for_test(
&mut ctx,
DevToolsCommand::DispatchKeyEvent(DevToolsDispatchKeyEventCommand {
context: target_context.clone(),
event_type: DevToolsKeyEventType::KeyPress,
key: "Z".to_owned(),
code: "KeyZ".to_owned(),
text: "Z".to_owned(),
modifiers: 0,
auto_repeat: false,
should_insert_text: true,
}),
)
.await;
assert_eq!(
key_result.expect("key dispatch should succeed"),
DevToolsCommandResult::Empty
);
assert!(
protocol_events.is_empty(),
"direct input key dispatch must not emit CDP-shaped sidecar messages: {protocol_events:?}"
);
let value_result = ctx
.execute_devtools_command_through_renderer_fence_for_test(DevToolsCommand::EvaluateScript(
+26
View File
@@ -164,6 +164,32 @@ impl CdpConnection {
Ok(transaction)
}
pub(crate) fn commit_renderer_agent_candidate_for_stable_page_replacement_for_session_owner(
&mut self,
session_id: Option<&str>,
candidate: PreparedRendererAgentAttachment,
expected: &super::StablePageNavigationCommitTarget,
) -> Result<CommittedRendererAgentAttachment, String> {
self.validate_navigation_target_owner(session_id, candidate.navigation())?;
if !self.target_page_residence_identity_is_current_for_session(
session_id,
expected.target_page(),
) || self.renderer_page_residence_identity_for_session_owner(session_id)
!= Some(expected.renderer_page())
{
return Err("StableNavigationPageSuperseded".to_owned());
}
let transaction = self
.runtime_session_owner_slot_mut(session_id)?
.commit_renderer_agent_candidate_transaction(candidate, expected.renderer_page())
.map_err(|error| error.to_string())?;
self.bind_renderer_page_output_owner(
expected.renderer_page(),
expected.target_page().clone(),
);
Ok(transaction)
}
pub(crate) fn rollback_committed_renderer_agent_candidate_for_session_owner(
&mut self,
session_id: Option<&str>,
@@ -316,6 +316,65 @@ impl BrowserContext {
})
}
pub(crate) fn commit_loaded_navigation_document_replacement(
&mut self,
renderer_attachment_commit: CommittedRendererAgentAttachment,
history_url: &Url,
) -> anyhow::Result<LoadedNavigationPageCommit> {
let primary_session_id = self.active_session_id_owned();
let previous_attachment = self
.active_target
.runtime_slot
.commit_loaded_navigation_document_replacement(&renderer_attachment_commit)?;
let page = self
.active_target
.runtime_slot
.loaded_page_mut()
.expect("committed stable navigation must retain its Page");
let new_attachment_id = page
.renderer_agent_attachment_id()
.expect("committed navigation Page must have a renderer attachment");
let committed_document_post_response_continuation =
page.take_committed_document_post_response_continuation();
if let Some(previous_attachment) = previous_attachment
&& previous_attachment.id() != new_attachment_id
{
let replacements = prepare_renderer_call_replacements_for_devtools_sessions(
primary_session_id.as_deref(),
&mut self.devtools_session_state,
&mut self.auxiliary_devtools_session_states,
previous_attachment.id(),
new_attachment_id,
)?;
self.active_target
.runtime_slot
.install_pending_renderer_call_replacements(replacements);
}
let page_snapshot = self
.active_target
.runtime_slot
.loaded_page()
.map(|page| (history_url.to_string(), page.document_title()))
.expect("committed stable navigation must retain its Page");
self.active_target
.owner_state
.record_loaded_page_navigation_history(page_snapshot);
self.reset_subresource_network_cursor();
self.clear_websocket_network_artifacts();
self.active_target
.owner_state
.clear_committed_document_navigation_state();
self.clear_active_target_loaded_document_session_state();
self.clear_active_target_runtime_remote_object_tracking();
Ok(LoadedNavigationPageCommit {
// A renderer Document replacement keeps the same typed Page
// attachment in the public repository. Page-owned work therefore
// remains resident; Document currentness is tracked separately.
replaced_page_owner: None,
committed_document_post_response_continuation,
})
}
pub(crate) async fn clear_active_target_session_scoped_state_async(
&mut self,
) -> Result<(), String> {
@@ -40,6 +40,7 @@ impl BrowserContext {
initial_empty_document_url,
creator,
None,
None,
session_storage_namespace,
);
}
@@ -51,6 +52,7 @@ impl BrowserContext {
url: String,
initial_empty_document_url: Option<String>,
creator: Option<TargetInitialEmptyDocumentCreator>,
pending_auxiliary_page: Option<moli_core::page::RendererPendingAuxiliaryPage>,
session_storage_store: Option<SharedWebStorageStore>,
initial_empty_document_storage_key: Option<moli_storage_key::MoliStorageKey>,
) {
@@ -63,6 +65,7 @@ impl BrowserContext {
url,
initial_empty_document_url,
creator,
pending_auxiliary_page,
initial_empty_document_storage_key,
session_storage_namespace,
);
@@ -88,6 +91,7 @@ impl BrowserContext {
url: String,
initial_empty_document_url: Option<String>,
creator: Option<TargetInitialEmptyDocumentCreator>,
pending_auxiliary_page: Option<moli_core::page::RendererPendingAuxiliaryPage>,
initial_empty_document_storage_key: Option<moli_storage_key::MoliStorageKey>,
session_storage_namespace: Option<TargetSessionStorageNamespace>,
) {
@@ -101,6 +105,14 @@ impl BrowserContext {
);
});
let mut target = BackgroundTarget::with_identity(target_id, session_id, target_identity);
if let Some(pending_auxiliary_page) = pending_auxiliary_page {
assert!(
target
.runtime_slot
.stage_pending_auxiliary_page(pending_auxiliary_page),
"new popup target must accept its renderer-owned auxiliary Page reservation"
);
}
if let Some(namespace) = session_storage_namespace {
target.replace_session_storage_namespace(namespace);
}
@@ -2284,6 +2296,7 @@ mod tests {
source: "globalThis.fromDocumentStart = true".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -2414,6 +2427,7 @@ mod tests {
source: "globalThis.demoted = true".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -612,6 +612,7 @@ impl BrowserContext {
source,
world_name: None,
has_bidi_channel_argument: false,
browser_internal: true,
bidi_channel_handoffs: Vec::new(),
})
}
+183 -62
View File
@@ -1,57 +1,23 @@
use super::{CdpConnection, CdpSessionRoute, CommandOwnerScope};
use super::{CdpConnection, CommandOwnerScope, TargetPageResidenceIdentity};
/// Navigation requested by an already-accepted auxiliary browsing-context
/// action.
/// Immutable authority for one popup destination navigation.
///
/// Creating or resolving the target is part of the renderer output that
/// precedes the causing Runtime response. Loading the requested URL is not.
/// Blink's `LocalDOMWindow::open()` resolves the target, invokes `Navigate()`,
/// and returns the Window without waiting for the network load or Document
/// commit. Moli's navigation helper can itself become asynchronous, so
/// protocol projection must hand that work to the owner scheduler instead of
/// awaiting it while the opener's output cursor is being projected. Keeping
/// the frozen URL and exact target route in this move-only action makes that
/// boundary explicit.
#[derive(Debug)]
pub(crate) struct PopupTargetNavigationOwnerAction {
owner_scope: CommandOwnerScope,
/// A target id is not sufficient authorization: CDP sessions and target-local
/// state can survive replacement of the installed renderer `Page`. Retaining
/// the exact residence generation makes an activation captured for the
/// initial empty Document incapable of navigating a later replacement Page.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PopupTargetNavigationClaimIdentity {
page_owner: TargetPageResidenceIdentity,
browser_context_id: String,
target_id: String,
url: String,
kind: PopupTargetNavigationKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PopupTargetNavigationKind {
InitialDocument,
NamedTargetReuse,
}
impl PopupTargetNavigationOwnerAction {
pub(crate) fn capture(
conn: &CdpConnection,
browser_context_id: &str,
target_id: &str,
url: String,
kind: PopupTargetNavigationKind,
) -> Option<Self> {
let route = conn.target_session_route_for_target_id(target_id)?;
(route.browser_context_id() == Some(browser_context_id)).then(|| Self {
// Activation has an independent owner action. Unlike a frozen
// BackgroundTarget route, AuxiliaryTarget keeps this exact target
// addressable if that action changes its residence first.
owner_scope: CommandOwnerScope::from_session_and_owner_route(
None,
Some(CdpSessionRoute::AuxiliaryTarget {
browser_context_id: browser_context_id.to_owned(),
target_id: target_id.to_owned(),
}),
),
browser_context_id: browser_context_id.to_owned(),
target_id: target_id.to_owned(),
url,
kind,
})
impl PopupTargetNavigationClaimIdentity {
pub(crate) fn page_owner(&self) -> &TargetPageResidenceIdentity {
&self.page_owner
}
pub(crate) fn browser_context_id(&self) -> &str {
@@ -69,22 +35,177 @@ impl PopupTargetNavigationOwnerAction {
pub(crate) fn kind(&self) -> PopupTargetNavigationKind {
self.kind
}
}
pub(crate) fn into_parts(
self,
) -> (
CommandOwnerScope,
String,
String,
String,
PopupTargetNavigationKind,
) {
(
self.owner_scope,
self.browser_context_id,
self.target_id,
self.url,
self.kind,
)
/// Navigation requested by an already-accepted auxiliary browsing-context
/// action.
///
/// Creating or resolving the target is part of the renderer output that
/// precedes the causing Runtime response. Loading the requested URL is not.
/// Blink's `LocalDOMWindow::open()` resolves the target, invokes `Navigate()`,
/// and returns the Window without waiting for the network load or Document
/// commit. Moli's navigation helper can itself become asynchronous, so
/// protocol projection must hand that work to the owner scheduler instead of
/// awaiting it while the opener's output cursor is being projected. Keeping
/// the frozen URL and exact target route in this move-only action makes that
/// boundary explicit.
#[derive(Debug)]
pub(crate) struct PopupTargetNavigationOwnerAction {
owner_scope: CommandOwnerScope,
claim: PopupTargetNavigationClaimIdentity,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PopupTargetNavigationKind {
InitialDocument,
NamedTargetReuse,
}
/// Target-local lifecycle of the initial popup navigation authority.
///
/// `Consumed` is deliberately retained as a tombstone. Once a captured
/// activation is stale, generic entry points such as `Page.enable` must not
/// reconstruct it from the mutable target URL and apply it to a newer Page.
#[derive(Debug)]
pub(crate) enum PopupTargetNavigationAuthorityState {
Held(PopupTargetNavigationOwnerAction),
Published(PopupTargetNavigationClaimIdentity),
Consumed(PopupTargetNavigationClaimIdentity),
}
impl PopupTargetNavigationAuthorityState {
pub(crate) fn claim_identity(&self) -> &PopupTargetNavigationClaimIdentity {
match self {
Self::Held(action) => action.claim_identity(),
Self::Published(claim) | Self::Consumed(claim) => claim,
}
}
}
impl PopupTargetNavigationOwnerAction {
pub(crate) fn capture(
conn: &mut CdpConnection,
browser_context_id: &str,
target_id: &str,
url: String,
kind: PopupTargetNavigationKind,
) -> Option<Self> {
let route = conn.target_session_route_for_target_id(target_id)?;
if route.browser_context_id() != Some(browser_context_id) {
return None;
}
let owner_scope =
CommandOwnerScope::from_session_and_owner_route(None, Some(route.clone()));
let page_owner = {
let mut route_scope = conn.scoped_none_session_owner_route_override(route);
route_scope
.conn_mut()
.target_page_residence_identity_for_session(None)?
};
if page_owner.browser_context_id() != browser_context_id
|| page_owner.target_id() != Some(target_id)
{
return None;
}
Some(Self {
owner_scope,
claim: PopupTargetNavigationClaimIdentity {
page_owner,
browser_context_id: browser_context_id.to_owned(),
target_id: target_id.to_owned(),
url,
kind,
},
})
}
pub(crate) fn browser_context_id(&self) -> &str {
self.claim.browser_context_id()
}
pub(crate) fn target_id(&self) -> &str {
self.claim.target_id()
}
pub(crate) fn url(&self) -> &str {
self.claim.url()
}
pub(crate) fn kind(&self) -> PopupTargetNavigationKind {
self.claim.kind()
}
pub(crate) fn page_owner(&self) -> &TargetPageResidenceIdentity {
self.claim.page_owner()
}
pub(crate) fn claim_identity(&self) -> &PopupTargetNavigationClaimIdentity {
&self.claim
}
pub(crate) fn into_parts(self) -> (CommandOwnerScope, PopupTargetNavigationClaimIdentity) {
(self.owner_scope, self.claim)
}
}
impl CdpConnection {
/// Installs the initial popup destination as target-local held authority.
/// The action is accepted only while its exact Page residence is current.
pub(crate) fn stage_initial_popup_target_navigation_owner_action(
&mut self,
action: PopupTargetNavigationOwnerAction,
) -> bool {
if action.kind() != PopupTargetNavigationKind::InitialDocument {
return false;
}
let owner_scope = action.owner_scope.clone();
let page_owner = action.page_owner().clone();
let mut route_scope = owner_scope.enter(self);
let conn = route_scope.conn_mut();
if !conn.target_page_residence_identity_is_current_for_session(None, &page_owner) {
return false;
}
conn.runtime_session_owner_slot_mut(None)
.is_ok_and(|slot| slot.stage_initial_popup_target_navigation_owner_action(action))
}
/// Releases a held action through the exact target route used at target
/// admission. The target slot retains a published claim until completion.
pub(crate) fn take_held_popup_target_navigation_owner_action_for_target(
&mut self,
target_id: &str,
) -> Option<PopupTargetNavigationOwnerAction> {
let route = self.target_session_route_for_target_id(target_id)?;
let mut route_scope = self.scoped_none_session_owner_route_override(route);
route_scope
.conn_mut()
.take_held_popup_target_navigation_owner_action_for_session_owner(None)
}
/// Releases the held action addressed by a concrete Runtime/Page session.
pub(crate) fn take_held_popup_target_navigation_owner_action_for_session_owner(
&mut self,
session_id: Option<&str>,
) -> Option<PopupTargetNavigationOwnerAction> {
self.runtime_session_owner_slot_mut(session_id)
.ok()?
.take_held_initial_popup_target_navigation_owner_action()
}
pub(crate) fn consume_published_popup_target_navigation_claim_for_session_owner(
&mut self,
session_id: Option<&str>,
expected: &PopupTargetNavigationClaimIdentity,
) -> bool {
self.runtime_session_owner_slot_mut(session_id)
.is_ok_and(|slot| slot.consume_published_popup_target_navigation_claim(expected))
}
pub(crate) fn runtime_session_owner_has_popup_target_navigation_authority(
&self,
session_id: Option<&str>,
) -> bool {
self.runtime_session_owner_slot(session_id)
.is_ok_and(|slot| slot.has_popup_target_navigation_authority())
}
}
+41 -8
View File
@@ -1,4 +1,8 @@
use std::{collections::hash_map::Entry, future::Future, pin::Pin};
use std::{
collections::{HashSet, hash_map::Entry},
future::Future,
pin::Pin,
};
use moli_page_types::{FrontendCommandId, RendererCallId, RendererInspectorResponseDelivery};
use moli_protocol_cdp::CdpRendererCommandReplayDispatch;
@@ -1791,9 +1795,8 @@ impl CdpConnection {
}
}
pub(crate) fn fail_pending_inspector_awaits_for_session_owner_background_events_into(
pub(crate) fn fail_claimed_pending_inspector_awaits_for_session_owner_background_events_into(
&mut self,
out: &mut Vec<BackgroundProtocolEvent>,
claimed_background_events: &mut Vec<BackgroundProtocolEvent>,
session_id: Option<&str>,
reason: &'static str,
@@ -1804,6 +1807,20 @@ impl CdpConnection {
claimed,
reason,
);
}
pub(crate) fn fail_pending_inspector_awaits_for_session_owner_background_events_into(
&mut self,
out: &mut Vec<BackgroundProtocolEvent>,
claimed_background_events: &mut Vec<BackgroundProtocolEvent>,
session_id: Option<&str>,
reason: &'static str,
) {
self.fail_claimed_pending_inspector_awaits_for_session_owner_background_events_into(
claimed_background_events,
session_id,
reason,
);
if let Some(owner_session_id) = session_id
&& self.shared_worker_target_for_session(session_id).is_some()
{
@@ -4274,6 +4291,8 @@ impl CdpConnection {
let mut isolated_world_context_count = 0;
let mut child_default_context_count = 0;
let mut failed_page_snapshot_count = 0;
let mut loaded_document_script_agent_ids = HashSet::new();
let mut loaded_document_missing_script_agent_id_count = 0;
for completed in completed.completed {
let Some(page) = self
@@ -4299,6 +4318,12 @@ impl CdpConnection {
failed_page_snapshot_count += 1;
continue;
};
match snapshot.diagnostics.script_agent_id {
Some(script_agent_id) => {
loaded_document_script_agent_ids.insert(script_agent_id);
}
None => loaded_document_missing_script_agent_id_count += 1,
}
document_context_count += snapshot.diagnostics.document_context_count;
isolated_world_context_count += snapshot.diagnostics.isolated_world_context_count;
child_default_context_count += snapshot.diagnostics.child_default_context_count;
@@ -4308,13 +4333,15 @@ impl CdpConnection {
.dedicated_worker_running_worker_isolate_count;
}
let estimated_document_isolate_count = self
let pending_document_page_build_count = self
.browser_contexts()
.map(|browser_context| {
browser_context.loaded_document_page_count()
+ browser_context.pending_document_page_build_count()
})
.map(BrowserContext::pending_document_page_build_count)
.sum::<usize>();
let loaded_document_script_agent_count = loaded_document_script_agent_ids.len();
let estimated_document_isolate_count = loaded_document_script_agent_count
+ loaded_document_missing_script_agent_id_count
+ failed_page_snapshot_count
+ pending_document_page_build_count;
let shared_worker_running_worker_isolate_count = self
.browser_contexts()
.map(|browser_context| {
@@ -4334,6 +4361,12 @@ impl CdpConnection {
json!(isolated_world_context_count);
diagnostics["isolateScope"]["childDefaultContextCount"] =
json!(child_default_context_count);
diagnostics["isolateScope"]["loadedDocumentScriptAgentCount"] =
json!(loaded_document_script_agent_count);
diagnostics["isolateScope"]["loadedDocumentMissingScriptAgentIdCount"] =
json!(loaded_document_missing_script_agent_id_count);
diagnostics["isolateScope"]["estimatedDocumentIsolateCount"] =
json!(estimated_document_isolate_count);
diagnostics["isolateScope"]["dedicatedWorkerLoadingCount"] =
json!(dedicated_worker_loading_count);
diagnostics["isolateScope"]["dedicatedWorkerRunningWorkerIsolateCount"] =
File diff suppressed because it is too large Load Diff
+32
View File
@@ -8,6 +8,11 @@ pub(super) struct CdpSchedulerHooks {
background_navigation_completion_sender: Option<
tokio::sync::mpsc::UnboundedSender<crate::domains::page::BackgroundNavigationCompletion>,
>,
document_continuation_completion_sender: Option<
tokio::sync::mpsc::UnboundedSender<
crate::domains::page::BackgroundDocumentContinuationCompletion,
>,
>,
renderer_publication_sender: Option<RendererOutputTransportSender>,
runtime_inspector_response_ready_sender: Option<RuntimeInspectorResponseReadySender>,
}
@@ -21,6 +26,10 @@ impl CdpSchedulerHooks {
self.background_event_sender.clone()
}
pub(super) fn has_background_event_sender(&self) -> bool {
self.background_event_sender.is_some()
}
pub(super) fn set_runtime_inspector_response_ready_sender(
&mut self,
sender: RuntimeInspectorResponseReadySender,
@@ -55,6 +64,29 @@ impl CdpSchedulerHooks {
self.background_navigation_completion_sender.is_some()
}
pub(super) fn set_document_continuation_completion_sender(
&mut self,
sender: tokio::sync::mpsc::UnboundedSender<
crate::domains::page::BackgroundDocumentContinuationCompletion,
>,
) {
self.document_continuation_completion_sender = Some(sender);
}
pub(super) fn document_continuation_completion_sender(
&self,
) -> Option<
tokio::sync::mpsc::UnboundedSender<
crate::domains::page::BackgroundDocumentContinuationCompletion,
>,
> {
self.document_continuation_completion_sender.clone()
}
pub(super) fn has_document_continuation_completion_sender(&self) -> bool {
self.document_continuation_completion_sender.is_some()
}
pub(super) fn set_renderer_publication_sender(
&mut self,
sender: RendererOutputTransportSender,
+69
View File
@@ -7,8 +7,77 @@ use serde_json::{Value, json};
const RECENT_ACTIVITY_TRACE_LIMIT: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BackgroundNavigationGateKey {
target_id: Option<String>,
session_id: Option<String>,
frame_id: String,
loader_id: String,
navigation_request_id: Option<u64>,
}
impl BackgroundNavigationGateKey {
pub(crate) fn for_navigation(
token: &super::state::DocumentNavigationToken,
state: &super::state::NavigationDispatchState,
) -> Self {
// Keep this key limited to fields that are stable from background task
// spawn through lifecycle completion. `navigate_id` is intentionally
// excluded because early Page.navigate replies clear it before the
// completion is sent.
Self {
target_id: Some(token.target_id.clone()),
session_id: state
.navigate_session_id
.clone()
.or_else(|| state.session_id.clone()),
frame_id: state.frame_id.clone(),
loader_id: token.loader_id.clone(),
navigation_request_id: Some(token.request_id.get()),
}
}
/// Reports whether this gate belongs to the renderer/session owner used by
/// an incoming CDP command.
pub fn matches_session_owner(&self, session_id: Option<&str>) -> bool {
self.session_id.as_deref() == session_id
}
pub(crate) fn target_id(&self) -> Option<&str> {
self.target_id.as_deref()
}
pub(crate) fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
pub(crate) fn loader_id(&self) -> &str {
&self.loader_id
}
#[cfg(feature = "test-support")]
pub(crate) fn from_test_parts(
target_id: Option<String>,
session_id: Option<String>,
frame_id: String,
loader_id: String,
navigation_request_id: Option<u64>,
) -> Self {
Self {
target_id,
session_id,
frame_id,
loader_id,
navigation_request_id,
}
}
}
#[derive(Debug)]
pub enum CdpSchedulerEvent {
DocumentContinuationStarted {
key: BackgroundNavigationGateKey,
},
ProtocolWorkPublished {
work: crate::domains::activity::ProtocolSchedulerWork,
},
@@ -42,6 +42,10 @@ impl NavigationRequestId {
"navigation request id",
))
}
pub(crate) fn get(self) -> u64 {
self.0.get()
}
}
fn allocate_nonzero_u64(counter: &AtomicU64, name: &str) -> NonZeroU64 {
+1 -1
View File
@@ -138,7 +138,7 @@ pub(crate) use emulation::{
pub(crate) use navigation_outcome::{CompletedDownloadBody, CompletedDownloadBodyArtifact};
pub use navigation_outcome::{
DownloadNavigation, LoadedNavigation, NavigationDispatchState, NavigationLoadOutcome,
NavigationRequestLoadPolicy, TargetInfo,
NavigationRequestLoadPolicy, NoCommitResponseNavigation, TargetInfo,
};
pub(crate) use navigation_outcome::{
NETWORK_ERROR_PAGE_URL, NavigationResultProjection, NavigationSourceDocumentSecurityContext,
@@ -125,6 +125,15 @@ impl TargetNavigationHistoryState {
self.pending_update = None;
}
pub(crate) fn retain_pending_initial_empty_document_replacement(&mut self) {
if !matches!(
self.pending_update,
Some(PendingNavigationHistoryUpdate::ReplaceInitialEmptyDocument)
) {
self.pending_update = None;
}
}
pub(crate) fn entry_url(&self, entry_id: i32) -> Option<String> {
self.entries
.iter()
@@ -13,6 +13,7 @@ use crate::conn::ResponseCommitReady;
use crate::devtools_runtime::DevToolsProtocol;
use crate::domains::network::{
CompletedDocumentProgressTransfer, CompletedDownloadProgressTransfer,
CompletedNoCommitResponseProgressTransfer,
};
use super::browser_context::BrowserContext;
@@ -193,6 +194,8 @@ pub struct LoadedNavigation {
pub initial_runtime_realms: Vec<RendererRuntimeRealmInfo>,
pub renderer_output_predecessor: Option<moli_core::RendererOutputFence>,
pub(crate) main_document_commit: Option<Arc<RendererMainDocumentCommit>>,
pub(crate) document_continuation_observer:
Option<moli_core::page::RendererDocumentContinuationObserver>,
pub(crate) document_progress_transfer: CompletedDocumentProgressTransfer,
pub(crate) navigation_engine: Option<NavigationEngine>,
pub(crate) network_error_page: Option<NetworkErrorPageNavigation>,
@@ -228,11 +231,24 @@ pub struct DownloadNavigation {
pub(crate) progress_transfer: CompletedDownloadProgressTransfer,
}
/// A navigation response that is observable on the network but cannot commit
/// a new Document.
///
/// HTTP 204 and 205 are the current producers. Keeping this distinct from a
/// transport failure is important: the response and redirect chain remain
/// observable, while the previously committed Document must stay resident.
#[derive(Debug)]
pub struct NoCommitResponseNavigation {
pub final_url: Url,
pub(crate) progress_transfer: CompletedNoCommitResponseProgressTransfer,
}
#[derive(Debug)]
pub enum NavigationLoadOutcome {
ResponseCommitReady(Box<ResponseCommitReady>),
Loaded(Box<LoadedNavigation>),
Download(Box<DownloadNavigation>),
NoCommitResponse(Box<NoCommitResponseNavigation>),
NetworkFailure(String),
}
@@ -249,6 +265,10 @@ impl NavigationLoadOutcome {
Self::Download(Box::new(navigation))
}
pub(crate) fn no_commit_response(navigation: NoCommitResponseNavigation) -> Self {
Self::NoCommitResponse(Box::new(navigation))
}
pub(crate) fn network_failure(error_text: String) -> Self {
Self::NetworkFailure(error_text)
}
@@ -260,6 +280,7 @@ impl NavigationLoadOutcome {
}
Self::Loaded(navigation) => Self::loaded(navigation.with_navigation_engine(engine)),
Self::Download(navigation) => Self::Download(navigation),
Self::NoCommitResponse(navigation) => Self::NoCommitResponse(navigation),
Self::NetworkFailure(error_text) => Self::NetworkFailure(error_text),
}
}
+35 -1
View File
@@ -6,8 +6,9 @@ use moli_core::page::{
RendererDocumentLifecycleSnapshot, RendererDocumentLifecycleWaitOutcome,
RendererDocumentLifecycleWaiter, RendererDocumentToken, RendererFrameToken,
RendererLifecycleEpoch, RendererLifecycleEventStamp, RendererLifecycleStartReason,
RendererLifecycleTerminationStamp, RendererPageCreationArtifacts,
RendererLifecycleTerminationStamp, RendererPageCreationArtifacts, RendererPendingAuxiliaryPage,
};
use moli_core::runtime::RendererPageReservationToken;
use tokio::sync::watch;
use super::document_lifecycle_observer::{
@@ -381,6 +382,8 @@ pub(crate) struct TargetPageSlot {
renderer_document_lifecycle_waiters: Vec<RegisteredRendererDocumentLifecycleWaiter>,
root_post_load_observation: Option<RootPostLoadObservation>,
initial_document_page_build_completion: Option<watch::Sender<Option<Result<(), String>>>>,
auxiliary_browsing_context_id: Option<u64>,
initial_document_page_reservation: Option<RendererPageReservationToken>,
pending_renderer_page: Option<PendingRendererPageBinding>,
}
@@ -451,6 +454,37 @@ impl TargetPageSlot {
self.initial_document_page_build_completion = Some(sender);
}
pub(crate) fn stage_pending_auxiliary_page(
&mut self,
pending: RendererPendingAuxiliaryPage,
) -> bool {
if self.loaded_page.is_some()
|| self.loaded_page_absence_reason
!= TargetPageAbsenceReason::InitialDocumentPageBuildPending
|| self.auxiliary_browsing_context_id.is_some()
|| self.initial_document_page_reservation.is_some()
{
return false;
}
self.auxiliary_browsing_context_id = Some(pending.browsing_context_id());
self.initial_document_page_reservation = Some(pending.page_reservation());
true
}
pub(crate) fn take_initial_document_page_reservation(
&mut self,
) -> Option<RendererPageReservationToken> {
self.initial_document_page_reservation.take()
}
pub(crate) fn auxiliary_browsing_context_id(&self) -> Option<u64> {
self.auxiliary_browsing_context_id
}
pub(crate) fn has_staged_initial_document_page_reservation(&self) -> bool {
self.initial_document_page_reservation.is_some()
}
pub(crate) fn bind_initial_document_page_build_renderer_page(
&mut self,
renderer_page: RendererPageResidenceIdentity,
+16
View File
@@ -105,6 +105,13 @@ impl BackgroundTarget {
self.session_id.is_some()
}
#[cfg(test)]
pub(crate) fn auxiliary_browsing_context_id(&self) -> Option<u64> {
self.runtime_slot
.page_slot()
.auxiliary_browsing_context_id()
}
pub(crate) fn is_session(&self, session_id: &str) -> bool {
self.session_id() == Some(session_id)
}
@@ -1115,6 +1122,15 @@ impl TargetOwnerState {
self.navigation_history_state.clear_pending_update();
}
pub(crate) fn resolve_no_commit_response_navigation_history(&mut self) {
if self.is_on_initial_empty_document() == Some(true) {
self.navigation_history_state
.retain_pending_initial_empty_document_replacement();
} else {
self.navigation_history_state.clear_pending_update();
}
}
pub(crate) fn navigation_history_entry_url(
&mut self,
page_snapshot: Option<(String, String)>,
+99 -2
View File
@@ -1,13 +1,18 @@
use moli_core::page::{
Page, RendererAgentAttachmentId, RendererDevToolsAgentToken, RendererDocumentLifecycleIdentity,
RendererRuntimeInspectorMessageBatch, ScriptNetworkOutputItem, SubresourceNetworkRequestHandle,
RendererPendingAuxiliaryPage, RendererRuntimeInspectorMessageBatch, ScriptNetworkOutputItem,
SubresourceNetworkRequestHandle,
};
#[cfg(test)]
use moli_core::page::{RendererPageDiagnosticsSnapshot, RendererRuntimeObservableSourceSummary};
use moli_core::runtime::RendererPageReservationToken;
use serde_json::{Value, json};
use crate::{
conn::{CapturedBody, ConnectionNetworkRequestIdAllocator},
conn::{
CapturedBody, ConnectionNetworkRequestIdAllocator, PopupTargetNavigationAuthorityState,
PopupTargetNavigationClaimIdentity, PopupTargetNavigationOwnerAction,
},
domains::{
log_output_state::{TargetLogOutputQueueState, TargetNetworkLogEntry},
network::{
@@ -56,6 +61,7 @@ pub(in crate::conn::state) struct TargetNetworkRequestCounters {
#[derive(Debug, Default)]
pub(crate) struct TargetRuntimeSlot {
page_slot: TargetPageSlot,
popup_target_navigation_authority: Option<PopupTargetNavigationAuthorityState>,
devtools_renderer_channel: DevToolsRendererChannel,
pending_renderer_call_replacements: PreparedRendererCallReplacements,
javascript_dialog_scope: TargetJavaScriptDialogScope,
@@ -151,6 +157,59 @@ impl TargetRuntimeSlot {
self.page_slot.has_loaded_page()
}
pub(crate) fn stage_initial_popup_target_navigation_owner_action(
&mut self,
action: PopupTargetNavigationOwnerAction,
) -> bool {
if self.popup_target_navigation_authority.is_some() {
return false;
}
self.popup_target_navigation_authority =
Some(PopupTargetNavigationAuthorityState::Held(action));
true
}
pub(crate) fn take_held_initial_popup_target_navigation_owner_action(
&mut self,
) -> Option<PopupTargetNavigationOwnerAction> {
let authority = self.popup_target_navigation_authority.take()?;
match authority {
PopupTargetNavigationAuthorityState::Held(action) => {
self.popup_target_navigation_authority = Some(
PopupTargetNavigationAuthorityState::Published(action.claim_identity().clone()),
);
Some(action)
}
authority => {
self.popup_target_navigation_authority = Some(authority);
None
}
}
}
pub(crate) fn consume_published_popup_target_navigation_claim(
&mut self,
expected: &PopupTargetNavigationClaimIdentity,
) -> bool {
let matches = matches!(
self.popup_target_navigation_authority.as_ref(),
Some(PopupTargetNavigationAuthorityState::Published(actual)) if actual == expected
);
if matches {
self.popup_target_navigation_authority = Some(
PopupTargetNavigationAuthorityState::Consumed(expected.clone()),
);
}
matches
}
pub(crate) fn has_popup_target_navigation_authority(&self) -> bool {
self.popup_target_navigation_authority
.as_ref()
.map(PopupTargetNavigationAuthorityState::claim_identity)
.is_some()
}
pub(crate) fn has_pending_initial_document_page_build(&self) -> bool {
matches!(
self.page_slot.loaded_page_absence_reason(),
@@ -176,6 +235,19 @@ impl TargetRuntimeSlot {
self.page_slot.start_initial_document_page_build();
}
pub(crate) fn stage_pending_auxiliary_page(
&mut self,
pending: RendererPendingAuxiliaryPage,
) -> bool {
self.page_slot.stage_pending_auxiliary_page(pending)
}
pub(crate) fn take_initial_document_page_reservation(
&mut self,
) -> Option<RendererPageReservationToken> {
self.page_slot.take_initial_document_page_reservation()
}
pub(crate) fn bind_initial_document_page_build_renderer_page(
&mut self,
renderer_page: RendererPageResidenceIdentity,
@@ -438,6 +510,27 @@ impl TargetRuntimeSlot {
Ok(())
}
pub(crate) fn commit_loaded_navigation_document_replacement(
&mut self,
transaction: &CommittedRendererAgentAttachment,
) -> Result<Option<RendererAgentAttachment>, DevToolsRendererChannelError> {
let current = transaction.current();
let page = self
.page_slot
.loaded_page_mut()
.ok_or(DevToolsRendererChannelError::CommittedCandidateMismatch)?;
if self.devtools_renderer_channel.current() != Some(current)
|| page.renderer_devtools_agent_token() != current.agent_token()
{
return Err(DevToolsRendererChannelError::CommittedCandidateMismatch);
}
page.bind_renderer_agent_attachment(current.id());
self.javascript_dialog_scope.retire();
self.reset_document_output_state();
self.ingest_owner_page_observable_output_updates();
Ok(transaction.previous())
}
pub(crate) fn commit_loaded_navigation_renderer_attachment(
&mut self,
page: &mut Page,
@@ -604,6 +697,10 @@ impl TargetRuntimeSlot {
.loaded_page_absence_reason()
.map(TargetPageAbsenceReason::label),
"pageAttachmentId": self.page_attachment_id().map(TargetPageAttachmentId::get),
"auxiliaryBrowsingContextId": self.page_slot.auxiliary_browsing_context_id(),
"hasStagedInitialDocumentPageReservation": self
.page_slot
.has_staged_initial_document_page_reservation(),
"hasPendingDocumentNavigation": self.has_pending_document_navigation(),
"rendererChannelClosed": self.devtools_renderer_channel.is_closed(),
"rendererChannelHasCurrentAttachment":
+25
View File
@@ -1274,6 +1274,7 @@ fn committed_document_navigation_state_clears_document_local_runtime_state() {
source: "globalThis.fromPreload = true;".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -1333,6 +1334,7 @@ fn target_parking_store_mutates_target_owner_state_with_default_elision() {
source: "globalThis.fromPreload = true;".to_owned(),
world_name: Some("utility".to_owned()),
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -1540,6 +1542,11 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() {
.materialized()
);
// Model an auxiliary target whose requested initial navigation must
// replace its internally materialized about:blank entry when it finally
// commits, even if an earlier response did not commit a Document.
context.mark_target_initial_url_replaces_empty_document("TID-initial-active");
let token = context
.start_document_navigation_for_active_target("LOADER-initial-active".to_owned())
.expect("active target should start document navigation");
@@ -1555,6 +1562,10 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() {
Some("TID-initial-active"),
"LOADER-initial-active",
);
context
.active_target
.owner_state
.resolve_no_commit_response_navigation_history();
let cleared = context
.active_target
.owner_state
@@ -1566,6 +1577,13 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() {
let committed = context
.start_document_navigation_for_active_target("LOADER-initial-active-2".to_owned())
.expect("active target should restart document navigation");
context
.active_target
.owner_state
.record_loaded_page_navigation_history((
"https://example.test/committed".to_owned(),
"committed".to_owned(),
));
context.commit_document_navigation_if_matches(&committed);
let exited = context
.active_target
@@ -1579,6 +1597,13 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() {
!context.accepts_pending_document_navigation_event(&token),
"cleared navigation must stay rejected"
);
let (current_index, entries) = context
.active_target
.owner_state
.navigation_history_snapshot(None);
assert_eq!(current_index, 0);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].url, "https://example.test/committed");
}
#[test]
+13 -6
View File
@@ -17,7 +17,7 @@ use crate::devtools_runtime::{
DevToolsTargetId, NavigationFrameEvent, NavigationFrameEventKind,
};
use crate::domains::network::{
FailedNavigationDocumentPolicy, FailedNavigationResponseMode,
FailedNavigationDocumentPolicy, FailedNavigationHistoryPolicy, FailedNavigationResponseMode,
MaterializedFailedDocumentProgress, MaterializedNavigationLoadOutcome,
empty_main_document_progress_gate_for_test,
};
@@ -249,8 +249,12 @@ fn background_navigation_completion_sender_routes_explicit_session_owners() {
inactive.attach_active_session("SID-inactive");
conn.inactive_browser_contexts.push(inactive);
let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
conn.set_background_navigation_completion_sender(sender);
let (event_sender, _event_receiver) = tokio::sync::mpsc::unbounded_channel();
let (completion_sender, _completion_receiver) = tokio::sync::mpsc::unbounded_channel();
let (continuation_sender, _continuation_receiver) = tokio::sync::mpsc::unbounded_channel();
conn.set_background_event_sender(event_sender);
conn.set_background_navigation_completion_sender(completion_sender);
conn.set_document_continuation_completion_sender(continuation_sender);
assert!(
conn.background_navigation_completion_sender_for_session_owner(Some("SID-active"))
@@ -414,7 +418,7 @@ async fn removing_active_browser_context_switches_engine_to_promoted_context() {
}
#[tokio::test]
async fn memory_diagnostics_reports_page_vm_document_isolate_model() {
async fn memory_diagnostics_reports_script_agent_document_isolate_model() {
let mut conn = CdpConnection::new();
conn.replace_navigation_engine(
NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics(),
@@ -465,7 +469,7 @@ async fn memory_diagnostics_reports_page_vm_document_isolate_model() {
assert_eq!(
diagnostics["isolateScope"]["documentIsolateModel"],
json!("page-vm")
json!("script-agent")
);
assert_eq!(
diagnostics["isolateScope"]["loadedDocumentPageCount"],
@@ -482,7 +486,7 @@ async fn memory_diagnostics_reports_page_vm_document_isolate_model() {
assert_eq!(
diagnostics["isolateScope"]["estimatedDocumentIsolateCount"],
json!(2),
"page-vm diagnostics should count one document isolate per loaded page"
"independent Pages should retain distinct document script agents"
);
assert_eq!(
diagnostics["isolateScope"]["estimatedWorkerIsolateCount"],
@@ -1229,6 +1233,7 @@ async fn materialized_navigation_completion_drops_stale_token() {
MaterializedNavigationLoadOutcome::Failed(MaterializedFailedDocumentProgress {
error_text: "stale navigation should not emit".to_owned(),
document_policy: FailedNavigationDocumentPolicy::InvalidateCommittedDocument,
history_policy: FailedNavigationHistoryPolicy::DiscardPendingUpdate,
response_mode: FailedNavigationResponseMode::ProtocolError,
progress_gate: empty_main_document_progress_gate_for_test(),
});
@@ -1281,6 +1286,7 @@ async fn materialized_navigation_completion_drops_stale_token_without_navigate_i
MaterializedNavigationLoadOutcome::Failed(MaterializedFailedDocumentProgress {
error_text: "stale navigation should not emit without a navigate id".to_owned(),
document_policy: FailedNavigationDocumentPolicy::InvalidateCommittedDocument,
history_policy: FailedNavigationHistoryPolicy::DiscardPendingUpdate,
response_mode: FailedNavigationResponseMode::ProtocolError,
progress_gate: empty_main_document_progress_gate_for_test(),
});
@@ -1315,6 +1321,7 @@ async fn materialized_navigation_completion_drains_current_token() {
MaterializedNavigationLoadOutcome::Failed(MaterializedFailedDocumentProgress {
error_text: "current navigation should emit".to_owned(),
document_policy: FailedNavigationDocumentPolicy::InvalidateCommittedDocument,
history_policy: FailedNavigationHistoryPolicy::DiscardPendingUpdate,
response_mode: FailedNavigationResponseMode::ProtocolError,
progress_gate: empty_main_document_progress_gate_for_test(),
});
@@ -58,6 +58,9 @@ async fn commit_navigation_outcome_for_test(
NavigationLoadOutcome::Download(_) => {
panic!("test navigation should not resolve to a download")
}
NavigationLoadOutcome::NoCommitResponse(_) => {
panic!("test navigation should not resolve to a no-commit response")
}
NavigationLoadOutcome::NetworkFailure(error_text) => {
panic!("test navigation should not fail: {error_text}")
}
@@ -115,6 +115,7 @@ impl fmt::Debug for ProtocolSchedulerWork {
debug
.field("browser_context_id", &action.browser_context_id())
.field("target_id", &action.target_id())
.field("page_owner", &action.page_owner())
.field("url", &action.url())
.field("navigation_kind", &action.kind());
}
+1
View File
@@ -330,6 +330,7 @@ mod tests {
#[tokio::test]
async fn debugger_instrumentation_navigation_publishes_new_context_with_bound_origin() {
let mut ctx = TestContext::new();
ctx.enable_document_continuation_scheduler_for_test();
with_loaded_document(&mut ctx).await;
let runtime = command(&mut ctx, json!({"id": 34, "method": "Runtime.enable"}), 34).await;
@@ -424,6 +424,7 @@ pub(super) async fn complete_tokened_materialized_navigation_into_buffer_async(
navigation_state,
navigation,
&mut command_context,
None,
))
.await;
if let Some(predecessor) = command_context.take_renderer_output_predecessor() {
@@ -443,6 +443,11 @@ async fn continue_with_auth_and_intercept_response_pauses_before_authorized_body
json!({ "frameId": "TID-1", "loaderId": LOADER_ID }),
Some("SID-1"),
);
ctx.wait_for_document_continuation_for_test(
Some("SID-1"),
"authorized response-stage navigation continuation",
)
.await;
assert!(
loaded_page_html_for_test(&mut ctx)
.await
@@ -105,6 +105,11 @@ async fn response_stage_pause_happens_before_navigation_body_eof() {
json!({ "frameId": "TID-1", "loaderId": LOADER_ID }),
Some("SID-1"),
);
ctx.wait_for_document_continuation_for_test(
Some("SID-1"),
"response-stage navigation continuation",
)
.await;
{
let page = ctx
@@ -416,6 +421,11 @@ lateBinding("author-script");
ctx.expect_result(36_506, json!({}), Some("SID-1"));
let navigate = take_response_by_id(&mut ctx, 36_502);
assert_eq!(navigate["result"]["frameId"], json!("TID-1"));
ctx.wait_for_document_continuation_for_test(
Some("SID-1"),
"response-stage configured Document continuation",
)
.await;
ctx.process_async(json!({
"id": 36_507,
@@ -890,6 +900,31 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document()
.runtime_slot
.enable_primary_network_events();
ctx.conn.browser_context = Some(bc);
ctx.install_navigation_fixture_for_session_owner("about:blank", Some("SID-1"))
.await;
let target_page_before = ctx
.conn
.target_page_residence_identity_for_session(Some("SID-1"))
.expect("interleaved navigation target Page residence");
let renderer_page_before = ctx
.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1"))
.expect("interleaved navigation renderer Page residence");
let page_attachment_before = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::page_attachment_id)
.expect("interleaved navigation target Page attachment");
let devtools_agent_before = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::loaded_page)
.map(moli_core::page::Page::renderer_devtools_agent_token)
.expect("interleaved navigation initial renderer agent");
ctx.enable_page_events_for_test(Some("SID-1"));
ctx.sent.clear();
ctx.process_async(json!({
"id": 364,
@@ -980,6 +1015,35 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document()
attachment_before_continue,
"a superseded response head must not switch the renderer channel"
);
assert_eq!(
ctx.conn
.target_page_residence_identity_for_session(Some("SID-1")),
Some(target_page_before.clone()),
"a superseded response head must preserve the target Page residence"
);
assert_eq!(
ctx.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1")),
Some(renderer_page_before),
"a superseded response head must preserve the renderer Page residence"
);
let browser_context = ctx
.conn
.browser_context
.as_ref()
.expect("interleaved navigation browser context");
assert_eq!(
browser_context.page_attachment_id(),
Some(page_attachment_before)
);
assert_eq!(
browser_context
.loaded_page()
.expect("superseded navigation must preserve the loaded Page")
.renderer_devtools_agent_token(),
devtools_agent_before,
"a superseded response head must preserve the live renderer agent"
);
ctx.process_async(json!({
"id": 368,
@@ -995,6 +1059,7 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document()
current_navigation["result"]["loaderId"],
second_pause["params"]["networkId"]
);
wait_until_frame_stopped_loading(&mut ctx, "TID-1").await;
let page = ctx
.conn
@@ -1002,8 +1067,28 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document()
.as_ref()
.and_then(|bc| bc.loaded_page())
.expect("current navigation should commit a page");
assert_eq!(
ctx.conn
.target_page_residence_identity_for_session(Some("SID-1")),
Some(target_page_before),
"the current response head must replace the Document in the same target Page"
);
assert_eq!(
ctx.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1")),
Some(renderer_page_before),
"the current response head must retain the renderer Page residence"
);
assert_eq!(
ctx.conn
.browser_context
.as_ref()
.and_then(BrowserContext::page_attachment_id),
Some(page_attachment_before)
);
assert_eq!(page.renderer_devtools_agent_token(), second_agent);
assert_ne!(page.renderer_devtools_agent_token(), first_agent);
assert_ne!(page.renderer_devtools_agent_token(), devtools_agent_before);
let html = page
.serialize_html_async()
.await
@@ -1389,6 +1474,548 @@ async fn continue_response_can_override_status_and_headers() {
server.abort();
}
#[derive(Clone, Copy)]
enum NoCommitResponseStageAction {
Fulfill,
ContinueOverride,
ContinueOriginal,
}
async fn assert_response_stage_no_commit_case(
ctx: &mut TestContext,
navigate_id: u64,
terminal_id: u64,
url: &str,
original_status: u16,
effective_status: u16,
action: NoCommitResponseStageAction,
) {
assert!(
ctx.sent.is_empty(),
"case must start with an empty event queue"
);
let target_page_before = ctx
.conn
.target_page_residence_identity_for_session(Some("SID-1"))
.expect("no-commit navigation target Page residence");
let renderer_page_before = ctx
.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1"))
.expect("no-commit navigation renderer Page residence");
let page_attachment_before = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::page_attachment_id)
.expect("no-commit navigation target Page attachment");
let devtools_agent_before = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::loaded_page)
.map(moli_core::page::Page::renderer_devtools_agent_token)
.expect("no-commit navigation initial renderer agent");
let html_before = loaded_page_html_for_test(ctx).await;
ctx.process_async(json!({
"id": navigate_id,
"method": "Page.navigate",
"sessionId": "SID-1",
"params": { "url": url }
}))
.await;
let paused = take_main_document_request_pause(ctx).await;
assert_eq!(paused["params"]["responseStatusCode"], original_status);
let fetch_request_id = paused["params"]["requestId"]
.as_str()
.expect("response-stage Fetch request id")
.to_owned();
let network_request_id = paused["params"]["networkId"]
.as_str()
.expect("response-stage Network request id")
.to_owned();
let prepared_agent = ctx
.conn
.browser_context
.as_ref()
.and_then(|browser_context| {
browser_context
.active_target
.fetch_owner
.pending_fetch_response_prepared_renderer_agent_for_test(&fetch_request_id)
});
assert_eq!(
prepared_agent.is_some(),
!matches!(original_status, 204 | 205),
"only a response that can commit should reserve a renderer agent at response pause"
);
assert!(
!ctx.sent
.iter()
.any(|message| message["id"] == json!(navigate_id)),
"Page.navigate must remain pending while the effective response is undecided: {:?}",
ctx.sent
);
let (method, params) = match action {
NoCommitResponseStageAction::Fulfill => (
"Fetch.fulfillRequest",
json!({
"requestId": fetch_request_id,
"responseCode": effective_status,
"responseHeaders": [
{ "name": "content-type", "value": "text/html; charset=utf-8" }
]
}),
),
NoCommitResponseStageAction::ContinueOverride => (
"Fetch.continueResponse",
json!({
"requestId": fetch_request_id,
"responseCode": effective_status,
"responseHeaders": [
{ "name": "content-type", "value": "text/html; charset=utf-8" }
],
"responsePhrase": "No Content"
}),
),
NoCommitResponseStageAction::ContinueOriginal => (
"Fetch.continueResponse",
json!({ "requestId": fetch_request_id }),
),
};
ctx.process_async(json!({
"id": terminal_id,
"method": method,
"sessionId": "SID-1",
"params": params
}))
.await;
ctx.expect_result(terminal_id, json!({}), Some("SID-1"));
let navigate_response = take_response_by_id(ctx, navigate_id);
assert_eq!(navigate_response["sessionId"], "SID-1");
assert_eq!(navigate_response["result"]["frameId"], "TID-1");
assert!(navigate_response["result"]["loaderId"].as_str().is_some());
assert_eq!(
navigate_response["result"]["errorText"], "net::ERR_ABORTED",
"unexpected Page.navigate terminal: {navigate_response:?}; remaining={:?}",
ctx.sent
);
assert_eq!(navigate_response["result"]["isDownload"], false);
let response_index = ctx
.sent
.iter()
.position(|message| {
message["method"] == json!("Network.responseReceived")
&& message["params"]["requestId"].as_str() == Some(network_request_id.as_str())
&& message["params"]["response"]["status"] == json!(effective_status)
})
.unwrap_or_else(|| {
panic!(
"missing effective {effective_status} response for {url}: {:?}",
ctx.sent
)
});
let failed_index = ctx
.sent
.iter()
.position(|message| {
message["method"] == json!("Network.loadingFailed")
&& message["params"]["requestId"].as_str() == Some(network_request_id.as_str())
&& message["params"]["errorText"] == json!("net::ERR_ABORTED")
&& message["params"]["canceled"] == json!(true)
})
.unwrap_or_else(|| panic!("missing no-commit loadingFailed for {url}: {:?}", ctx.sent));
assert!(
response_index < failed_index,
"the effective response must be observed before its canceled terminal"
);
assert!(!ctx.sent.iter().any(|message| {
message["method"] == json!("Network.loadingFinished")
&& message["params"]["requestId"].as_str() == Some(network_request_id.as_str())
}));
assert!(!ctx.sent.iter().any(|message| {
(message["method"] == json!("Page.frameNavigated")
&& message["params"]["frame"]["url"] == json!(url))
|| matches!(
message["method"].as_str(),
Some("DOM.documentUpdated" | "Page.domContentEventFired" | "Page.loadEventFired")
)
}));
assert_eq!(
ctx.conn
.target_page_residence_identity_for_session(Some("SID-1")),
Some(target_page_before),
"a no-commit response must retain the target Page residence"
);
assert_eq!(
ctx.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1")),
Some(renderer_page_before),
"a no-commit response must retain the renderer Page residence"
);
let browser_context = ctx
.conn
.browser_context
.as_ref()
.expect("no-commit browser context");
assert_eq!(
browser_context.page_attachment_id(),
Some(page_attachment_before)
);
assert_eq!(
browser_context
.loaded_page()
.expect("the committed Document must remain installed")
.renderer_devtools_agent_token(),
devtools_agent_before,
"a no-commit response must not replace the Document realm"
);
assert_eq!(loaded_page_html_for_test(ctx).await, html_before);
assert!(
!ctx.conn
.has_pending_document_navigation_for_session_owner(Some("SID-1")),
"the no-commit terminal must clear its pending navigation"
);
assert!(
!ctx.conn
.browser_context
.as_ref()
.expect("no-commit browser context")
.active_target
.fetch_owner
.pending_fetch_response_transfer_is_pending_for_test(&fetch_request_id),
"the response-stage transfer must be consumed exactly once"
);
ctx.sent.clear();
}
// Chromium sources:
// content/browser/renderer_host/navigation_request.cc
// third_party/blink/public/devtools_protocol/domains/Fetch.pdl
// third_party/blink/web_tests/http/tests/inspector-protocol/fetch/
// fetch-continue-response-with-overrides.js
#[tokio::test(flavor = "multi_thread")]
async fn response_stage_effective_no_content_statuses_abort_without_committing() {
async fn ok_page() -> impl IntoResponse {
(
[(CONTENT_TYPE.as_str(), "text/html")],
"<!doctype html><main>network-body</main>",
)
}
async fn no_content() -> StatusCode {
StatusCode::NO_CONTENT
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(
listener,
Router::new()
.route("/ok", get(ok_page))
.route("/no-content", get(no_content)),
)
.await
.unwrap();
});
let mut ctx = TestContext::new();
let mut browser_context = attached_browser_context();
browser_context
.active_target
.runtime_slot
.enable_primary_network_events();
ctx.conn.browser_context = Some(browser_context);
ctx.install_navigation_fixture_for_session_owner("about:blank", Some("SID-1"))
.await;
ctx.enable_page_events_for_test(Some("SID-1"));
ctx.enable_dom_events_for_test(Some("SID-1"));
wait_until_scheduler_message(&mut ctx, "initial Page.loadEventFired", |message| {
message["method"] == json!("Page.loadEventFired") && message["sessionId"] == json!("SID-1")
})
.await;
ctx.sent.clear();
ctx.process_async(json!({
"id": 43_000,
"method": "Fetch.enable",
"sessionId": "SID-1",
"params": {
"patterns": [{
"urlPattern": "*",
"requestStage": "Response",
"resourceType": "Document"
}]
}
}))
.await;
ctx.expect_result(43_000, json!({}), Some("SID-1"));
assert_response_stage_no_commit_case(
&mut ctx,
43_001,
43_002,
&format!("http://{addr}/ok"),
200,
204,
NoCommitResponseStageAction::Fulfill,
)
.await;
assert_response_stage_no_commit_case(
&mut ctx,
43_003,
43_004,
&format!("http://{addr}/ok"),
200,
205,
NoCommitResponseStageAction::ContinueOverride,
)
.await;
assert_response_stage_no_commit_case(
&mut ctx,
43_005,
43_006,
&format!("http://{addr}/no-content"),
204,
204,
NoCommitResponseStageAction::ContinueOriginal,
)
.await;
server.abort();
}
#[tokio::test(flavor = "multi_thread")]
async fn response_stage_fulfill_can_replace_original_no_content_with_committable_response() {
async fn no_content() -> StatusCode {
StatusCode::NO_CONTENT
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(
listener,
Router::new().route("/no-content", get(no_content)),
)
.await
.unwrap();
});
let mut ctx = TestContext::new();
let mut browser_context = attached_browser_context();
browser_context
.active_target
.runtime_slot
.enable_primary_network_events();
ctx.conn.browser_context = Some(browser_context);
ctx.install_navigation_fixture_for_session_owner("about:blank", Some("SID-1"))
.await;
let target_page_before = ctx
.conn
.target_page_residence_identity_for_session(Some("SID-1"))
.expect("fulfilled navigation target Page residence");
let renderer_page_before = ctx
.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1"))
.expect("fulfilled navigation renderer Page residence");
let page_attachment_before = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::page_attachment_id)
.expect("fulfilled navigation target Page attachment");
let devtools_agent_before = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::loaded_page)
.map(moli_core::page::Page::renderer_devtools_agent_token)
.expect("fulfilled navigation initial renderer agent");
ctx.enable_page_events_for_test(Some("SID-1"));
ctx.enable_dom_events_for_test(Some("SID-1"));
wait_until_scheduler_message(&mut ctx, "initial Page.loadEventFired", |message| {
message["method"] == json!("Page.loadEventFired") && message["sessionId"] == json!("SID-1")
})
.await;
ctx.sent.clear();
ctx.process_async(json!({
"id": 43_010,
"method": "Fetch.enable",
"sessionId": "SID-1",
"params": {
"patterns": [{
"urlPattern": "*",
"requestStage": "Response",
"resourceType": "Document"
}]
}
}))
.await;
ctx.expect_result(43_010, json!({}), Some("SID-1"));
let url = format!("http://{addr}/no-content");
ctx.process_async(json!({
"id": 43_011,
"method": "Page.navigate",
"sessionId": "SID-1",
"params": { "url": url }
}))
.await;
let paused = take_main_document_request_pause(&mut ctx).await;
assert_eq!(paused["params"]["responseStatusCode"], 204);
let fetch_request_id = paused["params"]["requestId"]
.as_str()
.expect("response-stage Fetch request id")
.to_owned();
let network_request_id = paused["params"]["networkId"]
.as_str()
.expect("response-stage Network request id")
.to_owned();
assert!(
ctx.conn
.browser_context
.as_ref()
.and_then(|browser_context| {
browser_context
.active_target
.fetch_owner
.pending_fetch_response_prepared_renderer_agent_for_test(&fetch_request_id)
})
.is_none(),
"the original 204 response must not reserve a renderer agent before interception"
);
let synthetic_body = base64::engine::general_purpose::STANDARD
.encode("<!doctype html><main>rescued-from-204</main>");
ctx.process_async(json!({
"id": 43_012,
"method": "Fetch.fulfillRequest",
"sessionId": "SID-1",
"params": {
"requestId": fetch_request_id,
"responseCode": 200,
"responseHeaders": [
{ "name": "content-type", "value": "text/html; charset=utf-8" }
],
"body": synthetic_body
}
}))
.await;
ctx.expect_result(43_012, json!({}), Some("SID-1"));
ctx.expect_result(
43_011,
json!({ "frameId": "TID-1", "loaderId": network_request_id }),
Some("SID-1"),
);
ctx.wait_for_document_continuation_for_test(
Some("SID-1"),
"fulfilled response-stage Document continuation",
)
.await;
wait_until_scheduler_message(
&mut ctx,
"fulfilled response-stage Network.loadingFinished",
|message| {
message["method"] == json!("Network.loadingFinished")
&& message["params"]["requestId"] == json!(network_request_id)
},
)
.await;
let response = ctx
.sent
.iter()
.find(|message| {
message["method"] == json!("Network.responseReceived")
&& message["params"]["requestId"] == json!(network_request_id)
})
.unwrap_or_else(|| panic!("missing synthetic response: {:?}", ctx.sent));
assert_eq!(response["params"]["response"]["status"], 200);
assert!(ctx.sent.iter().any(|message| {
message["method"] == json!("Network.loadingFinished")
&& message["params"]["requestId"] == json!(network_request_id)
}));
assert!(!ctx.sent.iter().any(|message| {
message["method"] == json!("Network.loadingFailed")
&& message["params"]["requestId"] == json!(network_request_id)
}));
assert_eq!(
ctx.sent
.iter()
.filter(|message| {
message["method"] == json!("Page.frameNavigated")
&& message["params"]["frame"]["url"] == json!(url)
})
.count(),
1,
"the effective 200 response must publish exactly one Document commit"
);
assert!(
ctx.sent
.iter()
.any(|message| message["method"] == json!("DOM.documentUpdated"))
);
assert!(
ctx.sent
.iter()
.any(|message| message["method"] == json!("Page.domContentEventFired"))
);
assert!(
ctx.sent
.iter()
.any(|message| message["method"] == json!("Page.loadEventFired"))
);
assert_eq!(
ctx.conn
.target_page_residence_identity_for_session(Some("SID-1")),
Some(target_page_before),
"the fulfilled navigation must retain the target Page residence"
);
assert_eq!(
ctx.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1")),
Some(renderer_page_before),
"the fulfilled navigation must retain the renderer Page residence"
);
let browser_context = ctx
.conn
.browser_context
.as_ref()
.expect("fulfilled navigation browser context");
assert_eq!(
browser_context.page_attachment_id(),
Some(page_attachment_before)
);
assert_ne!(
browser_context
.loaded_page()
.expect("the synthetic 200 Document must be installed")
.renderer_devtools_agent_token(),
devtools_agent_before,
"the effective 200 response must install a new Document realm"
);
assert!(
loaded_page_html_for_test(&mut ctx)
.await
.contains("rescued-from-204")
);
assert!(
!ctx.conn
.has_pending_document_navigation_for_session_owner(Some("SID-1"))
);
server.abort();
}
#[tokio::test(flavor = "multi_thread")]
async fn continue_response_header_override_keeps_streaming_parser_body() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -2819,6 +3446,29 @@ async fn fulfill_request_completes_navigation_with_synthetic_response() {
.runtime_slot
.enable_primary_network_events();
ctx.conn.browser_context = Some(bc);
ctx.install_navigation_fixture_for_session_owner("about:blank", Some("SID-1"))
.await;
let target_page_before = ctx
.conn
.target_page_residence_identity_for_session(Some("SID-1"))
.expect("fulfilled navigation target Page residence");
let renderer_page_before = ctx
.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1"))
.expect("fulfilled navigation renderer Page residence");
let page_attachment_before = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::page_attachment_id)
.expect("fulfilled navigation target Page attachment");
let devtools_agent_before = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::loaded_page)
.map(moli_core::page::Page::renderer_devtools_agent_token)
.expect("fulfilled navigation initial renderer agent");
ctx.enable_page_events_for_test(Some("SID-1"));
ctx.enable_dom_events_for_test(Some("SID-1"));
@@ -2890,6 +3540,36 @@ async fn fulfill_request_completes_navigation_with_synthetic_response() {
assert_eq!(ctx.take_one()["method"], "Page.loadEventFired");
assert_eq!(ctx.take_one()["method"], "Page.frameStoppedLoading");
assert_eq!(
ctx.conn
.target_page_residence_identity_for_session(Some("SID-1")),
Some(target_page_before),
"Fetch.fulfillRequest must replace the Document without replacing the target Page"
);
assert_eq!(
ctx.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1")),
Some(renderer_page_before),
"Fetch.fulfillRequest must retain the renderer Page residence"
);
let browser_context = ctx
.conn
.browser_context
.as_ref()
.expect("fulfilled navigation browser context");
assert_eq!(
browser_context.page_attachment_id(),
Some(page_attachment_before)
);
assert_ne!(
browser_context
.loaded_page()
.expect("fulfilled navigation loaded Page")
.renderer_devtools_agent_token(),
devtools_agent_before,
"Fetch.fulfillRequest must install a new renderer Document agent"
);
ctx.process_async(json!({
"id": 39,
"method": "Network.getResponseBody",
+9 -7
View File
@@ -92,17 +92,19 @@ pub(crate) use main_document_progress::FailedNavigationDocumentPolicy;
pub(crate) use main_document_progress::empty_main_document_progress_gate_for_test;
pub(crate) use main_document_progress::{
CompletedDocumentProgressTransfer, CompletedDownloadProgressTransfer,
CompletedMainDocumentNetworkEvents, FailedNavigationResponseMode,
MainDocumentBodyNetworkProgress, MainDocumentBodyProgressSource,
MainDocumentProgressBackgroundEventBarrier, MainDocumentProgressGate,
MaterializedDownloadDocumentProgress, MaterializedFailedDocumentProgress,
MaterializedLoadedDocumentProgress, MaterializedNavigationLoadOutcome,
CompletedMainDocumentNetworkEvents, CompletedNoCommitResponseProgressTransfer,
FailedNavigationHistoryPolicy, FailedNavigationResponseMode, MainDocumentBodyNetworkProgress,
MainDocumentBodyProgressSource, MainDocumentProgressBackgroundEventBarrier,
MainDocumentProgressGate, MaterializedDownloadDocumentProgress,
MaterializedFailedDocumentProgress, MaterializedLoadedDocumentProgress,
MaterializedNavigationLoadOutcome, MaterializedStablePageDocumentProgress,
emit_child_document_navigation_network_background_events,
emit_fetch_navigation_initial_request_for_pause_background_events,
materialize_loaded_navigation_progress,
materialize_navigation_failure_preserving_committed_document,
materialize_navigation_load_result, record_completed_main_document_response_body,
record_failed_main_document_response_body, record_main_document_request_body,
materialize_navigation_load_result, materialize_stable_page_navigation_progress,
record_completed_main_document_response_body, record_failed_main_document_response_body,
record_main_document_request_body,
response_stage_main_document_navigation_network_progress,
start_observed_main_document_navigation_progress_background_events,
};
@@ -137,6 +137,11 @@ impl MainDocumentProgressGate {
Self { queue }
}
pub(super) fn make_body_finished_visible(&mut self) {
self.queue
.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible);
}
fn drain_visible_until_into_background_events(
&mut self,
boundary: MainDocumentProgressOutputBoundary,
@@ -18,8 +18,9 @@ use url::Url;
use crate::conn::{
BackgroundEventSender, BackgroundProtocolEvent, CapturedBody, CdpConnection,
CompletedDownloadBodyArtifact, DownloadNavigation, LoadedNavigation, NavigationDispatchState,
NavigationLoadOutcome, ResponseCommitReady, TargetRuntimeSlot,
CommittedStablePageNavigation, CompletedDownloadBodyArtifact, DownloadNavigation,
LoadedNavigation, NavigationDispatchState, NavigationLoadOutcome, NoCommitResponseNavigation,
ResponseCommitReady, StablePageNavigationCommitTarget, TargetRuntimeSlot,
};
#[cfg(test)]
@@ -41,6 +42,26 @@ pub(crate) struct MaterializedLoadedDocumentProgress {
pub(crate) initial_runtime_realms: Vec<RendererRuntimeRealmInfo>,
pub(crate) renderer_output_predecessor: Option<RendererOutputFence>,
pub(crate) main_document_commit: Option<Arc<RendererMainDocumentCommit>>,
pub(crate) document_continuation_observer:
Option<moli_core::page::RendererDocumentContinuationObserver>,
pub(crate) progress_gate: MainDocumentProgressGate,
pub(crate) navigation_engine: Option<NavigationEngine>,
pub(crate) network_error_page: Option<crate::conn::NetworkErrorPageNavigation>,
}
pub(crate) struct MaterializedStablePageDocumentProgress {
pub(crate) stable_page_target: StablePageNavigationCommitTarget,
pub(crate) pending_download: Option<RendererPendingDownloadActivation>,
pub(crate) page_creation_artifacts: RendererPageCreationArtifacts,
pub(crate) final_url: Url,
pub(crate) response_headers: Vec<(String, String)>,
pub(crate) response_from_cache: bool,
pub(crate) main_document_body: Option<CapturedBody>,
pub(crate) initial_runtime_realms: Vec<RendererRuntimeRealmInfo>,
pub(crate) renderer_output_predecessor: Option<RendererOutputFence>,
pub(crate) main_document_commit: Option<Arc<RendererMainDocumentCommit>>,
pub(crate) document_continuation_observer:
Option<moli_core::page::RendererDocumentContinuationObserver>,
pub(crate) progress_gate: MainDocumentProgressGate,
pub(crate) navigation_engine: Option<NavigationEngine>,
pub(crate) network_error_page: Option<crate::conn::NetworkErrorPageNavigation>,
@@ -55,6 +76,7 @@ pub(crate) struct MaterializedDownloadDocumentProgress {
pub(crate) struct MaterializedFailedDocumentProgress {
pub(crate) error_text: String,
pub(crate) document_policy: FailedNavigationDocumentPolicy,
pub(crate) history_policy: FailedNavigationHistoryPolicy,
pub(crate) response_mode: FailedNavigationResponseMode,
pub(crate) progress_gate: MainDocumentProgressGate,
}
@@ -71,6 +93,12 @@ pub(crate) enum FailedNavigationDocumentPolicy {
PreserveCommittedDocument,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FailedNavigationHistoryPolicy {
DiscardPendingUpdate,
RetainInitialEmptyDocumentReplacement,
}
impl FailedNavigationDocumentPolicy {
pub(crate) fn invalidates_committed_document(self) -> bool {
matches!(self, Self::InvalidateCommittedDocument)
@@ -172,6 +200,41 @@ pub(crate) struct CompletedDownloadProgressTransfer {
network_events: CompletedMainDocumentNetworkEvents,
}
#[derive(Debug)]
pub(crate) struct CompletedNoCommitResponseProgressTransfer {
network_progress: MainDocumentBodyNetworkProgress,
}
impl CompletedNoCommitResponseProgressTransfer {
pub(crate) fn new(network_progress: MainDocumentBodyNetworkProgress) -> Self {
Self { network_progress }
}
fn into_progress_gate(
self,
conn: &mut CdpConnection,
state: &NavigationDispatchState,
final_url: &Url,
error_text: &str,
) -> MainDocumentProgressGate {
record_failed_main_document_response_body(conn, state, error_text.to_owned());
let mut progress_gate =
MainDocumentProgressGate::from_queue(completed_no_commit_response_progress_queue(
self.network_progress,
conn,
state,
final_url,
error_text,
));
// Unlike a committed Document, a no-commit response has no renderer
// lifecycle boundary that can reveal response/body phases later. Its
// terminal command turn must release responseReceived and
// loadingFailed together, in source order.
progress_gate.make_body_finished_visible();
progress_gate
}
}
#[derive(Debug)]
enum CompletedDownloadProgressBody {
Buffered(Vec<u8>),
@@ -288,6 +351,7 @@ fn materialize_failed_navigation_progress(
MaterializedFailedDocumentProgress {
error_text,
document_policy,
history_policy: FailedNavigationHistoryPolicy::DiscardPendingUpdate,
response_mode,
progress_gate,
}
@@ -308,6 +372,7 @@ pub(crate) fn materialize_loaded_navigation_progress(
initial_runtime_realms,
renderer_output_predecessor,
main_document_commit,
document_continuation_observer,
document_progress_transfer,
navigation_engine,
network_error_page,
@@ -329,6 +394,50 @@ pub(crate) fn materialize_loaded_navigation_progress(
initial_runtime_realms,
renderer_output_predecessor,
main_document_commit,
document_continuation_observer,
progress_gate,
navigation_engine,
network_error_page,
}
}
pub(crate) fn materialize_stable_page_navigation_progress(
conn: &mut CdpConnection,
state: &NavigationDispatchState,
navigation: CommittedStablePageNavigation,
) -> MaterializedStablePageDocumentProgress {
let CommittedStablePageNavigation {
stable_page_target,
pending_download,
page_creation_artifacts,
final_url,
response_headers,
response_from_cache,
initial_runtime_realms,
renderer_output_predecessor,
main_document_commit,
document_continuation_observer,
document_progress_transfer,
navigation_engine,
network_error_page,
} = navigation;
let main_document_body = document_progress_transfer.captured_body();
let progress_gate = match network_error_page.as_ref() {
Some(error_page) => network_error_page_progress_gate(conn, state, error_page.error_text()),
None => document_progress_transfer.into_progress_gate(conn, state, &final_url),
};
MaterializedStablePageDocumentProgress {
stable_page_target,
pending_download,
page_creation_artifacts,
final_url,
response_headers,
response_from_cache,
main_document_body,
initial_runtime_realms,
renderer_output_predecessor,
main_document_commit,
document_continuation_observer,
progress_gate,
navigation_engine,
network_error_page,
@@ -352,6 +461,23 @@ fn materialize_navigation_load_outcome(
NavigationLoadOutcome::Download(navigation) => MaterializedNavigationLoadOutcome::Download(
materialize_download_navigation_progress(conn, state, *navigation),
),
NavigationLoadOutcome::NoCommitResponse(navigation) => {
let NoCommitResponseNavigation {
final_url,
progress_transfer,
} = *navigation;
let error_text = moli_fetch::NET_ERR_ABORTED_ERROR_TEXT.to_owned();
let progress_gate =
progress_transfer.into_progress_gate(conn, state, &final_url, &error_text);
MaterializedNavigationLoadOutcome::Failed(MaterializedFailedDocumentProgress {
error_text,
document_policy: FailedNavigationDocumentPolicy::PreserveCommittedDocument,
history_policy:
FailedNavigationHistoryPolicy::RetainInitialEmptyDocumentReplacement,
response_mode: FailedNavigationResponseMode::CdpErrorTextResult,
progress_gate,
})
}
NavigationLoadOutcome::NetworkFailure(error_text) => {
MaterializedNavigationLoadOutcome::Failed(materialize_failed_navigation_progress(
conn,
@@ -502,6 +628,36 @@ fn completed_download_body_progress_queue(
)
}
fn completed_no_commit_response_progress_queue(
progress: MainDocumentBodyNetworkProgress,
conn: &CdpConnection,
state: &NavigationDispatchState,
final_url: &Url,
error_text: &str,
) -> MainDocumentProgressQueueHandle {
let Some(events) = progress.into_completed_body_events() else {
return MainDocumentProgressQueueHandle::from_source(
MainDocumentProgressSource::streaming(),
);
};
let network_observed = main_document_network_observed(conn, state.session_id.as_deref());
let context = CompletedMainDocumentProgressContext::new(
main_document_network_event_session_ids(conn, state.session_id.as_deref()),
completed_body_main_document_network_request_id(network_observed, state.request_id.clone()),
state.request_announced,
state.requested_url.clone(),
state.request_method.clone(),
state.request_body.clone(),
state.request_headers.clone(),
state.loader_id.clone(),
state.frame_id.clone(),
state.timestamp,
);
MainDocumentProgressQueueHandle::from_source(MainDocumentProgressSource::completed_body(
context.no_commit_response_event_batches(&events, final_url, error_text),
))
}
fn completed_or_streaming_document_progress_queue(
conn: &CdpConnection,
state: &NavigationDispatchState,
@@ -773,6 +929,12 @@ impl MainDocumentBodyProgressSource {
}
}
pub(crate) fn emit_body_failed(&self, error_text: &str) {
if let Some(live_source) = self.live_source.as_ref() {
live_source.emit_body_failed(error_text);
}
}
pub(crate) fn body_network_progress_for_completed_events(
&self,
completed_events: CompletedMainDocumentNetworkEvents,
@@ -1221,6 +1383,16 @@ impl MainDocumentLiveNetworkProgressSource {
);
}
fn emit_body_failed(&self, error_text: &str) {
self.send_progress_events(
MainDocumentProgressPhase::BodyFinished,
vec![MainDocumentNavigationProgressEvent::LoadingFailed {
target: self.progress_target(),
error_text: error_text.to_owned(),
}],
);
}
fn progress_target(&self) -> MainDocumentProgressEventTarget {
MainDocumentProgressEventTarget {
session_ids: self.session_ids.clone(),
@@ -1599,6 +1771,19 @@ impl CompletedMainDocumentProgressContext {
)
}
fn no_commit_response_event_batches(
&self,
events: &CompletedMainDocumentNetworkEvents,
final_url: &Url,
error_text: &str,
) -> MainDocumentNavigationProgressEventBatches {
MainDocumentNavigationProgressEventBatches::new(
self.request_and_redirect_progress_events(events),
self.response_received_progress_events(events, final_url, 0),
self.loading_failed_progress_events(error_text),
)
}
fn request_and_redirect_progress_events(
&self,
events: &CompletedMainDocumentNetworkEvents,
@@ -1792,6 +1977,19 @@ impl CompletedMainDocumentProgressContext {
}]
}
fn loading_failed_progress_events(
&self,
error_text: &str,
) -> Vec<MainDocumentNavigationProgressEvent> {
let Some(target) = self.progress_target() else {
return Vec::new();
};
vec![MainDocumentNavigationProgressEvent::LoadingFailed {
target,
error_text: error_text.to_owned(),
}]
}
fn progress_target(&self) -> Option<MainDocumentProgressEventTarget> {
Some(MainDocumentProgressEventTarget {
session_ids: self.session_ids.clone(),
@@ -1372,6 +1372,64 @@ fn progress_output_barrier_drains_source_generated_progress_before_cdp_output()
assert_eq!(out[2]["method"], json!("Network.loadingFinished"));
}
#[test]
fn completed_no_commit_response_progress_orders_response_before_canceled_failure() {
let final_url = Url::parse("http://example.test/no-content").unwrap();
let events = CompletedMainDocumentNetworkEvents::new(
"GET".to_owned(),
vec![("Accept".to_owned(), "text/html".to_owned())],
None,
204,
vec![("Content-Type".to_owned(), "text/plain".to_owned())],
Vec::new(),
Vec::new(),
false,
false,
);
let batches = completed_progress_context().no_commit_response_event_batches(
&events,
&final_url,
"net::ERR_ABORTED",
);
let mut drain =
MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches));
let mut out = drain_into_protocol_messages(&mut drain);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["method"], json!("Network.requestWillBeSent"));
drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::ResponseMetadataVisible);
out = drain_into_protocol_messages(&mut drain);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["method"], json!("Network.responseReceived"));
assert_eq!(out[0]["params"]["response"]["status"], json!(204));
drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible);
out = drain_into_protocol_messages(&mut drain);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["method"], json!("Network.loadingFailed"));
assert_eq!(out[0]["params"]["errorText"], json!("net::ERR_ABORTED"));
assert_eq!(out[0]["params"]["canceled"], json!(true));
let batches = completed_progress_context().no_commit_response_event_batches(
&events,
&final_url,
"net::ERR_ABORTED",
);
let mut gate = MainDocumentProgressGate::new(MainDocumentProgressDrain::from_source(
MainDocumentProgressSource::completed_body(batches),
));
gate.make_body_finished_visible();
let mut terminal = Vec::new();
MainDocumentProgressBackgroundEventBarrier::background_events(&mut terminal, &mut gate)
.drain_progress();
let terminal = protocol_messages_from_background_events(terminal);
assert_eq!(terminal.len(), 3);
assert_eq!(terminal[0]["method"], json!("Network.requestWillBeSent"));
assert_eq!(terminal[1]["method"], json!("Network.responseReceived"));
assert_eq!(terminal[2]["method"], json!("Network.loadingFailed"));
}
#[test]
fn failed_navigation_progress_drain_prequeues_loading_failed() {
let target = MainDocumentProgressEventTarget {
@@ -44,6 +44,19 @@ fn consume_main_document_navigation_start(ctx: &mut TestContext) {
assert_eq!(ctx.take_one()["method"], "Page.frameStartedNavigating");
assert_eq!(ctx.take_one()["method"], "Page.frameStartedLoading");
}
async fn wait_for_network_error_document_load(ctx: &mut TestContext, session_id: &str) {
wait_until_messages(
ctx,
Some(session_id),
"network error Document stopped loading",
|messages| {
messages
.iter()
.any(|message| message["method"] == json!("Page.frameStoppedLoading"))
},
)
.await;
}
async fn flush_until_subresource_finished(
ctx: &mut TestContext,
resource_type: &str,
@@ -1410,17 +1410,7 @@ async fn first_main_document_transport_failure_commits_error_document() {
"params": { "url": url }
}))
.await;
wait_until_messages(
&mut ctx,
Some("SID-1"),
"first error Document load",
|messages| {
messages
.iter()
.any(|message| message["method"] == json!("Page.loadEventFired"))
},
)
.await;
wait_for_network_error_document_load(&mut ctx, "SID-1").await;
let messages = ctx.take_all();
let request_index = messages
@@ -1469,7 +1459,6 @@ async fn first_main_document_transport_failure_commits_error_document() {
response["result"]["errorText"],
failed["params"]["errorText"]
);
let frame_index = messages
.iter()
.position(|message| {
@@ -1645,12 +1634,7 @@ async fn main_document_navigation_failure_emits_one_failed_and_finished_terminal
}))
.await;
wait_until_messages(&mut ctx, Some("SID-1"), "error Document load", |messages| {
messages
.iter()
.any(|message| message["method"] == json!("Page.loadEventFired"))
})
.await;
wait_for_network_error_document_load(&mut ctx, "SID-1").await;
let messages = ctx.take_all();
assert!(!messages.iter().any(|message| {
matches!(
+115 -1
View File
@@ -98,10 +98,10 @@ pub(in crate::domains) use main_document_commit::{
MainDocumentCommitPreparedOutput, append_renderer_main_document_commit_to_output_sink,
project_main_document_commit_async,
};
pub use navigation::BackgroundNavigationCompletion;
#[cfg(test)]
pub(crate) use navigation::emit_prepared_child_frame_tree_background_events;
pub(crate) use navigation::navigation_cookie_access_report;
pub use navigation::{BackgroundDocumentContinuationCompletion, BackgroundNavigationCompletion};
pub(crate) use navigation::{
MaterializedNavigationCompletion, complete_materialized_navigation_into_buffer_async,
emit_prepared_child_frame_activity, push_superseded_navigation_result,
@@ -2560,6 +2560,23 @@ mod producer_tests {
work
}
fn take_popup_target_navigation_work_for_test(
conn: &mut CdpConnection,
) -> crate::domains::activity::ProtocolSchedulerWork {
let [event]: [crate::conn::CdpSchedulerEvent; 1] = conn
.take_scheduler_events()
.try_into()
.expect("popup activation should publish one concrete scheduler action");
let crate::conn::CdpSchedulerEvent::ProtocolWorkPublished { work } = event else {
panic!("popup activation must publish a protocol owner action");
};
assert_eq!(
work.kind(),
crate::domains::activity::ProtocolSchedulerWorkKind::PopupTargetNavigationOwnerAction
);
work
}
fn root_document_attachment_for_test(
conn: &CdpConnection,
session_id: &str,
@@ -4807,6 +4824,103 @@ mod producer_tests {
));
}
#[tokio::test(flavor = "multi_thread")]
async fn popup_navigation_owner_action_rejects_replaced_page_and_cannot_be_rescanned() {
let mut conn = CdpConnection::default();
conn.set_root_target_discovery_enabled(true);
let mut bc = BrowserContext::new("BID-stale-popup".into());
bc.set_active_target_id("TID-stale-opener");
bc.attach_active_session("SID-stale-opener");
conn.browser_context = Some(bc);
let page_owner = page_residence_identity_for_test(&mut conn, "SID-stale-opener");
let source_document = renderer_document_identity_for_test(1, 1);
let popup_url = "data:text/html,%3Cmain%3Estale-popup%3C/main%3E";
let mut prepared =
ProtocolOutputPayloads::from_slot(super::PagePreparedOutputSlot::from_outputs(
super::PagePreparedOutputs::from_popup_activations_for_test(
page_owner,
vec![RendererPendingPopupActivation::window(
source_document,
RendererWindowDocumentSource::RootFrame,
true,
None,
popup_url.to_owned(),
"_blank".to_owned(),
)],
),
));
let mut out = Vec::new();
super::emit_popup_activity_background_events_async(
&mut conn,
&mut out,
Some("SID-stale-opener"),
Some(&mut prepared),
)
.await;
let popup_target_id = out
.into_iter()
.map(BackgroundProtocolEvent::into_protocol_message)
.find(|message| message["method"] == json!("Target.targetCreated"))
.and_then(|message| {
message["params"]["targetInfo"]["targetId"]
.as_str()
.map(str::to_owned)
})
.expect("popup target should be observable before its navigation runs");
let work = take_popup_target_navigation_work_for_test(&mut conn);
let route = conn
.target_session_route_for_target_id(&popup_target_id)
.expect("popup target should retain an exact route");
{
let mut route_scope = conn.scoped_none_session_owner_route_override(route);
route_scope
.conn_mut()
.runtime_session_owner_slot_mut(None)
.expect("popup target should retain its runtime slot")
.replace_page_attachment_id_for_test();
}
let outcome = conn.complete_ready_protocol_scheduler_work_turn(work).await;
let (protocol_events, scheduler_events) = outcome.into_parts();
assert!(
protocol_events.is_empty(),
"an activation captured for the retired Page residence must not navigate its replacement: {protocol_events:?}"
);
assert!(
scheduler_events.is_empty(),
"rejecting stale popup authority must not manufacture replacement work: {scheduler_events:?}"
);
let route = conn
.target_session_route_for_target_id(&popup_target_id)
.expect("popup target should remain addressable after stale work is rejected");
let mut rescanned_events = Vec::new();
let restarted = {
let mut route_scope = conn.scoped_none_session_owner_route_override(route);
crate::domains::target::start_initial_document_target_url_navigation_if_needed_background_events_async(
route_scope.conn_mut(),
&mut rescanned_events,
None,
)
.await
};
assert!(
!restarted && rescanned_events.is_empty(),
"generic initial-navigation entry points must respect the consumed popup authority tombstone"
);
assert!(
conn.browser_context
.as_ref()
.and_then(|browser_context| { browser_context.background_target(&popup_target_id) })
.and_then(|target| target.loaded_page())
.is_some_and(|page| moli_url::is_about_blank(page.final_url())),
"neither the stale claim nor a later target-URL scan may navigate the replacement Page"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn popup_activation_publishes_automation_lifecycle_without_cdp_discovery() {
let mut conn = CdpConnection::default();
+335 -49
View File
@@ -47,7 +47,10 @@ use super::{
emit_child_frame_document_opened_background_events, emit_child_frame_lifecycle_terminal,
emit_child_frame_navigation_commit, emit_navigation_started_background_events,
},
navigation_commit::{commit_download_navigation_async, commit_loaded_navigation_async},
navigation_commit::{
commit_download_navigation_async, commit_loaded_navigation_async,
commit_stable_page_navigation_async,
},
};
pub(super) struct PendingNavigateLoadCommand {
@@ -394,6 +397,7 @@ pub(crate) struct MaterializedNavigationCompletion {
state: NavigationDispatchState,
navigation: network::MaterializedNavigationLoadOutcome,
engine: Option<NavigationEngine>,
background_document_continuation: Option<BackgroundDocumentContinuationCompletion>,
}
impl MaterializedNavigationCompletion {
@@ -407,6 +411,7 @@ impl MaterializedNavigationCompletion {
state,
navigation,
engine: None,
background_document_continuation: None,
}
}
@@ -415,6 +420,14 @@ impl MaterializedNavigationCompletion {
self
}
pub(crate) fn with_background_document_continuation(
mut self,
completion: BackgroundDocumentContinuationCompletion,
) -> Self {
self.background_document_continuation = Some(completion);
self
}
pub(crate) fn is_current_for_connection(&self, conn: &CdpConnection) -> bool {
conn.accepts_pending_document_navigation_for_session_owner(
self.state.navigate_session_id.as_deref(),
@@ -441,8 +454,15 @@ impl MaterializedNavigationCompletion {
NavigationDispatchState,
network::MaterializedNavigationLoadOutcome,
Option<NavigationEngine>,
Option<BackgroundDocumentContinuationCompletion>,
) {
(self.token, self.state, self.navigation, self.engine)
(
self.token,
self.state,
self.navigation,
self.engine,
self.background_document_continuation,
)
}
}
@@ -537,6 +557,7 @@ impl BackgroundMainDocumentBodyCompletion {
pub enum BackgroundNavigationCompletion {
Lifecycle(Box<BackgroundNavigationLifecycleCompletion>),
MainDocumentBody(Box<BackgroundMainDocumentBodyCompletion>),
DocumentContinuation(Box<BackgroundDocumentContinuationCompletion>),
}
impl BackgroundNavigationCompletion {
@@ -544,6 +565,7 @@ impl BackgroundNavigationCompletion {
match self {
Self::Lifecycle(completion) => completion.state.requested_url.as_str(),
Self::MainDocumentBody(completion) => completion.state.requested_url.as_str(),
Self::DocumentContinuation(completion) => completion.requested_url.as_str(),
}
}
@@ -551,8 +573,101 @@ impl BackgroundNavigationCompletion {
match self {
Self::Lifecycle(_) => "lifecycle",
Self::MainDocumentBody(_) => "main_document_body",
Self::DocumentContinuation(_) => "document_continuation",
}
}
pub fn background_navigation_gate_key(
&self,
) -> Option<crate::conn::BackgroundNavigationGateKey> {
match self {
Self::DocumentContinuation(completion) => {
completion.background_navigation_gate_key.clone()
}
Self::Lifecycle(_) | Self::MainDocumentBody(_) => None,
}
}
pub fn document_continuation_gate_key(
&self,
) -> Option<crate::conn::BackgroundNavigationGateKey> {
match self {
Self::DocumentContinuation(completion) => {
Some(completion.document_continuation_gate_key.clone())
}
Self::Lifecycle(_) | Self::MainDocumentBody(_) => None,
}
}
}
/// Typed terminal for the post-commit renderer continuation.
///
/// `Page.navigate` may expose its response and install the committed renderer
/// attachment before parser/document-start work reaches DOMContentLoaded. The
/// navigation gate remains resident until that exact Document reaches the
/// milestone or terminates/supersedes. This lets Debugger/Fetch control
/// commands resume a commit-time V8 pause without allowing ordinary
/// document-observing commands to overtake the continuation.
pub struct BackgroundDocumentContinuationCompletion {
document_continuation_gate_key: crate::conn::BackgroundNavigationGateKey,
background_navigation_gate_key: Option<crate::conn::BackgroundNavigationGateKey>,
requested_url: String,
renderer_output_predecessor: Option<moli_core::RendererOutputFence>,
renderer_page_state: Option<std::sync::Arc<moli_core::page::RendererPageState>>,
}
impl BackgroundDocumentContinuationCompletion {
pub(crate) fn new(
gate_key: crate::conn::BackgroundNavigationGateKey,
requested_url: String,
) -> Self {
Self {
document_continuation_gate_key: gate_key.clone(),
background_navigation_gate_key: Some(gate_key),
requested_url,
renderer_output_predecessor: None,
renderer_page_state: None,
}
}
fn for_foreground_navigation(
token: &DocumentNavigationToken,
state: &NavigationDispatchState,
) -> Self {
Self {
document_continuation_gate_key:
crate::conn::BackgroundNavigationGateKey::for_navigation(token, state),
background_navigation_gate_key: None,
requested_url: state.requested_url.as_str().to_owned(),
renderer_output_predecessor: None,
renderer_page_state: None,
}
}
pub(crate) fn document_continuation_gate_key(
&self,
) -> crate::conn::BackgroundNavigationGateKey {
self.document_continuation_gate_key.clone()
}
pub(crate) fn with_renderer_completion(
mut self,
completion: moli_core::page::RendererDocumentContinuationCompletion,
) -> Self {
let (predecessor, page_state) = completion.into_parts();
self.renderer_output_predecessor = predecessor;
self.renderer_page_state = page_state;
self
}
pub(crate) fn into_renderer_completion_parts(
self,
) -> (
Option<moli_core::RendererOutputFence>,
Option<std::sync::Arc<moli_core::page::RendererPageState>>,
) {
(self.renderer_output_predecessor, self.renderer_page_state)
}
}
pub struct BackgroundNavigationLifecycleCompletion {
@@ -605,6 +720,13 @@ impl BackgroundNavigationLifecycleCompletion {
self.state.requested_url.as_str()
}
pub(crate) fn continuation_completion(&self) -> BackgroundDocumentContinuationCompletion {
BackgroundDocumentContinuationCompletion::new(
crate::conn::BackgroundNavigationGateKey::for_navigation(&self.token, &self.state),
self.state.requested_url.as_str().to_owned(),
)
}
pub(crate) fn ready_elapsed_ms(&self) -> u128 {
self.ready_at.elapsed().as_millis()
}
@@ -620,6 +742,7 @@ impl BackgroundNavigationLifecycleCompletion {
conn: &mut CdpConnection,
retain_engine: bool,
) -> MaterializedNavigationCompletion {
let document_continuation = self.continuation_completion();
let Self {
token,
state,
@@ -635,7 +758,8 @@ impl BackgroundNavigationLifecycleCompletion {
| NavigationLoadOutcome::Loaded(_))
);
let navigation = network::materialize_navigation_load_result(conn, &state, navigation);
let completion = MaterializedNavigationCompletion::new(token, state, navigation);
let completion = MaterializedNavigationCompletion::new(token, state, navigation)
.with_background_document_continuation(document_continuation);
if should_retain_engine {
completion.with_navigation_engine(engine)
} else {
@@ -684,6 +808,10 @@ impl BackgroundNavigationCompletion {
response_from_cache,
)))
}
pub fn document_continuation(completion: BackgroundDocumentContinuationCompletion) -> Self {
Self::DocumentContinuation(Box::new(completion))
}
}
pub(crate) fn current_navigation_initiator_url(browser_context: &BrowserContext) -> Option<Url> {
@@ -3384,64 +3512,158 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async(
state: NavigationDispatchState,
navigation: network::MaterializedNavigationLoadOutcome,
command_context: &mut crate::conn::CommandDispatchContext,
mut background_document_continuation: Option<BackgroundDocumentContinuationCompletion>,
) {
let navigation_session_id = state.navigate_session_id.clone();
let navigation_loader_id = state.loader_id.clone();
let document_continuation_gate_key =
crate::conn::BackgroundNavigationGateKey::for_navigation(&token, &state);
let mut foreground_document_continuation_observer = None;
match navigation {
network::MaterializedNavigationLoadOutcome::ResponseCommitReady(navigation) => {
let navigation = *navigation;
let configuration = conn.prepared_document_commit_configuration_for_session_owner(
let stable_page_target = navigation.stable_page_target().cloned();
let renderer_page = navigation.renderer_page_residence_identity();
let candidate = match conn.prepare_renderer_agent_candidate_token_for_session_owner(
state.navigate_session_id.as_deref(),
navigation.final_url(),
);
if let Err(error) = navigation.update_commit_configuration(configuration).await {
push_navigation_commit_error(out, &state, error);
} else {
let renderer_page = navigation.renderer_page_residence_identity();
let candidate = conn.prepare_renderer_agent_candidate_token_for_session_owner(
&token,
navigation.renderer_devtools_agent_token(),
) {
Ok(candidate) => Some(candidate),
Err(error) => {
tracing::debug!(
%error,
session_id = state.navigate_session_id.as_deref(),
loader_id = token.loader_id,
"dropping superseded response commit-ready navigation"
);
push_navigation_commit_error(out, &state, error);
None
}
};
if let Some(candidate) = candidate {
let configuration = conn.prepared_document_commit_configuration_for_session_owner(
state.navigate_session_id.as_deref(),
&token,
navigation.renderer_devtools_agent_token(),
navigation.final_url(),
);
match candidate.and_then(|candidate| {
conn.commit_renderer_agent_candidate_for_session_owner(
state.navigate_session_id.as_deref(),
candidate,
renderer_page,
)
}) {
if let Err(error) = navigation.update_commit_configuration(configuration).await {
push_navigation_commit_error(out, &state, error);
} else {
match match stable_page_target.as_ref() {
Some(expected) => conn
.commit_renderer_agent_candidate_for_stable_page_replacement_for_session_owner(
state.navigate_session_id.as_deref(),
candidate,
expected,
),
None => conn.commit_renderer_agent_candidate_for_session_owner(
state.navigate_session_id.as_deref(),
candidate,
renderer_page,
),
} {
Ok(transaction) => {
let permit = navigation.issue_commit_permit();
match navigation.commit(permit).await {
Ok(navigation) => {
let navigation = network::materialize_loaded_navigation_progress(
conn, &state, navigation,
);
commit_loaded_navigation_async(
conn,
out,
Some(&token),
state,
if let Some(expected) = stable_page_target {
let failed_url = navigation.final_url().clone();
match conn
.commit_stable_page_navigation_document_for_session_owner_async(
state.navigate_session_id.as_deref(),
&expected,
navigation,
Some(transaction),
command_context,
permit,
)
.await;
}
Err(error) => {
if let Err(rollback_error) = conn
.rollback_committed_renderer_agent_candidate_for_session_owner(
state.navigate_session_id.as_deref(),
.await
{
Ok(navigation) => {
let mut navigation =
network::materialize_stable_page_navigation_progress(
conn, &state, navigation,
);
foreground_document_continuation_observer =
schedule_renderer_document_continuation(
conn,
&token,
&state,
&mut background_document_continuation,
navigation.document_continuation_observer.take(),
);
Box::pin(commit_stable_page_navigation_async(
conn,
out,
&token,
state,
navigation,
transaction,
))
.await;
}
Err(error) => {
if error.page_was_preserved() {
if let Err(rollback_error) = conn
.rollback_committed_renderer_agent_candidate_for_session_owner(
state.navigate_session_id.as_deref(),
transaction,
)
{
tracing::warn!(
%rollback_error,
session_id = state.navigate_session_id.as_deref(),
"failed to roll back renderer channel after pre-commit Page replacement failure"
);
}
} else {
let _ = conn
.discard_loaded_page_after_failed_navigation_for_session_owner_async(
state.navigate_session_id.as_deref(),
&failed_url,
)
.await;
}
push_navigation_commit_error(out, &state, error.to_string());
}
}
} else {
match navigation.commit(permit).await {
Ok(navigation) => {
let mut navigation =
network::materialize_loaded_navigation_progress(
conn, &state, navigation,
);
foreground_document_continuation_observer =
schedule_renderer_document_continuation(
conn,
&token,
&state,
&mut background_document_continuation,
navigation.document_continuation_observer.take(),
);
commit_loaded_navigation_async(
conn,
out,
Some(&token),
state,
navigation,
Some(transaction),
command_context,
)
{
tracing::warn!(
%rollback_error,
session_id = state.navigate_session_id.as_deref(),
"failed to roll back renderer channel after prepared document commit failure"
);
.await;
}
Err(error) => {
if let Err(rollback_error) = conn
.rollback_committed_renderer_agent_candidate_for_session_owner(
state.navigate_session_id.as_deref(),
transaction,
)
{
tracing::warn!(
%rollback_error,
session_id = state.navigate_session_id.as_deref(),
"failed to roll back renderer channel after prepared document commit failure"
);
}
push_navigation_commit_error(out, &state, error);
}
push_navigation_commit_error(out, &state, error);
}
}
}
@@ -3455,15 +3677,24 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async(
push_navigation_commit_error(out, &state, error);
}
}
}
}
}
network::MaterializedNavigationLoadOutcome::Loaded(navigation) => {
let mut navigation = *navigation;
foreground_document_continuation_observer = schedule_renderer_document_continuation(
conn,
&token,
&state,
&mut background_document_continuation,
navigation.document_continuation_observer.take(),
);
commit_loaded_navigation_async(
conn,
out,
Some(&token),
state,
*navigation,
navigation,
None,
command_context,
)
@@ -3476,15 +3707,25 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async(
commit_download_navigation_async(conn, out, state, navigation, command_context).await;
}
network::MaterializedNavigationLoadOutcome::Failed(navigation) => {
let _ = conn.clear_pending_navigation_history_update_for_session_owner(
state.navigate_session_id.as_deref(),
);
let network::MaterializedFailedDocumentProgress {
error_text,
document_policy,
history_policy,
response_mode,
progress_gate,
} = navigation;
match history_policy {
network::FailedNavigationHistoryPolicy::DiscardPendingUpdate => {
let _ = conn.clear_pending_navigation_history_update_for_session_owner(
state.navigate_session_id.as_deref(),
);
}
network::FailedNavigationHistoryPolicy::RetainInitialEmptyDocumentReplacement => {
let _ = conn.resolve_no_commit_response_navigation_history_for_session_owner(
state.navigate_session_id.as_deref(),
);
}
}
if document_policy.invalidates_committed_document() {
let _ = conn
.discard_loaded_page_after_failed_navigation_for_session_owner_async(
@@ -3501,6 +3742,10 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async(
.emit_navigation_error_into_buffer(out, &error_text);
}
}
if let Some(completion) = background_document_continuation {
let observer = conn.schedule_background_document_continuation_completion(completion, None);
debug_assert!(observer.is_none());
}
let primary_protocol_session_id = conn
.runtime_session_owner_primary_session_id(navigation_session_id.as_deref())
.or_else(|| navigation_session_id.clone());
@@ -3544,6 +3789,47 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async(
navigation_session_id.as_deref(),
&navigation_loader_id,
);
if let Some(observer) = foreground_document_continuation_observer {
let (predecessor, page_state) = observer.wait().await.into_parts();
let page_state_applied = page_state.is_some_and(|page_state| {
conn.apply_renderer_document_continuation_page_state(
&document_continuation_gate_key,
page_state,
)
});
if page_state_applied {
let _ = conn
.target_session_owner_navigation_history_snapshot(navigation_session_id.as_deref());
if let Some(target_id) = document_continuation_gate_key.target_id() {
command_context.protocol_events_mut().extend(
conn.exact_target_info_changed_event_plan_for_target_delta(target_id)
.into_background_events(),
);
}
}
if let Some(predecessor) = predecessor {
command_context.set_renderer_output_predecessor(predecessor);
}
}
}
fn schedule_renderer_document_continuation(
conn: &mut CdpConnection,
token: &DocumentNavigationToken,
state: &NavigationDispatchState,
background_completion: &mut Option<BackgroundDocumentContinuationCompletion>,
observer: Option<moli_core::page::RendererDocumentContinuationObserver>,
) -> Option<moli_core::page::RendererDocumentContinuationObserver> {
if let Some(completion) = background_completion.take() {
return conn.schedule_background_document_continuation_completion(completion, observer);
}
if let Some(observer) = observer {
return conn.schedule_background_document_continuation_completion(
BackgroundDocumentContinuationCompletion::for_foreground_navigation(token, state),
Some(observer),
);
}
None
}
fn push_navigation_commit_error(
@@ -3,6 +3,7 @@ use url::Url;
use crate::conn::{
CdpConnection, CommandDispatchContext, CommittedRendererAgentAttachment,
DocumentNavigationToken, LoadedNavigationRendererAttachmentCommit, NavigationDispatchState,
StablePageNavigationCommitTarget,
};
use crate::domains::activity::{
MainDocumentDownloadNavigationActivity, MainDocumentNavigationActivity,
@@ -10,6 +11,7 @@ use crate::domains::activity::{
use crate::domains::command_output::CommandOutputBuffer;
use crate::domains::network::{
MaterializedDownloadDocumentProgress, MaterializedLoadedDocumentProgress,
MaterializedStablePageDocumentProgress,
};
use moli_core::page::{
Page, RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind,
@@ -41,6 +43,7 @@ pub(super) async fn commit_loaded_navigation_async(
initial_runtime_realms,
renderer_output_predecessor,
main_document_commit,
document_continuation_observer: _,
progress_gate,
navigation_engine,
network_error_page,
@@ -160,6 +163,135 @@ pub(super) async fn commit_loaded_navigation_async(
.await;
}
pub(super) async fn commit_stable_page_navigation_async(
conn: &mut CdpConnection,
out: &mut CommandOutputBuffer,
token: &DocumentNavigationToken,
state: NavigationDispatchState,
navigation: MaterializedStablePageDocumentProgress,
committed_renderer_attachment: CommittedRendererAgentAttachment,
) {
let MaterializedStablePageDocumentProgress {
stable_page_target,
pending_download,
page_creation_artifacts,
final_url,
response_headers,
response_from_cache,
main_document_body,
initial_runtime_realms,
renderer_output_predecessor,
main_document_commit,
document_continuation_observer: _,
progress_gate,
navigation_engine,
network_error_page,
} = navigation;
let target_url = network_error_page
.as_ref()
.map(|error_page| error_page.unreachable_url().clone())
.unwrap_or_else(|| final_url.clone());
let Some(main_document_commit) = main_document_commit else {
let error = "stable Page navigation is missing its frozen main Document commit identity";
if state.navigate_id.is_some() {
out.push_error_after_messages(-32000, error);
} else {
tracing::warn!(
session_id = state.navigate_session_id.as_deref(),
loader_id = state.loader_id,
"{error} after early Page.navigate result"
);
}
return;
};
let is_network_error_page = network_error_page.is_some();
let navigation_session_id = state.navigate_session_id.clone();
let (page_creation_artifacts, mut deferred_initial_renderer_document_lifecycle_events) =
split_renderer_page_creation_lifecycle_at_load_boundary(page_creation_artifacts);
let mut navigation_activity = MainDocumentNavigationActivity::new(
state,
final_url.clone(),
progress_gate,
Some(token.clone()),
);
if let Some(error_page) = network_error_page.as_ref() {
navigation_activity =
navigation_activity.with_network_error_page_result(error_page.error_text().to_owned());
}
// Keep the protocol-state transaction off the caller's navigation future:
// background target creation already carries the fetch and target-handoff
// state on its test thread stack.
let Some(_commit) = Box::pin(commit_stable_page_navigation_protocol_state_async(
conn,
out,
token,
navigation_activity.state(),
&stable_page_target,
&final_url,
&target_url,
&main_document_commit,
initial_runtime_realms,
committed_renderer_attachment,
))
.await
else {
return;
};
if !is_network_error_page {
let _ = conn.commit_main_document_resource_for_session_owner(
navigation_session_id.as_deref(),
navigation_activity.state().frame_id.clone(),
navigation_activity.state().loader_id.clone(),
final_url.clone(),
response_headers,
response_from_cache,
main_document_body,
);
}
let (renderer_document_binding, mut initial_renderer_document_lifecycle_events) = conn
.bind_renderer_document_lifecycle_for_session_owner(
navigation_activity.state().navigate_session_id.as_deref(),
page_creation_artifacts,
Some(token.clone()),
navigation_activity.state().frame_id.clone(),
navigation_activity.state().loader_id.clone(),
);
let load_visibility_barrier_armed = renderer_document_binding.is_some()
&& conn.begin_renderer_document_load_visibility_barrier_for_session_owner(
navigation_activity.state().navigate_session_id.as_deref(),
&navigation_activity.state().loader_id,
);
if load_visibility_barrier_armed {
let (_, visible_events) = conn.ingest_renderer_document_lifecycle_events_for_session_owner(
navigation_activity.state().navigate_session_id.as_deref(),
std::mem::take(&mut deferred_initial_renderer_document_lifecycle_events),
);
initial_renderer_document_lifecycle_events.extend(visible_events);
}
navigation_activity.defer_initial_renderer_document_lifecycle_events_until_load_boundary(
deferred_initial_renderer_document_lifecycle_events,
);
if let Some(engine) = navigation_engine {
conn.adopt_loaded_navigation_engine_for_session_owner(
navigation_session_id.as_deref(),
engine,
);
}
Box::pin(async move {
navigation_activity
.emit_loaded_navigation_commit_async(
conn,
out,
pending_download,
renderer_document_binding,
initial_renderer_document_lifecycle_events,
renderer_output_predecessor,
)
.await;
})
.await;
}
fn split_renderer_page_creation_lifecycle_at_load_boundary(
mut artifacts: RendererPageCreationArtifacts,
) -> (
@@ -557,6 +689,197 @@ async fn restore_and_commit_loaded_navigation_page_async(
Some(outcome)
}
async fn commit_stable_page_navigation_protocol_state_async(
conn: &mut CdpConnection,
out: &mut CommandOutputBuffer,
token: &DocumentNavigationToken,
state: &NavigationDispatchState,
expected: &StablePageNavigationCommitTarget,
final_url: &Url,
target_url: &Url,
main_document_commit: &moli_core::page::RendererMainDocumentCommit,
initial_runtime_realms: Vec<RendererRuntimeRealmInfo>,
committed_renderer_attachment: CommittedRendererAgentAttachment,
) -> Option<LoadedPageCommitOutcome> {
if !conn.target_page_residence_identity_is_current_for_session(
state.navigate_session_id.as_deref(),
expected.target_page(),
) || conn
.renderer_page_residence_identity_for_session_owner(state.navigate_session_id.as_deref())
!= Some(expected.renderer_page())
|| committed_renderer_attachment.navigation() != token
|| conn.current_renderer_agent_attachment_id_for_session_owner(
state.navigate_session_id.as_deref(),
) != Some(committed_renderer_attachment.current().id())
{
tracing::warn!(
session_id = state.navigate_session_id.as_deref(),
loader_id = token.loader_id,
"stable Page replacement no longer matches its committed protocol owner"
);
discard_irreversibly_committed_stable_page_if_current_async(
conn, state, expected, final_url,
)
.await;
return None;
}
let Some(commit_state) = conn
.prepare_loaded_navigation_commit_for_session_owner(state.navigate_session_id.as_deref())
else {
tracing::warn!(
session_id = state.navigate_session_id.as_deref(),
loader_id = token.loader_id,
"stable Page replacement lost its protocol commit state after renderer commit"
);
discard_irreversibly_committed_stable_page_if_current_async(
conn, state, expected, final_url,
)
.await;
return None;
};
let page_commit = match conn.commit_loaded_navigation_document_replacement_for_session_owner(
state.navigate_session_id.as_deref(),
committed_renderer_attachment,
target_url,
) {
Some(Ok(commit)) => commit,
Some(Err(error)) => {
if state.navigate_id.is_some() {
out.push_error_after_messages(
-32000,
format!("failed to commit stable Page protocol state: {error}"),
);
} else {
tracing::warn!(
%error,
session_id = state.navigate_session_id.as_deref(),
"stable Page navigation failed after early Page.navigate result"
);
}
let _ = conn
.discard_loaded_page_after_failed_navigation_for_session_owner_async(
state.navigate_session_id.as_deref(),
final_url,
)
.await;
return None;
}
None => {
tracing::warn!(
session_id = state.navigate_session_id.as_deref(),
loader_id = token.loader_id,
"stable Page replacement lost its target owner during protocol state commit"
);
discard_irreversibly_committed_stable_page_if_current_async(
conn, state, expected, final_url,
)
.await;
return None;
}
};
let mut claimed_inspector_await_events = Vec::new();
for session_id in
conn.page_event_session_ids_for_session_owner(state.navigate_session_id.as_deref())
{
conn.fail_claimed_pending_inspector_awaits_for_session_owner_background_events_into(
&mut claimed_inspector_await_events,
session_id.as_deref(),
"Inspected target navigated or closed",
);
}
out.extend_background_events_after_messages(claimed_inspector_await_events);
if let Some(replaced_page_owner) = page_commit.replaced_page_owner.as_ref() {
let worker_retirement_events =
crate::domains::target::retire_dedicated_worker_targets_for_replaced_page_async(
conn,
replaced_page_owner,
)
.await;
out.extend_background_events_after_messages(worker_retirement_events);
}
if conn
.commit_loaded_navigation_target_identity_for_session_owner(
state.navigate_session_id.as_deref(),
main_document_commit,
target_url,
)
.is_none()
{
tracing::warn!(
session_id = state.navigate_session_id.as_deref(),
loader_id = token.loader_id,
"stable Page replacement lost its target identity commit route"
);
discard_irreversibly_committed_stable_page_if_current_async(
conn, state, expected, final_url,
)
.await;
return None;
}
if commit_state.runtime_frontend_enabled {
let _ = conn.set_renderer_runtime_agent_owns_page_console_api_events_for_session_owner(
state.navigate_session_id.as_deref(),
true,
);
}
conn.commit_document_navigation_for_session_owner_if_matches(
state.navigate_session_id.as_deref(),
token,
);
let preload_channel_execution_context_ids = if conn
.target_owner_has_bidi_channel_preload_script_for_session(
state.navigate_session_id.as_deref(),
) {
dedupe_preload_channel_execution_context_ids(
initial_runtime_realms
.iter()
.filter_map(runtime_realm_execution_context_id)
.collect(),
)
} else {
Vec::new()
};
let mut preload_channel_listener_events = Vec::new();
for execution_context_id in preload_channel_execution_context_ids {
Box::pin(
crate::domains::runtime::start_bidi_preload_channel_listeners_for_execution_context_background_events_async(
conn,
state.navigate_session_id.as_deref(),
execution_context_id,
&mut preload_channel_listener_events,
),
)
.await;
}
out.extend_background_events_after_messages(preload_channel_listener_events);
Some(LoadedPageCommitOutcome::default())
}
async fn discard_irreversibly_committed_stable_page_if_current_async(
conn: &mut CdpConnection,
state: &NavigationDispatchState,
expected: &StablePageNavigationCommitTarget,
final_url: &Url,
) {
if !conn.target_page_residence_identity_is_current_for_session(
state.navigate_session_id.as_deref(),
expected.target_page(),
) {
tracing::error!(
session_id = state.navigate_session_id.as_deref(),
loader_id = state.loader_id,
"could not retire an irreversibly committed stable Page through its captured target route"
);
return;
}
let _ = conn
.discard_loaded_page_after_failed_navigation_for_session_owner_async(
state.navigate_session_id.as_deref(),
final_url,
)
.await;
}
fn runtime_realm_execution_context_id(realm: &RendererRuntimeRealmInfo) -> Option<i64> {
runtime_realm_has_native_unique_id(realm).then_some(realm.context_id)
}
+2
View File
@@ -93,6 +93,7 @@ pub(super) async fn emit_prepared(
popup_id,
url,
target_name,
pending_auxiliary_page,
session_storage_store,
initial_empty_document_storage_key,
) = activation.into_parts();
@@ -112,6 +113,7 @@ pub(super) async fn emit_prepared(
opener,
can_access_opener,
disposition,
pending_auxiliary_page,
session_storage_store,
initial_empty_document_storage_key,
);
@@ -277,6 +277,7 @@ fn document_start_script_from_add_preload_command(
source: source.clone(),
world_name: command.world_name.clone(),
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
}),
DevToolsPreloadScriptSource::FunctionDeclaration {
@@ -301,6 +302,7 @@ fn document_start_script_from_add_preload_command(
source: source.source,
world_name: command.world_name.clone(),
has_bidi_channel_argument,
browser_internal: false,
bidi_channel_handoffs: source.channel_handoffs,
})
}
@@ -1853,6 +1855,7 @@ mod protocol_neutral_tests {
source: "globalThis.ready = true;".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
)];
@@ -1877,6 +1880,7 @@ mod protocol_neutral_tests {
source: "globalThis.ready = true;".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
)];
@@ -1902,6 +1906,7 @@ mod protocol_neutral_tests {
source: "globalThis.ready = true;".to_owned(),
world_name: Some("utility".to_owned()),
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -1910,6 +1915,7 @@ mod protocol_neutral_tests {
source: "globalThis.ready = true;".to_owned(),
world_name: Some("utility".to_owned()),
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
};
@@ -1941,6 +1947,7 @@ mod protocol_neutral_tests {
source: "globalThis.ready = true;".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -1949,6 +1956,7 @@ mod protocol_neutral_tests {
source: "globalThis.ready = true;".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
};
@@ -88,6 +88,7 @@ async fn complete_tokened_materialized_navigation_background_events_async(
navigation_state,
navigation,
&mut command_context,
None,
)
.await;
let mut plan = output.into_plan();
@@ -58,6 +58,38 @@ fn assert_runtime_navigation_context_reset(
);
}
async fn wait_until_runtime_navigation_context_resets(
ctx: &mut TestContext,
session_ids: &[&str],
frame_id: &str,
) {
let description = format!(
"Runtime context replacement for {} in {frame_id}",
session_ids.join(", ")
);
wait_until_messages(
ctx,
session_ids.first().copied(),
&description,
|messages| {
session_ids.iter().all(|session_id| {
let cleared = messages.iter().any(|message| {
message["sessionId"] == json!(session_id)
&& message["method"] == json!("Runtime.executionContextsCleared")
});
let default_created = messages.iter().any(|message| {
message["sessionId"] == json!(session_id)
&& message["method"] == json!("Runtime.executionContextCreated")
&& message["params"]["context"]["auxData"]["isDefault"] == json!(true)
&& message["params"]["context"]["auxData"]["frameId"] == json!(frame_id)
});
cleared && default_created
})
},
)
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn data_url_commit_applies_preloads_worlds_and_bindings_before_author_script() {
let mut ctx = TestContext::new();
@@ -126,6 +158,16 @@ async fn data_url_commit_applies_preloads_worlds_and_bindings_before_author_scri
take_response_by_id(&mut ctx, 90_104)["result"]["frameId"],
json!("TID-1")
);
wait_until_message(
&mut ctx,
Some("SID-1"),
"data URL named-world execution context",
|message| {
message["method"] == json!("Runtime.executionContextCreated")
&& message["params"]["context"]["name"] == json!("data-world")
},
)
.await;
ctx.process_async(json!({
"id": 90_105,
@@ -179,7 +221,8 @@ async fn data_url_commit_applies_preloads_worlds_and_bindings_before_author_scri
assert_eq!(
binding_payloads,
vec![json!("named-preload"), json!("author-script")],
"data URL named-world preload must run before its first author script"
"data URL named-world preload must run before its first author script: sent={:#?}",
ctx.sent
);
}
@@ -226,6 +269,204 @@ async fn page_navigate_file_url_fails_before_navigation_events_or_document_repla
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cross_document_page_navigate_replaces_realm_in_stable_page_residence() {
let mut ctx = TestContext::new();
load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank");
ensure_initial_document_for_session(&mut ctx, Some("SID-1")).await;
ctx.process_async(json!({
"id": 90_110,
"method": "Runtime.enable",
"sessionId": "SID-1"
}))
.await;
assert_eq!(take_response_by_id(&mut ctx, 90_110)["result"], json!({}));
let old_context_id = ctx
.sent
.iter()
.rev()
.find(|message| {
message["method"] == json!("Runtime.executionContextCreated")
&& message["sessionId"] == json!("SID-1")
&& message["params"]["context"]["auxData"]["isDefault"] == json!(true)
})
.and_then(|message| message["params"]["context"]["id"].as_i64())
.expect("initial default execution context id");
ctx.process_async(json!({
"id": 90_111,
"method": "Runtime.evaluate",
"sessionId": "SID-1",
"params": {
"contextId": old_context_id,
"expression": "globalThis.__stablePageOldRealm = 'old'",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 90_111)["result"]["result"]["value"],
json!("old")
);
let before_target_page = ctx
.conn
.target_page_residence_identity_for_session(Some("SID-1"))
.expect("initial target Page residence");
let before_renderer_page = ctx
.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1"))
.expect("initial renderer Page residence");
let before_page_attachment = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::page_attachment_id)
.expect("initial target Page attachment");
let before_renderer_attachment = ctx
.conn
.current_renderer_agent_attachment_id_for_session_owner(Some("SID-1"))
.expect("initial renderer agent attachment");
let (before_devtools_agent, before_runtime) = {
let page = ctx
.conn
.browser_context
.as_mut()
.and_then(|browser_context| {
browser_context.active_target.runtime_slot.loaded_page_mut()
})
.expect("initial loaded Page");
let devtools_agent = page.renderer_devtools_agent_token();
let runtime = page
.runtime_heap_usage_async()
.await
.expect("initial Page runtime diagnostics")
.moli
.runtime;
(devtools_agent, runtime)
};
ctx.sent.clear();
ctx.process_async(json!({
"id": 90_112,
"method": "Page.navigate",
"sessionId": "SID-1",
"params": {
"url": "data:text/html,<!doctype html><body><main id='stable-page-replacement'>replacement document</main><script>globalThis.__stablePageNewRealm='new'</script></body>"
}
}))
.await;
let navigate = take_response_by_id(&mut ctx, 90_112);
let replacement_loader_id = navigate["result"]["loaderId"]
.as_str()
.expect("replacement navigation loader id")
.to_owned();
wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1"], "TID-1").await;
wait_until_renderer_document_load(&mut ctx, Some("SID-1"), "TID-1", &replacement_loader_id)
.await;
assert_runtime_navigation_context_reset(&ctx.sent, "SID-1", "TID-1");
let new_context_id = ctx
.sent
.iter()
.rev()
.find(|message| {
message["method"] == json!("Runtime.executionContextCreated")
&& message["sessionId"] == json!("SID-1")
&& message["params"]["context"]["auxData"]["isDefault"] == json!(true)
})
.and_then(|message| message["params"]["context"]["id"].as_i64())
.expect("replacement default execution context id");
assert_ne!(new_context_id, old_context_id);
let after_target_page = ctx
.conn
.target_page_residence_identity_for_session(Some("SID-1"))
.expect("replacement target Page residence");
let after_renderer_page = ctx
.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1"))
.expect("replacement renderer Page residence");
let after_page_attachment = ctx
.conn
.browser_context
.as_ref()
.and_then(BrowserContext::page_attachment_id)
.expect("replacement target Page attachment");
let after_renderer_attachment = ctx
.conn
.current_renderer_agent_attachment_id_for_session_owner(Some("SID-1"))
.expect("replacement renderer agent attachment");
let (after_devtools_agent, after_runtime) = {
let page = ctx
.conn
.browser_context
.as_mut()
.and_then(|browser_context| {
browser_context.active_target.runtime_slot.loaded_page_mut()
})
.expect("replacement loaded Page");
let devtools_agent = page.renderer_devtools_agent_token();
let runtime = page
.runtime_heap_usage_async()
.await
.expect("replacement Page runtime diagnostics")
.moli
.runtime;
(devtools_agent, runtime)
};
assert_eq!(after_target_page, before_target_page);
assert_eq!(after_renderer_page, before_renderer_page);
assert_eq!(after_page_attachment, before_page_attachment);
assert_ne!(after_renderer_attachment, before_renderer_attachment);
assert_ne!(after_devtools_agent, before_devtools_agent);
assert_eq!(
after_runtime.script_agent_id,
before_runtime.script_agent_id
);
assert_ne!(
after_runtime.inspector_context_group_id,
before_runtime.inspector_context_group_id
);
assert_eq!(
after_runtime.main_window_proxy_identity_hash,
before_runtime.main_window_proxy_identity_hash
);
assert!(after_runtime.main_window_proxy_identity_hash.is_some());
ctx.process_async(json!({
"id": 90_113,
"method": "Runtime.evaluate",
"sessionId": "SID-1",
"params": {
"expression": "JSON.stringify([document.querySelector('#stable-page-replacement').textContent, typeof globalThis.__stablePageOldRealm, globalThis.__stablePageNewRealm])",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 90_113)["result"]["result"]["value"],
json!(r#"["replacement document","undefined","new"]"#)
);
ctx.process_async(json!({
"id": 90_114,
"method": "Runtime.evaluate",
"sessionId": "SID-1",
"params": {
"contextId": old_context_id,
"expression": "globalThis.__stablePageOldRealm",
"returnByValue": true
}
}))
.await;
let stale_context = take_response_by_id(&mut ctx, 90_114);
assert_eq!(stale_context["error"]["code"], json!(-32000));
assert_eq!(
stale_context["error"]["message"],
json!("Cannot find context with specified id")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn renderer_navigation_background_events_keep_typed_sidecars() {
let mut ctx = TestContext::new();
@@ -3390,6 +3631,8 @@ async fn navigate_after_real_runtime_enable_resets_before_creating_default_conte
}))
.await;
wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1"], "TID-1").await;
let sent = ctx.take_all();
assert_runtime_navigation_context_reset(&sent, "SID-1", "TID-1");
}
@@ -3433,6 +3676,8 @@ async fn navigate_after_real_runtime_enable_fans_out_context_reset_to_auxiliary_
}))
.await;
wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1", "SID-aux"], "TID-1").await;
let sent = ctx.take_all();
for session_id in ["SID-1", "SID-aux"] {
assert_runtime_navigation_context_reset(&sent, session_id, "TID-1");
@@ -3470,6 +3715,8 @@ async fn navigate_from_auxiliary_session_keeps_primary_and_auxiliary_runtime_eve
}))
.await;
wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1", "SID-aux"], "TID-1").await;
let sent = ctx.take_all();
for session_id in ["SID-1", "SID-aux"] {
assert_runtime_navigation_context_reset(&sent, session_id, "TID-1");
@@ -3519,6 +3766,8 @@ async fn navigate_after_auxiliary_runtime_disable_keeps_primary_runtime_enabled(
}))
.await;
wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1"], "TID-1").await;
let sent = ctx.take_all();
assert_runtime_navigation_context_reset(&sent, "SID-1", "TID-1");
assert!(
@@ -5075,6 +5324,13 @@ async fn navigate_failure_commits_error_document_with_visible_unreachable_url()
.accepts_document_body_completion_event(&committed_document_token),
"ordinary navigation load failures must invalidate the previously committed document"
);
wait_until_message(
&mut ctx,
Some("SID-1"),
"network error Document stopped loading",
|message| message["method"] == json!("Page.frameStoppedLoading"),
)
.await;
let messages = ctx.take_all();
let frame_navigated = messages
@@ -5117,21 +5373,37 @@ async fn navigate_failure_commits_error_document_with_visible_unreachable_url()
ctx.process_async(json!({
"id": 233,
"method": "Page.getFrameTree",
"sessionId": "SID-1"
}))
.await;
let frame_tree = take_response_by_id(&mut ctx, 233);
assert_eq!(
frame_tree["result"]["frameTree"]["frame"]["url"],
NETWORK_ERROR_PAGE_URL
);
assert_eq!(
frame_tree["result"]["frameTree"]["frame"]["unreachableUrl"],
unreachable_url
);
ctx.process_async(json!({
"id": 234,
"method": "Page.getNavigationHistory",
"sessionId": "SID-1"
}))
.await;
let history = take_response_by_id(&mut ctx, 233);
let history = take_response_by_id(&mut ctx, 234);
let current_index = history["result"]["currentIndex"]
.as_u64()
.expect("current history index") as usize;
assert_eq!(
history["result"]["entries"][current_index]["url"],
unreachable_url
history["result"]["entries"][current_index]["url"], unreachable_url,
"history should expose the failed requested URL, not the internal error URL"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn navigate_failure_creates_runtime_context_and_completes_lifecycle() {
async fn navigate_failure_replaces_runtime_context_and_completes_lifecycle() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
@@ -5139,19 +5411,29 @@ async fn navigate_failure_creates_runtime_context_and_completes_lifecycle() {
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"));
let bc = ctx.conn.browser_context.as_mut().unwrap();
bc.devtools_session_state
.runtime_session_state
.runtime_frontend_enabled = true;
bc.devtools_session_state
ensure_initial_document_for_session(&mut ctx, Some("SID-1")).await;
ctx.process_async(json!({
"id": 230,
"method": "Runtime.enable",
"sessionId": "SID-1"
}))
.await;
let _ = take_response_by_id(&mut ctx, 230);
ctx.conn
.browser_context
.as_mut()
.unwrap()
.devtools_session_state
.page_session_state
.page_lifecycle_events = true;
ctx.sent.clear();
let unreachable_url = format!("http://{addr}/missing");
ctx.process_async(json!({
"id": 232,
"method": "Page.navigate",
"sessionId": "SID-1",
"params": { "url": format!("http://{addr}/missing") }
"params": { "url": unreachable_url }
}))
.await;
wait_until_message(
@@ -5162,8 +5444,8 @@ async fn navigate_failure_creates_runtime_context_and_completes_lifecycle() {
)
.await;
let response = ctx
.sent
let messages = ctx.take_all();
let response = messages
.iter()
.find(|message| message["id"] == json!(232))
.expect("Page.navigate response");
@@ -5173,23 +5455,52 @@ async fn navigate_failure_creates_runtime_context_and_completes_lifecycle() {
assert_eq!(response["result"]["isDownload"], false);
assert!(response["result"]["errorText"].is_string());
let messages = ctx.take_all();
assert!(
messages.iter().any(|message| {
assert_runtime_navigation_context_reset(&messages, "SID-1", "TID-1");
let frame_index = messages
.iter()
.position(|message| message["method"] == json!("Page.frameNavigated"))
.unwrap_or_else(|| panic!("missing error frame commit: {messages:?}"));
let default_context_index = messages
.iter()
.position(|message| {
message["method"] == json!("Runtime.executionContextCreated")
&& message["params"]["context"]["auxData"]["isDefault"] == json!(true)
}),
"error Document should create a default runtime context: {messages:?}"
);
})
.unwrap_or_else(|| panic!("missing replacement default context: {messages:?}"));
let dom_content_loaded_index = messages
.iter()
.position(|message| message["method"] == json!("Page.domContentEventFired"))
.unwrap_or_else(|| panic!("missing error Document DCL: {messages:?}"));
let load_index = messages
.iter()
.position(|message| message["method"] == json!("Page.loadEventFired"))
.unwrap_or_else(|| panic!("missing error Document load: {messages:?}"));
assert!(
messages.iter().any(|message| {
message["method"] == json!("Page.lifecycleEvent")
&& message["params"]["name"] == json!("DOMContentLoaded")
}) && messages.iter().any(|message| {
message["method"] == json!("Page.lifecycleEvent")
&& message["params"]["name"] == json!("load")
}),
"error Document should complete lifecycle: {messages:?}"
frame_index < default_context_index
&& default_context_index < dom_content_loaded_index
&& dom_content_loaded_index < load_index,
"error Document commit/context/lifecycle ordering should match a real replacement Document: {messages:?}"
);
assert_eq!(
messages[frame_index]["params"]["frame"]["url"],
NETWORK_ERROR_PAGE_URL
);
assert_eq!(
messages[frame_index]["params"]["frame"]["unreachableUrl"],
unreachable_url
);
assert!(messages.iter().any(|message| {
message["method"] == json!("Page.lifecycleEvent")
&& message["params"]["name"] == json!("DOMContentLoaded")
}));
assert!(messages.iter().any(|message| {
message["method"] == json!("Page.lifecycleEvent")
&& message["params"]["name"] == json!("load")
}));
assert!(
messages
.iter()
.any(|message| message["method"] == json!("Page.frameStoppedLoading"))
);
}
#[tokio::test(flavor = "multi_thread")]
@@ -6069,6 +6380,29 @@ async fn reload_targets_background_owner_without_promotion() {
initial_html.contains(">1<"),
"expected first background load to contain counter 1"
);
let target_page_before = ctx
.conn
.target_page_residence_identity_for_session(Some("SID-background"))
.expect("background target Page residence before reload");
let renderer_page_before = ctx
.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-background"))
.expect("background renderer Page residence before reload");
let (page_attachment_before, devtools_agent_before) = ctx
.conn
.browser_context
.as_ref()
.and_then(|browser_context| browser_context.background_target("TID-background"))
.map(|target| {
(
target.page_attachment_id(),
target
.loaded_page()
.expect("background loaded Page before reload")
.renderer_devtools_agent_token(),
)
})
.expect("background target before reload");
ctx.sent.clear();
ctx.process_async(json!({
@@ -6090,6 +6424,19 @@ async fn reload_targets_background_owner_without_promotion() {
let background = browser_context
.background_target_mut("TID-background")
.expect("background target should remain parked");
assert_eq!(
background.page_attachment_id(),
page_attachment_before,
"background reload must retain its target Page attachment"
);
assert_ne!(
background
.loaded_page()
.expect("background loaded Page after reload")
.renderer_devtools_agent_token(),
devtools_agent_before,
"background reload must install a new renderer Document agent"
);
let reloaded_html = background
.loaded_page_mut()
.expect("loaded background page after reload")
@@ -6100,6 +6447,16 @@ async fn reload_targets_background_owner_without_promotion() {
reloaded_html.contains(">2<"),
"expected background reload html to contain counter 2, got {reloaded_html}"
);
assert_eq!(
ctx.conn
.target_page_residence_identity_for_session(Some("SID-background")),
Some(target_page_before)
);
assert_eq!(
ctx.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-background")),
Some(renderer_page_before)
);
server.abort();
}
@@ -6348,6 +6705,8 @@ async fn repeated_http_navigation_after_runtime_enable_replaces_the_context_grou
.expect("second HTTP navigation should have a loader")
.to_owned();
wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1"], "TID-1").await;
assert_ne!(first_loader_id, second_loader_id);
assert_eq!(
request_count.load(std::sync::atomic::Ordering::SeqCst),
@@ -6546,66 +6905,76 @@ async fn parser_script_location_navigation_suppresses_aborted_document_dcl() {
let mut ctx = TestContext::new();
load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank");
ctx.process_async(json!({
"id": 251,
"method": "Page.navigate",
"sessionId": "SID-1",
"params": { "url": format!("http://{addr}/challenge") }
}))
.await;
let _ = take_response_by_id(&mut ctx, 251);
wait_until_messages(
&mut ctx,
Some("SID-1"),
"successor document lifecycle after parser-script navigation",
|messages| {
messages
.iter()
.filter(|message| message["method"] == json!("Page.frameNavigated"))
.count()
>= 2
&& messages
.iter()
.any(|message| message["method"] == json!("Page.domContentEventFired"))
},
)
.await;
ctx.enable_background_navigation_scheduler_for_test();
tokio::task::LocalSet::new()
.run_until(async {
ctx.process_and_wait_for_response_async(json!({
"id": 251,
"method": "Page.navigate",
"sessionId": "SID-1",
"params": { "url": format!("http://{addr}/challenge") }
}))
.await;
let _ = take_response_by_id(&mut ctx, 251);
ctx.wait_for_document_continuation_for_test(
Some("SID-1"),
"parser-script successor navigation",
)
.await;
wait_until_messages(
&mut ctx,
Some("SID-1"),
"successor document lifecycle after parser-script navigation",
|messages| {
messages
.iter()
.filter(|message| message["method"] == json!("Page.frameNavigated"))
.count()
>= 2
&& messages
.iter()
.any(|message| message["method"] == json!("Page.domContentEventFired"))
},
)
.await;
let events = ctx.take_all();
let domcontentloaded = events
.iter()
.filter(|message| message["method"] == json!("Page.domContentEventFired"))
.collect::<Vec<_>>();
assert_eq!(
domcontentloaded.len(),
1,
"the synchronously aborted challenge document must not emit DCL: {events:?}"
);
let final_frame_commit_index = events
.iter()
.rposition(|message| message["method"] == json!("Page.frameNavigated"))
.expect("final document frame commit");
let domcontentloaded_index = events
.iter()
.position(|message| message["method"] == json!("Page.domContentEventFired"))
.expect("final document DCL");
assert!(
final_frame_commit_index < domcontentloaded_index,
"the only DCL must belong to the final document: {events:?}"
);
assert_eq!(
ctx.conn
.browser_context
.as_ref()
.expect("browser context")
.target_url(),
format!("http://{addr}/final")
);
assert!(
loaded_page_html_for_test(&mut ctx)
.await
.contains("final content")
);
let events = ctx.take_all();
let domcontentloaded = events
.iter()
.filter(|message| message["method"] == json!("Page.domContentEventFired"))
.collect::<Vec<_>>();
assert_eq!(
domcontentloaded.len(),
1,
"the synchronously aborted challenge document must not emit DCL: {events:?}"
);
let final_frame_commit_index = events
.iter()
.rposition(|message| message["method"] == json!("Page.frameNavigated"))
.expect("final document frame commit");
let domcontentloaded_index = events
.iter()
.position(|message| message["method"] == json!("Page.domContentEventFired"))
.expect("final document DCL");
assert!(
final_frame_commit_index < domcontentloaded_index,
"the only DCL must belong to the final document: {events:?}"
);
assert_eq!(
ctx.conn
.browser_context
.as_ref()
.expect("browser context")
.target_url(),
format!("http://{addr}/final")
);
assert!(
loaded_page_html_for_test(&mut ctx)
.await
.contains("final content")
);
})
.await;
server.abort();
}
@@ -199,6 +199,7 @@ async fn add_script_to_evaluate_on_new_document_reuses_identifier_for_duplicate_
.to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -270,6 +271,7 @@ async fn add_script_to_evaluate_on_new_document_restored_scripts_bump_from_max_i
source: "globalThis.__lm_preload = 'seed';".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -327,6 +329,7 @@ async fn add_script_to_evaluate_on_new_document_bump_ignores_non_numeric_identif
source: "globalThis.__lm_preload = 'legacy';".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -344,6 +347,7 @@ async fn add_script_to_evaluate_on_new_document_bump_ignores_non_numeric_identif
source: "globalThis.__lm_preload = 'seed';".to_owned(),
world_name: None,
has_bidi_channel_argument: false,
browser_internal: false,
bidi_channel_handoffs: Vec::new(),
},
));
@@ -4,13 +4,21 @@ use crate::conn::BrowserContext;
use crate::testing::TestContext;
pub(super) async fn with_loaded_document_async(ctx: &mut TestContext, html: &str) {
with_loaded_document_for_target_async(ctx, html, "TID-1").await;
}
pub(super) async fn with_loaded_document_for_target_async(
ctx: &mut TestContext,
html: &str,
target_id: &str,
) {
ctx.conn
.insert_browser_context(BrowserContext::new("BID-1".into()));
ctx.conn
.browser_context
.as_mut()
.expect("browser context should be installed before loading test page")
.set_active_target_id("TID-1".to_owned());
.set_active_target_id(target_id.to_owned());
let data_url = format!("data:text/html,{html}");
ctx.install_navigation_fixture_for_session_owner(&data_url, None)
.await;
@@ -341,16 +341,12 @@ async fn failed_heap_profiler_sampling_command_does_not_create_typed_projection(
#[tokio::test(flavor = "multi_thread")]
async fn heap_profiler_sampling_and_tracking_are_restored_on_replacement_page_isolate() {
let mut ctx = TestContext::new();
with_loaded_document_async(
with_loaded_document_for_target_async(
&mut ctx,
"<!doctype html><script>globalThis.__heapBefore = []</script>",
"TID-heap-profiler-restore",
)
.await;
ctx.conn
.browser_context
.as_mut()
.expect("browser context should exist")
.set_active_target_id("TID-heap-profiler-restore");
for command in [
json!({"id": 206_840, "method": "HeapProfiler.enable"}),
@@ -665,13 +661,13 @@ async fn heap_profiler_moli_diagnostics_reports_connection_state_without_loaded_
);
assert_eq!(
isolate_scope["runtimeGetHeapUsageV8HeapScope"],
json!("page-vm-document-isolate"),
json!("script-agent-document-isolate"),
"diagnostics should label Runtime.getHeapUsage heap scope: {response:?}"
);
assert_eq!(
isolate_scope["runtimeGetHeapUsageV8HeapIsTargetLocal"],
json!(true),
"diagnostics should make PageVM-local heap stats explicit: {response:?}"
json!(false),
"heap stats may include explicitly related Pages in the same script agent: {response:?}"
);
assert_eq!(
isolate_scope["runtimeGetHeapUsageMoliCountersScope"],
@@ -680,12 +676,12 @@ async fn heap_profiler_moli_diagnostics_reports_connection_state_without_loaded_
);
assert_eq!(
isolate_scope["runtimeCollectGarbageScope"],
json!("page-vm-document-isolate"),
json!("script-agent-document-isolate"),
"diagnostics should label collectGarbage scope: {response:?}"
);
assert_eq!(
isolate_scope["v8ForegroundTaskWakeScope"],
json!("page-vm-document-isolate"),
json!("script-agent-document-isolate"),
"diagnostics should label V8 foreground task wake scope: {response:?}"
);
assert_eq!(
@@ -1,4 +1,5 @@
use super::*;
use crate::BackgroundNavigationCompletion;
/// Runtime.enable is a no-op that succeeds.
#[tokio::test]
@@ -516,12 +517,12 @@ async fn loaded_page_runtime_agent_state_commands_dispatch_after_runtime_enable(
#[tokio::test(flavor = "multi_thread")]
async fn runtime_agent_configuration_is_restored_on_replacement_page_isolate() {
let mut ctx = TestContext::new();
with_loaded_document_async(&mut ctx, "<!doctype html><body>before</body>").await;
ctx.conn
.browser_context
.as_mut()
.expect("browser context should exist")
.set_active_target_id("TID-runtime-agent-restore");
with_loaded_document_for_target_async(
&mut ctx,
"<!doctype html><body>before</body>",
"TID-runtime-agent-restore",
)
.await;
enable_runtime_and_take_execution_context_id_async(&mut ctx, 11_016).await;
for (id, method, params) in [
@@ -606,12 +607,12 @@ async fn runtime_agent_configuration_is_restored_on_replacement_page_isolate() {
#[tokio::test(flavor = "multi_thread")]
async fn opaque_reattach_state_wins_over_conflicting_runtime_listener_configuration() {
let mut ctx = TestContext::new();
with_loaded_document_async(&mut ctx, "<!doctype html><body>before</body>").await;
ctx.conn
.browser_context
.as_mut()
.expect("browser context should exist")
.set_active_target_id("TID-runtime-reattach-precedence");
with_loaded_document_for_target_async(
&mut ctx,
"<!doctype html><body>before</body>",
"TID-runtime-reattach-precedence",
)
.await;
ctx.process_async(json!({"id": 11_023, "method": "Runtime.enable"}))
.await;
@@ -2233,7 +2234,7 @@ async fn page_navigate_empty_http_error_commits_browser_error_document() {
}
#[tokio::test(flavor = "multi_thread")]
async fn page_navigate_network_failure_commits_error_document() {
async fn page_navigate_network_failure_commits_error_document_in_stable_page() {
async fn first() -> impl IntoResponse {
(
[(CONTENT_TYPE.as_str(), "text/html; charset=utf-8")],
@@ -2497,23 +2498,23 @@ async fn page_navigate_network_failure_commits_error_document() {
}))
.await;
ctx.expect_error(91, -32000, "No resource with given identifier found");
assert_ne!(
assert_eq!(
ctx.conn
.target_page_residence_identity_for_session(Some("SID-1")),
Some(before_target_page),
"current master installs navigation Documents as a new target Page attachment"
"the network error Document must stay in the target's stable Page residence"
);
assert_ne!(
assert_eq!(
ctx.conn
.renderer_page_residence_identity_for_session_owner(Some("SID-1")),
Some(before_renderer_page),
"the error Document must not reuse the retired renderer Page"
"the network error Document must replace only the renderer Page's current realm"
);
assert_ne!(
ctx.conn
.current_renderer_agent_attachment_id_for_session_owner(Some("SID-1")),
Some(before_renderer_attachment),
"the error Document must own a replacement realm/Inspector attachment"
"the error Document must replace the previous realm/renderer agent"
);
ctx.process_async(json!({
@@ -1,13 +1,13 @@
use super::test_support::{
create_isolated_world_async, enable_runtime_and_take_execution_context_id_async,
take_response_by_id, wait_for_response_by_id_async, with_loaded_document_async,
with_loaded_document_for_active_target_async, with_loaded_http_document_async,
with_loaded_document_for_active_target_async, with_loaded_document_for_target_async,
with_loaded_http_document_async,
};
use crate::conn::{
BrowserContext, CdpCommandTaskStep, CdpSchedulerEvent, NETWORK_ERROR_PAGE_URL,
PendingCdpCommandDispatch,
};
use crate::domains::page::BackgroundNavigationCompletion;
use crate::testing::{
TestContext, spawn_connection_drop_server, wait_until_message, wait_until_messages,
};
@@ -9,12 +9,7 @@ async fn enable_with_background_event_sender_defers_initial_document_page_build(
bc.set_target_url("about:blank".into());
ctx.conn.browser_context = Some(bc);
let (background_tx, _) = tokio::sync::mpsc::unbounded_channel();
let (completion_tx, _) =
tokio::sync::mpsc::unbounded_channel::<BackgroundNavigationCompletion>();
ctx.conn.set_background_event_sender(background_tx);
ctx.conn
.set_background_navigation_completion_sender(completion_tx);
ctx.enable_background_navigation_scheduler_for_test();
ctx.process_async(json!({
"id": 21,
@@ -600,7 +600,7 @@ onconnect = event => {
let response = take_response_by_id(&mut ctx, 90_022);
let isolate_scope = &response["result"]["isolateScope"];
assert_eq!(isolate_scope["documentIsolateModel"], json!("page-vm"));
assert_eq!(isolate_scope["documentIsolateModel"], json!("script-agent"));
assert_eq!(isolate_scope["loadedDocumentPageCount"], json!(2));
assert_eq!(
isolate_scope["loadedDocumentRendererOwnerCount"],
@@ -775,8 +775,8 @@ onconnect = event => {
);
assert_eq!(
isolate_scope["estimatedDocumentIsolateCount"],
json!(2),
"popup PageVM must be counted as a second document isolate: {response:?}"
json!(1),
"a related opener and popup keep separate Window realms in one stable script-agent isolate: {response:?}"
);
assert_eq!(
isolate_scope["documentContextCount"],
@@ -801,8 +801,8 @@ onconnect = event => {
);
assert_eq!(
isolate_scope["estimatedLiveV8IsolateCount"],
json!(4),
"combo should be two page document isolates plus two worker isolates: {response:?}"
json!(3),
"combo should be one related-page script-agent isolate plus two worker isolates: {response:?}"
);
assert_eq!(
response["result"]["activeBrowserContext"]["backgroundLoadedPageCount"],
File diff suppressed because it is too large Load Diff
@@ -5045,6 +5045,11 @@ async fn same_context_loaded_background_window_open_self_navigates_owner_without
})
})
.await;
ctx.wait_for_document_continuation_for_test(
Some(&owner.session_id),
"background _self Document continuation",
)
.await;
let emitted = ctx.take_all();
assert!(
!emitted
@@ -5134,6 +5139,11 @@ async fn same_context_named_popup_reuse_navigates_and_promotes_loaded_owner() {
},
)
.await;
ctx.wait_for_document_continuation_for_test(
Some(&owner.session_id),
"named background popup Document continuation",
)
.await;
let emitted = ctx.take_all();
assert!(
!emitted
@@ -1,5 +1,7 @@
use super::super::tests_cdp_smoke_fixture::SmokeFixtureServer;
use super::super::*;
use crate::conn::NETWORK_ERROR_PAGE_URL;
use crate::testing::spawn_connection_drop_server;
use crate::{CdpCommandTaskStep, CommandDispatchContext, ParsedCdpCommand};
use serde_json::{Value, json};
@@ -963,6 +965,724 @@ async fn rust_cdp_chromium_target_window_open_waiting_popup_routes_initial_docum
);
}
// Chromium source/runtime evidence:
// content/browser/renderer_host/navigation_request.cc::CommitErrorPage
// content/browser/renderer_host/navigation_request_browsertest.cc
// A failed popup destination replaces the initial empty Document while the
// auxiliary browsing context, stable Page, WindowProxy, and opener relation survive.
#[tokio::test(flavor = "multi_thread")]
async fn popup_transport_failure_commits_error_document_in_stable_auxiliary_page() {
let (failing_addr, failing_server) = spawn_connection_drop_server().await;
let mut ctx = TestContext::new_with_target_discovery(false);
ctx.enable_background_navigation_scheduler_for_test();
tokio::task::LocalSet::new()
.run_until(async {
load_bc_with_titled_page_async(
&mut ctx,
"BID-popup-network-error",
"TID-popup-network-error-opener",
"<main>opener</main>",
)
.await;
set_auto_attach_waiting_for_debugger(&mut ctx, 260_223).await;
ctx.take_all();
let unreachable_url = format!("http://{failing_addr}/popup-error");
let messages = open_popup_from_runtime(
&mut ctx,
260_224,
&format!(
"(() => {{ const popup = window.open('{unreachable_url}', '_blank'); globalThis.__networkErrorPopup = popup; globalThis.__networkErrorPopupAlias = popup; popup.__openerImmediateMutation = 'old realm'; popup.document.body.dataset.openerMutation = 'old document'; return popup !== null; }})()"
),
)
.await;
assert_eq!(
response(&messages, 260_224)["result"]["result"]["value"],
true
);
let popup = event(&messages, "Target.targetCreated");
let popup_target_id = popup["params"]["targetInfo"]["targetId"]
.as_str()
.expect("popup target id")
.to_owned();
assert_eq!(popup["params"]["targetInfo"]["url"], unreachable_url);
assert_eq!(
popup["params"]["targetInfo"]["openerId"],
"TID-popup-network-error-opener"
);
assert_eq!(popup["params"]["targetInfo"]["canAccessOpener"], true);
let attached = event(&messages, "Target.attachedToTarget");
let popup_session_id = attached["params"]["sessionId"]
.as_str()
.expect("popup session id")
.to_owned();
for (id, method, params) in [
(260_225, "Page.enable", json!({})),
(260_226, "Network.enable", json!({})),
(260_227, "Runtime.enable", json!({})),
(
260_228,
"Page.setLifecycleEventsEnabled",
json!({ "enabled": true }),
),
] {
ctx.process_async(json!({
"id": id,
"method": method,
"sessionId": popup_session_id,
"params": params
}))
.await;
ctx.expect_result(id, json!({}), Some(&popup_session_id));
}
ctx.process_async(json!({
"id": 260_229,
"method": "Runtime.evaluate",
"sessionId": popup_session_id,
"params": {
"expression": "({ href: location.href, marker: __openerImmediateMutation, bodyMarker: document.body.dataset.openerMutation, historyLength: history.length })",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 260_229)["result"]["result"]["value"],
json!({
"href": "about:blank",
"marker": "old realm",
"bodyMarker": "old document",
"historyLength": 1
})
);
let before_target_page = ctx
.conn
.target_page_residence_identity_for_session(Some(&popup_session_id))
.expect("popup target Page residence");
let before_renderer_page = ctx
.conn
.renderer_page_residence_identity_for_session_owner(Some(&popup_session_id))
.expect("popup renderer Page residence");
let before_renderer_attachment = ctx
.conn
.current_renderer_agent_attachment_id_for_session_owner(Some(&popup_session_id))
.expect("popup renderer attachment");
ctx.sent.clear();
ctx.process_async(json!({
"id": 260_230,
"method": "Runtime.runIfWaitingForDebugger",
"sessionId": popup_session_id
}))
.await;
take_response_by_id(&mut ctx, 260_230);
crate::testing::wait_until_scheduler_message(
&mut ctx,
"popup network error Document load",
|message| {
message["method"] == json!("Page.loadEventFired")
&& message["sessionId"] == json!(popup_session_id)
},
)
.await;
ctx.wait_until_scheduler_state("popup network error commit", |conn| {
!conn.has_pending_document_navigation_for_session_owner(Some(&popup_session_id))
&& conn
.browser_context_by_id("BID-popup-network-error")
.and_then(|browser_context| {
browser_context.background_target(&popup_target_id)
})
.and_then(|target| target.loaded_page())
.is_some_and(|page| page.final_url().as_str() == NETWORK_ERROR_PAGE_URL)
})
.await;
let request_index = ctx
.sent
.iter()
.position(|message| {
message["method"] == json!("Network.requestWillBeSent")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["request"]["url"] == json!(unreachable_url)
})
.unwrap_or_else(|| panic!("missing popup request: {:?}", ctx.sent));
let request_id = ctx.sent[request_index]["params"]["requestId"]
.as_str()
.expect("popup request id")
.to_owned();
let failed_index = ctx
.sent
.iter()
.position(|message| {
message["method"] == json!("Network.loadingFailed")
&& message["params"]["requestId"] == json!(request_id)
})
.unwrap_or_else(|| panic!("missing popup loadingFailed: {:?}", ctx.sent));
let frame_index = ctx
.sent
.iter()
.position(|message| {
message["method"] == json!("Page.frameNavigated")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["frame"]["url"]
== json!(NETWORK_ERROR_PAGE_URL)
})
.unwrap_or_else(|| panic!("missing popup error frame: {:?}", ctx.sent));
let finished_index = ctx
.sent
.iter()
.position(|message| {
message["method"] == json!("Network.loadingFinished")
&& message["params"]["requestId"] == json!(request_id)
})
.unwrap_or_else(|| panic!("missing popup loadingFinished: {:?}", ctx.sent));
let dom_content_loaded_index = ctx
.sent
.iter()
.position(|message| {
message["method"] == json!("Page.domContentEventFired")
&& message["sessionId"] == json!(popup_session_id)
})
.expect("popup error Document DCL");
assert!(
request_index < failed_index
&& failed_index < frame_index
&& frame_index < finished_index
&& finished_index < dom_content_loaded_index,
"popup error-page event order should match Chromium: {:?}",
ctx.sent
);
assert_eq!(
ctx.sent[frame_index]["params"]["frame"]["unreachableUrl"],
unreachable_url
);
assert_eq!(ctx.sent[failed_index]["params"]["canceled"], false);
assert!(!ctx.sent.iter().any(|message| {
message["method"] == json!("Network.responseReceived")
&& message["params"]["requestId"] == json!(request_id)
}));
ctx.process_async(json!({
"id": 260_231,
"method": "Runtime.evaluate",
"sessionId": popup_session_id,
"params": {
"expression": "({ href: location.href, origin: location.origin, oldMarkerType: typeof __openerImmediateMutation, oldBodyMarkerType: typeof document.body.dataset.openerMutation, title: document.title, readyState: document.readyState, historyLength: history.length, hasOpener: opener !== null })",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 260_231)["result"]["result"]["value"],
json!({
"href": NETWORK_ERROR_PAGE_URL,
"origin": "null",
"oldMarkerType": "undefined",
"oldBodyMarkerType": "undefined",
"title": "127.0.0.1",
"readyState": "complete",
"historyLength": 1,
"hasOpener": true
})
);
ctx.process_async(json!({
"id": 260_232,
"method": "Page.getNavigationHistory",
"sessionId": popup_session_id
}))
.await;
let history = take_response_by_id(&mut ctx, 260_232);
assert_eq!(history["result"]["currentIndex"], json!(0));
assert_eq!(
history["result"]["entries"]
.as_array()
.expect("popup error history entries")
.len(),
1
);
assert_eq!(history["result"]["entries"][0]["url"], unreachable_url);
ctx.process_async(json!({
"id": 260_233,
"method": "Target.getTargetInfo",
"params": { "targetId": popup_target_id }
}))
.await;
let target_info = take_response_by_id(&mut ctx, 260_233);
assert_eq!(target_info["result"]["targetInfo"]["url"], unreachable_url);
assert_eq!(
target_info["result"]["targetInfo"]["openerId"],
"TID-popup-network-error-opener"
);
assert_eq!(
target_info["result"]["targetInfo"]["canAccessOpener"],
true
);
assert_eq!(
ctx.conn
.target_page_residence_identity_for_session(Some(&popup_session_id)),
Some(before_target_page),
"popup error navigation should keep its target Page residence"
);
assert_eq!(
ctx.conn
.renderer_page_residence_identity_for_session_owner(Some(&popup_session_id)),
Some(before_renderer_page),
"popup error navigation should keep its renderer Page/WindowProxy residence"
);
assert_ne!(
ctx.conn
.current_renderer_agent_attachment_id_for_session_owner(Some(
&popup_session_id,
)),
Some(before_renderer_attachment),
"popup error navigation should replace only the renderer Document/realm"
);
ctx.process_async(json!({
"id": 260_234,
"method": "Runtime.evaluate",
"params": {
"expression": "({ retainedWindowProxy: __networkErrorPopup !== null && __networkErrorPopup === __networkErrorPopupAlias })",
"returnByValue": true
}
}))
.await;
let opener_probe = take_response_by_id(&mut ctx, 260_234);
assert_eq!(
opener_probe["result"]["result"]["value"],
json!({ "retainedWindowProxy": true }),
"opener-side stable popup WindowProxy probe failed: {opener_probe:?}"
);
})
.await;
failing_server.abort();
}
// Chromium/WPT sources:
// third_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/
// navigating-across-documents/initial-empty-document/window-open-204-fragment.html
// navigating-across-documents/initial-empty-document/
// window-open-204-pushState-replaceState.html
// third_party/blink/web_tests/http/tests/inspector-protocol/page/navigate-204.js
// third_party/blink/web_tests/http/tests/inspector-protocol/network/
// navigation-204-loading-failed.js
#[tokio::test(flavor = "multi_thread")]
async fn popup_no_commit_responses_preserve_initial_document_before_redirect_replacement() {
let fixture = SmokeFixtureServer::start().await;
let mut ctx = TestContext::new_with_target_discovery(false);
ctx.enable_background_navigation_scheduler_for_test();
tokio::task::LocalSet::new()
.run_until(async {
load_bc_with_titled_page_async(
&mut ctx,
"BID-popup-terminal",
"TID-popup-terminal-opener",
"<main>opener</main>",
)
.await;
set_auto_attach_waiting_for_debugger(&mut ctx, 260_230).await;
ctx.take_all();
let no_content_url = fixture.url("/no-content");
let messages = open_popup_from_runtime(
&mut ctx,
260_231,
&format!(
"(() => {{ const popup = window.open('{no_content_url}', '_blank'); popup.__openerImmediateMutation = 'kept'; popup.document.body.dataset.openerMutation = 'kept'; return popup !== null; }})()"
),
)
.await;
assert_eq!(
response(&messages, 260_231)["result"]["result"]["value"],
true
);
let popup = event(&messages, "Target.targetCreated");
let popup_target_id = popup["params"]["targetInfo"]["targetId"]
.as_str()
.expect("popup target id")
.to_owned();
let attached = event(&messages, "Target.attachedToTarget");
let popup_session_id = attached["params"]["sessionId"]
.as_str()
.expect("popup session id")
.to_owned();
for (id, method, params) in [
(260_232, "Page.enable", json!({})),
(260_233, "Network.enable", json!({})),
(
260_234,
"Page.setLifecycleEventsEnabled",
json!({ "enabled": true }),
),
] {
ctx.process_async(json!({
"id": id,
"method": method,
"sessionId": popup_session_id,
"params": params
}))
.await;
ctx.expect_result(id, json!({}), Some(&popup_session_id));
}
ctx.process_async(json!({
"id": 260_235,
"method": "Runtime.evaluate",
"sessionId": popup_session_id,
"params": {
"expression": "({ href: location.href, globalMarker: __openerImmediateMutation, bodyMarker: document.body.dataset.openerMutation, historyLength: history.length })",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 260_235)["result"]["result"]["value"],
json!({
"href": "about:blank",
"globalMarker": "kept",
"bodyMarker": "kept",
"historyLength": 1
})
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 260_236,
"method": "Runtime.runIfWaitingForDebugger",
"sessionId": popup_session_id
}))
.await;
take_response_by_id(&mut ctx, 260_236);
crate::testing::wait_until_scheduler_message(
&mut ctx,
"popup 204 navigation abort",
|message| {
message["method"] == json!("Network.loadingFailed")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["errorText"] == json!("net::ERR_ABORTED")
},
)
.await;
ctx.wait_until_scheduler_state("popup 204 terminal completion", |conn| {
!conn.has_pending_document_navigation_for_session_owner(Some(&popup_session_id))
})
.await;
let response_204_index = ctx
.sent
.iter()
.position(|message| {
message["method"] == json!("Network.responseReceived")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["response"]["url"] == json!(no_content_url)
&& message["params"]["response"]["status"] == json!(204)
})
.unwrap_or_else(|| panic!("missing popup 204 response: {:?}", ctx.sent));
let request_204 = ctx.sent[response_204_index]["params"]["requestId"]
.as_str()
.expect("204 request id")
.to_owned();
let failed_204_index = ctx
.sent
.iter()
.position(|message| {
message["method"] == json!("Network.loadingFailed")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["requestId"] == json!(request_204)
&& message["params"]["errorText"] == json!("net::ERR_ABORTED")
&& message["params"]["canceled"] == json!(true)
})
.unwrap_or_else(|| panic!("missing popup 204 loadingFailed: {:?}", ctx.sent));
assert!(response_204_index < failed_204_index);
assert!(!ctx.sent.iter().any(|message| {
message["method"] == json!("Network.loadingFinished")
&& message["params"]["requestId"] == json!(request_204)
}));
assert!(!ctx.sent.iter().any(|message| {
(message["method"] == json!("Page.frameNavigated")
&& message["params"]["frame"]["url"] == json!(no_content_url))
|| (message["method"] == json!("Page.lifecycleEvent")
&& matches!(
message["params"]["name"].as_str(),
Some("DOMContentLoaded" | "load")
))
}));
ctx.process_async(json!({
"id": 260_237,
"method": "Runtime.evaluate",
"sessionId": popup_session_id,
"params": {
"expression": "location.hash = 'after-204'; const fragmentHistoryLength = history.length; history.pushState({ source: 'initial-empty' }, '', '#after-push-state'); ({ href: location.href, globalMarker: __openerImmediateMutation, bodyMarker: document.body.dataset.openerMutation, fragmentHistoryLength, historyLength: history.length, historyState: history.state.source })",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 260_237)["result"]["result"]["value"],
json!({
"href": "about:blank#after-push-state",
"globalMarker": "kept",
"bodyMarker": "kept",
"fragmentHistoryLength": 1,
"historyLength": 1,
"historyState": "initial-empty"
})
);
ctx.process_async(json!({
"id": 260_243,
"method": "Page.getNavigationHistory",
"sessionId": popup_session_id
}))
.await;
let fragment_history = take_response_by_id(&mut ctx, 260_243);
assert_eq!(fragment_history["result"]["currentIndex"], json!(0));
assert_eq!(
fragment_history["result"]["entries"]
.as_array()
.expect("post-204 fragment history entries")
.len(),
1
);
assert_eq!(
fragment_history["result"]["entries"][0]["url"],
"about:blank#after-push-state"
);
ctx.sent.clear();
let reset_content_url = fixture.url("/reset-content");
ctx.process_async(json!({
"id": 260_238,
"method": "Page.navigate",
"sessionId": popup_session_id,
"params": { "url": reset_content_url }
}))
.await;
crate::testing::wait_until_scheduler_message(
&mut ctx,
"popup 205 Page.navigate result",
|message| message["id"] == json!(260_238),
)
.await;
let reset_result = take_response_by_id(&mut ctx, 260_238);
assert_eq!(reset_result["result"]["frameId"], popup_target_id);
assert!(reset_result["result"]["loaderId"].as_str().is_some());
assert_eq!(
reset_result["result"]["errorText"],
json!("net::ERR_ABORTED")
);
assert_eq!(reset_result["result"]["isDownload"], json!(false));
let response_205 = ctx
.sent
.iter()
.find(|message| {
message["method"] == json!("Network.responseReceived")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["response"]["url"] == json!(reset_content_url)
&& message["params"]["response"]["status"] == json!(205)
})
.unwrap_or_else(|| panic!("missing popup 205 response: {:?}", ctx.sent));
let request_205 = response_205["params"]["requestId"]
.as_str()
.expect("205 request id");
assert!(ctx.sent.iter().any(|message| {
message["method"] == json!("Network.loadingFailed")
&& message["params"]["requestId"] == json!(request_205)
&& message["params"]["errorText"] == json!("net::ERR_ABORTED")
&& message["params"]["canceled"] == json!(true)
}));
ctx.process_async(json!({
"id": 260_239,
"method": "Runtime.evaluate",
"sessionId": popup_session_id,
"params": {
"expression": "({ href: location.href, marker: __openerImmediateMutation, historyLength: history.length })",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 260_239)["result"]["result"]["value"],
json!({
"href": "about:blank#after-push-state",
"marker": "kept",
"historyLength": 1
})
);
ctx.sent.clear();
let redirect_start_url = fixture.url("/redirect-start");
let redirect_final_url = fixture.url("/redirect-final");
ctx.process_async(json!({
"id": 260_240,
"method": "Page.navigate",
"sessionId": popup_session_id,
"params": { "url": redirect_start_url }
}))
.await;
crate::testing::wait_until_scheduler_message(
&mut ctx,
"popup redirect Page.navigate result",
|message| message["id"] == json!(260_240),
)
.await;
let redirect_result = take_response_by_id(&mut ctx, 260_240);
let redirect_loader_id = redirect_result["result"]["loaderId"]
.as_str()
.expect("redirect loader id")
.to_owned();
crate::testing::wait_until_scheduler_message(
&mut ctx,
"popup redirect load lifecycle",
|message| {
message["method"] == json!("Page.lifecycleEvent")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["frameId"] == json!(popup_target_id)
&& message["params"]["loaderId"] == json!(redirect_loader_id)
&& message["params"]["name"] == json!("load")
},
)
.await;
ctx.wait_until_scheduler_state("popup redirect authoritative load", |conn| {
conn.renderer_document_lifecycle_authoritative_state_for_session_owner(Some(
&popup_session_id,
))
.is_some_and(|(binding, snapshot)| {
binding.loader_id == redirect_loader_id && snapshot.load.is_some()
})
})
.await;
let document_requests = ctx
.sent
.iter()
.filter(|message| {
message["method"] == json!("Network.requestWillBeSent")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["type"] == json!("Document")
})
.collect::<Vec<_>>();
assert_eq!(
document_requests
.iter()
.filter(|message| {
message["params"]["request"]["url"] == json!(redirect_start_url)
})
.count(),
1,
"redirect start must have one authoritative hop: {:?}",
ctx.sent
);
assert_eq!(
document_requests
.iter()
.filter(|message| {
message["params"]["request"]["url"] == json!(redirect_final_url)
})
.count(),
1,
"redirect final must have one authoritative hop: {:?}",
ctx.sent
);
let start_request = document_requests
.iter()
.find(|message| {
message["params"]["request"]["url"] == json!(redirect_start_url)
})
.expect("redirect start request");
let final_request = document_requests
.iter()
.find(|message| {
message["params"]["request"]["url"] == json!(redirect_final_url)
})
.expect("redirect final request");
assert_eq!(
start_request["params"]["requestId"],
final_request["params"]["requestId"]
);
assert_eq!(
final_request["params"]["redirectResponse"]["url"],
redirect_start_url
);
assert_eq!(
ctx.sent
.iter()
.filter(|message| {
message["method"] == json!("Page.frameNavigated")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["frame"]["loaderId"]
== json!(redirect_loader_id)
&& message["params"]["frame"]["url"] == json!(redirect_final_url)
})
.count(),
1
);
for lifecycle_name in ["DOMContentLoaded", "load"] {
assert_eq!(
ctx.sent
.iter()
.filter(|message| {
message["method"] == json!("Page.lifecycleEvent")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["loaderId"] == json!(redirect_loader_id)
&& message["params"]["name"] == json!(lifecycle_name)
})
.count(),
1,
"{lifecycle_name} must be published once: {:?}",
ctx.sent
);
}
ctx.process_async(json!({
"id": 260_241,
"method": "Runtime.evaluate",
"sessionId": popup_session_id,
"params": {
"expression": "({ href: location.href, text: document.querySelector('main').textContent, historyLength: history.length, readyState: document.readyState, oldMarkerType: typeof __openerImmediateMutation })",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 260_241)["result"]["result"]["value"],
json!({
"href": redirect_final_url,
"text": "redirect final",
"historyLength": 1,
"readyState": "complete",
"oldMarkerType": "undefined"
})
);
ctx.process_async(json!({
"id": 260_242,
"method": "Page.getNavigationHistory",
"sessionId": popup_session_id
}))
.await;
let history = take_response_by_id(&mut ctx, 260_242);
assert_eq!(history["result"]["currentIndex"], json!(0));
assert_eq!(
history["result"]["entries"]
.as_array()
.expect("popup history entries")
.len(),
1
);
assert_eq!(history["result"]["entries"][0]["url"], redirect_final_url);
})
.await;
}
// Chromium source:
// third_party/blink/web_tests/http/tests/inspector-protocol/target/target-setAutoAttach-windowOpen-empty-url.js
#[tokio::test(flavor = "multi_thread")]
@@ -400,6 +400,18 @@ async fn fixture_handler(
}
"/redirect-start" => redirect("/redirect-final", &[("cache-control", "no-store")]),
"/redirect-final" => html("<!doctype html><main>redirect final</main>"),
"/no-content" => response(
StatusCode::NO_CONTENT,
"text/plain; charset=utf-8",
Vec::new(),
&[("x-smoke-navigation-terminal", "no-content")],
),
"/reset-content" => response(
StatusCode::RESET_CONTENT,
"text/plain; charset=utf-8",
Vec::new(),
&[("x-smoke-navigation-terminal", "reset-content")],
),
"/history-a" => html("<!doctype html><main>history a</main>"),
"/history-b" => html("<!doctype html><main>history b</main>"),
"/document-continue" => {
@@ -749,6 +761,19 @@ async fn rust_smoke_fixture_serves_all_document_and_control_routes() {
}
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_smoke_fixture_serves_navigation_no_commit_routes() {
let fixture = SmokeFixtureServer::start().await;
for (path, expected_status) in [("/no-content", 204), ("/reset-content", 205)] {
let response = fixture_get(&fixture, path).await;
assert_eq!(response.status, expected_status, "route {path}");
assert!(
response.body.is_empty(),
"route {path} must not carry a body"
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_smoke_fixture_serves_all_subresource_routes() {
let fixture = SmokeFixtureServer::start().await;
@@ -6079,6 +6079,7 @@ async fn playwright_over_cdp_script_execution_disabled_blocks_page_scripts_but_n
let mut ctx = TestContext::new();
let attached = create_attached_page_session_async(&mut ctx, 240, 241, 242, 2393, 243).await;
let session_id = attached.session_id;
let target_id = attached.target_id;
ctx.process_async(json!({
"id": 244,
@@ -6125,7 +6126,7 @@ async fn playwright_over_cdp_script_execution_disabled_blocks_page_scripts_but_n
crate::testing::wait_until_renderer_document_load(
&mut ctx,
Some(session_id.as_str()),
&attached.target_id,
&target_id,
&loader_id,
)
.await;
@@ -171,7 +171,7 @@ async fn get_target_info_reports_background_target_in_same_browser_context() {
}
#[tokio::test(flavor = "multi_thread")]
async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() {
async fn popup_initial_about_blank_adopts_renderer_page_and_related_script_agent() {
let mut ctx = TestContext::new();
ctx.conn.auto_attach = true;
let browser_context = ctx
@@ -195,6 +195,7 @@ async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() {
)
.await
.expect("opener page should load");
let opener_renderer_page_id = opener_page.renderer_page_id().as_u64();
let opener_url = opener_page.final_url().as_str().to_owned();
{
let browser_context = ctx
@@ -251,25 +252,60 @@ async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() {
})
.cloned()
.unwrap_or_else(|| panic!("popup target should be auto-attached: {:?}", ctx.sent));
let popup_session_id = attached["params"]["sessionId"]
.as_str()
.expect("popup session id should be present")
.to_owned();
assert!(
attached["params"]["sessionId"].as_str().is_some(),
"popup session id should be present"
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 104_216,
"method": "Page.navigate",
"sessionId": popup_session_id,
"params": {
"url": "data:text/html,<!doctype html><title>popup</title><body>popup</body>"
}
}))
.await;
consume_main_document_navigation_start(&mut ctx);
let navigation = take_response_by_id(&mut ctx, 104_216);
assert_eq!(navigation["result"]["frameId"], json!(popup_target_id));
ctx.sent.clear();
let opener_script_agent_id = ctx
.conn
.browser_context
.as_mut()
.and_then(|browser_context| browser_context.active_target.runtime_slot.loaded_page_mut())
.expect("opener should remain loaded")
.runtime_heap_usage_async()
.await
.expect("opener heap diagnostics should be available")
.moli
.runtime
.script_agent_id;
let (popup_renderer_page_id, popup_browsing_context_id, popup_script_agent_id) = {
let popup = ctx
.conn
.browser_context
.as_mut()
.and_then(|browser_context| browser_context.background_target_mut(&popup_target_id))
.expect("popup target should remain staged");
let popup_browsing_context_id = popup
.auxiliary_browsing_context_id()
.expect("popup target should retain its renderer browsing-context identity");
let popup_page = popup
.loaded_page_mut()
.expect("popup initial empty Document should be loaded");
let popup_renderer_page_id = popup_page.renderer_page_id().as_u64();
let popup_script_agent_id = popup_page
.runtime_heap_usage_async()
.await
.expect("popup heap diagnostics should be available")
.moli
.runtime
.script_agent_id;
(
popup_renderer_page_id,
popup_browsing_context_id,
popup_script_agent_id,
)
};
assert_ne!(popup_renderer_page_id, opener_renderer_page_id);
assert_eq!(
popup_renderer_page_id, popup_browsing_context_id,
"protocol must build the popup's initial Document with the renderer-reserved Page identity"
);
assert_eq!(
popup_script_agent_id, opener_script_agent_id,
"the renderer-reserved initial popup Page should join its opener's script agent"
);
ctx.process_async(json!({
"id": 104_217,
@@ -284,15 +320,20 @@ async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() {
json!(1),
"opener and loaded popup target should share one renderer owner: {diagnostics:?}"
);
assert_eq!(
isolate_scope["loadedDocumentScriptAgentCount"],
json!(1),
"the renderer-reserved popup Page should join its opener's explicit script agent: {diagnostics:?}"
);
assert_eq!(
isolate_scope["estimatedDocumentIsolateCount"],
json!(2),
"loaded popup PageVM must own a distinct document isolate: {diagnostics:?}"
json!(1),
"two related PageVM realms should be hosted by one document script-agent isolate: {diagnostics:?}"
);
assert_eq!(
isolate_scope["estimatedLiveV8IsolateCount"],
json!(2),
"opener plus popup without workers should report two live page document isolates: {diagnostics:?}"
json!(1),
"opener plus related popup without workers should report one live script-agent isolate: {diagnostics:?}"
);
assert_eq!(
isolate_scope["documentContextCount"],
@@ -304,6 +345,79 @@ async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() {
json!(1),
"the popup should remain a loaded background target"
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 104_218,
"method": "Runtime.evaluate",
"sessionId": "SID-popup-opener",
"params": {
"expression": "window.open('about:blank#diagnostics-noopener', '_blank', 'noopener') === null",
"returnByValue": true
}
}))
.await;
let noopener_response = take_response_by_id(&mut ctx, 104_218);
assert_eq!(
noopener_response["result"]["result"]["value"],
json!(true),
"noopener window.open should return null after accepting the popup"
);
let noopener_target = ctx
.sent
.iter()
.find(|message| {
message["method"] == json!("Target.targetCreated")
&& message["params"]["targetInfo"]["canAccessOpener"] == json!(false)
})
.cloned()
.unwrap_or_else(|| panic!("noopener popup target should be created: {:?}", ctx.sent));
let noopener_target_id = noopener_target["params"]["targetInfo"]["targetId"]
.as_str()
.expect("noopener popup target id should be present")
.to_owned();
let noopener_script_agent_id = {
let popup = ctx
.conn
.browser_context
.as_mut()
.and_then(|browser_context| browser_context.background_target_mut(&noopener_target_id))
.expect("noopener popup target should remain staged");
let popup_browsing_context_id = popup
.auxiliary_browsing_context_id()
.expect("noopener popup should retain its auxiliary browsing-context identity");
let popup_page = popup
.loaded_page_mut()
.expect("noopener popup initial empty Document should be loaded");
assert_eq!(
popup_page.renderer_page_id().as_u64(),
popup_browsing_context_id,
"noopener popup must also adopt its renderer-reserved Page identity"
);
popup_page
.runtime_heap_usage_async()
.await
.expect("noopener popup heap diagnostics should be available")
.moli
.runtime
.script_agent_id
};
assert_ne!(
noopener_script_agent_id, opener_script_agent_id,
"opener suppression must keep the auxiliary Page in a fresh script agent"
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 104_219,
"method": "HeapProfiler.moliDiagnostics"
}))
.await;
let diagnostics = take_response_by_id(&mut ctx, 104_219);
let isolate_scope = &diagnostics["result"]["isolateScope"];
assert_eq!(isolate_scope["loadedDocumentPageCount"], json!(3));
assert_eq!(isolate_scope["loadedDocumentScriptAgentCount"], json!(2));
assert_eq!(isolate_scope["estimatedDocumentIsolateCount"], json!(2));
}
#[tokio::test(flavor = "multi_thread")]
@@ -1004,7 +1004,10 @@ async fn timer_window_open_emits_page_event_from_runtime_work() {
async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key() {
async fn opener() -> impl IntoResponse {
(
[(CONTENT_TYPE.as_str(), "text/html")],
[
(CONTENT_TYPE.as_str(), "text/html"),
("content-security-policy", "script-src 'self'"),
],
"<!doctype html><main>session storage opener</main>",
)
}
@@ -1017,17 +1020,13 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
Arc<tokio::sync::Semaphore>,
)>,
) -> impl IntoResponse {
// The opener's lightweight WindowProxy facade starts its mirrored
// load before the concrete popup action can reach protocol. Let that
// renderer-local request finish; gate the second request, which is the
// real auxiliary target navigation whose lifetime this test covers.
// The real auxiliary Page owns the only navigation. Gate that request
// so the test can inspect the synchronously created initial realm
// after target admission but before the replacement Document commits.
let request_index = request_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if request_index == 0 {
return opener().await;
}
assert_eq!(
request_index, 1,
"popup URL must have exactly two load owners"
request_index, 0,
"popup URL must have exactly one authoritative navigation owner"
);
request_started.add_permits(1);
let permit = response_release
@@ -1108,7 +1107,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
"id": 1201,
"method": "Runtime.evaluate",
"params": {
"expression": "(() => { localStorage.clear(); localStorage.setItem('shared', 'opener'); sessionStorage.clear(); sessionStorage.setItem('phase', 'before'); const popup = window.open('about:blank', '_blank'); sessionStorage.setItem('phase', 'after'); return `${popup.sessionStorage.getItem('phase')}|${sessionStorage.getItem('phase')}|${popup.localStorage.getItem('shared')}`; })()",
"expression": "(() => { const openerOrigin = location.origin; const openerHref = location.href; const openerBase = document.baseURI; localStorage.clear(); localStorage.setItem('shared', 'opener'); sessionStorage.clear(); sessionStorage.setItem('phase', 'before'); const popup = window.open('about:blank', '_blank'); let popupEvalBlocked = false; try { popup.eval('1'); } catch (error) { popupEvalBlocked = error.name === 'EvalError'; } sessionStorage.setItem('phase', 'after'); return `${popup.sessionStorage.getItem('phase')}|${sessionStorage.getItem('phase')}|${popup.localStorage.getItem('shared')}|${popup.location.origin === openerOrigin}|${popup.origin === openerOrigin}|${popup.document.referrer === openerHref}|${popup.document.baseURI === openerBase}|${popup.name === ''}|${popupEvalBlocked}`; })()",
"returnByValue": true
}
}))
@@ -1118,7 +1117,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
json!({
"result": {
"type": "string",
"value": "before|after|opener"
"value": "before|after|opener|true|true|true|true|true|true"
}
}),
None,
@@ -1150,12 +1149,40 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
.to_owned();
ctx.sent.clear();
ctx.process_async(json!({
"id": 12021,
"sessionId": popup_session_id,
"method": "Runtime.enable"
}))
.await;
ctx.expect_result(12021, json!({}), Some(&popup_session_id));
let default_context = ctx
.sent
.iter()
.find(|message| {
message["method"] == json!("Runtime.executionContextCreated")
&& message["sessionId"] == json!(popup_session_id)
&& message["params"]["context"]["auxData"]["isDefault"] == json!(true)
})
.unwrap_or_else(|| panic!("popup default execution context: {:?}", ctx.sent));
assert_eq!(
default_context["params"]["context"]["origin"],
json!(format!("http://{addr}")),
"CDP must expose the creator-inherited origin for the real initial about:blank realm: {default_context:?}"
);
assert_eq!(
default_context["params"]["context"]["auxData"]["frameId"],
json!(popup_target_id)
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 1203,
"sessionId": popup_session_id,
"method": "Runtime.evaluate",
"params": {
"expression": "`${String(sessionStorage.getItem('phase'))}|${String(localStorage.getItem('shared'))}`",
"expression": "(() => { let evalBlocked = false; try { eval('1'); } catch (error) { evalBlocked = error.name === 'EvalError'; } return `${String(sessionStorage.getItem('phase'))}|${String(localStorage.getItem('shared'))}|${evalBlocked}`; })()",
"allowUnsafeEvalBlockedByCSP": false,
"returnByValue": true
}
}))
@@ -1165,7 +1192,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
json!({
"result": {
"type": "string",
"value": "before|opener"
"value": "before|opener|true"
}
}),
Some(&popup_session_id),
@@ -1215,6 +1242,19 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
Some(&popup_session_id),
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 12033,
"method": "Target.setAutoAttach",
"params": {
"autoAttach": true,
"waitForDebuggerOnStart": true,
"flatten": true
}
}))
.await;
ctx.expect_result(12033, json!({}), None);
ctx.sent.clear();
let cross_origin_url = format!("http://{cross_origin_addr}/popup");
ctx.process_async(json!({
@@ -1223,7 +1263,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
"sessionId": opener_session_id,
"params": {
"expression": format!(
"(() => {{ sessionStorage.setItem('cross-origin-secret', 'opener-only'); return window.open({cross_origin_url:?}, '_blank') !== null; }})()"
"(() => {{ sessionStorage.setItem('cross-origin-secret', 'opener-only'); const popup = window.open({cross_origin_url:?}, '_blank'); if (popup === null) return false; popup.__initialNonEmptyMarker = 'before-navigation'; popup.document.body.setAttribute('data-before-navigation', 'preserved'); window.__pendingNonEmptyPopup = popup; window.__pendingNonEmptyDocument = popup.document; return popup.location.href === 'about:blank'; }})()"
),
"returnByValue": true
}
@@ -1251,6 +1291,67 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
.as_str()
.expect("cross-origin popup target id")
.to_owned();
let cross_origin_attached = ctx
.sent
.iter()
.find(|message| {
message["method"] == json!("Target.attachedToTarget")
&& message["params"]["targetInfo"]["targetId"]
== json!(cross_origin_target_id)
})
.unwrap_or_else(|| panic!("cross-origin popup auto-attach event: {:?}", ctx.sent));
assert_eq!(
cross_origin_attached["params"]["waitingForDebugger"],
json!(true)
);
let cross_origin_session_id = cross_origin_attached["params"]["sessionId"]
.as_str()
.expect("cross-origin popup session id")
.to_owned();
ctx.sent.clear();
assert!(
!ctx.conn
.browser_context_by_id("BID-popup-storage")
.is_some_and(|browser_context| browser_context
.has_pending_document_navigation_for_target(Some(&cross_origin_target_id))),
"waitForDebuggerOnStart must hold the target-owned navigation before it starts"
);
assert_eq!(
cross_origin_request_count.load(std::sync::atomic::Ordering::SeqCst),
0,
"target admission must precede the only popup network request"
);
ctx.process_async(json!({
"id": 12051,
"sessionId": cross_origin_session_id,
"method": "Runtime.evaluate",
"params": {
"expression": "`${window.opener.__pendingNonEmptyPopup === window}|${window.opener.__pendingNonEmptyDocument === document}|${window.__initialNonEmptyMarker}|${document.body.getAttribute('data-before-navigation')}|${sessionStorage.getItem('cross-origin-secret')}`",
"returnByValue": true
}
}))
.await;
ctx.expect_result(
12051,
json!({
"result": {
"type": "string",
"value": "true|true|before-navigation|preserved|opener-only"
}
}),
Some(&cross_origin_session_id),
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 12052,
"sessionId": cross_origin_session_id,
"method": "Runtime.runIfWaitingForDebugger"
}))
.await;
ctx.expect_result(12052, json!({}), Some(&cross_origin_session_id));
ctx.sent.clear();
ctx.wait_until_scheduler_state("cross-origin popup navigation starts", |conn| {
@@ -1270,28 +1371,6 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
.expect("cross-origin popup request gate must remain open");
request_started.forget();
// Attach while the target's requested URL is still loading. Session
// attachment belongs to the browsing context and must not race the
// replacement Document's commit.
ctx.process_and_wait_for_response_async(json!({
"id": 1205,
"method": "Target.attachToTarget",
"params": { "targetId": cross_origin_target_id }
}))
.await;
let attach_response = take_response_by_id(&mut ctx, 1205);
let cross_origin_session_id = attach_response["result"]["sessionId"]
.as_str()
.unwrap_or_else(|| panic!("cross-origin popup session id: {attach_response:?}"))
.to_owned();
assert!(
ctx.conn
.browser_context_by_id("BID-popup-storage")
.is_some_and(|browser_context| browser_context
.has_pending_document_navigation_for_target(Some(&cross_origin_target_id))),
"attaching a DevTools session must not replace the target-owned navigation"
);
ctx.sent.clear();
cross_origin_response_release.add_permits(1);
let response_returned = tokio::time::timeout(
std::time::Duration::from_secs(5),
@@ -1314,6 +1393,12 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key(
)
.await;
assert_eq!(
cross_origin_request_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"non-empty popup navigation must issue exactly one network request"
);
ctx.process_async(json!({
"id": 1206,
"sessionId": cross_origin_session_id,
@@ -1611,6 +1696,118 @@ async fn popup_initial_empty_document_frame_tree_inherits_opener_origin() {
assert!(initial.is_on_initial_empty_document());
}
#[tokio::test(flavor = "multi_thread")]
async fn opener_window_handle_projects_the_renderer_owned_auxiliary_realm() {
let mut ctx = TestContext::new();
load_bc_with_titled_page_async(
&mut ctx,
"BID-popup-window-proxy",
"TID-opener-window-proxy",
"<main>popup WindowProxy opener</main>",
)
.await;
ctx.process_async(json!({
"id": 1250,
"method": "Runtime.evaluate",
"params": {
"expression": "(() => { const popup = window.open('about:blank', '_blank'); globalThis.__lmPopupWindow = popup; globalThis.__lmPopupDocument = popup.document; popup.__lmSynchronousRealmMarker = 'before-target-activation'; popup.document.body.textContent = 'synchronous-document'; return `${popup !== null}|${__lmPopupDocument === popup.document}|${popup.__lmSynchronousRealmMarker}|${popup.document.body.textContent}|${popup.name === ''}|${popup.location.origin === location.origin}|${popup.document.referrer === location.href}|${popup.document.baseURI === document.baseURI}`; })()",
"returnByValue": true
}
}))
.await;
let open_response = take_response_by_id(&mut ctx, 1250);
assert_eq!(
open_response["result"]["result"]["value"],
json!("true|true|before-target-activation|synchronous-document|true|true|true|true")
);
let popup_target_id = ctx
.sent
.iter()
.find(|message| message["method"] == json!("Target.targetCreated"))
.and_then(|message| message["params"]["targetInfo"]["targetId"].as_str())
.expect("window.open should create its auxiliary target")
.to_owned();
ctx.sent.clear();
ctx.process_async(json!({
"id": 1251,
"method": "Target.attachToTarget",
"params": { "targetId": popup_target_id }
}))
.await;
let popup_session_id = take_response_by_id(&mut ctx, 1251)["result"]["sessionId"]
.as_str()
.expect("popup session id")
.to_owned();
ctx.sent.clear();
ctx.process_async(json!({
"id": 1252,
"sessionId": popup_session_id,
"method": "Runtime.evaluate",
"params": {
"expression": "const synchronousDocumentSurvived = window.opener.__lmPopupDocument === document; const synchronousRealmSurvived = __lmSynchronousRealmMarker === 'before-target-activation'; globalThis.__lmAuxiliaryRealmMarker = 'renderer-owned'; document.body.textContent = 'auxiliary-document'; `${synchronousDocumentSurvived}|${synchronousRealmSurvived}|${__lmAuxiliaryRealmMarker}|${document.body.textContent}|${window === globalThis}|${name === ''}|${document.referrer === window.opener.location.href}|${document.baseURI === window.opener.document.baseURI}`",
"returnByValue": true
}
}))
.await;
let popup_evaluate = take_response_by_id(&mut ctx, 1252);
assert_eq!(
popup_evaluate["result"]["result"]["value"],
json!("true|true|renderer-owned|auxiliary-document|true|true|true|true"),
"target adoption must retain the exact Document and realm created synchronously by window.open: {popup_evaluate:?}"
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 1253,
"method": "Runtime.evaluate",
"params": {
"expression": "`${__lmPopupDocument === __lmPopupWindow.document}|${__lmPopupWindow.__lmAuxiliaryRealmMarker}|${__lmPopupDocument.body.textContent}|${__lmPopupWindow === __lmPopupWindow.window}`",
"returnByValue": true
}
}))
.await;
let opener_projection = take_response_by_id(&mut ctx, 1253);
assert_eq!(
opener_projection["result"]["result"]["value"],
json!("true|renderer-owned|auxiliary-document|true"),
"the opener-retained objects must be the auxiliary Page's actual stable WindowProxy and Document: {opener_projection:?}"
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 1254,
"method": "Runtime.evaluate",
"params": {
"expression": "__lmPopupWindow.__lmProjectedFromOpener = 'same-proxy'; true",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 1254)["result"]["result"]["value"],
json!(true)
);
ctx.sent.clear();
ctx.process_async(json!({
"id": 1255,
"sessionId": popup_session_id,
"method": "Runtime.evaluate",
"params": {
"expression": "String(globalThis.__lmProjectedFromOpener)",
"returnByValue": true
}
}))
.await;
assert_eq!(
take_response_by_id(&mut ctx, 1255)["result"]["result"]["value"],
json!("same-proxy")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn window_open_self_navigates_current_target_without_popup_target() {
let mut ctx = TestContext::new();
@@ -3266,6 +3463,12 @@ async fn create_target_without_debugger_wait_starts_requested_url_navigation() {
"Target.createTarget response should retain the created target id: {messages:?}"
);
ctx.wait_for_document_continuation_for_test(
Some(session_id),
"created target requested-URL Document continuation",
)
.await;
ctx.process_async(json!({
"id": 9010,
"sessionId": session_id,
+8 -7
View File
@@ -18,10 +18,10 @@ pub mod version;
pub use devtools_runtime::*;
pub use conn::{
BackgroundCommandResponsePayload, BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection,
CdpInitialStoragePartition, CdpRendererCommandAccess, CdpRendererCommandReplacement,
CdpRendererCommandReplayDispatch, CdpRendererOwnerTurnOutcome, CdpSchedulerEvent,
CdpTargetHostLifecycleDelta, CdpTargetHostLifecycleObserver, CdpTurnOutcome,
BackgroundCommandResponsePayload, BackgroundNavigationGateKey, BackgroundProtocolEvent,
CdpCommandTaskStep, CdpConnection, CdpInitialStoragePartition, CdpRendererCommandAccess,
CdpRendererCommandReplacement, CdpRendererCommandReplayDispatch, CdpRendererOwnerTurnOutcome,
CdpSchedulerEvent, CdpTargetHostLifecycleDelta, CdpTargetHostLifecycleObserver, CdpTurnOutcome,
CommandDispatchContext, CommandResponseFlushContext, CommandResponseFlushPermit,
CompletedCdpCommandDispatch, CompletedDeferredMainDocumentLoadCompletion,
CompletedRuntimeProtocolMessageDispatch, DEFAULT_CDP_PAGE_TARGET_ID, DEFAULT_CDP_TAB_TARGET_ID,
@@ -39,9 +39,10 @@ pub use domains::activity::{
RuntimeCommandOutputBarrierTerminal, RuntimeCommandOutputBarriers,
};
pub use domains::page::{
BackgroundNavigationCompletion, CompletedPageScreencastCapture,
PageScreencastCaptureCompletion, PageScreencastCaptureStart, PageScreencastRegistration,
PageScreencastSubscriptionStatus, PendingPageScreencastCapture, build_default_raster_pdf,
BackgroundDocumentContinuationCompletion, BackgroundNavigationCompletion,
CompletedPageScreencastCapture, PageScreencastCaptureCompletion, PageScreencastCaptureStart,
PageScreencastRegistration, PageScreencastSubscriptionStatus, PendingPageScreencastCapture,
build_default_raster_pdf,
};
pub use domains::runtime::{
CompletedDevToolsRuntimeCommandDispatch, DevToolsRuntimeCommandTaskStep,
+20 -2
View File
@@ -9,11 +9,29 @@
use moli_core::{RendererDocumentLifecycleIdentity, RendererOutputResidenceIdentity};
use crate::{
DeferredMainDocumentLoadCompletionOutputInterest, DeferredMainDocumentLoadObservationId,
ProtocolSchedulerWork,
BackgroundNavigationGateKey, DeferredMainDocumentLoadCompletionOutputInterest,
DeferredMainDocumentLoadObservationId, ProtocolSchedulerWork,
conn::{CdpConnection, DocumentNavigationToken, RendererPageResidenceIdentity},
};
/// Constructs the exact key carried by a synthetic Document-continuation
/// completion in scheduler-only tests.
pub fn background_navigation_gate_key(
target_id: Option<String>,
session_id: Option<String>,
frame_id: String,
loader_id: String,
navigation_request_id: Option<u64>,
) -> BackgroundNavigationGateKey {
BackgroundNavigationGateKey::from_test_parts(
target_id,
session_id,
frame_id,
loader_id,
navigation_request_id,
)
}
/// Opaque exact-token fixture for scheduler tests that need a real
/// target-owned background navigation request.
pub struct BackgroundNavigationRequestFixture {
+310 -31
View File
@@ -4,7 +4,7 @@
//! focused assertions over emitted results, events, and errors.
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::collections::{HashSet, VecDeque};
use std::time::Duration;
use super::conn::{
@@ -53,7 +53,15 @@ pub struct TestContext {
tokio::sync::mpsc::UnboundedSender<crate::domains::page::BackgroundNavigationCompletion>,
background_navigation_completion_rx:
tokio::sync::mpsc::UnboundedReceiver<crate::domains::page::BackgroundNavigationCompletion>,
document_continuation_completion_tx: tokio::sync::mpsc::UnboundedSender<
crate::domains::page::BackgroundDocumentContinuationCompletion,
>,
document_continuation_completion_rx: tokio::sync::mpsc::UnboundedReceiver<
crate::domains::page::BackgroundDocumentContinuationCompletion,
>,
document_continuation_scheduler_explicitly_enabled: bool,
background_navigation_scheduler_enabled: bool,
pending_document_continuation_gates: HashSet<super::conn::BackgroundNavigationGateKey>,
}
struct PendingTestRuntimeDeferredReply {
@@ -209,8 +217,13 @@ impl TestContext {
let (background_event_tx, background_event_rx) = tokio::sync::mpsc::unbounded_channel();
let (background_navigation_completion_tx, background_navigation_completion_rx) =
tokio::sync::mpsc::unbounded_channel();
let (document_continuation_completion_tx, document_continuation_completion_rx) =
tokio::sync::mpsc::unbounded_channel();
conn.set_renderer_publication_sender(renderer_publication_tx);
conn.set_runtime_inspector_response_ready_sender(runtime_inspector_response_ready_tx);
conn.set_document_continuation_completion_sender(
document_continuation_completion_tx.clone(),
);
Self {
conn,
sent: Vec::new(),
@@ -223,7 +236,11 @@ impl TestContext {
background_event_rx,
background_navigation_completion_tx,
background_navigation_completion_rx,
document_continuation_completion_tx,
document_continuation_completion_rx,
document_continuation_scheduler_explicitly_enabled: false,
background_navigation_scheduler_enabled: false,
pending_document_continuation_gates: HashSet::new(),
}
}
@@ -239,6 +256,7 @@ impl TestContext {
if self.background_navigation_scheduler_enabled {
return;
}
self.enable_document_continuation_scheduler_for_test();
self.conn
.set_background_event_sender(self.background_event_tx.clone());
self.conn.set_background_navigation_completion_sender(
@@ -247,6 +265,20 @@ impl TestContext {
self.background_navigation_scheduler_enabled = true;
}
/// Enables only the typed post-commit continuation lane.
///
/// Most compatibility tests keep foreground navigation synchronous through
/// its first stable renderer-owner boundary. Tests that intentionally park
/// author script execution behind Debugger control need the command reply
/// to remain independently routable so the controlling command can resume
/// that same continuation.
pub(crate) fn enable_document_continuation_scheduler_for_test(&mut self) {
self.conn.set_document_continuation_completion_sender(
self.document_continuation_completion_tx.clone(),
);
self.document_continuation_scheduler_explicitly_enabled = true;
}
/// Loads and installs one production-shaped navigation fixture for the
/// exact Page target addressed by `session_id`.
///
@@ -409,6 +441,39 @@ impl TestContext {
let response_start = self.sent.len();
Box::pin(self.process_parsed_command_like_scheduler(&command, true)).await;
Box::pin(self.wait_for_test_command_response(command_id, response_start)).await;
if !self.document_continuation_scheduler_explicitly_enabled
&& test_command_starts_document_navigation(&command)
{
Box::pin(self.wait_for_document_continuation_for_test(
command.session_id(),
"foreground navigation command continuation",
))
.await;
}
}
#[cfg(test)]
pub(crate) async fn wait_for_document_continuation_for_test(
&mut self,
session_id: Option<&str>,
description: &str,
) {
while self
.conn
.has_pending_document_navigation_for_session_owner(session_id)
|| self
.pending_document_continuation_gates
.iter()
.any(|key| key.matches_session_owner(session_id))
{
assert!(
matches!(
Box::pin(self.wait_for_one_test_scheduler_turn()).await,
TestSchedulerTurnOutcome::Processed(_)
),
"test scheduler lost all input while waiting for {description}"
);
}
}
#[cfg(test)]
@@ -687,11 +752,106 @@ impl TestContext {
}
}
/// Mirrors the external-load wait used by the WebDriver adapters for one
/// protocol-neutral navigation command.
///
/// A raw `CdpConnection` returns at the command boundary. The production
/// scheduler then waits for the exact target's typed Document continuation
/// and main-Document load residence before exposing a `wait: Load` result.
/// Direct-command tests must drive that same boundary rather than relying
/// on the renderer owner happening to finish first.
#[cfg(test)]
pub(crate) async fn wait_for_direct_navigation_load_for_test(
&mut self,
context: &crate::devtools_runtime::DevToolsCommandContext,
expected_loader_id: &str,
description: &str,
) {
while self
.conn
.has_inflight_background_navigation_for_devtools_context(context)
|| self
.pending_document_continuation_gates
.iter()
.any(|key| navigation_gate_matches_devtools_context(key, context))
{
assert!(
matches!(
Box::pin(self.wait_for_one_test_scheduler_turn()).await,
TestSchedulerTurnOutcome::Processed(_)
),
"test scheduler lost all external input while waiting for {description}"
);
}
let wait_key = self
.conn
.capture_devtools_document_lifecycle_wait_key(
context,
expected_loader_id,
moli_core::page::RendererDocumentLifecycleMilestone::Load,
)
.unwrap_or_else(|| {
panic!(
"failed to capture the exact load lifecycle waiter for {description}; loader={expected_loader_id}"
)
});
let lifecycle_state = tokio::time::timeout(TEST_SCHEDULER_INPUT_TIMEOUT, async {
loop {
let state = self
.conn
.devtools_document_lifecycle_wait_state(context, &wait_key);
if state != crate::conn::DevToolsDocumentLifecycleWaitState::Pending {
return state;
}
assert!(
matches!(
Box::pin(self.wait_for_one_test_scheduler_turn()).await,
TestSchedulerTurnOutcome::Processed(_)
),
"test scheduler lost all external input while waiting for {description}"
);
}
})
.await
.unwrap_or_else(|_| panic!("timed out waiting for {description}"));
let _ = self
.conn
.release_devtools_document_lifecycle_wait_key(context, &wait_key);
assert_eq!(
lifecycle_state,
crate::conn::DevToolsDocumentLifecycleWaitState::Reached,
"exact load lifecycle did not reach its requested milestone for {description}"
);
let waited = tokio::time::timeout(TEST_SCHEDULER_INPUT_TIMEOUT, async {
while self.pending_protocol_scheduler_work.iter().any(|work| {
work.observes_main_document_load_for_devtools_context(&self.conn, context)
}) {
assert!(
matches!(
Box::pin(self.wait_for_one_test_scheduler_turn()).await,
TestSchedulerTurnOutcome::Processed(_)
),
"test scheduler lost all external input while waiting for {description}"
);
}
})
.await;
if waited.is_err() {
panic!(
"timed out waiting for {description}; pending protocol work={:?}",
self.pending_protocol_scheduler_work
);
}
}
async fn process_parsed_command_like_scheduler(
&mut self,
command: &ParsedCdpCommand,
drain_after_command: bool,
) -> Vec<CdpSchedulerEvent> {
Box::pin(self.wait_for_document_navigation_gate_before_command(command)).await;
let output_session_id = command.command_output_session_id().map(str::to_owned);
let mut protocol_events = Vec::new();
let mut scheduler_events = Vec::new();
@@ -767,6 +927,28 @@ impl TestContext {
}
}
async fn wait_for_document_navigation_gate_before_command(
&mut self,
command: &ParsedCdpCommand,
) {
if !command.waits_for_document_continuation_to_finish() {
return;
}
while self
.pending_document_continuation_gates
.iter()
.any(|key| key.matches_session_owner(command.session_id()))
{
assert!(
matches!(
Box::pin(self.wait_for_one_test_scheduler_turn()).await,
TestSchedulerTurnOutcome::Processed(_)
),
"CDP command waiting for the committed Document continuation lost all scheduler input"
);
}
}
fn admit_runtime_output_barrier(
&mut self,
command: &ParsedCdpCommand,
@@ -1174,6 +1356,12 @@ impl TestContext {
Box::pin(self.route_protocol_events_like_scheduler(prefix, work)).await;
}
if let Some(key) = completion.document_continuation_gate_key() {
assert!(
self.pending_document_continuation_gates.remove(&key),
"document-continuation completion must settle an admitted background navigation gate"
);
}
let outcome = self
.conn
.drain_background_navigation_completion_turn_async(completion)
@@ -1187,8 +1375,8 @@ impl TestContext {
renderer_output_predecessor,
) = outcome.into_renderer_owner_turn_parts();
assert!(
renderer_output_predecessor.is_none(),
"background navigation completion must use an insertion boundary"
renderer_output_boundary.is_none() || renderer_output_predecessor.is_none(),
"background navigation completion cannot carry both an insertion boundary and a continuation predecessor"
);
if !completion_prefix.is_empty() {
Box::pin(self.route_protocol_events_like_scheduler(
@@ -1197,9 +1385,8 @@ impl TestContext {
))
.await;
}
if let Some(boundary) = renderer_output_boundary {
Box::pin(self.route_renderer_output_predecessor_before_command_response(boundary))
.await;
if let Some(fence) = renderer_output_boundary.or(renderer_output_predecessor) {
Box::pin(self.route_renderer_output_predecessor_before_command_response(fence)).await;
}
completion_suffix.append(&mut post_response_events);
while let Ok(event) = self.background_event_rx.try_recv() {
@@ -1215,7 +1402,15 @@ impl TestContext {
async fn run_one_ready_test_scheduler_turn(&mut self) -> TestSchedulerTurnOutcome {
let mut work = VecDeque::new();
let input_kind = if self.background_navigation_scheduler_enabled
let input_kind = if let Ok(completion) = self.document_continuation_completion_rx.try_recv()
{
work.push_back(TestSchedulerWork::BackgroundNavigationCompletion(
crate::domains::page::BackgroundNavigationCompletion::document_continuation(
completion,
),
));
TestSchedulerInputKind::BackgroundNavigationCompletion
} else if self.background_navigation_scheduler_enabled
&& let Ok(completion) = self.background_navigation_completion_rx.try_recv()
{
work.push_back(TestSchedulerWork::BackgroundNavigationCompletion(
@@ -1325,6 +1520,15 @@ impl TestContext {
work.push_back(TestSchedulerWork::RuntimeDeferredReplyReady(response));
TestSchedulerInputKind::RuntimeDeferredReply
}
maybe_continuation = self.document_continuation_completion_rx.recv() => {
let Some(completion) = maybe_continuation else {
return TestSchedulerTurnOutcome::Idle;
};
work.push_back(TestSchedulerWork::BackgroundNavigationCompletion(
crate::domains::page::BackgroundNavigationCompletion::document_continuation(completion),
));
TestSchedulerInputKind::BackgroundNavigationCompletion
}
maybe_completion = self.background_navigation_completion_rx.recv(), if background_navigation_scheduler_enabled => {
let Some(completion) = maybe_completion else {
return TestSchedulerTurnOutcome::Idle;
@@ -1350,6 +1554,15 @@ impl TestContext {
} else {
tokio::select! {
biased;
maybe_continuation = self.document_continuation_completion_rx.recv() => {
let Some(completion) = maybe_continuation else {
return TestSchedulerTurnOutcome::Idle;
};
work.push_back(TestSchedulerWork::BackgroundNavigationCompletion(
crate::domains::page::BackgroundNavigationCompletion::document_continuation(completion),
));
TestSchedulerInputKind::BackgroundNavigationCompletion
}
maybe_completion = self.background_navigation_completion_rx.recv(), if background_navigation_scheduler_enabled => {
let Some(completion) = maybe_completion else {
return TestSchedulerTurnOutcome::Idle;
@@ -1485,7 +1698,17 @@ impl TestContext {
work: &mut VecDeque<TestSchedulerWork>,
) {
let mut queue = VecDeque::new();
enqueue_scheduler_events_like_scheduler(&mut queue, scheduler_events);
for event in scheduler_events {
match event {
CdpSchedulerEvent::DocumentContinuationStarted { key } => {
self.pending_document_continuation_gates.insert(key);
}
CdpSchedulerEvent::ProtocolWorkPublished { work } => {
queue.push_back(TestDeferredSchedulerWork(work));
}
CdpSchedulerEvent::PageScreencastStarted { .. } => {}
}
}
while let Some(TestDeferredSchedulerWork(protocol_work)) = queue.pop_front() {
self.pending_protocol_scheduler_work
.push_back(protocol_work);
@@ -1501,8 +1724,16 @@ impl TestContext {
work: &mut VecDeque<TestSchedulerWork>,
) {
loop {
let can_execute = |work: &ProtocolSchedulerWork| {
self.background_navigation_scheduler_enabled
|| !work.requires_background_navigation_scheduler()
};
let front_is_fixture_parked = self
.pending_protocol_scheduler_work
.front()
.is_some_and(|work| work.is_ready() && !can_execute(work));
let selected_index = match self.pending_protocol_scheduler_work.front() {
Some(front) if front.is_ready() => 0,
Some(front) if front.is_ready() && can_execute(front) => 0,
Some(front)
if front.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction =>
{
@@ -1511,36 +1742,51 @@ impl TestContext {
.iter()
.position(|candidate| {
candidate.is_ready()
&& candidate.is_top_level_location_navigation_owner_action()
&& can_execute(candidate)
&& (candidate.is_top_level_location_navigation_owner_action()
|| candidate.is_command_followup())
})
else {
return;
};
// Production checks the pending load observer out of the
// FIFO while it waits for the renderer. An unconstrained
// location owner action may then run and replace that
// exact source Document, which completes the observer as
// Superseded. Keeping the pending observer at the front in
// this protocol-only harness would deadlock the action
// needed to make it terminal.
// FIFO while it waits for the renderer. A navigation owner
// action may then replace that exact source Document and
// make the observer terminal.
index
}
Some(_) | None => return,
Some(_) if front_is_fixture_parked => {
let Some(index) = self
.pending_protocol_scheduler_work
.iter()
.position(|candidate| candidate.is_ready() && can_execute(candidate))
else {
return;
};
index
}
Some(_) => {
let Some(index) =
self.pending_protocol_scheduler_work
.iter()
.position(|candidate| {
candidate.is_ready()
&& can_execute(candidate)
&& candidate.is_command_followup()
})
else {
return;
};
index
}
None => return,
};
if !self.background_navigation_scheduler_enabled
&& self
.pending_protocol_scheduler_work
.get(selected_index)
.is_some_and(ProtocolSchedulerWork::requires_background_navigation_scheduler)
{
// The default protocol fixture has no owner task lane. Keep
// independent popup navigation resident rather than invoking
// the production function's synchronous fallback while the
// exact renderer cursor is still being projected. Tests that
// assert navigation progress opt into the production-shaped
// background scheduler and drive its typed completions.
return;
}
// The default protocol fixture has no owner task lane. Keep an
// independent popup navigation resident instead of invoking its
// synchronous fallback while the opener cursor is projected, but
// do not let that fixture-only limitation block ready work for a
// different target. Tests that assert popup navigation progress
// opt into the production-shaped background scheduler.
let protocol_work = self
.pending_protocol_scheduler_work
.remove(selected_index)
@@ -1830,6 +2076,29 @@ impl TestContext {
}
}
#[cfg(test)]
fn navigation_gate_matches_devtools_context(
key: &super::conn::BackgroundNavigationGateKey,
context: &crate::devtools_runtime::DevToolsCommandContext,
) -> bool {
if let Some(target_id) = context.target_id.as_ref() {
return key.target_id() == Some(target_id.as_str());
}
key.matches_session_owner(
context
.session_id
.as_ref()
.map(|session_id| session_id.as_str()),
)
}
fn test_command_starts_document_navigation(command: &ParsedCdpCommand) -> bool {
matches!(
command.method(),
"Page.navigate" | "Page.reload" | "Page.navigateToHistoryEntry"
)
}
fn protocol_events_into_messages(events: Vec<BackgroundProtocolEvent>) -> Vec<Value> {
events
.into_iter()
@@ -1925,6 +2194,7 @@ fn enqueue_scheduler_events_like_scheduler(
queue.push_back(TestDeferredSchedulerWork(work));
}
CdpSchedulerEvent::PageScreencastStarted { .. } => {}
CdpSchedulerEvent::DocumentContinuationStarted { key: _ } => {}
}
}
}
@@ -2150,8 +2420,13 @@ mod tests {
let (background_event_tx, background_event_rx) = tokio::sync::mpsc::unbounded_channel();
let (background_navigation_completion_tx, background_navigation_completion_rx) =
tokio::sync::mpsc::unbounded_channel();
let (document_continuation_completion_tx, document_continuation_completion_rx) =
tokio::sync::mpsc::unbounded_channel();
conn.set_renderer_publication_sender(publication_tx.clone());
conn.set_runtime_inspector_response_ready_sender(runtime_response_tx);
conn.set_document_continuation_completion_sender(
document_continuation_completion_tx.clone(),
);
let mut ctx = TestContext {
conn,
sent: Vec::new(),
@@ -2164,7 +2439,11 @@ mod tests {
background_event_rx,
background_navigation_completion_tx,
background_navigation_completion_rx,
document_continuation_completion_tx,
document_continuation_completion_rx,
document_continuation_scheduler_explicitly_enabled: false,
background_navigation_scheduler_enabled: false,
pending_document_continuation_gates: HashSet::new(),
};
let opened = |page_id| {
RendererOutputTransportMessage::from(RendererOutputStreamControl::Opened {
+4 -11
View File
@@ -28,10 +28,10 @@ pub(crate) struct BrowsingContextId {
/// Stable identity of the V8/script execution agent hosting one or more realms.
///
/// The current production policy assigns one agent to each concurrently live
/// top-level Page, while nested realms and same-Page navigation generations
/// reuse that agent. Related auxiliary Pages may share an id only after the
/// selective shared-agent admission path is implemented.
/// Fresh top-level Pages receive a new agent, while nested realms and
/// same-Page navigation generations reuse their Page's agent. An explicitly
/// related auxiliary Page may join its opener's agent; unrelated and
/// opener-suppressed Pages stay isolated.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub(crate) struct ScriptAgentId(u64);
@@ -47,13 +47,9 @@ impl ScriptAgentId {
}
/// Admission scope currently retaining a script agent.
///
/// `RelatedPageGroup` is used only by the Phase 2 feasibility path until
/// auxiliary browsing-context admission becomes a production policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ScriptAgentScope {
PageScriptEnvironment,
#[cfg(test)]
RelatedPageGroup,
}
@@ -61,7 +57,6 @@ impl ScriptAgentScope {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::PageScriptEnvironment => "page-script-environment",
#[cfg(test)]
Self::RelatedPageGroup => "related-page-group",
}
}
@@ -83,7 +78,6 @@ impl BrowsingContextId {
}
}
#[allow(dead_code)]
pub(crate) const fn auxiliary_top_level(value: u64) -> Self {
assert!(value != 0, "auxiliary browsing-context id must be non-zero");
Self {
@@ -96,7 +90,6 @@ impl BrowsingContextId {
self.kind
}
#[cfg(test)]
pub(crate) const fn value(self) -> u64 {
self.value
}
+3 -1
View File
@@ -353,11 +353,13 @@ use self::range_live::{
update_live_ranges_for_text_split,
};
pub(super) use self::runtime_state::finish_context_bootstrap;
pub(crate) use self::runtime_state::inherit_auxiliary_window_viewport_surface;
pub(crate) use self::runtime_state::install_child_window_eval_runtime_state;
pub(crate) use self::runtime_state::install_webassembly_runtime_state;
#[cfg(feature = "wpt-extensions")]
pub(crate) use self::runtime_state::install_wpt_webdriver_runtime_state;
pub(crate) use self::runtime_state::set_window_origin_runtime_state;
pub(crate) use self::runtime_state::window_origin_runtime_state;
pub(crate) use self::runtime_state::{
ORIGINAL_WEBASSEMBLY_COMPILE_ERROR_CONSTRUCTOR_SLOT,
ORIGINAL_WEBASSEMBLY_GLOBAL_VALUE_GETTER_SLOT, ORIGINAL_WEBASSEMBLY_INSTANCE_CONSTRUCTOR_SLOT,
@@ -371,7 +373,7 @@ pub(in crate::context_bootstrap) use self::shared::*;
pub(crate) use self::shared::{
CHILD_BROWSING_CONTEXT_HANDLE_SLOT, DOCUMENT_SELECTION_CHANGE_LISTENER_SLOT,
READABLE_STREAM_CHILD_REALM_HANDLED_REJECTION_SLOT, WINDOW_CUSTOM_ELEMENTS_SLOT,
WINDOW_NAME_SLOT,
WINDOW_NAME_SLOT, WINDOW_OPENER_SLOT,
};
pub(crate) use self::shared::{
RuntimeMessageSourceSecurity, current_runtime_message_agent_cluster,
@@ -23,8 +23,8 @@ use super::navigation_result::{
};
use super::navigation_serialize::sync_child_navigation_entry_seed_from_owner;
use super::navigation_window::{
runtime_window_is_global, runtime_window_owner, window_location_for_holder,
window_navigation_for_holder,
navigation_document_is_initial_empty, runtime_window_is_global, runtime_window_owner,
url_is_about_blank_document, window_location_for_holder, window_navigation_for_holder,
};
use super::*;
use crate::webidl;
@@ -140,7 +140,7 @@ fn mutate_history_object<'s>(
);
return;
};
if !moli_url::same_origin(&url, &current_url) {
if !history_target_is_same_origin(scope, owner, &current_href, &current_url, &url) {
throw_history_security_error(
scope,
"Failed to execute 'pushState' or 'replaceState' on 'History': A history state object with URL of a different origin cannot be created in a document with origin.",
@@ -178,6 +178,18 @@ fn mutate_history_object<'s>(
navigate_outcome = Some(outcome);
}
// The initial empty Document remains initial across same-document URL
// mutations. The URL and history update steps therefore convert pushState
// to replacement until a different Document commits (or document.open()
// explicitly exits the initial state).
let kind = if matches!(kind, HistoryMutationKind::Push)
&& navigation_document_is_initial_empty(scope, owner)
{
HistoryMutationKind::Replace
} else {
kind
};
let entries = history_entries(scope, history).unwrap_or_else(|| v8::Array::new(scope, 0));
let current_index = history_index(scope, history);
let current_navigation_index = navigation_current_entry_index(scope, owner).unwrap_or(0);
@@ -350,18 +362,49 @@ fn history_same_origin_reference_url<'s>(
if history_url_inherits_origin(scope, owner, current_href)
&& let Some(host_ptr) = context_host_ptr_from_global_bridge(scope)
{
return Ok(unsafe { &mut *host_ptr }.host_document().url().clone());
let host = unsafe { &mut *host_ptr };
return Ok(host
.dom_host()
.document_base_url()
.unwrap_or_else(|| host.host_document().url().clone()));
}
url::Url::parse(current_href)
}
fn history_target_is_same_origin<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
current_href: &str,
current_url: &url::Url,
target_url: &url::Url,
) -> bool {
if !history_url_inherits_origin(scope, owner, current_href) {
return moli_url::same_origin(target_url, current_url);
}
let Some(current_origin) = window_origin_runtime_state(scope, owner) else {
return moli_url::same_origin(target_url, current_url);
};
if url_is_about_blank_document(target_url) {
// An initial about:blank Document and its same-document URL variants
// retain the creator origin, including a shared opaque identity that
// cannot be reconstructed from either serialized URL.
return true;
}
moli_url::origin_ascii_serialization(target_url) == current_origin
}
fn history_url_inherits_origin<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
current_href: &str,
) -> bool {
!runtime_window_is_global(scope, owner)
&& matches!(current_href, "about:blank" | "about:srcdoc")
let current_url_inherits_origin = current_href == "about:srcdoc"
|| url::Url::parse(current_href)
.ok()
.is_some_and(|url| url_is_about_blank_document(&url));
current_url_inherits_origin
&& (!runtime_window_is_global(scope, owner)
|| navigation_document_is_initial_empty(scope, owner))
}
fn throw_history_security_error(scope: &mut v8::PinScope<'_, '_>, message: &str) {
@@ -36,9 +36,10 @@ use super::navigation_seed::history_entry_seed_for_reload;
use super::navigation_serialize::serialize_history_entries;
use super::navigation_window::{
child_browsing_context_handle_for_runtime_owner, navigation_document_can_update_current_entry,
navigation_document_has_opaque_origin, navigation_unload_event_active,
runtime_window_is_global, runtime_window_owner, runtime_window_uses_top_level_history_model,
url_is_about_blank_document, window_history_for_holder, window_location_for_holder,
navigation_document_has_opaque_origin, navigation_document_is_initial_empty,
navigation_unload_event_active, runtime_window_is_global, runtime_window_owner,
runtime_window_uses_top_level_history_model, url_is_about_blank_document,
window_history_for_holder, window_location_for_holder,
};
use super::*;
use crate::native_bridge::NavigationHistoryEntrySeed;
@@ -305,6 +306,11 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
super::navigation_window::window_navigation_for_holder(scope, owner)
};
let effective_kind = match kind {
LocationNavigationKind::Assign
if navigation_document_is_initial_empty(scope, owner) =>
{
LocationNavigationKind::Replace
}
LocationNavigationKind::Assign if source_element.is_some() && exact_same_href => {
LocationNavigationKind::Replace
}
@@ -1193,6 +1199,9 @@ fn history_entry_seed_for_cross_document_location<'s>(
let current_index = history_index(scope, history);
let current_navigation_index = navigation_current_entry_index(scope, owner).unwrap_or(0);
let mutation = match kind {
LocationNavigationKind::Assign if navigation_document_is_initial_empty(scope, owner) => {
NavigationHistoryMutation::Replace
}
LocationNavigationKind::Assign => NavigationHistoryMutation::Push,
LocationNavigationKind::Replace => NavigationHistoryMutation::Replace,
LocationNavigationKind::Reload => return None,
@@ -1,6 +1,7 @@
use super::super::navigation_window::{
navigation_document_has_opaque_origin, runtime_window_owner,
};
use super::super::window_origin_runtime_state;
use super::helpers::{
location_host_string, navigate_modified_location_url, parsed_location_url,
require_location_href_slot, set_return_string, v8_value_to_string,
@@ -355,7 +356,14 @@ fn location_attribute_getter<'s>(
"null".to_owned()
} else {
parsed_location_url(scope, holder)
.map(|url| moli_url::origin_ascii_serialization(&url))
.map(|url| {
if moli_url::is_about_blank(&url) {
window_origin_runtime_state(scope, owner)
.unwrap_or_else(|| moli_url::origin_ascii_serialization(&url))
} else {
moli_url::origin_ascii_serialization(&url)
}
})
.unwrap_or_default()
};
set_return_string(scope, rv, &origin);
@@ -15,8 +15,8 @@ use super::navigation_serialize::{
serialize_navigation_entry_object,
};
use super::navigation_window::{
child_browsing_context_handle_for_runtime_owner, runtime_window_is_global,
window_location_for_holder,
child_browsing_context_handle_for_runtime_owner, navigation_document_is_initial_empty,
runtime_window_is_global, window_location_for_holder,
};
use super::*;
use moli_page_types::{NavigationHistoryMutation, cross_document_navigation_seed};
@@ -53,11 +53,17 @@ pub(super) fn handle_navigation_navigate_cross_document<'s>(
.iter()
.find(|entry| entry.history_index == current_index)
.is_some_and(|entry| entry.url == "about:blank");
let replace_initial_empty_document = navigation_document_is_initial_empty(scope, owner);
let mutation = match navigate_history_kind {
NavigationNavigateHistoryKind::Push if replace_initial_empty_document => {
NavigationHistoryMutation::Replace
}
NavigationNavigateHistoryKind::Push => NavigationHistoryMutation::Push,
NavigationNavigateHistoryKind::Replace => NavigationHistoryMutation::Replace,
NavigationNavigateHistoryKind::Default => {
if current_href.as_deref() == Some("about:blank") && current_entry_is_about_blank {
if replace_initial_empty_document
|| (current_href.as_deref() == Some("about:blank") && current_entry_is_about_blank)
{
NavigationHistoryMutation::Replace
} else {
NavigationHistoryMutation::Push
@@ -29,8 +29,8 @@ use super::navigation_serialize::{
sync_child_pending_navigation_entry_seed_from_owner,
};
use super::navigation_window::{
runtime_top_window_owner, runtime_window_is_global, window_history_for_holder,
window_location_for_holder, window_navigation_for_holder,
navigation_document_is_initial_empty, runtime_top_window_owner, runtime_window_is_global,
window_history_for_holder, window_location_for_holder, window_navigation_for_holder,
};
use super::*;
use crate::native_bridge::NavigationActivationSeed;
@@ -4,8 +4,13 @@ pub(in crate::context_bootstrap) fn update_navigation_current_entry_for_same_doc
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
href: &str,
kind: LocationNavigationKind,
mut kind: LocationNavigationKind,
) {
if matches!(kind, LocationNavigationKind::Assign)
&& navigation_document_is_initial_empty(scope, owner)
{
kind = LocationNavigationKind::Replace;
}
let Some(history) = window_history_for_holder(scope, owner) else {
return;
};
@@ -130,9 +135,14 @@ pub(in crate::context_bootstrap) fn apply_navigation_navigate_same_document<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
href: &str,
kind: LocationNavigationKind,
mut kind: LocationNavigationKind,
navigation_state: Option<v8::Local<'s, v8::Value>>,
) {
if matches!(kind, LocationNavigationKind::Assign)
&& navigation_document_is_initial_empty(scope, owner)
{
kind = LocationNavigationKind::Replace;
}
let Some(history) = window_history_for_holder(scope, owner) else {
return;
};
@@ -44,6 +44,15 @@ pub(super) fn runtime_window_is_global<'s>(
}
}
pub(super) fn navigation_document_is_initial_empty<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
) -> bool {
runtime_window_is_global(scope, owner)
&& context_host_ptr_from_global_bridge(scope)
.is_some_and(|host_ptr| unsafe { &*host_ptr }.root_document_is_initial_empty())
}
pub(super) fn runtime_window_uses_top_level_history_model<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
@@ -60,6 +60,13 @@ pub(crate) const ORIGINAL_WEBASSEMBLY_GLOBAL_VALUE_GETTER_SLOT: &str =
const WINDOW_INDEXED_DB_SURFACE_SLOT: &str = "moli.Window.indexedDB";
const WINDOW_ORIGIN_RUNTIME_SLOT: &str = "__moliWindowOriginRuntime";
const WINDOW_INTRINSIC_EVAL_SLOT: &str = "__moliWindowIntrinsicEval";
const AUXILIARY_WINDOW_VIEWPORT_SURFACE_PROPERTIES: &[&str] = &[
"innerWidth",
"innerHeight",
"outerWidth",
"outerHeight",
"devicePixelRatio",
];
pub(in crate::context_bootstrap) const WINDOW_SECURE_CONTEXT_AVAILABLE_SLOT: &str =
"__moliWindowSecureContextAvailable";
@@ -2060,6 +2067,30 @@ pub(crate) fn set_window_origin_runtime_state(
Ok(())
}
pub(crate) fn inherit_auxiliary_window_viewport_surface<'s>(
scope: &mut v8::PinScope<'s, '_>,
opener: v8::Local<'s, v8::Object>,
window: v8::Local<'s, v8::Object>,
) {
for property in AUXILIARY_WINDOW_VIEWPORT_SURFACE_PROPERTIES {
let Some(value) = opener.get(scope, v8str(scope, property).into()) else {
continue;
};
if value.is_number() {
native_bridge::set_object_slot(scope, window, property, value);
}
}
}
pub(crate) fn window_origin_runtime_state<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
) -> Option<String> {
get_private_value(scope, window, WINDOW_ORIGIN_RUNTIME_SLOT)
.and_then(|value| value.to_string(scope))
.map(|value| value.to_rust_string_lossy(scope))
}
pub(crate) fn install_webassembly_runtime_state<'s>(
scope: &mut v8::PinScope<'s, '_>,
global: v8::Local<'s, v8::Object>,
@@ -30,6 +30,7 @@ pub(in crate::context_bootstrap) const WINDOW_SESSION_STORAGE_SLOT: &str =
"__moliWindowSessionStorage";
pub(crate) const WINDOW_CUSTOM_ELEMENTS_SLOT: &str = "__moliWindowCustomElements";
pub(crate) const WINDOW_NAME_SLOT: &str = "__moliWindowName";
pub(crate) const WINDOW_OPENER_SLOT: &str = "__moliWindowOpener";
pub(crate) const CHILD_BROWSING_CONTEXT_HANDLE_SLOT: &str = "__moliChildBrowsingContextHandle";
pub(in crate::context_bootstrap) const WINDOW_SELF_SLOT: &str = "__moliWindowSelf";
pub(in crate::context_bootstrap) const WINDOW_PARENT_SLOT: &str = "__moliWindowParent";
@@ -104,9 +104,13 @@ pub(in crate::context_bootstrap) fn window_opener_getter<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
if window_receiver(scope, &args).is_some() {
rv.set_null();
}
let Some(receiver) = window_receiver(scope, &args) else {
return;
};
rv.set(
window_hidden_value(scope, receiver, WINDOW_OPENER_SLOT)
.unwrap_or_else(|| v8::null(scope).into()),
);
}
pub(in crate::context_bootstrap) fn window_inner_width_getter<'s>(
@@ -244,14 +244,21 @@ pub(crate) fn window_open_callback<'s>(
opener_child_handle,
&parsed.target_name,
&url,
Some(!suppress_opener),
entered_base_url,
creator_policy_container,
)
{
let popup_id = opened_popup.popup_id;
let session_storage_store = host.lightweight_popup_session_storage_store(popup_id);
let initial_empty_document_storage_key =
host.lightweight_popup_initial_empty_document_storage_key(popup_id);
let session_storage_store = opened_popup
.captured_session_storage_store
.clone()
.or_else(|| host.lightweight_popup_session_storage_store(popup_id));
let initial_empty_document_storage_key = opened_popup
.captured_initial_empty_document_storage_key
.clone()
.or_else(|| host.lightweight_popup_initial_empty_document_storage_key(popup_id));
let pending_auxiliary_page = opened_popup.pending_auxiliary_page;
let window_open_event = opened_popup
.created_new_browsing_context
.then_some(window_open_event);
@@ -265,10 +272,8 @@ pub(crate) fn window_open_callback<'s>(
parsed.target_name,
popup_disposition,
)
.with_initial_auxiliary_state(
session_storage_store,
initial_empty_document_storage_key,
),
.with_initial_auxiliary_state(session_storage_store, initial_empty_document_storage_key)
.with_pending_auxiliary_page(pending_auxiliary_page),
window_open_event,
);
if suppress_opener {
@@ -503,6 +503,15 @@ impl DocumentRuntime {
self.policy_container = policy;
}
/// Installs the creator-derived policy state for a real initial-empty
/// Document before its realm becomes observable to author code.
pub(crate) fn set_initial_document_policy_container(
&mut self,
policy_container: DocumentPolicyContainer,
) {
self.policy_container = policy_container;
}
pub(crate) fn set_bypass_content_security_policy(&mut self, bypass: bool) {
self.bypass_content_security_policy = bypass;
}
+6 -2
View File
@@ -310,12 +310,16 @@ fn host_lightweight_popup_id_for_object_callback(
args: v8::FunctionCallbackArguments<'_>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Ok(host_ptr) = context_host_ptr_from_callback_data(args.data()) else {
rv.set_null();
return;
};
let Some(object) = args.get(0).to_object(scope) else {
rv.set_null();
return;
};
let Some(popup_id) = crate::native_bridge::lightweight_popup_id_from_window(scope, object)
else {
let host = unsafe { &mut *host_ptr };
let Some(popup_id) = host.lightweight_popup_id_for_window_proxy(scope, object) else {
rv.set_null();
return;
};
+5 -2
View File
@@ -231,6 +231,7 @@ pub use runtime::{
RendererDevToolsAgentToken, RendererDevToolsMainCommandEnvelope, RendererDocumentBoxModel,
RendererDocumentChildNodeSnapshotEvent, RendererDocumentChildNodeSnapshotEvents,
RendererDocumentChildNodeSnapshots, RendererDocumentCommitPermit,
RendererDocumentContinuationCompletion, RendererDocumentContinuationObserver,
RendererDocumentFrontendNodeIdsResolution, RendererDocumentHitTestResult,
RendererDocumentIsolateAccountingDiagnostics, RendererDocumentLifecycleEvent,
RendererDocumentLifecycleEventKind, RendererDocumentLifecycleIdentity,
@@ -276,8 +277,10 @@ pub use runtime::{
RendererPageCookieFacadeSnapshotReply, RendererPageCreationArtifacts,
RendererPageCreationDiagnostics, RendererPageCreationPhase, RendererPageCreationProgress,
RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, RendererPageDumpOptions,
RendererPageDumpStripOptions, RendererPageHandle, RendererPageReply,
RendererPageReservationToken, RendererPageState, RendererPageTestingHandle, RendererPageView,
RendererPageDumpStripOptions, RendererPageHandle, RendererPageReplacementCommit,
RendererPageReplacementCommitError, RendererPageReplacementCommitFailureDisposition,
RendererPageReplacementReservationPending, RendererPageReply, RendererPageReservationToken,
RendererPageState, RendererPageTestingHandle, RendererPageView, RendererPendingAuxiliaryPage,
RendererPendingDownloadActivation, RendererPendingDownloadResponse,
RendererPendingFileChooserActivation, RendererPendingJavaScriptDialog,
RendererPendingPopupActivation, RendererPendingSameDocumentNavigation,
@@ -190,6 +190,26 @@ impl NativeBridgeBindings {
v8::Local::new(scope, &self.window_global_template)
}
/// Builds per-Context bridge bindings while the owning isolate is already
/// entered by an outer native callback.
///
/// Related auxiliary Pages share the opener's script-agent isolate but
/// require independent wrapper templates for their own `JsContextHost`.
/// Reusing only the isolate-level Window templates is intentional; every
/// mutable bridge/cache object below that boundary is rebuilt per realm.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn build_peer_in_scope(&self, scope: &mut v8::PinScope<'_, '_>) -> Self {
let window_global_template = v8::Local::new(scope, &self.window_global_template);
let cross_origin_window_global_template =
v8::Local::new(scope, &self.cross_origin_window_global_template);
Self::build(
scope,
self.isolate_ptr,
window_global_template,
cross_origin_window_global_template,
)
}
/// Install the native bridge global object with two internal fields:
/// - field 0: `*mut JsContextHost` (for `runtime_ptr_from_object` compatibility)
/// - field 1: `*const RefCell<JsContextHost>` from the per-context bridge-ref token
@@ -353,6 +373,40 @@ impl NativeBridgeBindings {
(global, v8::Global::new(scope, context))
}
pub(super) fn instantiate_same_origin_window_proxy_shell<'s, 'i>(
&mut self,
scope: &mut v8::PinScope<'s, 'i>,
host_ptr: *mut JsContextHost,
) -> (v8::Local<'s, v8::Object>, v8::Global<v8::Context>) {
// An accepted same-agent popup needs a real V8 global proxy before
// protocol has materialized its auxiliary Page. A temporary context
// owns that proxy synchronously; the related Page later detaches it
// and supplies the exact object as ContextOptions::global_object.
let global_template = v8::Local::new(scope, &self.window_global_template);
let parent_security_token = scope.get_current_context().get_security_token(scope);
let context = v8::Context::new(
scope,
v8::ContextOptions {
global_template: Some(global_template),
..Default::default()
},
);
context.set_security_token(parent_security_token);
crate::util::install_context_host_pointer_slot(context, host_ptr);
{
let facade_scope = &mut v8::ContextScope::new(scope, context);
let global = context.global(facade_scope);
crate::context_bootstrap::exposed_interfaces::capture_eager_intrinsic_interfaces(
facade_scope,
global,
crate::context_bootstrap::exposed_interfaces::RealmKind::Window,
)
.expect("same-origin WindowProxy facade must initialize intrinsic interfaces");
}
let global = context.global(scope);
(global, v8::Global::new(scope, context))
}
pub(super) fn attach_window_proxy_shell_to_facade<'s, 'i>(
&self,
scope: &mut v8::PinScope<'s, 'i, ()>,
@@ -350,7 +350,7 @@ impl ChildWindowProxyRecords {
) -> bool {
// A replacement Document reuses the browsing-context handle. Its context
// binding must survive delayed retirement of the previous LocalWindow.
let Some(record) = self.records.get_mut(&handle) else {
let Some(record) = self.existing_record_mut(handle) else {
return false;
};
if record.default_execution_context_id != Some(expected_execution_context_id) {
@@ -76,6 +76,78 @@ impl JsContextHost {
}
}
pub(crate) fn bind_page_script_environment(
&mut self,
environment: crate::script_vm::RendererPageScriptEnvironment,
) {
assert!(
self.page_script_environment.is_none(),
"context host Page script environment must be bound only once"
);
self.bind_auxiliary_page_reservation_allocator(
environment.auxiliary_page_reservation_allocator(),
);
self.page_script_environment = Some(environment);
}
pub(crate) fn bind_auxiliary_page_reservation_allocator(
&mut self,
allocator: RendererAuxiliaryPageReservationAllocator,
) {
assert!(
self.auxiliary_page_reservation_allocator
.replace(allocator)
.is_none(),
"context host auxiliary Page allocator must be bound only once"
);
}
pub(crate) fn reserve_pending_auxiliary_page(
&self,
exposes_opener: bool,
) -> Option<RendererPendingAuxiliaryPage> {
self.auxiliary_page_reservation_allocator
.as_ref()
.map(|allocator| allocator.reserve(exposes_opener))
}
pub(crate) fn stage_pending_auxiliary_window_proxy(
&self,
scope: &mut v8::PinScope<'_, '_>,
pending: RendererPendingAuxiliaryPage,
window_proxy: v8::Global<v8::Object>,
facade_context: v8::Global<v8::Context>,
inherits_creator_security_token: bool,
) -> anyhow::Result<()> {
let inherited_security_token = inherits_creator_security_token.then(|| {
let facade_context = v8::Local::new(scope, &facade_context);
v8::Global::new(scope, facade_context.get_security_token(scope))
});
self.auxiliary_page_reservation_allocator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("context host is missing its auxiliary Page allocator"))?
.stage_related_window_proxy(
pending,
RendererStagedAuxiliaryWindowProxy::new(
window_proxy,
facade_context,
inherited_security_token,
),
)
}
pub(crate) fn stage_related_initial_empty_page_in_scope(
&self,
scope: &mut v8::PinScope<'_, '_>,
pending: RendererPendingAuxiliaryPage,
init: crate::runtime::RendererRelatedInitialEmptyPageRealmInit,
) -> anyhow::Result<()> {
self.page_script_environment
.as_ref()
.ok_or_else(|| anyhow::anyhow!("context host is missing its Page script environment"))?
.stage_related_initial_empty_page_in_scope(scope, &self.bridge.bindings, pending, init)
}
pub(crate) fn install_page_default_context(
&mut self,
scope: &mut v8::PinScope<'_, '_, ()>,
@@ -209,7 +281,10 @@ impl JsContextHost {
#[cfg(test)]
force_fresh_layout_reads_for_test: false,
root_document_lifecycle: None,
root_document_is_initial_empty: false,
output_journal: None,
auxiliary_page_reservation_allocator: None,
page_script_environment: None,
page_context_resources_closed: false,
page_default_context: None,
v8_finalizers: crate::v8_finalizer::V8FinalizerRegistry::default(),
@@ -484,6 +559,18 @@ impl JsContextHost {
self.root_document_lifecycle = Some(lifecycle);
}
pub(crate) fn mark_root_document_initial_empty(&mut self) {
debug_assert!(
moli_url::is_about_blank(self.document_url()),
"only an about:blank root Document can be marked initial empty"
);
self.root_document_is_initial_empty = true;
}
pub(crate) fn root_document_is_initial_empty(&self) -> bool {
self.root_document_is_initial_empty
}
/// Returns the exact root Document that owns Page-scoped protocol
/// handoffs produced by this host.
///
@@ -499,6 +586,11 @@ impl JsContextHost {
}
pub(crate) fn open_root_document(&mut self, scope: &mut v8::PinScope<'_, '_>) {
// Blink's Document::open() explicitly makes an initial empty Document
// cease to be initial. Keep this state on the Document owner rather
// than inferring it from the current URL, which same-document
// navigation is allowed to change.
self.root_document_is_initial_empty = false;
let descendant_count_before = self.child_browsing_contexts.len();
for child_handle in self.top_level_child_browsing_context_handles_in_document_order() {
self.drop_child_browsing_context_subtree_with_window_realm(scope, child_handle);
@@ -29,8 +29,10 @@ use crate::{
reflector::ReflectorId,
renderer_resource_scheduler::RendererResourceScheduler,
runtime::{
RendererBrowserContextRuntime, RendererDocumentLifecycleJournalHandle,
RendererPageContextCancelReceiver, SharedRendererBackendNodeRegistry,
RendererAuxiliaryPageReservationAllocator, RendererBrowserContextRuntime,
RendererDocumentLifecycleJournalHandle, RendererPageContextCancelReceiver,
RendererPendingAuxiliaryPage, RendererStagedAuxiliaryWindowProxy,
SharedRendererBackendNodeRegistry,
},
script_provenance::CompiledStringProvenance,
shared_worker_runtime::SharedWorkerClientEndpointOwner,
@@ -208,6 +210,7 @@ pub(crate) use popups::{
enter_top_level_lightweight_popup_scope, javascript_url_csp_source,
lightweight_popup_id_from_window, restore_active_lightweight_popup_scope,
restore_deferred_active_lightweight_popup_scope_if_present,
set_renderer_owned_auxiliary_popup_id,
};
pub(crate) use range_records::{RangeBoundarySide, RangeRecordHandle};
pub(crate) use runtime_observable::{
@@ -810,7 +813,10 @@ pub(crate) struct JsContextHost {
#[cfg(test)]
force_fresh_layout_reads_for_test: bool,
root_document_lifecycle: Option<RendererDocumentLifecycleJournalHandle>,
root_document_is_initial_empty: bool,
output_journal: Option<crate::runtime::RendererTurnOutputJournal>,
auxiliary_page_reservation_allocator: Option<RendererAuxiliaryPageReservationAllocator>,
page_script_environment: Option<crate::script_vm::RendererPageScriptEnvironment>,
page_context_resources_closed: bool,
page_default_context: Option<v8::Weak<v8::Context>>,
pub(crate) v8_finalizers: crate::v8_finalizer::V8FinalizerRegistry,
@@ -4,8 +4,9 @@ use super::{
use crate::{
content_security_policy::content_security_policy_forces_opaque_origin,
context_bootstrap::{
SharedWebStorageStore, WINDOW_NAME_SLOT, apply_local_window_location_navigation,
deep_clone_shared_web_storage_store, dispatch_simple_event_target_event,
SharedWebStorageStore, WINDOW_NAME_SLOT, WINDOW_OPENER_SLOT,
apply_local_window_location_navigation, deep_clone_shared_web_storage_store,
dispatch_simple_event_target_event, inherit_auxiliary_window_viewport_surface,
install_navigation_bootstrap_entry_for_holder, install_simple_event_target_methods,
install_storage_aliases_for_window,
install_window_location_history_navigation_runtime_state, new_shared_web_storage_store,
@@ -32,7 +33,10 @@ use crate::{
set_object_value,
},
},
runtime::RendererPendingPopupActivation,
runtime::{
PageVmEnvConfig, RendererPendingAuxiliaryPage, RendererPendingPopupActivation,
RendererRelatedInitialEmptyPageRealmInit, RendererStagedAuxiliaryWindowProxy,
},
types::{
LoadedChildDocument, LoadedChildScriptSource, PopupClassicScriptLoadCompletion,
PopupDocumentLoadCompletion, PopupDocumentLoadOutcome,
@@ -58,6 +62,7 @@ use url::Url;
const LIGHTWEIGHT_POPUP_EVENT_LISTENERS_SLOT: &str = "__lmLightweightPopupEventListeners";
const LIGHTWEIGHT_POPUP_ID_SLOT: &str = "__lmLightweightPopupId";
const RENDERER_OWNED_AUXILIARY_POPUP_ID_SLOT: &str = "__lmRendererOwnedAuxiliaryPopupId";
const ACTIVE_LIGHTWEIGHT_POPUP_ID_SLOT: &str = "__lmActiveLightweightPopupId";
const LIGHTWEIGHT_POPUP_JAVASCRIPT_POPUP_ID_SLOT: &str = "__lmLightweightPopupJavascriptPopupId";
const LIGHTWEIGHT_POPUP_JAVASCRIPT_DOCUMENT_ID_SLOT: &str =
@@ -67,14 +72,6 @@ const LIGHTWEIGHT_POPUP_JAVASCRIPT_NAVIGATION_ID_SLOT: &str =
const LIGHTWEIGHT_POPUP_JAVASCRIPT_SOURCE_SLOT: &str = "__lmLightweightPopupJavascriptSource";
const LIGHTWEIGHT_POPUP_DOCUMENT_WRITE_SESSION_SLOT: &str =
"__lmLightweightPopupDocumentWriteSession";
const LIGHTWEIGHT_POPUP_VIEWPORT_SURFACE_PROPERTIES: &[&str] = &[
"innerWidth",
"innerHeight",
"outerWidth",
"outerHeight",
"devicePixelRatio",
];
/// Whether applying a popup response entered author code or an event-dispatch
/// algorithm before returning to the enclosing Page resource task.
///
@@ -413,6 +410,9 @@ pub(crate) struct OpenedLightweightPopup<'scope> {
pub(crate) window: v8::Local<'scope, v8::Object>,
pub(crate) popup_id: u64,
pub(crate) created_new_browsing_context: bool,
pub(crate) pending_auxiliary_page: Option<RendererPendingAuxiliaryPage>,
pub(crate) captured_session_storage_store: Option<SharedWebStorageStore>,
pub(crate) captured_initial_empty_document_storage_key: Option<MoliStorageKey>,
}
fn inherit_lightweight_popup_opener_sandbox(
@@ -609,6 +609,7 @@ impl JsContextHost {
opener_child_handle: Option<DomHandle>,
target_name: &str,
href: &str,
auxiliary_page_exposes_opener: Option<bool>,
creator_base_url: Url,
creator_policy_container: DocumentPolicyContainer,
) -> Option<OpenedLightweightPopup<'s>> {
@@ -630,24 +631,184 @@ impl JsContextHost {
window,
popup_id,
created_new_browsing_context: false,
pending_auxiliary_page: None,
captured_session_storage_store: None,
captured_initial_empty_document_storage_key: None,
});
}
let (window, popup_id) = self.create_lightweight_popup_window(
let pending_auxiliary_page = auxiliary_page_exposes_opener
.and_then(|exposes_opener| self.reserve_pending_auxiliary_page(exposes_opener));
let stages_same_agent_window_proxy =
pending_auxiliary_page.is_some() && auxiliary_page_exposes_opener == Some(true);
let opener_sandbox_policy = opener_child_handle
.and_then(|handle| self.child_browsing_context_popup_opener_sandbox_policy(handle));
let inherits_creator_security_token =
!opener_sandbox_policy.is_some_and(|policy| policy.forces_opaque_origin);
if let (Some(pending_auxiliary_page), Some(opener)) = (pending_auxiliary_page, opener)
&& auxiliary_page_exposes_opener == Some(true)
&& trackable_lightweight_popup_window_name(target_name).is_none()
&& let Ok(requested_url) = Url::parse(href)
&& requested_url.scheme() != "javascript"
{
return self.create_renderer_owned_initial_empty_window(
scope,
host_ptr,
opener,
opener_child_handle,
requested_url,
pending_auxiliary_page,
opener_sandbox_policy,
creator_base_url.clone(),
creator_policy_container.clone(),
inherits_creator_security_token,
);
}
let (window, popup_id, facade_context) = self.create_lightweight_popup_window(
scope,
host_ptr,
opener,
opener_child_handle,
target_name,
href,
opener_child_handle
.and_then(|handle| self.child_browsing_context_popup_opener_sandbox_policy(handle)),
stages_same_agent_window_proxy,
opener_sandbox_policy,
creator_base_url,
creator_policy_container,
)?;
if let (Some(pending_auxiliary_page), Some(facade_context)) =
(pending_auxiliary_page, facade_context)
{
self.stage_pending_auxiliary_window_proxy(
scope,
pending_auxiliary_page,
v8::Global::new(scope, window),
facade_context,
inherits_creator_security_token,
)
.expect("related auxiliary Page must stage its exact opener WindowProxy");
}
Some(OpenedLightweightPopup {
window,
popup_id,
created_new_browsing_context: true,
pending_auxiliary_page,
captured_session_storage_store: None,
captured_initial_empty_document_storage_key: None,
})
}
#[allow(clippy::too_many_arguments)]
fn create_renderer_owned_initial_empty_window<'s>(
&mut self,
scope: &mut v8::PinScope<'s, '_>,
host_ptr: *mut JsContextHost,
opener: v8::Local<'s, v8::Object>,
opener_child_handle: Option<DomHandle>,
requested_url: Url,
pending_auxiliary_page: RendererPendingAuxiliaryPage,
opener_sandbox_policy: Option<DocumentSandboxPolicy>,
creator_base_url: Url,
mut creator_policy_container: DocumentPolicyContainer,
inherits_creator_security_token: bool,
) -> Option<OpenedLightweightPopup<'s>> {
let initial_url = if moli_url::is_about_blank(&requested_url) {
requested_url
} else {
// A non-empty destination is not the initial Document. It remains
// on the immutable popup activation until target admission, then
// the auxiliary Page owner starts its one replacement navigation.
about_blank_url()
};
let initial_base_url = lightweight_popup_initial_base_url(&initial_url, creator_base_url);
inherit_lightweight_popup_opener_sandbox(
&mut creator_policy_container.sandbox,
opener_sandbox_policy,
);
let creator_resource_authority = self
.document_resource_loader_for_dispatch_scope(self.entered_owner_dispatch_scope(scope))?
.clone();
let storage_scope = self.lightweight_popup_storage_scope_for_initiated_navigation(
scope,
Some(opener),
opener_child_handle,
&initial_url,
opener_sandbox_policy.is_some_and(|policy| policy.forces_opaque_origin),
);
let session_storage_store = self.cloned_lightweight_popup_session_storage_store(
scope,
opener,
opener_child_handle,
&storage_scope,
);
let (window, facade_context) = self
.bridge
.bindings
.instantiate_same_origin_window_proxy_shell(scope, host_ptr);
let inherited_security_token = inherits_creator_security_token.then(|| {
let facade_context = v8::Local::new(scope, &facade_context);
v8::Global::new(scope, facade_context.get_security_token(scope))
});
let staged_window_proxy = RendererStagedAuxiliaryWindowProxy::new(
v8::Global::new(scope, window),
facade_context,
inherited_security_token,
);
let mut dom_host = crate::dom::native::DomHost::from_dom(
crate::parser::HtmlParser::with_scripting_enabled(
creator_policy_container.sandbox.allows_scripts,
)
.parse(
initial_url.clone(),
"<!doctype html><html><head></head><body></body></html>".to_owned(),
),
);
let document_handle = dom_host.document_handle();
let _ = dom_host.set_document_fallback_base_url_for_handle(
document_handle,
Some(initial_base_url.clone()),
);
let env = PageVmEnvConfig::related_initial_empty(
crate::RendererWebStorageHandles::new(
self.web_storage_store(),
session_storage_store.clone(),
),
storage_scope.storage_key().clone(),
&creator_policy_container,
self.indexed_db_manager(),
self.storage_bucket_store(),
);
let popup_id = self.next_lightweight_popup_id;
self.next_lightweight_popup_id = self.next_lightweight_popup_id.wrapping_add(1).max(1);
let init = RendererRelatedInitialEmptyPageRealmInit {
dom_host,
loader: creator_resource_authority
.request_client()
.fork_with_isolated_page_network_policy(),
env,
inherited_origin: storage_scope.origin().to_owned(),
policy_container: creator_policy_container,
auxiliary_popup_id: popup_id,
staged_window_proxy,
opener: Some(v8::Global::new(scope, opener)),
window_name: String::new(),
};
if let Err(error) =
self.stage_related_initial_empty_page_in_scope(scope, pending_auxiliary_page, init)
{
tracing::debug!(
error = %error,
"failed to stage renderer-owned initial auxiliary realm; rejecting synchronous proxy path"
);
return None;
}
Some(OpenedLightweightPopup {
window,
popup_id,
created_new_browsing_context: true,
pending_auxiliary_page: Some(pending_auxiliary_page),
captured_session_storage_store: Some(session_storage_store),
captured_initial_empty_document_storage_key: Some(storage_scope.storage_key().clone()),
})
}
@@ -659,10 +820,15 @@ impl JsContextHost {
opener_child_handle: Option<DomHandle>,
target_name: &str,
href: &str,
detachable_window_proxy: bool,
opener_sandbox_policy: Option<DocumentSandboxPolicy>,
creator_base_url: Url,
creator_policy_container: DocumentPolicyContainer,
) -> Option<(v8::Local<'s, v8::Object>, u64)> {
) -> Option<(
v8::Local<'s, v8::Object>,
u64,
Option<v8::Global<v8::Context>>,
)> {
let parsed_url = Url::parse(href).ok()?;
let initial_url = if parsed_url.scheme() == "javascript" {
about_blank_url()
@@ -693,10 +859,20 @@ impl JsContextHost {
.next_lightweight_popup_id
.checked_add(1)
.expect("lightweight popup id space exhausted");
let window = self
.bridge
.bindings
.instantiate_window_shell(scope, host_ptr);
let (window, facade_context) = if detachable_window_proxy {
let (window, context) = self
.bridge
.bindings
.instantiate_same_origin_window_proxy_shell(scope, host_ptr);
(window, Some(context))
} else {
(
self.bridge
.bindings
.instantiate_window_shell(scope, host_ptr),
None,
)
};
let popup_id_private_value = v8::BigInt::new_from_u64(scope, popup_id);
set_private_value(
scope,
@@ -734,12 +910,48 @@ impl JsContextHost {
set_object_slot(scope, window, "name", value.into());
}
if let Some(opener) = opener {
set_private_value(scope, window, WINDOW_OPENER_SLOT, opener.into());
set_object_slot(scope, window, "opener", opener.into());
install_lightweight_popup_viewport_surface_from_opener(scope, opener, window);
inherit_auxiliary_window_viewport_surface(scope, opener, window);
} else {
let opener = v8::null(scope);
set_private_value(scope, window, WINDOW_OPENER_SLOT, opener.into());
set_object_slot(scope, window, "opener", opener.into());
}
if let Some(facade_context) = facade_context.as_ref() {
// The legacy lightweight loader continues to execute until the
// initial Document owner is unified. Give its temporary realm an
// observable token and install realm-local identity slots from
// inside that realm; setting a V8 private on its global proxy
// while entered in the opener realm can otherwise target the
// wrong inner global.
let facade_context = v8::Local::new(scope, facade_context);
let context_token = self.allocate_runtime_observable_context_token();
crate::native_bridge::install_runtime_observable_context_token_for_context(
facade_context,
context_token,
);
let opener_value: v8::Local<'s, v8::Value> = opener
.map(Into::into)
.unwrap_or_else(|| v8::null(scope).into());
let opener_value = v8::Global::new(scope, opener_value);
let facade_scope = &mut v8::ContextScope::new(scope, facade_context);
let facade_window = facade_context.global(facade_scope);
let popup_id_value = v8::BigInt::new_from_u64(facade_scope, popup_id);
set_private_value(
facade_scope,
facade_window,
LIGHTWEIGHT_POPUP_ID_SLOT,
popup_id_value.into(),
);
let opener_value = v8::Local::new(facade_scope, &opener_value);
set_private_value(
facade_scope,
facade_window,
WINDOW_OPENER_SLOT,
opener_value,
);
}
if let Ok(navigator) =
crate::context_bootstrap::build_lightweight_popup_window_navigator_object(
scope, popup_id,
@@ -905,7 +1117,7 @@ impl JsContextHost {
if queue_synthetic_load {
self.queue_lightweight_popup_load_event(navigation_task);
}
Some((window, popup_id))
Some((window, popup_id, facade_context))
}
fn reopen_lightweight_popup_window<'s>(
@@ -1030,6 +1242,28 @@ impl JsContextHost {
.map(|record| v8::Local::new(scope, &record.window_proxy))
}
pub(crate) fn lightweight_popup_id_for_window_proxy<'s>(
&self,
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
) -> Option<u64> {
lightweight_popup_id_from_window(scope, window)
.filter(|popup_id| {
self.lightweight_popup_browsing_contexts
.contains_key(popup_id)
})
.or_else(|| {
self.lightweight_popup_browsing_contexts
.iter()
.find_map(|(popup_id, record)| {
v8::Local::new(scope, &record.window_proxy)
.strict_equals(window.into())
.then_some(*popup_id)
})
})
.or_else(|| renderer_owned_auxiliary_popup_id_from_window(scope, window))
}
pub(in crate::native_bridge::context_host) fn lightweight_popup_opener_endpoint(
&self,
popup_id: u64,
@@ -3912,6 +4146,28 @@ pub(crate) fn lightweight_popup_id_from_window<'s>(
lightweight_popup_id_from_value(scope, value)
}
pub(crate) fn set_renderer_owned_auxiliary_popup_id<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
popup_id: u64,
) {
let value = v8::BigInt::new_from_u64(scope, popup_id);
set_private_value(
scope,
window,
RENDERER_OWNED_AUXILIARY_POPUP_ID_SLOT,
value.into(),
);
}
fn renderer_owned_auxiliary_popup_id_from_window<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
) -> Option<u64> {
let value = get_private_value(scope, window, RENDERER_OWNED_AUXILIARY_POPUP_ID_SLOT)?;
lightweight_popup_id_from_value(scope, value)
}
pub(crate) fn active_lightweight_popup_id(scope: &mut v8::PinScope<'_, '_>) -> Option<u64> {
let global = scope.get_current_context().global(scope);
let value = get_private_value(scope, global, ACTIVE_LIGHTWEIGHT_POPUP_ID_SLOT)?;
@@ -4029,21 +4285,6 @@ fn install_lightweight_popup_indexed_db_factory<'s>(
true
}
fn install_lightweight_popup_viewport_surface_from_opener<'s>(
scope: &mut v8::PinScope<'s, '_>,
opener: v8::Local<'s, v8::Object>,
window: v8::Local<'s, v8::Object>,
) {
for property in LIGHTWEIGHT_POPUP_VIEWPORT_SURFACE_PROPERTIES {
let Some(value) = opener.get(scope, v8str(scope, property).into()) else {
continue;
};
if value.is_number() {
set_object_slot(scope, window, property, value);
}
}
}
fn lightweight_popup_close_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
+11 -4
View File
@@ -965,10 +965,17 @@ fn document_referrer_getter_function<'s>(
return;
};
let runtime = unsafe { &*runtime_ptr };
let referrer = runtime
.child_browsing_context_referrer_for_document_handle(handle)
.or_else(|| runtime.lightweight_popup_referrer_for_document_handle(handle))
.unwrap_or("");
let referrer = if handle == runtime.document_handle() {
runtime
.document_policy_container()
.document_referrer
.as_str()
} else {
runtime
.child_browsing_context_referrer_for_document_handle(handle)
.or_else(|| runtime.lightweight_popup_referrer_for_document_handle(handle))
.unwrap_or("")
};
set_document_string_return_value(scope, &mut rv, referrer);
}
@@ -226,6 +226,7 @@ fn navigate_hyperlink_popup_target(
None,
target_name,
resolved_url,
Some(!relations.suppress_opener),
creator.base_url,
creator.policy_container,
) else {
@@ -253,6 +254,7 @@ fn navigate_hyperlink_popup_target(
let session_storage_store = runtime.lightweight_popup_session_storage_store(popup_id);
let initial_empty_document_storage_key =
runtime.lightweight_popup_initial_empty_document_storage_key(popup_id);
let pending_auxiliary_page = opened_popup.pending_auxiliary_page;
let user_gesture = runtime.protocol_user_gesture_activation();
let window_open_event = opened_popup.created_new_browsing_context.then(|| {
RendererPendingWindowOpenEvent::browser_window(resolved_url, target_name, user_gesture)
@@ -267,7 +269,8 @@ fn navigate_hyperlink_popup_target(
target_name.to_owned(),
disposition,
)
.with_initial_auxiliary_state(session_storage_store, initial_empty_document_storage_key),
.with_initial_auxiliary_state(session_storage_store, initial_empty_document_storage_key)
.with_pending_auxiliary_page(pending_auxiliary_page),
window_open_event,
);
true
+3 -1
View File
@@ -16,6 +16,7 @@ pub(crate) use current_input::{
pub(crate) mod document;
pub(crate) mod element;
mod helpers;
pub(crate) use helpers::set_object_slot;
mod history_queue;
pub(super) mod identity;
mod node;
@@ -61,7 +62,8 @@ pub(crate) use context_host::{
install_runtime_observable_context_token_for_context, is_cross_origin_top_window_proxy,
lightweight_popup_id_from_window, restore_active_lightweight_popup_scope,
restore_deferred_active_lightweight_popup_scope_if_present,
throw_cross_origin_location_security_error, throw_cross_origin_type_error,
set_renderer_owned_auxiliary_popup_id, throw_cross_origin_location_security_error,
throw_cross_origin_type_error,
};
pub(crate) const ACTIVE_CHILD_WINDOW_HANDLE_SLOT: &str = "__moliActiveChildWindowHandle";
@@ -246,6 +246,10 @@ impl DocumentResourceLoader {
&self.request_client
}
pub(crate) fn document_origin(&self) -> String {
self.authority.lifecycle.lock().context.origin().to_owned()
}
#[cfg(test)]
pub(crate) fn owner(&self) -> crate::native_bridge::WindowDocumentOwner {
self.authority.lifecycle.lock().context.owner()
@@ -316,6 +316,16 @@ impl PageRuntimeTaskSource {
Rc::as_ptr(&self.page_task_producer_routes) as usize
}
/// Allocates one exact cross-Document commit identity from this stable
/// Page source. Prepared browser-owned navigation does not originate in a
/// ScriptVm, but it must share the same identity namespace as script
/// navigation so two replacement publications can never alias.
pub(crate) fn next_top_level_navigation_handoff(
&self,
) -> super::RendererTopLevelNavigationHandoff {
self.wake.next_top_level_navigation_handoff()
}
pub(crate) fn v8_foreground_task_sender(&self) -> Option<RendererPageV8ForegroundTaskSender> {
self.page_task_producer_routes
.borrow()
+488 -15
View File
@@ -4,6 +4,7 @@ use std::{
cell::RefCell,
collections::HashMap,
fmt,
rc::Rc,
sync::Arc,
sync::atomic::{AtomicU64, Ordering},
time::Instant,
@@ -14,6 +15,7 @@ use crate::DocumentStartScript;
#[cfg(test)]
use crate::dom::native::NativeDom;
use crate::{
browsing_context_model::BrowsingContextId,
dom::NodeId,
network::{ResourceRequestClient, context::DocumentResourceLoader},
};
@@ -286,7 +288,8 @@ pub use self::owner::{
};
pub use self::owner_local::RendererPageTestingHandle;
pub use self::owner_local::{
RendererPageCommandPending, RendererPageHandle, RendererRuntimeInspectorSessionDetachGuard,
RendererPageCommandPending, RendererPageHandle, RendererPageReplacementReservationPending,
RendererRuntimeInspectorSessionDetachGuard,
};
pub(crate) use self::owner_local_store::RendererPageToken;
pub use self::page::{JsRuntime, JsRuntimeOwner, PendingHtmlPage, PreparedRendererDocument};
@@ -528,21 +531,27 @@ impl PageId {
}
}
/// Opaque owner-local reservation for one renderer Page identity.
///
/// The reservation is allocated before a queued full-body build or prepared
/// document can enter parser or author-script execution. This lets external
/// observers bind work to the future Page without guessing from the currently
/// installed Page.
/// Selects the script environment admitted when a Page reservation is
/// consumed by document bootstrap.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) enum RendererScriptAgentAdmission {
Fresh,
#[cfg(test)]
RelatedPageForExperiment {
source_page_id: PageId,
RelatedAuxiliaryPage {
opener_page_id: PageId,
},
ExistingPageReplacement {
expected_vm_creation_id: u64,
reservation_nonce: u64,
},
}
/// Opaque owner-local reservation for one renderer Page admission.
///
/// Initial creation reserves a future Page identity. A live replacement
/// reserves one exact committed `PageVm` generation of an existing identity.
/// Both are allocated before a queued full-body build or prepared document can
/// enter parser or author-script execution, so external observers never infer
/// ownership from whichever Page happens to be installed later.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct RendererPageReservationToken {
local_host_id: RendererOwnerLocalHostId,
@@ -559,17 +568,32 @@ impl RendererPageReservationToken {
}
}
#[cfg(test)]
pub(crate) fn new_related_page_for_experiment(
pub(crate) fn new_related_auxiliary_page(
local_host_id: RendererOwnerLocalHostId,
page_id: PageId,
source_page_id: PageId,
opener_page_id: PageId,
) -> Self {
Self {
local_host_id,
page_id,
script_agent_admission: RendererScriptAgentAdmission::RelatedPageForExperiment {
source_page_id,
script_agent_admission: RendererScriptAgentAdmission::RelatedAuxiliaryPage {
opener_page_id,
},
}
}
pub(crate) fn new_existing_page_replacement(
local_host_id: RendererOwnerLocalHostId,
page_id: PageId,
expected_vm_creation_id: u64,
reservation_nonce: u64,
) -> Self {
Self {
local_host_id,
page_id,
script_agent_admission: RendererScriptAgentAdmission::ExistingPageReplacement {
expected_vm_creation_id,
reservation_nonce,
},
}
}
@@ -587,6 +611,228 @@ impl RendererPageReservationToken {
}
}
/// Renderer-owned identity reserved synchronously for a newly accepted
/// auxiliary top-level browsing context.
///
/// The typed browsing-context identity remains stable across Document
/// replacements, while the Page reservation is consumed exactly once by the
/// protocol target's initial empty-Document build. Reserving here prevents
/// protocol code from manufacturing a second Page identity for a popup the
/// renderer has already accepted.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct RendererPendingAuxiliaryPage {
browsing_context_id: BrowsingContextId,
page_reservation: RendererPageReservationToken,
}
impl RendererPendingAuxiliaryPage {
pub fn browsing_context_id(self) -> u64 {
self.browsing_context_id.value()
}
pub fn page_reservation(self) -> RendererPageReservationToken {
self.page_reservation
}
}
/// Owner-local V8 state staged between synchronous popup acceptance and the
/// related auxiliary Page's initial realm bootstrap.
pub(crate) struct RendererStagedAuxiliaryWindowProxy {
window_proxy: v8::Global<v8::Object>,
facade_context: v8::Global<v8::Context>,
inherited_security_token: Option<v8::Global<v8::Value>>,
}
/// Inputs captured from the creator Document for one real synchronous
/// auxiliary initial-empty Page realm.
pub(crate) struct RendererRelatedInitialEmptyPageRealmInit {
pub(crate) dom_host: crate::dom::native::DomHost,
pub(crate) loader: ResourceRequestClient,
pub(crate) env: PageVmEnvConfig,
pub(crate) inherited_origin: String,
pub(crate) policy_container: crate::document_runtime::DocumentPolicyContainer,
pub(crate) auxiliary_popup_id: u64,
pub(crate) staged_window_proxy: RendererStagedAuxiliaryWindowProxy,
pub(crate) opener: Option<v8::Global<v8::Object>>,
pub(crate) window_name: String,
}
impl RendererStagedAuxiliaryWindowProxy {
pub(crate) fn new(
window_proxy: v8::Global<v8::Object>,
facade_context: v8::Global<v8::Context>,
inherited_security_token: Option<v8::Global<v8::Value>>,
) -> Self {
Self {
window_proxy,
facade_context,
inherited_security_token,
}
}
pub(crate) fn into_parts(
self,
) -> (
v8::Global<v8::Object>,
v8::Global<v8::Context>,
Option<v8::Global<v8::Value>>,
) {
(
self.window_proxy,
self.facade_context,
self.inherited_security_token,
)
}
}
#[derive(Default)]
struct RendererStagedAuxiliaryWindowProxyRegistry {
by_page_id: HashMap<PageId, RendererStagedAuxiliaryWindowProxy>,
}
/// Page-local allocation and handoff capability for auxiliary browsing
/// contexts created synchronously by one opener realm.
///
/// The registry intentionally stays owner-local: V8 handles never cross the
/// renderer/protocol transport carried by [`RendererPendingAuxiliaryPage`]. A
/// related Page admitted to the same script agent consumes its exact staged
/// proxy once during its initial realm bootstrap.
#[derive(Clone)]
pub(crate) struct RendererAuxiliaryPageReservationAllocator {
owner: Option<owner_local_store::RendererOwnerLocalContext>,
local_host_id: RendererOwnerLocalHostId,
opener_page_id: PageId,
next_page_id: Arc<AtomicU64>,
staged_window_proxies: Rc<RefCell<RendererStagedAuxiliaryWindowProxyRegistry>>,
}
impl fmt::Debug for RendererAuxiliaryPageReservationAllocator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RendererAuxiliaryPageReservationAllocator")
.field("local_host_id", &self.local_host_id)
.field("opener_page_id", &self.opener_page_id)
.field(
"staged_window_proxy_count",
&self.staged_window_proxies.borrow().by_page_id.len(),
)
.finish_non_exhaustive()
}
}
impl RendererAuxiliaryPageReservationAllocator {
pub(in crate::runtime) fn new_for_owner(
owner: owner_local_store::RendererOwnerLocalContext,
opener_page_id: PageId,
) -> Self {
let local_host_id = owner.local_host_id;
let next_page_id = owner.owner_state.next_page_id.clone();
Self {
owner: Some(owner),
local_host_id,
opener_page_id,
next_page_id,
staged_window_proxies: Rc::new(RefCell::new(
RendererStagedAuxiliaryWindowProxyRegistry::default(),
)),
}
}
#[cfg(test)]
pub(crate) fn new_for_test(
local_host_id: RendererOwnerLocalHostId,
opener_page_id: PageId,
next_page_id: Arc<AtomicU64>,
) -> Self {
Self {
owner: None,
local_host_id,
opener_page_id,
next_page_id,
staged_window_proxies: Rc::new(RefCell::new(
RendererStagedAuxiliaryWindowProxyRegistry::default(),
)),
}
}
pub(crate) fn reserve(&self, exposes_opener: bool) -> RendererPendingAuxiliaryPage {
let page_id = PageId::new(self.next_page_id.fetch_add(1, Ordering::Relaxed));
let page_reservation = if exposes_opener {
RendererPageReservationToken::new_related_auxiliary_page(
self.local_host_id,
page_id,
self.opener_page_id,
)
} else {
RendererPageReservationToken::new(self.local_host_id, page_id)
};
RendererPendingAuxiliaryPage {
browsing_context_id: BrowsingContextId::auxiliary_top_level(page_id.as_u64()),
page_reservation,
}
}
pub(crate) fn stage_related_window_proxy(
&self,
pending: RendererPendingAuxiliaryPage,
staged: RendererStagedAuxiliaryWindowProxy,
) -> Result<()> {
let reservation = pending.page_reservation();
ensure!(
reservation.local_host_id() == self.local_host_id,
"auxiliary WindowProxy reservation belongs to a different renderer owner"
);
ensure!(
matches!(
reservation.script_agent_admission(),
RendererScriptAgentAdmission::RelatedAuxiliaryPage { opener_page_id }
if opener_page_id == self.opener_page_id
),
"only a related auxiliary Page may adopt its opener-created WindowProxy"
);
let mut registry = self.staged_window_proxies.borrow_mut();
let std::collections::hash_map::Entry::Vacant(entry) =
registry.by_page_id.entry(reservation.page_id())
else {
return Err(anyhow!(
"auxiliary Page already has a staged opener WindowProxy"
));
};
entry.insert(staged);
Ok(())
}
pub(crate) fn take_related_window_proxy(
&self,
page_id: PageId,
) -> Option<RendererStagedAuxiliaryWindowProxy> {
self.staged_window_proxies
.borrow_mut()
.by_page_id
.remove(&page_id)
}
pub(crate) fn stage_related_initial_empty_page_in_scope(
&self,
scope: &mut v8::PinScope<'_, '_>,
pending: RendererPendingAuxiliaryPage,
source_environment: &crate::script_vm::RendererPageScriptEnvironment,
source_bridge_bindings: &crate::native_bridge::bindings::NativeBridgeBindings,
init: RendererRelatedInitialEmptyPageRealmInit,
) -> Result<()> {
let owner = self.owner.as_ref().ok_or_else(|| {
anyhow!("standalone auxiliary allocator cannot stage a production Page realm")
})?;
owner_local_store::stage_related_initial_empty_page_on_bound_owner_local_store(
owner,
scope,
pending,
source_environment,
source_bridge_bindings,
init,
)
}
}
/// Typed authority to consume one matching prepared document and enter its
/// renderer bootstrap.
#[derive(Debug)]
@@ -604,6 +850,233 @@ impl RendererDocumentCommitPermit {
}
}
/// Result of committing a prepared Document into an already-owned renderer
/// Page.
///
/// Unlike initial Page creation, this value deliberately carries no
/// [`RendererPageHandle`]. The existing handle remains the sole Page close and
/// command authority; callers only adopt the replacement Document's state and
/// DevTools agent identity.
pub struct RendererPageReplacementCommit {
pub(crate) local_host_id: RendererOwnerLocalHostId,
pub(crate) page_id: PageId,
pub(crate) renderer_devtools_agent_token: RendererDevToolsAgentToken,
pub(crate) javascript_dialog_broker: RendererJavaScriptDialogBroker,
pub(crate) devtools_target: crate::devtools::target::RendererDevToolsTargetHandle,
pub(crate) page_state: Arc<RendererPageState>,
pub(crate) creation_diagnostics: RendererPageCreationDiagnostics,
pub(crate) creation_artifacts: RendererPageCreationArtifacts,
pub(crate) pending_download: Option<RendererPendingDownloadActivation>,
}
#[derive(Clone, Debug)]
enum RendererDocumentContinuationState {
Pending,
Settled(RendererDocumentContinuationCompletion),
}
/// State captured by the exact owner turn that settles a committed Document's
/// requested continuation target.
///
/// The output predecessor and PageState belong to the same owner turn. This
/// lets protocol adapters first project every lifecycle/Inspector record up to
/// the target and then replace their cached Page view without issuing a later
/// renderer command that could observe a different turn.
#[derive(Clone, Debug)]
pub struct RendererDocumentContinuationCompletion {
renderer_output_predecessor: Option<RendererOutputFence>,
page_state: Option<Arc<RendererPageState>>,
}
impl RendererDocumentContinuationCompletion {
fn settled(
renderer_output_predecessor: Option<RendererOutputFence>,
page_state: Option<Arc<RendererPageState>>,
) -> Self {
Self {
renderer_output_predecessor,
page_state,
}
}
fn canceled() -> Self {
Self::settled(None, None)
}
pub fn into_parts(self) -> (Option<RendererOutputFence>, Option<Arc<RendererPageState>>) {
(self.renderer_output_predecessor, self.page_state)
}
}
/// Exact observer for the owner turn that finishes a DocumentCommit
/// continuation at its requested lifecycle target.
///
/// The lifecycle journal may publish DOMContentLoaded while that owner turn is
/// still committing PageState and settling Inspector output. This observer is
/// resolved only after that concrete output publication has been sent, and
/// carries its fence when one exists.
#[derive(Clone)]
pub struct RendererDocumentContinuationObserver {
receiver: tokio::sync::watch::Receiver<RendererDocumentContinuationState>,
}
impl std::fmt::Debug for RendererDocumentContinuationObserver {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RendererDocumentContinuationObserver")
.finish_non_exhaustive()
}
}
impl PartialEq for RendererDocumentContinuationObserver {
fn eq(&self, other: &Self) -> bool {
self.receiver.same_channel(&other.receiver)
}
}
impl RendererDocumentContinuationObserver {
pub async fn wait(mut self) -> RendererDocumentContinuationCompletion {
loop {
match self.receiver.borrow_and_update().clone() {
RendererDocumentContinuationState::Pending => {}
RendererDocumentContinuationState::Settled(completion) => return completion,
}
if self.receiver.changed().await.is_err() {
return RendererDocumentContinuationCompletion::canceled();
}
}
}
}
pub(in crate::runtime) struct RendererDocumentContinuationPublisher {
sender: Option<tokio::sync::watch::Sender<RendererDocumentContinuationState>>,
}
impl RendererDocumentContinuationPublisher {
fn channel() -> (Self, RendererDocumentContinuationObserver) {
let (sender, receiver) =
tokio::sync::watch::channel(RendererDocumentContinuationState::Pending);
(
Self {
sender: Some(sender),
},
RendererDocumentContinuationObserver { receiver },
)
}
pub(in crate::runtime) fn settle(
mut self,
renderer_output_predecessor: Option<RendererOutputFence>,
page_state: Option<Arc<RendererPageState>>,
) {
if let Some(sender) = self.sender.take() {
sender.send_replace(RendererDocumentContinuationState::Settled(
RendererDocumentContinuationCompletion::settled(
renderer_output_predecessor,
page_state,
),
));
}
}
}
impl Drop for RendererDocumentContinuationPublisher {
fn drop(&mut self) {
if let Some(sender) = self.sender.take() {
sender.send_replace(RendererDocumentContinuationState::Settled(
RendererDocumentContinuationCompletion::canceled(),
));
}
}
}
pub(in crate::runtime) fn renderer_document_continuation_channel() -> (
RendererDocumentContinuationPublisher,
RendererDocumentContinuationObserver,
) {
RendererDocumentContinuationPublisher::channel()
}
/// Whether a failed prepared replacement left the stable Page's old Document
/// usable or crossed the renderer's irreversible retirement boundary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RendererPageReplacementCommitFailureDisposition {
PagePreserved,
PageRetired,
}
#[derive(Debug)]
pub struct RendererPageReplacementCommitError {
disposition: RendererPageReplacementCommitFailureDisposition,
source: anyhow::Error,
}
impl RendererPageReplacementCommitError {
#[doc(hidden)]
pub fn page_preserved(source: anyhow::Error) -> Self {
Self {
disposition: RendererPageReplacementCommitFailureDisposition::PagePreserved,
source,
}
}
#[doc(hidden)]
pub fn page_retired(source: anyhow::Error) -> Self {
Self {
disposition: RendererPageReplacementCommitFailureDisposition::PageRetired,
source,
}
}
pub fn disposition(&self) -> RendererPageReplacementCommitFailureDisposition {
self.disposition
}
}
impl std::fmt::Display for RendererPageReplacementCommitError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.source, formatter)
}
}
impl std::error::Error for RendererPageReplacementCommitError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.source.as_ref())
}
}
impl RendererPageReplacementCommit {
pub fn owner_local_host_id(&self) -> RendererOwnerLocalHostId {
self.local_host_id
}
pub fn page_id(&self) -> PageId {
self.page_id
}
pub fn renderer_devtools_agent_token(&self) -> RendererDevToolsAgentToken {
self.renderer_devtools_agent_token
}
pub fn into_parts(
self,
) -> (
RendererDevToolsAgentToken,
Arc<RendererPageState>,
RendererPageCreationDiagnostics,
RendererPageCreationArtifacts,
Option<RendererPendingDownloadActivation>,
) {
(
self.renderer_devtools_agent_token,
self.page_state,
self.creation_diagnostics,
self.creation_artifacts,
self.pending_download,
)
}
}
#[cfg(test)]
pub(in crate::runtime) enum PageVmNavigationTurnOutcome {
Completed(Box<PageVm>),
File diff suppressed because it is too large Load Diff
@@ -107,6 +107,33 @@ enum RendererPageCommandPendingDispatch {
InspectorMain(Box<RendererRuntimeInspectorMainCommandRoute>),
}
/// A non-owning request for reserving the next Document in one exact live
/// renderer Page.
///
/// This carries only the renderer owner route and stable Page token. It may be
/// moved into protocol background work without cloning the Page handle or its
/// close authority.
pub struct RendererPageReplacementReservationPending {
render_runtime: RenderRuntimeHandle,
token: RendererPageToken,
_not_send: PhantomData<Rc<()>>,
}
impl RendererPageReplacementReservationPending {
pub async fn await_ready(self) -> Result<RendererPageReservationToken> {
match self
.render_runtime
.dispatch(RendererOwnerCommand::ReserveLivePageReplacement { token: self.token })
.await?
{
RendererOwnerReply::LivePageReplacementReserved(reservation) => Ok(reservation),
_ => Err(anyhow!(
"renderer owner returned non-reservation reply for live Page replacement"
)),
}
}
}
#[derive(Clone)]
pub struct RendererPageTestingHandle {
render_runtime: RenderRuntimeHandle,
@@ -172,6 +199,52 @@ impl RendererPageHandle {
self.committed_document_post_response_continuation.take()
}
/// Adopts the current Document's inspector identity without replacing the
/// Page handle or its close/command authority.
pub fn adopt_page_replacement(
&mut self,
replacement: &RendererPageReplacementCommit,
) -> anyhow::Result<()> {
anyhow::ensure!(
replacement.owner_local_host_id() == self.owner_local_host_id()
&& replacement.page_id() == self.renderer_page_id(),
"renderer Page replacement belongs to a different stable Page"
);
self.devtools_target.detach_page(
self.renderer_page_id(),
"Inspector Page document was replaced",
);
self.javascript_dialog_broker.dismiss_pending();
self.devtools_agent_token = replacement.renderer_devtools_agent_token();
self.javascript_dialog_broker = replacement.javascript_dialog_broker.clone();
self.devtools_target = replacement.devtools_target.clone();
Ok(())
}
/// Reserves a replacement Document for this exact live Page generation.
///
/// The renderer owner serializes this snapshot with Page turns. Preparing
/// the replacement fails closed if another cross-document commit changes
/// the resident `PageVm` before the reservation is consumed. A newer
/// unconsumed reservation for the same Page supersedes an older one.
pub async fn reserve_replacement_document_for_navigation(
&self,
) -> Result<RendererPageReservationToken> {
self.start_replacement_document_reservation()
.await_ready()
.await
}
pub fn start_replacement_document_reservation(
&self,
) -> RendererPageReplacementReservationPending {
RendererPageReplacementReservationPending {
render_runtime: self.render_runtime.clone(),
token: self.token(),
_not_send: PhantomData,
}
}
pub fn take_pending_modal_javascript_dialogs(&self) -> Vec<RendererPendingJavaScriptDialog> {
self.javascript_dialog_broker.take_pending()
}
@@ -145,6 +145,26 @@ pub(super) fn reserve_renderer_document_isolate_on_bound_owner_local_store(
})
}
pub(crate) fn stage_related_initial_empty_page_on_bound_owner_local_store(
owner: &RendererOwnerLocalContext,
scope: &mut v8::PinScope<'_, '_>,
pending: RendererPendingAuxiliaryPage,
source_environment: &RendererPageScriptEnvironment,
source_bridge_bindings: &crate::native_bridge::bindings::NativeBridgeBindings,
init: RendererRelatedInitialEmptyPageRealmInit,
) -> Result<()> {
with_bound_render_runtime_owner_local_store_session(|session| {
session.store.stage_related_initial_empty_page_for_owner(
owner,
scope,
pending,
source_environment,
source_bridge_bindings,
init,
)
})
}
pub(super) fn remove_reserved_renderer_document_isolate_on_bound_owner_local_store(
token: RendererPageToken,
reservation_id: u64,
@@ -481,6 +501,66 @@ pub(in crate::runtime) async fn begin_post_parse_lifecycle_on_entry_via_local_ta
.await
}
pub(in crate::runtime) async fn begin_prepared_document_replacement_on_entry_via_local_task(
local_executor: JsLocalExecutor,
entry: LivePageEntry,
bootstrap: RendererDocumentIsolateBootstrap,
reservation: RendererDocumentIsolateReservation,
) -> (
LivePageEntry,
Result<(
PageVmRuntimeHooks,
crate::page_task_queue::RendererTopLevelNavigationHandoff,
)>,
) {
run_entry_on_bound_owner_local_store_local_task(local_executor, entry, move |entry| {
Box::pin(async move {
entry.retire_document_lifecycle_turn();
entry
.page_vm_mut()
.begin_prepared_document_replacement(bootstrap, reservation)
})
})
.await
}
pub(in crate::runtime) async fn install_prepared_replacement_page_vm_on_entry_via_local_task(
local_executor: JsLocalExecutor,
entry: LivePageEntry,
page_vm: PageVm,
navigation_handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff,
metadata: PreparedReplacementDocumentMetadata,
) -> (LivePageEntry, Result<PublishedReplacementDocument>) {
run_entry_on_bound_owner_local_store_local_task(local_executor, entry, move |entry| {
Box::pin(async move {
entry.install_prepared_replacement_page_vm(page_vm, navigation_handoff, metadata)
})
})
.await
}
pub(in crate::runtime) async fn install_prepared_replacement_pending_phase_one_on_entry_via_local_task(
local_executor: JsLocalExecutor,
entry: LivePageEntry,
pending: PageVmPendingPhaseOneNavigation,
navigation_handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff,
metadata: PreparedReplacementDocumentMetadata,
) -> (
LivePageEntry,
Result<(RendererPageToken, PublishedReplacementDocument)>,
) {
run_entry_on_bound_owner_local_store_local_task(local_executor, entry, move |entry| {
Box::pin(async move {
entry.install_prepared_replacement_pending_phase_one(
pending,
navigation_handoff,
metadata,
)
})
})
.await
}
pub(in crate::runtime) async fn commit_page_state_on_entry_via_local_task(
local_executor: JsLocalExecutor,
entry: LivePageEntry,
@@ -510,6 +590,53 @@ pub(in crate::runtime) async fn commit_page_state_on_entry_via_local_task_with_p
.await
}
pub(in crate::runtime) async fn finalize_prepared_page_replacement_on_entry_via_local_task(
local_executor: JsLocalExecutor,
token: RendererPageToken,
entry: LivePageEntry,
) -> (
LivePageEntry,
Result<(
RendererPageReplacementCommit,
Option<RendererOutputPublication>,
)>,
) {
run_entry_on_bound_owner_local_store_local_task(local_executor, entry, move |entry| {
Box::pin(async move {
let page_state = RendererOwnerLocalStore::commit_current_vm_page_state_on_entry(entry)?;
let initial_runtime_realms = entry.page_vm_mut().vm_mut().runtime_realm_inventory();
let renderer_devtools_agent_token = entry.page_vm().devtools_agent_token();
let javascript_dialog_broker = entry.page_vm().javascript_dialog_broker();
let devtools_target = entry.page_vm().devtools_target();
let creation_artifacts = entry.page_vm_mut().take_page_creation_artifacts();
let renderer_output = entry.page_vm_mut().settle_renderer_output_publication();
let renderer_output_predecessor = entry
.page_vm()
.renderer_output_tail_cursor()
.map(|cursor| entry.page_vm().declare_renderer_output_fence(cursor));
Ok((
RendererPageReplacementCommit {
local_host_id: token.local_host_id,
page_id: token.page_id,
renderer_devtools_agent_token,
javascript_dialog_broker,
devtools_target,
page_state,
creation_diagnostics: RendererPageCreationDiagnostics {
initial_runtime_realms,
renderer_output_predecessor,
document_continuation_observer: None,
},
creation_artifacts,
pending_download: None,
},
renderer_output,
))
})
})
.await
}
pub(in crate::runtime) async fn advance_network_idle_wait_turn_on_entry_via_local_task(
local_executor: JsLocalExecutor,
entry: LivePageEntry,
@@ -324,6 +324,7 @@ impl LivePageEntry {
}
let current = self
.active_page_vm()
.filter(|page_vm| page_vm.has_live_script_vm())
.and_then(|page_vm| page_vm.vm().pending_location_navigation_handoff());
self.standalone_navigation_follow.claim(current, requested)
}
@@ -331,6 +332,7 @@ impl LivePageEntry {
pub(in crate::runtime) fn settle_standalone_navigation_follow(&mut self, succeeded: bool) {
let current = self
.active_page_vm()
.filter(|page_vm| page_vm.has_live_script_vm())
.and_then(|page_vm| page_vm.vm().pending_location_navigation_handoff());
self.standalone_navigation_follow.settle(current, succeeded);
}
@@ -370,6 +372,13 @@ impl LivePageEntry {
pub(super) fn publish_replacement_document_commit(
&mut self,
) -> Result<PublishedReplacementDocument> {
self.publish_replacement_document_commit_with_metadata(None)
}
fn publish_replacement_document_commit_with_metadata(
&mut self,
metadata: Option<PreparedReplacementDocumentMetadata>,
) -> Result<PublishedReplacementDocument> {
let stable_before = self.slot.entry();
ensure!(
@@ -401,7 +410,16 @@ impl LivePageEntry {
self.page_vm_mut()
.settle_replacement_document_commit(navigation_handoff)?;
RendererOwnerLocalStore::commit_active_vm_page_state_on_entry(self)?;
match metadata {
Some(metadata) => {
RendererOwnerLocalStore::commit_active_vm_page_state_on_entry_with_metadata(
self, metadata,
)?;
}
None => {
RendererOwnerLocalStore::commit_active_vm_page_state_on_entry(self)?;
}
}
let stable_after = self.slot.entry();
ensure!(
stable_after.vm_creation_id() == vm_creation_id,
@@ -427,6 +445,72 @@ impl LivePageEntry {
Ok(published)
}
pub(in crate::runtime) fn validate_prepared_replacement_generation(
&self,
expected_vm_creation_id: u64,
) -> Result<()> {
let stable = self.slot.entry();
ensure!(
stable.is_active(),
"prepared replacement cannot commit into a retired Page slot"
);
ensure!(
!self.has_uncommitted_page_vm(),
"prepared replacement cannot overlap another uncommitted Document"
);
let resident = self
.active_page_vm()
.ok_or_else(|| anyhow!("prepared replacement lost the resident PageVm"))?;
ensure!(
stable.vm_creation_id() == expected_vm_creation_id
&& resident.creation_id == expected_vm_creation_id,
"stale prepared replacement expected PageVm {expected_vm_creation_id}, current stable PageVm {} and resident PageVm {}",
stable.vm_creation_id(),
resident.creation_id
);
ensure!(
resident.has_live_script_vm(),
"prepared replacement cannot detach an already-retired Document realm"
);
Ok(())
}
pub(super) fn install_prepared_replacement_page_vm(
&mut self,
mut page_vm: PageVm,
navigation_handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff,
metadata: PreparedReplacementDocumentMetadata,
) -> Result<PublishedReplacementDocument> {
ensure!(
self.pending_phase_one_navigation.is_none(),
"prepared replacement cannot install over a phase-one residence"
);
ensure!(
self.vm
.as_ref()
.is_some_and(|page_vm| !page_vm.has_live_script_vm()),
"prepared replacement PageVm must install after the old Document realm is detached"
);
page_vm.prepare_replacement_document_commit(navigation_handoff)?;
self.retire_document_lifecycle_turn();
self.vm = Some(page_vm);
self.publish_replacement_document_commit_with_metadata(Some(metadata))
}
pub(super) fn install_prepared_replacement_pending_phase_one(
&mut self,
mut pending: PageVmPendingPhaseOneNavigation,
navigation_handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff,
metadata: PreparedReplacementDocumentMetadata,
) -> Result<(RendererPageToken, PublishedReplacementDocument)> {
pending
.page_vm_mut()
.prepare_replacement_document_commit(navigation_handoff)?;
let wake_token = self.install_new_pending_phase_one_navigation(pending)?;
let published = self.publish_replacement_document_commit_with_metadata(Some(metadata))?;
Ok((wake_token, published))
}
pub(in crate::runtime) fn active_page_vm(&self) -> Option<&PageVm> {
self.pending_phase_one_navigation
.as_ref()

Some files were not shown because too many files have changed in this diff Show More