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..b89cb4a7fe9 100644 --- a/moli-bounded-buffer/src/buffer.rs +++ b/moli-bounded-buffer/src/buffer.rs @@ -66,6 +66,21 @@ 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 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); + } else { + break; + } + } + evicted + } + /// Returns the sum of the logical byte charges for retained entries. pub fn used_bytes(&self) -> usize { self.used_bytes @@ -151,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 dc013dd9201..d002720a5fe 100644 --- a/moli-bounded-buffer/src/tests.rs +++ b/moli-bounded-buffer/src/tests.rs @@ -1,5 +1,44 @@ 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)); + 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)); @@ -115,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-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-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}"), } }; 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/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-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-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..eb5c0a8132a 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( @@ -563,13 +573,56 @@ 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.output_range.start >= last_atomic_offset + && (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, @@ -580,6 +633,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, @@ -604,6 +665,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) @@ -3405,6 +3469,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); @@ -3500,7 +3598,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/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..63c84071671 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,150 @@ 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)); + self.reposition_empty_inline_continuation(run.preceding_inline, parent, top); + 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); + } + } + + /// 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 + .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 +1463,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 +1518,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..d80b650a3ab 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 @@ -98,15 +103,21 @@ impl PreparedWorldLayout { N: Copy + Debug + Eq + Hash, { 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); + 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(); 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; @@ -114,13 +125,22 @@ impl PreparedWorldLayout { 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); - } + self.feedback_invalidation_worklist + .extend(world.boxes[id.index()].layout_children.iter().copied()); } - if viewport_changed || !changed_boxes.is_empty() { - world.viewport_layout.cache.clear(); + + 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; + } } + world.viewport_layout.cache.clear(); for id in invalidated.iter().copied() { self.feedback_invalidation_marks[id.index()] = false; } @@ -1622,6 +1642,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 +1809,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); } @@ -2849,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-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..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), ) }) } @@ -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,17 +912,203 @@ 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!( "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 @@ -970,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" @@ -1001,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" ); } @@ -2622,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![ @@ -2898,6 +3128,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 +3734,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 @@ -3594,3 +4405,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() + ); + } +} 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-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-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs b/moli-protocol-server/src/protocol_server/tests/classic/extracted/navigation.rs index 2570f14f033..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 @@ -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"] { @@ -1813,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, @@ -1839,7 +1839,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()); @@ -1885,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/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/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 40795a4fe30..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,12 +756,30 @@ 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() .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.stop_requested = true; + request.cancel(); + } + } + pub(crate) fn bind_pending_document_navigation_renderer_page( &mut self, token: &DocumentNavigationToken, @@ -1659,6 +1679,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..a8380d1e68c 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -650,10 +650,21 @@ 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() } + 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, @@ -1199,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) } @@ -1213,6 +1233,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..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)] @@ -504,6 +505,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 +525,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..623e05a2580 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 { @@ -693,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 @@ -705,6 +718,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(); } @@ -1336,10 +1367,19 @@ 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, + entry_order: VecDeque, + max_entries: Option, + retained_request_ids: HashSet, } impl Default for CapturedResponseBodyStore { @@ -1354,8 +1394,69 @@ 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(), + } + } + + fn configure_durable(&mut self, session_id: Option<&str>, limits: Option) { + let session = session_id.map(str::to_owned); + if let Some(limits) = limits { + 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()); + } + } + } + } + } + + 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); + } + } + 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) { + self.current.clear(); + for buffer in self.durable_sessions.values_mut() { + buffer.prepare_navigation(); } } @@ -1383,29 +1484,15 @@ impl CapturedResponseBodyStore { collector_ids: impl IntoIterator, collection_was_gated: bool, ) { - let byte_len = body.len(); - 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); - } - } } pub(crate) fn insert_pending_with_collector_scope( @@ -1415,12 +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; - } - self.bodies.insert( + self.insert_body( request_id, CapturedResponseBody::pending_with_collector_scope( session_ids, @@ -1438,8 +1520,7 @@ impl CapturedResponseBodyStore { collector_ids: impl IntoIterator, collection_was_gated: bool, ) { - self.buffered_bodies.remove(&request_id); - self.bodies.insert( + self.insert_body( request_id, CapturedResponseBody::failed_with_collector_scope( error_text, @@ -1450,58 +1531,191 @@ impl CapturedResponseBodyStore { ); } + 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.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.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); + } - 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()); + #[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); } } @@ -1958,11 +2172,343 @@ 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) ); } + #[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, + ); + } + let buffer = store.durable_sessions.get(&Some("owner".into())).unwrap(); + assert!( + buffer.bodies.len() + buffer.buffered_bodies.len() + <= super::DURABLE_RESPONSE_BODY_MAX_ENTRIES + ); + assert_eq!( + buffer.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.current.retained_request_ids.is_empty()); + assert!(store.current.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(); + 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 + .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())]); + 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 + .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] 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..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, @@ -261,6 +262,7 @@ fn emit_complete_subresource_network_delivery_record( .then(|| output.request_cookie_report()) .flatten(), &[], + true, ); } } @@ -286,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, @@ -297,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(( @@ -312,6 +320,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 +462,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, ); } } @@ -502,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, @@ -513,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(( @@ -528,6 +547,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..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 { @@ -279,6 +282,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 +347,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 +1540,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..a89f2019c3f 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())].into(), + 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/redirect_request.rs b/moli-protocol/src/domains/network/redirect_request.rs new file mode 100644 index 00000000000..132cbe72e35 --- /dev/null +++ b/moli-protocol/src/domains/network/redirect_request.rs @@ -0,0 +1,101 @@ +/// Project the request metadata for successive followed redirect hops. +/// +/// 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, + 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) { + if moli_fetch::redirect_status_rewrites_to_get(status, self.method) { + self.method = "GET"; + self.body = None; + self.headers + .retain(|(name, _)| !moli_fetch::is_request_body_header_name(name)); + } + } +} + +#[cfg(test)] +mod tests { + use super::RedirectRequest; + + #[test] + fn redirect_status_method_matrix_matches_fetch_semantics() { + // 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"); + } + } + + #[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); + } +} 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/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..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,241 @@ 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(); + 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/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/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..35e0c315921 100644 --- a/moli-protocol/src/domains/page/termination.rs +++ b/moli-protocol/src/domains/page/termination.rs @@ -396,12 +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) - && 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, @@ -410,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 b1a6b3bb515..329417359db 100644 --- a/moli-protocol/src/domains/page/tests/navigation/requests.rs +++ b/moli-protocol/src/domains/page/tests/navigation/requests.rs @@ -602,3 +602,1258 @@ 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); + 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["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!("Navigation stopped") + }, + ) + .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_preserves_partial_document_and_allows_next_navigation() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let prefix_parsed = std::sync::Arc::new(tokio::sync::Notify::new()); + let server_parsed = prefix_parsed.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(); + 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(); + // 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") + .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!({})); + 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 frozen = vm + .eval( + r#" + (() => { + const parent = candidate.offsetParent; + const width = candidate.offsetWidth; + return JSON.stringify({ parentIsMenu: parent === menu, width }); + })() + "#, + ) + .expect("offsetParent-first frozen geometry read should evaluate"); + assert_eq!( + 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, + "geometry reads must not publish the connected insertion" + ); + publish_layout_for_test(&mut vm); + + 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_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')); + 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; + let mut published = "40"; + 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(), + 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(), + expected + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + } +} + +#[test] +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')); + 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; + let mut published = "40"; + 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(), + 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(), + expected + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "clean geometry must be reusable across script turns" + ); + } +} + +#[test] +fn geometry_publishes_inserted_and_existing_nodes_at_visual_boundaries() { + 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 frozen = 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("frozen geometry after a connected insertion should evaluate"); + + assert_eq!( + vm.layout_pass_observability_for_test().1, + 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"); + 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!( + 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, + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); +} 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!( 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..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,100 @@ return [ } #[test] -fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() { +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/", + "
", + ); + 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"); + let mut published_width = 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 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, + "explicit publication 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_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 @@ -1542,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, @@ -1549,6 +1645,8 @@ yield; // Publish this scene before reading its geometry. first[3], second[0], second[3], + frozenAfterInsertion, + frozenInserted, afterInsertion, inserted.offsetTop ].join('|'); @@ -1557,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|38|0"); + assert_eq!(result, "38|8|38|8|38|38|0|43|8"); let passes = vm ._context_host .borrow() @@ -1565,13 +1663,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 two explicit publication boundaries 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.0, cache_before.0 + 13); assert_eq!(cache_after.1, cache_before.1); - assert_eq!(cache_after.2, cache_before.2 + 1); + assert_eq!(cache_after.2, cache_before.2 + 2); assert!(cache_after.3.is_some()); } 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-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-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,