From 8168e068c34babf093d339875049a6d97a61b10e Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 17 Sep 2026 16:16:08 +0800 Subject: [PATCH 01/15] fix(browser): address benchmark-discovered protocol and rendering failures --- README.md | 4 +- docs/README.de.md | 2 +- docs/README.es.md | 2 +- docs/README.fr.md | 2 +- docs/README.ja.md | 2 +- docs/README.zh-CN.md | 2 +- moli-bounded-buffer/src/buffer.rs | 30 + moli-bounded-buffer/src/tests.rs | 23 + moli-fetch/src/network_fetch_result.rs | 34 +- moli-layout/src/builder.rs | 19 + moli-layout/src/inline.rs | 10 + moli-layout/src/pass.rs | 8 +- moli-layout/src/projection.rs | 235 ++- moli-layout/src/taffy_tree.rs | 70 +- moli-layout/src/world.rs | 38 + moli-layout/tests/phase4_layout_contract.rs | 42 +- moli-layout/tests/phase5_output_contract.rs | 763 +++++++++- moli-page-types/src/lib.rs | 8 + .../src/actions.rs | 9 + .../src/conn/browser_context/network_owner.rs | 8 + .../src/conn/page_state/fetch_state.rs | 2 +- moli-protocol/src/conn/runtime_load.rs | 41 + moli-protocol/src/conn/state/page_slot.rs | 33 + moli-protocol/src/conn/state/runtime_slot.rs | 17 + .../src/domains/dom/tests/geometry.rs | 4 +- moli-protocol/src/domains/network.rs | 12 + moli-protocol/src/domains/network/agent.rs | 245 +++- moli-protocol/src/domains/network/backlog.rs | 7 + moli-protocol/src/domains/network/events.rs | 4 +- .../network/main_document_progress/mod.rs | 295 +++- .../network/main_document_progress/tests.rs | 323 ++++- .../src/domains/network/output_queue.rs | 71 +- moli-protocol/src/domains/network/settings.rs | 29 + .../domains/network/tests/response_body.rs | 162 +++ .../src/domains/page/producer_tests.rs | 108 ++ moli-protocol/src/domains/page/termination.rs | 12 +- .../domains/page/tests/navigation/requests.rs | 1267 +++++++++++++++++ .../target/worker_target/dedicated_worker.rs | 1 + .../worker_target/service_worker_targets.rs | 1 + .../src/document_runtime/mutation_commands.rs | 2 +- moli-renderer-v8/src/frame_owner_model.rs | 3 +- .../frame_owner_model/lifecycle_blockers.rs | 5 + .../src/frame_owner_model/lifecycle_tasks.rs | 7 + .../src/frame_owner_model/load_event_gate.rs | 91 +- .../src/frame_owner_model/records.rs | 2 +- .../src/frame_owner_model/store.rs | 33 +- .../src/frame_owner_model/store_tests.rs | 66 +- moli-renderer-v8/src/live_document_parser.rs | 1 + .../context_host/child_documents/loads.rs | 45 +- .../context_host/host_environment.rs | 122 +- .../context_host/image_resources/mod.rs | 4 + .../src/native_bridge/context_host/layout.rs | 7 + .../context_host/main_document_lifecycle.rs | 3 +- .../src/native_bridge/element/forms.rs | 1 + .../src/network/navigation/loader.rs | 37 + .../src/page_task_queue/stylesheet_task.rs | 3 + .../src/runtime/owner_local_store/entry.rs | 19 + .../src/runtime/owner_local_store/mod.rs | 3 + .../tests/child_document_completion.rs | 2 + .../page_vm/tests/computed_size/sampling.rs | 38 +- .../tests/grid_resolved_track_values.rs | 68 +- .../runtime/page_vm/tests/stylesheet_task.rs | 100 ++ .../runtime/phase_one/pending_residence.rs | 8 + .../src/runtime/phase_one/state.rs | 6 + .../src/runtime/phase_one/streaming.rs | 21 + .../src/runtime/phase_one/streaming_input.rs | 56 +- .../runtime/phase_one/streaming_residence.rs | 6 + .../protocol_output/transport_memory.rs | 133 ++ .../src/runtime/tests/open_streaming.rs | 83 ++ .../src/script_vm/stylesheet_page_tasks.rs | 7 +- .../tests/dom_elements/dom_surface.rs | 291 ++++ .../script_vm/tests/dom_xhr/computed_style.rs | 185 +++ .../src/script_vm/tests/dom_xhr/misc.rs | 14 +- moli-renderer-v8/src/style_engine/computed.rs | 39 +- moli-renderer-v8/src/style_engine/retained.rs | 32 + moli-renderer-v8/src/style_engine/state.rs | 6 + .../src/stylesheet_runtime/connected.rs | 4 +- moli/src/telemetry.rs | 27 + 78 files changed, 5257 insertions(+), 268 deletions(-) diff --git a/README.md b/README.md index c1f0eee574e..2c6e236a009 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,9 @@ Layout is an on-demand snapshot rather than continuously maintained state. The first geometry request (a cold start) builds a working layout tree from the current DOM/style, freezes its canonical geometry into an immutable, DOM-independent `FrozenLayoutTree`, and retains only that latest tree. Ordinary -geometry reads may reuse it even if the page has changed. Screenshots always +geometry reads reuse it while layout inputs are unchanged. Input changes mark +the retained tree dirty without running layout; the next geometry request +rebuilds it once from the current inputs. Screenshots always rebuild and replace the frozen tree. Each screencast subscription remembers only an opaque visual-state token: an unchanged token suppresses the poll, while a changed token triggers one fresh frame. Paint results are never reused. diff --git a/docs/README.de.md b/docs/README.de.md index 7c61ba5faaf..bbac6dd3e7d 100644 --- a/docs/README.de.md +++ b/docs/README.de.md @@ -204,7 +204,7 @@ Rechenintensive Browser-Operationen sind in Moli standardmäßig deaktiviert und | `--image`, `--font`, `--audio`, `--video`, `--media`, `--text-track` | Eine bestimmte optionale Ressourcengruppe einzeln aktivieren | | `--profile-dir`, `--http-cache-dir`, `--cookie-file` | Persistenz gezielt aktivieren, je nach Bedarf des Workloads | -Das Layoutergebnis ist ein bei Bedarf erzeugter Snapshot, kein dauerhaft gepflegter Zustand: Die erste Geometrieanfrage (Kaltstart) baut aus dem aktuellen DOM/Stil einen temporären Arbeitsbaum auf und friert dessen kanonische Geometrie in einen unveränderlichen, vom DOM unabhängigen `FrozenLayoutTree` ein — nur dieser jeweils neueste Baum wird vorgehalten. Normale Geometrieabfragen können ihn auch nach Seitenänderungen weiterverwenden; Screenshots und Screencasts dagegen bauen jedes Mal neu auf, ersetzen den eingefrorenen Baum und greifen nie auf alte Zeichenergebnisse zurück. +Das Layoutergebnis ist ein bei Bedarf erzeugter Snapshot, kein dauerhaft gepflegter Zustand: Die erste Geometrieanfrage (Kaltstart) baut aus dem aktuellen DOM/Stil einen temporären Arbeitsbaum auf und friert dessen kanonische Geometrie in einen unveränderlichen, vom DOM unabhängigen `FrozenLayoutTree` ein — nur dieser jeweils neueste Baum wird vorgehalten. Normale Geometrieabfragen verwenden ihn wieder, solange die Layouteingaben unverändert sind. Änderungen markieren den Baum als veraltet, ohne sofort ein Layout auszuführen; erst die nächste Geometrieanfrage baut ihn neu auf. Screenshots und Screencasts dagegen bauen jedes Mal neu auf, ersetzen den eingefrorenen Baum und greifen nie auf alte Zeichenergebnisse zurück. ## Architektur diff --git a/docs/README.es.md b/docs/README.es.md index f86a357a2be..4ddb7e34fc2 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -205,7 +205,7 @@ En Moli, las operaciones caras del navegador hay que activarlas explícitamente: | `--image`, `--font`, `--audio`, `--video`, `--media`, `--text-track` | Activa por separado una familia concreta de recursos opcionales | | `--profile-dir`, `--http-cache-dir`, `--cookie-file` | Activa de forma selectiva la persistencia que necesite tu carga de trabajo | -El resultado del layout es una instantánea tomada bajo demanda, no un estado que se mantiene todo el tiempo: la primera petición de geometría (cold start) construye un árbol de trabajo temporal a partir del DOM y los estilos actuales, congela su geometría canónica en un `FrozenLayoutTree` inmutable e independiente del DOM, y se queda solo con ese árbol, el más reciente. Las lecturas de geometría normales pueden reutilizarlo aunque la página haya cambiado entretanto. Las capturas de pantalla siempre reconstruyen y sustituyen el árbol congelado. Cada suscripción de screencast solo conserva un token opaco del estado visual: un token sin cambios evita el trabajo, mientras que un cambio genera un frame nuevo. Los resultados de pintado nunca se reutilizan. +El resultado del layout es una instantánea tomada bajo demanda, no un estado que se mantiene todo el tiempo: la primera petición de geometría (cold start) construye un árbol de trabajo temporal a partir del DOM y los estilos actuales, congela su geometría canónica en un `FrozenLayoutTree` inmutable e independiente del DOM, y se queda solo con ese árbol, el más reciente. Las lecturas de geometría normales lo reutilizan mientras no cambien las entradas del layout. Los cambios marcan el árbol como desactualizado sin calcular el layout de inmediato; la siguiente petición de geometría lo reconstruye una sola vez. Las capturas de pantalla siempre reconstruyen y sustituyen el árbol congelado. Cada suscripción de screencast solo conserva un token opaco del estado visual: un token sin cambios evita el trabajo, mientras que un cambio genera un frame nuevo. Los resultados de pintado nunca se reutilizan. ## Arquitectura diff --git a/docs/README.fr.md b/docs/README.fr.md index a9a287266ee..abb4a33b5f3 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -205,7 +205,7 @@ Dans Moli, les opérations coûteuses du navigateur ne sont jamais activées par | `--image`, `--font`, `--audio`, `--video`, `--media`, `--text-track` | Active une famille spécifique de ressources optionnelles | | `--profile-dir`, `--http-cache-dir`, `--cookie-file` | Active, au cas par cas, la persistance dont la charge de travail a besoin | -Le résultat de la mise en page est un instantané produit à la demande, pas un état maintenu en continu : la première requête de géométrie (« à froid ») construit un arbre de travail temporaire à partir du DOM et des styles courants, fige sa géométrie canonique dans un `FrozenLayoutTree` immuable et indépendant du DOM, puis ne conserve que ce dernier. Les lectures de géométrie ultérieures peuvent réutiliser cet arbre figé, même si la page a changé entre-temps. Les captures d'écran reconstruisent toujours et remplacent l'arbre figé. Chaque abonnement de screencast ne mémorise qu'un jeton opaque d'état visuel : un jeton inchangé supprime le travail, tandis qu'un changement produit une image fraîche. Les résultats de rendu ne sont jamais réutilisés. +Le résultat de la mise en page est un instantané produit à la demande, pas un état maintenu en continu : la première requête de géométrie (« à froid ») construit un arbre de travail temporaire à partir du DOM et des styles courants, fige sa géométrie canonique dans un `FrozenLayoutTree` immuable et indépendant du DOM, puis ne conserve que ce dernier. Les lectures de géométrie réutilisent cet arbre tant que les données de mise en page restent inchangées. Une modification marque l’arbre comme périmé sans recalcul immédiat ; la prochaine requête de géométrie le reconstruit une seule fois. Les captures d'écran reconstruisent toujours et remplacent l'arbre figé. Chaque abonnement de screencast ne mémorise qu'un jeton opaque d'état visuel : un jeton inchangé supprime le travail, tandis qu'un changement produit une image fraîche. Les résultats de rendu ne sont jamais réutilisés. ## Architecture diff --git a/docs/README.ja.md b/docs/README.ja.md index 853da053bdb..46e7995cb90 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -203,7 +203,7 @@ Moli では、処理負荷の高い機能は明示的に有効にした場合だ | `--image`、`--font`、`--audio`、`--video`、`--media`、`--text-track` | 指定した種類のデータを取得します。 | | `--profile-dir`、`--http-cache-dir`、`--cookie-file` | 必要な保存機能だけを選んで有効にします。 | -レイアウト結果は常に更新される状態ではなく、要求を受けた時点の状態です。最初に位置・寸法を要求されたときは、現在の DOM とスタイルから一時的な作業用レイアウト木を構築し、その確定済みジオメトリを DOM から独立した不変の `FrozenLayoutTree` に凍結して、最新の木だけを保持します。その後はページが変化していても通常の位置・寸法取得で古い木を再利用する場合があります。一方、画面画像と連続画面配信は毎回再構築して凍結木を置き換え、古い描画結果を再利用しません。 +レイアウト結果は常に更新される状態ではなく、要求を受けた時点の状態です。最初に位置・寸法を要求されたときは、現在の DOM とスタイルから一時的な作業用レイアウト木を構築し、その確定済みジオメトリを DOM から独立した不変の `FrozenLayoutTree` に凍結して、最新の木だけを保持します。レイアウトの入力が変わらない間は、通常の位置・寸法取得でこの木を再利用します。入力が変わってもすぐにはレイアウトを計算せず、木を古い状態としてマークし、次の位置・寸法要求で一度だけ再構築します。一方、画面画像と連続画面配信は毎回再構築して凍結木を置き換え、古い描画結果を再利用しません。 ## 仕組み diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 14175e16bf7..584fd8fcbea 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -203,7 +203,7 @@ Moli 是 Lexmount 旗下的开源无头浏览器;Lexmount Browser 则是围绕 | `--image`、`--font`、`--audio`、`--video`、`--media`、`--text-track` | 单独启用某一类可选资源 | | `--profile-dir`、`--http-cache-dir`、`--cookie-file` | 按工作负载需要,选择性开启持久化能力 | -布局结果是按需采样的一份快照,而不是持续维护的状态:第一次几何请求(冷启动)会根据当前 DOM/样式构建临时工作布局树,再把工作树丢弃后无法重算的几何冻结成一棵与 DOM 无关、不可变的 `FrozenLayoutTree`,长期只保留最新的这一棵树。在此之后,即便页面发生了变化,普通几何读取也可能复用旧树。截图始终重新构建并替换冻结树。每个屏幕串流订阅只记录一个不透明的视觉状态 token:状态未变时不产帧,状态变化时才新鲜渲染一帧;绘制结果不会被复用。 +布局结果是按需采样的一份快照,而不是持续维护的状态:第一次几何请求(冷启动)会根据当前 DOM/样式构建临时工作布局树,再把工作树丢弃后无法重算的几何冻结成一棵与 DOM 无关、不可变的 `FrozenLayoutTree`,长期只保留最新的这一棵树。布局输入未变时,普通几何读取复用这棵树;输入变化只标记旧树失效,不立即执行布局,下一次几何请求才按当前输入重新构建一次。截图始终重新构建并替换冻结树。每个屏幕串流订阅只记录一个不透明的视觉状态 token:状态未变时不产帧,状态变化时才新鲜渲染一帧;绘制结果不会被复用。 ## 架构 diff --git a/moli-bounded-buffer/src/buffer.rs b/moli-bounded-buffer/src/buffer.rs index 9a8d14fb55b..d5a415e6e75 100644 --- a/moli-bounded-buffer/src/buffer.rs +++ b/moli-bounded-buffer/src/buffer.rs @@ -66,6 +66,36 @@ where self.limits } + /// Changes budgets, returning oversized and then oldest entries that no + /// longer fit. Retained entries keep their original insertion order. + pub fn set_limits(&mut self, limits: ByteLimits) -> Vec<(K, V)> { + self.limits = limits; + let oversized = self + .insertion_order + .iter() + .filter(|key| { + self.entries + .get(*key) + .is_some_and(|entry| entry.byte_len > limits.max_entry_bytes) + }) + .cloned() + .collect::>(); + let mut evicted = Vec::new(); + for key in oversized { + if let Some(value) = self.remove(&key) { + evicted.push((key, value)); + } + } + while self.used_bytes > limits.max_total_bytes { + if let Some(entry) = self.pop_oldest() { + evicted.push(entry); + } else { + break; + } + } + evicted + } + /// Returns the sum of the logical byte charges for retained entries. pub fn used_bytes(&self) -> usize { self.used_bytes diff --git a/moli-bounded-buffer/src/tests.rs b/moli-bounded-buffer/src/tests.rs index dc013dd9201..d4558052cea 100644 --- a/moli-bounded-buffer/src/tests.rs +++ b/moli-bounded-buffer/src/tests.rs @@ -1,5 +1,28 @@ use super::{BoundedByteBuffer, ByteLimits, InsertOutcome}; +#[test] +fn changing_limits_evicts_oversized_then_oldest_without_reordering_survivors() { + let mut buffer = BoundedByteBuffer::new(ByteLimits::new(20, 10)); + buffer.insert("a", 1, 3); + buffer.insert("b", 2, 6); + buffer.insert("c", 3, 3); + assert_eq!( + buffer.set_limits(ByteLimits::new(4, 4)), + vec![("b", 2), ("a", 1)] + ); + assert_eq!(buffer.used_bytes(), 3); + assert_eq!(buffer.get(&"c"), Some(&3)); + assert!(buffer.set_limits(ByteLimits::new(10, 10)).is_empty()); + assert_eq!( + buffer.insert("d", 4, 8), + InsertOutcome::Stored { + evicted: vec![("c", 3)] + } + ); + assert_eq!(buffer.set_limits(ByteLimits::new(0, 0)), vec![("d", 4)]); + assert!(buffer.is_empty()); +} + #[test] fn accepts_entries_at_exact_limits() { let mut buffer = BoundedByteBuffer::new(ByteLimits::new(4, 4)); diff --git a/moli-fetch/src/network_fetch_result.rs b/moli-fetch/src/network_fetch_result.rs index dd09d8b1f17..fcc89d7ebce 100644 --- a/moli-fetch/src/network_fetch_result.rs +++ b/moli-fetch/src/network_fetch_result.rs @@ -16,6 +16,7 @@ const MAX_OBSERVED_HEADER_BLOCK_BYTES: usize = 256 * 1024; /// Request headers observed after the transport has finalized an HTTP exchange. #[derive(Clone, Debug, Eq, PartialEq)] pub struct NetworkRequestObservation { + method: Option, headers: Vec<(String, String)>, cookie_report: Option, truncated: bool, @@ -24,6 +25,16 @@ pub struct NetworkRequestObservation { impl NetworkRequestObservation { pub fn new(headers: Vec<(String, String)>) -> Self { Self { + method: None, + headers, + cookie_report: None, + truncated: false, + } + } + + pub fn new_with_method(method: impl Into, headers: Vec<(String, String)>) -> Self { + Self { + method: Some(method.into()), headers, cookie_report: None, truncated: false, @@ -33,12 +44,17 @@ impl NetworkRequestObservation { fn from_header_block(data: &[u8], cookie_report: Option) -> Self { let (data, truncated) = bounded_header_data(data); Self { + method: parse_request_method(data), headers: parse_request_header_block(data), cookie_report, truncated, } } + pub fn method(&self) -> Option<&str> { + self.method.as_deref() + } + pub fn headers(&self) -> &[(String, String)] { &self.headers } @@ -340,6 +356,14 @@ fn parse_request_header_block(data: &[u8]) -> Vec<(String, String)> { .collect() } +fn parse_request_method(data: &[u8]) -> Option { + let line = String::from_utf8_lossy(data); + let line = line.lines().next()?.trim_end_matches('\r'); + is_http_request_line(line) + .then(|| line.split_whitespace().next().map(str::to_owned)) + .flatten() +} + fn is_http_request_line(line: &str) -> bool { let mut fields = line.split_whitespace(); let (Some(method), Some(target), Some(version), None) = @@ -706,7 +730,7 @@ mod tests { fn recorder_preserves_redirect_exchange_order_and_raw_response_status() { let recorder = NetworkObservationRecorder::default(); recorder.record_request_header_block( - b"GET /start HTTP/1.1\r\nHost: example.test\r\nAccept-Encoding: gzip\r\n\r\n", + b"POST /start HTTP/1.1\r\nHost: example.test\r\nAccept-Encoding: gzip\r\n\r\n", ); recorder.record_response_header_line(b"HTTP/1.1 302 Found\r\n"); recorder.record_response_header_line(b"Location: /final\r\n"); @@ -720,6 +744,7 @@ mod tests { let journal = recorder.snapshot(); assert_eq!(journal.exchanges().len(), 2); + assert_eq!(journal.exchanges()[0].request().method(), Some("POST")); assert_eq!( journal.exchanges()[0].request().headers(), [ @@ -734,6 +759,13 @@ mod tests { .status(), 302 ); + assert_eq!( + journal + .final_request_observation() + .expect("final request") + .method(), + Some("GET") + ); assert_eq!( journal .final_request_observation() diff --git a/moli-layout/src/builder.rs b/moli-layout/src/builder.rs index 5e5c3c0fc31..f2f822df228 100644 --- a/moli-layout/src/builder.rs +++ b/moli-layout/src/builder.rs @@ -624,6 +624,9 @@ where let mut output = Vec::new(); let mut current = Some(principal); let mut run = Vec::new(); + let mut blocks: Option = None; + let mut preceding_inline = principal; + world.inline_split_sources.insert(owner); for child in children { if self.is_block_in_flow(world, child) { @@ -634,11 +637,24 @@ where std::mem::take(&mut run), )?; output.push(fragment); + preceding_inline = fragment; + } + if let Some(run) = blocks.as_mut() { + run.last = child; + } else { + blocks = Some(crate::world::InlineSplitBlockRun { + preceding_inline, + first: child, + last: child, + }); } output.push(child); continue; } + if let Some(run) = blocks.take() { + world.inline_split_block_runs.push(run); + } if current.is_none() { current = Some(self.allocate_inline_continuation(world, owner, style, semantics.clone())); @@ -646,6 +662,9 @@ where run.push(child); } + if let Some(run) = blocks { + world.inline_split_block_runs.push(run); + } if let Some(fragment) = current { self.replace_children_and_mark_context(world, fragment, run)?; output.push(fragment); diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index 9f5c614a9bf..7472fa9b00a 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -37,6 +37,16 @@ pub(crate) fn relative_atomic_inset_offset( style: &taffy::Style, containing_block_size: Size, container_direction: InlineDirection, +) -> Point { + relative_inset_offset(style, containing_block_size.map(Some), container_direction) +} + +/// The block-axis percentage basis may be indefinite even when the final +/// content height is nonzero (auto height or min-height-only sizing). +pub(crate) fn relative_inset_offset( + style: &taffy::Style, + containing_block_size: Size>, + container_direction: InlineDirection, ) -> Point { let inset = taffy::Rect { left: style.inset.left.maybe_resolve( diff --git a/moli-layout/src/pass.rs b/moli-layout/src/pass.rs index fbf2d088fe1..a0a570ee0d8 100644 --- a/moli-layout/src/pass.rs +++ b/moli-layout/src/pass.rs @@ -314,11 +314,13 @@ where /// /// Every iteration enters Taffy through the synthetic viewport root, but that /// is a scheduling entry point rather than an unconditional full-tree layout. -/// `invalidate_scrollbar_feedback` clears only changed boxes and their numeric -/// ancestors. All other subtrees keep valid Taffy cache entries and return +/// `invalidate_scrollbar_feedback` clears a changed local scroll container's +/// subtree and numeric ancestor path. A viewport gutter invalidates the whole +/// tree because it changes the initial containing block. Unaffected branches +/// of a local scrollbar change keep valid Taffy cache entries and return /// immediately. This is the same important boundary as Blink's corrective /// scrollbar relayout: pay a follow-up pass only after state changes, and only -/// recompute the paths whose available space can have changed. +/// recompute boxes whose available space can have changed. #[derive(Default)] struct NumericLayoutMetrics { pass_count: usize, diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index b2022c72e80..e563082330c 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, fmt::Debug, hash::Hash, time::Instant}; -use taffy::ResolveOrZero; +use taffy::{MaybeMath, MaybeResolve, ResolveOrZero}; use crate::layout_tree::{CssSizing, CssSizingBox, LayoutCoordinateSpace}; use crate::overflow::{OverflowProjection, inset_rect, offset_rect, outset_rect}; @@ -52,6 +52,7 @@ where projection.build_scroll_extents(&overflow); projection.build_coordinate_spaces()?; projection.build_fragments(); + projection.build_split_block_fragments(); projection.assign_clip_and_paint_order(); let content_size = projection.document_content_size(); @@ -750,6 +751,140 @@ where } } + fn build_split_block_fragments(&mut self) { + // Construction records only contiguous, promoted in-flow block runs. + // Their middle continuation uses the existing flow bounds and parent + // content width, not transformed descendant ink or a new numeric box. + // In particular this must not introduce an auto-height containing block. + for run in &self.world.inline_split_block_runs { + let first = &self.world.boxes[run.first.index()]; + let last = &self.world.boxes[run.last.index()]; + let Some(parent) = first.parent else { + continue; + }; + let content = self.boxes[parent.index()].content_box; + let basis = self.split_block_relative_inset_basis(parent); + let direction = self.world.boxes[parent.index()].style.direction(); + let first_offset = + crate::inline::relative_inset_offset(&first.style.taffy, basis, direction); + let last_offset = + crate::inline::relative_inset_offset(&last.style.taffy, basis, direction); + let top = first.final_layout.location.y - first_offset.y; + let bottom = + last.final_layout.location.y - last_offset.y + last.final_layout.size.height; + let rect = LayoutRect::new(content.x, top, content.width, (bottom - top).max(0.0)); + // A zero-area leading continuation follows escaped leading + // margins into the first block's flow position. It carries no + // line strut of its own and must not remain at the pre-collapse y. + for id in &self.boxes[run.preceding_inline.index()].fragments { + let existing = &mut self.fragments[id.index()]; + if matches!(existing.kind, LayoutFragmentKind::InlineBox { .. }) + && existing.rect.width == 0.0 + && existing.rect.height == 0.0 + { + let original = self.coordinate_spaces[existing.coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .map_point(LayoutPoint::new(existing.rect.x, existing.rect.y)); + let x = self.coordinate_spaces[parent.index() + 1] + .layout + .local_to_document_ignoring_css_transforms + .inverse() + .map(|inverse| inverse.map_point(original).x) + .unwrap_or(existing.rect.x); + let boundary = LayoutRect::new(x, top, 0.0, 0.0); + existing.rect = boundary; + existing.coordinate_space = + LayoutCoordinateSpaceId::from_index(parent.index() + 1); + existing.box_model = Some(LayoutFragmentBoxModel { + content: boundary, + padding: boundary, + border: boundary, + margin: boundary, + }); + } + } + let fragment = self.push_fragment(LayoutFragment { + id: LayoutFragmentId::from_index(0), + kind: LayoutFragmentKind::Box { + box_id: LayoutOutputBoxId::from_index(run.preceding_inline.index()), + }, + rect, + box_model: Some(LayoutFragmentBoxModel { + content: rect, + padding: rect, + border: rect, + margin: rect, + }), + coordinate_space: LayoutCoordinateSpaceId::from_index(parent.index() + 1), + clip_chain: None, + paint_order: None, + }); + self.register_box_fragment(run.preceding_inline.index(), fragment); + } + } + + fn split_block_relative_inset_basis(&self, parent: LayoutBoxId) -> taffy::Size> { + let content = self.boxes[parent.index()].content_box; + let Some(inputs) = self + .world + .inline_split_parent_inputs + .get(&parent) + .copied() + .flatten() + else { + return taffy::Size { + width: Some(content.width), + height: None, + }; + }; + let layout_box = &self.world.boxes[parent.index()]; + let style = &layout_box.style.taffy; + let percentage_basis = inputs + .constraint_space(layout_box.style.writing_mode()) + .margin_padding_percentage_basis(); + let padding = style + .padding + .resolve_or_zero(percentage_basis, crate::style::resolve_stylo_calc_value); + let border = style + .border + .resolve_or_zero(percentage_basis, crate::style::resolve_stylo_calc_value); + let padding_border = (padding + border).sum_axes(); + let adjustment = if style.box_sizing == taffy::BoxSizing::ContentBox { + padding_border + } else { + taffy::Size::ZERO + }; + // Match the block backend's relative-inset definiteness, not its + // broader percentage-size/min-height basis. The actual incoming + // definite dimensions win; auto/min-height alone remain indefinite. + let preferred = if inputs.sizing_mode == taffy::SizingMode::InherentSize { + let size = style + .size + .maybe_resolve(inputs.parent_size, crate::style::resolve_stylo_calc_value) + .maybe_add(adjustment); + block_relative_inset_preferred_size( + size, + style.size.map(|dimension| dimension.is_auto()), + layout_box.style.writing_mode(), + inputs.block_auto_behavior, + layout_box.resolved_aspect_ratio(), + padding_border, + ) + } else { + taffy::Size::NONE + }; + let vertical_inset = layout_box.final_layout.size.height - content.height; + taffy::Size { + width: Some(content.width), + height: inputs + .definite_dimensions + .height + .or(preferred.height) + .map(|height| (height - vertical_inset).max(0.0)), + } + } + fn register_box_fragment(&mut self, box_index: usize, fragment: LayoutFragmentId) { self.boxes[box_index].fragments.push(fragment); } @@ -1318,12 +1453,32 @@ where let right_padding = if has_right_edge { padding.right } else { 0.0 }; let left_border = if has_left_edge { border.left } else { 0.0 }; let right_border = if has_right_edge { border.right } else { 0.0 }; - let border_box = LayoutRect::new( + let mut border_box = LayoutRect::new( content_origin.x + fragment.rect.x + left_margin, content_origin.y + fragment.rect.y - padding.top - border.top, (fragment.rect.width - left_margin - right_margin).max(0.0), fragment.rect.height + padding.top + padding.bottom + border.top + border.bottom, ); + if fragment.rect.width == 0.0 + && inline_box + .source + .or(inline_box.owner) + .is_some_and(|source| world.inline_split_sources.contains(&source)) + && padding.top + padding.bottom + border.top + border.bottom == 0.0 + { + // Empty split continuations are zero-area boundaries of the block + // continuation, not font-height glyph boxes. Ordinary empty inlines + // retain their normal font metrics. + if let Some(line) = owner + .inline_layout + .as_ref() + .and_then(|context| context.fragments.lines.get(fragment.line_index)) + .filter(|line| line.rect.height == 0.0) + { + border_box.y = content_origin.y + line.rect.y; + border_box.height = 0.0; + } + } let padding_box = inset_rect( border_box, border.top, @@ -1353,10 +1508,86 @@ where } } +// Taffy 71bc149 compute/block.rs uses the *unclamped preferred size* for +// relative-inset definiteness, not used/min/max height. This narrow adapter +// mirrors common/aspect_ratio.rs::apply_preferred_aspect_ratio; it does not +// re-run layout or transfer min/max constraints. Taffy keeps that helper private. +fn block_relative_inset_preferred_size( + size: taffy::Size>, + size_is_auto: taffy::Size, + writing_mode: taffy::WritingMode, + block_auto_behavior: taffy::AutoSizeBehavior, + aspect_ratio: Option, + padding_border: taffy::Size, +) -> taffy::Size> { + let ratio_size = size.maybe_apply_aspect_ratio_with_box_sizing( + aspect_ratio, + taffy::BoxSizing::BorderBox, + padding_border, + ); + if block_auto_behavior != taffy::AutoSizeBehavior::StretchExplicit { + return ratio_size; + } + let source = writing_mode.to_logical(size); + let auto = writing_mode.to_logical(size_is_auto); + let mut resolved = writing_mode.to_logical(ratio_size); + if auto.block_size && source.block_size.is_none() && resolved.block_size.is_some() { + resolved.block_size = None; + } + writing_mode.to_physical(resolved) +} + #[cfg(test)] mod paint_space_tests { use super::*; + #[test] + fn split_block_relative_basis_keeps_preferred_ratio_and_stretch_provenance() { + use taffy::{AutoSizeBehavior, BoxSizing, ResolvedAspectRatio, Size, WritingMode}; + let ratio = ResolvedAspectRatio::new(2.0, BoxSizing::BorderBox); + let source = Size { + width: Some(200.0), + height: None, + }; + let auto = Size { + width: false, + height: true, + }; + let fit = block_relative_inset_preferred_size( + source, + auto, + WritingMode::HorizontalTb, + AutoSizeBehavior::FitContent, + ratio, + Size::ZERO, + ); + assert_eq!(fit.height, Some(100.0)); + let stretch = block_relative_inset_preferred_size( + source, + auto, + WritingMode::HorizontalTb, + AutoSizeBehavior::StretchExplicit, + ratio, + Size::ZERO, + ); + assert_eq!(stretch.height, None); + let explicit = block_relative_inset_preferred_size( + Size { + width: Some(200.0), + height: Some(200.0), + }, + Size { + width: false, + height: false, + }, + WritingMode::HorizontalTb, + AutoSizeBehavior::StretchExplicit, + ratio, + Size::ZERO, + ); + assert_eq!(explicit.height, Some(200.0)); + } + fn property_transform(transform: LayoutTransform2D) -> ResolvedLayoutTransform { ResolvedLayoutTransform { transform, diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 1ece773c859..8163fd3530f 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -79,9 +79,14 @@ impl PreparedWorldLayout { self.numeric_viewport_layout = world.viewport_layout.unrounded_layout; } - /// Clears only the cache entries on changed scrollbar boxes and their - /// numeric ancestors. The reusable mark table makes this proportional to - /// the affected paths rather than to every box in the document. + /// Clears the cache entries whose available space can change after a + /// scrollbar appears. + /// + /// A viewport gutter changes the initial containing block, so every box + /// is affected. A local gutter changes the scroll container's content box, + /// so its complete numeric subtree and ancestor path are affected. Keeping + /// descendant caches in either case can reuse a size-only result during + /// the corrective pass and leave nested boxes without final geometry. /// /// Taffy's next compute still starts at the viewport root, as its public /// API requires, but siblings outside these paths retain their cached @@ -100,22 +105,38 @@ impl PreparedWorldLayout { assert!(self.feedback_invalidation_worklist.is_empty()); self.feedback_invalidation_marks .resize(world.boxes.len(), false); - if viewport_changed { - self.feedback_invalidation_worklist.push(world.root); - } - self.feedback_invalidation_worklist - .extend(changed_boxes.iter().copied()); - let mut invalidated = Vec::new(); - while let Some(id) = self.feedback_invalidation_worklist.pop() { - if self.feedback_invalidation_marks[id.index()] { - continue; + if viewport_changed { + for index in 0..world.boxes.len() { + let id = LayoutBoxId::from_index(index); + self.feedback_invalidation_marks[index] = true; + invalidated.push(id); + world.boxes[index].clear_layout_caches(); } - self.feedback_invalidation_marks[id.index()] = true; - invalidated.push(id); - world.boxes[id.index()].clear_layout_caches(); - if let Some(parent) = world.boxes[id.index()].layout_parent { - self.feedback_invalidation_worklist.push(parent); + } else { + self.feedback_invalidation_worklist + .extend(changed_boxes.iter().copied()); + while let Some(id) = self.feedback_invalidation_worklist.pop() { + if self.feedback_invalidation_marks[id.index()] { + continue; + } + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); + self.feedback_invalidation_worklist + .extend(world.boxes[id.index()].layout_children.iter().copied()); + } + + for changed in changed_boxes.iter().copied() { + let mut ancestor = world.boxes[changed.index()].layout_parent; + while let Some(id) = ancestor { + if !self.feedback_invalidation_marks[id.index()] { + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); + } + ancestor = world.boxes[id.index()].layout_parent; + } } } if viewport_changed || !changed_boxes.is_empty() { @@ -1622,6 +1643,14 @@ where } fn compute_child_layout(&mut self, node_id: NodeId, inputs: LayoutInput) -> LayoutOutput { + if !self.is_viewport_taffy_node(node_id) + && inputs.run_mode == RunMode::PerformLayout + && let Some(slot) = self + .inline_split_parent_inputs + .get_mut(&LayoutBoxId::from_taffy(node_id)) + { + *slot = Some(inputs); + } // Float parents own horizontal margin subtraction in both the Taffy // block path and Moli's Parley IFC path. The generic intrinsic // resolver follows Taffy's ordinary child-input contract and subtracts @@ -1781,6 +1810,13 @@ where inputs: LayoutInput, block_context: Option<&mut BlockContext<'_>>, ) -> LayoutOutput { + if inputs.run_mode == RunMode::PerformLayout + && let Some(slot) = self + .inline_split_parent_inputs + .get_mut(&LayoutBoxId::from_taffy(node_id)) + { + *slot = Some(inputs); + } if self.should_hide(node_id, inputs) { return compute_hidden_layout(self, node_id); } diff --git a/moli-layout/src/world.rs b/moli-layout/src/world.rs index 2b86514028b..0ab2a79b0d3 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -640,6 +640,14 @@ impl LayoutBox { } } +/// One source-owned middle continuation, with no numeric containing block. +#[derive(Debug)] +pub(crate) struct InlineSplitBlockRun { + pub(crate) preceding_inline: LayoutBoxId, + pub(crate) first: LayoutBoxId, + pub(crate) last: LayoutBoxId, +} + /// Entire short-lived sidecar used for one construction/layout demand. #[derive(Debug)] pub struct LayoutWorld @@ -649,6 +657,9 @@ where pub(crate) boxes: Vec>, pub(crate) source_mapping: HashMap, pub(crate) display_contents_mapping: HashMap>, + pub(crate) inline_split_sources: std::collections::HashSet, + pub(crate) inline_split_block_runs: Vec, + pub(crate) inline_split_parent_inputs: HashMap>, pub(crate) root: LayoutBoxId, /// The root is the document element only for a complete document source. /// Subtree and synthetic sources still use the same internal root slot. @@ -676,6 +687,9 @@ where boxes: vec![root], source_mapping: HashMap::new(), display_contents_mapping: HashMap::new(), + inline_split_sources: std::collections::HashSet::new(), + inline_split_block_runs: Vec::new(), + inline_split_parent_inputs: HashMap::new(), root: LayoutBoxId::from_index(0), root_is_document_element, quirks_mode: style::context::QuirksMode::NoQuirks, @@ -956,6 +970,30 @@ where *ids = ids.iter().filter_map(|id| remap[id.index()]).collect(); !ids.is_empty() }); + self.inline_split_block_runs.retain_mut(|run| { + let (Some(preceding), Some(first), Some(last)) = ( + remap[run.preceding_inline.index()], + remap[run.first.index()], + remap[run.last.index()], + ) else { + return false; + }; + run.preceding_inline = preceding; + run.first = first; + run.last = last; + true + }); + self.inline_split_sources + .retain(|source| self.source_mapping.contains_key(source)); + self.inline_split_parent_inputs = self + .inline_split_block_runs + .iter() + .filter_map(|run| { + self.boxes[run.first.index()] + .parent + .map(|parent| (parent, None)) + }) + .collect(); } /// Validates graph ownership invariants without relying on allocator IDs. diff --git a/moli-layout/tests/phase4_layout_contract.rs b/moli-layout/tests/phase4_layout_contract.rs index 728800a5a37..e66c5ea00d4 100644 --- a/moli-layout/tests/phase4_layout_contract.rs +++ b/moli-layout/tests/phase4_layout_contract.rs @@ -1564,41 +1564,29 @@ fn inline_blocks_use_their_internal_last_line_baseline_and_overflow_fallback() { .primary .insert(5, nav_item_style(BLUE, 19.0, Overflow::Hidden)); let fallback = render(&source, &mut styles, 300, 100); - let more = rect(&fallback, BLUE); - assert_close(more.y, 19.0); - assert_close(more.height, 42.0); - assert_close(rect(&fallback, RED).y, rect(&fallback, GREEN).y); - // A clipped inline-block uses its bottom margin edge as the baseline. - // Check the adjacent text's actual baselines: its box top depends on the - // platform font's ascent, even with an explicit font size and line height. - let baselines = fallback - .fragments - .iter() - .filter_map(|fragment| match fragment { - PaintFragment::GlyphRun(run) => run.glyphs_in_surface().first().map(|glyph| glyph.y), - _ => None, - }) - .collect::>(); - assert_eq!( - baselines.len(), - 3, - "each nav item should paint one text run" - ); - assert_close(baselines[0], more.y + more.height); - assert_close(baselines[1], more.y + more.height); - - // Extra block-end padding moves the text-backed peers by the same amount - // while leaving the clipped inline-block's top edge fixed. let fallback_news = rect(&fallback, RED); let fallback_hao = rect(&fallback, GREEN); + let fallback_more = rect(&fallback, BLUE); + assert_close(fallback_news.y, fallback_hao.y); + assert!(fallback_news.y > news.y); + assert_close(fallback_more.y, more.y); + + // The scrolling inline-block synthesizes its baseline from its margin-box + // edge. Increasing only its block-end padding must therefore move both + // text-backed peers by exactly the same amount while its own top remains + // fixed. This checks the fallback contract without baking a platform font's + // ascent into an absolute y coordinate. styles .primary .insert(5, nav_item_style(BLUE, 29.0, Overflow::Hidden)); let deeper_fallback = render(&source, &mut styles, 300, 100); assert_close(rect(&deeper_fallback, RED).y, fallback_news.y + 10.0); assert_close(rect(&deeper_fallback, GREEN).y, fallback_hao.y + 10.0); - assert_close(rect(&deeper_fallback, BLUE).y, more.y); - assert_close(rect(&deeper_fallback, BLUE).height, more.height + 10.0); + assert_close(rect(&deeper_fallback, BLUE).y, fallback_more.y); + assert_close( + rect(&deeper_fallback, BLUE).height, + fallback_more.height + 10.0, + ); } #[test] diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index fc1e7ae1f13..e318b992989 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -785,8 +785,8 @@ fn auto_scrollbar_feedback_reveals_the_perpendicular_axis() { let output = build(&source, &mut styles); assert_eq!(output.metrics.numeric_layout_pass_count, 3); assert_eq!( - output.metrics.numeric_feedback_invalidated_node_count, 4, - "each newly revealed axis invalidates only the scroller-to-root path" + output.metrics.numeric_feedback_invalidated_node_count, 6, + "each newly revealed axis invalidates the scroller subtree and root path" ); let metrics = output.element_metrics_for_source(1).unwrap(); assert_eq!( @@ -912,6 +912,180 @@ fn fixed_inline_font() -> (ResolvedLayoutStyle, DocumentLayoutServices) { (style, services) } +#[test] +fn viewport_scrollbar_feedback_relayouts_nested_absolute_descendants() { + for (tall_height, expects_feedback) in [(100.0, false), (480.0, true)] { + let source = Source(vec![ + Node::element("root", vec![1, 6]), + Node::element("positioned-container", vec![2]), + Node::element("absolute-menu", vec![3]), + Node::element("menu-item", vec![4]), + Node::element("menu-link", vec![5]), + Node::text("menu-link-text", "New"), + Node::element("page-content", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles.0.insert( + 1, + fixed_size(LayoutDisplay::Block, 40.0, 25.0).with_position(LayoutPosition::Relative), + ); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0), + right: LengthPercentageAuto::auto(), + top: length(25.0), + bottom: LengthPercentageAuto::auto(), + }, + size: Size { + width: length(120.0), + height: Dimension::auto(), + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Absolute), + ); + styles + .0 + .insert(3, resolved(LayoutDisplay::Block, Style::default())); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 120.0, 35.0)); + styles.0.insert( + 5, + resolved(LayoutDisplay::Inline, Style::default()).with_text_metrics(14.0, 21.0), + ); + styles + .0 + .insert(6, fixed_size(LayoutDisplay::Block, 320.0, tall_height)); + + let output = build(&source, &mut styles); + assert_eq!( + output.metrics.numeric_layout_pass_count > 1, + expects_feedback, + "only the overflowing control should need scrollbar feedback" + ); + let link = output + .source_output(4) + .and_then(|source| source.principal_box) + .and_then(|id| output.box_geometry(id)) + .expect("the nested absolute descendant must retain a principal box"); + assert_rect(link.border_box, LayoutRect::new(0.0, 0.0, 120.0, 35.0)); + } +} + +#[test] +fn local_scrollbar_feedback_invalidates_only_the_changed_subtree_and_ancestor_path() { + for (content_height, expects_feedback) in [(50.0, false), (200.0, true)] { + let source = Source(vec![ + Node::element("root", vec![1, 7]), + Node::element("scroller", vec![2, 6]), + Node::element("absolute-menu", vec![3]), + Node::element("menu-item", vec![4]), + Node::element("menu-link", vec![5]), + Node::text("menu-link-text", "New"), + Node::element("scroll-content", Vec::new()), + Node::element("unrelated-fixed", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles.0.insert( + 1, + resolved( + LayoutDisplay::Block, + Style { + position: Position::Relative, + size: Size { + width: length(200.0), + height: length(100.0), + }, + overflow: Point { + x: Overflow::Scroll, + y: Overflow::Scroll, + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Relative), + ); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0), + right: LengthPercentageAuto::auto(), + top: length(0.0), + bottom: LengthPercentageAuto::auto(), + }, + size: Size { + width: length(120.0), + height: Dimension::auto(), + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Absolute), + ); + styles + .0 + .insert(3, resolved(LayoutDisplay::Block, Style::default())); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 120.0, 35.0)); + styles.0.insert( + 5, + resolved(LayoutDisplay::Inline, Style::default()).with_text_metrics(14.0, 21.0), + ); + styles + .0 + .insert(6, fixed_size(LayoutDisplay::Block, 200.0, content_height)); + styles.0.insert( + 7, + fixed_size(LayoutDisplay::Block, 10.0, 10.0).with_position(LayoutPosition::Fixed), + ); + + let output = build(&source, &mut styles); + assert_eq!( + output.metrics.numeric_layout_pass_count > 1, + expects_feedback, + "a non-overflowing local scrollport must keep its first-pass caches" + ); + assert_eq!( + output.metrics.numeric_feedback_invalidated_node_count, + if expects_feedback { 12 } else { 0 }, + "each of two scrollbar turns must invalidate the five-box numeric scroller subtree and its root ancestor; inline text has no separate numeric box, and the unrelated fixed branch remains cached" + ); + let link = output + .source_output(4) + .and_then(|source| source.principal_box) + .and_then(|id| output.box_geometry(id)) + .expect("the nested local descendant must retain a principal box"); + assert_rect(link.border_box, LayoutRect::new(0.0, 0.0, 120.0, 35.0)); + assert_eq!( + output + .source_output(7) + .and_then(|source| source.principal_box) + .and_then(|id| output.box_geometry(id)) + .expect("the unrelated fixed branch must retain geometry") + .border_box, + LayoutRect::new(0.0, 0.0, 10.0, 10.0), + ); + } +} + #[test] fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { const TEXT: &str = concat!( @@ -2898,6 +3072,583 @@ fn caret_query_uses_parley_cluster_sides_and_inline_direction() { assert_cluster_sides("א", true); } +#[test] +fn split_inline_block_continuation_keeps_its_middle_fragment() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("split-inline", vec![2, 3]), + Node::element("flex", Vec::new()), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 120.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Flex, 50.0, 20.0)); + styles + .0 + .insert(3, fixed_size(LayoutDisplay::Block, 80.0, 30.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 3, "{rects:?}"); + let middle = rects[1].bounding_rect(); + assert_eq!(middle.width, 200.0, "{rects:?}"); + assert_eq!(middle.height, 50.0, "{rects:?}"); + assert_eq!(rects[0].bounding_rect().width, 0.0); + assert_eq!(rects[2].bounding_rect().width, 0.0); + assert_rect( + rects[0].bounding_rect(), + LayoutRect::new(0.0, 0.0, 0.0, 0.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(0.0, 50.0, 0.0, 0.0), + ); +} + +#[test] +fn split_inline_nested_and_transformed_continuations_keep_source_geometry() { + for nested in [false, true] { + for transformed_child in [false, true] { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("outer", vec![2]), + Node::element("inner", vec![3]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles.0.insert( + 0, + fixed_size(LayoutDisplay::Block, 200.0, 120.0) + .with_2d_transform(LayoutTransform2D::translation(10.0, 5.0)), + ); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + if nested { + LayoutDisplay::Inline + } else { + LayoutDisplay::Contents + }, + Style::default(), + ), + ); + let mut child = fixed_size(LayoutDisplay::Block, 50.0, 20.0); + if transformed_child { + child = child.with_2d_transform(LayoutTransform2D::translation(100.0, 10.0)); + } + styles.0.insert(3, child); + let output = build(&source, &mut styles); + for source_id in if nested { vec![1, 2] } else { vec![1] } { + let rects = output.client_rects_for_source(source_id); + assert_eq!( + rects.len(), + 3, + "nested={nested}, source={source_id}: {rects:?}" + ); + assert_rect( + rects[0].bounding_rect(), + LayoutRect::new(10.0, 5.0, 0.0, 0.0), + ); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(10.0, 5.0, 200.0, 20.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(10.0, 25.0, 0.0, 0.0), + ); + } + } + } +} + +#[test] +fn split_inline_does_not_union_out_of_flow_descendants() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2, 3]), + Node::element("block", Vec::new()), + Node::element("absolute", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 120.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + styles.0.insert( + 3, + fixed_size(LayoutDisplay::Block, 500.0, 800.0).with_position(LayoutPosition::Absolute), + ); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 3, "{rects:?}"); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 20.0), + ); +} + +#[test] +fn split_inline_percentage_block_height_uses_nonanonymous_containing_block() { + for nested in [false, true] { + for rule in 0..3 { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("outer", if nested { vec![2] } else { vec![3] }), + Node::element("inner", vec![3]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + for id in [1, 2] { + styles + .0 + .insert(id, resolved(LayoutDisplay::Inline, Style::default())); + } + let mut child = Style { + size: Size { + width: percent(0.5), + height: if rule == 2 { + length(150.0) + } else { + Dimension::auto() + }, + }, + ..Style::default() + }; + match rule { + 0 => child.size.height = percent(0.5), + 1 => child.min_size.height = percent(0.5), + _ => child.max_size.height = percent(0.5), + } + styles.0.insert(3, resolved(LayoutDisplay::Block, child)); + let output = build(&source, &mut styles); + assert_rect( + output.client_rects_for_source(3)[0].bounding_rect(), + LayoutRect::new(0.0, 0.0, 100.0, 100.0), + ); + let rects = output.client_rects_for_source(1); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 100.0), + ); + } + } +} + +#[test] +fn split_inline_middle_fragment_ignores_child_relative_insets() { + for parent_mode in 0..3 { + let fixed_height = parent_mode == 1; + for (top, bottom, expected_offset) in [ + (length(10.0), LengthPercentageAuto::auto(), 10.0), + (LengthPercentageAuto::auto(), length(10.0), -10.0), + ( + percent(0.1), + LengthPercentageAuto::auto(), + if fixed_height { 20.0 } else { 0.0 }, + ), + ( + LengthPercentageAuto::auto(), + percent(0.1), + if fixed_height { -20.0 } else { 0.0 }, + ), + ] { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles.0.insert( + 0, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(200.0), + height: if fixed_height { + length(200.0) + } else { + Dimension::auto() + }, + }, + min_size: Size { + width: Dimension::auto(), + height: if parent_mode == 2 { + length(200.0) + } else { + Dimension::auto() + }, + }, + ..Style::default() + }, + ), + ); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(100.0), + height: length(20.0), + }, + inset: Rect { + left: length(30.0), + top, + bottom, + ..Rect::auto() + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Relative), + ); + let output = build(&source, &mut styles); + assert_rect( + output.client_rects_for_source(2)[0].bounding_rect(), + LayoutRect::new(30.0, expected_offset, 100.0, 20.0), + ); + let rects = output.client_rects_for_source(1); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 20.0), + ); + } + } +} + +#[test] +fn split_inline_relative_basis_tracks_clamped_and_ratio_sized_parents() { + for mode in 0..3 { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2]), + Node::element("child", Vec::new()), + ]); + let mut styles = Styles::default(); + let mut parent = Style { + size: Size { + width: length(200.0), + height: if mode == 2 { + Dimension::auto() + } else { + length(200.0) + }, + }, + ..Style::default() + }; + if mode == 0 { + parent.max_size.height = length(100.0); + } + if mode == 1 { + parent.min_size.height = length(300.0); + } + if mode == 2 { + parent.aspect_ratio = Some(2.0); + } + styles.0.insert(0, resolved(LayoutDisplay::Block, parent)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(100.0), + height: length(20.0), + }, + inset: Rect { + top: percent(0.5), + ..Rect::auto() + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Relative), + ); + let output = build(&source, &mut styles); + let middle = output.client_rects_for_source(1)[1].bounding_rect(); + assert_rect(middle, LayoutRect::new(0.0, 0.0, 200.0, 20.0)); + } +} + +#[test] +fn split_inline_avatar_flex_and_title_inline_both_keep_nonempty_geometry() { + let source = Source(vec![ + Node::element("root", vec![1, 5]), + Node::element("avatar-cell", vec![2]), + Node::html_element("avatar-link", "a", vec![3]), + Node::element("avatar-flex", vec![4]), + Node::text("avatar-letter", "D"), + Node::html_element("title-link", "a", vec![6]), + Node::text("title", "Primer / design"), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles + .0 + .insert(1, fixed_size(LayoutDisplay::Block, 48.0, 48.0)); + styles + .0 + .insert(3, fixed_size(LayoutDisplay::Flex, 48.0, 48.0)); + for id in [2, 4, 5, 6] { + styles + .0 + .insert(id, resolved(LayoutDisplay::Inline, Style::default())); + } + let output = build(&source, &mut styles); + let avatar = output.client_rects_for_source(2); + assert_eq!(avatar.len(), 3); + assert_rect( + avatar[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 48.0, 48.0), + ); + let title = output.client_rects_for_source(5); + assert!(!title.is_empty() && title.iter().any(|r| r.bounding_rect().width > 0.0)); +} + +#[test] +fn split_inline_two_block_runs_keep_content_fragment_order() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2, 3, 4]), + Node::element("first", Vec::new()), + Node::text("between", "between"), + Node::element("second", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + styles + .0 + .insert(3, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 80.0, 30.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 5, "{rects:?}"); + assert_eq!(rects[1].bounding_rect().height, 20.0); + assert!(rects[2].bounding_rect().width > 0.0); + assert_eq!(rects[3].bounding_rect().height, 30.0); + for pair in rects.windows(2) { + assert!( + pair[0].bounding_rect().y <= pair[1].bounding_rect().y, + "{rects:?}" + ); + } +} + +#[test] +fn split_inline_large_float_between_blocks_does_not_expand_middle_fragments() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2, 3, 4]), + Node::element("first", Vec::new()), + Node::element("float", Vec::new()), + Node::element("second", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 200.0, 20.0)); + styles.0.insert( + 3, + fixed_size(LayoutDisplay::Block, 500.0, 800.0) + .with_float(taffy::Float::Left, taffy::Clear::None), + ); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 200.0, 30.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 5, "{rects:?}"); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 20.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(500.0, 20.0, 0.0, 0.0), + ); + assert_rect( + rects[3].bounding_rect(), + LayoutRect::new(0.0, 20.0, 200.0, 30.0), + ); +} + +#[test] +fn split_inline_metadata_survives_discarded_table_children() { + let source = Source(vec![ + Node::element("root", vec![1, 4]), + Node::element("column-group", vec![2]), + Node::element("discarded", vec![3]), + Node::text("discarded-text", "discarded"), + Node::element("inline", vec![5]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles.0.insert( + 1, + resolved(LayoutDisplay::TableColumnGroup, Style::default()), + ); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + styles + .0 + .insert(3, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(4, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(5, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(4); + assert_eq!(rects.len(), 3, "{rects:?}"); + assert_eq!(rects[1].bounding_rect().width, 200.0); + assert_eq!(rects[1].bounding_rect().height, 20.0); +} + +#[test] +fn split_inline_escaped_margins_keep_middle_flow_bounds() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(100.0), + height: length(20.0), + }, + margin: Rect { + top: length(10.0), + bottom: length(10.0), + ..Rect::zero() + }, + ..Style::default() + }, + ), + ); + let output = build(&source, &mut styles); + assert_rect( + output.client_rects_for_source(2)[0].bounding_rect(), + LayoutRect::new(0.0, 10.0, 100.0, 20.0), + ); + let rects = output.client_rects_for_source(1); + assert_rect( + rects[0].bounding_rect(), + LayoutRect::new(0.0, 10.0, 0.0, 0.0), + ); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 10.0, 200.0, 20.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(0.0, 40.0, 0.0, 0.0), + ); +} + +#[test] +fn split_inline_middle_fragment_preserves_child_flow_and_margin_collapse() { + for margin in [0.0, 10.0, -5.0] { + let mut observed = Vec::new(); + for split in [false, true] { + let source = Source(vec![ + Node::element("root", if split { vec![1] } else { vec![2, 3] }), + Node::element("inline", vec![2, 3]), + Node::element("first", Vec::new()), + Node::element("second", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 120.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + for id in [2, 3] { + styles.0.insert( + id, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: percent(0.5), + height: length(20.0), + }, + margin: Rect { + top: length(margin), + bottom: length(margin), + ..Rect::zero() + }, + ..Style::default() + }, + ), + ); + } + let output = build(&source, &mut styles); + observed.push([2, 3].map(|id| output.client_rects_for_source(id)[0].bounding_rect())); + } + assert_eq!(observed[0], observed[1], "margin={margin}"); + } +} + #[test] fn split_inline_continuations_remain_mapped_to_the_originating_element() { let source = Source(vec![ @@ -2927,9 +3678,13 @@ fn split_inline_continuations_remain_mapped_to_the_originating_element() { let output = build(&source, &mut styles); assert_fragment_batches_match_direct_queries(&output, 0..7); let rects = output.client_rects_for_source(1); - assert_eq!(rects.len(), 2, "{rects:?}"); + assert_eq!(rects.len(), 3, "{rects:?}"); let first = rects[0].bounding_rect(); - let second = rects[1].bounding_rect(); + let middle = rects[1].bounding_rect(); + let second = rects[2].bounding_rect(); + assert_eq!(middle.width, 200.0); + assert_eq!(middle.height, 20.0); + assert!(first.y < middle.y && middle.y < second.y, "{rects:?}"); assert!(first.width > 0.0 && second.width > 0.0); assert!(second.y > first.y, "{rects:?}"); let union = output diff --git a/moli-page-types/src/lib.rs b/moli-page-types/src/lib.rs index 1d85a92ec11..a831612343f 100644 --- a/moli-page-types/src/lib.rs +++ b/moli-page-types/src/lib.rs @@ -3285,6 +3285,14 @@ pub struct ChildFrameDocumentNetworkSnapshot { pub request_method: String, #[serde(default)] pub request_headers: Vec<(String, String)>, + /// Exact in-process transport observations for the actual child-navigation + /// hops. Serialized snapshots retain their historical lightweight shape. + #[serde(skip)] + pub network_observation_journal: moli_fetch::NetworkObservationJournal, + /// In-process browser-facing redirect metadata paired with the transport + /// journal. Serialized snapshots retain their historical lightweight shape. + #[serde(skip)] + pub redirect_chain: Vec, pub final_url: String, pub status: u16, #[serde(default)] diff --git a/moli-protocol-webdriver-classic/src/actions.rs b/moli-protocol-webdriver-classic/src/actions.rs index eb7f9325536..3ce7ab77ad6 100644 --- a/moli-protocol-webdriver-classic/src/actions.rs +++ b/moli-protocol-webdriver-classic/src/actions.rs @@ -176,6 +176,15 @@ pub fn element_click_input_commands( 0, Some(0), ), + dispatch_mouse_event_command( + context, + DevToolsMouseEventType::Moved, + DevToolsPointerType::Mouse, + point.x, + point.y, + 0, + Some(0), + ), dispatch_mouse_event_command( context, DevToolsMouseEventType::Pressed, diff --git a/moli-protocol/src/conn/browser_context/network_owner.rs b/moli-protocol/src/conn/browser_context/network_owner.rs index aeb49d4ba47..618785572f8 100644 --- a/moli-protocol/src/conn/browser_context/network_owner.rs +++ b/moli-protocol/src/conn/browser_context/network_owner.rs @@ -134,6 +134,14 @@ impl TargetNetworkListenerOwnerMut<'_> { } self.remove_network_observation_cursor(listener_session_id.as_deref()); self.remove_captured_response_body_visibility_for_session(listener_session_id.as_deref()); + if !self.is_attached_session() + && let Some(primary_session_id) = self.target.session_id().map(str::to_owned) + { + // Primary commands share one listener whether routed on the root + // channel or its flattened session. Captured events can carry the + // explicit primary ID, so revoke both wire identities on disable. + self.remove_captured_response_body_visibility_for_session(Some(&primary_session_id)); + } self.clear_network_observation_artifacts_if_unobserved(); true } diff --git a/moli-protocol/src/conn/page_state/fetch_state.rs b/moli-protocol/src/conn/page_state/fetch_state.rs index c08a20776ce..c9eb21c23b9 100644 --- a/moli-protocol/src/conn/page_state/fetch_state.rs +++ b/moli-protocol/src/conn/page_state/fetch_state.rs @@ -314,7 +314,7 @@ impl PageTargetHost { needs_fetch_navigation_request_id: bool, ) -> (String, Option, Option) { if clear_captured_response_bodies { - self.runtime_slot.clear_captured_response_bodies(); + self.runtime_slot.prepare_response_bodies_for_navigation(); } let mut allocator = self.runtime_slot.request_id_allocator(); let document_loader_id = diff --git a/moli-protocol/src/conn/runtime_load.rs b/moli-protocol/src/conn/runtime_load.rs index a52f864f845..e973548e0eb 100644 --- a/moli-protocol/src/conn/runtime_load.rs +++ b/moli-protocol/src/conn/runtime_load.rs @@ -4088,6 +4088,47 @@ mod tests { assert!(format!("{error:#}").contains("main document body capture task failed")); } + #[tokio::test] + async fn response_capture_survives_ordinary_renderer_body_retirement() { + let cancellation = moli_fetch::FetchCancelHandle::new(); + let (source_tx, source_rx) = tokio::sync::mpsc::unbounded_channel(); + let (source_completion_tx, source_completion_rx) = tokio::sync::oneshot::channel(); + let response = moli_fetch::StreamingRawResponse::new( + url::Url::parse("https://capture.test/document").unwrap(), + 200, + Vec::new(), + None, + Vec::new(), + false, + Vec::new(), + source_rx, + cancellation.clone(), + source_completion_rx, + ); + let (renderer_tx, renderer_rx) = tokio::sync::mpsc::channel(1); + let (renderer_completion_tx, renderer_completion_rx) = tokio::sync::oneshot::channel(); + drop(renderer_rx); + drop(renderer_completion_rx); + let capture = super::spawn_streaming_body_capture( + response, + None, + renderer_tx, + renderer_completion_tx, + ); + source_tx.send(b"prefix".to_vec()).unwrap(); + source_tx + .send(b"captured after parser retirement".to_vec()) + .unwrap(); + drop(source_tx); + source_completion_tx.send(Ok(())).unwrap(); + let body = capture.await.unwrap().unwrap(); + assert_eq!( + body.materialize_bytes().unwrap().as_slice(), + b"prefixcaptured after parser retirement" + ); + assert!(!cancellation.is_cancelled()); + } + #[test] fn decode_text_html_data_url_uses_data_url_processor() { assert_eq!( diff --git a/moli-protocol/src/conn/state/page_slot.rs b/moli-protocol/src/conn/state/page_slot.rs index 40795a4fe30..f9d89110287 100644 --- a/moli-protocol/src/conn/state/page_slot.rs +++ b/moli-protocol/src/conn/state/page_slot.rs @@ -760,6 +760,14 @@ impl TargetPageSlot { .is_some_and(|request| request.background_work == BackgroundWorkState::Running) } + pub(crate) fn cancel_inflight_document_navigation(&mut self) { + if let Some(request) = self.pending_navigation_request.as_mut() { + // Keep the token installed: the existing completion path owns the + // aborted response and must still settle this exact navigation. + request.cancel(); + } + } + pub(crate) fn bind_pending_document_navigation_renderer_page( &mut self, token: &DocumentNavigationToken, @@ -1659,6 +1667,31 @@ mod pending_renderer_page_tests { ); } + #[test] + fn navigation_cancellation_preserves_completion_token_and_is_target_local() { + let mut slot = TargetPageSlot::default(); + let token = slot.start_document_navigation("TID-1".to_owned(), "LOADER-1".to_owned()); + let cancellation = slot + .document_navigation_cancellation_handle(&token) + .unwrap(); + let mut peer = TargetPageSlot::default(); + let peer_token = peer.start_document_navigation("TID-2".to_owned(), "LOADER-2".to_owned()); + let peer_cancellation = peer + .document_navigation_cancellation_handle(&peer_token) + .unwrap(); + slot.cancel_inflight_document_navigation(); + assert!(cancellation.is_cancelled()); + assert!(!peer_cancellation.is_cancelled()); + assert!(slot.accepts_pending_document_navigation_event(&token)); + let next = slot.start_document_navigation("TID-1".to_owned(), "LOADER-next".to_owned()); + assert!( + !slot + .document_navigation_cancellation_handle(&next) + .unwrap() + .is_cancelled() + ); + } + #[test] fn navigation_binding_cannot_follow_a_superseding_navigation() { let mut slot = TargetPageSlot::default(); diff --git a/moli-protocol/src/conn/state/runtime_slot.rs b/moli-protocol/src/conn/state/runtime_slot.rs index 174b09c9118..0b1593a08e9 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -654,6 +654,10 @@ impl TargetRuntimeSlot { self.page_slot.has_inflight_background_navigation() } + pub(crate) fn cancel_inflight_document_navigation(&mut self) { + self.page_slot.cancel_inflight_document_navigation(); + } + pub(crate) fn accepts_document_body_completion_event( &self, token: &DocumentNavigationToken, @@ -1213,6 +1217,19 @@ impl TargetRuntimeSlot { self.network_agent.clear_captured_response_bodies(); } + pub(crate) fn configure_durable_response_bodies( + &mut self, + session_id: Option<&str>, + limits: Option, + ) { + self.network_agent + .configure_durable_response_bodies(session_id, limits); + } + + pub(crate) fn prepare_response_bodies_for_navigation(&mut self) { + self.network_agent.prepare_response_bodies_for_navigation(); + } + pub(crate) fn clear_network_body_artifacts(&mut self) { self.network_agent.clear_body_artifacts(); } diff --git a/moli-protocol/src/domains/dom/tests/geometry.rs b/moli-protocol/src/domains/dom/tests/geometry.rs index 9e4ba0a80e9..f8a21388de1 100644 --- a/moli-protocol/src/domains/dom/tests/geometry.rs +++ b/moli-protocol/src/domains/dom/tests/geometry.rs @@ -1660,7 +1660,7 @@ async fn scroll_into_view_if_needed_uses_first_rendered_child_of_display_content async fn scroll_into_view_if_needed_honors_stylesheet_display_and_visibility() { let mut ctx = TestContext::new(); load_bc(&mut ctx, "BID-A"); - let rows = "
row
".repeat(60); + let rows = "
row
".repeat(60); let url = format!( "data:text/html,{rows}
suppressed child
" ); @@ -1821,7 +1821,7 @@ async fn scroll_into_view_if_needed_observes_style_change_after_explicit_capture async fn scroll_into_view_if_needed_clamps_relative_rect_to_document_scroll_range() { let mut ctx = TestContext::new(); load_bc(&mut ctx, "BID-A"); - let rows = "
row
".repeat(60); + let rows = "
row
".repeat(60); let url = format!("data:text/html,{rows}
"); navigate_to_url_and_wait_for_load_async(&mut ctx, 44, url).await; ctx.capture_fixture_layout(None).await; diff --git a/moli-protocol/src/domains/network.rs b/moli-protocol/src/domains/network.rs index edc23951564..86aca1d8d76 100644 --- a/moli-protocol/src/domains/network.rs +++ b/moli-protocol/src/domains/network.rs @@ -504,6 +504,14 @@ fn start_set_network_domain_enabled_command( cmd: &Cmd<'_>, enabled: bool, ) -> NetworkCommandTaskStep { + let durable_limits = if enabled { + match settings::durable_body_limits(cmd) { + Ok(limits) => limits, + Err(plan) => return NetworkCommandTaskStep::Complete(plan), + } + } else { + None + }; let updated = if enabled { conn.enable_network_listener_for_session_owner(cmd.session_id) } else { @@ -516,6 +524,10 @@ fn start_set_network_domain_enabled_command( )); } + if enabled && let Ok(slot) = conn.runtime_session_owner_slot_mut(cmd.session_id) { + slot.configure_durable_response_bodies(cmd.session_id, durable_limits); + } + let kind = if enabled { PendingNetworkCommandKind::Enable } else { diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs index 6ec95029735..7cc72cd4c74 100644 --- a/moli-protocol/src/domains/network/agent.rs +++ b/moli-protocol/src/domains/network/agent.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use moli_bounded_buffer::{BoundedByteBuffer, ByteLimits, InsertOutcome}; use moli_core::page::{ScriptNetworkOutputItem, SubresourceNetworkRequestHandle}; @@ -15,6 +15,8 @@ use super::{ const RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES: usize = 20_000_000; const RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES: usize = 2_000_000; +// Bound zero-byte bodies and payload-free failure/eviction metadata as well. +const DURABLE_RESPONSE_BODY_MAX_ENTRIES: usize = 4096; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RendererSubresourceTeardownDisposition { @@ -705,6 +707,24 @@ impl TargetNetworkAgentState { .clear_captured_response_bodies(); } + pub(crate) fn configure_durable_response_bodies( + &mut self, + session_id: Option<&str>, + limits: Option, + ) { + self.artifacts + .body_artifacts + .captured_response_bodies + .configure_durable(session_id, limits); + } + + pub(crate) fn prepare_response_bodies_for_navigation(&mut self) { + self.artifacts + .body_artifacts + .captured_response_bodies + .prepare_navigation(); + } + pub(crate) fn clear_body_artifacts(&mut self) { self.artifacts.body_artifacts.clear_session_scoped(); } @@ -1340,6 +1360,9 @@ struct CapturedResponseBodyStore { /// `buffered_bodies`, making the bounded buffer the single body owner. bodies: HashMap, buffered_bodies: BoundedByteBuffer, + durable_sessions: HashMap, ByteLimits>, + durable_entry_order: VecDeque, + retained_request_ids: HashSet, } impl Default for CapturedResponseBodyStore { @@ -1356,9 +1379,121 @@ impl CapturedResponseBodyStore { Self { bodies: HashMap::new(), buffered_bodies: BoundedByteBuffer::new(limits), + durable_sessions: HashMap::new(), + durable_entry_order: VecDeque::new(), + retained_request_ids: HashSet::new(), } } + fn configure_durable(&mut self, session_id: Option<&str>, limits: Option) { + let session = session_id.map(str::to_owned); + if let Some(limits) = limits { + self.durable_sessions.insert(session, limits); + } else { + self.durable_sessions.remove(&session); + // Turning off durability revokes only old-document claims, not + // ordinary access to responses from the current document. + for request_id in self.retained_request_ids.clone() { + if let Some(body) = self.bodies.get_mut(&request_id) + && !body.remove_session_visibility(session_id) + { + self.bodies.remove(&request_id); + } + if let Some(body) = self.buffered_bodies.get_mut(&request_id) + && !body.remove_session_visibility(session_id) + { + self.buffered_bodies.remove(&request_id); + } + } + } + // One target owns one payload store. Multiple clients cannot multiply + // its budget; each requested budget is an upper bound, not a reserve. + let limits = self.durable_sessions.values().fold( + ByteLimits::new( + RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES, + RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES, + ), + |acc, limits| { + ByteLimits::new( + acc.max_total_bytes.min(limits.max_total_bytes), + acc.max_entry_bytes.min(limits.max_entry_bytes), + ) + }, + ); + for (id, mut body) in self.buffered_bodies.set_limits(limits) { + body.mark_evicted(); + self.bodies.insert(id, body); + } + if self.durable_sessions.is_empty() { + self.durable_entry_order.clear(); + self.retained_request_ids.clear(); + } else { + let mut untracked = self + .bodies + .keys() + .chain(self.buffered_bodies.iter().map(|(id, _)| id)) + .filter(|id| !self.durable_entry_order.contains(id)) + .cloned() + .collect::>(); + untracked.sort(); + self.durable_entry_order.extend(untracked); + self.trim_durable_entries(); + } + } + + fn track_durable_entry(&mut self, request_id: &str) { + if self.durable_sessions.is_empty() { + return; + } + self.durable_entry_order.retain(|id| id != request_id); + self.durable_entry_order.push_back(request_id.to_owned()); + self.trim_durable_entries(); + } + + fn trim_durable_entries(&mut self) { + while self.durable_entry_order.len() > DURABLE_RESPONSE_BODY_MAX_ENTRIES { + if let Some(id) = self.durable_entry_order.pop_front() { + self.bodies.remove(&id); + self.buffered_bodies.remove(&id); + self.retained_request_ids.remove(&id); + } + } + } + + fn prepare_navigation(&mut self) { + if self.durable_sessions.is_empty() { + self.clear(); + return; + } + let retain = |body: &mut CapturedResponseBody| { + body.session_ids + .retain(|id| self.durable_sessions.contains_key(id)); + // CDP durability does not opt BiDi collectors into retention. + body.collector_ids.clear(); + !body.session_ids.is_empty() + && !matches!(body.state, CapturedResponseBodyState::Pending) + }; + self.bodies.retain(|_, body| retain(body)); + let ids = self + .buffered_bodies + .iter() + .map(|(id, _)| id.clone()) + .collect::>(); + for id in ids { + if !self.buffered_bodies.get_mut(&id).is_some_and(retain) { + self.buffered_bodies.remove(&id); + } + } + self.retained_request_ids = self + .bodies + .keys() + .chain(self.buffered_bodies.iter().map(|(id, _)| id)) + .cloned() + .collect(); + self.durable_entry_order + .retain(|id| self.retained_request_ids.contains(id)); + } + #[cfg(test)] pub(crate) fn insert( &mut self, @@ -1384,6 +1519,7 @@ impl CapturedResponseBodyStore { collection_was_gated: bool, ) { let byte_len = body.len(); + let tracked_request_id = request_id.clone(); let captured = CapturedResponseBody::from_captured_body_with_collector_scope( body, session_ids, @@ -1406,6 +1542,7 @@ impl CapturedResponseBodyStore { self.bodies.insert(request_id, rejected_body); } } + self.track_durable_entry(&tracked_request_id); } pub(crate) fn insert_pending_with_collector_scope( @@ -1420,6 +1557,7 @@ impl CapturedResponseBodyStore { { return; } + let tracked_request_id = request_id.clone(); self.bodies.insert( request_id, CapturedResponseBody::pending_with_collector_scope( @@ -1428,6 +1566,7 @@ impl CapturedResponseBodyStore { collection_was_gated, ), ); + self.track_durable_entry(&tracked_request_id); } pub(crate) fn insert_failed_with_collector_scope( @@ -1438,6 +1577,7 @@ impl CapturedResponseBodyStore { collector_ids: impl IntoIterator, collection_was_gated: bool, ) { + let tracked_request_id = request_id.clone(); self.buffered_bodies.remove(&request_id); self.bodies.insert( request_id, @@ -1448,6 +1588,7 @@ impl CapturedResponseBodyStore { collection_was_gated, ), ); + self.track_durable_entry(&tracked_request_id); } pub(crate) fn get(&self, request_id: &str) -> Option<&CapturedResponseBody> { @@ -1467,6 +1608,8 @@ impl CapturedResponseBodyStore { pub(crate) fn clear(&mut self) { self.bodies.clear(); self.buffered_bodies.clear(); + self.durable_entry_order.clear(); + self.retained_request_ids.clear(); } #[cfg(test)] @@ -1480,6 +1623,7 @@ impl CapturedResponseBodyStore { } pub(crate) fn remove_session_visibility(&mut self, session_id: Option<&str>) { + self.configure_durable(session_id, None); self.bodies .retain(|_, body| body.remove_session_visibility(session_id)); @@ -1963,6 +2107,105 @@ mod tests { ); } + #[test] + fn durable_response_body_store_bounds_payload_metadata_and_zero_byte_entries() { + let mut store = CapturedResponseBodyStore::default(); + store.configure_durable( + Some("owner"), + Some(moli_bounded_buffer::ByteLimits::new(5, 3)), + ); + store.insert("a".into(), "aaa".into(), [Some("owner".into())]); + store.insert("b".into(), "bbb".into(), [Some("owner".into())]); + assert!(store.get("a").unwrap().body_bytes_limited(10).is_err()); + assert_eq!(store.buffered_body_bytes(), 3); + store.insert("oversize".into(), "xxxx".into(), [Some("owner".into())]); + assert!( + store + .get("oversize") + .unwrap() + .body_bytes_limited(10) + .is_err() + ); + for n in 0..super::DURABLE_RESPONSE_BODY_MAX_ENTRIES + 10 { + store.insert(format!("empty-{n}"), String::new(), [Some("owner".into())]); + store.insert_failed_with_collector_scope( + format!("failed-{n}"), + "failed".into(), + [Some("owner".into())], + [], + false, + ); + } + assert!( + store.bodies.len() + store.buffered_bodies.len() + <= super::DURABLE_RESPONSE_BODY_MAX_ENTRIES + ); + assert_eq!( + store.durable_entry_order.len(), + super::DURABLE_RESPONSE_BODY_MAX_ENTRIES + ); + assert!(!store.contains_key("a")); + store.prepare_navigation(); + store.remove_session_visibility(Some("owner")); + assert!(store.is_empty()); + assert!(store.durable_sessions.is_empty()); + assert!(store.retained_request_ids.is_empty()); + assert!(store.durable_entry_order.is_empty()); + } + + #[test] + fn durable_response_body_store_navigation_preserves_only_existing_opted_claims() { + let mut store = CapturedResponseBodyStore::default(); + store.configure_durable( + Some("a"), + Some(moli_bounded_buffer::ByteLimits::new(100, 50)), + ); + store.configure_durable( + Some("b"), + Some(moli_bounded_buffer::ByteLimits::new(80, 40)), + ); + store.insert( + "old".into(), + "body".into(), + [Some("a".into()), Some("ordinary".into())], + ); + store.insert_pending_with_collector_scope("pending".into(), [Some("a".into())], [], false); + store.prepare_navigation(); + let body = store.get("old").unwrap(); + assert!(body.is_visible_to_session(Some("a"))); + assert!(!body.is_visible_to_session(Some("b"))); + assert!(!body.is_visible_to_session(Some("ordinary"))); + assert!(!store.contains_key("pending")); + assert_eq!( + store.buffered_bodies.limits(), + moli_bounded_buffer::ByteLimits::new(80, 40) + ); + store.insert("current".into(), "current".into(), [Some("a".into())]); + store.configure_durable(Some("a"), None); + assert!(!store.contains_key("old")); + assert!(store.contains_key("current")); + store.prepare_navigation(); + assert!(store.is_empty()); + let other_target = CapturedResponseBodyStore::default(); + assert!(!other_target.contains_key("current")); + } + + #[test] + fn durable_response_body_default_navigation_still_clears_and_budget_shrink_releases_payloads() { + let mut store = CapturedResponseBodyStore::default(); + store.insert("ordinary".into(), "body".into(), [None]); + store.prepare_navigation(); + assert!(store.is_empty()); + store.configure_durable(None, Some(moli_bounded_buffer::ByteLimits::new(20, 10))); + store.insert("a".into(), "aaaa".into(), [None]); + store.insert("b".into(), "bbbb".into(), [None]); + store.configure_durable(None, Some(moli_bounded_buffer::ByteLimits::new(5, 3))); + assert_eq!(store.buffered_body_bytes(), 0); + store.clear(); + assert!(store.is_empty()); + assert!(store.retained_request_ids.is_empty()); + } + #[test] fn response_body_store_marks_oldest_payload_evicted_when_total_budget_fills() { let mut store = diff --git a/moli-protocol/src/domains/network/backlog.rs b/moli-protocol/src/domains/network/backlog.rs index 82c35001ee5..4a3037fd9e3 100644 --- a/moli-protocol/src/domains/network/backlog.rs +++ b/moli-protocol/src/domains/network/backlog.rs @@ -261,6 +261,7 @@ fn emit_complete_subresource_network_delivery_record( .then(|| output.request_cookie_report()) .flatten(), &[], + true, ); } } @@ -312,6 +313,7 @@ fn emit_complete_subresource_network_delivery_record( !redirect.cookie_set_reports.is_empty(), redirect.request_cookie_report.as_ref(), &[], + true, ); emit_redirect_response_received_extra_info( out, @@ -453,6 +455,10 @@ fn emit_staged_subresource_request_started( false, output.request_cookie_report(), &[], + // Cookie selection precedes transport header generation. Keep + // it in the main event, but defer ExtraInfo until completion + // supplies the actual headers (or a terminal fallback). + false, ); } } @@ -528,6 +534,7 @@ fn emit_staged_subresource_response_started( !redirect.cookie_set_reports.is_empty(), redirect.request_cookie_report.as_ref(), &[], + true, ); emit_redirect_response_received_extra_info( out, diff --git a/moli-protocol/src/domains/network/events.rs b/moli-protocol/src/domains/network/events.rs index df313f66a0b..95b6fb493a2 100644 --- a/moli-protocol/src/domains/network/events.rs +++ b/moli-protocol/src/domains/network/events.rs @@ -279,6 +279,7 @@ pub(crate) fn emit_request_will_be_sent( redirect_has_extra_info: bool, cookie_access_report: Option<&moli_cookie_jar::StoredCookieQueryReport>, blocked_intercepts: &[DevToolsNetworkInterceptId], + emit_cookie_extra_info: bool, ) { if redirect_response.is_some_and(|(_, _, _, from_cache, _)| from_cache) { emit_request_served_from_cache(out, session_id, request_id); @@ -343,7 +344,7 @@ pub(crate) fn emit_request_will_be_sent( }), session_id, ); - if let Some(cookie_access_report) = cookie_access_report { + if emit_cookie_extra_info && let Some(cookie_access_report) = cookie_access_report { emit_request_will_be_sent_extra_info( out, session_id, @@ -1536,6 +1537,7 @@ mod tests { false, None, &[], + true, ); assert_eq!(events.len(), 2); diff --git a/moli-protocol/src/domains/network/main_document_progress/mod.rs b/moli-protocol/src/domains/network/main_document_progress/mod.rs index 785ff767e4b..6691650f928 100644 --- a/moli-protocol/src/domains/network/main_document_progress/mod.rs +++ b/moli-protocol/src/domains/network/main_document_progress/mod.rs @@ -377,17 +377,26 @@ fn materialize_navigation_load_outcome( materialize_no_content_navigation_progress(conn, state, *navigation) } NavigationLoadOutcome::NetworkFailure(error_text) => { + let document_policy = failed_navigation_document_policy(&error_text); MaterializedNavigationLoadOutcome::Failed(materialize_failed_navigation_progress( conn, state, error_text, - FailedNavigationDocumentPolicy::InvalidateCommittedDocument, + document_policy, FailedNavigationResponseMode::CdpErrorTextResult, )) } } } +fn failed_navigation_document_policy(error_text: &str) -> FailedNavigationDocumentPolicy { + if error_text == moli_fetch::NET_ERR_ABORTED_ERROR_TEXT { + FailedNavigationDocumentPolicy::PreserveCommittedDocument + } else { + FailedNavigationDocumentPolicy::InvalidateCommittedDocument + } +} + pub(crate) fn materialize_navigation_load_result( conn: &mut CdpConnection, state: &NavigationDispatchState, @@ -717,7 +726,7 @@ impl MainDocumentBodyProgressSource { response_headers, response_cookie_reports, network_observation_journal, - redirect_chain.len(), + redirect_chain, network_extra_info_available, response_from_cache, negotiated_http_version, @@ -810,7 +819,7 @@ impl MainDocumentBodyProgressSource { }; let final_exchange = navigation_exchange_group( network_observation_journal, - redirect_chain.len(), + redirect_chain, redirect_chain.len(), ) .and_then(|group| group.last()); @@ -827,6 +836,7 @@ impl MainDocumentBodyProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, + SubresourceRequestInitiatorType::Other, ); live_source.send_progress_events(MainDocumentProgressPhase::RequestStarted, events); } @@ -865,7 +875,7 @@ impl MainDocumentBodyProgressSource { if network_extra_info_available { let (response_status, response_headers) = observed_response_metadata( network_observation_journal, - redirect_chain.len(), + redirect_chain, redirect_chain.len(), response_status, response_headers, @@ -1050,6 +1060,7 @@ impl MainDocumentLiveNetworkProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, + SubresourceRequestInitiatorType::Other, ); self.send_progress_events(MainDocumentProgressPhase::RequestStarted, events); } @@ -1073,6 +1084,7 @@ impl MainDocumentLiveNetworkProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, + SubresourceRequestInitiatorType::Other, ); self.send_progress_events_into_output( MainDocumentProgressPhase::RequestStarted, @@ -1090,13 +1102,15 @@ impl MainDocumentLiveNetworkProgressSource { redirect_chain: &[RedirectInfo], network_observation_journal: &NetworkObservationJournal, final_network_extra_info_available: bool, + request_initiator_type: SubresourceRequestInitiatorType, ) -> Vec { let mut events = Vec::new(); - let initial_network_extra_info_available = !network_observation_journal.is_empty() - || redirect_chain - .first() - .is_some_and(|redirect| redirect.network_extra_info_available) - || (redirect_chain.is_empty() && final_network_extra_info_available); + let initial_network_extra_info_available = + navigation_exchange_group(network_observation_journal, redirect_chain, 0).is_some() + || redirect_chain + .first() + .is_some_and(|redirect| redirect.network_extra_info_available) + || (redirect_chain.is_empty() && final_network_extra_info_available); if initial_network_extra_info_available { let initial_request_cookie_report = if redirect_chain.is_empty() { final_request_cookie_report @@ -1109,7 +1123,7 @@ impl MainDocumentLiveNetworkProgressSource { self.progress_target(), observed_request_headers( network_observation_journal, - redirect_chain.len(), + redirect_chain, 0, &self.initial_request_headers, ), @@ -1118,15 +1132,27 @@ impl MainDocumentLiveNetworkProgressSource { } for (index, redirect) in redirect_chain.iter().enumerate() { let target = self.progress_target(); + let request_method = observed_request_method( + network_observation_journal, + redirect_chain, + index + 1, + final_request_method, + ); + let request_headers = observed_request_headers( + network_observation_journal, + redirect_chain, + index + 1, + final_request_headers, + ); let observed_response_available = - navigation_exchange_group(network_observation_journal, redirect_chain.len(), index) + navigation_exchange_group(network_observation_journal, redirect_chain, index) .and_then(|group| group.last()) .and_then(NetworkExchangeObservation::response) .is_some(); if observed_response_available && !redirect.network_extra_info_available { let (status, headers) = observed_response_metadata( network_observation_journal, - redirect_chain.len(), + redirect_chain, index, redirect.status, &redirect.headers, @@ -1143,10 +1169,12 @@ impl MainDocumentLiveNetworkProgressSource { events.push(MainDocumentNavigationProgressEvent::RequestWillBeSent { target: target.clone(), url: redirect.to_url.clone(), - method: final_request_method.to_owned(), - request_body: request_body.map(str::to_owned), - request_headers: final_request_headers.to_vec(), - request_initiator_type: SubresourceRequestInitiatorType::Other, + method: request_method.clone(), + request_body: (request_method == final_request_method) + .then(|| request_body.map(str::to_owned)) + .flatten(), + request_headers, + request_initiator_type, redirect_response: Box::new(Some(MainDocumentRedirectResponse { url: redirect.from_url.clone(), status: redirect.status, @@ -1167,7 +1195,7 @@ impl MainDocumentLiveNetworkProgressSource { if observed_response_available && redirect.network_extra_info_available { let (status, headers) = observed_response_metadata( network_observation_journal, - redirect_chain.len(), + redirect_chain, index, redirect.status, &redirect.headers, @@ -1181,14 +1209,13 @@ impl MainDocumentLiveNetworkProgressSource { }, ); } - let request_network_extra_info_available = network_observation_journal - .exchanges() - .get(index + 1) - .is_some() - || redirect_chain - .get(index + 1) - .is_some_and(|next| next.network_extra_info_available) - || (index + 1 == redirect_chain.len() && final_network_extra_info_available); + let request_network_extra_info_available = + navigation_exchange_group(network_observation_journal, redirect_chain, index + 1) + .is_some() + || redirect_chain + .get(index + 1) + .is_some_and(|next| next.network_extra_info_available) + || (index + 1 == redirect_chain.len() && final_network_extra_info_available); if request_network_extra_info_available { let cookie_report = redirect.request_cookie_report.clone().or_else(|| { (index + 1 == redirect_chain.len()) @@ -1199,7 +1226,7 @@ impl MainDocumentLiveNetworkProgressSource { target, observed_request_headers( network_observation_journal, - redirect_chain.len(), + redirect_chain, index + 1, final_request_headers, ), @@ -1217,15 +1244,15 @@ impl MainDocumentLiveNetworkProgressSource { response_headers: &[(String, Vec)], response_cookie_reports: &[StoredCookieSetReport], network_observation_journal: &NetworkObservationJournal, - redirect_count: usize, + redirect_chain: &[RedirectInfo], network_extra_info_available: bool, response_from_cache: bool, negotiated_http_version: Option, ) { let (extra_info_status, extra_info_headers) = observed_response_metadata( network_observation_journal, - redirect_count, - redirect_count, + redirect_chain, + redirect_chain.len(), response_status, response_headers, ); @@ -1460,32 +1487,85 @@ pub(crate) fn emit_child_document_navigation_network_background_events( frame_id: frame_id.to_owned(), timestamp, }; + let redirect_count = network.redirect_chain.len(); + let request_method = observed_request_method( + &network.network_observation_journal, + &network.redirect_chain, + 0, + &network.request_method, + ); + let request_headers = observed_request_headers( + &network.network_observation_journal, + &network.redirect_chain, + 0, + &network.request_headers, + ); let mut output = MainDocumentProgressOutputTarget::background_events(out); output.emit_event(MainDocumentNavigationProgressEvent::RequestWillBeSent { target: target.clone(), url: request_url, - method: network.request_method.clone(), + method: request_method, request_body: None, - request_headers: network.request_headers.clone(), + request_headers, request_initiator_type: SubresourceRequestInitiatorType::Parser, redirect_response: Box::new(None), redirect_has_extra_info: false, cookie_access_report: None, }); + let source = MainDocumentLiveNetworkProgressSource { + sender: None, + progress_queue: MainDocumentProgressQueueHandle::from_source( + MainDocumentProgressSource::streaming(), + ), + session_ids: session_ids.clone(), + request_id: request_id.to_owned(), + loader_id: loader_id.to_owned(), + frame_id: frame_id.to_owned(), + timestamp, + initial_request_headers: network.request_headers.clone(), + initial_request_cookie_report: None, + }; + for event in source.redirect_request_events( + &network.request_method, + None, + &network.request_headers, + None, + &network.redirect_chain, + &network.network_observation_journal, + !network.network_observation_journal.is_empty(), + SubresourceRequestInitiatorType::Parser, + ) { + output.emit_event(event); + } + let (extra_info_status, extra_info_headers) = observed_response_metadata( + &network.network_observation_journal, + &network.redirect_chain, + redirect_count, + network.status, + &network.response_headers, + ); + let network_extra_info_available = navigation_exchange_group( + &network.network_observation_journal, + &network.redirect_chain, + redirect_count, + ) + .and_then(|group| group.last()) + .and_then(NetworkExchangeObservation::response) + .is_some(); output.emit_event(MainDocumentNavigationProgressEvent::ResponseReceived { target: target.clone(), final_url, status: network.status, headers: network.response_headers.clone(), cookie_set_reports: Vec::new(), - extra_info_status: network.status, - extra_info_headers: network.response_headers.clone(), - network_extra_info_available: false, - emit_extra_info: false, + extra_info_status, + extra_info_headers, + network_extra_info_available, + emit_extra_info: network_extra_info_available, encoded_data_length: 0, from_cache: network.from_cache, negotiated_http_version: None, - has_extra_info: false, + has_extra_info: network_extra_info_available, }); record_child_document_response_body( conn, @@ -1759,7 +1839,12 @@ impl CompletedMainDocumentProgressContext { cookie_access_report: initial_request_cookie_report.clone(), }); } - let initial_network_extra_info_available = !events.network_observation_journal.is_empty() + let initial_network_extra_info_available = navigation_exchange_group( + &events.network_observation_journal, + &events.redirect_chain, + 0, + ) + .is_some() || events .redirect_chain .first() @@ -1770,7 +1855,7 @@ impl CompletedMainDocumentProgressContext { target.clone(), observed_request_headers( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, 0, &self.request_headers, ), @@ -1778,9 +1863,21 @@ impl CompletedMainDocumentProgressContext { )); } for (index, redirect) in events.redirect_chain.iter().enumerate() { + let request_method = observed_request_method( + &events.network_observation_journal, + &events.redirect_chain, + index + 1, + &events.request_method, + ); + let request_headers = observed_request_headers( + &events.network_observation_journal, + &events.redirect_chain, + index + 1, + &events.request_headers, + ); let observed_response_available = navigation_exchange_group( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, index, ) .and_then(|group| group.last()) @@ -1789,7 +1886,7 @@ impl CompletedMainDocumentProgressContext { if observed_response_available && !redirect.network_extra_info_available { let (status, headers) = observed_response_metadata( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, index, redirect.status, &redirect.headers, @@ -1806,9 +1903,11 @@ impl CompletedMainDocumentProgressContext { progress_events.push(MainDocumentNavigationProgressEvent::RequestWillBeSent { target: target.clone(), url: redirect.to_url.clone(), - method: events.request_method.clone(), - request_body: self.request_body.clone(), - request_headers: events.request_headers.clone(), + method: request_method.clone(), + request_body: (request_method == self.request_method) + .then(|| self.request_body.clone()) + .flatten(), + request_headers, request_initiator_type: SubresourceRequestInitiatorType::Other, redirect_response: Box::new(Some(MainDocumentRedirectResponse { url: redirect.from_url.clone(), @@ -1830,7 +1929,7 @@ impl CompletedMainDocumentProgressContext { if observed_response_available && redirect.network_extra_info_available { let (status, headers) = observed_response_metadata( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, index, redirect.status, &redirect.headers, @@ -1844,11 +1943,12 @@ impl CompletedMainDocumentProgressContext { }, ); } - let request_network_extra_info_available = events - .network_observation_journal - .exchanges() - .get(index + 1) - .is_some() + let request_network_extra_info_available = navigation_exchange_group( + &events.network_observation_journal, + &events.redirect_chain, + index + 1, + ) + .is_some() || events .redirect_chain .get(index + 1) @@ -1865,7 +1965,7 @@ impl CompletedMainDocumentProgressContext { target.clone(), observed_request_headers( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, index + 1, &events.request_headers, ), @@ -1887,7 +1987,7 @@ impl CompletedMainDocumentProgressContext { }; let (extra_info_status, extra_info_headers) = observed_response_metadata( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, events.redirect_chain.len(), events.response_status, &events.response_headers, @@ -2032,62 +2132,107 @@ fn request_extra_info_event( } } -fn navigation_exchange_group( - journal: &NetworkObservationJournal, - redirect_count: usize, +fn navigation_exchange_group<'a>( + journal: &'a NetworkObservationJournal, + redirect_chain: &[impl RedirectTransportBoundary], hop_index: usize, -) -> Option<&[NetworkExchangeObservation]> { +) -> Option<&'a [NetworkExchangeObservation]> { // A truncated journal still proves that a transport exchange happened, but // no longer provides a trustworthy tail for redirect-hop correlation. if journal.truncated() { return None; } - if hop_index > redirect_count { + if hop_index > redirect_chain.len() { return None; } let exchanges = journal.exchanges(); let mut group_start = 0; - let mut current_hop = 0; - for (index, exchange) in exchanges.iter().enumerate() { - let ends_redirect_hop = exchange.response().is_some_and(|response| { - matches!(response.status(), 301 | 302 | 303 | 307 | 308) - || response.headers().iter().any(|(name, value)| { - name.eq_ignore_ascii_case("critical-ch") && !value.trim_ascii().is_empty() - }) - }); - if !ends_redirect_hop || current_hop >= redirect_count { + for (redirect_index, redirect) in redirect_chain.iter().enumerate() { + let Some(expected_status) = redirect.transport_response_status() else { + if redirect_index == hop_index { + return None; + } continue; + }; + let group_end = exchanges[group_start..] + .iter() + .position(|exchange| { + exchange + .response() + .is_some_and(|response| response.status() == expected_status) + })? + .saturating_add(group_start); + if redirect_index == hop_index { + return Some(&exchanges[group_start..=group_end]); } - if current_hop == hop_index { - return Some(&exchanges[group_start..=index]); - } - current_hop += 1; - group_start = index.saturating_add(1); + group_start = group_end.saturating_add(1); + } + (hop_index == redirect_chain.len() && group_start < exchanges.len()) + .then_some(&exchanges[group_start..]) +} + +trait RedirectTransportBoundary { + fn transport_response_status(&self) -> Option; +} + +impl RedirectTransportBoundary for RedirectInfo { + fn transport_response_status(&self) -> Option { + self.response_extra_info + .as_ref() + .map(|response| response.status) + .or_else(|| { + (self.source == moli_fetch::RedirectSource::Network && !self.from_cache) + .then_some(self.status) + }) + } +} + +impl RedirectTransportBoundary for NavigationRedirect { + fn transport_response_status(&self) -> Option { + self.response_extra_info + .as_ref() + .map(|response| response.status) + .or_else(|| { + (self.source == moli_fetch::RedirectSource::Network && !self.from_cache) + .then_some(self.status) + }) } - (current_hop == hop_index && group_start < exchanges.len()).then_some(&exchanges[group_start..]) } fn observed_request_headers( journal: &NetworkObservationJournal, - redirect_count: usize, + redirect_chain: &[impl RedirectTransportBoundary], hop_index: usize, fallback: &[(String, String)], ) -> Vec<(String, String)> { - navigation_exchange_group(journal, redirect_count, hop_index) + navigation_exchange_group(journal, redirect_chain, hop_index) .and_then(|group| group.first()) .map(|exchange| exchange.request().headers().to_vec()) .unwrap_or_else(|| fallback.to_vec()) } +fn observed_request_method( + journal: &NetworkObservationJournal, + redirect_chain: &[impl RedirectTransportBoundary], + hop_index: usize, + fallback: &str, +) -> String { + navigation_exchange_group(journal, redirect_chain, hop_index) + .and_then(|group| group.first()) + .and_then(|exchange| exchange.request().method()) + .unwrap_or(fallback) + .to_owned() +} + fn observed_response_metadata( journal: &NetworkObservationJournal, - redirect_count: usize, + redirect_chain: &[impl RedirectTransportBoundary], hop_index: usize, fallback_status: u16, fallback_headers: &[(String, Vec)], ) -> (u16, Vec<(String, Vec)>) { - navigation_exchange_group(journal, redirect_count, hop_index) + navigation_exchange_group(journal, redirect_chain, hop_index) .and_then(|group| group.last()) .and_then(NetworkExchangeObservation::response) .map(|response| (response.status(), response.headers().to_vec())) diff --git a/moli-protocol/src/domains/network/main_document_progress/tests.rs b/moli-protocol/src/domains/network/main_document_progress/tests.rs index 5901712a9e9..7f3a821ebbd 100644 --- a/moli-protocol/src/domains/network/main_document_progress/tests.rs +++ b/moli-protocol/src/domains/network/main_document_progress/tests.rs @@ -4,7 +4,8 @@ use moli_cookie_jar::{ }; use moli_fetch::{ NegotiatedHttpVersion, NetworkExchangeObservation, NetworkObservationJournal, - NetworkRequestObservation, NetworkResponseObservation, RedirectInfo, + NetworkRequestExtraInfo, NetworkRequestObservation, NetworkResponseExtraInfo, + NetworkResponseObservation, RedirectInfo, }; use serde_json::{Value, json}; use tokio::sync::mpsc::unbounded_channel; @@ -98,6 +99,25 @@ fn observation_journal( ) } +fn response_extra_info( + request_headers: Vec<(String, String)>, + status: u16, + response_headers: Vec<(String, String)>, +) -> NetworkResponseExtraInfo { + NetworkResponseExtraInfo { + request_extra_info: NetworkRequestExtraInfo { + headers: request_headers, + cookie_report: StoredCookieQueryReport::default(), + }, + status, + headers: response_headers + .into_iter() + .map(|(name, value)| (name, value.into_bytes())) + .collect(), + cookie_set_reports: Vec::new(), + } +} + fn completed_progress_context() -> CompletedMainDocumentProgressContext { CompletedMainDocumentProgressContext::new( vec![Some("SID-1".to_owned())], @@ -475,25 +495,36 @@ fn live_progress_source_serializes_through_progress_emissions() { from_cache: false, negotiated_http_version: None, }; - let journal = observation_journal(vec![ - ( - vec![("Host".to_owned(), "example.test".to_owned())], - 302, - vec![("Location".to_owned(), final_url.to_string())], + let journal = NetworkObservationJournal::from_exchanges(vec![ + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "POST", + vec![("Host".to_owned(), "example.test".to_owned())], + ), + Some(NetworkResponseObservation::new( + 302, + vec![("Location".to_owned(), final_url.to_string().into_bytes())], + )), ), - ( - vec![("Host".to_owned(), "example.test".to_owned())], - 200, - vec![("Content-Type".to_owned(), "text/html".to_owned())], + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("Host".to_owned(), "example.test".to_owned())], + ), + Some(NetworkResponseObservation::new( + 200, + vec![("Content-Type".to_owned(), b"text/html".to_vec())], + )), ), ]); + let redirects = vec![redirect]; source.emit_redirect_requests( - "GET", - None, + "POST", + Some("account=1"), &[("Accept".to_owned(), "text/html".to_owned())], None, - &[redirect], + &redirects, &journal, true, ); @@ -503,7 +534,7 @@ fn live_progress_source_serializes_through_progress_emissions() { &[("Content-Type".to_owned(), b"text/html".to_vec())], &[], &journal, - 1, + &redirects, true, false, None, @@ -531,6 +562,11 @@ fn live_progress_source_serializes_through_progress_emissions() { json!("Network.requestWillBeSent") ); assert_eq!(redirect_request["params"]["requestId"], json!("REQ-1")); + assert_eq!( + redirect_request["params"]["request"]["method"], + json!("GET") + ); + assert!(redirect_request["params"]["request"]["postData"].is_null()); assert_eq!( redirect_request["params"]["redirectResponse"]["status"], json!(302) @@ -602,6 +638,135 @@ fn live_progress_source_serializes_through_progress_emissions() { assert_eq!(finished["params"]["encodedDataLength"], json!(17)); } +#[test] +fn redirect_request_preserves_post_body_when_transport_preserves_method() { + let (source, mut receiver) = live_progress_source(); + let final_url = Url::parse("http://example.test/final").unwrap(); + let redirect = RedirectInfo { + source: moli_fetch::RedirectSource::Network, + from_url: Url::parse("http://example.test/start").unwrap(), + to_url: final_url.clone(), + status: 307, + headers: vec![("Location".to_owned(), final_url.to_string().into_bytes())], + network_extra_info_available: true, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: true, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }; + let journal = NetworkObservationJournal::from_exchanges(vec![ + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method("POST", Vec::new()), + Some(NetworkResponseObservation::new(307, Vec::new())), + ), + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method("POST", Vec::new()), + Some(NetworkResponseObservation::new(200, Vec::new())), + ), + ]); + + source.emit_redirect_requests( + "POST", + Some("account=1"), + &[], + None, + &[redirect], + &journal, + true, + ); + + let _initial_request_extra = receiver + .try_recv() + .expect("initial request extra info event"); + let (redirect_request, _) = receiver + .try_recv() + .expect("redirect request event") + .into_parts(); + assert_eq!( + redirect_request["params"]["request"]["method"], + json!("POST") + ); + assert_eq!( + redirect_request["params"]["request"]["postData"], + json!("account=1") + ); +} + +#[test] +fn observed_request_headers_do_not_restore_a_referrer_removed_by_transport_policy() { + let journal = NetworkObservationJournal::from_exchanges(vec![NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("Host".to_owned(), "example.test".to_owned())], + ), + Some(NetworkResponseObservation::new(200, Vec::new())), + )]); + + let headers = super::observed_request_headers( + &journal, + &[] as &[RedirectInfo], + 0, + &[("Referer".to_owned(), "https://stale.test/".to_owned())], + ); + + assert_eq!( + headers, + vec![("Host".to_owned(), "example.test".to_owned())] + ); +} + +#[test] +fn truncated_observation_journal_falls_back_instead_of_guessing_redirect_alignment() { + let final_url = Url::parse("http://example.test/final").unwrap(); + let redirect = RedirectInfo { + source: moli_fetch::RedirectSource::Network, + from_url: Url::parse("http://example.test/start").unwrap(), + to_url: final_url.clone(), + status: 302, + headers: vec![("Location".to_owned(), final_url.to_string().into_bytes())], + network_extra_info_available: true, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: true, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }; + let journal = NetworkObservationJournal::from_exchanges( + (0..33) + .map(|index| { + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("X-Wire-Hop".to_owned(), index.to_string())], + ), + Some(NetworkResponseObservation::new(200, Vec::new())), + ) + }) + .collect(), + ); + assert!(journal.truncated()); + + let fallback_headers = vec![("X-Logical-Hop".to_owned(), "final".to_owned())]; + assert_eq!( + super::observed_request_headers( + &journal, + std::slice::from_ref(&redirect), + 1, + &fallback_headers + ), + fallback_headers + ); + assert_eq!( + super::observed_request_method(&journal, &[redirect], 1, "POST"), + "POST" + ); +} + #[test] fn completed_body_http_response_emits_correlated_empty_cookie_extra_info() { let final_url = Url::parse("http://example.test/final").unwrap(); @@ -832,19 +997,46 @@ fn completed_body_http_redirect_emits_correlated_no_cookie_extra_info() { from_cache: false, negotiated_http_version: None, }]; - events = events.with_network_observation_journal(observation_journal(vec![ - ( - vec![("X-Request-Hop".to_owned(), "initial".to_owned())], - 302, - vec![("X-Response-Hop".to_owned(), "redirect".to_owned())], - ), - ( - vec![("X-Request-Hop".to_owned(), "final".to_owned())], - 200, - vec![("X-Response-Hop".to_owned(), "final".to_owned())], - ), - ])); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + events.request_method = "POST".to_owned(); + events = + events.with_network_observation_journal(NetworkObservationJournal::from_exchanges(vec![ + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "POST", + vec![("X-Request-Hop".to_owned(), "initial".to_owned())], + ), + Some(NetworkResponseObservation::new( + 302, + vec![("X-Response-Hop".to_owned(), b"redirect".to_vec())], + )), + ), + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("X-Request-Hop".to_owned(), "final".to_owned())], + ), + Some(NetworkResponseObservation::new( + 200, + vec![("X-Response-Hop".to_owned(), b"final".to_vec())], + )), + ), + ])); + let context = CompletedMainDocumentProgressContext::new( + vec![Some("SID-1".to_owned())], + Some("REQ-1".to_owned()), + false, + Url::parse("http://example.test/start").unwrap(), + "POST".to_owned(), + Some("account=1".to_owned()), + vec![( + "Content-Type".to_owned(), + "application/x-www-form-urlencoded".to_owned(), + )], + "LOADER-1".to_owned(), + "FRAME-1".to_owned(), + 12.5, + ); + let batches = context.event_batches(&events, &final_url, 17); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -885,6 +1077,11 @@ fn completed_body_http_redirect_emits_correlated_no_cookie_extra_info() { redirected_request["params"]["redirectHasExtraInfo"], json!(true) ); + assert_eq!( + redirected_request["params"]["request"]["method"], + json!("GET") + ); + assert!(redirected_request["params"]["request"]["postData"].is_null()); let request_extras = out .iter() .filter(|message| message["method"] == json!("Network.requestWillBeSentExtraInfo")) @@ -996,7 +1193,14 @@ fn critical_client_hint_restart_keeps_discarded_response_extra_info_separate_fro )], network_extra_info_available: false, request_extra_info: None, - response_extra_info: None, + response_extra_info: Some(response_extra_info( + vec![("Sec-CH-UA".to_owned(), "\"Chromium\";v=\"145\"".to_owned())], + 403, + vec![ + ("Accept-CH".to_owned(), "Sec-CH-UA-Arch".to_owned()), + ("Critical-CH".to_owned(), "Sec-CH-UA-Arch".to_owned()), + ], + )), redirect_has_extra_info: false, request_cookie_report: None, cookie_set_reports: Vec::new(), @@ -1060,6 +1264,69 @@ fn critical_client_hint_restart_keeps_discarded_response_extra_info_separate_fro assert_eq!(relevant[6]["params"]["hasExtraInfo"], json!(true)); } +#[test] +fn transportless_https_upgrade_attributes_wire_headers_to_upgraded_request() { + let initial_url = Url::parse("http://example.test/start").unwrap(); + let final_url = Url::parse("https://example.test/start").unwrap(); + let journal = NetworkObservationJournal::from_exchanges(vec![NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("X-Wire-Upgraded".to_owned(), "yes".to_owned())], + ), + Some(NetworkResponseObservation::new( + 200, + vec![("Content-Type".to_owned(), b"text/html".to_vec())], + )), + )]); + let mut events = completed_events().with_network_observation_journal(journal); + events.network_extra_info_available = true; + events.redirect_chain = vec![moli_core::page::NavigationRedirect { + source: moli_fetch::RedirectSource::Internal, + from_url: initial_url, + to_url: final_url.clone(), + status: 307, + headers: vec![("Location".to_owned(), final_url.to_string().into_bytes())], + network_extra_info_available: false, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: false, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: Some(NegotiatedHttpVersion::Http11), + }]; + + let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let mut drain = + MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); + drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); + let out = drain_into_protocol_messages(&mut drain); + + let redirected_request = out + .iter() + .filter(|message| message["method"] == json!("Network.requestWillBeSent")) + .nth(1) + .expect("upgraded request"); + assert_eq!( + redirected_request["params"]["request"]["headers"]["X-Wire-Upgraded"], + json!("yes"), + "the sole wire exchange belongs to the post-upgrade HTTPS request" + ); + let request_extras = out + .iter() + .filter(|message| message["method"] == json!("Network.requestWillBeSentExtraInfo")) + .collect::>(); + assert_eq!( + request_extras.len(), + 1, + "a transportless internal hop must not receive wire ExtraInfo" + ); + assert_eq!( + request_extras[0]["params"]["headers"]["X-Wire-Upgraded"], + json!("yes") + ); +} + #[test] fn completed_body_uses_negotiated_protocol_for_redirect_and_final_response() { let final_url = Url::parse("https://example.test/final").unwrap(); diff --git a/moli-protocol/src/domains/network/output_queue.rs b/moli-protocol/src/domains/network/output_queue.rs index 90e6199a437..5f8cd111791 100644 --- a/moli-protocol/src/domains/network/output_queue.rs +++ b/moli-protocol/src/domains/network/output_queue.rs @@ -2976,17 +2976,18 @@ impl TargetNetworkOutputQueue { let Some(request) = self.staged_subresource_requests.get(&handle) else { return; }; - if request.request_cookie_report().is_some() { - return; - } let request_headers = network_request_headers .unwrap_or_else(|| request.request_headers()) .to_vec(); - let Some(request_cookie_report) = request_cookie_report.cloned().or_else(|| { - network_request_headers - .is_some() - .then(StoredCookieQueryReport::default) - }) else { + let Some(request_cookie_report) = request_cookie_report + .cloned() + .or_else(|| request.request_cookie_report().cloned()) + .or_else(|| { + network_request_headers + .is_some() + .then(StoredCookieQueryReport::default) + }) + else { return; }; self.append_subresource_request_extra_info( @@ -3686,6 +3687,60 @@ mod tests { )); } + #[test] + fn staged_cookie_report_does_not_suppress_observed_transport_headers() { + let handle = SubresourceNetworkRequestHandle::new(81); + let document_url = Url::parse("https://example.com/page").unwrap(); + let request_url = Url::parse("https://example.com/api").unwrap(); + let request = SubresourceRequestStarted::new( + handle, + Some("FRAME-1".to_owned()), + document_url, + request_url.clone(), + "POST".to_owned(), + vec![("Content-Type".to_owned(), "text/plain".to_owned())], + Some("body".to_owned()), + SubresourceResourceType::Fetch, + SubresourceRequestInitiatorType::Script, + Some(StoredCookieQueryReport::default()), + ); + let headers = vec![ + ("Referer".to_owned(), "https://example.com/page".to_owned()), + ("User-Agent".to_owned(), "Moli/Test".to_owned()), + ]; + let record = subresource_record(SubresourceResourceType::Fetch, request_url.as_str()) + .with_request_handle(handle) + .with_network_request_headers(Some(headers.clone())); + let mut queue = TargetNetworkOutputQueue::default(); + append_concrete_items_for_test( + &mut queue, + &[ + ScriptNetworkOutputItem::SubresourceRequestStarted(Box::new(request)), + ScriptNetworkOutputItem::SubresourceNetworkRecord(Box::new(record)), + ], + "LOADER-1", + ); + let activity = PendingSubresourceNetworkActivity::from_sessions(vec![ + PendingSubresourceNetworkActivitySession::new(None, 0), + ]); + let mut request_ids = StableSubresourceHandleRequestIds::default(); + let snapshot = pending_delivery_snapshot(&queue, activity, None, &mut request_ids).unwrap(); + let outputs = subresource_outputs(&snapshot); + let extras = outputs + .iter() + .filter_map(|output| match output { + TargetSubresourceNetworkDeliveryOutput::RequestExtraInfo(output) => Some(output), + _ => None, + }) + .collect::>(); + assert_eq!( + extras.len(), + 1, + "cookie metadata is not transport-header evidence" + ); + assert_eq!(extras[0].output().request_headers(), headers.as_slice()); + } + #[test] fn staged_request_followed_by_redirect_complete_record_preserves_redirect_chain() { let handle = SubresourceNetworkRequestHandle::new(7); diff --git a/moli-protocol/src/domains/network/settings.rs b/moli-protocol/src/domains/network/settings.rs index 61ab1d1270b..9ebfc7cb3bc 100644 --- a/moli-protocol/src/domains/network/settings.rs +++ b/moli-protocol/src/domains/network/settings.rs @@ -13,6 +13,35 @@ struct SetBlockedUrlsParams { urls: Vec, } +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DurableBodyParams { + #[serde(default)] + enable_durable_messages: bool, + max_total_buffer_size: Option, + max_resource_buffer_size: Option, +} + +pub(super) fn durable_body_limits( + cmd: &Cmd<'_>, +) -> Result, CommandOutputPlan> { + let params = cmd + .get_params::() + .map_err(|_| CommandOutputPlan::error(-32602, "InvalidParams"))? + .unwrap_or_default(); + if !params.enable_durable_messages { + return Ok(None); + } + let Some(total) = params.max_total_buffer_size.filter(|size| *size > 0) else { + return Err(CommandOutputPlan::error(-32602, "InvalidParams")); + }; + let resource = params.max_resource_buffer_size.unwrap_or(2_000_000); + if resource == 0 { + return Err(CommandOutputPlan::error(-32602, "InvalidParams")); + } + Ok(Some(moli_bounded_buffer::ByteLimits::new(total, resource))) +} + pub(super) fn enabled_command_output_plan( conn: &mut CdpConnection, session_id: Option<&str>, diff --git a/moli-protocol/src/domains/network/tests/response_body.rs b/moli-protocol/src/domains/network/tests/response_body.rs index 6965e491418..fc72b5acdb1 100644 --- a/moli-protocol/src/domains/network/tests/response_body.rs +++ b/moli-protocol/src/domains/network/tests/response_body.rs @@ -15,6 +15,168 @@ use crate::domains::network::{ use super::*; +#[tokio::test(flavor = "multi_thread")] +async fn durable_response_body_survives_navigation_only_for_opted_session() { + let mut ctx = TestContext::new(); + let mut bc = BrowserContext::new("BID-durable".into()); + bc.set_active_target_id("TID-durable".to_owned()); + bc.attach_active_session("SID-durable".to_owned()); + assert!(bc.assign_attached_session_to_target("TID-durable", "SID-ordinary".to_owned())); + ctx.conn.install_browser_context_fixture_for_test(bc); + for (id, session, params) in [ + ( + 79_100, + "SID-durable", + json!({"enableDurableMessages":true,"maxTotalBufferSize":1024,"maxResourceBufferSize":128}), + ), + (79_101, "SID-ordinary", json!({})), + ] { + ctx.process_async( + json!({"id":id,"method":"Network.enable","sessionId":session,"params":params}), + ) + .await; + ctx.expect_result(id, json!({}), Some(session)); + } + let bc = ctx.conn.browser_context.as_mut().unwrap(); + bc.record_captured_response_body( + "REQ-durable".into(), + "retained".into(), + [Some("SID-durable".into()), Some("SID-ordinary".into())], + ); + bc.active_page_target_mut() + .prepare_document_navigation_request_ids( + &mut crate::conn::ConnectionNetworkRequestIdAllocator::default(), + true, + true, + false, + ); + ctx.process_async(json!({"id":79_102,"method":"Network.getResponseBody","sessionId":"SID-durable","params":{"requestId":"REQ-durable"}})).await; + ctx.expect_result( + 79_102, + json!({"body":"retained","base64Encoded":false}), + Some("SID-durable"), + ); + ctx.process_async(json!({"id":79_103,"method":"Network.getResponseBody","sessionId":"SID-ordinary","params":{"requestId":"REQ-durable"}})).await; + ctx.expect_error(79_103, -32000, "No resource with given identifier found"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn durable_response_body_requires_explicit_positive_total_budget() { + let mut ctx = TestContext::new(); + let mut bc = BrowserContext::new("BID-durable".into()); + bc.set_active_target_id("TID-durable".to_owned()); + bc.attach_active_session("SID-durable".to_owned()); + ctx.conn.install_browser_context_fixture_for_test(bc); + for (index, params) in [ + json!({"enableDurableMessages":true}), + json!({"enableDurableMessages":true,"maxTotalBufferSize":0}), + json!({"enableDurableMessages":true,"maxTotalBufferSize":-1}), + json!({"enableDurableMessages":true,"maxTotalBufferSize":100,"maxResourceBufferSize":0}), + json!({"enableDurableMessages":"true","maxTotalBufferSize":100}), + ] + .into_iter() + .enumerate() + { + let id = 79_104 + index as u64; + ctx.process_async( + json!({"id":id,"method":"Network.enable","sessionId":"SID-durable","params":params}), + ) + .await; + ctx.expect_error(id, -32602, "InvalidParams"); + } + assert!( + !ctx.conn + .browser_context + .as_ref() + .unwrap() + .active_page_target() + .runtime_slot + .has_network_event_listeners() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn durable_response_body_disable_detach_close_and_other_target_do_not_leak() { + let mut ctx = TestContext::new(); + let mut bc = BrowserContext::new("BID-durable".into()); + bc.set_active_target_id("TID-durable".to_owned()); + bc.attach_active_session("SID-owner".to_owned()); + assert!(bc.assign_attached_session_to_target("TID-durable", "SID-detach".to_owned())); + bc.insert_page_target_host(PageTargetHost::new( + "TID-other".into(), + Some("SID-other".into()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + ctx.conn.install_browser_context_fixture_for_test(bc); + let params = + json!({"enableDurableMessages":true,"maxTotalBufferSize":1024,"maxResourceBufferSize":128}); + for (index, session) in ["SID-owner", "SID-detach", "SID-other"] + .into_iter() + .enumerate() + { + let id = 79_200 + index as u64; + ctx.process_async( + json!({"id":id,"method":"Network.enable","sessionId":session,"params":params}), + ) + .await; + ctx.expect_result(id, json!({}), Some(session)); + } + let bc = ctx.conn.browser_context.as_mut().unwrap(); + bc.page_target_mut("TID-durable") + .unwrap() + .runtime_slot + .record_captured_response_body( + "REQ-old".into(), + "retained".into(), + [Some("SID-owner".into()), Some("SID-detach".into())], + ); + bc.page_target_mut("TID-durable") + .unwrap() + .prepare_document_navigation_request_ids( + &mut crate::conn::ConnectionNetworkRequestIdAllocator::default(), + true, + true, + false, + ); + ctx.process_async(json!({"id":79_203,"method":"Network.getResponseBody","sessionId":"SID-other","params":{"requestId":"REQ-old"}})).await; + ctx.expect_error(79_203, -32000, "No resource with given identifier found"); + ctx.process_async(json!({"id":79_204,"method":"Network.disable","sessionId":"SID-owner"})) + .await; + ctx.expect_result(79_204, json!({}), Some("SID-owner")); + ctx.process_async( + json!({"id":79_205,"method":"Network.enable","sessionId":"SID-owner","params":params}), + ) + .await; + ctx.expect_result(79_205, json!({}), Some("SID-owner")); + ctx.process_async(json!({"id":79_206,"method":"Network.getResponseBody","sessionId":"SID-owner","params":{"requestId":"REQ-old"}})).await; + ctx.expect_error(79_206, -32000, "No resource with given identifier found"); + ctx.process_async( + json!({"id":79_207,"method":"Target.detachFromTarget","params":{"sessionId":"SID-detach"}}), + ) + .await; + ctx.expect_result(79_207, json!({}), None); + assert!( + ctx.conn + .browser_context + .as_ref() + .unwrap() + .page_target("TID-durable") + .unwrap() + .runtime_slot + .captured_response_body("REQ-old") + .is_none() + ); + ctx.process_async( + json!({"id":79_208,"method":"Target.closeTarget","params":{"targetId":"TID-durable"}}), + ) + .await; + ctx.expect_result(79_208, json!({"success":true}), None); + let bc = ctx.conn.browser_context.as_ref().unwrap(); + assert!(bc.page_target("TID-durable").is_none()); + assert!(bc.page_target("TID-other").is_some()); +} + fn bidi_network_context(session_id: &str) -> crate::automation::AutomationContext { crate::automation::AutomationContext { protocol: crate::automation::FrontendProtocol::WebDriverBidi, diff --git a/moli-protocol/src/domains/page/producer_tests.rs b/moli-protocol/src/domains/page/producer_tests.rs index fb0bfa43189..d70d3a371f9 100644 --- a/moli-protocol/src/domains/page/producer_tests.rs +++ b/moli-protocol/src/domains/page/producer_tests.rs @@ -1637,6 +1637,8 @@ async fn child_frame_activity_emits_document_network_events_from_prepared_load() request_url: "https://example.test/child".to_owned(), request_method: "GET".to_owned(), request_headers: vec![("Accept".to_owned(), "text/html".to_owned())], + network_observation_journal: Default::default(), + redirect_chain: Vec::new(), final_url: "https://example.test/child".to_owned(), status: 200, response_headers: vec![("Content-Type".to_owned(), b"text/html".to_vec())], @@ -1766,6 +1768,8 @@ async fn stale_child_document_response_emits_network_without_navigation_or_lifec request_url: "https://example.test/retired-child".to_owned(), request_method: "GET".to_owned(), request_headers: Vec::new(), + network_observation_journal: Default::default(), + redirect_chain: Vec::new(), final_url: "https://example.test/retired-child".to_owned(), status: 200, response_headers: vec![("Content-Type".to_owned(), b"text/html".to_vec())], @@ -1869,6 +1873,8 @@ async fn child_document_network_without_body_records_known_no_data() { request_url: "https://example.test/legacy-child".to_owned(), request_method: "GET".to_owned(), request_headers: Vec::new(), + network_observation_journal: Default::default(), + redirect_chain: Vec::new(), final_url: "https://example.test/legacy-child".to_owned(), status: 200, response_headers: vec![("Content-Type".to_owned(), b"text/html".to_vec())], @@ -2815,3 +2821,105 @@ async fn stale_page_residence_top_level_navigation_cannot_replace_current_page() "discarding the retired Page action must install no navigation token" ); } + +#[tokio::test(flavor = "multi_thread")] +async fn child_document_network_reports_actual_redirect_methods_and_request_headers() { + let mut conn = CdpConnection::default(); + let mut bc = BrowserContext::new("BID-1".into()); + bc.set_active_target_id("TID-1"); + bc.attach_active_session("SID-1"); + conn.install_browser_context_fixture_for_test(bc); + assert!(conn.enable_network_listener_for_session_owner(Some("SID-1"))); + let final_url = url::Url::parse("https://example.test/done").unwrap(); + let snapshot = ChildFrameDocumentNetworkSnapshot { + request_url: "https://example.test/profile".to_owned(), + request_method: "POST".to_owned(), + request_headers: Vec::new(), + network_observation_journal: moli_fetch::NetworkObservationJournal::from_exchanges(vec![ + moli_fetch::NetworkExchangeObservation::new( + moli_fetch::NetworkRequestObservation::new_with_method( + "POST", + vec![( + "Referer".to_owned(), + "https://example.test/source".to_owned(), + )], + ), + Some(moli_fetch::NetworkResponseObservation::new( + 302, + vec![("Location".to_owned(), final_url.to_string().into_bytes())], + )), + ), + moli_fetch::NetworkExchangeObservation::new( + moli_fetch::NetworkRequestObservation::new_with_method( + "GET", + vec![( + "Referer".to_owned(), + "https://example.test/source".to_owned(), + )], + ), + Some(moli_fetch::NetworkResponseObservation::new( + 200, + vec![("Content-Type".to_owned(), b"text/html".to_vec())], + )), + ), + ]), + redirect_chain: vec![moli_fetch::RedirectInfo { + source: moli_fetch::RedirectSource::Network, + from_url: url::Url::parse("https://example.test/profile").unwrap(), + to_url: final_url.clone(), + status: 302, + headers: vec![("Location".to_owned(), final_url.to_string().into_bytes())], + network_extra_info_available: true, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: true, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }], + final_url: final_url.to_string(), + status: 200, + response_headers: vec![("Content-Type".to_owned(), b"text/html".to_vec())], + encoded_data_length: 0, + response_body: None, + from_cache: false, + }; + let mut background_events = Vec::new(); + + crate::domains::network::emit_child_document_navigation_network_background_events( + &mut conn, + &mut background_events, + Some("SID-1"), + "CHILD-FRAME-REDIRECT", + "LID-CHILD-REDIRECT", + "LID-CHILD-REDIRECT", + 12.5, + &snapshot, + ); + + let messages = protocol_messages_from_background_events(background_events); + let requests = messages + .iter() + .filter(|message| message["method"] == json!("Network.requestWillBeSent")) + .collect::>(); + assert_eq!(requests.len(), 2, "redirect hop must remain observable"); + assert_eq!(requests[0]["params"]["request"]["method"], json!("POST")); + assert_eq!(requests[1]["params"]["request"]["method"], json!("GET")); + assert_eq!( + requests[1]["params"]["redirectResponse"]["status"], + json!(302) + ); + assert_eq!( + requests[0]["params"]["request"]["headers"]["Referer"], + json!("https://example.test/source") + ); + let request_extra = messages + .iter() + .filter(|message| message["method"] == json!("Network.requestWillBeSentExtraInfo")) + .collect::>(); + assert_eq!(request_extra.len(), 2); + assert!(request_extra.iter().all(|message| { + message["params"]["headers"]["Referer"] == json!("https://example.test/source") + })); +} diff --git a/moli-protocol/src/domains/page/termination.rs b/moli-protocol/src/domains/page/termination.rs index 2e26c794d63..8921aa53788 100644 --- a/moli-protocol/src/domains/page/termination.rs +++ b/moli-protocol/src/domains/page/termination.rs @@ -396,11 +396,13 @@ pub(super) async fn complete_stop_loading_command_dispatch( owner: &CommandOwnerScope, ) -> PageCommandTaskStep { let mut out = Vec::new(); - if let Ok(slot) = conn.runtime_session_owner_slot_mut_for_owner(owner) - && let Some(page) = slot.loaded_page_mut() - && let Err(error) = page.stop_document_lifecycle_async().await - { - tracing::debug!(%error, "failed to stop renderer document lifecycle"); + if let Ok(slot) = conn.runtime_session_owner_slot_mut_for_owner(owner) { + slot.cancel_inflight_document_navigation(); + if let Some(page) = slot.loaded_page_mut() + && let Err(error) = page.stop_document_lifecycle_async().await + { + tracing::debug!(%error, "failed to stop renderer document lifecycle"); + } } let ( pending_navigations, diff --git a/moli-protocol/src/domains/page/tests/navigation/requests.rs b/moli-protocol/src/domains/page/tests/navigation/requests.rs index b1a6b3bb515..574c1db2da8 100644 --- a/moli-protocol/src/domains/page/tests/navigation/requests.rs +++ b/moli-protocol/src/domains/page/tests/navigation/requests.rs @@ -602,3 +602,1270 @@ async fn repeated_protocol_navigate_to_same_url_does_not_inherit_referrer_or_rel server.abort(); } + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_cancels_inflight_unpaused_navigation_transport() { + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + let token = ctx + .conn + .browser_context + .as_mut() + .unwrap() + .start_document_navigation_for_active_target("LOADER-inflight-stop".to_owned()) + .unwrap(); + let cancellation = ctx + .conn + .document_navigation_cancellation_handle(&token) + .unwrap(); + ctx.conn + .browser_context + .as_mut() + .expect("browser context") + .active_page_target_mut() + .runtime_slot + .claim_background_navigation_completion(&token, None) + .expect("current navigation should be claimable"); + assert!(!cancellation.is_cancelled()); + + ctx.process_async(json!({ + "id": 901, + "method": "Page.stopLoading", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(901, json!({}), Some("SID-1")); + assert!( + cancellation.is_cancelled(), + "stopLoading must cancel an ordinary HTTP navigation, not only Fetch-paused requests" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_before_response_preserves_document_and_allows_next_navigation() { + assert_stopped_provisional_navigation_preserves_document(None).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_during_xml_body_preserves_document_and_allows_next_navigation() { + assert_stopped_provisional_navigation_preserves_document(Some((200, "application/xml"))).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_during_http_error_body_preserves_document_and_allows_next_navigation() { + assert_stopped_provisional_navigation_preserves_document(Some((500, "text/html"))).await; +} + +async fn assert_stopped_provisional_navigation_preserves_document( + response_head: Option<(u16, &'static str)>, +) { + let request_received = std::sync::Arc::new(tokio::sync::Notify::new()); + let release_response = std::sync::Arc::new(tokio::sync::Notify::new()); + let handler_received = request_received.clone(); + let handler_release = release_response.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let app = axum::Router::new() + .route( + "/held", + axum::routing::get(move || { + let received = handler_received.clone(); + let release = handler_release.clone(); + async move { + received.notify_one(); + let (status, content_type) = if let Some(head) = response_head { + head + } else { + release.notified().await; + return axum::http::Response::builder() + .header("content-type", "text/html") + .body(axum::body::Body::from("cancelled response")) + .unwrap(); + }; + let body = futures_util::stream::once(async move { + release.notified().await; + Ok::<_, std::convert::Infallible>(axum::body::Bytes::from_static( + b"cancelled response", + )) + }); + axum::http::Response::builder() + .status(status) + .header("content-type", content_type) + .body(axum::body::Body::from_stream(body)) + .unwrap() + } + }), + ) + .route( + "/ready", + axum::routing::get(|| async { + axum::response::Html("subsequent document") + }), + ); + axum::serve(listener, app).await.unwrap(); + }); + + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + let initial_url = "data:text/html,previous committed document"; + ctx.install_navigation_fixture_for_session_owner(initial_url, Some("SID-1")) + .await; + wait_until_renderer_document_load(&mut ctx, Some("SID-1"), "TID-1", LOADER_ID).await; + for (id, method) in [ + (910, "Page.enable"), + (911, "DOM.enable"), + (912, "Network.enable"), + (913, "Runtime.enable"), + ] { + ctx.process_async(json!({ + "id": id, + "method": method, + "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, id)["result"], json!({})); + } + let previous_html = loaded_page_html_for_test(&mut ctx).await; + ctx.sent.clear(); + ctx.enable_background_navigation_scheduler_for_test(); + + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_async(json!({ + "id": 914, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/held") } + })) + .await; + tokio::time::timeout( + std::time::Duration::from_secs(5), + request_received.notified(), + ) + .await + .expect("the ordinary HTTP navigation should reach the held response"); + if let Some((status, _)) = response_head { + wait_until_scheduler_message( + &mut ctx, + "held document response headers", + |message| { + message["method"] == json!("Network.responseReceived") + && message["params"]["type"] == json!("Document") + && message["params"]["response"]["status"] == json!(status) + }, + ) + .await; + } else { + assert!(ctx.sent.iter().all(|message| message["id"] != json!(914))); + } + + ctx.process_and_wait_for_response_async(json!({ + "id": 915, + "method": "Page.stopLoading", + "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 915)["result"], json!({})); + wait_until_scheduler_message(&mut ctx, "cancelled navigation response", |message| { + message["id"] == json!(914) + }) + .await; + let navigation = take_response_by_id(&mut ctx, 914); + assert_eq!(navigation["result"]["frameId"], json!("TID-1")); + if response_head.is_some_and(|(status, _)| status == 200) { + // Successful response headers acknowledge navigation before + // XML buffering completes; cancellation must not send a + // second reply or replace the still-committed old Document. + assert!(navigation["result"].get("errorText").is_none()); + } else { + assert_eq!(navigation["result"]["errorText"], json!("net::ERR_ABORTED")); + } + wait_until_scheduler_message( + &mut ctx, + "cancelled navigation network event", + |message| { + message["method"] == json!("Network.loadingFailed") + && message["params"]["errorText"] == json!("net::ERR_ABORTED") + }, + ) + .await; + let failure = ctx + .sent + .iter() + .find(|message| message["method"] == json!("Network.loadingFailed")) + .expect("cancelled request failure"); + assert_eq!(failure["params"]["canceled"], json!(true)); + assert_eq!(failure["params"]["type"], json!("Document")); + assert!(ctx.sent.iter().all(|message| message["id"] != json!(914))); + assert_eq!(loaded_page_html_for_test(&mut ctx).await, previous_html); + assert_eq!( + ctx.conn.browser_context.as_ref().unwrap().target_url(), + initial_url + ); + for method in [ + "Page.frameNavigated", + "DOM.documentUpdated", + "Runtime.executionContextsCleared", + "Runtime.executionContextCreated", + "Page.domContentEventFired", + "Page.loadEventFired", + ] { + assert!( + ctx.sent + .iter() + .all(|message| message["method"] != json!(method)), + "cancelled pre-response navigation must not replace the document: {method}" + ); + } + + // The response remains held until cancellation has completed. No + // server delay or timing race can masquerade as transport abort. + release_response.notify_one(); + ctx.sent.clear(); + ctx.process_and_wait_for_response_async(json!({ + "id": 916, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/ready") } + })) + .await; + let navigation = take_response_by_id(&mut ctx, 916); + assert!(navigation["result"].get("errorText").is_none()); + wait_until_frame_stopped_loading(&mut ctx, "TID-1").await; + let html = loaded_page_html_for_test(&mut ctx).await; + assert!(html.contains("subsequent document")); + assert!(!html.contains("cancelled response")); + assert_eq!( + ctx.conn.browser_context.as_ref().unwrap().target_url(), + format!("http://{addr}/ready") + ); + }) + .await; + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_after_commit_cancels_transport_without_replacing_partial_document() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let prefix_parsed = std::sync::Arc::new(tokio::sync::Notify::new()); + let transport_closed = std::sync::Arc::new(tokio::sync::Notify::new()); + let server_parsed = prefix_parsed.clone(); + let server_closed = transport_closed.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let parsed = server_parsed.clone(); + let closed = server_closed.clone(); + tokio::spawn(async move { + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + if socket.read(&mut byte).await.unwrap() == 0 { + return; + } + request.push(byte[0]); + } + if request.starts_with(b"GET /stream ") { + let prefix = b"
committed prefix
"; + let tail = b"
unreceived tail
"; + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + prefix.len() + tail.len() + ); + socket.write_all(headers.as_bytes()).await.unwrap(); + socket.write_all(prefix).await.unwrap(); + socket.flush().await.unwrap(); + // The server never supplies EOF or the tail. Completion + // here can only come from the client's transport close. + let read = socket.read(&mut byte).await; + assert!( + matches!(read, Ok(0)) || read.is_err(), + "client must close the held transfer: {read:?}" + ); + closed.notify_one(); + } else if request.starts_with(b"GET /parsed ") { + socket + .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + parsed.notify_one(); + } else { + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 26\r\nConnection: close\r\n\r\ncomplete page").await.unwrap(); + } + }); + } + }); + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + ctx.enable_page_events_for_test(Some("SID-1")); + ctx.enable_background_navigation_scheduler_for_test(); + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_and_wait_for_response_async(json!({ + "id": 920, "method": "Page.navigate", "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/stream") } + })) + .await; + assert!( + take_response_by_id(&mut ctx, 920)["result"] + .get("errorText") + .is_none() + ); + wait_until_scheduler_message(&mut ctx, "streaming document commit", |message| { + message["method"] == json!("Page.frameNavigated") + }) + .await; + tokio::time::timeout(std::time::Duration::from_secs(5), prefix_parsed.notified()) + .await + .expect("committed prefix script must execute before stopping"); + assert!( + loaded_page_html_for_test(&mut ctx) + .await + .contains("committed prefix") + ); + ctx.sent.clear(); + ctx.process_and_wait_for_response_async(json!({ + "id": 921, "method": "Page.stopLoading", "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 921)["result"], json!({})); + tokio::time::timeout( + std::time::Duration::from_secs(5), + transport_closed.notified(), + ) + .await + .expect("explicit document stop must cancel the committed response transport"); + let html = loaded_page_html_for_test(&mut ctx).await; + assert!(html.contains("committed prefix")); + assert!(!html.contains("unreceived tail")); + assert_eq!( + ctx.conn.browser_context.as_ref().unwrap().target_url(), + format!("http://{addr}/stream") + ); + assert!( + ctx.sent + .iter() + .all(|message| message["method"] != json!("Page.frameNavigated")) + ); + + ctx.sent.clear(); + ctx.process_and_wait_for_response_async(json!({ + "id": 922, "method": "Page.navigate", "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/complete") } + })) + .await; + assert!( + take_response_by_id(&mut ctx, 922)["result"] + .get("errorText") + .is_none() + ); + wait_until_frame_stopped_loading(&mut ctx, "TID-1").await; + let completed_html = loaded_page_html_for_test(&mut ctx).await; + assert!(completed_html.contains("complete page")); + ctx.process_and_wait_for_response_async(json!({ + "id": 923, "method": "Page.stopLoading", "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 923)["result"], json!({})); + assert_eq!(loaded_page_html_for_test(&mut ctx).await, completed_html); + }) + .await; + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reload_reloads_current_url_and_returns_empty_result() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + async fn page( + axum::extract::State(counter): axum::extract::State>, + ) -> impl axum::response::IntoResponse { + let next = counter.fetch_add(1, Ordering::SeqCst) + 1; + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + format!("{next}"), + ) + } + + let counter = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_counter = counter.clone(); + let server = tokio::spawn(async move { + axum::serve( + listener, + axum::Router::new() + .route("/page", axum::routing::get(page)) + .with_state(server_counter), + ) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + + ctx.process_async(json!({ + "id": 239, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/page") } + })) + .await; + let navigate = take_response_by_id(&mut ctx, 239); + assert_eq!( + navigate["result"], + json!({ "frameId": "TID-1", "loaderId": LOADER_ID }) + ); + + let first_html = loaded_page_html_for_test(&mut ctx).await; + assert!( + first_html.contains(">1<"), + "expected first navigation html to contain counter 1, got {first_html}" + ); + + ctx.process_async(json!({ + "id": 241, + "method": "Page.reload", + "sessionId": "SID-1" + })) + .await; + let reload = take_response_by_id(&mut ctx, 241); + assert_eq!(reload["result"], json!({})); + + let second_html = loaded_page_html_for_test(&mut ctx).await; + assert!( + second_html.contains(">2<"), + "expected reloaded html to contain counter 2, got {second_html}" + ); + + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_bypass_csp_controls_response_policy_across_reload() { + async fn page() -> impl axum::response::IntoResponse { + ( + [ + (axum::http::header::CONTENT_TYPE.as_str(), "text/html"), + ("content-security-policy", "script-src 'none'"), + ], + r#" +CSP Locked +"#, + ) + } + + async fn evaluate_csp_state(ctx: &mut TestContext, id: u64) -> serde_json::Value { + ctx.process_async(json!({ + "id": id, + "method": "Runtime.evaluate", + "sessionId": "SID-CSP", + "params": { + "expression": "[globalThis.__cspRan === true, document.title]", + "returnByValue": true + } + })) + .await; + take_response_by_id(ctx, id)["result"]["result"]["value"].clone() + } + + 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, + axum::Router::new().route("/csp", axum::routing::get(page)), + ) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-CSP", "TID-CSP", "SID-CSP", "about:blank"); + ctx.process_async(json!({ + "id": 24_000, + "method": "Page.enable", + "sessionId": "SID-CSP" + })) + .await; + take_response_by_id(&mut ctx, 24_000); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 24_001, + "method": "Page.navigate", + "sessionId": "SID-CSP", + "params": { "url": format!("http://{addr}/csp") } + })) + .await; + take_response_by_id(&mut ctx, 24_001); + wait_until_frame_stopped_loading(&mut ctx, "TID-CSP").await; + assert_eq!( + evaluate_csp_state(&mut ctx, 24_002).await, + json!([false, "CSP Locked"]) + ); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 24_003, + "method": "Page.setBypassCSP", + "sessionId": "SID-CSP", + "params": { "enabled": true } + })) + .await; + take_response_by_id(&mut ctx, 24_003); + ctx.sent.clear(); + ctx.process_async(json!({ + "id": 24_004, + "method": "Page.reload", + "sessionId": "SID-CSP" + })) + .await; + take_response_by_id(&mut ctx, 24_004); + wait_until_frame_stopped_loading(&mut ctx, "TID-CSP").await; + assert_eq!( + evaluate_csp_state(&mut ctx, 24_005).await, + json!([true, "CSP Bypassed"]) + ); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 24_006, + "method": "Page.setBypassCSP", + "sessionId": "SID-CSP", + "params": { "enabled": false } + })) + .await; + take_response_by_id(&mut ctx, 24_006); + ctx.sent.clear(); + ctx.process_async(json!({ + "id": 24_007, + "method": "Page.reload", + "sessionId": "SID-CSP" + })) + .await; + take_response_by_id(&mut ctx, 24_007); + wait_until_frame_stopped_loading(&mut ctx, "TID-CSP").await; + assert_eq!( + evaluate_csp_state(&mut ctx, 24_008).await, + json!([false, "CSP Locked"]) + ); + + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn child_response_csp_controls_parser_and_dynamic_inline_scripts() { + async fn parent() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + r#""#, + ) + } + + async fn child() -> impl axum::response::IntoResponse { + ( + [ + (axum::http::header::CONTENT_TYPE.as_str(), "text/html"), + ("content-security-policy", "script-src 'nonce-allowed'"), + ], + r#" + + + +"#, + ) + } + + 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, + axum::Router::new() + .route("/parent", axum::routing::get(parent)) + .route("/child", axum::routing::get(child)), + ) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + load_bc_with_session( + &mut ctx, + "BID-CHILD-CSP", + "TID-CHILD-CSP", + "SID-CHILD-CSP", + "about:blank", + ); + ctx.process_async(json!({ + "id": 24_100, + "method": "Page.enable", + "sessionId": "SID-CHILD-CSP" + })) + .await; + take_response_by_id(&mut ctx, 24_100); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 24_101, + "method": "Page.navigate", + "sessionId": "SID-CHILD-CSP", + "params": { "url": format!("http://{addr}/parent") } + })) + .await; + take_response_by_id(&mut ctx, 24_101); + wait_until_frame_stopped_loading(&mut ctx, "TID-CHILD-CSP").await; + let child_frame_id = child_frame_id_for_single_iframe(&mut ctx, 24_102).await; + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 24_103, + "method": "Runtime.enable", + "sessionId": "SID-CHILD-CSP" + })) + .await; + take_response_by_id(&mut ctx, 24_103); + let child_context_id = ctx + .sent + .iter() + .find(|message| { + message["method"] == json!("Runtime.executionContextCreated") + && message["params"]["context"]["auxData"]["isDefault"] == json!(true) + && message["params"]["context"]["auxData"]["frameId"] == json!(child_frame_id) + }) + .and_then(|message| message["params"]["context"]["id"].as_i64()) + .expect("child default execution context id"); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 24_104, + "method": "Runtime.evaluate", + "sessionId": "SID-CHILD-CSP", + "params": { + "contextId": child_context_id, + "expression": "[globalThis.__blockedParserInlineRan === true, globalThis.__allowedParserInlineRan === true, globalThis.__blockedDynamicInlineRan === true, globalThis.__allowedDynamicInlineRan === true, globalThis.__inlineCspViolations]", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 24_104)["result"]["result"]["value"], + json!([ + false, + true, + false, + true, + ["script-src-elem:enforce", "script-src-elem:enforce"] + ]) + ); + + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reload_targets_background_owner_without_activation() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + async fn page( + axum::extract::State(counter): axum::extract::State>, + ) -> impl axum::response::IntoResponse { + let next = counter.fetch_add(1, Ordering::SeqCst) + 1; + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + format!("{next}"), + ) + } + + let counter = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_counter = counter.clone(); + let server = tokio::spawn(async move { + axum::serve( + listener, + axum::Router::new() + .route("/page", axum::routing::get(page)) + .with_state(server_counter), + ) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + let page_url = format!("http://{addr}/page"); + let background = PageTargetHost::with_url( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + page_url.clone(), + ); + + let mut bc = BrowserContext::new("BID-1".to_owned()); + bc.set_active_target_id("TID-active".to_owned()); + bc.attach_active_session("SID-active".to_owned()); + bc.set_target_url("data:text/html,Active
active
".to_owned()); + bc.insert_page_target_host(background); + ctx.conn.install_browser_context_fixture_for_test(bc); + ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-background")) + .await; + let initial_html = ctx + .conn + .browser_context + .as_mut() + .and_then(|browser_context| { + browser_context + .background_target_mut("TID-background") + .and_then(PageTargetHost::loaded_page_mut) + }) + .expect("loaded background page") + .serialize_html_async() + .await + .expect("background page should serialize HTML"); + assert!( + initial_html.contains(">1<"), + "expected first background load to contain counter 1" + ); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 242, + "method": "Page.reload", + "sessionId": "SID-background" + })) + .await; + let reload = take_response_by_id(&mut ctx, 242); + assert_eq!(reload["sessionId"], json!("SID-background")); + assert_eq!(reload["result"], json!({})); + + let browser_context = ctx.conn.browser_context.as_mut().unwrap(); + assert_eq!( + browser_context.active_target_id(), + Some("TID-active"), + "background Page.reload should not activate the target" + ); + let background = browser_context + .background_target_mut("TID-background") + .expect("background target should remain background"); + let reloaded_html = background + .loaded_page_mut() + .expect("loaded background page after reload") + .serialize_html_async() + .await + .expect("background page should serialize HTML"); + assert!( + reloaded_html.contains(">2<"), + "expected background reload html to contain counter 2, got {reloaded_html}" + ); + + server.abort(); +} +#[tokio::test(flavor = "multi_thread")] +async fn repeated_http_navigation_after_runtime_enable_replaces_the_context_group() { + async fn page() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + "runtime context replacement", + ) + } + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let request_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let server_request_count = request_count.clone(); + let server = tokio::spawn(async move { + axum::serve( + listener, + axum::Router::new().route( + "/page", + axum::routing::get(move || { + let request_count = server_request_count.clone(); + async move { + request_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + page().await + } + }), + ), + ) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + let url = format!("http://{addr}/page"); + + ctx.process_async(json!({ + "id": 404, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": url.clone() } + })) + .await; + let first_loader_id = take_response_by_id(&mut ctx, 404)["result"]["loaderId"] + .as_str() + .expect("first HTTP navigation should have a loader") + .to_owned(); + // Page.navigate acknowledges the navigation before the renderer reaches + // load. Chromium clients wait for a lifecycle event when they need a + // fully completed document; reading readyState immediately after the + // command response is allowed to observe "interactive". + wait_until_renderer_document_load(&mut ctx, Some("SID-1"), "TID-1", &first_loader_id).await; + ctx.process_async(json!({ + "id": 4041, + "method": "Runtime.evaluate", + "sessionId": "SID-1", + "params": { + "expression": "document.readyState", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 4041)["result"]["result"]["value"], + json!("complete"), + "the benchmark enables Runtime only after the first document has completed" + ); + let state_before_enable = ctx + .conn + .navigation_load_inputs_for_session_owner(Some("SID-1")) + .runtime_inspector_session_restore_snapshots + .into_iter() + .find(|restore| restore.inspector_session_id.is_none()) + .and_then(|restore| restore.v8_attach.reattach_state().cloned()) + .expect("Runtime.evaluate should establish the primary V8 session state"); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 405, + "method": "Runtime.enable", + "sessionId": "SID-1" + })) + .await; + assert!( + ctx.sent.iter().any(|message| { + message["method"] == json!("Runtime.executionContextCreated") + && message["sessionId"] == json!("SID-1") + && message["params"]["context"]["auxData"]["isDefault"] == json!(true) + }), + "Runtime.enable should report the first HTTP document context: {:?}", + ctx.sent + ); + let state_after_enable = ctx + .conn + .navigation_load_inputs_for_session_owner(Some("SID-1")) + .runtime_inspector_session_restore_snapshots + .into_iter() + .find(|restore| restore.inspector_session_id.is_none()) + .and_then(|restore| restore.v8_attach.reattach_state().cloned()) + .expect("Runtime.enable should retain the primary V8 session state"); + assert_ne!( + state_before_enable, state_after_enable, + "Runtime.enable must replace the pre-enable V8 session cookie" + ); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 406, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": url } + })) + .await; + let second_loader_id = take_response_by_id(&mut ctx, 406)["result"]["loaderId"] + .as_str() + .expect("second HTTP navigation should have a loader") + .to_owned(); + + assert_ne!(first_loader_id, second_loader_id); + assert_eq!( + request_count.load(std::sync::atomic::Ordering::SeqCst), + 2, + "same-URL Page.navigate should fetch and commit a replacement document" + ); + assert_runtime_navigation_context_reset(&ctx.sent, "SID-1", "TID-1"); + + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reload_after_crash_emits_target_reloaded_after_crash() { + let mut ctx = TestContext::new(); + load_bc_with_session( + &mut ctx, + "BID-1", + "TID-1", + "SID-1", + "data:text/html,reload-after-crash", + ); + let bc = ctx.conn.browser_context.as_mut().unwrap(); + bc.active_page_target_mut().devtools_sessions[moli_page_types::DevToolsSessionKey::Primary] + .runtime_session_state + .record_inspector_target_crashed(); + bc.active_page_target_mut() + .owner_state + .target_crash_state + .mark_crashed(); + + ctx.process_async(json!({ + "id": 248, + "method": "Page.reload", + "sessionId": "SID-1" + })) + .await; + let _ = take_response_by_id(&mut ctx, 248); + + let events = ctx.take_all(); + assert!(events.iter().any(|message| { + message["method"] == json!("Inspector.targetReloadedAfterCrash") + && message["sessionId"] == json!("SID-1") + })); + assert!( + events + .iter() + .any(|message| message["method"] == json!("Page.frameNavigated")) + ); + assert!( + !ctx.conn + .browser_context + .as_ref() + .expect("browser context") + .active_page_target() + .owner_state + .target_crash_state + .is_crashed() + ); +} +#[tokio::test(flavor = "multi_thread")] +async fn navigate_after_crash_emits_target_reloaded_after_crash() { + let mut ctx = TestContext::new(); + load_bc_with_session( + &mut ctx, + "BID-1", + "TID-1", + "SID-1", + "data:text/html,before-crash", + ); + let bc = ctx.conn.browser_context.as_mut().unwrap(); + bc.active_page_target_mut().devtools_sessions[moli_page_types::DevToolsSessionKey::Primary] + .runtime_session_state + .record_inspector_target_crashed(); + bc.active_page_target_mut() + .owner_state + .target_crash_state + .mark_crashed(); + + ctx.process_async(json!({ + "id": 249, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { + "url": "data:text/html,after-crash" + } + })) + .await; + let _ = take_response_by_id(&mut ctx, 249); + + let events = ctx.take_all(); + assert!(events.iter().any(|message| { + message["method"] == json!("Inspector.targetReloadedAfterCrash") + && message["sessionId"] == json!("SID-1") + })); + assert!( + events + .iter() + .any(|message| message["method"] == json!("Page.frameNavigated")) + ); + assert!( + !ctx.conn + .browser_context + .as_ref() + .expect("browser context") + .active_page_target() + .owner_state + .target_crash_state + .is_crashed() + ); + assert_eq!( + loaded_page_html_for_test(&mut ctx).await, + "after-crash" + ); +} +#[tokio::test(flavor = "multi_thread")] +async fn navigate_after_crash_without_inspector_enabled_clears_crash_without_event() { + let mut ctx = TestContext::new(); + load_bc_with_session( + &mut ctx, + "BID-1", + "TID-1", + "SID-1", + "data:text/html,before-crash", + ); + ctx.conn + .browser_context + .as_mut() + .unwrap() + .active_page_target_mut() + .owner_state + .target_crash_state + .mark_crashed(); + + ctx.process_async(json!({ + "id": 250, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { + "url": "data:text/html,after-crash" + } + })) + .await; + let _ = take_response_by_id(&mut ctx, 250); + + let events = ctx.take_all(); + assert!( + !events + .iter() + .any(|message| message["method"] == json!("Inspector.targetReloadedAfterCrash")) + ); + assert!( + events + .iter() + .any(|message| message["method"] == json!("Page.frameNavigated")) + ); + assert!( + !ctx.conn + .browser_context + .as_ref() + .expect("browser context") + .active_page_target() + .owner_state + .target_crash_state + .is_crashed() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn parser_script_location_navigation_suppresses_aborted_document_dcl() { + async fn challenge() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + "", + ) + } + + async fn final_page() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + "Final document
final 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, + axum::Router::new() + .route("/challenge", axum::routing::get(challenge)) + .route("/final", axum::routing::get(final_page)), + ) + .await + .unwrap(); + }); + + 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; + + let events = ctx.take_all(); + let domcontentloaded = events + .iter() + .filter(|message| message["method"] == json!("Page.domContentEventFired")) + .collect::>(); + 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") + ); + + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn parser_script_location_navigation_continues_after_target_response() { + async fn challenge() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + "", + ) + } + + let final_requested = std::sync::Arc::new(tokio::sync::Notify::new()); + let final_release = std::sync::Arc::new(tokio::sync::Notify::new()); + let requested_for_handler = std::sync::Arc::clone(&final_requested); + let release_for_handler = std::sync::Arc::clone(&final_release); + let final_handler = move || { + let requested = std::sync::Arc::clone(&requested_for_handler); + let release = std::sync::Arc::clone(&release_for_handler); + async move { + requested.notify_one(); + release.notified().await; + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + "Delayed final
delayed final 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, + axum::Router::new() + .route("/challenge", axum::routing::get(challenge)) + .route("/final", axum::routing::get(final_handler)), + ) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + ctx.process_async(json!({ + "id": 252, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/challenge") } + })) + .await; + let _ = take_response_by_id(&mut ctx, 252); + + // Chromium returns Page.navigate once the new document commits. Parser + // continuation (and therefore this script navigation) is admitted only + // after that response boundary has been flushed. + let release_response = tokio::spawn(async move { + tokio::time::timeout( + std::time::Duration::from_secs(5), + final_requested.notified(), + ) + .await + .expect("successor navigation should request the gated final response"); + final_release.notify_one(); + }); + wait_until_messages( + &mut ctx, + Some("SID-1"), + "response-gated successor document lifecycle", + |messages| { + messages + .iter() + .filter(|message| message["method"] == json!("Page.frameNavigated")) + .count() + >= 2 + && messages + .iter() + .any(|message| message["method"] == json!("Page.domContentEventFired")) + }, + ) + .await; + release_response + .await + .expect("gated final-response release task"); + + let events = ctx.take_all(); + assert_eq!( + events + .iter() + .filter(|message| message["method"] == json!("Page.domContentEventFired")) + .count(), + 1, + "the response-gated source document must stay terminated before DCL: {events:?}" + ); + assert_eq!( + ctx.conn + .browser_context + .as_ref() + .expect("browser context") + .target_url(), + format!("http://{addr}/final") + ); + + server.abort(); +} diff --git a/moli-protocol/src/domains/target/worker_target/dedicated_worker.rs b/moli-protocol/src/domains/target/worker_target/dedicated_worker.rs index ef0e97b3512..f3c2129cb0c 100644 --- a/moli-protocol/src/domains/target/worker_target/dedicated_worker.rs +++ b/moli-protocol/src/domains/target/worker_target/dedicated_worker.rs @@ -96,6 +96,7 @@ pub(super) fn register_dedicated_worker_target( false, None, &[], + true, ); if !events.is_empty() { outputs.push(WorkerTargetLifecycleOutput::DedicatedWorkerEvents { diff --git a/moli-protocol/src/domains/target/worker_target/service_worker_targets.rs b/moli-protocol/src/domains/target/worker_target/service_worker_targets.rs index 29685550c55..c9dfa2aff6c 100644 --- a/moli-protocol/src/domains/target/worker_target/service_worker_targets.rs +++ b/moli-protocol/src/domains/target/worker_target/service_worker_targets.rs @@ -896,6 +896,7 @@ pub(super) fn emit_service_worker_fetch_diagnostic_events( false, None, &[], + true, ); tag_service_worker_fetch_diagnostic_event(out.get_mut(request_event_index), diagnostic); diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands.rs b/moli-renderer-v8/src/document_runtime/mutation_commands.rs index 077ef4b3408..94b582bee1a 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -1821,7 +1821,7 @@ pub(super) fn finish_runtime_mutation_effects( owner = ?binding.owner(), element = ?binding.element(), load_delay_token = ?binding.load_delay_token(), - settled, + ?settled, "settled invalidated connected-style lease at mutation commit" ); } diff --git a/moli-renderer-v8/src/frame_owner_model.rs b/moli-renderer-v8/src/frame_owner_model.rs index a0eb8cdbfd7..71bb3f1f0cf 100644 --- a/moli-renderer-v8/src/frame_owner_model.rs +++ b/moli-renderer-v8/src/frame_owner_model.rs @@ -75,7 +75,8 @@ pub(crate) use lifecycle_tasks::{ MainDocumentImageLoadDelayBinding, MainDocumentInteractiveLifecycleAction, MainDocumentMediaLoadDelayBinding, MainDocumentScriptLoadDelayKind, MainDocumentScriptLoadDelayLease, MainDocumentScriptLoadDelayRelease, - MainDocumentStyleLoadEventBinding, StylesheetSubresourceLoadDelayBinding, + MainDocumentStyleLoadEventBinding, MainDocumentStyleLoadEventSettlement, + StylesheetSubresourceLoadDelayBinding, }; pub(crate) use load_delivery_tasks::{ FrameDocumentLoadDeliveryAction, FrameDocumentLoadDeliveryAdmission, diff --git a/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs b/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs index f040b537496..55197a23310 100644 --- a/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs +++ b/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs @@ -94,6 +94,11 @@ impl DocumentLifecycleBlockers { self.window_load.clear(); } + pub(super) fn cancel_for_stop(&mut self) { + self.parser_deferred_scripts.clear(); + self.window_load.cancel(); + } + #[cfg(test)] pub(super) fn len(&self) -> usize { self.parser_deferred_scripts.len() + self.window_load.len() diff --git a/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs b/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs index 6a624156a06..5d122d995f5 100644 --- a/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs +++ b/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs @@ -81,6 +81,13 @@ impl MainDocumentScriptLoadDelayLease { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MainDocumentStyleLoadEventSettlement { + NotOwned, + Released, + CancelledAfterStop, +} + /// Exact main-document ownership for a connected ``; + document.body.innerHTML = '
'; + document.getElementById('existing').getBoundingClientRect().width; + globalThis.menu = document.createElement('div'); + menu.id = 'menu'; + globalThis.candidate = document.createElement('button'); + candidate.id = 'candidate'; + candidate.textContent = 'Abishek S abisubramanya27'; + menu.appendChild(candidate); + 'installed'; + "#, + ) + .expect("fixture and initial layout should evaluate"); + + let passes_after_initial_layout = vm.layout_pass_observability_for_test().1; + assert_eq!( + vm.eval("candidate.offsetParent === null && candidate.offsetWidth === 0") + .unwrap(), + "true", + "geometry for a detached candidate must remain empty" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout, + "building and querying a detached subtree must not invalidate live layout" + ); + + vm.eval("document.body.appendChild(menu); 'connected'") + .expect("connected insertion should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout, + "the connected insertion must mark layout dirty without rebuilding it eagerly" + ); + + let result = vm + .eval( + r#" + (() => { + const parent = candidate.offsetParent; + const width = candidate.offsetWidth; + return JSON.stringify({ parentIsMenu: parent === menu, width }); + })() + "#, + ) + .expect("offsetParent-first geometry read should evaluate"); + assert_eq!( + result, r#"{"parentIsMenu":true,"width":240}"#, + "offsetParent must refresh a dirty tree before answering for the inserted element" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout + 1, + "offsetParent and the following width read should share one fresh pass" + ); + + assert_eq!( + vm.eval("candidate.offsetParent === menu && candidate.offsetWidth === 240") + .unwrap(), + "true" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout + 1, + "clean geometry reads must reuse the refreshed tree" + ); +} + +#[test] +fn geometry_reuses_clean_layout_after_detached_subtree_mutations() { + let mut vm = new_storage_test_vm("https://layout-detached.test/"); + vm.eval( + r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.body.innerHTML = '
'; + globalThis.target = document.getElementById('target'); + globalThis.detached = document.createElement('div'); + target.getBoundingClientRect().width; + "#, + ) + .expect("fixture should evaluate"); + let passes = vm.layout_pass_observability_for_test().1; + for mutation in [ + "detached.textContent = 'not on the page'", + "detached.appendChild(document.createElement('span'))", + "detached.style.width = '300px'", + "detached.setAttribute('data-wide', '')", + "detached.firstChild.data = 'changed text'", + ] { + vm.eval(mutation) + .expect("detached mutation should evaluate"); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + "40" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "detached mutation must not invalidate page layout: {mutation}" + ); + } +} + +#[test] +fn geometry_refreshes_pseudo_class_inputs_on_demand() { + let mut vm = new_storage_test_vm("https://layout-state.test/"); + vm.eval(r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.body.innerHTML = ''; + globalThis.target = document.getElementById('target'); + target.getBoundingClientRect().width; + "#).expect("fixture should evaluate"); + let mut passes = vm.layout_pass_observability_for_test().1; + for (mutation, expected) in [ + ("target.focus({preventScroll: true})", "80"), + ("target.checked = true", "120"), + ("target.checked = false", "80"), + ("target.blur()", "40"), + ] { + vm.eval(mutation).expect("element state should change"); + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected, + "geometry must reflect changed pseudo class: {mutation}" + ); + passes += 1; + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + } +} + +#[test] +fn geometry_refreshes_attribute_selectors_and_cssom_on_demand() { + let mut vm = new_storage_test_vm("https://layout-inputs.test/"); + vm.eval(r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.body.innerHTML = '
'; + globalThis.target = document.getElementById('target'); + target.getBoundingClientRect().width; + "#).expect("fixture should evaluate"); + let mut passes = vm.layout_pass_observability_for_test().1; + for (mutation, expected) in [ + ("target.setAttribute('data-wide', '')", "80"), + ( + "document.getElementById('rules').sheet.insertRule('#target[data-wide] { width: 120px }', 2)", + "120", + ), + ("target.removeAttribute('data-wide')", "40"), + ] { + vm.eval(mutation).expect("layout input should change"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "input changes must not eagerly lay out: {mutation}" + ); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected + ); + passes += 1; + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "clean geometry must be reusable across script turns" + ); + } +} + +#[test] +fn geometry_refreshes_inserted_and_existing_nodes_on_demand() { + let mut vm = new_storage_test_vm("https://layout-mutation.test/"); + + vm.eval( + r#" + (() => { + if (!document.documentElement) { + document.appendChild(document.createElement("html")); + } + if (!document.body) { + document.documentElement.appendChild(document.createElement("body")); + } + document.body.innerHTML = '
Marker
'; + return document.getElementById("marker").getBoundingClientRect().top; + })() + "#, + ) + .expect("initial layout read should evaluate"); + + let before_top = vm + .eval("document.getElementById('marker').getBoundingClientRect().top") + .expect("initial marker geometry should evaluate") + .parse::() + .expect("initial marker top should be numeric"); + + let passes_before = vm.layout_pass_observability_for_test().1; + vm.eval( + r#"document.getElementById("content").insertAdjacentHTML( + "afterbegin", + '
' + )"#, + ) + .expect("connected insertion should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before, + "a DOM mutation alone must not run layout" + ); + assert!( + vm.layout_snapshot_cache_observability_for_test() + .3 + .is_some(), + "mutation should retain the sampled frozen tree" + ); + + let result = vm + .eval( + r#" + (() => { + const rect = document.getElementById("target").getBoundingClientRect(); + const marker = document.getElementById("marker").getBoundingClientRect(); + return JSON.stringify({ target: [rect.width, rect.height], markerTop: marker.top }); + })() + "#, + ) + .expect("geometry after a connected insertion should evaluate"); + + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "all post-mutation geometry reads should share one fresh pass" + ); + + let result: serde_json::Value = + serde_json::from_str(&result).expect("geometry result should be JSON"); + assert!( + result["target"][0] + .as_f64() + .is_some_and(|width| width > 0.0) + && result["target"][1] + .as_f64() + .is_some_and(|height| height > 0.0), + "inserted content should receive real geometry: {result}" + ); + assert!( + result["markerTop"] + .as_f64() + .is_some_and(|top| top > before_top), + "existing geometry must not be reused after a preceding sibling is inserted: {result}" + ); + vm.eval("const content = document.getElementById('content'); content.firstElementChild.remove(); content.firstElementChild.remove()") + .expect("connected removals should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "removal itself must not run layout" + ); + assert_eq!( + vm.eval("document.getElementById('marker').getBoundingClientRect().top") + .unwrap() + .parse::() + .unwrap(), + before_top, + "removing previously connected siblings must invalidate old geometry" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs index d23e1c5673d..23452ffdc7f 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs @@ -62,3 +62,188 @@ mod css_computed_map_enumeration; mod nested_document_invalidation; mod properties_and_selectors; mod stylesheet_and_document_lifecycle; + +#[test] +fn root_font_relative_units_follow_each_documents_live_root_style() { + let mut vm = new_parsed_test_vm( + "https://root-font-relative-units.test/", + r#" + + +
+ + "#, + ); + + let result = vm + .eval( + r#" +(() => { + const root = document.documentElement; + const target = document.getElementById('target'); + const values = () => { + const targetStyle = getComputedStyle(target); + const targetValues = [targetStyle.width, targetStyle.height, targetStyle.fontSize]; + const rootFontSize = getComputedStyle(root).fontSize; + return [rootFontSize, ...targetValues].join(','); + }; + const initial = values(); + + root.style.fontSize = '20px'; + const updated = values(); + + root.style.fontSize = '0.625rem'; + const selfRelative = values(); + + root.style.lineHeight = '3'; + const updatedLineHeight = values(); + root.style.lineHeight = '2'; + + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const childDocument = frame.contentWindow.document; + childDocument.open(); + childDocument.write('
'); + childDocument.close(); + const childWidth = frame.contentWindow + .getComputedStyle(childDocument.getElementById('child')) + .width; + + return [initial, updated, selfRelative, updatedLineHeight, childWidth, values()].join('|'); +})() +"#, + ) + .expect("root font-relative units should track isolated live root styles"); + + assert_eq!( + result, + "10px,88px,20px,14px|20px,176px,40px,28px|10px,88px,20px,14px|10px,88px,30px,14px|70px|10px,88px,20px,14px" + ); +} + +#[test] +fn replacing_document_root_replaces_retained_root_font_state() { + let mut vm = new_parsed_test_vm( + "https://replaced-root-font-state.test/", + r#" +
+ "#, + ); + + let initial = vm + .eval("getComputedStyle(document.getElementById('target')).width") + .expect("initial descendant should resolve root-relative width"); + assert_eq!(initial, "20px"); + + let replaced = vm + .eval( + r#" +(() => { + const nextRoot = document.createElement('html'); + nextRoot.style.fontSize = '7px'; + const nextBody = document.createElement('body'); + const nextTarget = document.createElement('div'); + nextTarget.id = 'next-target'; + nextTarget.style.width = '2rem'; + nextBody.appendChild(nextTarget); + nextRoot.appendChild(nextBody); + document.replaceChild(nextRoot, document.documentElement); + return getComputedStyle(nextTarget).width; +})() +"#, + ) + .expect("new descendant should replace the retained root state"); + assert_eq!(replaced, "14px"); + + vm.set_viewport_surface(Some(crate::protocol_types::ViewportSurface { + inner_width: 800, + inner_height: 600, + outer_width: 800, + outer_height: 600, + device_pixel_ratio: 1.0, + screen_width: 1920, + screen_height: 1080, + screen_avail_width: 1920, + screen_avail_height: 1040, + ..Default::default() + })) + .expect("viewport device should rebuild after root replacement"); + let rebuilt = vm + .eval("getComputedStyle(document.getElementById('next-target')).width") + .expect("rebuilt device should retain the replacement root state"); + assert_eq!(rebuilt, "14px"); +} + +#[test] +fn root_font_relative_units_survive_viewport_device_rebuilds() { + let mut vm = new_parsed_test_vm( + "https://root-rem-device-rebuild.test/", + "", + ); + let surface = |inner_width| crate::protocol_types::ViewportSurface { + inner_width, + inner_height: 600, + outer_width: inner_width, + outer_height: 600, + device_pixel_ratio: 1.0, + screen_width: 1920, + screen_height: 1080, + screen_avail_width: 1920, + screen_avail_height: 1040, + ..Default::default() + }; + vm.set_viewport_surface(Some(surface(1000))) + .expect("initial viewport should update"); + + let wide = vm + .eval( + r#" +(() => { + const style = document.createElement('style'); + style.textContent = ` + html { font-size: 10px; } + @media (min-width: 800px) { html { font-size: 20px; } } + #root-rem-target { width: 2rem; } + `; + document.head.appendChild(style); + const target = document.createElement('div'); + target.id = 'root-rem-target'; + document.body.appendChild(target); + globalThis.__rootRemTarget = target; + const width = getComputedStyle(target).width; + return [width, getComputedStyle(document.documentElement).fontSize].join('|'); +})() +"#, + ) + .expect("wide root rem styles should resolve"); + assert_eq!(wide, "40px|20px"); + + vm.set_viewport_surface(Some(surface(500))) + .expect("narrow viewport should update"); + let narrow = vm + .eval( + "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", + ) + .expect("narrow root rem styles should resolve"); + assert_eq!(narrow, "20px|10px"); + + vm.set_viewport_surface(Some(surface(1000))) + .expect("restored viewport should update"); + let restored = vm + .eval( + "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", + ) + .expect("restored root rem styles should resolve"); + assert_eq!(restored, "40px|20px"); + + vm.eval("document.documentElement.style.fontSize = '30px'; true") + .expect("pending root font mutation should apply"); + vm.set_viewport_surface(Some(surface(900))) + .expect("device should rebuild while the root mutation is pending"); + let pending_mutation = vm + .eval( + "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", + ) + .expect("descendant should observe the pending root mutation after device rebuild"); + assert_eq!(pending_mutation, "60px|30px"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs index 7c53f41d786..df137cdd509 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs @@ -1507,7 +1507,7 @@ return [ } #[test] -fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() { +fn geometry_getters_refresh_after_mutation_and_reuse_clean_layout() { let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/"); let passes_before = vm ._context_host @@ -1557,7 +1557,7 @@ yield; // Publish this scene before reading its geometry. ) .expect("latest layout snapshot reads should evaluate"); - assert_eq!(result, "38|8|38|8|38|38|0"); + assert_eq!(result, "38|8|38|8|38|43|8"); let passes = vm ._context_host .borrow() @@ -1565,13 +1565,13 @@ yield; // Publish this scene before reading its geometry. .1 .saturating_sub(passes_before); assert_eq!( - passes, 1, - "only the explicit fixture publication builds layout" + passes, 2, + "the explicit fixture publication and the first dirty geometry read build layout" ); let cache_after = vm.layout_snapshot_cache_observability_for_test(); - assert_eq!(cache_after.0, cache_before.0 + 11); - assert_eq!(cache_after.1, cache_before.1); - assert_eq!(cache_after.2, cache_before.2 + 1); + assert_eq!(cache_after.0, cache_before.0 + 10); + assert_eq!(cache_after.1, cache_before.1 + 1); + assert_eq!(cache_after.2, cache_before.2 + 2); assert!(cache_after.3.is_some()); } diff --git a/moli-renderer-v8/src/style_engine/computed.rs b/moli-renderer-v8/src/style_engine/computed.rs index 7161ee44144..3443630189a 100644 --- a/moli-renderer-v8/src/style_engine/computed.rs +++ b/moli-renderer-v8/src/style_engine/computed.rs @@ -4,6 +4,7 @@ use dom::ElementState as StyloElementState; use style::{ Atom, animation::DocumentAnimationSet, + computed_value_flags::ComputedValueFlags, context::{ QuirksMode, RegisteredSpeculativePainter, RegisteredSpeculativePainters, SharedStyleContext, StyleContext, StyleSystemOptions, ThreadLocalStyleContext, @@ -49,6 +50,7 @@ use super::{ cache::ComputedElementStyleCacheKey, document_world::DocumentStyleWorld, lazy_invalidation::StyleValidationPathEntry, + retained::synchronize_root_style_device, source_lifecycle::StyleSourceDocumentContext, world_key::StyleWorldKey, world_lifecycle::{ @@ -928,12 +930,13 @@ fn resolve_element_styles( shared: &shared, thread_local: &mut thread_local, }; - let ancestor_resolution_count = materialize_ancestor_styles_for_resolution( - &mut context, - element, - world, - validation_path, - ); + let (ancestor_resolution_count, mut resolved_root_style) = + materialize_ancestor_styles_for_resolution( + &mut context, + element, + world, + validation_path, + ); let target = DomHandle::new(element.as_node().debug_id()); world.pseudo_style_cache.invalidate_handles([target]); // `resolve_style` is the single-node initial-style API. Keep dirty @@ -949,6 +952,14 @@ fn resolve_element_styles( pseudo_element, None, ); + if let Some(root_style) = styles.primary.as_ref().filter(|computed| { + computed + .flags + .contains(ComputedValueFlags::IS_ROOT_ELEMENT_STYLE) + }) { + synchronize_root_style_device(context.shared.stylist.device(), root_style); + resolved_root_style = Some(root_style.clone()); + } unsafe { let mut data = element.ensure_data(); data.styles = styles.clone(); @@ -970,6 +981,9 @@ fn resolve_element_styles( world .document_state .note_element_style_resolutions(ancestor_resolution_count.saturating_add(1)); + if let Some(root_style) = resolved_root_style { + *retained.root_style.borrow_mut() = Some(root_style); + } styles }); world @@ -983,7 +997,7 @@ fn materialize_ancestor_styles_for_resolution( element: E, world: &DocumentStyleWorld, validation_path: Option<&[StyleValidationPathEntry]>, -) -> u64 +) -> (u64, Option>) where E: TElement, { @@ -1010,6 +1024,7 @@ where .note_ancestor_style_validation_visits(ancestors.len() as u64); let mut resolution_count = 0_u64; + let mut resolved_root_style = None; let mut force_descendants = false; for (ancestor, required_generation) in ancestors { let handle = DomHandle::new(ancestor.as_node().debug_id()); @@ -1028,6 +1043,14 @@ where ancestor.ensure_data().styles = ElementStyles::default(); } let styles = resolve_style(context, ancestor, RuleInclusion::All, None, None); + if let Some(root_style) = styles.primary.as_ref().filter(|computed| { + computed + .flags + .contains(ComputedValueFlags::IS_ROOT_ELEMENT_STYLE) + }) { + synchronize_root_style_device(context.shared.stylist.device(), root_style); + resolved_root_style = Some(root_style.clone()); + } resolution_count = resolution_count.saturating_add(1); unsafe { let mut data = ancestor.ensure_data(); @@ -1040,7 +1063,7 @@ where .mark_element_current(handle, required_generation); force_descendants = true; } - resolution_count + (resolution_count, resolved_root_style) } /// Maps the memoized DOM path suffix back to Stylo ancestors. diff --git a/moli-renderer-v8/src/style_engine/retained.rs b/moli-renderer-v8/src/style_engine/retained.rs index 51fd1b51c83..f8600903954 100644 --- a/moli-renderer-v8/src/style_engine/retained.rs +++ b/moli-renderer-v8/src/style_engine/retained.rs @@ -2,7 +2,9 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; use style::{ author_styles::AuthorStyles, + device::Device, invalidation::stylesheets::StylesheetInvalidationSet, + properties::ComputedValues, servo_arc::Arc as ServoArc, shared_lock::{SharedRwLock, StylesheetGuards}, stylesheets::{CustomMediaMap, DocumentStyleSheet, Origin, OriginSet, UrlExtraData}, @@ -165,6 +167,7 @@ pub(super) fn build_retained_style_system( stylist_identity: NEXT_STYLIST_IDENTITY.fetch_add(1, Ordering::Relaxed), key, stylist, + root_style: Default::default(), document_stylesheets, shadow_scopes, stylesheet_resources, @@ -383,6 +386,9 @@ fn update_document_scope( if device_changed { let device = new_style_device_with_viewport(key.style_viewport(), key.environment, key.quirks_mode); + if let Some(root_style) = retained.root_style.borrow().as_ref() { + synchronize_root_style_device(&device, root_style); + } let guard = shared_lock.read(); let guards = StylesheetGuards::same(&guard); device_affected_origins = retained.stylist.set_device(device, &guards); @@ -437,6 +443,32 @@ fn update_document_scope( } } +/// Synchronizes the state Stylo uses for root font-relative units. +/// +/// Full Stylo traversal performs this in `matching.rs`, but Moli deliberately +/// uses the single-node `resolve_style` API and therefore must mirror the +/// update when the root element is materialized. Keeping this helper at the +/// Device boundary also lets a rebuilt Device inherit the current root state +/// before any descendant is resolved against it. +pub(super) fn synchronize_root_style_device( + device: &Device, + root_style: &ServoArc, +) { + device.set_root_style(root_style); + let font = root_style.get_font(); + let font_size = font.clone_font_size().computed_size(); + device.set_root_font_size(root_style.effective_zoom.unzoom(font_size.px())); + + let line_height = device + .calc_line_height(font, root_style.writing_mode, None) + .0; + device.set_root_line_height(root_style.effective_zoom.unzoom(line_height.px())); + + if device.used_root_font_metrics() { + device.update_root_font_metrics(); + } +} + fn refresh_retained_derived_state( retained: &mut RetainedStyleSystem, device_changed: bool, diff --git a/moli-renderer-v8/src/style_engine/state.rs b/moli-renderer-v8/src/style_engine/state.rs index 6bb6016f8dc..c6cdcc37fe6 100644 --- a/moli-renderer-v8/src/style_engine/state.rs +++ b/moli-renderer-v8/src/style_engine/state.rs @@ -6,6 +6,7 @@ use std::{ use moli_selector::StyloSourceInvalidationFallbackReason; use selectors::context::SelectorCaches; use style::{ + properties::ComputedValues, servo_arc::Arc as ServoArc, stylist::{CascadeData, Stylist}, }; @@ -31,6 +32,11 @@ pub(super) struct RetainedStyleSystem { pub(super) stylist_identity: u64, pub(super) key: StyleWorldKey, pub(super) stylist: Stylist, + /// Last computed style for this Document's root element. Stylo normally + /// updates the Device's root-relative unit state during a full traversal; + /// Moli resolves demanded paths one element at a time, so retain the root + /// style explicitly both for those resolutions and for Device rebuilds. + pub(super) root_style: RefCell>>, pub(super) document_stylesheets: ActiveStylesheetCollection, pub(super) shadow_scopes: Vec, pub(super) stylesheet_resources: StylesheetResourceManifest, diff --git a/moli-renderer-v8/src/stylesheet_runtime/connected.rs b/moli-renderer-v8/src/stylesheet_runtime/connected.rs index e4a58dea69b..15b2e2c7a28 100644 --- a/moli-renderer-v8/src/stylesheet_runtime/connected.rs +++ b/moli-renderer-v8/src/stylesheet_runtime/connected.rs @@ -549,11 +549,11 @@ impl DocumentRuntime { owner = ?binding.owner(), element = ?binding.element(), load_delay_token = ?binding.load_delay_token(), - settled, + ?settled, reason, "settled main connected style load before event posting" ); - settled + settled != crate::frame_owner_model::MainDocumentStyleLoadEventSettlement::NotOwned } fn settle_connected_style_load_admission( diff --git a/moli/src/telemetry.rs b/moli/src/telemetry.rs index 626b89d53a3..ae99c520dc9 100644 --- a/moli/src/telemetry.rs +++ b/moli/src/telemetry.rs @@ -40,6 +40,9 @@ mod tests { struct SharedWriterGuard(Arc>>); + #[derive(Clone, Copy)] + struct BrokenWriter; + impl Write for SharedWriterGuard { fn write(&mut self, buffer: &[u8]) -> io::Result { self.0.lock().extend_from_slice(buffer); @@ -51,6 +54,30 @@ mod tests { } } + impl Write for BrokenWriter { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "diagnostic sink is gone", + )) + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "diagnostic sink is gone", + )) + } + } + + impl<'writer> MakeWriter<'writer> for BrokenWriter { + type Writer = Self; + + fn make_writer(&'writer self) -> Self::Writer { + *self + } + } + impl<'writer> MakeWriter<'writer> for SharedWriter { type Writer = SharedWriterGuard; From f9b3ce789704b4018edcd403570bfadf80ea8533 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 18 Sep 2026 07:18:03 +0800 Subject: [PATCH 02/15] fix(layout): exclude collapsed line-end spaces from overflow --- moli-layout/src/inline.rs | 67 ++++++++++++++++++++- moli-layout/tests/phase5_output_contract.rs | 65 ++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index 7472fa9b00a..785126243c1 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -576,10 +576,28 @@ pub(crate) fn build_inline_fragments( for (line_index, line) in layout.lines().enumerate() { let metrics = line.metrics(); + let line_range = line.text_range(); + let trailing_start = overlapping_output_ranges(&context.text_units, &line_range) + .iter() + .rev() + .take_while(|unit| unit.control || unit.collapsible_whitespace) + .filter(|unit| unit.collapsible_whitespace) + .map(|unit| unit.output_range.start) + .last(); + let mut collapsed_trailing_advance = 0.0; + if let Some(start) = trailing_start { + for run in line.runs() { + for cluster in run.visual_clusters() { + if cluster.text_range().start >= start { + collapsed_trailing_advance += cluster.advance().max(0.0); + } + } + } + } let placement = line_placements .get(line_index) .filter(|placement| placement.line_index == line_index); - let line_rect = placement.map_or_else( + let mut line_rect = placement.map_or_else( || { PaintRect::new( metrics.inline_min_coord + metrics.offset, @@ -590,6 +608,14 @@ pub(crate) fn build_inline_fragments( }, |placement| placement.rect, ); + // Parley retains hanging spaces for shaping/line breaking. CSS + // collapsed line-end spaces contribute neither range/inline-box + // geometry nor scrollable overflow. Do not trim preserved or NBSP + // advances merely because Parley classifies them as whitespace. + line_rect.width = (line_rect.width - collapsed_trailing_advance).max(0.0); + if layout.is_rtl() { + line_rect.x += collapsed_trailing_advance; + } fragments.lines.push(InlineLineFragment { line_index, rect: line_rect, @@ -614,6 +640,9 @@ pub(crate) fn build_inline_fragments( let run_metrics = run.font_metrics(); for cluster in run.visual_clusters() { let range = cluster.text_range(); + if trailing_start.is_some_and(|start| range.start >= start) { + continue; + } let style_index = usize::from(cluster.style_index()); let vertical_offset = placement.map_or(0.0, |placement| { placement.glyph_offset(run.index(), style_index) @@ -3415,6 +3444,40 @@ mod tests { normalizer.finish() } + #[test] + fn only_collapsible_whitespaces_are_marked_for_line_end_removal() { + let text = LayoutBoxId::from_index(1); + for mode in [ + InlineWhiteSpaceCollapse::Collapse, + InlineWhiteSpaceCollapse::PreserveBreaks, + InlineWhiteSpaceCollapse::Preserve, + InlineWhiteSpaceCollapse::BreakSpaces, + ] { + let input = normalize(&[(text, "A \u{a0} B")], mode, InlineTextTransform::None); + let collapsed = input + .units + .iter() + .filter(|unit| unit.collapsible_whitespace) + .collect::>(); + assert_eq!( + collapsed.len(), + if matches!( + mode, + InlineWhiteSpaceCollapse::Collapse | InlineWhiteSpaceCollapse::PreserveBreaks + ) { + 2 + } else { + 0 + } + ); + assert!( + collapsed + .iter() + .all(|unit| &input.text[unit.output_range.clone()] == " ") + ); + } + } + #[test] fn preserve_merges_crlf_across_adjacent_text_nodes_with_both_origins() { let first = LayoutBoxId::from_index(1); @@ -3510,7 +3573,7 @@ mod tests { } #[test] - fn collapsed_spaces_remain_in_dom_order_across_inline_boundaries() { + fn collapsible_whitespaces_remain_in_dom_order_across_inline_boundaries() { let root = LayoutBoxId::from_index(0); let first_inline = LayoutBoxId::from_index(1); let first_text = LayoutBoxId::from_index(2); diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index e318b992989..73460027fd5 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -4349,3 +4349,68 @@ fn transformed_fixed_containing_block_still_clips_its_fixed_descendant() { "the fixed containing block's overflow clip must still apply" ); } + +#[test] +fn inline_scroll_overflow_keeps_unbreakable_text_and_nbsp() { + for text in ["abcdefghijklmno", "alpha\u{a0}beta\u{a0}gamma"] { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("scroller", vec![2]), + Node::element("fixed-font", vec![3]), + Node::text("text", text), + ]); + let (font_style, mut services) = fixed_inline_font(); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles.0.insert( + 1, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(85.0), + height: length(40.0), + }, + overflow: Point { + x: Overflow::Scroll, + y: Overflow::Scroll, + }, + ..Style::default() + }, + ), + ); + styles.0.insert(2, font_style); + let output = build_layout_pass( + &source, + &mut styles, + &mut services, + LayoutPassRequest::new(LayoutViewport::new(320, 240, 1.0), LayoutFlushReason::Test), + ) + .unwrap(); + let scroller = output.source_output(1).unwrap().principal_box.unwrap(); + assert!( + output + .scroll_extent(scroller) + .unwrap() + .horizontal_scrollbar + .is_some(), + "{text}" + ); + assert!( + output + .element_metrics_for_source(1) + .unwrap() + .scroll_size + .width + > 85.0, + "{text}" + ); + assert!( + !output + .text_range_rects(3, 0..text.encode_utf16().count()) + .is_empty() + ); + } +} From fba5a4253fb8548302a9070160779b8a7920c240 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 16:56:02 +0800 Subject: [PATCH 03/15] fix(network): project redirected request metadata --- moli-layout/tests/phase5_output_contract.rs | 23 ++++-- moli-protocol/src/domains/network.rs | 1 + moli-protocol/src/domains/network/backlog.rs | 25 ++++-- .../src/domains/network/redirect_request.rs | 79 +++++++++++++++++++ 4 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 moli-protocol/src/domains/network/redirect_request.rs diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index 73460027fd5..fa550f9a233 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -1092,11 +1092,23 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { "alpha beta gamma delta epsilon zeta eta theta iota kappa ", "supercalifragilisticexpialidocious", ); + assert_scrollbar_feedback_inline_geometry(TEXT, true); +} + +#[test] +fn collapsed_line_end_spaces_do_not_create_horizontal_scrollbar_after_feedback() { + assert_scrollbar_feedback_inline_geometry( + "alpha beta gamma delta epsilon zeta eta theta iota kappa", + false, + ); +} + +fn assert_scrollbar_feedback_inline_geometry(text: &'static str, horizontal_overflow: bool) { let source = Source(vec![ Node::element("root", vec![1]), Node::element("scroller", vec![2]), Node::element("fixed-font", vec![3]), - Node::text("text", TEXT), + Node::text("text", text), ]); let mut styles = Styles::default(); styles @@ -1144,7 +1156,7 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { assert!(extent.vertical_scrollbar.is_some()); assert!(extent.horizontal_scrollbar.is_none()); assert!(!extent.allows_user_scroll_x); - let feedback_text = feedback.text_range_rects(3, 0..TEXT.encode_utf16().count()); + let feedback_text = feedback.text_range_rects(3, 0..text.encode_utf16().count()); assert!( !feedback_text.is_empty(), "compare real text fragments, not element-only client rects" @@ -1175,13 +1187,14 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { .unwrap(); assert_eq!( feedback_text, - direct.text_range_rects(3, 0..TEXT.encode_utf16().count()) + direct.text_range_rects(3, 0..text.encode_utf16().count()) ); - assert!( + assert_eq!( feedback_text .iter() .any(|quad| quad.points.iter().any(|point| point.x > 85.0)), - "the unbreakable final word should retain horizontal overflow" + horizontal_overflow, + "only the unbreakable final word should retain horizontal overflow" ); } diff --git a/moli-protocol/src/domains/network.rs b/moli-protocol/src/domains/network.rs index 86aca1d8d76..8a30a9f9e10 100644 --- a/moli-protocol/src/domains/network.rs +++ b/moli-protocol/src/domains/network.rs @@ -44,6 +44,7 @@ mod load_resource; mod main_document_progress; mod output; mod output_queue; +mod redirect_request; mod response_body; pub(crate) mod settings; #[cfg(test)] diff --git a/moli-protocol/src/domains/network/backlog.rs b/moli-protocol/src/domains/network/backlog.rs index 4a3037fd9e3..7fc34652321 100644 --- a/moli-protocol/src/domains/network/backlog.rs +++ b/moli-protocol/src/domains/network/backlog.rs @@ -24,6 +24,7 @@ use super::output_queue::{ TargetSubresourceRequestNetworkDeliveryOutput, TargetSubresourceResponseNetworkDeliveryOutput, TargetWebSocketDeliveryRecord, TargetWebSocketLifecycleDeliveryKind, }; +use super::redirect_request::RedirectRequest; pub(crate) struct NetworkBacklogProjectionContext<'a> { owner: CommandOwnerScope, @@ -287,7 +288,13 @@ fn emit_complete_subresource_network_delivery_record( response_headers, response_body_len, } => { + let mut redirected_request = RedirectRequest::new( + output.method(), + output.request_body(), + output.request_headers(), + ); for redirect in redirect_chain { + redirected_request.follow(redirect.status); for event_session_id in event_session_ids { emit_request_will_be_sent( out, @@ -298,9 +305,9 @@ fn emit_complete_subresource_network_delivery_record( timestamp, record_document_url, &redirect.to_url, - output.method(), - output.request_body(), - output.request_headers(), + redirected_request.method, + redirected_request.body, + &redirected_request.headers, resource_type, output.request_initiator_type(), Some(( @@ -508,7 +515,13 @@ fn emit_staged_subresource_response_started( let loader_id = request.loader_id(); let timestamp = base_timestamp + ((output.index() + 1) as f64 * 0.000_001); let resource_type = request.resource_type().into(); + let mut redirected_request = RedirectRequest::new( + request.method(), + request.request_body(), + request.request_headers(), + ); for redirect in output.redirect_chain() { + redirected_request.follow(redirect.status); for event_session_id in event_session_ids { emit_request_will_be_sent( out, @@ -519,9 +532,9 @@ fn emit_staged_subresource_response_started( timestamp, request.document_url(), &redirect.to_url, - request.method(), - request.request_body(), - request.request_headers(), + redirected_request.method, + redirected_request.body, + &redirected_request.headers, resource_type, request.request_initiator_type(), Some(( diff --git a/moli-protocol/src/domains/network/redirect_request.rs b/moli-protocol/src/domains/network/redirect_request.rs new file mode 100644 index 00000000000..01dbebf0d20 --- /dev/null +++ b/moli-protocol/src/domains/network/redirect_request.rs @@ -0,0 +1,79 @@ +/// Project the request metadata for successive followed redirect hops. +/// +/// This mirrors FetchRequest::apply_redirect_status: the emitted CDP request +/// must describe the new hop, not repeat the original POST after it became GET. +pub(super) struct RedirectRequest<'a> { + pub(super) method: &'a str, + pub(super) body: Option<&'a str>, + pub(super) headers: Vec<(String, String)>, +} + +impl<'a> RedirectRequest<'a> { + pub(super) fn new( + method: &'a str, + body: Option<&'a str>, + headers: &[(String, String)], + ) -> Self { + Self { + method, + body, + headers: headers.to_vec(), + } + } + + pub(super) fn follow(&mut self, status: u16) { + let becomes_get = matches!(status, 301 | 302) && self.method.eq_ignore_ascii_case("POST") + || status == 303 + && !self.method.eq_ignore_ascii_case("GET") + && !self.method.eq_ignore_ascii_case("HEAD"); + if becomes_get { + self.method = "GET"; + self.body = None; + self.headers.retain(|(name, _)| { + !matches!( + name.to_ascii_lowercase().as_str(), + "content-encoding" + | "content-language" + | "content-length" + | "content-location" + | "content-type" + ) + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::RedirectRequest; + + #[test] + fn redirect_status_method_matrix_matches_fetch_semantics() { + for status in [301, 302, 303, 307, 308] { + for method in ["GET", "HEAD", "POST", "PUT", "DELETE", "post"] { + let headers = vec![ + ("Content-Type".into(), "text/plain".into()), + ("CONTENT-LENGTH".into(), "5".into()), + ("Accept".into(), "*/*".into()), + ]; + let mut request = RedirectRequest::new(method, Some("value"), &headers); + request.follow(status); + let changed = matches!(status, 301 | 302) && method.eq_ignore_ascii_case("POST") + || status == 303 && !matches!(method, "GET" | "HEAD"); + assert_eq!(request.method, if changed { "GET" } else { method }); + assert_eq!(request.body, if changed { None } else { Some("value") }); + assert_eq!(request.headers.len(), if changed { 1 } else { 3 }); + assert_eq!(headers.len(), 3, "original metadata stays unchanged"); + } + } + } + + #[test] + fn redirected_get_does_not_recover_original_post_on_later_307() { + let mut request = RedirectRequest::new("POST", Some("value"), &[]); + request.follow(302); + request.follow(307); + assert_eq!(request.method, "GET"); + assert_eq!(request.body, None); + } +} From b731d64154c63ec54dbee13f6c70b033792aa022 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 19:03:55 +0800 Subject: [PATCH 04/15] fix(browser): address activation and document review regressions --- moli-bounded-buffer/src/buffer.rs | 29 +++---- moli-bounded-buffer/src/tests.rs | 16 ++++ moli-layout/src/inline.rs | 27 ++++++- moli-layout/src/projection.rs | 72 ++++++++++-------- moli-layout/src/taffy_tree.rs | 59 +++++++-------- moli-layout/tests/phase5_output_contract.rs | 51 ++++++++++++- moli-protocol/src/domains/network/agent.rs | 75 +++++++++++-------- .../src/runtime/page_vm/tests/fetch_xhr.rs | 6 +- 8 files changed, 223 insertions(+), 112 deletions(-) diff --git a/moli-bounded-buffer/src/buffer.rs b/moli-bounded-buffer/src/buffer.rs index d5a415e6e75..ed0fa4ed35b 100644 --- a/moli-bounded-buffer/src/buffer.rs +++ b/moli-bounded-buffer/src/buffer.rs @@ -70,22 +70,23 @@ where /// longer fit. Retained entries keep their original insertion order. pub fn set_limits(&mut self, limits: ByteLimits) -> Vec<(K, V)> { self.limits = limits; - let oversized = self - .insertion_order - .iter() - .filter(|key| { - self.entries - .get(*key) - .is_some_and(|entry| entry.byte_len > limits.max_entry_bytes) - }) - .cloned() - .collect::>(); let mut evicted = Vec::new(); - for key in oversized { - if let Some(value) = self.remove(&key) { - evicted.push((key, value)); + // Filter the order once. Calling remove for every oversized entry + // would rescan the whole queue on each removal when budgets shrink. + self.insertion_order.retain(|key| { + if self + .entries + .get(key) + .is_none_or(|entry| entry.byte_len <= limits.max_entry_bytes) + { + return true; } - } + if let Some(entry) = self.entries.remove(key) { + self.used_bytes -= entry.byte_len; + evicted.push((key.clone(), entry.value)); + } + false + }); while self.used_bytes > limits.max_total_bytes { if let Some(entry) = self.pop_oldest() { evicted.push(entry); diff --git a/moli-bounded-buffer/src/tests.rs b/moli-bounded-buffer/src/tests.rs index d4558052cea..0f3b57d5663 100644 --- a/moli-bounded-buffer/src/tests.rs +++ b/moli-bounded-buffer/src/tests.rs @@ -1,5 +1,21 @@ use super::{BoundedByteBuffer, ByteLimits, InsertOutcome}; +#[test] +fn shrinking_entry_limit_keeps_interleaved_survivors_in_fifo_order() { + let mut buffer = BoundedByteBuffer::new(ByteLimits::new(100, 20)); + for (key, bytes) in [(0, 10), (1, 1), (2, 10), (3, 1), (4, 10)] { + buffer.insert(key, key, bytes); + } + assert_eq!( + buffer.set_limits(ByteLimits::new(100, 1)), + vec![(0, 0), (2, 2), (4, 4)] + ); + assert_eq!(buffer.used_bytes(), 2); + assert_eq!(buffer.set_limits(ByteLimits::new(1, 1)), vec![(1, 1)]); + assert_eq!(buffer.get(&3), Some(&3)); + assert_eq!(buffer.len(), 1); +} + #[test] fn changing_limits_evicts_oversized_then_oldest_without_reordering_survivors() { let mut buffer = BoundedByteBuffer::new(ByteLimits::new(20, 10)); diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index 785126243c1..eb5c0a8132a 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -573,14 +573,39 @@ pub(crate) fn build_inline_fragments( .iter() .map(text_style_paint_outsets) .collect::>(); + // Inline objects occupy positions between text units, not text bytes. + // Retain their logical offsets so spaces before an atomic box cannot be + // mistaken for line-end whitespace (including in bidi-reordered lines). + let atomic_offsets: HashMap<_, _> = layout + .inline_boxes() + .filter(|inline_box| { + context + .object(inline_box.id) + .is_some_and(|object| object.role == InlineObjectRole::Atomic) + }) + .map(|inline_box| (inline_box.id, inline_box.index)) + .collect(); for (line_index, line) in layout.lines().enumerate() { let metrics = line.metrics(); let line_range = line.text_range(); + let last_atomic_offset = line + .items() + .filter_map(|item| match item { + PositionedLayoutItem::InlineBox(positioned) => { + atomic_offsets.get(&positioned.id).copied() + } + PositionedLayoutItem::GlyphRun(_) => None, + }) + .max() + .unwrap_or(line_range.start); let trailing_start = overlapping_output_ranges(&context.text_units, &line_range) .iter() .rev() - .take_while(|unit| unit.control || unit.collapsible_whitespace) + .take_while(|unit| { + unit.output_range.start >= last_atomic_offset + && (unit.control || unit.collapsible_whitespace) + }) .filter(|unit| unit.collapsible_whitespace) .map(|unit| unit.output_range.start) .last(); diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index e563082330c..63c84071671 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -773,37 +773,7 @@ where let bottom = last.final_layout.location.y - last_offset.y + last.final_layout.size.height; let rect = LayoutRect::new(content.x, top, content.width, (bottom - top).max(0.0)); - // A zero-area leading continuation follows escaped leading - // margins into the first block's flow position. It carries no - // line strut of its own and must not remain at the pre-collapse y. - for id in &self.boxes[run.preceding_inline.index()].fragments { - let existing = &mut self.fragments[id.index()]; - if matches!(existing.kind, LayoutFragmentKind::InlineBox { .. }) - && existing.rect.width == 0.0 - && existing.rect.height == 0.0 - { - let original = self.coordinate_spaces[existing.coordinate_space.index()] - .layout - .local_to_document_ignoring_css_transforms - .map_point(LayoutPoint::new(existing.rect.x, existing.rect.y)); - let x = self.coordinate_spaces[parent.index() + 1] - .layout - .local_to_document_ignoring_css_transforms - .inverse() - .map(|inverse| inverse.map_point(original).x) - .unwrap_or(existing.rect.x); - let boundary = LayoutRect::new(x, top, 0.0, 0.0); - existing.rect = boundary; - existing.coordinate_space = - LayoutCoordinateSpaceId::from_index(parent.index() + 1); - existing.box_model = Some(LayoutFragmentBoxModel { - content: boundary, - padding: boundary, - border: boundary, - margin: boundary, - }); - } - } + self.reposition_empty_inline_continuation(run.preceding_inline, parent, top); let fragment = self.push_fragment(LayoutFragment { id: LayoutFragmentId::from_index(0), kind: LayoutFragmentKind::Box { @@ -824,6 +794,46 @@ where } } + /// An empty leading continuation follows escaped margins into the first + /// block's flow position; it carries no independent line strut. + fn reposition_empty_inline_continuation( + &mut self, + inline: LayoutBoxId, + parent: LayoutBoxId, + top: f32, + ) { + let coordinate_space = LayoutCoordinateSpaceId::from_index(parent.index() + 1); + let document_to_parent = self.coordinate_spaces[coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .inverse(); + for id in &self.boxes[inline.index()].fragments { + let fragment = &mut self.fragments[id.index()]; + if !matches!(fragment.kind, LayoutFragmentKind::InlineBox { .. }) + || fragment.rect.width != 0.0 + || fragment.rect.height != 0.0 + { + continue; + } + let original = self.coordinate_spaces[fragment.coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .map_point(LayoutPoint::new(fragment.rect.x, fragment.rect.y)); + let x = document_to_parent + .map(|inverse| inverse.map_point(original).x) + .unwrap_or(fragment.rect.x); + let boundary = LayoutRect::new(x, top, 0.0, 0.0); + fragment.rect = boundary; + fragment.coordinate_space = coordinate_space; + fragment.box_model = Some(LayoutFragmentBoxModel { + content: boundary, + padding: boundary, + border: boundary, + margin: boundary, + }); + } + } + fn split_block_relative_inset_basis(&self, parent: LayoutBoxId) -> taffy::Size> { let content = self.boxes[parent.index()].content_box; let Some(inputs) = self diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 8163fd3530f..1a3aaf8e35b 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -103,45 +103,44 @@ impl PreparedWorldLayout { N: Copy + Debug + Eq + Hash, { assert!(self.feedback_invalidation_worklist.is_empty()); + if viewport_changed { + for layout_box in &mut world.boxes { + layout_box.clear_layout_caches(); + } + world.viewport_layout.cache.clear(); + return world.boxes.len(); + } + if changed_boxes.is_empty() { + return 0; + } self.feedback_invalidation_marks .resize(world.boxes.len(), false); let mut invalidated = Vec::new(); - if viewport_changed { - for index in 0..world.boxes.len() { - let id = LayoutBoxId::from_index(index); - self.feedback_invalidation_marks[index] = true; - invalidated.push(id); - world.boxes[index].clear_layout_caches(); + self.feedback_invalidation_worklist + .extend(changed_boxes.iter().copied()); + while let Some(id) = self.feedback_invalidation_worklist.pop() { + if self.feedback_invalidation_marks[id.index()] { + continue; } - } else { + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); self.feedback_invalidation_worklist - .extend(changed_boxes.iter().copied()); - while let Some(id) = self.feedback_invalidation_worklist.pop() { - if self.feedback_invalidation_marks[id.index()] { - continue; - } - self.feedback_invalidation_marks[id.index()] = true; - invalidated.push(id); - world.boxes[id.index()].clear_layout_caches(); - self.feedback_invalidation_worklist - .extend(world.boxes[id.index()].layout_children.iter().copied()); - } + .extend(world.boxes[id.index()].layout_children.iter().copied()); + } - for changed in changed_boxes.iter().copied() { - let mut ancestor = world.boxes[changed.index()].layout_parent; - while let Some(id) = ancestor { - if !self.feedback_invalidation_marks[id.index()] { - self.feedback_invalidation_marks[id.index()] = true; - invalidated.push(id); - world.boxes[id.index()].clear_layout_caches(); - } - ancestor = world.boxes[id.index()].layout_parent; + for changed in changed_boxes.iter().copied() { + let mut ancestor = world.boxes[changed.index()].layout_parent; + while let Some(id) = ancestor { + if !self.feedback_invalidation_marks[id.index()] { + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); } + ancestor = world.boxes[id.index()].layout_parent; } } - if viewport_changed || !changed_boxes.is_empty() { - world.viewport_layout.cache.clear(); - } + world.viewport_layout.cache.clear(); for id in invalidated.iter().copied() { self.feedback_invalidation_marks[id.index()] = false; } diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index fa550f9a233..7d0b339d226 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -5,9 +5,9 @@ use moli_layout::{ LayoutElementSemantics, LayoutEnvironment, LayoutError, LayoutFlushReason, LayoutFragmentKind, LayoutNamespace, LayoutPaintedSurfaceHit, LayoutPassRequest, LayoutPassResult, LayoutPoint, LayoutPosition, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutRect, - LayoutScrollbarColors, LayoutScrollbarGutter, LayoutScrollbarPart, LayoutScrollbarWidth, - LayoutSource, LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, LayoutViewport, - PaintBrush, PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, + LayoutReplacedKind, LayoutScrollbarColors, LayoutScrollbarGutter, LayoutScrollbarPart, + LayoutScrollbarWidth, LayoutSource, LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, + LayoutViewport, PaintBrush, PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, ResolvedLayoutElementStyles, ResolvedLayoutStyle, build_layout_pass, }; use style::Atom; @@ -91,7 +91,7 @@ impl LayoutSource for Source { LayoutNamespace::Html, self.0[node].local_name, LayoutElementCategory::Generic, - None, + (self.0[node].local_name == "img").then_some(LayoutReplacedKind::Image), ) }) } @@ -2809,6 +2809,49 @@ fn inline_output_preserves_line_text_and_utf16_source_fragments() { assert_eq!(scroll_geometry.scroll_containers.len(), 1); } +#[test] +fn collapsed_space_before_atomic_inline_keeps_its_range_geometry() { + for local_name in ["span", "img"] { + for trailing_text in ["", " "] { + let source = Source(vec![ + Node::element("root", vec![1, 2, 3]), + Node::text("before", "a "), + Node::html_element("atomic", local_name, vec![]), + Node::text("after", trailing_text), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 80.0)); + for node in [1, 3] { + styles + .0 + .insert(node, resolved(LayoutDisplay::Inline, Style::default())); + } + let display = if local_name == "img" { + LayoutDisplay::Inline + } else { + LayoutDisplay::InlineBlock + }; + styles.0.insert(2, fixed_size(display, 20.0, 20.0)); + let output = build(&source, &mut styles); + let space = output.text_range_rects(1, 1..2); + assert_eq!(space.len(), 1, "{local_name}, {trailing_text:?}"); + let space = space[0].bounding_rect(); + assert!( + space.width > 0.0, + "space before an atomic inline is not trailing" + ); + let atomic = output.client_rects_for_source(2)[0].bounding_rect(); + assert_close(space.x + space.width, atomic.x); + assert!( + output.text_range_rects(3, 0..1).is_empty(), + "line-end space still collapses" + ); + } + } +} + #[test] fn inline_offset_metrics_use_fragment_bounds_and_keep_empty_sibling_anchors() { let source = Source(vec![ diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs index 7cc72cd4c74..dae7c92f381 100644 --- a/moli-protocol/src/domains/network/agent.rs +++ b/moli-protocol/src/domains/network/agent.rs @@ -1391,24 +1391,39 @@ impl CapturedResponseBodyStore { self.durable_sessions.insert(session, limits); } else { self.durable_sessions.remove(&session); - // Turning off durability revokes only old-document claims, not - // ordinary access to responses from the current document. - for request_id in self.retained_request_ids.clone() { - if let Some(body) = self.bodies.get_mut(&request_id) - && !body.remove_session_visibility(session_id) - { - self.bodies.remove(&request_id); - } - if let Some(body) = self.buffered_bodies.get_mut(&request_id) - && !body.remove_session_visibility(session_id) - { - self.buffered_bodies.remove(&request_id); - } + self.revoke_retained_visibility(session_id); + } + for (id, mut body) in self + .buffered_bodies + .set_limits(self.effective_durable_limits()) + { + body.mark_evicted(); + self.bodies.insert(id, body); + } + self.synchronize_durable_entries(); + } + + fn revoke_retained_visibility(&mut self, session_id: Option<&str>) { + // Revocation affects only old-document claims, not ordinary access to + // responses from the current document. + for request_id in &self.retained_request_ids { + if let Some(body) = self.bodies.get_mut(request_id) + && !body.remove_session_visibility(session_id) + { + self.bodies.remove(request_id); + } + if let Some(body) = self.buffered_bodies.get_mut(request_id) + && !body.remove_session_visibility(session_id) + { + self.buffered_bodies.remove(request_id); } } + } + + fn effective_durable_limits(&self) -> ByteLimits { // One target owns one payload store. Multiple clients cannot multiply // its budget; each requested budget is an upper bound, not a reserve. - let limits = self.durable_sessions.values().fold( + self.durable_sessions.values().fold( ByteLimits::new( RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES, RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES, @@ -1419,26 +1434,26 @@ impl CapturedResponseBodyStore { acc.max_entry_bytes.min(limits.max_entry_bytes), ) }, - ); - for (id, mut body) in self.buffered_bodies.set_limits(limits) { - body.mark_evicted(); - self.bodies.insert(id, body); - } + ) + } + + fn synchronize_durable_entries(&mut self) { if self.durable_sessions.is_empty() { self.durable_entry_order.clear(); self.retained_request_ids.clear(); - } else { - let mut untracked = self - .bodies - .keys() - .chain(self.buffered_bodies.iter().map(|(id, _)| id)) - .filter(|id| !self.durable_entry_order.contains(id)) - .cloned() - .collect::>(); - untracked.sort(); - self.durable_entry_order.extend(untracked); - self.trim_durable_entries(); + return; } + let tracked: HashSet<_> = self.durable_entry_order.iter().collect(); + let mut untracked = self + .bodies + .keys() + .chain(self.buffered_bodies.iter().map(|(id, _)| id)) + .filter(|id| !tracked.contains(id)) + .cloned() + .collect::>(); + untracked.sort(); + self.durable_entry_order.extend(untracked); + self.trim_durable_entries(); } fn track_durable_entry(&mut self, request_id: &str) { diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs b/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs index 695fc600ce0..ef5e1e41826 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs @@ -489,10 +489,12 @@ fn spawn_repeated_synchronous_xhr_server( } Err(error) => panic!("accept repeated synchronous XHR: {error}"), }; - // Accepted sockets inherit nonblocking mode on macOS. + // macOS can inherit O_NONBLOCK from the listening socket. + // This fixture reads the complete request synchronously; the + // socket timeout alone does not turn WouldBlock into a wait. stream .set_nonblocking(false) - .expect("use blocking synchronous XHR request I/O"); + .expect("make accepted synchronous XHR stream blocking"); stream .set_read_timeout(Some(Duration::from_secs(2))) .expect("bound synchronous XHR request read"); From 0fec2220dd604caaf6c88fd8085448f8b3679a1c Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 20:19:48 +0800 Subject: [PATCH 05/15] fix(webdriver): preserve activation boundaries and network cleanup --- moli-bounded-buffer/src/buffer.rs | 49 +++++++++++------ moli-bounded-buffer/src/tests.rs | 34 ++++++++++++ moli-fetch/src/lib.rs | 3 +- moli-fetch/src/request.rs | 6 ++- .../tests/classic/extracted/navigation.rs | 2 - moli-protocol/src/domains/network/agent.rs | 54 ++++++------------- .../src/domains/network/redirect_request.rs | 20 ++----- moli/src/telemetry.rs | 27 ---------- 8 files changed, 93 insertions(+), 102 deletions(-) diff --git a/moli-bounded-buffer/src/buffer.rs b/moli-bounded-buffer/src/buffer.rs index ed0fa4ed35b..b89cb4a7fe9 100644 --- a/moli-bounded-buffer/src/buffer.rs +++ b/moli-bounded-buffer/src/buffer.rs @@ -70,23 +70,7 @@ where /// longer fit. Retained entries keep their original insertion order. pub fn set_limits(&mut self, limits: ByteLimits) -> Vec<(K, V)> { self.limits = limits; - let mut evicted = Vec::new(); - // Filter the order once. Calling remove for every oversized entry - // would rescan the whole queue on each removal when budgets shrink. - self.insertion_order.retain(|key| { - if self - .entries - .get(key) - .is_none_or(|entry| entry.byte_len <= limits.max_entry_bytes) - { - return true; - } - if let Some(entry) = self.entries.remove(key) { - self.used_bytes -= entry.byte_len; - evicted.push((key.clone(), entry.value)); - } - false - }); + let mut evicted = self.retain_entries(|_, entry| entry.byte_len <= limits.max_entry_bytes); while self.used_bytes > limits.max_total_bytes { if let Some(entry) = self.pop_oldest() { evicted.push(entry); @@ -182,12 +166,43 @@ where } /// Removes all entries and releases all byte charges. + /// + /// This does not return removed entries; use [`Self::retain`] to take ownership. pub fn clear(&mut self) { self.entries.clear(); self.insertion_order.clear(); self.used_bytes = 0; } + /// Visits each value once in insertion order, returning removed entries in + /// that order and releasing their byte charges. Mutating a value does not + /// change its recorded charge; use [`Self::insert`] to replace its charge. + pub fn retain(&mut self, mut keep: impl FnMut(&K, &mut V) -> bool) -> Vec<(K, V)> { + self.retain_entries(|key, entry| keep(key, &mut entry.value)) + } + + fn retain_entries( + &mut self, + mut keep: impl FnMut(&K, &mut BufferedValue) -> bool, + ) -> Vec<(K, V)> { + let mut removed = Vec::new(); + self.insertion_order.retain(|key| { + if self + .entries + .get_mut(key) + .is_some_and(|entry| keep(key, entry)) + { + return true; + } + if let Some(entry) = self.entries.remove(key) { + self.used_bytes -= entry.byte_len; + removed.push((key.clone(), entry.value)); + } + false + }); + removed + } + fn pop_oldest(&mut self) -> Option<(K, V)> { while let Some(key) = self.insertion_order.pop_front() { let Some(entry) = self.entries.remove(&key) else { diff --git a/moli-bounded-buffer/src/tests.rs b/moli-bounded-buffer/src/tests.rs index 0f3b57d5663..d002720a5fe 100644 --- a/moli-bounded-buffer/src/tests.rs +++ b/moli-bounded-buffer/src/tests.rs @@ -154,3 +154,37 @@ fn remove_and_clear_return_all_byte_charges() { assert!(buffer.is_empty()); assert_eq!(buffer.used_bytes(), 0); } + +#[test] +fn retain_visits_once_preserves_fifo_and_releases_only_removed_charges() { + let mut buffer = BoundedByteBuffer::new(ByteLimits::new(10, 10)); + for key in 0..4 { + let _ = buffer.insert(key, key * 10, 2); + } + let mut visited = Vec::new(); + let removed = buffer.retain(|key, value| { + visited.push(*key); + *value += 1; + key % 2 == 0 + }); + assert_eq!(visited, vec![0, 1, 2, 3]); + assert_eq!(removed, vec![(1, 11), (3, 31)]); + assert_eq!(buffer.used_bytes(), 4); + assert_eq!(buffer.get(&2), Some(&21)); + assert_eq!( + buffer.insert(4, 40, 8), + InsertOutcome::Stored { + evicted: vec![(0, 1)] + } + ); + assert!(buffer.retain(|_, _| true).is_empty()); + assert_eq!(buffer.used_bytes(), 10); + assert_eq!(buffer.retain(|_, _| false), vec![(2, 21), (4, 40)]); + assert_eq!(buffer.used_bytes(), 0); + assert!(buffer.is_empty()); + assert!( + buffer + .retain(|_, _| panic!("empty buffer must not visit")) + .is_empty() + ); +} diff --git a/moli-fetch/src/lib.rs b/moli-fetch/src/lib.rs index 18bc414c6bd..6c822ddd043 100644 --- a/moli-fetch/src/lib.rs +++ b/moli-fetch/src/lib.rs @@ -77,7 +77,8 @@ pub use request::{ RequestAuthScheme, RequestAuthTarget, RequestCacheMode, RequestCredentialsMode, RequestHeaderOverride, RequestMode, RequestPriorityHints, RequestRedirectMode, RequestResourceType, ResourceLoadPriority, ScriptFetchRequestMetadata, - ScriptFetchSchedulerPriority, SubresourceRequestMetadata, + ScriptFetchSchedulerPriority, SubresourceRequestMetadata, is_request_body_header_name, + redirect_status_rewrites_to_get, }; pub use request_policy::{is_bad_port, should_request_be_blocked_due_to_bad_port}; pub use response::{ diff --git a/moli-fetch/src/request.rs b/moli-fetch/src/request.rs index 16cc65e9ec9..b2cf21adc55 100644 --- a/moli-fetch/src/request.rs +++ b/moli-fetch/src/request.rs @@ -1122,12 +1122,14 @@ impl Request { } } -fn redirect_status_rewrites_to_get(status: u16, method: &str) -> bool { +/// Whether following this HTTP redirect replaces the request method with GET. +pub fn redirect_status_rewrites_to_get(status: u16, method: &str) -> bool { status == 303 && !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") || matches!(status, 301 | 302) && method.eq_ignore_ascii_case("POST") } -fn is_request_body_header_name(name: &str) -> bool { +/// Whether a header must be removed when a redirect discards the request body. +pub fn is_request_body_header_name(name: &str) -> bool { matches!( name.to_ascii_lowercase().as_str(), "content-encoding" diff --git a/moli-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs b/moli-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs index 2570f14f033..a3e4d150610 100644 --- a/moli-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs +++ b/moli-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs @@ -1753,7 +1753,6 @@ async fn webdriver_classic_click_uses_top_level_pointer_coordinates_inside_offse } } #[tokio::test] -#[ignore = "Pending iframe overlay click fix; expectations verified against Chromium"] async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_wrapping() { for depth in 0..=2 { for selector in ["#target", "#container"] { @@ -1839,7 +1838,6 @@ async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_w } } #[tokio::test] -#[ignore = "Pending ancestor overlay click fix; expectations verified against Chromium"] async fn webdriver_classic_frame_click_rejects_ancestor_overlays_without_dispatch() { for (depth, blocked_level) in [(1, 0), (2, 0), (2, 1)] { let app = build_router(test_state()); diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs index dae7c92f381..11d13683bdc 100644 --- a/moli-protocol/src/domains/network/agent.rs +++ b/moli-protocol/src/domains/network/agent.rs @@ -1406,18 +1406,12 @@ impl CapturedResponseBodyStore { fn revoke_retained_visibility(&mut self, session_id: Option<&str>) { // Revocation affects only old-document claims, not ordinary access to // responses from the current document. - for request_id in &self.retained_request_ids { - if let Some(body) = self.bodies.get_mut(request_id) - && !body.remove_session_visibility(session_id) - { - self.bodies.remove(request_id); - } - if let Some(body) = self.buffered_bodies.get_mut(request_id) - && !body.remove_session_visibility(session_id) - { - self.buffered_bodies.remove(request_id); - } - } + let keep = |request_id: &String, body: &mut CapturedResponseBody| { + !self.retained_request_ids.contains(request_id) + || body.remove_session_visibility(session_id) + }; + self.bodies.retain(keep); + self.buffered_bodies.retain(keep); } fn effective_durable_limits(&self) -> ByteLimits { @@ -1466,13 +1460,20 @@ impl CapturedResponseBodyStore { } fn trim_durable_entries(&mut self) { + let mut expired_payloads = HashSet::new(); while self.durable_entry_order.len() > DURABLE_RESPONSE_BODY_MAX_ENTRIES { if let Some(id) = self.durable_entry_order.pop_front() { self.bodies.remove(&id); - self.buffered_bodies.remove(&id); self.retained_request_ids.remove(&id); + if self.buffered_bodies.contains_key(&id) { + expired_payloads.insert(id); + } } } + if !expired_payloads.is_empty() { + self.buffered_bodies + .retain(|id, _| !expired_payloads.contains(id)); + } } fn prepare_navigation(&mut self) { @@ -1489,16 +1490,7 @@ impl CapturedResponseBodyStore { && !matches!(body.state, CapturedResponseBodyState::Pending) }; self.bodies.retain(|_, body| retain(body)); - let ids = self - .buffered_bodies - .iter() - .map(|(id, _)| id.clone()) - .collect::>(); - for id in ids { - if !self.buffered_bodies.get_mut(&id).is_some_and(retain) { - self.buffered_bodies.remove(&id); - } - } + self.buffered_bodies.retain(|_, body| retain(body)); self.retained_request_ids = self .bodies .keys() @@ -1642,20 +1634,8 @@ impl CapturedResponseBodyStore { self.bodies .retain(|_, body| body.remove_session_visibility(session_id)); - let buffered_request_ids = self - .buffered_bodies - .iter() - .map(|(request_id, _)| request_id.clone()) - .collect::>(); - for request_id in buffered_request_ids { - let retain = self - .buffered_bodies - .get_mut(request_id.as_str()) - .is_some_and(|body| body.remove_session_visibility(session_id)); - if !retain { - self.buffered_bodies.remove(request_id.as_str()); - } - } + self.buffered_bodies + .retain(|_, body| body.remove_session_visibility(session_id)); } #[cfg(test)] diff --git a/moli-protocol/src/domains/network/redirect_request.rs b/moli-protocol/src/domains/network/redirect_request.rs index 01dbebf0d20..459caf94300 100644 --- a/moli-protocol/src/domains/network/redirect_request.rs +++ b/moli-protocol/src/domains/network/redirect_request.rs @@ -1,6 +1,6 @@ /// Project the request metadata for successive followed redirect hops. /// -/// This mirrors FetchRequest::apply_redirect_status: the emitted CDP request +/// This shares FetchRequest::apply_redirect_status rules: the emitted CDP request /// must describe the new hop, not repeat the original POST after it became GET. pub(super) struct RedirectRequest<'a> { pub(super) method: &'a str, @@ -22,23 +22,11 @@ impl<'a> RedirectRequest<'a> { } pub(super) fn follow(&mut self, status: u16) { - let becomes_get = matches!(status, 301 | 302) && self.method.eq_ignore_ascii_case("POST") - || status == 303 - && !self.method.eq_ignore_ascii_case("GET") - && !self.method.eq_ignore_ascii_case("HEAD"); - if becomes_get { + if moli_fetch::redirect_status_rewrites_to_get(status, self.method) { self.method = "GET"; self.body = None; - self.headers.retain(|(name, _)| { - !matches!( - name.to_ascii_lowercase().as_str(), - "content-encoding" - | "content-language" - | "content-length" - | "content-location" - | "content-type" - ) - }); + self.headers + .retain(|(name, _)| !moli_fetch::is_request_body_header_name(name)); } } } diff --git a/moli/src/telemetry.rs b/moli/src/telemetry.rs index ae99c520dc9..626b89d53a3 100644 --- a/moli/src/telemetry.rs +++ b/moli/src/telemetry.rs @@ -40,9 +40,6 @@ mod tests { struct SharedWriterGuard(Arc>>); - #[derive(Clone, Copy)] - struct BrokenWriter; - impl Write for SharedWriterGuard { fn write(&mut self, buffer: &[u8]) -> io::Result { self.0.lock().extend_from_slice(buffer); @@ -54,30 +51,6 @@ mod tests { } } - impl Write for BrokenWriter { - fn write(&mut self, _buffer: &[u8]) -> io::Result { - Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "diagnostic sink is gone", - )) - } - - fn flush(&mut self) -> io::Result<()> { - Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "diagnostic sink is gone", - )) - } - } - - impl<'writer> MakeWriter<'writer> for BrokenWriter { - type Writer = Self; - - fn make_writer(&'writer self) -> Self::Writer { - *self - } - } - impl<'writer> MakeWriter<'writer> for SharedWriter { type Writer = SharedWriterGuard; From 1f0c3c9d7a89b82846d76f4067e8b29ef176df01 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 21:07:26 +0800 Subject: [PATCH 06/15] fix(layout): invalidate adopted stylesheet geometry --- .../context_host/host_environment.rs | 1 + .../src/script_vm/tests/dom_xhr/misc.rs | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs index f44a14c6069..9269a6b34c0 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs @@ -901,6 +901,7 @@ impl JsContextHost { document: DomHandle, installations: Vec, ) { + self.mark_layout_input_dirty(); self.style_engine .set_document_adopted_style_sheet_installations(document, installations); } diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs index df137cdd509..a20bf08e056 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs @@ -1506,6 +1506,83 @@ return [ assert_eq!(result, "38|8|88"); } +#[test] +fn adopted_stylesheet_geometry_refreshes_across_script_turns_without_rendering() { + for shadow in [false, true] { + let mut vm = new_parsed_test_vm( + "https://adopted-sheet-layout.test/", + "
", + ); + vm.eval(&format!(r#" + globalThis.styleRoot = {root}; + styleRoot.innerHTML = '
'; + globalThis.target = styleRoot.querySelector('#target'); + globalThis.sheet = new CSSStyleSheet(); + sheet.replaceSync('#target {{width:40px;height:10px}}'); + "#, root = if shadow {"document.getElementById('host').attachShadow({mode:'open'})"} else {"document.body"})) + .expect("create the same baseline rules in document and shadow scopes"); + // Document adoption is on Document, while its test markup lives in body. + vm.eval(if shadow { + "globalThis.adopter=styleRoot;" + } else { + "globalThis.adopter=document;" + }) + .unwrap(); + let read = "[target.getBoundingClientRect().width,parseFloat(getComputedStyle(target).width),target.offsetWidth].join('|')"; + assert_eq!(vm.eval(read).unwrap(), "20|20|20"); + for (mutation, width) in [ + ("adopter.adoptedStyleSheets=[sheet];", 40), + ("sheet.replaceSync('#target {width:80px;height:10px}');", 80), + ("sheet.deleteRule(0);", 20), + ( + "sheet.insertRule('#target {width:120px;height:10px}',0);", + 120, + ), + ("adopter.adoptedStyleSheets=[];", 20), + ("adopter.adoptedStyleSheets.push(sheet);", 120), + ("adopter.adoptedStyleSheets.pop();", 20), + ] { + let before = vm + ._context_host + .borrow() + .layout_pass_observability_for_test() + .1; + vm.eval(mutation).unwrap(); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before, + "stylesheet changes must not eagerly build layout: shadow={shadow} {mutation}" + ); + let expected = format!("{width}|{width}|{width}"); + assert_eq!( + vm.eval(read).unwrap(), + expected, + "shadow={shadow} {mutation}" + ); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before + 1, + "first geometry read must refresh exactly once: shadow={shadow} {mutation}" + ); + assert_eq!(vm.eval(read).unwrap(), expected); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before + 1, + "a later clean script turn must reuse layout" + ); + } + } +} + #[test] fn geometry_getters_refresh_after_mutation_and_reuse_clean_layout() { let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/"); From c76e53d9c03e63654700c9fcd50ae00aeb074606 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 21:07:26 +0800 Subject: [PATCH 07/15] test(cdp): declare redirect expectations explicitly --- .../src/domains/network/redirect_request.rs | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/moli-protocol/src/domains/network/redirect_request.rs b/moli-protocol/src/domains/network/redirect_request.rs index 459caf94300..132cbe72e35 100644 --- a/moli-protocol/src/domains/network/redirect_request.rs +++ b/moli-protocol/src/domains/network/redirect_request.rs @@ -37,22 +37,56 @@ mod tests { #[test] fn redirect_status_method_matrix_matches_fetch_semantics() { - for status in [301, 302, 303, 307, 308] { - for method in ["GET", "HEAD", "POST", "PUT", "DELETE", "post"] { - let headers = vec![ - ("Content-Type".into(), "text/plain".into()), - ("CONTENT-LENGTH".into(), "5".into()), - ("Accept".into(), "*/*".into()), - ]; - let mut request = RedirectRequest::new(method, Some("value"), &headers); - request.follow(status); - let changed = matches!(status, 301 | 302) && method.eq_ignore_ascii_case("POST") - || status == 303 && !matches!(method, "GET" | "HEAD"); - assert_eq!(request.method, if changed { "GET" } else { method }); - assert_eq!(request.body, if changed { None } else { Some("value") }); - assert_eq!(request.headers.len(), if changed { 1 } else { 3 }); - assert_eq!(headers.len(), 3, "original metadata stays unchanged"); - } + // Explicit HTTP expectations, independent of the implementation's + // classification formula. None means the redirect discards the body. + for (status, method, expected_method, expected_body) in [ + (301, "GET", "GET", Some("value")), + (301, "HEAD", "HEAD", Some("value")), + (301, "POST", "GET", None), + (301, "PUT", "PUT", Some("value")), + (301, "DELETE", "DELETE", Some("value")), + (301, "post", "GET", None), + (302, "GET", "GET", Some("value")), + (302, "HEAD", "HEAD", Some("value")), + (302, "POST", "GET", None), + (302, "PUT", "PUT", Some("value")), + (302, "DELETE", "DELETE", Some("value")), + (302, "post", "GET", None), + (303, "GET", "GET", Some("value")), + (303, "HEAD", "HEAD", Some("value")), + (303, "POST", "GET", None), + (303, "PUT", "GET", None), + (303, "DELETE", "GET", None), + (303, "post", "GET", None), + (307, "GET", "GET", Some("value")), + (307, "HEAD", "HEAD", Some("value")), + (307, "POST", "POST", Some("value")), + (307, "PUT", "PUT", Some("value")), + (307, "DELETE", "DELETE", Some("value")), + (307, "post", "post", Some("value")), + (308, "GET", "GET", Some("value")), + (308, "HEAD", "HEAD", Some("value")), + (308, "POST", "POST", Some("value")), + (308, "PUT", "PUT", Some("value")), + (308, "DELETE", "DELETE", Some("value")), + (308, "post", "post", Some("value")), + ] { + let headers = vec![ + ("Content-Type".into(), "text/plain".into()), + ("CONTENT-LENGTH".into(), "5".into()), + ("Accept".into(), "*/*".into()), + ]; + let mut request = RedirectRequest::new(method, Some("value"), &headers); + request.follow(status); + assert_eq!(request.method, expected_method, "{status} {method}"); + assert_eq!(request.body, expected_body, "{status} {method}"); + let expected_headers = if expected_body.is_none() { + vec![("Accept".into(), "*/*".into())] + } else { + headers.clone() + }; + assert_eq!(request.headers, expected_headers, "{status} {method}"); + assert_eq!(headers.len(), 3, "original metadata stays unchanged"); } } From 6930e84c2af3bc166cd3a55c0306ffd468c454c7 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 21:07:27 +0800 Subject: [PATCH 08/15] test(curl): wait for proxy fixture connections --- moli-curl/src/proxy.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/moli-curl/src/proxy.rs b/moli-curl/src/proxy.rs index 0288698d272..3341a02acee 100644 --- a/moli-curl/src/proxy.rs +++ b/moli-curl/src/proxy.rs @@ -579,21 +579,21 @@ mod tests { assert!(connected_port == origin_port || connected_port == proxy_port); let curl_uses_proxy = connected_port == proxy_port; let listener = if curl_uses_proxy { &proxy } else { &origin }; - // A completed client connect can precede the listener's - // accept readiness on macOS. Bound that handoff explicitly. + // Client connect completion does not guarantee the server's + // nonblocking accept queue is observable in the same turn. let deadline = Instant::now() + Duration::from_secs(2); let _connection = loop { match listener.accept() { Ok(connection) => break connection, Err(error) - if error.kind() == std::io::ErrorKind::WouldBlock - && Instant::now() < deadline => + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted + ) && Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(1)); - } - Err(error) => { - panic!("accept {host} with NO_PROXY={no_proxy:?}: {error}") + std::thread::sleep(Duration::from_millis(5)); } + Err(error) => panic!("libcurl's selected endpoint did not accept: {error}"), } }; From d17f505b5d890fe0218bea4a37cf94944ce7e725 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 22:03:30 +0800 Subject: [PATCH 09/15] fix(forms): preserve detached document property semantics --- moli-renderer-v8/src/native_bridge/element/forms.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/moli-renderer-v8/src/native_bridge/element/forms.rs b/moli-renderer-v8/src/native_bridge/element/forms.rs index dbc299ceb75..7ddd9aee44b 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms.rs @@ -224,7 +224,6 @@ pub(in crate::native_bridge) use self::simple_controls::{ progress_max_setter_function, progress_position_getter_function, progress_value_getter_function, progress_value_setter_function, }; -pub(crate) use self::submission::submit_form_with_submit_event; pub(in crate::native_bridge) use self::submission::{ FormAssociatedResetCallbackTiming, form_request_submit_callback, form_reset_callback, form_submit_callback, reset_form_default_action, From f1e172bbffc18725472da39725b3dde4e04c4901 Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 21 Sep 2026 15:25:02 +0800 Subject: [PATCH 10/15] fix(review): preserve cache lifetime and validation contracts --- moli-protocol/src/domains/network/agent.rs | 49 +++++++++++++++++++ .../src/domains/network/output_queue.rs | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs index 11d13683bdc..a561062c68e 100644 --- a/moli-protocol/src/domains/network/agent.rs +++ b/moli-protocol/src/domains/network/agent.rs @@ -1412,6 +1412,14 @@ impl CapturedResponseBodyStore { }; self.bodies.retain(keep); self.buffered_bodies.retain(keep); + self.prune_removed_entry_indexes(); + } + + fn prune_removed_entry_indexes(&mut self) { + let exists = + |id: &String| self.bodies.contains_key(id) || self.buffered_bodies.contains_key(id); + self.durable_entry_order.retain(exists); + self.retained_request_ids.retain(exists); } fn effective_durable_limits(&self) -> ByteLimits { @@ -1636,6 +1644,7 @@ impl CapturedResponseBodyStore { self.buffered_bodies .retain(|_, body| body.remove_session_visibility(session_id)); + self.prune_removed_entry_indexes(); } #[cfg(test)] @@ -2148,6 +2157,46 @@ mod tests { assert!(store.durable_entry_order.is_empty()); } + #[test] + fn durable_response_body_revocation_does_not_evict_other_sessions_below_budget() { + for disable_retention in [false, true] { + let mut store = CapturedResponseBodyStore::default(); + for session in ["a", "b"] { + store.configure_durable( + Some(session), + Some(moli_bounded_buffer::ByteLimits::new(100_000, 100)), + ); + } + store.insert("keep".into(), "original".into(), [Some("a".into())]); + for n in 1..super::DURABLE_RESPONSE_BODY_MAX_ENTRIES { + store.insert(format!("b-{n}"), "other".into(), [Some("b".into())]); + } + if disable_retention { + store.prepare_navigation(); + store.configure_durable(Some("b"), None); + } else { + store.remove_session_visibility(Some("b")); + } + assert!(store.get("b-1").is_none()); + store.insert("new".into(), "next".into(), [Some("a".into())]); + assert_eq!( + store + .get("keep") + .expect("unrelated response must remain readable") + .body_bytes_limited(100) + .unwrap(), + b"original" + ); + assert_eq!( + store.get("new").unwrap().body_bytes_limited(100).unwrap(), + b"next" + ); + store.prepare_navigation(); + assert!(store.get("keep").unwrap().is_visible_to_session(Some("a"))); + assert!(!store.get("keep").unwrap().is_visible_to_session(Some("b"))); + } + } + #[test] fn durable_response_body_store_navigation_preserves_only_existing_opted_claims() { let mut store = CapturedResponseBodyStore::default(); diff --git a/moli-protocol/src/domains/network/output_queue.rs b/moli-protocol/src/domains/network/output_queue.rs index 5f8cd111791..a89f2019c3f 100644 --- a/moli-protocol/src/domains/network/output_queue.rs +++ b/moli-protocol/src/domains/network/output_queue.rs @@ -3698,7 +3698,7 @@ mod tests { document_url, request_url.clone(), "POST".to_owned(), - vec![("Content-Type".to_owned(), "text/plain".to_owned())], + vec![("Content-Type".to_owned(), "text/plain".to_owned())].into(), Some("body".to_owned()), SubresourceResourceType::Fetch, SubresourceRequestInitiatorType::Script, From ac382f97944eda817c581e974a09cce0d5da4482 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:25:23 +0800 Subject: [PATCH 11/15] fix(browser): preserve layout and navigation invariants --- moli-layout/src/taffy_tree.rs | 5 ++++- moli-protocol-webdriver-classic/src/actions.rs | 9 --------- .../context_host/host_environment.rs | 17 +++++++++++++++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 1a3aaf8e35b..d80b650a3ab 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -2884,7 +2884,10 @@ where parent_width: child_inputs.parent_size.width, }); let line_y = state.line_y() as f32; - slot = inline_float_slot(block_context, line_y, None, 0.0); + // Preserve the edge slot for an empty inline boundary even + // when the float consumes all available inline space. The + // next nonempty line will search for a fitting slot below. + slot = block_context.find_content_slot(line_y, Clear::None, None); if slot.y > line_y { clearance = clearance.max(slot.y); } diff --git a/moli-protocol-webdriver-classic/src/actions.rs b/moli-protocol-webdriver-classic/src/actions.rs index 3ce7ab77ad6..eb7f9325536 100644 --- a/moli-protocol-webdriver-classic/src/actions.rs +++ b/moli-protocol-webdriver-classic/src/actions.rs @@ -176,15 +176,6 @@ pub fn element_click_input_commands( 0, Some(0), ), - dispatch_mouse_event_command( - context, - DevToolsMouseEventType::Moved, - DevToolsPointerType::Mouse, - point.x, - point.y, - 0, - Some(0), - ), dispatch_mouse_event_command( context, DevToolsMouseEventType::Pressed, diff --git a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs index 9269a6b34c0..e7e00090d91 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs @@ -837,11 +837,24 @@ impl JsContextHost { viewport_surface: Option, ) -> bool { let changed = self.viewport_surface != viewport_surface; - if changed { + let previous_style_viewport = self.style_viewport(); + let previous_emulated_view = self + .viewport_surface + .and_then(|surface| surface.emulated_view); + self.viewport_surface = viewport_surface; + if changed && previous_style_viewport != self.style_viewport() { self.invalidate_layout_inputs(LayoutInputChange::StyleEnvironment); self.mark_layout_input_dirty(); } - self.viewport_surface = viewport_surface; + if previous_emulated_view + != self + .viewport_surface + .and_then(|surface| surface.emulated_view) + { + self.document_layout_state + .borrow_mut() + .mark_visual_state_dirty(); + } changed } From 37272542e4b68ef28812590ba4462c72b4011f73 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:27:18 +0800 Subject: [PATCH 12/15] fix(browser): preserve frozen layout and stop semantics --- .../tests/classic/extracted/navigation.rs | 2 + moli-protocol/src/conn.rs | 20 +++++- .../src/conn/state/browser_context.rs | 11 +++ moli-protocol/src/conn/state/page_slot.rs | 12 ++++ moli-protocol/src/conn/state/runtime_slot.rs | 7 ++ moli-protocol/src/domains/network/events.rs | 5 +- moli-protocol/src/domains/page/navigation.rs | 16 ++++- moli-protocol/src/domains/page/termination.rs | 20 +++--- .../domains/page/tests/navigation/requests.rs | 30 +++----- .../page_vm/tests/computed_size/sampling.rs | 16 ++++- .../tests/grid_resolved_track_values.rs | 25 +++++-- .../tests/dom_elements/dom_surface.rs | 68 ++++++++++++++----- .../src/script_vm/tests/dom_xhr/misc.rs | 35 ++++++++-- 13 files changed, 200 insertions(+), 67 deletions(-) diff --git a/moli-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs b/moli-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs index a3e4d150610..32d70980087 100644 --- a/moli-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs +++ b/moli-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs @@ -1812,6 +1812,7 @@ async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_w document.body.appendChild(veil); "#,"args":[]}), ).await; + classic_capture_layout(app.clone(), session_id).await; let (status, blocked) = classic_request_status_and_json( app.clone(), Method::POST, @@ -1883,6 +1884,7 @@ async fn webdriver_classic_frame_click_rejects_ancestor_overlays_without_dispatc json!({"script":overlay_script,"args":[if blocked {"block"} else {"none"}]}), ) .await; + classic_capture_layout(app.clone(), session_id).await; let (status, clicked) = classic_request_status_and_json( app.clone(), Method::POST, diff --git a/moli-protocol/src/conn.rs b/moli-protocol/src/conn.rs index 87d26a46076..c8b93cfa5ec 100644 --- a/moli-protocol/src/conn.rs +++ b/moli-protocol/src/conn.rs @@ -1620,6 +1620,19 @@ impl CdpConnection { }) } + pub(crate) fn background_navigation_stop_requested( + &self, + token: &DocumentNavigationToken, + ) -> bool { + let Some(browser_context_id) = self.browser_context_id_for_target(&token.target_id) else { + return false; + }; + self.browser_context_by_id(browser_context_id) + .is_some_and(|browser_context| { + browser_context.background_navigation_stop_requested(token) + }) + } + pub fn has_inflight_background_navigation(&self) -> bool { self.browser_contexts() .any(BrowserContext::has_inflight_background_navigation) @@ -2534,19 +2547,22 @@ impl CdpConnection { ) -> Vec { let completion = match completion { crate::domains::page::BackgroundNavigationCompletion::Lifecycle(completion) => { + let stop_requested = + self.background_navigation_stop_requested(completion.navigation_token()); if !self.settle_background_navigation_completion(completion.navigation_token()) { tracing::debug!( token = ?completion.navigation_token(), "background navigation completion did not match the target-owned request" ); } - completion + (completion, stop_requested) } crate::domains::page::BackgroundNavigationCompletion::MainDocumentBody(completion) => { completion.record_if_current(self); return command_context.take_protocol_events(); } }; + let (completion, stop_requested) = completion; let timing_started = moli_trace::cdp_nav_timing_enabled().then(std::time::Instant::now); if timing_started.is_some() { tracing::info!( @@ -2560,7 +2576,7 @@ impl CdpConnection { // Page.navigate response (success or abort-error) for the outstanding // command id. The target retains its NavigationEngine independently of // this completion, including when the completion is stale. - let completion = completion.materialize(self); + let completion = completion.materialize(self, stop_requested); if let Some(started) = timing_started { tracing::info!( target: "moli_cdp_nav_timing", diff --git a/moli-protocol/src/conn/state/browser_context.rs b/moli-protocol/src/conn/state/browser_context.rs index 49d7eb0fc98..9f1b9f71b7b 100644 --- a/moli-protocol/src/conn/state/browser_context.rs +++ b/moli-protocol/src/conn/state/browser_context.rs @@ -1222,6 +1222,17 @@ impl BrowserContext { }) } + pub(crate) fn background_navigation_stop_requested( + &self, + token: &DocumentNavigationToken, + ) -> bool { + self.page_target(&token.target_id).is_some_and(|target| { + target + .runtime_slot() + .background_navigation_stop_requested(token) + }) + } + pub(crate) fn has_inflight_background_navigation(&self) -> bool { self.page_targets .iter() diff --git a/moli-protocol/src/conn/state/page_slot.rs b/moli-protocol/src/conn/state/page_slot.rs index f9d89110287..f193c5f582c 100644 --- a/moli-protocol/src/conn/state/page_slot.rs +++ b/moli-protocol/src/conn/state/page_slot.rs @@ -99,6 +99,7 @@ pub(crate) struct PendingNavigationRequest { cancellation_handles: Vec, background_work: BackgroundWorkState, committed: bool, + stop_requested: bool, } impl PendingNavigationRequest { @@ -109,6 +110,7 @@ impl PendingNavigationRequest { cancellation_handles: vec![moli_fetch::FetchCancelHandle::new()], background_work: BackgroundWorkState::NotStarted, committed: false, + stop_requested: false, } } @@ -754,6 +756,15 @@ impl TargetPageSlot { true } + pub(crate) fn background_navigation_stop_requested( + &self, + token: &DocumentNavigationToken, + ) -> bool { + self.pending_navigation_request + .as_ref() + .is_some_and(|request| request.matches(token) && request.stop_requested) + } + pub(crate) fn has_inflight_background_navigation(&self) -> bool { self.pending_navigation_request .as_ref() @@ -764,6 +775,7 @@ impl TargetPageSlot { if let Some(request) = self.pending_navigation_request.as_mut() { // Keep the token installed: the existing completion path owns the // aborted response and must still settle this exact navigation. + request.stop_requested = true; request.cancel(); } } diff --git a/moli-protocol/src/conn/state/runtime_slot.rs b/moli-protocol/src/conn/state/runtime_slot.rs index 0b1593a08e9..2f117ed727d 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -650,6 +650,13 @@ impl TargetRuntimeSlot { .settle_background_navigation_completion(token) } + pub(crate) fn background_navigation_stop_requested( + &self, + token: &DocumentNavigationToken, + ) -> bool { + self.page_slot.background_navigation_stop_requested(token) + } + pub(crate) fn has_inflight_background_navigation(&self) -> bool { self.page_slot.has_inflight_background_navigation() } diff --git a/moli-protocol/src/domains/network/events.rs b/moli-protocol/src/domains/network/events.rs index 95b6fb493a2..76e4a8812d3 100644 --- a/moli-protocol/src/domains/network/events.rs +++ b/moli-protocol/src/domains/network/events.rs @@ -17,7 +17,10 @@ use crate::conn::{BackgroundProtocolEvent, build_event}; pub(crate) use moli_fetch::NET_ERR_ABORTED_ERROR_TEXT; pub(crate) fn loading_failed_canceled(error_text: &str) -> bool { - error_text == NET_ERR_ABORTED_ERROR_TEXT + matches!( + error_text, + NET_ERR_ABORTED_ERROR_TEXT | "Navigation stopped" + ) } pub(crate) trait CdpNetworkAutomationEventSink { diff --git a/moli-protocol/src/domains/page/navigation.rs b/moli-protocol/src/domains/page/navigation.rs index 1292af9149e..36d4669a5e6 100644 --- a/moli-protocol/src/domains/page/navigation.rs +++ b/moli-protocol/src/domains/page/navigation.rs @@ -557,14 +557,26 @@ impl BackgroundNavigationLifecycleCompletion { self.ready_at.elapsed().as_millis() } - pub(crate) fn materialize(self, conn: &mut CdpConnection) -> MaterializedNavigationCompletion { + pub(crate) fn materialize( + self, + conn: &mut CdpConnection, + stop_requested: bool, + ) -> MaterializedNavigationCompletion { let Self { token, state, navigation, ready_at: _, } = self; - let navigation = network::materialize_navigation_load_result(conn, &state, navigation); + let navigation = if stop_requested { + network::materialize_navigation_failure_preserving_committed_document( + conn, + &state, + "Navigation stopped".to_owned(), + ) + } else { + network::materialize_navigation_load_result(conn, &state, navigation) + }; MaterializedNavigationCompletion::new(token, state, navigation) } } diff --git a/moli-protocol/src/domains/page/termination.rs b/moli-protocol/src/domains/page/termination.rs index 8921aa53788..35e0c315921 100644 --- a/moli-protocol/src/domains/page/termination.rs +++ b/moli-protocol/src/domains/page/termination.rs @@ -396,14 +396,9 @@ pub(super) async fn complete_stop_loading_command_dispatch( owner: &CommandOwnerScope, ) -> PageCommandTaskStep { let mut out = Vec::new(); - if let Ok(slot) = conn.runtime_session_owner_slot_mut_for_owner(owner) { - slot.cancel_inflight_document_navigation(); - if let Some(page) = slot.loaded_page_mut() - && let Err(error) = page.stop_document_lifecycle_async().await - { - tracing::debug!(%error, "failed to stop renderer document lifecycle"); - } - } + // Claim protocol-visible continuations before signalling the transport. + // Otherwise the cancelled fetch can win the race and publish its raw curl + // error, bypassing stopLoading's stable failure projection. let ( pending_navigations, pending_auth_navigations, @@ -412,7 +407,14 @@ pub(super) async fn complete_stop_loading_command_dispatch( pending_subresource_auths, pending_subresource_responses, ) = take_pending_fetch_state_for_owner(conn, owner); - + if let Ok(slot) = conn.runtime_session_owner_slot_mut_for_owner(owner) { + slot.cancel_inflight_document_navigation(); + if let Some(page) = slot.loaded_page_mut() + && let Err(error) = page.stop_document_lifecycle_async().await + { + tracing::debug!(%error, "failed to stop renderer document lifecycle"); + } + } let renderer_output_predecessor = fail_pending_fetch_state_for_owner_background_events_async( conn, &mut out, diff --git a/moli-protocol/src/domains/page/tests/navigation/requests.rs b/moli-protocol/src/domains/page/tests/navigation/requests.rs index 574c1db2da8..329417359db 100644 --- a/moli-protocol/src/domains/page/tests/navigation/requests.rs +++ b/moli-protocol/src/domains/page/tests/navigation/requests.rs @@ -772,21 +772,21 @@ async fn assert_stopped_provisional_navigation_preserves_document( }) .await; let navigation = take_response_by_id(&mut ctx, 914); - assert_eq!(navigation["result"]["frameId"], json!("TID-1")); if response_head.is_some_and(|(status, _)| status == 200) { // Successful response headers acknowledge navigation before // XML buffering completes; cancellation must not send a // second reply or replace the still-committed old Document. assert!(navigation["result"].get("errorText").is_none()); } else { - assert_eq!(navigation["result"]["errorText"], json!("net::ERR_ABORTED")); + assert_eq!(navigation["error"]["code"], json!(-32000)); + assert_eq!(navigation["error"]["message"], json!("Navigation stopped")); } wait_until_scheduler_message( &mut ctx, "cancelled navigation network event", |message| { message["method"] == json!("Network.loadingFailed") - && message["params"]["errorText"] == json!("net::ERR_ABORTED") + && message["params"]["errorText"] == json!("Navigation stopped") }, ) .await; @@ -846,20 +846,17 @@ async fn assert_stopped_provisional_navigation_preserves_document( } #[tokio::test(flavor = "multi_thread")] -async fn stop_loading_after_commit_cancels_transport_without_replacing_partial_document() { +async fn stop_loading_after_commit_preserves_partial_document_and_allows_next_navigation() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let prefix_parsed = std::sync::Arc::new(tokio::sync::Notify::new()); - let transport_closed = std::sync::Arc::new(tokio::sync::Notify::new()); let server_parsed = prefix_parsed.clone(); - let server_closed = transport_closed.clone(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { loop { let (mut socket, _) = listener.accept().await.unwrap(); let parsed = server_parsed.clone(); - let closed = server_closed.clone(); tokio::spawn(async move { let mut request = Vec::new(); let mut byte = [0_u8; 1]; @@ -879,14 +876,11 @@ async fn stop_loading_after_commit_cancels_transport_without_replacing_partial_d socket.write_all(headers.as_bytes()).await.unwrap(); socket.write_all(prefix).await.unwrap(); socket.flush().await.unwrap(); - // The server never supplies EOF or the tail. Completion - // here can only come from the client's transport close. - let read = socket.read(&mut byte).await; - assert!( - matches!(read, Ok(0)) || read.is_err(), - "client must close the held transfer: {read:?}" - ); - closed.notify_one(); + // Keep the incomplete body open. The observable contract + // is that stopLoading preserves the committed prefix and + // lets a later navigation proceed; socket pooling and the + // exact FIN timing are transport implementation details. + std::future::pending::<()>().await; } else if request.starts_with(b"GET /parsed ") { socket .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") @@ -933,12 +927,6 @@ async fn stop_loading_after_commit_cancels_transport_without_replacing_partial_d })) .await; assert_eq!(take_response_by_id(&mut ctx, 921)["result"], json!({})); - tokio::time::timeout( - std::time::Duration::from_secs(5), - transport_closed.notified(), - ) - .await - .expect("explicit document stop must cancel the committed response transport"); let html = loaded_page_html_for_test(&mut ctx).await; assert!(html.contains("committed prefix")); assert!(!html.contains("unreceived tail")); diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs index 46fa1825d11..3240f9d7355 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs @@ -77,7 +77,7 @@ async fn computed_size_held_getters_wait_for_explicit_publication() { } #[tokio::test(flavor = "current_thread")] -async fn computed_size_grid_sampling_refreshes_dirty_geometry_on_first_demand() { +async fn computed_size_grid_sampling_holds_dirty_geometry_until_publication() { run_page_vm_async_test(async move { let mut page = page_with_size_fixture(GRID)?; page.set_viewport_surface(Some(crate::protocol_types::ViewportSurface { @@ -130,20 +130,30 @@ async fn computed_size_grid_sampling_refreshes_dirty_geometry_on_first_demand() held_sizes(&mut page)?, json!(["160px", "40px", "160px", "40px"]) ); + assert_eq!( + page.vm_mut().eval("held.gridTemplateColumns")?, + "40px 120px" + ); + assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 1); + assert_eq!( + held_sizes(&mut page)?, + json!(["160px", "40px", "160px", "40px"]) + ); + publish_size_layout(&mut page)?; assert_eq!( page.vm_mut().eval("held.gridTemplateColumns")?, "100px 100px" ); - assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2); assert_eq!( held_sizes(&mut page)?, json!(["200px", "40px", "200px", "40px"]) ); + assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2); let refreshed = page.vm().layout_snapshot_cache_observability_for_test(); assert_eq!( refreshed.2, sampled.2 + 1, - "the first exact demand must publish one replacement tree" + "explicit visual publication must replace the retained tree" ); page.vm_mut().eval("'clean turn'")?; diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs b/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs index 83485f053a4..c5fd3394d8a 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs @@ -1,7 +1,7 @@ use super::*; #[tokio::test(flavor = "current_thread")] -async fn computed_style_refreshes_dirty_used_grid_tracks_on_exact_demand() { +async fn computed_style_holds_dirty_used_grid_tracks_until_publication() { run_page_vm_async_test(async move { let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); @@ -110,20 +110,37 @@ vertical:getComputedStyle(document.getElementById('vertical')).gridTemplateColum "marking the current frozen tree dirty must not discard or replace it eagerly", ); + let frozen_tracks = page_vm.vm_mut().eval( + "getComputedStyle(document.getElementById('named')).gridTemplateColumns", + )?; + assert_eq!( + frozen_tracks, + "[a] 21px [b c] 22px [d] 23px [e c] 22px [d] 23px [e f] 189px [g]", + "an exact used-track read must keep the published layout epoch", + ); + assert_eq!( + page_vm.vm().layout_pass_observability_for_test().1, + passes_before_mutation, + ); + page_vm.vm_mut().sync_live_document_style_sources(); + page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(400, 300, 1.0))? + .expect("mutated used Grid tracks should publish for screenshot"); assert_eq!( page_vm.vm_mut().eval( "getComputedStyle(document.getElementById('named')).gridTemplateColumns", )?, "[new] 200px 200px", - "an exact used-track read must refresh a dirty layout before answering", + "the next explicit publication must expose the changed used tracks", ); let passes_after_demand = page_vm.vm().layout_pass_observability_for_test().1; let cache_after_demand = page_vm .vm() .layout_snapshot_cache_observability_for_test(); assert_eq!(passes_after_demand, passes_before_mutation + 1); - assert_eq!(cache_after_demand.0, cache_before_mutation.0); - assert_eq!(cache_after_demand.1, cache_before_mutation.1 + 1); + assert_eq!(cache_after_demand.0, cache_before_mutation.0 + 2); + assert_eq!(cache_after_demand.1, cache_before_mutation.1); assert_eq!(cache_after_demand.2, cache_before_mutation.2 + 1); page_vm.vm_mut().eval("'clean turn'")?; diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs index 384bcbe0375..2474fa41e1f 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs @@ -75,7 +75,7 @@ fn drain_image_load_event_bodies_for_test(vm: &mut ScriptVm) -> usize { mod extracted; #[test] -fn offset_parent_refreshes_a_connected_insertion_before_any_other_geometry_read() { +fn offset_parent_uses_the_published_tree_until_connected_insertion_is_published() { let mut vm = new_storage_test_vm("https://offset-parent-layout.test/"); vm.eval( r#" @@ -120,7 +120,7 @@ fn offset_parent_refreshes_a_connected_insertion_before_any_other_geometry_read( "the connected insertion must mark layout dirty without rebuilding it eagerly" ); - let result = vm + let frozen = vm .eval( r#" (() => { @@ -130,16 +130,17 @@ fn offset_parent_refreshes_a_connected_insertion_before_any_other_geometry_read( })() "#, ) - .expect("offsetParent-first geometry read should evaluate"); + .expect("offsetParent-first frozen geometry read should evaluate"); assert_eq!( - result, r#"{"parentIsMenu":true,"width":240}"#, - "offsetParent must refresh a dirty tree before answering for the inserted element" + frozen, r#"{"parentIsMenu":false,"width":0}"#, + "the inserted element must remain absent from the published tree" ); assert_eq!( vm.layout_pass_observability_for_test().1, - passes_after_initial_layout + 1, - "offsetParent and the following width read should share one fresh pass" + passes_after_initial_layout, + "geometry reads must not publish the connected insertion" ); + publish_layout_for_test(&mut vm); assert_eq!( vm.eval("candidate.offsetParent === menu && candidate.offsetWidth === 240") @@ -190,7 +191,7 @@ fn geometry_reuses_clean_layout_after_detached_subtree_mutations() { } #[test] -fn geometry_refreshes_pseudo_class_inputs_on_demand() { +fn geometry_publishes_pseudo_class_inputs_at_visual_boundaries() { let mut vm = new_storage_test_vm("https://layout-state.test/"); vm.eval(r#" if (!document.documentElement) document.appendChild(document.createElement('html')); @@ -200,6 +201,7 @@ fn geometry_refreshes_pseudo_class_inputs_on_demand() { target.getBoundingClientRect().width; "#).expect("fixture should evaluate"); let mut passes = vm.layout_pass_observability_for_test().1; + let mut published = "40"; for (mutation, expected) in [ ("target.focus({preventScroll: true})", "80"), ("target.checked = true", "120"), @@ -210,10 +212,12 @@ fn geometry_refreshes_pseudo_class_inputs_on_demand() { assert_eq!(vm.layout_pass_observability_for_test().1, passes); assert_eq!( vm.eval("target.getBoundingClientRect().width").unwrap(), - expected, - "geometry must reflect changed pseudo class: {mutation}" + published, + "geometry must retain the published pseudo-class state: {mutation}" ); + publish_layout_for_test(&mut vm); passes += 1; + published = expected; assert_eq!(vm.layout_pass_observability_for_test().1, passes); assert_eq!( vm.eval("target.getBoundingClientRect().width").unwrap(), @@ -224,7 +228,7 @@ fn geometry_refreshes_pseudo_class_inputs_on_demand() { } #[test] -fn geometry_refreshes_attribute_selectors_and_cssom_on_demand() { +fn geometry_publishes_attribute_selectors_and_cssom_at_visual_boundaries() { let mut vm = new_storage_test_vm("https://layout-inputs.test/"); vm.eval(r#" if (!document.documentElement) document.appendChild(document.createElement('html')); @@ -234,6 +238,7 @@ fn geometry_refreshes_attribute_selectors_and_cssom_on_demand() { target.getBoundingClientRect().width; "#).expect("fixture should evaluate"); let mut passes = vm.layout_pass_observability_for_test().1; + let mut published = "40"; for (mutation, expected) in [ ("target.setAttribute('data-wide', '')", "80"), ( @@ -250,9 +255,12 @@ fn geometry_refreshes_attribute_selectors_and_cssom_on_demand() { ); assert_eq!( vm.eval("target.getBoundingClientRect().width").unwrap(), - expected + published, + "geometry must retain the published style inputs: {mutation}" ); + publish_layout_for_test(&mut vm); passes += 1; + published = expected; assert_eq!(vm.layout_pass_observability_for_test().1, passes); assert_eq!( vm.eval("target.getBoundingClientRect().width").unwrap(), @@ -267,7 +275,7 @@ fn geometry_refreshes_attribute_selectors_and_cssom_on_demand() { } #[test] -fn geometry_refreshes_inserted_and_existing_nodes_on_demand() { +fn geometry_publishes_inserted_and_existing_nodes_at_visual_boundaries() { let mut vm = new_storage_test_vm("https://layout-mutation.test/"); vm.eval( @@ -312,7 +320,7 @@ fn geometry_refreshes_inserted_and_existing_nodes_on_demand() { "mutation should retain the sampled frozen tree" ); - let result = vm + let frozen = vm .eval( r#" (() => { @@ -322,13 +330,29 @@ fn geometry_refreshes_inserted_and_existing_nodes_on_demand() { })() "#, ) - .expect("geometry after a connected insertion should evaluate"); + .expect("frozen geometry after a connected insertion should evaluate"); assert_eq!( vm.layout_pass_observability_for_test().1, - passes_before + 1, - "all post-mutation geometry reads should share one fresh pass" + passes_before, + "post-mutation geometry reads must not publish a new tree" ); + let frozen: serde_json::Value = + serde_json::from_str(&frozen).expect("frozen geometry result should be JSON"); + assert_eq!(frozen["target"], serde_json::json!([0, 0])); + assert_eq!(frozen["markerTop"].as_f64(), Some(before_top)); + + publish_layout_for_test(&mut vm); + let result = vm + .eval( + r#"JSON.stringify({ + target: [document.getElementById("target").getBoundingClientRect().width, + document.getElementById("target").getBoundingClientRect().height], + markerTop: document.getElementById("marker").getBoundingClientRect().top + })"#, + ) + .expect("published geometry after a connected insertion should evaluate"); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1); let result: serde_json::Value = serde_json::from_str(&result).expect("geometry result should be JSON"); @@ -354,13 +378,21 @@ fn geometry_refreshes_inserted_and_existing_nodes_on_demand() { passes_before + 1, "removal itself must not run layout" ); + assert!( + vm.eval("document.getElementById('marker').getBoundingClientRect().top") + .unwrap() + .parse::() + .unwrap() + > before_top, + "removal must retain the previous published geometry" + ); + publish_layout_for_test(&mut vm); assert_eq!( vm.eval("document.getElementById('marker').getBoundingClientRect().top") .unwrap() .parse::() .unwrap(), before_top, - "removing previously connected siblings must invalidate old geometry" ); assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); } diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs index a20bf08e056..3defc7b2277 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs @@ -1507,7 +1507,7 @@ return [ } #[test] -fn adopted_stylesheet_geometry_refreshes_across_script_turns_without_rendering() { +fn adopted_stylesheet_geometry_updates_on_explicit_publication() { for shadow in [false, true] { let mut vm = new_parsed_test_vm( "https://adopted-sheet-layout.test/", @@ -1530,6 +1530,7 @@ fn adopted_stylesheet_geometry_refreshes_across_script_turns_without_rendering() .unwrap(); let read = "[target.getBoundingClientRect().width,parseFloat(getComputedStyle(target).width),target.offsetWidth].join('|')"; assert_eq!(vm.eval(read).unwrap(), "20|20|20"); + let mut published_width = 20; for (mutation, width) in [ ("adopter.adoptedStyleSheets=[sheet];", 40), ("sheet.replaceSync('#target {width:80px;height:10px}');", 80), @@ -1556,19 +1557,34 @@ fn adopted_stylesheet_geometry_refreshes_across_script_turns_without_rendering() before, "stylesheet changes must not eagerly build layout: shadow={shadow} {mutation}" ); + let frozen = format!("{published_width}|{published_width}|{published_width}"); + assert_eq!( + vm.eval(read).unwrap(), + frozen, + "ordinary reads retain the published tree: shadow={shadow} {mutation}" + ); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before, + ); + vm.publish_layout_for_test().unwrap(); let expected = format!("{width}|{width}|{width}"); assert_eq!( vm.eval(read).unwrap(), expected, "shadow={shadow} {mutation}" ); + published_width = width; assert_eq!( vm._context_host .borrow() .layout_pass_observability_for_test() .1, before + 1, - "first geometry read must refresh exactly once: shadow={shadow} {mutation}" + "explicit publication must refresh exactly once: shadow={shadow} {mutation}" ); assert_eq!(vm.eval(read).unwrap(), expected); assert_eq!( @@ -1584,7 +1600,7 @@ fn adopted_stylesheet_geometry_refreshes_across_script_turns_without_rendering() } #[test] -fn geometry_getters_refresh_after_mutation_and_reuse_clean_layout() { +fn geometry_getters_reuse_frozen_layout_until_the_next_publication() { let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/"); let passes_before = vm ._context_host @@ -1619,6 +1635,9 @@ yield; // Publish this scene before reading its geometry. const inserted = document.createElement('div'); inserted.style.height = '5px'; document.body.insertBefore(inserted, targets[0]); + const frozenAfterInsertion = targets[3].offsetTop; + const frozenInserted = inserted.offsetTop; + yield; // Publish the mutated scene before observing its new geometry. const afterInsertion = targets[3].offsetTop; return [ initialLast, @@ -1626,6 +1645,8 @@ yield; // Publish this scene before reading its geometry. first[3], second[0], second[3], + frozenAfterInsertion, + frozenInserted, afterInsertion, inserted.offsetTop ].join('|'); @@ -1634,7 +1655,7 @@ yield; // Publish this scene before reading its geometry. ) .expect("latest layout snapshot reads should evaluate"); - assert_eq!(result, "38|8|38|8|38|43|8"); + assert_eq!(result, "38|8|38|8|38|38|0|43|8"); let passes = vm ._context_host .borrow() @@ -1643,11 +1664,11 @@ yield; // Publish this scene before reading its geometry. .saturating_sub(passes_before); assert_eq!( passes, 2, - "the explicit fixture publication and the first dirty geometry read build layout" + "the two explicit publication boundaries build layout" ); let cache_after = vm.layout_snapshot_cache_observability_for_test(); - assert_eq!(cache_after.0, cache_before.0 + 10); - assert_eq!(cache_after.1, cache_before.1 + 1); + assert_eq!(cache_after.0, cache_before.0 + 13); + assert_eq!(cache_after.1, cache_before.1); assert_eq!(cache_after.2, cache_before.2 + 2); assert!(cache_after.3.is_some()); } From 85a109c4a7721fe4badfd3f2409ed2442f90c1fe Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 1 Oct 2026 00:25:31 +0800 Subject: [PATCH 13/15] fix(network): isolate durable response budgets by session Keep shared response payloads with per-session accounting, eviction, and lookup. Add protocol regressions and CDP smoke coverage for independent session budgets and reconfiguration. --- moli-cdp-smoke/README.md | 14 + moli-cdp-smoke/moli_cdp_smoke/fixture.py | 6 + .../moli_cdp_smoke/groups/network_durable.py | 130 ++++ moli-cdp-smoke/moli_cdp_smoke/runner.py | 7 + moli-protocol/src/conn/state/runtime_slot.rs | 9 + moli-protocol/src/domains/network/agent.rs | 611 +++++++++++++----- .../src/domains/network/response_body.rs | 4 +- .../domains/network/tests/response_body.rs | 73 +++ 8 files changed, 676 insertions(+), 178 deletions(-) create mode 100644 moli-cdp-smoke/moli_cdp_smoke/groups/network_durable.py diff --git a/moli-cdp-smoke/README.md b/moli-cdp-smoke/README.md index 7c59d0051cb..e9b6843b5a2 100644 --- a/moli-cdp-smoke/README.md +++ b/moli-cdp-smoke/README.md @@ -6,6 +6,20 @@ The suite covers real CDP-client workflows: `connect_over_cdp`, concurrent brows ## Chromium Behavior Evidence +The `network-durable` group was calibrated on 2026-09-30 against +`/usr/bin/chromium` 145.0.7632.116. Two independent browser WebSockets attach +to the same page with 128,000-byte and 12,000-byte durable budgets. After +three 8,000-byte responses and a cross-document navigation, the large client +retains all bodies and the small client retains only the newest. A second +case checks an 18,000-byte response followed by a 4,000-byte response, then +both cases disable, re-enable and disconnect the small client without +affecting the large client. Each case was observed once on Chromium before +running the same matrix against Moli. Repeated durable `Network.enable` is +accepted by this Chromium build. Chromium shares a collector between flattened +sessions under one browser WebSocket, so this oracle uses independent clients; +focused Rust tests also verify Moli's accounting for sessions on one connection. +Run with `uv run moli-cdp-smoke --group network-durable`. + Any smoke assertion described as Chromium-compatible must be verified against a real Chromium binary. Do not infer observable behavior solely from the specification, Chromium source code, an existing Moli test, or intuition. diff --git a/moli-cdp-smoke/moli_cdp_smoke/fixture.py b/moli-cdp-smoke/moli_cdp_smoke/fixture.py index f49f13720ae..b08169ea46e 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/fixture.py +++ b/moli-cdp-smoke/moli_cdp_smoke/fixture.py @@ -1444,6 +1444,12 @@ def _dispatch(self) -> None: self._send_text("fixture api body") elif route == "/api-response-body-budget-small": self._send_text("bounded response body remains readable") + elif route == "/api-durable-body": + size = int(query.get("size", ["0"])[0]) + if not 0 <= size <= 128_000: + self.send_error(HTTPStatus.BAD_REQUEST) + else: + self._send_text("x" * size) elif route == "/api-response-body-budget-oversize": self._send_common( HTTPStatus.OK, diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/network_durable.py b/moli-cdp-smoke/moli_cdp_smoke/groups/network_durable.py new file mode 100644 index 00000000000..2c45dfd50cc --- /dev/null +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/network_durable.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from ..assertions import SmokeError, assert_equal +from ..raw_cdp import RawCdpClient, connect_raw_cdp +from ..state import SmokeState + + +class _Session: + def __init__(self, client: RawCdpClient, session_id: str): + self.client = client + self.session_id = session_id + self.events: list[dict[str, Any]] = [] + + @classmethod + async def open(cls, endpoint: str, target_id: str) -> _Session: + client = await connect_raw_cdp(endpoint) + try: + command_id = await client.send("Target.attachToTarget", {"targetId": target_id, "flatten": True}) + response, _ = await client.recv_until_id(command_id) + return cls(client, response["result"]["sessionId"]) + except BaseException: + await client.websocket.close() + raise + + async def send(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + command_id = await self.client.send(method, params, session_id=self.session_id) + response, seen = await self.client.recv_until_id(command_id) + self.events.extend(e for e in seen if "method" in e) + return response["result"] + + async def completed_request(self, url: str) -> str: + async with asyncio.timeout(5): + while True: + requests = [e["params"]["requestId"] for e in self.events + if e["method"] == "Network.requestWillBeSent" and e["params"]["request"]["url"] == url] + if requests and any(e["method"] == "Network.loadingFinished" and e["params"]["requestId"] == requests[0] + for e in self.events): + return requests[0] + self.events.append(await self.client.recv()) + + async def detach(self) -> None: + await self.client.websocket.close() + + +async def run_network_durable_group(state: SmokeState) -> None: + await _budget_case(state, "total", 12_000, 32_000, [8_000, 8_000, 8_000], {2}) + await _budget_case(state, "oversize", 12_000, 10_000, [18_000, 4_000], {1}) + + +async def _budget_case( + state: SmokeState, + name: str, + small_total: int, + small_resource: int, + sizes: list[int], + small_retained: set[int], +) -> None: + page = state.page + await page.goto(f"{state.fixture}/plain", wait_until="load") + target_id = (await state.cdp.send("Target.getTargetInfo"))["targetInfo"]["targetId"] + # Independent WebSockets also isolate Chromium's root-session collectors. + large = await _Session.open(state.endpoint, target_id) + small = None + try: + small = await _Session.open(state.endpoint, target_id) + for label, session, total, resource in ( + ("large", large, 128_000, 32_000), + ("small", small, small_total, small_resource), + ): + await session.send("Network.enable", { + "enableDurableMessages": True, + "maxTotalBufferSize": total, + "maxResourceBufferSize": resource, + }) + + request_ids: dict[str, list[str]] = {"large": [], "small": []} + for index, size in enumerate(sizes): + url = f"{state.fixture}/api-durable-body?size={size}&case={name}&index={index}" + length = await page.evaluate("url => fetch(url).then(r => r.text()).then(s => s.length)", url) + assert_equal(length, size, "durable budget does not truncate the page consumer") + + for label, session in (("large", large), ("small", small)): + request_ids[label].append(await session.completed_request(url)) + + # Retire the renderer's ordinary cache to observe durable retention. + await page.goto("data:text/html,

new document

", wait_until="load") + for index, size in enumerate(sizes): + body = await large.send("Network.getResponseBody", {"requestId": request_ids["large"][index]}) + assert_equal(body, {"body": "x" * size, "base64Encoded": False}, f"{name} large session retains body {index}") + if index in small_retained: + body = await small.send("Network.getResponseBody", {"requestId": request_ids["small"][index]}) + assert_equal(body, {"body": "x" * size, "base64Encoded": False}, f"{name} small session retains body {index}") + else: + await _missing(small, request_ids["small"][index]) + state.record(f"network_durable_{name}_session_budget", { + "sizes": sizes, "smallRetained": sorted(small_retained), "largeRetained": list(range(len(sizes))), + }) + + # Chromium 145 permits another Network.enable on the same handler. + await large.send("Network.enable", { + "enableDurableMessages": True, "maxTotalBufferSize": 128_000, + "maxResourceBufferSize": 32_000, + }) + await small.send("Network.disable") + await small.send("Network.enable", { + "enableDurableMessages": True, "maxTotalBufferSize": small_total, + "maxResourceBufferSize": small_resource, + }) + await small.detach() + small = None + body = await large.send("Network.getResponseBody", {"requestId": request_ids["large"][0]}) + assert_equal(body["body"], "x" * sizes[0], "peer disable/re-enable/detach preserves large session") + state.record(f"network_durable_{name}_peer_revocation") + finally: + if small is not None: + await small.detach() + await large.detach() + + +async def _missing(session: Any, request_id: str) -> None: + try: + await session.send("Network.getResponseBody", {"requestId": request_id}) + except Exception as error: + if "No resource with given identifier" in str(error) or "evicted from inspector cache" in str(error): + return + raise + raise SmokeError(f"session unexpectedly retained body {request_id}") diff --git a/moli-cdp-smoke/moli_cdp_smoke/runner.py b/moli-cdp-smoke/moli_cdp_smoke/runner.py index d02673f556b..2c2a4ec43d9 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/runner.py +++ b/moli-cdp-smoke/moli_cdp_smoke/runner.py @@ -54,6 +54,7 @@ run_page_network_group, run_websocket_group, ) +from .groups.network_durable import run_network_durable_group from .groups.navigation_outcomes import run_navigation_outcomes_group from .groups.pdf import run_pdf_group from .groups.playwright_compat import run_playwright_compat_group @@ -231,6 +232,12 @@ async def _await_group(group: SmokeGroup, awaitable: Awaitable[None]) -> None: ) PAGE_GROUPS: tuple[SmokeGroup, ...] = ( + SmokeGroup( + "network-durable", + "Per-session durable response total/resource budgets, navigation retention and peer revocation.", + "page", + run_network_durable_group, + ), SmokeGroup( "iframe-input", "Cross-engine transformed single and nested iframe hover, click, wheel, and target-Document coordinate routing.", diff --git a/moli-protocol/src/conn/state/runtime_slot.rs b/moli-protocol/src/conn/state/runtime_slot.rs index 2f117ed727d..a8380d1e68c 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -1210,6 +1210,15 @@ impl TargetRuntimeSlot { self.network_agent.captured_response_body(request_id) } + pub(crate) fn captured_response_body_for_session( + &self, + request_id: &str, + session_id: Option<&str>, + ) -> Option<&CapturedResponseBody> { + self.network_agent + .captured_response_body_for_session(request_id, session_id) + } + pub(crate) fn captured_request_body(&self, request_id: &str) -> Option<&CapturedRequestBody> { self.network_agent.captured_request_body(request_id) } diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs index a561062c68e..623e05a2580 100644 --- a/moli-protocol/src/domains/network/agent.rs +++ b/moli-protocol/src/domains/network/agent.rs @@ -695,6 +695,17 @@ impl TargetNetworkAgentState { .captured_response_body(request_id) } + pub(crate) fn captured_response_body_for_session( + &self, + request_id: &str, + session_id: Option<&str>, + ) -> Option<&CapturedResponseBody> { + self.artifacts + .body_artifacts + .captured_response_bodies + .get_for_session(request_id, session_id) + } + pub(crate) fn captured_request_body(&self, request_id: &str) -> Option<&CapturedRequestBody> { self.artifacts .body_artifacts @@ -1356,12 +1367,18 @@ impl CapturedRequestBodyStore { #[derive(Debug, Clone, PartialEq, Eq)] struct CapturedResponseBodyStore { + current: ResponseBodyBuffer, + durable_sessions: HashMap, ResponseBodyBuffer>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResponseBodyBuffer { /// Terminal entries without retained payloads. Ready entries live only in /// `buffered_bodies`, making the bounded buffer the single body owner. bodies: HashMap, buffered_bodies: BoundedByteBuffer, - durable_sessions: HashMap, ByteLimits>, - durable_entry_order: VecDeque, + entry_order: VecDeque, + max_entries: Option, retained_request_ids: HashSet, } @@ -1377,136 +1394,70 @@ impl Default for CapturedResponseBodyStore { impl CapturedResponseBodyStore { fn with_limits(limits: ByteLimits) -> Self { Self { - bodies: HashMap::new(), - buffered_bodies: BoundedByteBuffer::new(limits), + current: ResponseBodyBuffer::new(limits, None), durable_sessions: HashMap::new(), - durable_entry_order: VecDeque::new(), - retained_request_ids: HashSet::new(), } } fn configure_durable(&mut self, session_id: Option<&str>, limits: Option) { let session = session_id.map(str::to_owned); if let Some(limits) = limits { - self.durable_sessions.insert(session, limits); - } else { - self.durable_sessions.remove(&session); - self.revoke_retained_visibility(session_id); - } - for (id, mut body) in self - .buffered_bodies - .set_limits(self.effective_durable_limits()) - { - body.mark_evicted(); - self.bodies.insert(id, body); - } - self.synchronize_durable_entries(); - } - - fn revoke_retained_visibility(&mut self, session_id: Option<&str>) { - // Revocation affects only old-document claims, not ordinary access to - // responses from the current document. - let keep = |request_id: &String, body: &mut CapturedResponseBody| { - !self.retained_request_ids.contains(request_id) - || body.remove_session_visibility(session_id) - }; - self.bodies.retain(keep); - self.buffered_bodies.retain(keep); - self.prune_removed_entry_indexes(); - } - - fn prune_removed_entry_indexes(&mut self) { - let exists = - |id: &String| self.bodies.contains_key(id) || self.buffered_bodies.contains_key(id); - self.durable_entry_order.retain(exists); - self.retained_request_ids.retain(exists); - } - - fn effective_durable_limits(&self) -> ByteLimits { - // One target owns one payload store. Multiple clients cannot multiply - // its budget; each requested budget is an upper bound, not a reserve. - self.durable_sessions.values().fold( - ByteLimits::new( - RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES, - RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES, - ), - |acc, limits| { - ByteLimits::new( - acc.max_total_bytes.min(limits.max_total_bytes), - acc.max_entry_bytes.min(limits.max_entry_bytes), - ) - }, - ) - } - - fn synchronize_durable_entries(&mut self) { - if self.durable_sessions.is_empty() { - self.durable_entry_order.clear(); - self.retained_request_ids.clear(); - return; - } - let tracked: HashSet<_> = self.durable_entry_order.iter().collect(); - let mut untracked = self - .bodies - .keys() - .chain(self.buffered_bodies.iter().map(|(id, _)| id)) - .filter(|id| !tracked.contains(id)) - .cloned() - .collect::>(); - untracked.sort(); - self.durable_entry_order.extend(untracked); - self.trim_durable_entries(); - } - - fn track_durable_entry(&mut self, request_id: &str) { - if self.durable_sessions.is_empty() { - return; + if let Some(buffer) = self.durable_sessions.get_mut(&session) { + buffer.set_limits(limits); + return; + } + let mut buffer = + ResponseBodyBuffer::new(limits, Some(DURABLE_RESPONSE_BODY_MAX_ENTRIES)); + // Adopt this session's current-document claims in capture order. + // CapturedBody clones share bytes and spool files through Arc. + for id in &self.current.entry_order { + if let Some(body) = self.current.get(id) + && body.is_visible_to_session(session_id) + { + let mut body = body.clone(); + body.session_ids = [session.clone()].into(); + buffer.insert(id.clone(), body); + } + } + self.current.remove_session_visibility(session_id); + self.durable_sessions.insert(session, buffer); + } else if let Some(buffer) = self.durable_sessions.remove(&session) { + // Disabling durability preserves ordinary current-document + // access, while revoking this session's old-document claims. + for id in &buffer.entry_order { + if !buffer.retained_request_ids.contains(id) + && let Some(body) = buffer.get(id) + { + if let Some(current) = self.current.get_mut(id) { + current.session_ids.extend(body.session_ids.iter().cloned()); + } else { + self.current.insert(id.clone(), body.clone()); + } + } + } } - self.durable_entry_order.retain(|id| id != request_id); - self.durable_entry_order.push_back(request_id.to_owned()); - self.trim_durable_entries(); } - fn trim_durable_entries(&mut self) { - let mut expired_payloads = HashSet::new(); - while self.durable_entry_order.len() > DURABLE_RESPONSE_BODY_MAX_ENTRIES { - if let Some(id) = self.durable_entry_order.pop_front() { - self.bodies.remove(&id); - self.retained_request_ids.remove(&id); - if self.buffered_bodies.contains_key(&id) { - expired_payloads.insert(id); - } + fn insert_body(&mut self, request_id: String, mut body: CapturedResponseBody) { + for (session, buffer) in &mut self.durable_sessions { + if body.session_ids.contains(session) { + let mut scoped = body.clone(); + scoped.session_ids = [session.clone()].into(); + buffer.insert(request_id.clone(), scoped); } } - if !expired_payloads.is_empty() { - self.buffered_bodies - .retain(|id, _| !expired_payloads.contains(id)); + body.session_ids + .retain(|id| !self.durable_sessions.contains_key(id)); + if !body.session_ids.is_empty() || !body.collector_ids.is_empty() { + self.current.insert(request_id, body); } } fn prepare_navigation(&mut self) { - if self.durable_sessions.is_empty() { - self.clear(); - return; + self.current.clear(); + for buffer in self.durable_sessions.values_mut() { + buffer.prepare_navigation(); } - let retain = |body: &mut CapturedResponseBody| { - body.session_ids - .retain(|id| self.durable_sessions.contains_key(id)); - // CDP durability does not opt BiDi collectors into retention. - body.collector_ids.clear(); - !body.session_ids.is_empty() - && !matches!(body.state, CapturedResponseBodyState::Pending) - }; - self.bodies.retain(|_, body| retain(body)); - self.buffered_bodies.retain(|_, body| retain(body)); - self.retained_request_ids = self - .bodies - .keys() - .chain(self.buffered_bodies.iter().map(|(id, _)| id)) - .cloned() - .collect(); - self.durable_entry_order - .retain(|id| self.retained_request_ids.contains(id)); } #[cfg(test)] @@ -1533,31 +1484,15 @@ impl CapturedResponseBodyStore { collector_ids: impl IntoIterator, collection_was_gated: bool, ) { - let byte_len = body.len(); - let tracked_request_id = request_id.clone(); - let captured = CapturedResponseBody::from_captured_body_with_collector_scope( - body, - session_ids, - collector_ids, - collection_was_gated, + self.insert_body( + request_id, + CapturedResponseBody::from_captured_body_with_collector_scope( + body, + session_ids, + collector_ids, + collection_was_gated, + ), ); - self.bodies.remove(&request_id); - match self.buffered_bodies.insert(request_id, captured, byte_len) { - InsertOutcome::Stored { evicted } => { - for (evicted_request_id, mut evicted_body) in evicted { - evicted_body.mark_evicted(); - self.bodies.insert(evicted_request_id, evicted_body); - } - } - InsertOutcome::Rejected { - key: request_id, - value: mut rejected_body, - } => { - rejected_body.mark_evicted(); - self.bodies.insert(request_id, rejected_body); - } - } - self.track_durable_entry(&tracked_request_id); } pub(crate) fn insert_pending_with_collector_scope( @@ -1567,13 +1502,7 @@ impl CapturedResponseBodyStore { collector_ids: impl IntoIterator, collection_was_gated: bool, ) { - if self.bodies.contains_key(&request_id) - || self.buffered_bodies.contains_key(request_id.as_str()) - { - return; - } - let tracked_request_id = request_id.clone(); - self.bodies.insert( + self.insert_body( request_id, CapturedResponseBody::pending_with_collector_scope( session_ids, @@ -1581,7 +1510,6 @@ impl CapturedResponseBodyStore { collection_was_gated, ), ); - self.track_durable_entry(&tracked_request_id); } pub(crate) fn insert_failed_with_collector_scope( @@ -1592,9 +1520,7 @@ impl CapturedResponseBodyStore { collector_ids: impl IntoIterator, collection_was_gated: bool, ) { - let tracked_request_id = request_id.clone(); - self.buffered_bodies.remove(&request_id); - self.bodies.insert( + self.insert_body( request_id, CapturedResponseBody::failed_with_collector_scope( error_text, @@ -1603,53 +1529,193 @@ impl CapturedResponseBodyStore { collection_was_gated, ), ); - self.track_durable_entry(&tracked_request_id); + } + + pub(crate) fn get_for_session( + &self, + request_id: &str, + session_id: Option<&str>, + ) -> Option<&CapturedResponseBody> { + match self.durable_sessions.get(&session_id.map(str::to_owned)) { + Some(buffer) => buffer.get(request_id), + None => self.current.get(request_id), + } } pub(crate) fn get(&self, request_id: &str) -> Option<&CapturedResponseBody> { - self.buffered_bodies - .get(request_id) - .or_else(|| self.bodies.get(request_id)) + self.current.get(request_id).or_else(|| { + let bodies = || { + self.durable_sessions + .values() + .filter_map(|buffer| buffer.get(request_id)) + }; + bodies() + .find(|body| matches!(body.state, CapturedResponseBodyState::Ready(_))) + .or_else(|| bodies().next()) + }) } fn collected_network_data_artifacts(&self) -> Vec { - self.buffered_bodies + self.current + .buffered_bodies .iter() - .chain(self.bodies.iter()) + .chain(self.current.bodies.iter()) .filter_map(|(request_id, body)| body.collected_network_data_artifact(request_id)) .collect() } pub(crate) fn clear(&mut self) { - self.bodies.clear(); - self.buffered_bodies.clear(); - self.durable_entry_order.clear(); - self.retained_request_ids.clear(); + self.current.clear(); + for buffer in self.durable_sessions.values_mut() { + buffer.clear(); + } } #[cfg(test)] pub(crate) fn is_empty(&self) -> bool { - self.bodies.is_empty() && self.buffered_bodies.is_empty() + self.current.is_empty() + && self + .durable_sessions + .values() + .all(ResponseBodyBuffer::is_empty) } #[cfg(test)] pub(crate) fn contains_key(&self, request_id: &str) -> bool { - self.bodies.contains_key(request_id) || self.buffered_bodies.contains_key(request_id) + self.get(request_id).is_some() } pub(crate) fn remove_session_visibility(&mut self, session_id: Option<&str>) { - self.configure_durable(session_id, None); - self.bodies - .retain(|_, body| body.remove_session_visibility(session_id)); + self.durable_sessions.remove(&session_id.map(str::to_owned)); + self.current.remove_session_visibility(session_id); + } - self.buffered_bodies - .retain(|_, body| body.remove_session_visibility(session_id)); + #[cfg(test)] + fn buffered_body_bytes(&self) -> usize { + self.current.buffered_bodies.used_bytes() + + self + .durable_sessions + .values() + .map(|buffer| buffer.buffered_bodies.used_bytes()) + .sum::() + } +} + +impl ResponseBodyBuffer { + fn new(limits: ByteLimits, max_entries: Option) -> Self { + Self { + bodies: HashMap::new(), + buffered_bodies: BoundedByteBuffer::new(limits), + entry_order: VecDeque::new(), + max_entries, + retained_request_ids: HashSet::new(), + } + } + + fn set_limits(&mut self, limits: ByteLimits) { + for (id, mut body) in self.buffered_bodies.set_limits(limits) { + body.mark_evicted(); + self.bodies.insert(id, body); + } + } + + fn insert(&mut self, request_id: String, body: CapturedResponseBody) { + if matches!(body.state, CapturedResponseBodyState::Pending) + && self.get(&request_id).is_some() + { + return; + } + let byte_len = match &body.state { + CapturedResponseBodyState::Ready(body) => Some(body.len()), + _ => None, + }; + self.entry_order.retain(|id| id != &request_id); + self.entry_order.push_back(request_id.clone()); + self.retained_request_ids.remove(&request_id); + self.bodies.remove(&request_id); + self.buffered_bodies.remove(&request_id); + if let Some(byte_len) = byte_len { + match self.buffered_bodies.insert(request_id, body, byte_len) { + InsertOutcome::Stored { evicted } => { + for (evicted_request_id, mut evicted_body) in evicted { + evicted_body.mark_evicted(); + self.bodies.insert(evicted_request_id, evicted_body); + } + } + InsertOutcome::Rejected { + key: request_id, + value: mut rejected_body, + } => { + rejected_body.mark_evicted(); + self.bodies.insert(request_id, rejected_body); + } + } + } else { + self.bodies.insert(request_id, body); + } + while self + .max_entries + .is_some_and(|max| self.entry_order.len() > max) + { + if let Some(id) = self.entry_order.pop_front() { + self.bodies.remove(&id); + self.buffered_bodies.remove(&id); + self.retained_request_ids.remove(&id); + } + } + } + + fn prepare_navigation(&mut self) { + let keep = |_: &String, body: &mut CapturedResponseBody| { + body.collector_ids.clear(); + !matches!(body.state, CapturedResponseBodyState::Pending) + }; + self.bodies.retain(keep); + self.buffered_bodies.retain(keep); self.prune_removed_entry_indexes(); + self.retained_request_ids = self.entry_order.iter().cloned().collect(); + } + + fn get(&self, request_id: &str) -> Option<&CapturedResponseBody> { + self.buffered_bodies + .get(request_id) + .or_else(|| self.bodies.get(request_id)) + } + + fn get_mut(&mut self, request_id: &str) -> Option<&mut CapturedResponseBody> { + if self.buffered_bodies.contains_key(request_id) { + self.buffered_bodies.get_mut(request_id) + } else { + self.bodies.get_mut(request_id) + } + } + + fn clear(&mut self) { + self.bodies.clear(); + self.buffered_bodies.clear(); + self.entry_order.clear(); + self.retained_request_ids.clear(); } #[cfg(test)] - fn buffered_body_bytes(&self) -> usize { - self.buffered_bodies.used_bytes() + fn is_empty(&self) -> bool { + self.bodies.is_empty() && self.buffered_bodies.is_empty() + } + + fn remove_session_visibility(&mut self, session_id: Option<&str>) { + let keep = |_: &String, body: &mut CapturedResponseBody| { + body.remove_session_visibility(session_id) || !body.collector_ids.is_empty() + }; + self.bodies.retain(keep); + self.buffered_bodies.retain(keep); + self.prune_removed_entry_indexes(); + } + + fn prune_removed_entry_indexes(&mut self) { + let exists = + |id: &String| self.bodies.contains_key(id) || self.buffered_bodies.contains_key(id); + self.entry_order.retain(exists); + self.retained_request_ids.retain(exists); } } @@ -2106,7 +2172,7 @@ mod tests { let store = CapturedResponseBodyStore::default(); assert_eq!( - store.buffered_bodies.limits(), + store.current.buffered_bodies.limits(), moli_bounded_buffer::ByteLimits::new(20_000_000, 2_000_000) ); } @@ -2140,12 +2206,13 @@ mod tests { false, ); } + let buffer = store.durable_sessions.get(&Some("owner".into())).unwrap(); assert!( - store.bodies.len() + store.buffered_bodies.len() + buffer.bodies.len() + buffer.buffered_bodies.len() <= super::DURABLE_RESPONSE_BODY_MAX_ENTRIES ); assert_eq!( - store.durable_entry_order.len(), + buffer.entry_order.len(), super::DURABLE_RESPONSE_BODY_MAX_ENTRIES ); assert!(!store.contains_key("a")); @@ -2153,8 +2220,8 @@ mod tests { store.remove_session_visibility(Some("owner")); assert!(store.is_empty()); assert!(store.durable_sessions.is_empty()); - assert!(store.retained_request_ids.is_empty()); - assert!(store.durable_entry_order.is_empty()); + assert!(store.current.retained_request_ids.is_empty()); + assert!(store.current.entry_order.is_empty()); } #[test] @@ -2221,7 +2288,12 @@ mod tests { assert!(!body.is_visible_to_session(Some("ordinary"))); assert!(!store.contains_key("pending")); assert_eq!( - store.buffered_bodies.limits(), + store + .durable_sessions + .get(&Some("b".into())) + .unwrap() + .buffered_bodies + .limits(), moli_bounded_buffer::ByteLimits::new(80, 40) ); store.insert("current".into(), "current".into(), [Some("a".into())]); @@ -2247,7 +2319,194 @@ mod tests { assert_eq!(store.buffered_body_bytes(), 0); store.clear(); assert!(store.is_empty()); - assert!(store.retained_request_ids.is_empty()); + assert!( + store + .durable_sessions + .values() + .all(|buffer| buffer.retained_request_ids.is_empty()) + ); + } + + #[test] + fn durable_response_body_budgets_and_evictions_are_session_local() { + let mut store = CapturedResponseBodyStore::default(); + store.configure_durable( + Some("large"), + Some(moli_bounded_buffer::ByteLimits::new(30, 20)), + ); + store.configure_durable( + Some("small"), + Some(moli_bounded_buffer::ByteLimits::new(10, 6)), + ); + for id in ["a", "b", "c"] { + store.insert( + id.into(), + "123456".into(), + [Some("large".into()), Some("small".into()), None], + ); + } + for id in ["a", "b", "c"] { + assert_eq!( + store + .get_for_session(id, Some("large")) + .unwrap() + .body_bytes_limited(100) + .unwrap(), + b"123456" + ); + assert_eq!( + store + .get_for_session(id, None) + .unwrap() + .body_bytes_limited(100) + .unwrap(), + b"123456" + ); + } + assert!( + store + .get_for_session("a", Some("small")) + .unwrap() + .body_bytes_limited(100) + .is_err() + ); + assert_eq!( + store + .get_for_session("c", Some("small")) + .unwrap() + .body_bytes_limited(100) + .unwrap(), + b"123456" + ); + store.prepare_navigation(); + store.configure_durable( + Some("small"), + Some(moli_bounded_buffer::ByteLimits::new(4, 4)), + ); + assert!( + store + .get_for_session("c", Some("small")) + .unwrap() + .body_bytes_limited(100) + .is_err() + ); + assert_eq!( + store + .get_for_session("a", Some("large")) + .unwrap() + .body_bytes_limited(100) + .unwrap(), + b"123456" + ); + store.configure_durable(Some("small"), None); + assert!(store.get_for_session("c", Some("small")).is_none()); + assert_eq!( + store + .get_for_session("c", Some("large")) + .unwrap() + .body_bytes_limited(100) + .unwrap(), + b"123456" + ); + } + + #[test] + fn durable_response_body_resource_budget_is_session_local() { + let mut store = CapturedResponseBodyStore::default(); + store.configure_durable( + Some("large"), + Some(moli_bounded_buffer::ByteLimits::new(100, 20)), + ); + store.configure_durable( + Some("small"), + Some(moli_bounded_buffer::ByteLimits::new(100, 4)), + ); + store.insert( + "body".into(), + "123456".into(), + [Some("large".into()), Some("small".into())], + ); + assert_eq!( + store + .get_for_session("body", Some("large")) + .unwrap() + .body_bytes_limited(100) + .unwrap(), + b"123456" + ); + assert!( + store + .get_for_session("body", Some("small")) + .unwrap() + .body_bytes_limited(100) + .is_err() + ); + store.configure_durable( + Some("small"), + Some(moli_bounded_buffer::ByteLimits::new(100, 20)), + ); + // Increasing a budget must not resurrect an already evicted payload. + assert!( + store + .get_for_session("body", Some("small")) + .unwrap() + .body_bytes_limited(100) + .is_err() + ); + } + + #[test] + fn durable_response_body_metadata_eviction_does_not_affect_peer_session() { + let mut store = CapturedResponseBodyStore::default(); + for session in ["a", "b"] { + store.configure_durable( + Some(session), + Some(moli_bounded_buffer::ByteLimits::new(100, 20)), + ); + } + store.insert("keep".into(), "body".into(), [Some("a".into())]); + for n in 0..=super::DURABLE_RESPONSE_BODY_MAX_ENTRIES { + store.insert(format!("b-{n}"), String::new(), [Some("b".into())]); + } + assert_eq!( + store + .get_for_session("keep", Some("a")) + .unwrap() + .body_bytes_limited(100) + .unwrap(), + b"body" + ); + assert!(store.get_for_session("b-0", Some("b")).is_none()); + } + + #[test] + fn durable_response_body_explicit_budget_can_exceed_target_defaults() { + let mut store = + CapturedResponseBodyStore::with_limits(moli_bounded_buffer::ByteLimits::new(5, 4)); + store.configure_durable( + Some("owner"), + Some(moli_bounded_buffer::ByteLimits::new(20, 10)), + ); + store.insert( + "body".into(), + "123456789".into(), + [Some("owner".into()), None], + ); + assert_eq!( + store + .get_for_session("body", Some("owner")) + .unwrap() + .body_bytes_limited(100) + .unwrap(), + b"123456789" + ); + assert!( + store + .get_for_session("body", None) + .unwrap() + .body_bytes_limited(100) + .is_err() + ); } #[test] diff --git a/moli-protocol/src/domains/network/response_body.rs b/moli-protocol/src/domains/network/response_body.rs index 7a0e392cf65..886cc08c501 100644 --- a/moli-protocol/src/domains/network/response_body.rs +++ b/moli-protocol/src/domains/network/response_body.rs @@ -45,7 +45,7 @@ pub(super) fn get_response_body_command_output_plan( if !network_events_enabled_for_session(slot, cmd.session_id, primary_session_id.as_deref()) { return CommandOutputPlan::error(-32000, "No resource with given identifier found"); }; - let Some(body) = slot.captured_response_body(request_id) else { + let Some(body) = slot.captured_response_body_for_session(request_id, cmd.session_id) else { return CommandOutputPlan::error(-32000, "No resource with given identifier found"); }; if !body.is_visible_to_session(cmd.session_id) { @@ -88,7 +88,7 @@ pub(super) fn get_request_post_data_command_output_plan( } let Some(body) = slot.captured_request_body(request_id) else { let request_is_known = slot - .captured_response_body(request_id) + .captured_response_body_for_session(request_id, cmd.session_id) .is_some_and(|body| body.is_visible_to_session(cmd.session_id)); let message = if request_is_known { "No post data available for the request" diff --git a/moli-protocol/src/domains/network/tests/response_body.rs b/moli-protocol/src/domains/network/tests/response_body.rs index fc72b5acdb1..d55aa079133 100644 --- a/moli-protocol/src/domains/network/tests/response_body.rs +++ b/moli-protocol/src/domains/network/tests/response_body.rs @@ -15,6 +15,79 @@ use crate::domains::network::{ use super::*; +#[tokio::test(flavor = "multi_thread")] +async fn durable_response_body_session_budgets_survive_peer_eviction_and_reconfiguration() { + let mut ctx = TestContext::new(); + let mut bc = BrowserContext::new("BID-durable".into()); + bc.set_active_target_id("TID-durable".to_owned()); + bc.attach_active_session("SID-large".to_owned()); + assert!(bc.assign_attached_session_to_target("TID-durable", "SID-small".to_owned())); + ctx.conn.install_browser_context_fixture_for_test(bc); + for (id, session, total, resource) in + [(79_300, "SID-large", 30, 20), (79_301, "SID-small", 10, 6)] + { + ctx.process_async(json!({"id":id,"method":"Network.enable","sessionId":session,"params":{ + "enableDurableMessages":true,"maxTotalBufferSize":total,"maxResourceBufferSize":resource + }})).await; + ctx.expect_result(id, json!({}), Some(session)); + } + let bc = ctx.conn.browser_context.as_mut().unwrap(); + for id in ["REQ-a", "REQ-b", "REQ-c"] { + bc.record_captured_response_body( + id.into(), + "123456".into(), + [Some("SID-large".into()), Some("SID-small".into())], + ); + } + bc.active_page_target_mut() + .prepare_document_navigation_request_ids( + &mut crate::conn::ConnectionNetworkRequestIdAllocator::default(), + true, + true, + false, + ); + for (index, request_id) in ["REQ-a", "REQ-b", "REQ-c"].into_iter().enumerate() { + let id = 79_302 + index as u64; + ctx.process_async(json!({"id":id,"method":"Network.getResponseBody","sessionId":"SID-large","params":{"requestId":request_id}})).await; + ctx.expect_result( + id, + json!({"body":"123456","base64Encoded":false}), + Some("SID-large"), + ); + } + ctx.process_async(json!({"id":79_305,"method":"Network.getResponseBody","sessionId":"SID-small","params":{"requestId":"REQ-a"}})).await; + ctx.expect_error( + 79_305, + -32000, + "Request content was evicted from inspector cache", + ); + ctx.process_async(json!({"id":79_306,"method":"Network.getResponseBody","sessionId":"SID-small","params":{"requestId":"REQ-c"}})).await; + ctx.expect_result( + 79_306, + json!({"body":"123456","base64Encoded":false}), + Some("SID-small"), + ); + ctx.process_async( + json!({"id":79_307,"method":"Network.enable","sessionId":"SID-small","params":{ + "enableDurableMessages":true,"maxTotalBufferSize":4,"maxResourceBufferSize":4 + }}), + ) + .await; + ctx.expect_result(79_307, json!({}), Some("SID-small")); + ctx.process_async(json!({"id":79_308,"method":"Network.getResponseBody","sessionId":"SID-small","params":{"requestId":"REQ-c"}})).await; + ctx.expect_error( + 79_308, + -32000, + "Request content was evicted from inspector cache", + ); + ctx.process_async(json!({"id":79_309,"method":"Network.getResponseBody","sessionId":"SID-large","params":{"requestId":"REQ-c"}})).await; + ctx.expect_result( + 79_309, + json!({"body":"123456","base64Encoded":false}), + Some("SID-large"), + ); +} + #[tokio::test(flavor = "multi_thread")] async fn durable_response_body_survives_navigation_only_for_opted_session() { let mut ctx = TestContext::new(); From 65887c4c74c0a177cc6ceb66e2a1526885996617 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 1 Oct 2026 00:25:45 +0800 Subject: [PATCH 14/15] test(webdriver): cover first-fragment click geometry Verify trusted pointer events use the clipped first client rect center for multiline, fragmented, clipped, and fractional elements, and reject an obstructed first fragment. Run the element-click smoke group with real layout and document Chromium calibration. --- .../src/script_vm/tests/element_click.rs | 92 +++++++++++++++++++ moli-webdriver-smoke/README.md | 16 +++- .../groups/element_click.py | 84 +++++++++++++++++ .../moli_webdriver_smoke/runner.py | 19 +++- .../moli_webdriver_smoke/serve.py | 3 +- 5 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 moli-webdriver-smoke/moli_webdriver_smoke/groups/element_click.py diff --git a/moli-renderer-v8/src/script_vm/tests/element_click.rs b/moli-renderer-v8/src/script_vm/tests/element_click.rs index 1ebfa41bcf4..cb4d09c4e8c 100644 --- a/moli-renderer-v8/src/script_vm/tests/element_click.rs +++ b/moli-renderer-v8/src/script_vm/tests/element_click.rs @@ -1,6 +1,98 @@ use super::*; use crate::runtime::{RendererElementClickTarget, RendererPointerEventProperties}; +#[test] +fn native_element_click_reuses_first_fragment_in_view_center_for_all_pointer_phases() { + for (markup, fragmented, obscured) in [ + ( + "", + true, + false, + ), + ( + "first
second
", + true, + false, + ), + ( + "", + false, + false, + ), + ( + "", + false, + false, + ), + ( + "
cover
", + true, + true, + ), + ] { + let html = + format!("{markup}"); + let mut vm = new_parsed_test_vm("https://click-fragments.test/", &html); + let target = vm.document_runtime.get_element_by_id("target").unwrap(); + let geometry: serde_json::Value = serde_json::from_str(&vm.eval(r#"(() => { + const target = document.getElementById('target'); + window.events = []; + for (const type of ['pointermove','pointerdown','mousedown','pointerup','mouseup','click']) + target.addEventListener(type, e => events.push([e.type,e.clientX,e.clientY,e.isTrusted])); + const rects = target.getClientRects(), r = rects[0], b = target.getBoundingClientRect(); + return JSON.stringify({count:rects.length, + point:[Math.floor((Math.max(0,r.left)+Math.min(innerWidth,r.right))/2), + Math.floor((Math.max(0,r.top)+Math.min(innerHeight,r.bottom))/2)], + boundingCenter:[(b.left+b.right)/2,(b.top+b.bottom)/2]}); + })()"#).unwrap()).unwrap(); + if fragmented { + assert!(geometry["count"].as_u64().unwrap() > 1, "{markup}"); + } + assert_ne!(geometry["point"], geometry["boundingCenter"], "{markup}"); + let prepared = vm.prepare_element_click(target); + if obscured { + assert_eq!( + prepared, + Err(crate::runtime::RendererElementClickError::Obscured) + ); + assert_eq!(vm.eval("JSON.stringify(events)").unwrap(), "[]"); + continue; + } + let RendererElementClickTarget::Pointer(click) = prepared.unwrap() else { + panic!("real layout must prepare pointer input"); + }; + assert_eq!(click.root_x, geometry["point"][0].as_f64().unwrap()); + assert_eq!(click.root_y, geometry["point"][1].as_f64().unwrap()); + vm.dispatch_prepared_element_click(click).unwrap(); + let events: serde_json::Value = + serde_json::from_str(&vm.eval("JSON.stringify(events)").unwrap()).unwrap(); + let events = events.as_array().unwrap(); + let names = events + .iter() + .map(|e| e[0].as_str().unwrap()) + .collect::>(); + assert_eq!( + names, + [ + "pointermove", + "pointerdown", + "mousedown", + "pointerup", + "mouseup", + "click" + ] + ); + for event in events { + assert_eq!( + serde_json::json!([event[1], event[2]]), + geometry["point"], + "{markup}" + ); + assert_eq!(event[3], true, "{markup}"); + } + } +} + #[test] fn native_element_click_initializes_layout_once() { let mut vm = new_parsed_test_vm( diff --git a/moli-webdriver-smoke/README.md b/moli-webdriver-smoke/README.md index a99bb130e10..2d16672501f 100644 --- a/moli-webdriver-smoke/README.md +++ b/moli-webdriver-smoke/README.md @@ -139,16 +139,28 @@ uv sync uv run moli-webdriver-smoke ``` -With no `--group`, both local and CI runs execute all seven Moli groups: +With no `--group`, both local and CI runs execute all eight Moli groups: ```text -classic,bidi,selenium,semantics,url-policy,navigation-errors,script-interrupt +element-click,classic,bidi,selenium,semantics,url-policy,navigation-errors,script-interrupt ``` CI adds `--continue-on-failure` so one failing group does not hide later smoke results. The ChromeDriver-only `script-timeout-chromium` oracle remains an explicit external-target run. +The `element-click` group checks multiline and fragmented inline elements, +viewport-clipped and fractional rectangles, and an overlay covering only the +first fragment. It requires every trusted pointer phase to use the floored +in-view center of the first client rect. A managed Moli run starts a separate +`--layout` process for this group; an external Moli endpoint must have layout +enabled. Run it with `uv run moli-webdriver-smoke --group element-click`. +The five cases were also run unchanged on 2026-09-30 against +`/usr/bin/chromium` 145.0.7632.116 through ChromeDriver 145.0.7632.117, +once with a fresh headless session. Each successful case emitted the full +trusted pointer sequence at the expected point; the covered first fragment +returned `element click intercepted` and emitted no pointer events. + List available groups: ```bash diff --git a/moli-webdriver-smoke/moli_webdriver_smoke/groups/element_click.py b/moli-webdriver-smoke/moli_webdriver_smoke/groups/element_click.py new file mode 100644 index 00000000000..6d71e4b7abe --- /dev/null +++ b/moli-webdriver-smoke/moli_webdriver_smoke/groups/element_click.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import asyncio +from typing import Any +from urllib.parse import quote + +from selenium import webdriver +from selenium.common.exceptions import ElementClickInterceptedException +from selenium.webdriver.common.by import By + +from ..assertions import assert_equal, assert_true, record_contract +from ..config import WebDriverTarget +from ..selenium_options import create_selenium_options + + +CASES = ( + ("multiline", "", False), + ("fragmented", "first
second
", False), + ("viewport-clipped", "", False), + ("fractional", "", False), + ("first-fragment-obscured", "
cover
", True), +) + + +async def run_element_click_group( + target: WebDriverTarget, + _fixture: str, + results: list[dict[str, Any]], + _continue_on_failure: bool = False, +) -> None: + await asyncio.to_thread(_run, target, results) + + +def _run(target: WebDriverTarget, results: list[dict[str, Any]]) -> None: + with webdriver.Remote(target.endpoint, options=create_selenium_options(target)) as driver: + for name, markup, intercepted in CASES: + html = """""" + markup + """ + """ + driver.get("data:text/html," + quote(html)) + element = driver.find_element(By.ID, "target") + geometry = driver.execute_script(""" + const rects = arguments[0].getClientRects(), r = rects[0]; + const left = Math.max(0, Math.min(r.x, r.x + r.width)); + const right = Math.min(innerWidth, Math.max(r.x, r.x + r.width)); + const top = Math.max(0, Math.min(r.y, r.y + r.height)); + const bottom = Math.min(innerHeight, Math.max(r.y, r.y + r.height)); + const b = arguments[0].getBoundingClientRect(); + return {count: rects.length, point: [Math.floor((left+right)/2), Math.floor((top+bottom)/2)], + boundingCenter: [(b.left+b.right)/2, (b.top+b.bottom)/2]}; + """, element) + if name in ("multiline", "fragmented", "first-fragment-obscured"): + assert_true(geometry["count"] > 1, f"{name} has multiple fragments") + assert_true(geometry["point"] != geometry["boundingCenter"], f"{name} distinguishes the bounding center") + if intercepted: + try: + element.click() + except ElementClickInterceptedException: + pass + else: + raise AssertionError("the first fragment is covered") + assert_equal(driver.execute_script("return events"), [], "intercepted click dispatches no events") + else: + element.click() + events = driver.execute_script("return events") + assert_equal([e[0] for e in events], + ['pointermove','pointerdown','mousedown','pointerup','mouseup','click'], + f"{name} trusted pointer sequence") + for event in events: + assert_equal(event[1:3], geometry["point"], f"{name} {event[0]} uses the in-view center") + assert_equal(event[3], True, f"{name} {event[0]} is trusted") + record_contract(results, f"webdriver_element_click_{name}", + contract="Element Click hit-tests and dispatches at the floored viewport intersection of the first client rect.", + source="https://www.w3.org/TR/webdriver2/#dfn-in-view-center-point", + commands=["POST /element/{elementId}/click", "POST /execute/sync"], + observed={**geometry, "intercepted": intercepted}) diff --git a/moli-webdriver-smoke/moli_webdriver_smoke/runner.py b/moli-webdriver-smoke/moli_webdriver_smoke/runner.py index 9391ec99a43..f9b0a661b01 100644 --- a/moli-webdriver-smoke/moli_webdriver_smoke/runner.py +++ b/moli-webdriver-smoke/moli_webdriver_smoke/runner.py @@ -14,6 +14,7 @@ from .fixture import FixtureServer from .groups.bidi import run_bidi_group from .groups.classic import run_classic_group +from .groups.element_click import run_element_click_group from .groups.navigation_errors import run_navigation_errors_group from .groups.script_interrupt import ( run_chromedriver_script_timeout_group, @@ -77,6 +78,11 @@ async def _run_bidi_group( MOLI_GROUPS: tuple[SmokeGroup, ...] = ( + SmokeGroup( + "element-click", + "Real-layout WebDriver first-fragment, viewport-clipped, fractional and intercepted pointer clicks.", + run_element_click_group, + ), SmokeGroup( "classic", "Raw WebDriver Classic HTTP session, navigation, element, script, alert, shadow, cookie, and window state flows.", @@ -247,12 +253,23 @@ async def async_main(argv: list[str] | None = None) -> int: ) await wait_for_webdriver_server(endpoint, serve) for group in selection: + layout_serve = None try: - await group.runner(target, fixture.url, results, continue_on_failure) + group_target = target + if group.name == "element-click" and serve is not None: + layout_port = reserve_port() + layout_serve = await start_moli_serve(layout_port, layout=True) + group_target = WebDriverTarget(f"http://127.0.0.1:{layout_port}", "moli") + await wait_for_webdriver_server(group_target.endpoint, layout_serve) + await group.runner(group_target, fixture.url, results, continue_on_failure) except Exception as error: + if layout_serve is not None: + print(render_moli_serve_diagnostics(layout_serve), file=sys.stderr) if not continue_on_failure: raise record_failure(results, group.name, f"{group.name}_group", error) + finally: + await stop_moli_serve(layout_serve) ok = not has_failures(results) emit_serve_diagnostics = not ok print( diff --git a/moli-webdriver-smoke/moli_webdriver_smoke/serve.py b/moli-webdriver-smoke/moli_webdriver_smoke/serve.py index 08781cb74ac..c8215c55e4a 100644 --- a/moli-webdriver-smoke/moli_webdriver_smoke/serve.py +++ b/moli-webdriver-smoke/moli_webdriver_smoke/serve.py @@ -35,7 +35,7 @@ async def _collect_process_output(stream: asyncio.StreamReader | None, logs: lis print(f"[moli serve {label}] {text}", file=sys.stderr, flush=True) -async def start_moli_serve(port: int) -> MoliServe: +async def start_moli_serve(port: int, *, layout: bool = False) -> MoliServe: binary = moli_binary() process = await asyncio.create_subprocess_exec( str(binary), @@ -45,6 +45,7 @@ async def start_moli_serve(port: int) -> MoliServe: "--port", str(port), "--image", + *(["--layout"] if layout else []), cwd=str(REPO_ROOT), env=clear_proxy_env(os.environ), stdout=asyncio.subprocess.PIPE, From 2e05488f8ce276e465421282a7fece932c9a1b35 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:34:51 +0800 Subject: [PATCH 15/15] test(browser): bind navigation and geometry fixtures to native owners --- .../protocol_server/tests/cdp_dynamic_page.rs | 58 +++++++++++-------- .../src/native_bridge/context_host/layout.rs | 13 +++++ .../dom_surface/extracted/inline_layout.rs | 50 ++++++++++++---- 3 files changed, 84 insertions(+), 37 deletions(-) diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs index 011dc5c3a28..9c3ed855bd5 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs @@ -17,17 +17,34 @@ async fn navigate_fixture_document( url: &str, ) { let session = Some(target.session_id.as_str()); - for (id, method, params) in [ - (90, "Page.enable", json!({})), - ( - 91, - "Page.setLifecycleEventsEnabled", - json!({"enabled": true}), - ), - ] { - let enabled = send_cdp_command(browser, id, method, session, params).await; - assert_eq!(response_by_id(&enabled, id)["result"], json!({})); - } + let enabled = send_cdp_command(browser, 90, "Page.enable", session, json!({})).await; + assert_eq!(response_by_id(&enabled, 90)["result"], json!({})); + navigate_document_and_wait_for_lifecycle( + browser, + navigation_id, + session, + url, + "DOMContentLoaded", + ) + .await; +} + +async fn navigate_document_and_wait_for_lifecycle( + browser: &mut TestCdpSocket, + navigation_id: u64, + session: Option<&str>, + url: &str, + milestone: &str, +) { + let enabled = send_cdp_command( + browser, + navigation_id, + "Page.setLifecycleEventsEnabled", + session, + json!({"enabled": true}), + ) + .await; + assert_eq!(response_by_id(&enabled, navigation_id)["result"], json!({})); let navigation = send_cdp_command( browser, navigation_id, @@ -40,14 +57,13 @@ async fn navigate_fixture_document( let loader = response_by_id(&navigation, navigation_id)["result"]["loaderId"] .as_str() .expect("fixture navigation loader"); - // Navigation can acknowledge before parsing finishes and invalidates an - // earlier DOM root. Wait for this session and loader's parsed document; - // an initial-document lifecycle replay must not satisfy the fixture gate. + // Initial-document replay and a previous navigation's queued events cannot + // complete this navigation, even when its command acknowledges first. let parsed = |message: &serde_json::Value| { - message["sessionId"] == target.session_id + message["sessionId"].as_str() == session && message["method"] == "Page.lifecycleEvent" && message["params"]["loaderId"] == loader - && message["params"]["name"] == "DOMContentLoaded" + && message["params"]["name"] == milestone }; if !navigation.iter().any(parsed) { recv_until_match(browser, parsed).await; @@ -2876,15 +2892,7 @@ async fn named_popup_references_from_sibling_pages_share_the_close_lifetime() { } async fn navigate_dynamic_page_and_wait_for_load(page: &mut TestCdpSocket, id: u64, url: &str) { - send_cdp_command_without_wait(page, id, "Page.navigate", None, json!({"url": url})).await; - let mut responded = false; - let mut loaded = false; - recv_until_match(page, |message| { - responded |= message["id"] == id; - loaded |= message["method"] == "Page.loadEventFired"; - responded && loaded - }) - .await; + navigate_document_and_wait_for_lifecycle(page, id, None, url, "load").await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout.rs b/moli-renderer-v8/src/native_bridge/context_host/layout.rs index 1dcbb697efd..18b4c2819d8 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -599,6 +599,19 @@ impl JsContextHost { self.document_layout_state.borrow_mut().complete(terminal) } + #[cfg(test)] + pub(crate) fn set_document_layout_services_for_test( + &self, + services: moli_layout::DocumentLayoutServices, + ) { + let document = self.document_handle(); + let mut state = self.document_layout_state.borrow_mut(); + state.with_services_for_document(document, document, |current, _| { + *current = services; + }); + state.clear_latest_layout(); + } + #[cfg(test)] pub(crate) fn document_web_font_counts_for_test(&self) -> (usize, usize, usize) { self.document_layout_state.borrow().web_font_counts() diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs index 27246e0286a..e78682c44b8 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs @@ -1339,7 +1339,31 @@ fn inline_end_border_preserves_tall_atomic_content() { } } -// Geometry measured in Chromium 145.0.7632.116, before and after publication. +// Font-dependent geometry uses the same checked-in Ahem bytes on every platform. +// Expected values were independently measured in Chromium 145.0.7632.6 with +// moli-layout/tests/fixtures/moli-ahem.ttf loaded as "Moli Inline Metrics". +fn new_fixed_font_inline_test_vm(url: &str, markup: &str) -> StandaloneScriptVmHarness { + let vm = new_parsed_test_vm(url, markup); + let mut services = moli_layout::DocumentLayoutServices::with_system_font_policy( + moli_layout::SystemFontPolicy::Disabled, + ); + services + .register_web_font(moli_layout::WebFontRegistration::new( + "inline-metrics", + moli_layout::WebFontFace::new("Moli Inline Metrics"), + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../moli-layout/tests/fixtures/moli-ahem.ttf" + )) + .to_vec(), + )) + .expect("fixed inline metrics font must register"); + vm._context_host + .borrow() + .set_document_layout_services_for_test(services); + vm +} + #[test] fn empty_inline_alignment_resolves_placement_separately_from_line_contribution() { let cases = [ @@ -1389,14 +1413,14 @@ fn empty_inline_alignment_resolves_placement_separately_from_line_contribution() ( "text_top", "", - [46.0, -46.0], - [91.0, 11.0], + [40.0, -40.0], + [95.0, 15.0], ), ( "text_bottom", "", - [32.0, 12.0], - [91.0, -11.0], + [30.0, 10.0], + [95.0, -15.0], ), ( "top", @@ -1421,9 +1445,9 @@ fn empty_inline_alignment_resolves_placement_separately_from_line_contribution() ] { for (id, content, quirks_geometry, standards_geometry) in cases { let markup = format!( - r#"{doctype}
{content}
"# + r#"{doctype}
{content}
"# ); - let mut vm = new_parsed_test_vm("https://inline-alignment.test/", &markup); + let mut vm = new_fixed_font_inline_test_vm("https://inline-alignment.test/", &markup); let query = "JSON.stringify([line.getBoundingClientRect().height,b.getBoundingClientRect().top-a.getBoundingClientRect().top])"; let expected = if quirks { quirks_geometry @@ -1471,15 +1495,17 @@ fn forced_breaks_restore_ancestor_bidi_before_closing_inner_inline_boxes() { let whitespace = if preserved { ";white-space:pre" } else { "" }; let newline = if preserved { "\n" } else { "
" }; let markup = format!( - r#"{doctype}
x{newline}x
"# + r#"{doctype}
x{newline}x
"# + ); + let mut vm = new_fixed_font_inline_test_vm( + "https://inline-bidi-forced-break.test/", + &markup, ); - let mut vm = - new_parsed_test_vm("https://inline-bidi-forced-break.test/", &markup); let query = "JSON.stringify([line.getBoundingClientRect().height,second.getBoundingClientRect().top-line.getBoundingClientRect().top])"; let expected = if quirks || bidi == "normal" { - [90.0, 63.0] + [90.0, 65.0] } else { - [120.0, 81.0] + [120.0, 83.0] }; let first = vm.eval(query).unwrap(); assert_eq!(