From fa43124ff7fc4e9a18140bb0744af8948d4f2acc Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 23 Aug 2026 19:11:11 +0800 Subject: [PATCH] feat(popup): adopt stable renderer-owned auxiliary pages Create auxiliary contexts as real Pages with synchronous initial realms, stable WindowProxy handoff and one navigation owner. Preserve the Page residence across redirects, no-content responses and transport error documents. --- ...g-context-chromium-alignment-2026-08-02.md | 1376 +++++++- moli-core/src/page/mod.rs | 53 +- moli-core/src/runtime/mod.rs | 10 +- moli-core/src/runtime/navigation_engine.rs | 197 +- moli-page-types/src/lib.rs | 3 + moli-protocol-cdp/src/wire.rs | 41 +- moli-protocol-server/src/cdp_scheduler.rs | 97 +- .../src/cdp_scheduler/actor.rs | 35 +- .../src/cdp_scheduler/tests.rs | 3 + .../src/protocol_server/tests/websocket.rs | 49 +- .../src/protocol_server/webdriver_bidi.rs | 16 +- .../webdriver_classic/state.rs | 28 + moli-protocol/src/conn.rs | 114 +- .../browser_context/target_session_owner.rs | 182 +- moli-protocol/src/conn/dispatch_tests.rs | 118 +- moli-protocol/src/conn/inspector_route.rs | 26 + moli-protocol/src/conn/page_state/loaded.rs | 59 + moli-protocol/src/conn/page_state/parked.rs | 14 + moli-protocol/src/conn/page_state/surfaces.rs | 1 + .../src/conn/popup_navigation_work.rs | 245 +- moli-protocol/src/conn/runtime_eval.rs | 49 +- moli-protocol/src/conn/runtime_load.rs | 668 +++- moli-protocol/src/conn/scheduler_hooks.rs | 32 + moli-protocol/src/conn/scheduler_state.rs | 69 + .../src/conn/state/attachment_identity.rs | 4 + moli-protocol/src/conn/state/mod.rs | 2 +- moli-protocol/src/conn/state/navigation.rs | 9 + .../src/conn/state/navigation_outcome.rs | 21 + moli-protocol/src/conn/state/page_slot.rs | 36 +- moli-protocol/src/conn/state/parking.rs | 16 + moli-protocol/src/conn/state/runtime_slot.rs | 101 +- moli-protocol/src/conn/state/tests.rs | 25 + moli-protocol/src/conn/tests/mod.rs | 19 +- .../src/conn/tests/resource_runtime.rs | 3 + .../src/domains/activity/scheduler_work.rs | 1 + moli-protocol/src/domains/debugger.rs | 1 + moli-protocol/src/domains/fetch/navigation.rs | 1 + .../domains/fetch/tests/navigation_auth.rs | 5 + .../fetch/tests/navigation_response_stage.rs | 680 ++++ moli-protocol/src/domains/network.rs | 16 +- .../network/main_document_progress/gate.rs | 5 + .../network/main_document_progress/mod.rs | 202 +- .../network/main_document_progress/tests.rs | 58 + .../src/domains/network/tests/mod.rs | 13 + .../src/domains/network/tests/navigation.rs | 20 +- moli-protocol/src/domains/page.rs | 116 +- moli-protocol/src/domains/page/navigation.rs | 384 +- .../src/domains/page/navigation_commit.rs | 323 ++ moli-protocol/src/domains/page/popup.rs | 2 + moli-protocol/src/domains/page/preload.rs | 8 + moli-protocol/src/domains/page/termination.rs | 1 + .../src/domains/page/tests/navigation.rs | 541 ++- .../src/domains/page/tests/scripts.rs | 4 + .../src/domains/runtime/test_support.rs | 10 +- .../src/domains/runtime/tests/console.rs | 18 +- .../src/domains/runtime/tests/evaluate.rs | 37 +- .../src/domains/runtime/tests/mod.rs | 4 +- .../src/domains/runtime/tests/navigation.rs | 7 +- .../domains/runtime/tests/shared_worker.rs | 10 +- moli-protocol/src/domains/target/lifecycle.rs | 3095 +++++++++++++++++ .../target/tests/tests_background_staging.rs | 10 + .../p1_target_multipage.rs | 720 ++++ .../target/tests/tests_cdp_smoke_fixture.rs | 25 + .../target/tests/tests_playwright_cdp.rs | 3 +- .../tests/tests_same_context_target_local.rs | 158 +- .../target/tests/tests_target_creation.rs | 277 +- moli-protocol/src/lib.rs | 15 +- moli-protocol/src/test_support.rs | 22 +- moli-protocol/src/testing.rs | 341 +- .../src/browsing_context_model.rs | 15 +- moli-renderer-v8/src/context_bootstrap.rs | 4 +- .../src/context_bootstrap/history_mutation.rs | 55 +- .../context_bootstrap/location_navigation.rs | 15 +- .../location_runtime/install.rs | 10 +- .../navigation_cross_document.rs | 12 +- .../context_bootstrap/navigation_mutation.rs | 4 +- .../navigation_mutation/same_document.rs | 14 +- .../context_bootstrap/navigation_window.rs | 9 + .../src/context_bootstrap/runtime_state.rs | 31 + .../src/context_bootstrap/shared/slots.rs | 1 + .../window_accessors/surface.rs | 10 +- .../window_runtime/dialogs.rs | 19 +- .../src/document_runtime/security_policy.rs | 9 + moli-renderer-v8/src/host_bindings.rs | 8 +- moli-renderer-v8/src/lib.rs | 7 +- .../src/native_bridge/bindings.rs | 54 + .../child_frame_runtime/window.rs | 2 +- .../src/native_bridge/context_host/core.rs | 92 + .../src/native_bridge/context_host/mod.rs | 10 +- .../src/native_bridge/context_host/popups.rs | 313 +- .../src/native_bridge/document.rs | 15 +- .../element/activation/targets.rs | 5 +- moli-renderer-v8/src/native_bridge/mod.rs | 4 +- .../src/network/context/document.rs | 4 + .../src/page_task_queue/senders.rs | 10 + moli-renderer-v8/src/runtime/mod.rs | 503 ++- moli-renderer-v8/src/runtime/owner.rs | 1128 +++++- moli-renderer-v8/src/runtime/owner_local.rs | 73 + .../src/runtime/owner_local_store/bound.rs | 127 + .../src/runtime/owner_local_store/entry.rs | 86 +- .../src/runtime/owner_local_store/mod.rs | 670 +++- .../src/runtime/owner_local_store/tests.rs | 2 + moli-renderer-v8/src/runtime/page.rs | 82 +- moli-renderer-v8/src/runtime/page_surface.rs | 2 + .../runtime/page_surface/popup_activation.rs | 22 +- .../page_vm/document_lifecycle_turn.rs | 12 + .../runtime/page_vm/followed_navigation.rs | 2 +- moli-renderer-v8/src/runtime/page_vm/mod.rs | 528 ++- .../child_realm_materialization_completion.rs | 4 + .../src/runtime/page_vm/tests/worker.rs | 92 +- .../src/runtime/phase_one/bootstrap.rs | 6 +- moli-renderer-v8/src/runtime/phase_one/mod.rs | 10 +- .../src/runtime/phase_one/scaffold.rs | 6 + .../src/runtime/phase_one/state.rs | 15 + .../src/runtime/phase_one/streaming.rs | 16 +- .../phase_one/streaming_admission_tests.rs | 69 +- .../runtime/protocol_output/turn_journal.rs | 25 + .../src/runtime/script_preloads.rs | 39 +- moli-renderer-v8/src/runtime/tests.rs | 1418 +++++++- moli-renderer-v8/src/script_vm.rs | 611 +++- .../child_frame_realm_materialization.rs | 23 +- .../src/script_vm/document_isolate.rs | 234 +- moli-renderer-v8/src/script_vm/eval_exec.rs | 22 + .../src/script_vm/inspector/mod.rs | 13 +- moli-renderer-v8/src/script_vm/post_parse.rs | 40 +- ...ice_worker_internal_client_request_body.rs | 2 + .../src/script_vm/standalone_test_harness.rs | 246 ++ .../script_vm/tests/browser_api/navigation.rs | 87 + .../script_vm/tests/dom_xhr/computed_style.rs | 1 + moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + moli-renderer-v8/src/v8_platform.rs | 60 +- 131 files changed, 16642 insertions(+), 1221 deletions(-) create mode 100644 moli-protocol/src/domains/target/lifecycle.rs diff --git a/docs/popup-auxiliary-browsing-context-chromium-alignment-2026-08-02.md b/docs/popup-auxiliary-browsing-context-chromium-alignment-2026-08-02.md index 513ff02b5c..8a7dc0a382 100644 --- a/docs/popup-auxiliary-browsing-context-chromium-alignment-2026-08-02.md +++ b/docs/popup-auxiliary-browsing-context-chromium-alignment-2026-08-02.md @@ -5,9 +5,47 @@ 状态:架构评估与分阶段迁移设计;`popup-refactor` 已完成 Phase 1 primitive 抽取、 Phase 2A script-agent identity / current-policy baseline,以及 Phase 2B 的 selective shared-agent ownership、V8 foreground routing、核心可行性矩阵和两次 release 长序列 -内存验收。Phase 2B 的 test-only 可行性实验已完成;production auxiliary admission -仍未开放,下一步是 Phase 3 的真实 initial-empty auxiliary Page。本文不表示 popup -迁移已经完成;尚未落地的拟议类型名只用于表达责任边界。 +内存验收。Phase 3 第一纵切已经把 renderer-owned auxiliary browsing-context/Page +reservation 和 selective related-agent admission 接入 production initial `about:blank` +target build;第二纵切的首个基础提交又建立了 live Page replacement reservation 和 +prepared-document environment reuse,第二个提交已经把 prepared replacement commit、原 +stable Page slot 内的 `PageVm`/view publication 和同一个 core `Page` 的 adoption 边界接通; +第三个提交已经让 protocol `Page.navigate` / target navigation 在已有 Page 上使用这份 +replacement path,并覆盖 active、background、inactive target 及 Fetch response-stage; +`noopener` 仍显式使用 fresh agent。第四个提交又把 opener 同步拿到的同一 V8 +WindowProxy 交给 related auxiliary Page 的首个 realm,并保留 inherited `about:blank` +的 creator security token。第五个基础提交把 main default realm bootstrap 拆成 callback +内可用的 in-scope prebootstrap 与 callback 后 Inspector materialization,并验证两段之间 +Window/Document identity 不变。第六个基础提交又把 related Page admission 从 isolate +holder 内部路由改成 live source Page membership capability,并允许在已经进入的 opener +scope 内重建 target realm 的独立 native bridge bindings、复用缓存的 Inspector backend +handle,全程不回借 holder。第三纵切 D 已经把这些基础接到 production:对保留 opener、 +非命名 target 的 initial `about:blank`,`window.open()` 在同步 callback 内创建并暂存真实 +auxiliary `PageVm`、独立 realm 和唯一 Document,protocol target 随后采纳同一份 residence, +不再创建或 replay 第二份 initial Document。该窄路径同时继承 creator origin、referrer、 +base URL、policy/storage authority,并保留 Classic WebDriver 所需的不可伪造 target identity, +而不恢复 opener 侧 Document owner。Phase 4 第一纵切又把相同的真实 initial Page 扩展到保留 +opener、非命名、非 `javascript:` 的 non-empty URL:destination 在 target admission 后只由 +auxiliary Page owner 导航一次,opener host 不再启动 mirrored loader。Phase 4 第二纵切又把 +destination 提升为携带 exact `TargetPageResidenceIdentity` 的 typed claim,并在 target-local +slot 中建立 `Held → Published → Consumed` authority;旧 admission 因 Page generation 变化而失效 +后,不能导航 replacement Page,也不能被 `Page.enable` 等入口从 target URL 重建。Phase 3-5 +仍未整体完成。Phase 4 第三纵切已经把 HTTP 204/205 收敛为不提交 Document 的独立 terminal, +并覆盖 initial realm/history 保留和后续 redirect replacement。第四纵切又把 replacement +Document 的 commit/attachment publication 与其精确 DCL continuation 分开:protocol 可以在 +parser 仍阻塞时控制已经提交的 realm,renderer owner 则用独立 typed terminal 交付同一 turn 的 +output fence 与最终 PageState,避免 popup、普通 navigation、Classic/BiDi 和 direct child lane +各自猜测异步完成。第五纵切进一步补齐 Fetch response-stage 的 effective-response terminal: +`fulfillRequest` / `continueResponse` 覆盖后的 204/205 与原始 204 都不提交 Document,而原始 204 +被 fulfill 为 200 时仍可正常提交;buffered synthetic body 不再绕过公共 no-commit/download +分类器。第六纵切进一步把普通 main-document pre-response transport failure 收敛成 browser-owned +error Document:请求失败 URL 继续作为 Target/history URL,新 Document 使用 +`chrome-error://chromewebdata/` 并通过 `unreachableUrl` 暴露原 URL;stable Page、popup +WindowProxy 和 opener graph 保持,Document/realm 按正常 replacement 边界替换。同步 initial +realm 会把 opener 的数值 viewport surface 安装到最终 target Context,而不是即将 detach 的临时 +facade;Page script environment 也会跨 realm 保存实际 opener 值。named target、`noopener`、 +`javascript:` URL、完整 cross-origin WindowProxy whitelist、target admission 前的早期任务以及 +close transaction 仍需后续纵切收敛。 代码基线: @@ -21,7 +59,7 @@ top-level browsing context。它不是 Blink 用于 ``、权限气泡等 拿到的 Window 与 CDP target 看到不同 DOM、`close()` 与 target 生命周期分裂,继续 补 facade 只会扩大同步成本。 +`popup-refactor` 当前已经为 opener-preserving、非命名、非 `javascript:` URL 建立一条 +production 迁移路径:opener 与 target 看到同一 stable WindowProxy、initial realm、Document +和 Page residence;non-empty destination 在 target admission 后从该 Page 发起一次 replacement +navigation,opener host 不再保存对应 lightweight Document record 或启动 mirrored loader。 +上述双实现判断仍适用于 named target、`noopener`、`javascript:` URL 和其余尚未迁移入口; +因此 lightweight 模型仍是 Phase 4-6 的主要删除对象,而不是可以继续扩展的长期架构。 + 建议采纳下面的方向: - 复用 child-frame 已经成熟的 stable `WindowProxy`、可替换 `LocalWindow` / @@ -137,6 +182,10 @@ Chromium: ## Lightmount 当前实现 +下面 1-4 节保留原始架构评估,便于说明迁移为什么必要;其后 Phase 3 实施记录是当前分支 +状态的增量事实。尤其是第三纵切 D 已经替换了窄 initial `about:blank` 路径,不能再把该路径 +计入“两个 Document / 两个 Page owner”。 + ### 1. `window.open()` 同步路径 `window_open_callback` 已经处理了不少正确的前置语义: @@ -216,7 +265,8 @@ opener target / opener PageVM / opener isolate `window_open_hands_off_session_storage_snapshot_and_initial_storage_key` 的测试注释明确 把第一个请求称为 opener lightweight facade 的 mirrored load,把第二个请求称为 真实 auxiliary target navigation,并断言 popup URL “must have exactly two load -owners”。这不是推测,而是当前入库合同。 +owners”。这不是原始评估时的推测,而是 Phase 4 第一纵切之前的入库合同;同一测试现已 +反转为“exactly one authoritative navigation owner”,当前实现状态见 Phase 4 第一纵切 A。 ### 5. 已经可用的能力 @@ -770,7 +820,7 @@ popup 不应因为 opener Page 关闭而自动销毁,除非产品 policy 明 #### Phase 2A:显式 identity 与当前策略基线 -当前源码已经引入 typed `ScriptAgentId`,由 document isolate holder 分配并通过 +Phase 2A 当时的源码基线已经引入 typed `ScriptAgentId`,由 document isolate holder 分配并通过 Lightmount runtime memory diagnostics 暴露。`RendererPageScriptEnvironment` 是当前 agent identity 的稳定宿主: @@ -779,8 +829,8 @@ agent identity 的稳定宿主: WindowProxy,但替换 Document realm/context generation; - child default world、isolated world 和同 Page navigation generation 复用所属 Page agent; -- diagnostics 当前明确报告 `scriptAgentScope = page-script-environment`,尚未开放 - related-page admission。 +- fresh Page diagnostics 明确报告 `scriptAgentScope = page-script-environment`;Phase 2A + 当时尚未开放 related-page admission。 这一步只把现有策略变成可命名、可测试的边界,没有让两个 production Page 共享 isolate,也没有改变 popup 双实现或双 load。 @@ -820,16 +870,17 @@ Context,并验证: 如果实验无法在当前 `RendererPageScriptEnvironment` / `PageVm` ownership 下保持这些 不变量,应先重构 script-agent owner,不能退回 mirror 同步。 -实施状态(test-only selective admission,production policy 不变): +实施状态(Phase 2B 完成时的 test-only 验证基线;Phase 3 第一纵切已提升窄入口): - document isolate 现在持有 `RendererScriptAgentV8ForegroundTaskRouter`,production fresh Page 默认只注册一个 Page member,因此现有 per-Page containment policy 不变; - stable `RendererPageScriptEnvironment` 持有 RAII agent membership;same-Page replacement 复用 membership,Page slot retirement 先撤销 route,再清理 Page tasks; -- `#[cfg(test)]` 的 related-page reservation 可以从同 renderer owner 的 live source +- Phase 2B 中 `#[cfg(test)]` 的 related-page reservation 可以从同 renderer owner 的 live source Page 共享 isolate holder,同时创建独立 Page environment、main WindowProxy、V8 - Context、task sources、output journal 和 Inspector binding;production popup 尚不能 - 使用该 admission; + Context、task sources、output journal 和 Inspector binding;Phase 3 第一纵切已把 + reservation/admission/router 提升为 production primitive,但只允许 renderer 明确新建的 + auxiliary context 使用,默认 Page 仍是 fresh; - V8 foreground task 是 isolate/agent scoped,V8 不提供 originating `Context`。router 只让一个 live member 执行 concrete task 一次,随后给其他 member 排入 typed checkpoint;如果执行 Page 正在退休,尚未执行的 concrete task 会转投 surviving @@ -1016,11 +1067,13 @@ cargo clippy --workspace --all-targets -- -D warnings 按本文定义,Phase 2B 的 selective related-page shared-agent 可行性实验至此完成:功能、 Page-local 路由、non-LIFO ownership、realm retirement、isolate lifetime 和两次 release -长序列内存门均有证据。这个结论只授权进入 Phase 3,不授权把 owner-wide sharing 恢复 -为默认,也不表示 popup 已完成。WindowProxy link 与 related admission 仍只在 -`#[cfg(test)]` 暴露;production auxiliary Page ownership、multi-session Inspector event -顺序、SharedWorker/ServiceWorker、跨 origin agent split 和真实 popup 并发 close/navigation -仍分别属于 Phase 3-5。 +长序列内存门均有证据。这个结论只授权 Phase 3 打开显式 relationship admission,不授权 +把 owner-wide sharing 恢复为默认,也不表示 popup 已完成。Phase 3 第一纵切已经提升 +related admission 和 Page reservation;第三纵切 A 又把 WindowProxy cross-realm link +提升到 production 的 opener-preserving popup 路径。production 的单一 initial Document +owner、multi-session Inspector event 顺序、 +SharedWorker/ServiceWorker、跨 origin agent split 和真实 popup 并发 close/navigation 仍分别 +属于 Phase 3-5。 ### Phase 3:真实 auxiliary initial empty Page @@ -1034,6 +1087,594 @@ Page-local 路由、non-LIFO ownership、realm retirement、isolate lifetime 和 优先只切 `about:blank` 垂直路径,因为它能验证最关键的同步创建、realm identity 和 target adoption,又不先引入网络导航。 +实施状态(Phase 3 第一纵切:identity reservation / initial target adoption): + +- renderer Page script environment 现在持有一个不反向拥有 Page/VM 的窄 allocator; + `window.open()` 或 hyperlink 确认新建 lightweight auxiliary context 时,同步产生 + `RendererPendingAuxiliaryPage`。它把 typed `AuxiliaryTopLevel` browsing-context id 与 + exact `RendererPageReservationToken` 绑定在一个不可拆错的 carrier 中; +- opener 可见时 reservation 显式携带 `RelatedAuxiliaryPage { opener_page_id }`, + `noopener` / `noreferrer` 携带 `Fresh`。普通 Page 创建、`Target.createTarget` 和 + renderer owner 内其他 Page 不会隐式加入共享 agent; +- popup activation 将该 carrier 原样交给 protocol。新 target 的 `TargetPageSlot` 长期 + 保留 auxiliary browsing-context id,并在 initial empty Document build 时一次性消费 + renderer Page reservation;protocol 不再为这条路径制造第二个 initial Page id; +- initial build 不再仅凭 BrowserContext metadata 选择 NavigationEngine。它会从当前与 + retained background engine 中找到能消费 exact reservation 的 opener renderer owner, + 再创建共享该 owner 的 engine wrapper;找不到 owner 时 fail closed,不放宽 token 校验。 + 这保证 active、inactive background、BiDi user-context 和轻量测试 fixture 都不会把 + renderer 已接受的 auxiliary Page 偷换到另一个 owner; +- named lightweight target reuse 不产生第二份 reservation;尚未 materialize lightweight + context 的 fallback action、browser-context action 和 service-worker action 仍走旧路径; +- production related Page 使用已验收的 script-agent router/membership。初始 + `about:blank` 集成回归证明 opener 与 popup 有不同 Page/Context/realm,但报告同一个 + `scriptAgentId` 和一个 live document isolate;`noopener` popup 采用不同 agent; +- `HeapProfiler.lightmountDiagnostics` 改为从 Page snapshot 汇总唯一 `scriptAgentId`, + 不再把 loaded Page 数机械等同于 V8 isolate 数。V8 heap、GC、Inspector default-context + registry 和 foreground wake 的诊断 scope 相应标为 script-agent,而 target-document + 计数仍保持 Page/Document-local; +- shared-agent Inspector pause bridge 已从单一 target route 改为按 + `RendererDevToolsAgentToken -> Page output journal` 路由。关闭或替换 popup Page 只撤销 + 该 Page 的 route、pause session 和 queued command,不会永久关闭 opener target;nested + pause loop 也按 agent token 选择 V8 Inspector session,而不是假定一个 isolate 只有一个 + context group。Classic WebDriver 命名 popup 的“创建、切换、导航复用、再回到 opener + click”路径覆盖了这个 lifetime。没有 concrete Page pause route 的低层 Inspector binding + 仍可把普通通知留在 agent-local queue;只有 `Debugger.paused` 必须有精确 Page route, + 防止共享 bridge 的 route 存在性误伤 replacement/overlap teardown; +- owner handoff 与 Inspector lifetime 由 initial adoption、inactive-background CDP、BiDi + viewport inheritance、Classic named-popup reuse、replacement/overlap binding teardown 及 + `closing_related_page_route_keeps_opener_target_routable` 联合覆盖。 + +本纵切完成时的实跑证据: + +```bash +cargo nextest run -p lightmount-renderer-v8 \ + -E 'test(/script_vm::inspector_pause::tests/) | test(/script_vm::inspector::tests::replacement_document_binding_does_not_adopt_previous_agent_outbound/) | test(/script_vm::inspector::tests::dropping_overlapping_peer_binding_does_not_deactivate_current_agent/) | test(/script_vm::tests::window_execution_context::strict_window_binding_resolves_registry_policy_and_rejects_retired_realm/) | test(/runtime::tests::related_page_script_agent/) | test(per_page_isolate_policy_keeps_window_open_routes_page_owned)' \ + --no-fail-fast +# 20 passed + +cargo nextest run -p lightmount-protocol --no-fail-fast +# 3233 passed + +cargo nextest run -p lightmount \ + -E 'test(websocket_bidi_set_viewport_user_context_inherits_through_window_open) | test(webdriver_classic_named_popup_reuse_navigates_existing_window)' +# 2 passed + +cargo nextest run --no-fail-fast --status-level fail --final-status-level fail +# 15635 passed, 18 skipped +``` + +这一纵切只完成“renderer 先保留身份、protocol initial `about:blank` 接管”的不变量。 +它尚未完成 Phase 3:`window.open()` 返回的仍是 opener PageVM 内 lightweight proxy,因而 +opener immediate `document.write` 与 CDP target 仍不是同一个 Document;现有 protocol +cross-document `Page.navigate` 仍会分配 fresh Page/agent。 + +#### Phase 3 第二纵切 A:live Page replacement prepare 基础 + +本提交先收窄 prepared document 进入稳定 Page replacement path 前必须成立的 ownership +边界,没有提前改 protocol install: + +- `RendererPageHandle` 通过 renderer owner command 异步预留 replacement Document;token + 保留原 Page id / owner-local host,并携带当前已提交 `PageVm` creation id 与唯一 nonce; +- owner-local store 只保留同一 Page 最新的未消费 reservation。旧 nonce 在 isolate + bootstrap 前以 `superseded` 失败;Page 已发生其它 cross-document commit 时,旧 + generation 也在 bootstrap 前 fail closed; +- prepare 不再为该 token 创建 fresh/related isolate 或第二套 Page task sources,而是从 + stable Page slot 取得 `RendererPageScriptEnvironment`,调用既有 + `bootstrap_replacement_document_isolate()`。因此 reservation 已明确复用 script agent、 + isolate、agent membership、Page task producer routes、output journal,并声明复用 stable + main WindowProxy; +- isolate reservation 现在区分“initial creation 自己拥有 output stream”和“replacement + 只借用 live Page stream”。replacement cancel、stale failure 或当前尚未开放的 commit + 拒绝只释放 prepared residence,不能发送 live stream `Closed`,也不能改变 isolate + created/live/destroyed accounting; +- 在 replacement install/handle ownership 尚未接入前,`commit()` 显式报错并同步取消 + residence,避免误入只允许新 Page 的 `attach_page_entry_for_owner`,更不能靠同 Page id + 碰撞偶然失败。 + +聚焦回归同时覆盖了旧 prepared-document 自有 stream 的取消语义,防止修复 replacement +时把 initial creation 的 stream lifetime 放宽: + +```bash +cargo nextest run -p lightmount-renderer-v8 \ + -E 'test(/runtime::tests::(canceled_prepared_document_closes_its_ordered_output_stream|prepared_external_raw_document_waits_for_matching_commit_permit|per_page_isolate_policy_reuses_navigation_isolate_and_replaces_contexts|related_page_script_agent_experiment_shares_isolate_and_survives_source_close|canceling_prepared_live_page_replacement_preserves_page_environment_and_output_stream|stale_live_page_replacement_reservation_fails_before_isolate_bootstrap|newer_live_page_replacement_reservation_supersedes_unconsumed_nonce)$/)' \ + --no-fail-fast +# 7 passed + +cargo nextest run --no-fail-fast +# 15638 passed, 18 skipped + +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +# passed +``` + +#### Phase 3 第二纵切 B:stable Page replacement commit / core adoption + +本提交已经完成上一节要求的 replacement commit 边界,但尚未改 protocol: + +- `PreparedRendererDocument::commit_page_replacement()` 使用独立 owner command;只有 + `ExistingPageReplacement` admission 可以进入。初始 Page prepared commit 与 replacement + commit 类型上分开,误用时释放 prepared residence 并 fail closed; +- commit 在拆旧 realm 前再次比对 reservation 记录的 `PageVm` creation id、stable slot + generation 和当前 resident generation。prepare 后若发生另一场 cross-document commit, + stale prepared Document 不能覆盖新 Document; +- 旧 Document lifecycle 以 `SupersededByCrossDocumentNavigation` 结束,旧 default Inspector + context 和 Page-context resources 在新 realm bootstrap 前撤销;新 PageVm 复用原 script + agent、isolate、agent membership、typed Page task routes、output journal 和 stable main + WindowProxy;JavaScript dialog broker 与 Inspector pause bridge 属于 Document-scoped + adoption artifact,由新 PageVm 重新产生并在 stable handle 上替换; +- streaming raw 与 NativeDom 两条 prepared bootstrap 都把结果直接安装到 checked-out 的原 + `RendererPageLocalEntry`。phase-one residence、pending location navigation 和 post-parse + lifecycle 都沿用现有 live Page continuation,不进入 initial Page attach; +- replacement publication 使用同一 Page id,推进 `vm_creation_id` 和 `view_generation`,并 + 通过 typed replacement-settled wake 解锁等待 committed view 的命令。response metadata + 同时更新原 stable `RendererPageState`,而不是保留旧 status/headers/initiator; +- `DocumentCommit` reply 可以在 streaming response 尚未结束时返回 non-owning replacement + result,后台继续同一 phase-one/lifecycle。它保留 `ReturnWithPendingNavigation` policy, + protocol-owned script navigation 不会被 standalone adapter 偷走; +- `RendererPageReplacementCommit` 只含 Page identity、新 Document DevTools agent token、 + 新 Document dialog broker / pause bridge、Page state、creation diagnostics/artifacts 和可选 + download,不含 `RendererPageHandle`、Page cancel sender 或第二份 close authority; +- core `PreparedDocumentPage::commit_page_replacement(..., &mut Page)` 在 renderer commit 前校验 + exact owner/Page identity,并让原 `Page`/`RendererPageHandle` 显式采纳新 agent token 和 + state。原 handle 仍是唯一 command/close owner,已有 renderer-agent attachment id 不被 + 偷换。 + +聚焦回归覆盖 stable identity、realm retirement、commit-time stale generation、open stream +DocumentCommit、NativeDom 和 core adoption: + +```bash +cargo nextest run -p lightmount-renderer-v8 \ + -E 'test(/runtime::tests::(prepared_live_page_replacement_commits_in_stable_page_slot_without_new_handle|prepared_live_page_replacement_document_commit_replies_before_stream_completion|prepared_live_page_replacement_document_commit_preserves_browser_owned_tail_navigation|prepared_native_dom_live_page_replacement_uses_the_stable_page_slot|prepared_live_page_replacement_revalidates_generation_at_commit|canceling_prepared_live_page_replacement_preserves_page_environment_and_output_stream|stale_live_page_replacement_reservation_fails_before_isolate_bootstrap|newer_live_page_replacement_reservation_supersedes_unconsumed_nonce)$/)' \ + --no-fail-fast +# 8 passed + +cargo nextest run -p lightmount-core \ + -E 'test(runtime::navigation_engine::tests::core_page_adopts_prepared_renderer_replacement_without_replacing_ownership)' \ + --no-fail-fast +# 1 passed + +cargo nextest run --no-fail-fast +# 15644 passed, 18 skipped + +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +# passed +``` + +第二纵切 B 单独完成时仍不是 Phase 3 完成标志;它要求的 protocol 切换由下面的第二纵切 C +完成。 + +#### Phase 3 第二纵切 C:protocol stable Page navigation + +本提交把已有 top-level Page 的 cross-document navigation 从“创建 fresh Page,再替换 target +slot”切换为“在同一 stable Page residence 中替换 Document”。这包括 `Page.navigate` 与 +target-owned navigation,覆盖 active target、同 BrowserContext background target 和 inactive +background target: + +- navigation detached work 启动前,同时捕获 exact `TargetPageResidenceIdentity` 与 + `RendererPageResidenceIdentity`,并从当前 core `Page` 取得 non-owning replacement + reservation。后台 future 只持 reservation capability,不复制 `RendererPageHandle` 或 close + authority; +- stable commit 保留同一个 core `Page` handle、renderer `PageId`、target Page residence + generation 与 `TargetPageAttachmentId`。新 Document 改用新的 DevTools agent token、 + renderer-agent attachment、default realm、execution context 和 context group;旧 realm 与 + attachment 按 replacement 顺序退休; +- inline HTML、`data:`、普通网络 streaming response,以及 Fetch response-stage 的 buffered / + captured response 都携带同一个 stable target carrier。Fetch pause 后才确定的 commit + configuration 会先写回 prepared Document,再进入 renderer commit,避免 interception + 旁路重新分配 Page; +- stable reservation 不能只按 BrowserContext 或“最近一个 engine”选择 renderer owner。 + navigation 会比对 captured `RendererOwnerLocalHostId`,必要时从当前或 retained background + engine 中取出 exact owner 的 `NavigationEngine`;找不到时在 prepare 前 fail closed。这修复 + 了同 BrowserContext 内 background target 导航误用 active target renderer owner 的路径; +- renderer commit settlement 现在发布新 Document 的初始 output,建立 predecessor fence, + 再把 creation artifacts、lifecycle binding、main-resource body 和 navigation engine 交给同一 + target owner。protocol 不再因为 Page identity 稳定而漏掉 execution-context / console / + lifecycle 可观察输出; +- renderer-agent candidate 先作为 transaction 准备,renderer Page commit 成功后再把 stable + target 的 DevTools channel、全部 frontend session call route 和 Page state 切到新 + attachment。Document-scoped JavaScript dialog broker、Inspector pause bridge 与 dialog scope + 同步轮换,旧 Document 的 pending dialog 不会泄漏到新 Document; +- `pending_live_page_replacement_reservations` 只负责 prepare admission,另用 latest + reservation 记录保持 commit-time ordering。于是两个已经 prepare 的并发 candidate 中,后 + reservation 可以提交,旧 candidate 以 `PagePreserved` 失败,不能覆盖新 Document 或关闭 + stable Page; +- replacement error 明确携带 `PagePreserved` / `PageRetired` disposition。pre-commit identity、 + nonce 或 candidate mismatch 会回滚 renderer channel 并保留旧 Page;一旦旧 realm 已退休, + 后续 materialization / protocol commit 失败就 fail closed 丢弃当前 Page,不能伪装成可回滚; +- 已从 scheduler registry claim、正在等待 Promise 的 Inspector command 也属于旧 Document。 + replacement 会遍历该 target 的 primary / auxiliary session,给这些 await 精确发送一次 + `Inspected target navigated or closed`,避免命令永久挂起; +- related popup 继续遵守 selective shared-agent 结论:opener 与 popup 在同一个 document + isolate 中运行,但保持两个 Page realm / execution context,而不是把“两个 Document”误报成 + “两个 isolate”; +- `Target.createTarget` 的 background initial load 之后可能立即进入完整 Page navigation。 + 该 target-to-Page future 边界显式 boxed,避免 test thread 同时保留 initial build、response + plan 与 navigation state machine 导致确定性 stack overflow;这里没有加入 sleep、retry 或 + 调大线程栈来掩盖问题。 + +本纵切的聚焦与 crate 级证据包括: + +```bash +cargo nextest run -p lightmount-renderer-v8 \ + -E 'test(newer_live_page_replacement_supersedes_prepared_candidate_without_retiring_page)' \ + --no-fail-fast +# 1 passed + +cargo nextest run -p lightmount-protocol \ + -E 'test(cross_document_page_navigate_replaces_realm_in_stable_page_residence) | test(interleaved_response_heads_only_commit_the_current_prepared_document) | test(runtime_evaluate_await_promise_pending_is_terminated_once_by_navigation_replacement) | test(same_context_background_session_can_stage_its_own_locale_and_timezone_before_promotion)' \ + --no-fail-fast +# 4 passed + +cargo nextest run -p lightmount-protocol \ + -E 'test(local_storage_mutations_fan_out_across_targets_without_leaking_session_storage)' \ + --no-fail-fast +# default test stack 下连续 3 次通过 + +cargo nextest run -p lightmount-protocol --no-fail-fast +# 3234 passed + +cargo nextest run --no-fail-fast --status-level fail --final-status-level fail +# 15646 passed, 18 skipped +``` + +第二纵切 C 单独完成时仍不是 Phase 3 完成标志:它只统一了已经存在的 Page 内的 +cross-document replacement,还没有改变 `window.open()` 同步返回的 lightweight +WindowProxy/Document。下面的第三纵切 A 先统一 proxy identity;initial Document owner 和 +mirrored load 仍是后续边界。 + +#### Phase 3 第三纵切 A:opener-visible stable WindowProxy handoff + +本提交把已经在 Phase 2B 验收的 related-page 跨 realm WindowProxy 能力接入 production +popup creation,但刻意不把 initial Document mirror 误报为完成: + +- `window.open()` / hyperlink 在确认需要新建 lightweight browsing context 后,先从 opener + 的 `RendererPageScriptEnvironment` 预留 exact related auxiliary Page。named target reuse + 不再分配 Page,也不会覆盖已有 handoff; +- opener-preserving 路径不再创建只能留在 opener realm 的 synthetic Window wrapper。它用 + normal Window global template 创建一个由临时 V8 Context 持有的真实 global proxy,立即 + 安装现有同步 popup surface,并把这同一个对象返回给 author script。临时 facade 也初始化 + eager intrinsic interface registry;否则第二个命令里重新物化 `HTMLDocument` 等 wrapper 时 + 会因为 facade realm 没有 prototype registry 而终止进程。只要 lightweight mirrored loader + 尚未删除,facade 还必须拥有独立 runtime-observable context token,并从 facade realm 内安装 + popup id / opener private slots;这样 handoff 前的 response script 仍能观察 creator,但不把 + Phase 5 尚未实现的 target opener graph 伪装成已完成; +- V8 handle 不进入可跨 renderer/protocol transport 的 `RendererPendingAuxiliaryPage`。opener + Page 的窄 allocator 持有 owner-local registry,以 reserved target `PageId` 为 key 暂存 + `WindowProxy + facade Context + optional creator security token`;registry 不反向持有 Page、 + PageVM 或 protocol target,因此不会形成 ownership cycle; +- owner-local store 消费 `RelatedAuxiliaryPage { opener_page_id }` 时,必须先找到 exact live + source Page environment,再一次性取走对应 proxy。目标 `RendererPageScriptEnvironment` + 在首个 realm bootstrap 前登记它,`ScriptVmContextBootstrap` detach 临时 facade,并把 exact + proxy 作为 `ContextOptions::global_object` 交给真实 auxiliary default Context;没有 alias、 + proxy 状态复制或等待 protocol 的同步补丁; +- initial `about:blank` 对普通 origin 可以重新计算相同 internalized token,但 opaque origin + 与 `document.domain` mutation 使用 V8 unique token。handoff 因此额外只消费一次 creator + token,保证 inherited initial realm 可由 opener 同步访问;后续 cross-document replacement + 不再复用该 token,而是按新 Document origin 正常计算; +- `noopener` / `noreferrer` reservation 仍是 `Fresh` agent,调用方返回 `null`,不会尝试把 + V8 object 搬到另一个 isolate;ServiceWorker `clients.openWindow` / notification fallback + 也显式保持无 handoff 的旧 lightweight 路径; +- facade inner global 退役后,旧的 lightweight popup private marker 不一定还能从 opener + realm 直接读取。Classic WebDriver 的 Window reference adapter 因此先走 marker 快路径, + 再由 opener host 按其仍持有的 `LightweightPopupBrowsingContextRecord.window_proxy` exact + identity 回查 popup id;这不会给 target realm 重新安装 opener-local popup marker,也不会 + 把 target 的正常 top-level Window 行为重新路由回 lightweight owner; +- protocol 端到端回归不是比较 metadata:popup session 在真实 auxiliary realm 写入 global + 与 `document.body`,opener 保存的 handle 必须读取同一值;opener 再经该 handle 写入,popup + session 必须反向观察到。这证明双方使用同一个 stable proxy/inner realm projection,而不是 + 两个 proxy 的 mirror synchronization。 + +本纵切当前的聚焦证据: + +```bash +cargo nextest run -p lightmount-renderer-v8 \ + -E 'test(per_page_isolate_policy_keeps_window_open_routes_page_owned) | test(related_page_script_agent_transfers_stable_window_proxy_objects_and_dom_wrappers)' \ + --no-fail-fast +# 2 passed + +cargo nextest run -p lightmount-protocol \ + opener_window_handle_projects_the_renderer_owned_auxiliary_realm \ + --no-fail-fast +# 1 passed + +cargo nextest run -p lightmount-protocol \ + window_open_hands_off_session_storage_snapshot_and_initial_storage_key \ + --no-fail-fast +# 1 passed;现阶段仍明确验证 mirrored loader 的双 owner characterization + +cargo nextest run -p lightmount-protocol \ + window_open_named_target_reused_in_same_command_emits_one_page_event \ + --no-fail-fast +# 1 passed;覆盖 facade intrinsic registry 与 named reuse + +cargo nextest run -p lightmount \ + webdriver_classic_execute_script_round_trips_window_and_frame_references \ + --no-fail-fast --stress-count 5 +# 5/5 passed;覆盖 handoff 后 popup Window reference identity 回查 + +cargo nextest run -p lightmount-renderer-v8 \ + owner_scheduler_applies_popup_terminal_from_stable_page_route \ + --no-fail-fast --stress-count 5 +# 5/5 passed;覆盖 facade token、opener projection 与 mirrored terminal application + +cargo nextest run --no-fail-fast +# 15833 passed, 18 skipped +``` + +这仍不是 Phase 3 完成标志。同步调用期间 `popup.document` 仍由 opener PageVM 的 +`LightweightPopupDocumentRecord` 提供;protocol 接管后 stable proxy 已投射到真实 auxiliary +realm,但此前的 DOM mutation 还不会成为 target initial Document。lightweight record 也仍 +拥有 Document/navigation/close 状态,并对 non-empty URL 发起 mirrored load。下一纵切必须让 +真实 auxiliary Page environment 从同步创建起就拥有唯一 initial realm/Document,删除对应 +lightweight Document owner;之后 Phase 4 才能把 pending non-empty URL 收敛为一次 +authoritative navigation。 + +#### Phase 3 第三纵切 B:in-scope main realm prebootstrap 基础 + +本提交先解决上一节下一步的 reentrancy 前置条件,尚未改变 popup production ownership: + +- `window.open()` native callback 执行时,opener `ScriptVm` 已通过 document-isolate holder + 持有 `OwnedIsolate` 的可变借用。此时再次调用 `PageVm::new()` 会重入同一个 `RefCell`;临时 + 释放借用、重入 owner loop 或从裸 isolate pointer 再造 scope 都不满足本文的不变量; +- main default realm 现在和 child default realm 一样有显式 in-scope primitive。 + `ScriptVmContextBootstrap::new_main_default_in_scope()` 接受调用方已经进入的 + `PinScope` 与 isolate global template,在同一 scope 内创建真实 V8 `Context`、安装 stable + main WindowProxy、native bridge、runtime token 和完整 Window surface; +- `ScriptVmPageRealmBootstrap` 把这一步产出为 + `ScriptVmPreinspectorDefaultWorldBootstrap`。在该边界,独立 `DocumentRuntime` / + `JsContextHost`、main Document resource authority、Window execution-context registration + 和 baseline globals 已经就绪,author script 可以同步访问该 realm;但 Inspector default + context 尚未发布; +- callback/outer scope 退出后,`materialize_default_inspector_context()` 才借用 isolate-level + Inspector backend,把同一个 Context 注册到对应 Page binding。它不创建第二个 Context、 + 不 detach/reattach WindowProxy,也不重建或复制 Document; +- 普通 Page、replacement Document 和现有 related auxiliary target 的创建已经全部经过这份 + 两段式实现,只是立即连续执行两段,因此现有 production event/ownership surface 不需要 + 旁路;下一提交可以在两段之间暂存 renderer-owned auxiliary Page realm; +- 新回归在持有 shared isolate owner borrow 且已经进入模拟 opener Context 的情况下直接调用 + in-scope prebootstrap。Inspector registry 必须仍为 `0`;随后在预创建 realm 中保存 + `document` / `Array` identity 并写入 `document.body`,后置 materialization 后 registry 必须 + 精确变为 `1`,且两个 identity 与 DOM mutation 全部保持。 + +聚焦证据: + +```bash +cargo nextest run -p lightmount-renderer-v8 \ + main_default_realm_prebootstrap_preserves_window_and_document_until_inspector_materialization \ + --no-fail-fast +# 1 passed + +cargo nextest run -p lightmount-renderer-v8 \ + -E 'test(main_default_realm_prebootstrap_preserves_window_and_document_until_inspector_materialization) | test(per_page_isolate_policy_keeps_window_open_routes_page_owned) | test(related_page_script_agent_transfers_stable_window_proxy_objects_and_dom_wrappers) | test(/script_vm::inspector_pause::tests/) | test(/script_vm::inspector::tests::replacement_document_binding_does_not_adopt_previous_agent_outbound/) | test(/script_vm::inspector::tests::dropping_overlapping_peer_binding_does_not_deactivate_current_agent/)' \ + --no-fail-fast +# 16 passed + +cargo nextest run -p lightmount-protocol \ + -E 'test(opener_window_handle_projects_the_renderer_owned_auxiliary_realm) | test(window_open_hands_off_session_storage_snapshot_and_initial_storage_key) | test(window_open_named_target_reused_in_same_command_emits_one_page_event)' \ + --no-fail-fast +# 3 passed + +cargo nextest run --no-fail-fast +# 15834 passed, 18 skipped +``` + +这仍不是 Phase 3 完成标志:`window.open()` 尚未创建 +`ScriptVmPreinspectorDefaultWorldBootstrap`,protocol initial build 也尚未消费这份 staged +realm/Page residence。下一纵切应让 related auxiliary reservation 同步准备独立 DomHost、 +Page task routes、resource/storage authority 和这份 in-scope realm,再由 protocol target 只做 +Inspector/target adoption;不能把 lightweight DOM replay 到新 Page,也不能重新创建一份 +initial Document。 + +#### Phase 3 第三纵切 C:in-scope related-agent admission 基础 + +这一提交继续收窄 native callback 内剩余的 isolate holder 重入点,仍不改变 production +popup 的 Document owner: + +- `RendererScriptAgentPageMembership` 现在是 admission authority。只有仍 active 的 source + Page membership 能为一个明确的 target Page route 调用 `admit_related_page()`;调用方不再 + 为了取得 holder 内的 router 而借用 document isolate。普通 owner-lane related Page build + 也改走同一能力,避免同步路径与既有异步路径形成两套 admission 规则; +- `RendererDocumentIsolateBootstrap` 和稳定 `RendererPageScriptEnvironment` 缓存同一份 + `RendererInspectorIsolateBackendHandle`。创建 target Page binding 不需要在 callback 栈上 + 重新进入 holder 读取 backend;handle 仍不暴露任何 V8/Inspector mutation authority; +- `NativeBridgeBindings::build_peer_in_scope()` 只复用 source isolate 的 Window global / + cross-origin Window global templates,并在 caller 已进入的 `PinScope` 内重建独立 bridge、 + wrapper templates 和 cache。target `JsContextHost` 不会共享 opener 的 mutable bridge state; +- `RendererPageScriptEnvironment::bootstrap_related_page_document_isolate_in_scope()` 把上述两份 + capability 合并成 callback-safe bootstrap。失败或尚未 adoption 时,RAII membership 会撤销 + target Page route,不会给 shared script agent 留下幽灵成员; +- 回归在 holder 已持有 mutable borrow 且 opener Context 已进入时执行这份 admission,再用 + 新 bindings 创建独立 target Context。它同时验证 exact isolate identity、target Page id、 + page-count `1 -> 2 -> 1` 和未 adoption bootstrap 的 rollback;旧实现会在读取 router 或 + Inspector backend 时直接触发 `RefCell` 重入。 + +聚焦证据: + +```bash +cargo nextest run -p lightmount-renderer-v8 \ + related_page_isolate_admission_builds_peer_bindings_inside_entered_opener_scope \ + --no-fail-fast +# 1 passed + +cargo nextest run -p lightmount-renderer-v8 \ + -E 'test(related_page_isolate_admission_builds_peer_bindings_inside_entered_opener_scope) | test(main_default_realm_prebootstrap_preserves_window_and_document_until_inspector_materialization) | test(per_page_isolate_policy_keeps_window_open_routes_page_owned) | test(related_page_script_agent_experiment_shares_isolate_and_survives_source_close) | test(related_page_script_agent_transfers_stable_window_proxy_objects_and_dom_wrappers)' \ + --no-fail-fast +# 5 passed + +cargo nextest run -p lightmount-protocol \ + -E 'test(opener_window_handle_projects_the_renderer_owned_auxiliary_realm) | test(window_open_hands_off_session_storage_snapshot_and_initial_storage_key) | test(window_open_named_target_reused_in_same_command_emits_one_page_event)' \ + --no-fail-fast +# 3 passed + +cargo nextest run --no-fail-fast +# 15835 passed, 18 skipped + +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +# both passed +``` + +这仍不是 Phase 3 完成标志。in-scope capability 目前只在窄回归中直接调用;production +`window.open()` 仍先创建 facade,owner 后续才创建 target `DomHost` / Page task residence。 +下一提交应在 related auxiliary reservation 上同步创建并暂存 exact task sources、resource / +storage authority 和 `ScriptVmPreinspectorDefaultWorldBootstrap`,然后让现有 initial Page build +消费它并仅 materialize Inspector/target ownership。不能为消除 facade 而把 lightweight DOM +内容 replay 到第二份 Document。 + +#### Phase 3 第三纵切 D:synchronous Page residence / exact initial Document adoption + +本提交完成了上一节定义的 production 纵切,但有意把准入范围限制在最能证明 identity 与 +ownership 的 initial-empty 路径: + +- 必须由 live opener Page 产生 `RelatedAuxiliaryPage` reservation; +- `noopener` / `noreferrer` 不进入,因为它们是 `Fresh` agent 且调用方也不应取得同步 + Window reference; +- target 必须是空字符串或 `_blank` 等非命名 target。可追踪 name 仍留给 group-level named + registry 纵切,不能先创建一份无法复用的真实 Page; +- URL 必须是 `about:blank`,允许 fragment。non-empty URL 仍由 Phase 4 负责唯一 authoritative + navigation,不能在本提交中继续保留 mirrored request 又声称 owner 已统一。 + +同步 callback 内的新拓扑是: + +```text +opener PageVm / shared related script agent + | + | window.open("about:blank", "_blank") + v +owner-local staged auxiliary PageVm + +-- stable WindowProxy(立即返回给 opener) + +-- independent V8 Context / inner Window + +-- unique DomHost / initial Document + +-- Page task sources / lifecycle / resource authority / storage authority + +-- unopened Inspector binding + Page output journal + | + | exact RendererPageReservationToken + v +protocol popup target + +-- adopt 上述同一 PageVm / Context / Document + +-- 只补 frame、session、Inspector 与 target configuration +``` + +具体 ownership 与同步语义如下: + +- `open_lightweight_popup_window()` 在 legacy record 创建之前识别上述准入条件。它捕获 creator + base URL、policy container、document referrer、inherited origin、request client、local / + session storage、top-level storage key、IndexedDB 与 bucket authority;随后创建 detachable + stable WindowProxy shell 和 canonical initial HTML DomHost; +- inherited origin 不再从 `about:blank` URL 重新算成 `"null"`。创建输入在 realm bootstrap 前 + 安装到 root `DocumentRuntime` policy container 和 `DocumentFetchContext`,Window 的 runtime + origin slot 也在 target Context 内同步覆盖。`Location.origin` 对 `about:blank` 读取该 inherited + runtime origin;root `Document.referrer` 直接读取同一 policy container;default runtime realm + inventory 也读取 Document resource authority,而不再从 URL 重算 origin。HTTP opener 回归同时 + 验证 `window.origin`、`location.origin`、`document.referrer`、fallback `baseURI` 和 target + `Runtime.executionContextCreated.origin`; +- `_blank` 是选择关键字,不是 browsing-context name。真实 realm 的 `window.name` 因此初始化为 + 空字符串;不能把传入的 `_blank` 写进 target Window; +- source `JsContextHost` 不再保存对应 `LightweightPopupBrowsingContextRecord`。否则 opener host + 会通过 record 中的 `Global` 反向强持有 target realm,形成第二 owner,并使 target + close 后的 realm containment 无法证明。protocol handoff 需要的 session-storage snapshot 与 + initial storage key 改为 `OpenedLightweightPopup` 上的一次性 carrier; +- Classic WebDriver 仍需把 opener 返回的 WindowProxy 编码成随后创建的 window handle。真实 + target Window 因此只带一个独立的 V8 private auxiliary-popup identity;host serializer 可以 + 识别它,但 author script 无法伪造。这个 marker 不提供 Document、navigation、timer 或 close + ownership,也不会让现有 lightweight API 把真实 Window 重新路由到 opener host; +- owner-local store 以 `(RendererOwnerLocalHostId, PageId)` 暂存完整 `PageVm`,而不是暂存可被 + replay 的 DOM snapshot。它在已经进入的 opener isolate scope 内创建 exact Page task source、 + typed producer routes、output stream、Page Inspector binding、related-agent membership、peer + native bindings 和 pre-Inspector default realm; +- callback 内不能重新借用同一 document-isolate holder。无 restore session 时 bootstrap 不再 + 为一次空 reattach 进入 holder;IndexedDB / bucket 初始 backend 也直接写入当前 target Context + 与 host。所有需要 V8 scope 的初始化都在 caller scope 完成,退出 callback 后才允许普通 + owner command 再进入 holder; +- staged initial Document 同步设为 `readyState=complete`,并用 typed lifecycle transition 记录 + DCL/load 已达成。后续空 lifecycle turn 识别“里程碑已完成且没有 work”并返回 Idle,不依靠 + sleep、retry、任意 drain 或重复 dispatch 修正状态; +- isolate reservation 在 `PageVm` construction 的异常区间暂时 disarm,避免失败析构递归借用 + 当前 bound owner-local store;成功后 rearm,交回正常 Page lifetime。owner-local store 析构 + 会先 drain staged Page,再显式撤销 reservation,避免 source Page 先析构时留下 related-agent + membership 或 Inspector route。 + +protocol initial target build 现在是 adoption,而不是第二次 bootstrap: + +- initial request 必须仍是匹配的 `about:blank`;owner command 用 exact reservation 一次性取走 + staged `PageVm`。protocol 为旧 creation path 预留的 service-worker client 被释放,保留同步 + Page 已经创建的 client; +- target 提供 root frame id、main-document commit、Inspector session restore、isolated worlds、 + bindings 和最终环境配置。adoption 把 lifecycle journal 绑定到 Page output stream,并在同一 + V8 Context 上 materialize Inspector;它不创建 Context、WindowProxy、Document 或 DomHost; +- opener 在 target activation 前已经可以改 DOM、设置 global、访问 storage 或产生 renderer + observation。output journal 暂存尚未发布的 author records,adoption 先插入 + `executionContextsCleared -> frame commit -> contextCreated` 前缀,再原序追加这些 records;一旦 + stream prefix 已发布就 fail closed,不能用乱序事件掩盖 ownership 错误; +- adoption 只替换 protocol 提供的 request transport / network policy 和 Page-level runtime + configuration,随后直接进入现有 Page creation phase two。环境应用模式显式区分普通创建、 + staged 创建和 staged adoption:adoption 不得用 synthetic `about:blank` response 的空 CSP、 + referrer policy、COEP 或 Document-Isolation Policy 覆盖 creator-derived policy container; + sandbox 导出的 script-disable 只能保持或收紧,不能被 target 默认配置放宽(显式 CDP + `Page.setBypassCSP` 仍是独立的调试器能力)。同步保存的 `Document` object、body mutation、 + global lexical/realm state、WindowProxy 和 Page id 全部保持原对象。 + +核心回归不再只比较 metadata: + +- `opener_window_handle_projects_the_renderer_owned_auxiliary_realm` 在 opener 的同一次 + `Runtime.evaluate` 中保存 exact `popup.document`,写入 target global 与 body,并检查 name、 + origin、referrer 和 base URL;attach target 后验证 `window.opener` 保存的 Document 就是当前 + `document`,同步 realm/global/DOM 状态全部存活;target 再修改 DOM/global,opener 必须经原 + WindowProxy 和原 Document 看到变化,反向 proxy mutation 也必须成立; +- `window_open_hands_off_session_storage_snapshot_and_initial_storage_key` 使用 HTTP opener,证明 + 非 opaque creator origin、referrer、base URL、localStorage 共享和 sessionStorage clone 在 + target adoption 前后保持一致;它也在同步 WindowProxy 与 attach 后的 exact target realm 两侧 + 验证 creator response CSP 继续拒绝 `eval`。后者显式设置 CDP + `allowUnsafeEvalBlockedByCSP=false`,避免把调试器默认的临时 CSP 豁免误判为 policy 丢失; +- Classic WebDriver round-trip 覆盖多个顺序不同的 `about:blank#fragment` popup。真实 proxy 的 + private target identity 必须稳定映射到 window handle,重复引用不能退化成循环对象 clone。 + +本纵切的聚焦与 repository gate 实跑证据: + +```bash +cargo nextest run -p lightmount-protocol \ + -E 'test(opener_window_handle_projects_the_renderer_owned_auxiliary_realm) | test(window_open_hands_off_session_storage_snapshot_and_initial_storage_key) | test(popup_initial_empty_document_frame_tree_inherits_opener_origin) | test(popup_initial_empty_document_record_captures_creator_identity) | test(rust_cdp_chromium_target_window_open_empty_url_creates_about_blank_popup) | test(window_open_named_target_reused_in_same_command_emits_one_page_event)' \ + --no-fail-fast +# 6 passed + +cargo nextest run -p lightmount \ + webdriver_classic_execute_script_round_trips_window_and_frame_references \ + --no-fail-fast +# 1 passed + +cargo check -p lightmount-renderer-v8 --all-targets +cargo check -p lightmount-protocol --all-targets +# passed + +cargo nextest run --no-fail-fast +# 15835 passed, 18 skipped + +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +# both passed +``` + +第三纵切 D 单独完成的是 Phase 3 的窄 initial-empty owner 不变量,不是 popup 完成标志。 +该提交当时明确保留的缺口如下;其中 non-empty URL 的首个 owner 纵切已由下一节继续收敛: + +- named target 与 `noopener` 仍走 legacy creation;non-empty URL 在该提交时也走 legacy, + 下一节 Phase 4 第一纵切 A 已只迁移保留 opener 的非命名、非 `javascript:` 路径; +- target 级 `close()` / `window.close()` 还没有统一 transaction,真实 target Window 也不能因 + 一个旧 lightweight close callback 被误认为已关闭; +- protocol 的 document-start scripts、isolated worlds 和 runtime bindings 到 adoption 时才可得, + 尚未证明它们相对 opener 的同步 initial-Document mutation 具有 Chromium 一致的顺序; +- target activation 前启动 timer/fetch、触发 modal dialog 或发布 Inspector-sensitive output 的 + scheduler 边界尚缺专门回归。当前 output journal 对已发布 prefix 拒绝 adoption,这是安全门, + 不是这些时序已经完成的证据; +- initial request 与 staged URL 不匹配时会 fail closed;staged residence 目前依赖 owner teardown + 作为最终清理安全网,后续应增加不递归借用 owner store 的 eager reject/retire transaction; +- initial DomHost 继续沿用本项目 child initial-empty 的完整 HTML tree 约定。是否需要进一步对齐 + Chromium 对 doctype / parser state 的细节,应由 WPT/Chromium probe 决定,不能在 identity + 纵切中凭印象改树形。 + ### Phase 4:non-empty URL 单一导航 - pending URL 只交给 auxiliary Page owner; @@ -1046,6 +1687,691 @@ target adoption,又不先引入网络导航。 完成标志:同一个 popup URL 不再因为实现结构产生两个请求。 +#### Phase 4 第一纵切 A:non-named related popup 的唯一 navigation owner + +本提交把上一节的 exact initial Page residence 扩展到保留 opener、非命名、URL 可解析且 +scheme 不是 `javascript:` 的 `window.open()`。它解决的是最直接的双请求 owner,不把尚未 +迁移的 name/group/opener policy 或全部 navigation terminal 语义混入同一提交。 + +renderer 同步路径现在遵守以下边界: + +- non-empty destination 不是 initial Document URL。同步 callback 始终构造真实 + `about:blank` initial Document(显式 `about:blank#fragment` 仍保留其 fragment),继承 + creator origin、policy、referrer、base URL、storage authority 和 stable WindowProxy; +- stable WindowProxy shell 只负责 identity handoff;opener 的 `innerWidth`、`innerHeight`、 + `outerWidth`、`outerHeight` 和 `devicePixelRatio` 数值 surface 会在真实 target Context + 初始化时复制到最终 inner Window。把这些值只写到临时 facade 会在 realm handoff 时丢失, + 已由 Chromium/WPT 移植的 BiDi user-context viewport 回归覆盖; +- requested destination 只保留在 immutable `RendererPendingPopupActivation` 中。同步返回后, + opener 可以立即修改 popup global 与 `document.body`;source `JsContextHost` 不创建 + `LightweightPopupBrowsingContextRecord`,也不调用 + `start_lightweight_popup_document_load()`; +- protocol 先用 exact `RendererPendingAuxiliaryPage` materialize/adopt 上述 initial Page,发布 + target/attach/Inspector ownership,然后才把 requested URL 变成绑定该 target residence 的 + `PopupTargetNavigationOwnerAction`。后续 fetch、response、replacement commit、lifecycle 和 + generation 继续走现有 stable Page navigation path,没有新增 protocol loader; +- `waitForDebuggerOnStart` 是明确 admission gate:等待期间 target session 可以观察 initial + realm,但 destination 请求数必须为零;`Runtime.runIfWaitingForDebugger` 之后才释放这一份 + target-owned navigation; +- eligible staging 失败时不再 fall through 到 legacy lightweight loader。调用方失去同步 proxy + 并由普通 target fallback 继续处理,比悄悄恢复两个 authoritative owner 更安全;正常 + production owner-local path 由聚焦回归证明可成功 staging。 + +`window_open_hands_off_session_storage_snapshot_and_initial_storage_key` 现在同时覆盖两段: + +1. initial `about:blank` adoption 前后保持 exact Document、origin/referrer、storage 与 CSP; +2. gated HTTP non-empty popup 在 admission 前保存 target proxy/Document/global/body mutation, + auto-attached target session 必须看到同一对象;等待调试器时服务端计数为 `0`,resume 后 + 完成 cross-origin replacement,最终计数严格为 `1`。旧注释与断言中的 two owners 已删除。 + +本纵切的实跑聚焦证据: + +```bash +cargo nextest run -p lightmount-protocol \ + -E 'test(window_open_hands_off_session_storage_snapshot_and_initial_storage_key) | test(opener_window_handle_projects_the_renderer_owned_auxiliary_realm) | test(rust_cdp_chromium_target_window_open_blank_creates_popup_target) | test(rust_cdp_chromium_target_window_open_auto_attached_popup_materializes_initial_document) | test(rust_cdp_chromium_target_window_open_waiting_popup_routes_initial_document_after_resume) | test(window_open_emits_popup_target_created_from_runtime_work) | test(rust_cdp_chromium_target_window_open_javascript_url_still_reports_popup_target) | test(window_open_named_target_reuses_existing_popup_target) | test(rust_cdp_chromium_target_window_open_empty_url_creates_about_blank_popup) | test(popup_initial_about_blank_adopts_renderer_page_and_related_script_agent)' \ + --no-fail-fast +# 10 passed + +cargo nextest run -p lightmount-renderer-v8 \ + -E 'test(window_open_non_about_returns_lightweight_popup_and_dispatches_load) | test(window_open_named_lightweight_popup_reuses_without_recloning_session_storage)' \ + --no-fail-fast +# 2 passed;standalone / named legacy 边界未被 production admission 改写 + +cargo nextest run -p lightmount \ + websocket_bidi_set_viewport_user_context_inherits_through_window_open \ + --no-fail-fast +# 1 passed;同步 stable WindowProxy 与 navigation 后 target 都继承 user-context viewport + +cargo nextest run -p lightmount-renderer-v8 \ + window_open_lightweight_popup_inherits_opener_viewport_surface \ + --no-fail-fast +# 1 passed;保留 legacy fallback 的 viewport 行为 + +cargo nextest run --no-fail-fast +# 15837 passed,18 skipped(rebase 到 origin/master f16860e4fb 后) + +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +# both passed +``` + +#### Phase 4 第二纵切 B:Page-residence-bound navigation claim + +第一纵切已经消除了 renderer/protocol 双 loader,但当时的 +`PopupTargetNavigationOwnerAction` 只冻结 browser context id、target id 和 URL。target 与 CDP +session 可以跨 renderer Page replacement 存活,因此 target id 不是足够的导航权限;此外 +`waitForDebuggerOnStart` 恢复路径仍会从当前 target URL 重新推导一次 initial navigation。旧 +activation 若晚于 Page replacement 到达,就可能把属于 initial Page 的 destination 应用到新 +residence。 + +本纵切把 navigation admission 收敛为以下状态机: + +```text +RendererPendingPopupActivation + -> capture exact target route + TargetPageResidenceIdentity + frozen URL + -> TargetRuntimeSlot::Held(action) + immediate admission -> Published(claim) -> scheduler owner action + waitForDebugger admission -> Published(claim) -> runIfWaitingForDebugger owner turn + -> validate exact route + target + loaded_page_generation + -> Consumed(claim) tombstone + current -> one Page navigation + stale -> drop; never rescan target URL +``` + +具体边界如下: + +- target creation 在发布 `Target.targetCreated` / attach lifecycle 前捕获 exact + `TargetPageResidenceIdentity` 并把 initial destination stage 到该 target 的 + `TargetRuntimeSlot`;捕获或 staging 失败会回滚不完整 target,不能留下“只有 URL、没有 owner” + 的半接受状态; +- `PopupTargetNavigationClaimIdentity` 同时冻结 Page residence/generation、browser context、 + concrete target、URL 和 navigation kind。普通 admission 只把这一个 move-only action 发布给 + protocol scheduler;named-target reuse 暂时仍走 legacy group policy,但其既有 action 也获得 + 相同的 Page-generation currentness 检查; +- `waitForDebuggerOnStart` 期间 action 保持 `Held`,`Page.enable` 和 + `Page.createIsolatedWorld` 不能触发 destination。`Runtime.runIfWaitingForDebugger` 是明确的 + target-owner admission turn:它把同一 action 变成 `Published` 后直接消费,并保留触发恢复的 + explicit popup session 作为 execution attachment,使 Fetch pause/fulfill 与后续 lifecycle 都 + 继续路由到同一个 session;这里没有重新读取 target URL; +- completion 先把匹配的 `Published` claim 原子变成 `Consumed`,再检查 exact + `TargetPageResidenceIdentity`。即使检查发现 Page generation 已变化,`Consumed` tombstone 也 + 保留在 target slot;所有通用 initial-navigation 入口看到 `Held`、`Published` 或 `Consumed` + 都会拒绝从 target URL 制造第二份工作; +- Page replacement 不清除这份 authority。这样旧 action 必须在新 generation 上 fail closed, + 而不是因为状态被清空后被 generic fallback 重新解释;target teardown 则连同整个 slot 一起 + 回收它; +- admission action 和其内部 Page navigation 都沿用现有 `Box::pin` orchestration 边界。若把 + 这两个大 async state 直接内嵌进通用 initial-navigation future,即使普通 + `Target.createTarget` 不走 popup 分支,也会放大 Tokio worker 的栈布局;target-creation + storage fan-out 回归把这个非业务分支的栈边界一并锁住。 + +聚焦回归分别证明正常 action、stale generation 和 debugger admission: + +```bash +cargo nextest run -p lightmount-protocol \ + -E 'test(local_storage_mutations_fan_out_across_targets_without_leaking_session_storage) or test(popup_navigation_owner_action_rejects_replaced_page_and_cannot_be_rescanned) or test(rust_cdp_chromium_target_window_open_waiting_popup_routes_initial_document_after_resume) or test(popup_activation_creates_target_and_schedules_navigation_without_page_readback)' \ + --no-fail-fast +# 4 passed + +cargo nextest run --no-fail-fast +# 15839 passed,18 skipped(rebase 到 origin/master c597ac97dc 后) + +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +# both passed +``` + +其中 stale 回归在 action 发布后推进 target 的 `loaded_page_generation`,随后证明:旧 action 不 +产生 navigation/event、新 Page 仍是 `about:blank`,再次调用通用 target-URL initial-navigation +入口也不能复活该 destination。debugger 回归同时证明 waiting 期间请求数为零,resume 后只有 +同一 explicit popup session 的一份 Fetch/Network 请求,并能完成 replacement lifecycle。 +第一次全量运行让未 boxed 的 nested future 在上述 DOMStorage target-creation 回归中稳定触发 +stack overflow;单独复现后恢复 heap-boxed orchestration 边界,4 个聚焦用例与第二次全量均 +通过,因此没有把它归类为 flaky。 + +#### Phase 4 第三纵切 C:no-commit HTTP terminal 与 initial Document history + +204/205 不是普通 transport failure,也不是一个可交给 renderer 构造新 Page 的空 HTML +response。它们已经收到 HTTP response,但导航必须以“不提交 Document”结束。旧实现把二者 +继续送入 response-stage preparation / `Page` construction,因而可能替换 popup 的 initial +Document、丢掉 opener 同步写入的 realm/DOM 状态,并错误发布 `frameNavigated`、DCL、load 或 +`loadingFinished`。只在 popup 调用点绕过 commit 也不够:streaming、buffered、Fetch +response-stage 和 background `Page.navigate` 会从不同入口到达同一个 load outcome 边界。 + +Chromium 对照给出的是一个明确的两层合同: + +- `content/browser/renderer_host/navigation_request.cc` 将 204/205 归为不可 render/commit 的 + response,并中止这次 navigation;`navigation_controller_impl_browsertest.cc` 对应回归证明 + 不会新增 NavigationEntry; +- `third_party/blink/renderer/core/dom/document.h` 用 `Document::IsInitialEmptyDocument()` 保存 + Document 身份,而不是从 URL 推断;`frame_loader.cc` 和 + `document_loader.cc::UpdateForSameDocumentNavigation` 在 URL/history update 步骤把 initial + empty Document 上的标准导航转换为 replacement。fragment、`history.pushState()` 和 + `history.replaceState()` 不会让该 Document 失去 initial 身份,`document.open()` 则会显式 + 退出; +- WPT `initial-empty-document/window-open-204-fragment.html`、 + `window-open-204-pushState-replaceState.html` 和 `window-open-history-length.html` 把这些 + 行为连成同一个矩阵:204 后仍是原 initial Document,same-document 更新后 + `history.length == 1`,下一次成功 cross-document navigation 仍替换该唯一条目; +- inspector-protocol 的 `page/navigate-204.js` 和 + `network/navigation-204-loading-failed.js` 要求 `Page.navigate` 返回 + `net::ERR_ABORTED`,Network 顺序为 `responseReceived → loadingFailed(canceled=true)`,而不是 + `loadingFinished`。 + +Lightmount 现在把这条语义放在公共 navigation terminal 边界: + +```text +HTTP response head (204/205) + -> publish response metadata / redirect hops + -> NavigationLoadOutcome::NoCommitResponse + -> CompletedNoCommitResponseProgressTransfer + -> FailedNavigationDocumentPolicy::PreserveCommittedDocument + + FailedNavigationHistoryPolicy::RetainInitialEmptyDocumentReplacement + + FailedNavigationResponseMode::CdpErrorTextResult + -> responseReceived + -> loadingFailed(errorText = net::ERR_ABORTED, canceled = true) +``` + +具体责任边界如下: + +- `NavigationLoadOutcome::NoCommitResponse` 是独立 typed outcome,携带 final URL 和已经完成的 + main-document progress transfer。它不复用 `NetworkFailure(String)`:后者仍保留现有的 failed + navigation / Document invalidation policy,避免在尚未决定 error-page 设计前悄悄改变普通网络 + 错误; +- streaming response 和 captured/buffered response 都在 prepared-Document/Page construction + 之前识别 204/205;Fetch response-stage preparation 也拒绝为它们准备 Page。background + `Page.navigate` 不再提前发送 success result,因此 terminal owner 能返回 Chromium 形状的 + `{frameId, loaderId, errorText: "net::ERR_ABORTED", isDownload: false}`; +- no-commit progress 先保留 response/redirect 元数据,再用同一 request id 发布 canceled + `loadingFailed`。由于没有 renderer DCL/load boundary 可以在后续 turn 解锁 body phase,terminal + turn 会显式让 response/body-failed 两阶段可见,但仍通过同一个 progress queue 保证源顺序; +- materialization 使用 `PreserveCommittedDocument`:popup 的 exact stable Page、WindowProxy、 + V8 Context、Document、global 与 body mutation 全部保持,且不会发布新 + `Page.frameNavigated`、DCL 或 load。failed response body 仍进入统一的 failed-body bookkeeping, + 不制造“协议终态完成但 body owner 悬空”的旁路; +- browser-owned initial history 在 popup 创建边界显式 stage + `ReplaceInitialEmptyDocument`。no-commit terminal 不从“当前 URL/Document 看起来像 initial”反推 + 新意图,而是只保留已经 pending 的 initial replacement;reload、traverse 和普通 append 的 + pending update 都会丢弃。这样先后遇到 204、205 后,下一次成功导航仍替换 popup 的唯一条目, + 同时普通顶层 `about:blank` 的首次 `Page.navigate` 仍按既有 Chromium 合同追加; +- renderer-owned history 在 `JsContextHost` 上保存持久的 root initial-Document bit。related + auxiliary Page 在原 stable realm 构造时设置它,fragment、`pushState`、Navigation API + same-document mutation 和后续 cross-document seed 都据此转换为 replacement;URL 变成 + `about:blank#...` 后仍然正确。`document.open()` 在 root Document replacement owner 边界清除 + 该 bit,与 Blink 的 Document 合同一致; +- 后续 redirect success 继续走既有 replacement Page path。integration matrix 要求 redirect + 每个 hop 恰好一条 `requestWillBeSent`、共享 request id、后续 hop 携带前一跳 + `redirectResponse`,最终只出现一次 `frameNavigated`、一次 DCL 和一次 load,并把 initial realm + 替换为一个 history entry。 + +本纵切新增的 end-to-end popup 用例从 `waitForDebuggerOnStart` 的真实 initial Page 开始:等待时 +写入 opener global/body marker;resume 后完成 204;依次执行 fragment 和 `pushState`;再执行 +205;最后导航到 redirect chain。它同时检查每个 no-commit terminal 的 CDP 形状、事件顺序、 +Document/realm identity、browser/renderer history projection,以及成功 replacement 的请求和 +lifecycle 基数。renderer 附近的回归单独锁住 initial bit 对 fragment、`pushState`、cross-document +seed 和 `document.open()` 的作用;progress queue 与 browser history owner 也各有边界测试。 + +聚焦与全量验证: + +```bash +cargo nextest run -p lightmount-protocol --no-fail-fast \ + -E 'test(completed_no_commit_response_progress_orders_response_before_canceled_failure) | test(active_target_initial_empty_document_record_tracks_navigation_lifecycle) | test(rust_smoke_fixture_serves_navigation_no_commit_routes) | test(page_navigate_network_failure_invalidates_previous_document) | test(popup_no_commit_responses_preserve_initial_document_before_redirect_replacement)' +# 5 passed + +cargo nextest run -p lightmount-protocol --no-fail-fast \ + -E 'test(navigation_history_marks_reload_as_reload_transition) | test(navigation_history_supports_playwright_back_forward_commands) | test(navigated_within_document_matches_chromium_mixed_history_sequence) | test(navigation_history_is_preserved_per_parked_target) | test(renderer_history_back_uses_browser_owned_navigation_history) | test(rust_cdp_capability_page_navigation_history_round_trip)' +# 6 passed + +cargo nextest run -p lightmount-renderer-v8 --no-fail-fast \ + -E 'test(root_initial_empty_document_replaces_same_and_cross_document_history_updates) | test(document_open_exits_root_initial_empty_history_replacement_mode) | test(window_open_204_popup_ignores_navigation_and_preserves_initial_empty_history) | test(window_open_without_url_replaces_initial_empty_history_on_first_navigation)' +# 4 passed + +cargo nextest run --no-fail-fast +# 15844 passed,18 skipped + +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +# both passed +``` + +第一次全量运行暴露 6 个稳定可复现的 browser-owned history 回归:当时实现只要 target 仍在 +initial empty Document,就在每次 cross-document start 无条件重新 arm replacement,导致普通顶层 +`about:blank` 的首次导航、reload、traverse 和 parked-target history 少一个 entry。6 个用例聚焦 +复跑为 0/6,因而没有归类为 flaky。最终实现把意图来源收回 popup 创建边界,并让 no-commit +terminal 只保留已经存在的 `ReplaceInitialEmptyDocument`;相同 6 个用例恢复为 6/6,第二次全量 +为 15844/15844。 + +本纵切没有把以下问题伪装成已经完成:普通 DNS/connect/TLS/HTTP transport failure 应保留旧 +Document、提交统一 error page,还是使旧 Document 失效,仍需下一纵切用 Chromium/CDP/WPT +矩阵明确命名 policy。当时尚缺的 Fetch fulfill/continue 204/205 interception 入口矩阵现已由 +Phase 4 第五纵切 E 补齐;它也反向证明当时所谓“公共 response builder 已共享 classification”并 +不完整,因为 response-stage synthetic buffered-body 入口仍存在一条直达 prepared Document 的旁路。 + +Phase 4 尚未完成,后续纵切仍必须补齐: + +- 普通 network error/error-page 的 Document policy、CDP response shape、history、DCL/load/done + 和 opener-visible state 仍需专门 integration 矩阵; +- named target、`noopener` / `noreferrer` 和 `javascript:` URL 仍由 legacy policy/path 处理, + 分别属于 Phase 5 group/opener 纵切和后续 URL semantics 纵切; +- target admission 前启动 timer/fetch/modal 或先关闭 popup 的行为仍需和 close transaction 一起 + 定义,不能靠当前 output journal 的 fail-closed 门槛代替正常时序。 + +#### Phase 4 第四纵切 D:DocumentCommit / exact continuation owner boundary + +第三纵切证明了 no-commit terminal 不应构造 replacement Document,但成功 response 的另一条 +边界仍不够准确:renderer 在 parser 尚未走到 DCL 时已经拥有可用的 replacement realm 和 +Document,protocol 也必须发布新的 execution context、允许 debugger/configuration 控制并返回 +早期 `Page.navigate` 结果;与此同时,DCL、load、最终 title/history 和 renderer output 又不能 +由 command future 的返回时刻推断。原实现把这些状态压在一个 async completion 里,不同入口会 +出现两类相反错误:要么为等待最终 Page snapshot 而锁死 debugger/Fetch 控制命令,要么过早取 +一份仍在变化的 PageState,并让旧 Document 的异步完成污染 replacement generation。 + +Chromium 对齐约束不是“所有命令都等到 DCL”。已经 commit 的 Document 即使 parser-blocking +script 仍在等待网络,也可通过其 replacement execution context 执行 `Runtime.evaluate`,此时 +`document.readyState == "loading"`、`document.body` 甚至可以仍为 `null`。只有 attachment 尚在 +replacement cutover 时,document-bound 命令才必须等待;`Runtime.addBinding`、preload、isolated +world 和 debugger resume 等配置/控制面还必须穿过这个 cutover,才能在第一段 author script +之前生效。DCL 是独立的 lifecycle target,不是 realm usability gate。 + +本纵切把成功 navigation 收敛成以下两段 owner transaction: + +```text +response / prepared replacement + -> DocumentCommit + -> adopt exact stable Page residence + replacement realm/Document + -> publish attachment / executionContextCreated / early navigate result + -> release renderer attachment cutover + -> RendererDocumentContinuationObserver (exact loader + generation) + -> renderer owner reaches this Document's DCL target + -> capture RendererOutputFence + Arc in the same owner turn + -> typed Send completion lane + -> project predecessor, then apply PageState iff target + loader are still current + -> refresh history title / Target.targetInfoChanged + -> later continue to exact Load lifecycle observation +``` + +核心实现边界如下: + +- renderer 的 `RendererDocumentContinuationPublisher/Observer` 是一次性 typed terminal。publisher + 被安装到创建或 replacement navigation 的真实 owner continuation,terminal 同时携带 exact + `RendererOutputFence` 和 `Arc`;两者来自同一个 owner turn,protocol 不再在 + fence 之后另发 snapshot command,因而不会跨 turn 或跨 generation 观察到不一致状态。publisher + drop 也会显式产生 canceled terminal,receiver 不会永久悬挂; +- continuation target 固定为该 committed Document 的 DCL。phase-one producer 被网络、parser + source、debugger pause 或 location navigation 挡住时只保留真实 owner turn,不再因为 + `DocumentCommit` reply 已经发出就提前 settle producer park。若 parser script 发起 replacement + navigation,源 Document 的 continuation 会按 generation 终止,不能补发源 DCL;successor + navigation 拥有自己的 token、loader 和 terminal; +- owner 恢复 live Page 时先取得同一 renderer stream 的 output fence,再 settle terminal。这样 + lifecycle、Inspector、popup/worker/child-frame publication 都先按 concrete cursor 进入 protocol + scheduler,PageState 才能替换 protocol cache;`PageState` 只在 gate 的 target id、session owner + route 和 loader id 仍与当前 Page 匹配时应用,旧 completion 无权修改新 target/runtime; +- CDP scheduler 为 continuation 使用独立的 Send receiver,不与 background navigation completion + 或 background event gate 混成同一含义。background navigation gate 负责 early navigate result + 之后的 load residence/event ordering;continuation gate 只表达 exact committed Document 的 typed + terminal。Classic WebDriver、WebDriver BiDi 和 CDP actor 都消费相同 completion,不各自重建 + renderer wait; +- attachment cutover 和 DCL gate 使用不同 command policy。document-bound Runtime/DOM/CSS 等命令 + 在 renderer attachment suspended 时仍等待 replacement commit;persistent configuration/control + 命令可跨过 suspension。commit 之后,`Runtime.evaluate` 等命令可访问仍为 `loading` 的当前 + Document;当前只有依赖 DCL PageState title projection 的 `Page.getNavigationHistory` 等待 typed + continuation。这个拆分由 parser-blocking WebSocket CDP 回归锁住,不能再把宽泛的 + `waits_for_document_navigation_to_finish` 直接复用为 DCL gate; +- prepared commit configuration 会在 author script 前安装 document-start preload、isolated world + 和 Runtime binding。browser-internal bootstrap script 使用专门的 Inspector execution path:它不 + 触发 instrumentation pause,并使用真实 replacement origin;author script 仍按普通 Debugger + policy 发布和暂停。这避免为“先配置后执行”重放一份 Document 或临时关闭 debugger; +- typed PageState 应用后刷新 browser-owned current history entry 的 title,并基于 exact target + delta 生成 `Target.targetInfoChanged`。active、background、inactive/parked target 都沿用 target + owner identity;旧 loader 的 completion 即使稍后到达也只会被消费和丢弃,不能改写当前 title; +- protocol-neutral direct command 测试不再以“pending queue 暂时为空”代替 load。Navigate、Reload + 和 TraverseHistory 的 `wait: Load` 路径从 command result 提取 exact loader,注册 + `RendererDocumentLifecycleMilestone::Load` waiter,驱动 typed scheduler input 到 `Reached` 后再 + 完成对应 main-Document load residence。child-frame navigation、input、`Target.createTarget`、 + Classic 和 BiDi lane 因而验证的是同一 lifecycle invariant,而不是某次 executor 恰好先跑完; +- `TestContext` 保留 production-shaped owner ordering:队首若是 fixture-only、缺少 background owner + lane 的 popup action,可以让另一个 target 的 ready work 越过;同一 command 的 follow-up 也可 + 越过尚未 ready 的旧 load residence。这个有限规则使 parser script 的 `location` successor 能 + 接管 owner,源 DCL 被压制;它不是任意 drain/retry,也没有加入 sleep 或无限 polling。 + +本纵切的回归矩阵覆盖:stable Page background DCL→load continuation、parser script replacement +及独立 response gate、preload/world/binding 先于 author script、Debugger instrumentation +pause/resume、Fetch auth/response-stage、target discovery 与 title delta、browser-session target +route、auto-attach owner、Playwright script execution disable、history back/forward、popup +create/navigate/close、child-frame protocol-neutral navigation、direct input、worker/AudioWorklet +owner continuation,以及 Classic/BiDi completion routing。 + +聚焦验证证据: + +```bash +cargo nextest run -p lightmount-protocol --no-fail-fast \ + --success-output never --status-level fail --final-status-level fail --show-progress none +# 3295 passed + +cargo nextest run -p lightmount-protocol-cdp --no-fail-fast +# 8 passed + +cargo nextest run -p lightmount \ + websocket_cdp_raw_client_runtime_evaluate_immediately_after_page_navigate_succeeds \ + websocket_cdp_runtime_control_command_waits_for_navigation_attachment_cutover \ + websocket_cdp_runtime_evaluate_uses_committed_page_while_parser_blocking_source_is_pending \ + --no-fail-fast +# 3 passed;同时锁住 pre-commit cutover 和 post-commit loading Document 两侧 + +cargo nextest run --no-fail-fast +# 15884 passed,18 skipped + +cargo fmt --all --check +# passed + +cargo clippy --workspace --all-targets -- -D warnings +# passed +``` + +raw-CDP 立即 evaluate 回归不再把 body 已解析当作 attachment commit 的必要条件:它验证新 +Document URL 和合法 readyState;若 readyState 已越过 `loading`,才要求 body link 已存在。这样 +workspace 高并发下 continuation 尚未跑完不再被误判为 `NoDocumentLoaded`,而真正绑定旧 +`about:blank` 或 attachment 尚不可用仍会失败。 + +以上是 rebase 前结果;若 rebase 改变 Rust 基线,必须在 rebase 后重复,而不能沿用这组结果。 + +本纵切仍不是 Phase 4 完成标志。除上一节列出的 network error、named/`noopener`/`javascript:` +矩阵外,target admission 前已经排队的 timer/fetch/dialog/Inspector output、`window.close()` 与 +target close 的单一 transaction,以及 completion lane 关闭时的 production teardown 诊断仍需 +后续切片完成。exact continuation 只建立了这些行为可依赖的 owner/lifecycle 基础,没有替它们 +定义产品语义。 + +#### Phase 4 第五纵切 E:Fetch response-stage effective response terminal + +第三纵切 C 已经证明直接网络 204/205 必须保留 popup initial Document,但当 response head 被 +Fetch 拦截后,决定 navigation terminal 的不再只是服务器原始状态,而是 DevTools action 释放的 +effective response。这个入口不能只补一条“原始 204 继续后仍失败”的容易路径;必须同时证明 +override 能把可提交响应变成 no-commit,也能把原始 no-commit 响应变回可提交响应,否则实现仍 +可能在原始 head 或 synthetic body 的某一侧过早作出不可逆决定。 + +Chromium 的责任链给出了明确合同: + +- `third_party/blink/public/devtools_protocol/domains/Fetch.pdl` 要求 + `continueResponse` 修改 status 或 headers 时同时给出两者;全都省略则沿用原始 response head; +- `content/browser/devtools/protocol/fetch_handler.cc::ContinueResponse` 在完整 override 时直接复用 + `FulfillRequest` 构造新的 HTTP response head,不带 override 时才原样 continue; +- `content/browser/devtools/devtools_url_loader_interceptor.cc` 把 override 安装到下游可见的 + `URLResponseHead`,保留原 body 或替换 synthetic body。因而后续 navigation 看到的是 effective + status/header,而不是一份只供 Network domain 展示的旁路 metadata; +- `content/browser/renderer_host/navigation_request.cc` 在处理这份 response head 时把 204/205 判为 + `response_should_be_rendered_ == false`,设置 `net::ERR_ABORTED` 并终止而不 commit; +- `content/browser/devtools/protocol/page_handler.cc::NavigationReset` 最终从同一个 + `NavigationRequest` 读取 net error,`Page.navigate` 因而返回 `errorText: net::ERR_ABORTED`。这也 + 解释了为何不能先发 success,再在 Network domain 单独把请求标成 canceled。 + +本轮矩阵第一次运行确实发现了后一类分裂。streaming/captured 网络 builder 已在创建 replacement +Page 前识别 204/205,但 response-stage `Fetch.fulfillRequest` 使用的 +`build_navigation_from_buffered_body_source_with_load_inputs_async` 自己重复了一份 Page reservation +和 prepared-document construction,直接把 synthetic response 包成 `ResponseCommitReady`。当原始 +200 被 fulfill 为 204 时,旧实现会同时发布 `Network.responseReceived(status=204)`、成功的 +`Page.navigate`、`Page.frameNavigated`、`DOM.documentUpdated` 和 DCL;Network projection 与真实 +Document owner 互相矛盾。 + +修复没有在 Fetch command handler 追加 204/205 特判,而是删除这份重复 construction。buffered +body source 现在先构造 typed `ResponseHead`,再委托既有 +`build_navigation_from_captured_raw_response_with_load_inputs_async`,由一个公共边界依次分类 +no-commit、download 和 committable response。这样 classification 发生在 renderer Page reservation +之前;已有 200 response-stage prepared candidate 在 synthetic 204 terminal 被丢弃,原始 204 则 +从未创建 candidate,而 synthetic 200 仍可在释放 pause 后创建并提交新 Document。最终路径是: + +```text +PausedDocumentTransfer + -> fulfill synthetic head/body OR continue original/overridden head + original body + -> captured/streaming effective-response classifier + -> 204/205: NavigationLoadOutcome::NoCommitResponse + -> attachment: NavigationLoadOutcome::Download + -> otherwise: ResponseCommitReady + -> one shared materialized navigation terminal +``` + +新增集成矩阵如下;四格都从已经安装的 stable Page/Document 开始,并使用真实 response-stage +`Fetch.requestPaused`: + +| 原始状态 | terminal action | effective 状态 | 预期结果 | +| --- | --- | --- | --- | +| 200 | `Fetch.fulfillRequest` | 204 | `ERR_ABORTED`,保留旧 Document/realm | +| 200 | `Fetch.continueResponse` 完整 override | 205 | `ERR_ABORTED`,保留旧 Document/realm | +| 204 | `Fetch.continueResponse` 无 override | 204 | `ERR_ABORTED`,保留旧 Document/realm | +| 204 | `Fetch.fulfillRequest` | 200 | 恰好一次新 Document commit | + +前三格共同断言 effective `responseReceived` 先于同 request id 的 +`loadingFailed(canceled=true)`,不存在 `loadingFinished`、`frameNavigated`、DOM update、DCL/load; +target Page residence、renderer Page residence、attachment、renderer agent、HTML 和 pending +navigation 状态全部保持。第四格反向断言 `responseReceived(200)`、`loadingFinished`、唯一 +`frameNavigated`、DOM update、DCL/load 和 synthetic body,同时 stable Page/attachment 不变而 +Document agent 必须变化。它还锁住原始 204 pause 不预建 renderer agent,避免未来为了优化 +response-stage 又按原始 head 提前终止。第三纵切的真实 popup 204/205→fragment/pushState→redirect +用例继续负责 popup initial realm/history;本纵切在公共 Fetch/navigation 边界补入口矩阵,两者 +组合覆盖 popup 与普通 stable Page,而不复制一份更大的 popup scenario。 + +聚焦验证: + +```bash +cargo nextest run -p lightmount-protocol \ + response_stage_effective_no_content_statuses_abort_without_committing +# 1 passed + +cargo nextest run -p lightmount-protocol \ + response_stage_fulfill_can_replace_original_no_content_with_committable_response +# 1 passed + +cargo nextest run -p lightmount-protocol \ + -E 'test(/(continue_response_can_override_status_and_headers|fulfill_request_completes_navigation_with_synthetic_response|popup_no_commit_responses_preserve_initial_document_before_redirect_replacement)/)' +# 4 passed(同时命中一条同名 subresource override 回归) + +cargo nextest run --no-fail-fast +# 15886 passed,18 skipped + +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +# both passed +``` + +本纵切补齐的是 204/205 effective-response interception terminal,不改变普通 +DNS/connect/TLS/HTTP failure 的 Document/error-page policy,也不扩大到 named target、`noopener`、 +`javascript:` 或 close transaction;这些仍是 Phase 4/5 后续切片。 + +#### Phase 4 第六纵切 F:pre-response transport failure 的 browser-owned error Document + +第三纵切 C 和第五纵切 E 已经把“收到一个不可提交的 HTTP response”定义为保留旧 Document 的 +no-commit terminal,但普通 DNS/connect/reset 等 transport failure 是另一类行为:请求尚未得到 +可渲染 response,Chromium 会提交一个新的 browser-owned error Document。旧 Lightmount 路径把 +`NetworkFetchFailure` 直接 materialize 为 failed navigation,并使 target 的旧 Document 不再可用; +popup initial `about:blank` 因而既没有 Chromium 的错误页,也不能继续通过原 stable Page/WindowProxy +观察新 realm。这个差异同时影响 `Page.navigate`、Target/history、Network terminal、Runtime realm +和 popup opener,不能只在错误返回字符串处补一个 HTML。 + +本纵切先明确一个窄而可验证的范围:普通 top-level main-document fetch 在 response metadata 之前 +返回 `NetworkFetchFailure`。这里包括本轮连接接受后立即断开的最小复现,以及沿用同一 typed failure +的常见 DNS/connect 类错误。它不把证书 interstitial、HTTP 4xx/5xx、proxy CONNECT、显式 offline / +blocked URL、request-stage `Fetch.failRequest`、continued interception transport failure 或 policy +block 混成同一种产品语义;这些入口必须各自先确定 Chromium terminal,再决定是否复用 error +Document primitive。 + +##### Chromium 源码与二进制证据 + +对照基线为本地 `/home/donoughliu/chromium/src` commit +`a03603fe9af6230a12f1b2fb2c18a7d003a0d937`,`out/Default/chrome --version` 为 +`Chromium 147.0.7709.0`。运行时 probe 使用: + +```bash +/home/donoughliu/chromium/src/out/Default/chrome \ + --headless=new --disable-gpu --no-sandbox \ + --remote-debugging-port=9229 \ + --user-data-dir=/tmp/lightmount-chromium-network-error-probe \ + --noerrdialogs --no-first-run --ozone-platform=headless \ + --ozone-override-screen-size=800,600 --use-angle=swiftshader-webgl \ + about:blank +``` + +CDP probe 分别执行普通 `Page.navigate` 和 `window.open()`,目标是一个接受 TCP 后不返回 HTTP head +便关闭连接的本地 server。它记录 Target/Page/Network/Runtime 事件,并在 popup opener 中保留同步 +返回的 WindowProxy 引用。观察结果如下: + +| 可观察面 | Chromium 147 结果 | +| --- | --- | +| browsing context | frame id、target id 和 popup opener relation 保持;不是销毁 target 后另建错误页 target | +| Document URL | `location.href` / `Page.frameNavigated.frame.url` 为 `chrome-error://chromewebdata/` | +| 请求 URL | `Page.frameNavigated.frame.unreachableUrl`、Target URL 和当前 history entry 仍是失败 URL | +| Page.navigate | success-shaped callback,包含原 loader/frame identity、`errorText` 和 `isDownload: false` | +| Network | 同一 request id 上 `requestWillBeSent → loadingFailed → loadingFinished`,没有 `responseReceived` | +| realm/lifecycle | 旧 global/Document 状态消失,新 execution context 可执行;随后才有 DCL 和 load | +| popup initial history | error Document 替换 initial entry,`history.length == 1` | +| opener | popup 新 realm 中 `window.opener !== null`;opener 保存的 WindowProxy identity 不变 | + +`loadingFailed` 后同 request id 又出现 `loadingFinished(encodedDataLength=0)` 看起来反直觉,但这是本地 +Chromium 二进制的实际协议序列;实现和测试保留该事实,不用“一个请求只能有一个 terminal”的内部 +直觉改写 CDP。probe 中 frame commit 位于二者之间,DCL/load 位于 `loadingFinished` 之后。 + +源码责任链与运行时结果一致: + +- `content/browser/renderer_host/navigation_request.cc::CommitErrorPage` 仍走一次 cross-document + commit,并通过 `ShouldReplaceCurrentEntryForFailedNavigation()` 决定 history replacement;initial + entry 必须被替换; +- `content/renderer/render_frame_impl.cc::FailedNavigation` 把 Document URL 设置为 + `content::kUnreachableWebDataURL`,再把失败 URL 写入 `WebNavigationParams::unreachable_url`;同文件 + 明确说明 HistoryItem 使用 unreachable URL,而不是内部错误页 URL; +- `content/public/common/url_constants.h` 将该内部 URL 定义为 + `chrome-error://chromewebdata/`;它不是一个可当作普通 WebUI 导航的 `chrome://` 页面; +- `third_party/blink/public/web/web_navigation_params.h` 和 + `third_party/blink/renderer/core/loader/document_loader.*` 把 unreachable URL 保存在 DocumentLoader + 上,供 frame/DevTools projection 使用; +- `content/browser/devtools/protocol/page_handler.cc::DispatchNavigateCallback` 从同一个 + `NavigationRequest::GetNetErrorCode()` 生成 `errorText`,所以不能先返回普通 success,再只在 + Network domain 标记失败; +- `third_party/blink/web_tests/inspector-protocol/page/frameNavigatedToUnreachableUrl.js` 直接锁住 + `frameNavigated.frame.unreachableUrl`,browser tests 还检查 error Document 的内部 URL和 opaque + origin。 + +##### Lightmount owner transaction + +实现复用已经成熟的 stable Page replacement/realm 基础,不创建第二个 Page,也不恢复 lightweight +popup loader。普通 pre-response failure 现在走: + +```text +NetworkFetchFailure(original request / request id / net error) + -> browser-owned NetworkErrorPageNavigation + { error_text, unreachable_url } + -> synthetic internal ResponseHead + { final_url = chrome-error://chromewebdata/, status = 200, text/html } + -> existing prepared replacement reservation + -> DocumentCommit on the exact stable Page + -> detach old default realm, keep Page-owned WindowProxy + -> install error Document realm with opaque/insecure security state + -> Page.frameNavigated(url = internal, unreachableUrl = requested) + -> exact renderer DCL/load continuation +``` + +这里必须区分三种 URL,不能再用一个 `final_url` 同时满足所有观察面: + +| owner / projection | 本纵切保存的 URL | 原因 | +| --- | --- | --- | +| renderer `Page` / Document / frame tree | `chrome-error://chromewebdata/` | 当前可执行 realm 确实是 error Document | +| `unreachableUrl` | transport failure 的 current request URL | DevTools 需要知道哪个资源不可达 | +| Target identity / browser history | transport failure URL | 地址栏、Target、history 代表用户请求,而不是内部实现 URL | + +`RendererMainDocumentCommit` 因而新增可选 `unreachable_url`,frame commit 和 `Page.getFrameTree` / +resource tree 都从 Document identity 投影内部 URL和 unreachable URL;Target/history commit API 则显式 +接收另一份 browser-visible URL。history snapshot 不再从 `Page.final_url()` 反推地址栏 URL,避免 +后续 title refresh 把 error entry 偷换成内部 URL。error Document 使用 opaque origin,CDP frame / +Target security origin 投影为 `://`,secure-context type 为 `InsecureScheme`。 + +内部错误 HTML 只提供轻量、可脚本化、可诊断的 Document:title 使用失败 host,正文展示转义后的 +URL和 net error。它通过与普通 response 相同的 parser、realm、DCL/load 和 PageState owner 路径 +构建,但不是原网络请求的 response:Network domain 不发布 `responseReceived`,也不把 synthetic +body 存进 main-resource response-body cache。这样 `Runtime.evaluate`、DOM snapshot 和 lifecycle +都能观察真实新 Document,同时 `Network.getResponseBody` 不会伪装服务器返回了一份 200 HTML。 + +Network progress 使用一个专门的 two-boundary gate,而不是在 commit 调用点手排 JSON: + +```text +response-visible boundary -> loadingFailed(errorText, canceled=false) +renderer output boundary -> frame commit / contexts cleared + created +body-finished boundary -> loadingFinished(encodedDataLength=0) +DCL/load continuation -> DOMContentLoaded / load / stoppedLoading +``` + +`Page.navigate` result 在 failure progress 可见后返回 `{frameId, loaderId, errorText, +isDownload:false}`。无显式 navigate command 的 popup initial destination 仍消费相同 activity,只是不 +制造 command response。active、background 和 stable replacement 共用这一 transaction;error +Document 的 main resource 不进入普通 response store,旧 loader/generation 的 completion 也仍受 +existing Document token/currentness gate 限制。 + +##### Stable WindowProxy、realm 与 opener + +popup 回归第一次运行暴露了一个比 Network 更底层的真实缺口:related auxiliary Page 已经复用 +stable main WindowProxy,但 `window.opener` 只存在于旧 realm 的 `WINDOW_OPENER_SLOT`; +`detach_global()` 后新 error realm 得到 `null`。修复没有从 protocol `TargetInfo.openerId` 反向制造 +JS object。`RendererPageScriptEnvironment` 现在在旧 main realm commit 前捕获该槽中的实际 V8 value, +在同一 stable WindowProxy 绑定新 Context、完成 bootstrap 后再恢复。于是: + +- target/core Page residence 和 renderer Page/WindowProxy residence 跨失败保持; +- renderer attachment、execution context、global lexical state 和 Document generation 必须变化; +- popup 新 error realm 仍有 `window.opener`,旧 realm/body marker 不会泄漏; +- opener 同步保存的两个 popup WindowProxy 引用在失败前后仍严格相等; +- 保存的是实际槽值而不是一条 target id,未来 opener 被显式 sever 为 `null` 时也不会被导航重新连上。 + +同一回归也精确暴露了下一层边界:从 opener 读取已跨源 popup WindowProxy 的 `.closed` 目前仍得到 +`SecurityError`。这不是 identity 或 opener graph 丢失,而是 top-level related Page 尚未接入 child +frame 已有的 restricted cross-origin access surface。测试因此用导航前保存的两个引用做 strict +identity 检查,并单独验证 popup realm 的 `opener !== null`;它没有把 `.closed` 硬编码成 `false`。 +`closed`、`postMessage`、`location`、identity aliases、descriptor/enumerator 与 close 后动态状态应在 +Phase 5 作为一个完整 WindowProxy whitelist 纵切复用 child-frame primitive,不能逐属性开洞。 + +##### 回归矩阵与当前边界 + +新增/扩展回归覆盖: + +- Page domain:普通 navigate failure 提交内部 error frame、`unreachableUrl` 和 requested-URL + history; +- Network domain:`requestWillBeSent < loadingFailed < frame commit < loadingFinished < DCL/load`, + 同 request id、无 `responseReceived`,并锁住 response/body 两个 progress boundary; +- Runtime:error Document 可执行,旧 global 不存在,stable target/core/renderer Page identity 保持而 + renderer attachment 更新; +- popup end-to-end:真实 connection drop、initial `about:blank` replacement、history length 1、 + Target opener metadata、new-realm opener 和 opener-side stable WindowProxy identity; +- Page frame/resource tree:Document URL 与 Target/history URL 不再互相覆盖。 + +本轮聚焦验证与全量门禁: + +```bash +cargo nextest run -p lightmount-protocol navigate_failure --no-fail-fast +# 2 passed + +cargo nextest run -p lightmount-protocol main_document_navigation_failure --no-fail-fast +# 2 passed + +cargo nextest run -p lightmount-protocol \ + page_navigate_network_failure_commits_error_document_in_stable_page --no-fail-fast +# 1 passed + +cargo nextest run -p lightmount-protocol \ + error_page_progress_releases_failed_before_finished_at_separate_boundaries --no-fail-fast +# 1 passed + +cargo nextest run -p lightmount-protocol \ + popup_transport_failure_commits_error_document_in_stable_auxiliary_page --no-fail-fast +# 1 passed + +cargo nextest run --no-fail-fast +# 15888 passed,18 skipped + +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +# both passed +``` + +以上矩阵仍不足以宣称“所有网络错误完成”。下一批必须至少分别覆盖 redirect-then-drop、DNS、TLS +证书/interstitial、proxy、offline/blocked policy、Fetch request-stage fail/continue、reload/traverse +到 error entry,以及 error page 上的再次导航。尤其 redirect failure 的 Network redirect metadata、 +error HistoryItem method/state 和 `Network.getResponseBody` 错误形状目前证据较弱。named target、 +`noopener` / COOP、full cross-origin WindowProxy surface 和 close transaction 仍按 Phase 5 处理。 + ### Phase 5:name、opener、cross-origin、sandbox 与 COOP - group-level named target registry 和 related Page 查找; @@ -1167,9 +2493,10 @@ cargo clippy --workspace --all-targets -- -D warnings ## 风险与停止条件 -### 最高风险:当前 per-page-isolate policy +### 最高风险:fresh-by-default / selective-related script-agent policy -`PageVm` / `RendererPageScriptEnvironment` 当前明确按 Page 持有 isolate。已有大量 +`PageVm` / `RendererPageScriptEnvironment` 默认仍为每个 fresh Page 建立 agent;只有显式 +related auxiliary admission 才共享 isolate。已有大量 `per_page_isolate_policy_*` 测试保护: - isolate/context 隔离; @@ -1192,7 +2519,10 @@ Phase 3 必须在已经验收的 selective agent 基础上确定安全的 `Pendi 多个 target 共享 isolate 后,inspector backend、execution context id 和 remote object registry 很容易错误地按 isolate 全局化。任何一个 target 能 evaluate/release 另一个 -target 的 object 都是阻断问题。 +target 的 object 都是阻断问题。pause loop 是 isolate 级,但 target close、Page output +journal、queued command 和 frontend session 必须按 renderer agent/Page 路由;Phase 3 +第一纵切已修正 close/command/session 路由,跨 target remote-object 隔离仍由 Phase 2B +矩阵保护。 ### COOP / agent split 风险 @@ -1224,9 +2554,9 @@ group/agent split 必须新建 realm并把旧 WindowProxy 切到 disconnected/re 多 Page realm 的 script agent;第一版共享 isolate,后续可演化 remote endpoint。 5. popup 仍是独立 PageVM/CDP target,可在 opener 关闭后存活。 6. `noopener` / COOP 是 group/agent/endpoint policy,不只是 `window.opener = null`。 -7. Phase 2B selective shared-agent 实验已经通过;production 仍保留 per-Page 默认隔离, - 只允许 Phase 3 通过显式 browsing relationship admission 打开窄路径,不恢复 - renderer-owner-wide sharing,也不先做大范围 popup 迁移。 +7. Phase 2B selective shared-agent 实验已经通过;Phase 3 第一纵切只为 renderer 明确 + 新建的 auxiliary context 打开 production relationship admission。fresh Page 与 + `noopener` 仍隔离,不恢复 renderer-owner-wide sharing,也不先做大范围 popup 迁移。 8. 最终删除 lightweight popup 专用 loader/parser/script/realm alias,避免长期双栈。 ## 相关文档 diff --git a/moli-core/src/page/mod.rs b/moli-core/src/page/mod.rs index f11943d511..14c13695d9 100644 --- a/moli-core/src/page/mod.rs +++ b/moli-core/src/page/mod.rs @@ -35,7 +35,6 @@ use std::sync::Arc; use crate::renderer::{ RendererPageCommand, RendererPageCommandPending, RendererPageHandle, RendererPageReply, - RendererPageState, }; use anyhow::Result; pub use command_dispatch::{ @@ -88,7 +87,8 @@ pub use moli_renderer_v8::{ RendererCaptureScreencastFrameRequest, RendererCaptureScreenshotReply, RendererCaptureScreenshotRequest, RendererCommandTurnCompletion, RendererCommandTurnOutput, RendererDedicatedWorkerTargetEvent, RendererDedicatedWorkerTargetInfo, - RendererDevToolsAgentToken, RendererDocumentHitTestResult, + RendererDevToolsAgentToken, RendererDocumentContinuationCompletion, + RendererDocumentContinuationObserver, RendererDocumentHitTestResult, RendererDocumentIsolateAccountingDiagnostics, RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, RendererDocumentLifecycleSnapshot, @@ -108,7 +108,9 @@ pub use moli_renderer_v8::{ RendererMainDocumentCommit, RendererPageCommandPostResponseContinuation, RendererPageCreationArtifacts, RendererPageCreationDiagnostics, RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, RendererPageDumpOptions, - RendererPageDumpStripOptions, RendererPendingDownloadActivation, + RendererPageDumpStripOptions, RendererPageReplacementCommitError, + RendererPageReplacementCommitFailureDisposition, RendererPageReplacementReservationPending, + RendererPageState, RendererPendingAuxiliaryPage, RendererPendingDownloadActivation, RendererPendingDownloadResponse, RendererPendingFileChooserActivation, RendererPendingJavaScriptDialog, RendererPendingPopupActivation, RendererPendingSameDocumentNavigation, RendererPendingTopLevelHistoryTraversal, @@ -207,6 +209,14 @@ impl fmt::Debug for Page { } impl Page { + #[doc(hidden)] + pub fn apply_renderer_document_continuation_state( + &mut self, + page_state: Arc, + ) { + self.replace_page_state(page_state); + } + pub(crate) fn from_attached_handle( handle: RendererPageHandle, page_state: Arc, @@ -234,6 +244,27 @@ impl Page { } } + pub(crate) fn adopt_renderer_page_replacement( + &mut self, + replacement: moli_renderer_v8::RendererPageReplacementCommit, + ) -> Result<( + RendererPageCreationDiagnostics, + RendererPageCreationArtifacts, + Option, + )> { + self.handle.adopt_page_replacement(&replacement)?; + let ( + _renderer_devtools_agent_token, + page_state, + creation_diagnostics, + creation_artifacts, + pending_download, + ) = replacement.into_parts(); + self.page_state.replace(page_state); + self.page_creation_artifacts = None; + Ok((creation_diagnostics, creation_artifacts, pending_download)) + } + /// Takes the renderer lifecycle facts captured while this page was created. /// /// Direct-fetch callers can retain these facts until the page is handed to @@ -264,6 +295,22 @@ impl Page { self.renderer_devtools_command_session_id = session_id; } + #[doc(hidden)] + pub async fn reserve_renderer_document_replacement( + &self, + ) -> Result { + self.handle + .reserve_replacement_document_for_navigation() + .await + } + + #[doc(hidden)] + pub fn start_renderer_document_replacement_reservation( + &self, + ) -> RendererPageReplacementReservationPending { + self.handle.start_replacement_document_reservation() + } + #[doc(hidden)] pub fn renderer_agent_attachment_id(&self) -> Option { self.renderer_agent_attachment_id diff --git a/moli-core/src/runtime/mod.rs b/moli-core/src/runtime/mod.rs index 0df1a3e783..faf830530e 100644 --- a/moli-core/src/runtime/mod.rs +++ b/moli-core/src/runtime/mod.rs @@ -44,10 +44,10 @@ pub use moli_renderer_v8::{ RendererServiceWorkerMainResourceFetch, RendererSharedWorkerRuntimeDiagnostics, }; pub use navigation_engine::{ - BuiltDocumentPage, CommittedDocumentResourceSource, NavigationEngine, - NavigationPageStorageHandles, NavigationResourceStorageHandles, NavigationRuntimeConfig, - PendingBuiltDocumentPage, PreparedDocumentPage, PreparedDocumentPageCommitConfiguration, - PreparedDocumentPageCommitPermit, + BuiltDocumentPage, CommittedDocumentPageReplacement, CommittedDocumentResourceSource, + NavigationEngine, NavigationPageStorageHandles, NavigationResourceStorageHandles, + NavigationRuntimeConfig, PendingBuiltDocumentPage, PreparedDocumentPage, + PreparedDocumentPageCommitConfiguration, PreparedDocumentPageCommitPermit, }; static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1); @@ -1102,6 +1102,7 @@ impl Browser { source, world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }) .collect::>(); @@ -1230,6 +1231,7 @@ impl Browser { source, world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }) .collect::>(); diff --git a/moli-core/src/runtime/navigation_engine.rs b/moli-core/src/runtime/navigation_engine.rs index 6bb9cfd682..35b12e6446 100644 --- a/moli-core/src/runtime/navigation_engine.rs +++ b/moli-core/src/runtime/navigation_engine.rs @@ -188,6 +188,12 @@ pub struct BuiltDocumentPage { pub pending_download: Option, } +pub struct CommittedDocumentPageReplacement { + pub page_creation_diagnostics: RendererPageCreationDiagnostics, + pub page_creation_artifacts: RendererPageCreationArtifacts, + pub pending_download: Option, +} + /// A renderer document whose isolate and DevTools agent are reserved, but /// whose parser and execution contexts have not started. pub struct PreparedDocumentPage { @@ -310,6 +316,41 @@ impl PreparedDocumentPage { }) } + /// Commits the prepared Document into `page` without replacing its owning + /// handle. The Page identity is checked before the renderer crosses the + /// old-Document teardown boundary. + pub async fn commit_page_replacement( + self, + permit: PreparedDocumentPageCommitPermit, + page: &mut Page, + ) -> std::result::Result< + CommittedDocumentPageReplacement, + moli_renderer_v8::RendererPageReplacementCommitError, + > { + if self.renderer_owner_local_host_id() != page.renderer_owner_local_host_id() + || self.renderer_page_id() != page.renderer_page_id() + { + return Err( + moli_renderer_v8::RendererPageReplacementCommitError::page_preserved(anyhow!( + "prepared Document belongs to a different live Page" + )), + ); + } + let replacement = self.prepared.commit_page_replacement(permit.permit).await?; + let (page_creation_diagnostics, page_creation_artifacts, pending_download) = page + .adopt_renderer_page_replacement(replacement) + .map_err(|error| { + moli_renderer_v8::RendererPageReplacementCommitError::page_retired( + error.context("failed to adopt committed live Page replacement"), + ) + })?; + Ok(CommittedDocumentPageReplacement { + page_creation_diagnostics, + page_creation_artifacts, + pending_download, + }) + } + pub async fn cancel(self) -> Result<()> { self.prepared.cancel().await } @@ -458,6 +499,13 @@ impl NavigationEngine { self.js_runtime.reserve_page_for_creation() } + pub fn owns_page_reservation( + &self, + reservation: moli_renderer_v8::RendererPageReservationToken, + ) -> bool { + self.js_runtime.owns_page_reservation(reservation) + } + pub fn new() -> Self { Self::new_with_runtime_config(NavigationRuntimeConfig::default()) } @@ -663,6 +711,10 @@ impl NavigationEngine { self.js_runtime.renderer_owner_id_for_diagnostics() } + pub fn renderer_owner_local_host_id(&self) -> moli_renderer_v8::RendererOwnerLocalHostId { + self.js_runtime.renderer_owner_local_host_id() + } + pub fn shares_renderer_owner_with(&self, other: &Self) -> bool { self.js_runtime .shares_renderer_owner_with(&other.js_runtime) @@ -2435,7 +2487,10 @@ impl NavigationEngine { #[cfg(test)] mod tests { - use super::{CommittedDocumentResourceSource, NavigationEngine, NavigationRuntimeConfig}; + use super::{ + CommittedDocumentResourceSource, NavigationEngine, NavigationRuntimeConfig, + PageVmInitStage, PreparedDocumentPage, + }; use crate::{ LayoutPolicy, OptionalResourceFetchMask, network::ResourceRequestClient, @@ -2443,6 +2498,7 @@ mod tests { }; use moli_cookie_jar::new_shared_browser_cookie_store; use moli_fetch::FetchConfig; + use tokio::sync::oneshot; use url::Url; static_assertions::assert_not_impl_any!(Browser: Send, Sync); @@ -2737,4 +2793,143 @@ mod tests { .contains("does not belong to the NavigationEngine browser resource runtime") ); } + + #[tokio::test(flavor = "current_thread")] + async fn core_page_adopts_prepared_renderer_replacement_without_replacing_ownership() { + let mut engine = NavigationEngine::new(); + let initial_url = + Url::parse("https://example.test/core-replacement/initial").expect("initial URL"); + let built = engine + .build_inline_html_document_page_best_effort_with_inspector_session_restores_async( + new_shared_browser_cookie_store(), + moli_renderer_v8::RendererWebStorageHandles::ephemeral(), + None, + None, + initial_url, + None, + "initial core Page".to_owned(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + None, + false, + 1.0, + Default::default(), + None, + false, + Vec::new(), + false, + None, + None, + None, + ) + .await + .expect("initial core Page should build"); + let mut page = built.page; + let page_id = page.renderer_page_id(); + let owner_local_host_id = page.renderer_owner_local_host_id(); + let initial_agent = page.renderer_devtools_agent_token(); + let reservation = page + .reserve_renderer_document_replacement() + .await + .expect("core Page should reserve its live renderer generation"); + let replacement_url = + Url::parse("https://example.test/core-replacement/next").expect("replacement URL"); + let (completion_tx, completion_rx) = oneshot::channel(); + let (body_tx, raw_body) = + moli_renderer_v8::ExternalRawDocumentBodyStream::channel(completion_rx); + body_tx + .send( + b"
replacement core Page
" + .to_vec(), + ) + .await + .expect("replacement body should send"); + drop(body_tx); + completion_tx + .send(Ok(())) + .expect("replacement body completion should send"); + let loader = engine + .resource_request_client() + .expect("engine loader should exist"); + let prepared = engine + .js_runtime + .prepare_streaming_raw_document_from_external_body_with_inspector_session_restores( + reservation, + replacement_url.clone(), + replacement_url.clone(), + None, + false, + 0, + 200, + vec![("content-type".to_owned(), "text/html".to_owned())], + &loader, + moli_renderer_v8::RendererWebStorageHandles::ephemeral(), + raw_body, + None, + None, + Vec::new(), + Vec::new(), + Vec::new(), + None, + None, + false, + false, + 1.0, + Default::default(), + None, + false, + Vec::new(), + false, + None, + Vec::new(), + false, + PageVmInitStage::Load, + moli_renderer_v8::RendererReplyBoundary::Stage, + moli_renderer_v8::RendererTopLevelNavigationDispatch::DelegateToBrowser, + moli_renderer_v8::RendererNavigationReplyPolicy::ReturnWithPendingNavigation, + None, + None, + None, + None, + ) + .await + .expect("core replacement should prepare"); + let replacement_agent = prepared.renderer_devtools_agent_token(); + assert_ne!(replacement_agent, initial_agent); + let prepared = PreparedDocumentPage { prepared }; + let permit = prepared.issue_commit_permit(); + let committed = prepared + .commit_page_replacement(permit, &mut page) + .await + .expect("core Page should adopt its renderer replacement"); + + assert_eq!(page.renderer_page_id(), page_id); + assert_eq!(page.renderer_owner_local_host_id(), owner_local_host_id); + assert_eq!(page.renderer_devtools_agent_token(), replacement_agent); + assert_eq!(page.final_url(), &replacement_url); + assert!(committed.pending_download.is_none()); + assert!( + committed + .page_creation_artifacts + .lifecycle_snapshot + .load + .is_some() + ); + let value = page + .evaluate_runtime_expression_async( + "document.querySelector('#core-replacement').textContent", + ) + .await + .expect("replacement core Page should evaluate through its original owner"); + assert_eq!( + value.get("value"), + Some(&serde_json::json!("replacement core Page")) + ); + page.close_async() + .await + .expect("replacement core Page should close through its original owner"); + } } diff --git a/moli-page-types/src/lib.rs b/moli-page-types/src/lib.rs index 9b272fccc4..cb6c12d799 100644 --- a/moli-page-types/src/lib.rs +++ b/moli-page-types/src/lib.rs @@ -1033,6 +1033,9 @@ pub struct DocumentStartScript { pub source: String, pub world_name: Option, pub has_bidi_channel_argument: bool, + /// Browser-owned realm bootstrap that must not appear as author script + /// execution to Inspector instrumentation breakpoints. + pub browser_internal: bool, pub bidi_channel_handoffs: Vec, } diff --git a/moli-protocol-cdp/src/wire.rs b/moli-protocol-cdp/src/wire.rs index e22e23e06e..0e36684436 100644 --- a/moli-protocol-cdp/src/wire.rs +++ b/moli-protocol-cdp/src/wire.rs @@ -232,6 +232,7 @@ pub struct CdpRendererCommandPolicy { renderer_replacement: CdpRendererCommandReplacement, renderer_replay_dispatch: CdpRendererCommandReplayDispatch, executes_page_javascript: bool, + waits_for_document_continuation: bool, } impl CdpRendererCommandPolicy { @@ -254,6 +255,10 @@ impl CdpRendererCommandPolicy { pub const fn executes_page_javascript(self) -> bool { self.executes_page_javascript } + + pub const fn waits_for_document_continuation(self) -> bool { + self.waits_for_document_continuation + } } /// Parsed inbound CDP command plus its downstream JSON representation. @@ -398,6 +403,18 @@ impl ParsedCdpCommand { pub fn renderer_replay_dispatch(&self) -> CdpRendererCommandReplayDispatch { self.renderer_policy.replay_dispatch() } + + /// Whether this command reads protocol-owned state that is finalized by + /// the committed Document's typed continuation. + /// + /// A committed renderer attachment is usable while its parser is still + /// blocked: Runtime/DOM commands must be able to observe that `loading` + /// Document. Keep this narrower than the main-thread renderer access + /// classification, which protects commands from binding to the attachment + /// being replaced. + pub fn waits_for_document_continuation_to_finish(&self) -> bool { + self.renderer_policy.waits_for_document_continuation() + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -472,6 +489,7 @@ impl RuntimeWireAction { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PageWireAction { MainThread, + NavigationHistory, Crash, Other, } @@ -487,6 +505,7 @@ impl PageWireAction { | "searchInResource" | "getLayoutMetrics" | "printToPDF" => Self::MainThread, + "getNavigationHistory" => Self::NavigationHistory, "crash" => Self::Crash, _ => Self::Other, } @@ -573,6 +592,7 @@ impl CdpRendererCommandPolicy { renderer_replacement: CdpRendererCommandReplacement::Replay, renderer_replay_dispatch: CdpRendererCommandReplayDispatch::Direct, executes_page_javascript: false, + waits_for_document_continuation: false, }; }; let domain = CdpMethodDomain::parse(domain); @@ -612,7 +632,9 @@ impl CdpRendererCommandPolicy { } } CdpMethodDomain::Page => match PageWireAction::parse(action) { - PageWireAction::MainThread => CdpRendererCommandAccess::MainThread, + PageWireAction::MainThread | PageWireAction::NavigationHistory => { + CdpRendererCommandAccess::MainThread + } PageWireAction::Crash => CdpRendererCommandAccess::Io, PageWireAction::Other => CdpRendererCommandAccess::OwnerIndependent, }, @@ -650,6 +672,8 @@ impl CdpRendererCommandPolicy { executes_page_javascript: runtime_action .is_some_and(RuntimeWireAction::executes_page_javascript) || debugger_action.is_some_and(DebuggerWireAction::executes_page_javascript), + waits_for_document_continuation: domain == CdpMethodDomain::Page + && PageWireAction::parse(action) == PageWireAction::NavigationHistory, } } } @@ -676,6 +700,7 @@ mod tests { command.renderer_access(), CdpRendererCommandAccess::MainThread ); + assert!(!command.waits_for_document_continuation_to_finish()); } #[test] @@ -690,6 +715,7 @@ mod tests { command.renderer_access(), CdpRendererCommandAccess::OwnerIndependent ); + assert!(!command.waits_for_document_continuation_to_finish()); } #[test] @@ -855,6 +881,7 @@ mod tests { command.renderer_access(), CdpRendererCommandAccess::MainThread ); + assert!(!command.waits_for_document_continuation_to_finish()); } #[test] @@ -1012,4 +1039,16 @@ mod tests { }) ); } + + #[test] + fn navigation_history_waits_for_committed_document_continuation() { + let command = + parse(r#"{"id":12,"method":"Page.getNavigationHistory","params":{},"sessionId":"s1"}"#); + + assert_eq!( + command.renderer_access(), + CdpRendererCommandAccess::MainThread + ); + assert!(command.waits_for_document_continuation_to_finish()); + } } diff --git a/moli-protocol-server/src/cdp_scheduler.rs b/moli-protocol-server/src/cdp_scheduler.rs index df16133d51..a17a4d85e4 100644 --- a/moli-protocol-server/src/cdp_scheduler.rs +++ b/moli-protocol-server/src/cdp_scheduler.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, VecDeque}, + collections::{HashMap, HashSet, VecDeque}, sync::{Arc, atomic::AtomicU64}, time::{Duration, Instant}, }; @@ -11,7 +11,8 @@ use moli_core::{ runtime::NavigationRuntimeConfig, }; use moli_protocol::{ - BackgroundNavigationCompletion, BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection, + BackgroundDocumentContinuationCompletion, BackgroundNavigationCompletion, + BackgroundNavigationGateKey, BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection, CdpInitialStoragePartition, CdpRendererCommandAccess, CdpSchedulerEvent, CdpTargetHostLifecycleObserver, CommandDispatchContext, CompletedCdpCommandDispatch, CompletedDeferredMainDocumentLoadCompletion, DeferredMainDocumentLoadCompletionOutputAction, @@ -112,6 +113,7 @@ pub(crate) enum CommandStartAction { pub(crate) struct CdpScheduler { conn: CdpConnection, + document_continuation_gate: DocumentContinuationGate, pending_navigation_background_events: VecDeque, runtime_command_output_barriers: RuntimeCommandOutputBarriers, queues: SchedulerQueues, @@ -166,6 +168,32 @@ fn append_unique_target_ids(target_ids: &mut Vec, additional: Vec, +} + +impl DocumentContinuationGate { + fn note_navigation_started(&mut self, key: BackgroundNavigationGateKey) { + self.pending.insert(key); + } + + fn note_navigation_completion_drained(&mut self, key: &BackgroundNavigationGateKey) { + if !self.pending.remove(key) { + tracing::debug!( + ?key, + "document continuation completion did not match any pending gate" + ); + } + } + + fn has_inflight_navigation_for_session_owner(&self, session_id: Option<&str>) -> bool { + self.pending + .iter() + .any(|key| key.matches_session_owner(session_id)) + } +} + #[derive(Debug, Default)] struct ForegroundNavigationNetworkBarrier { wait_for_document_response_started: bool, @@ -220,10 +248,13 @@ impl ForegroundNavigationNetworkBarrier { pub(crate) type CdpBackgroundEventReceiver = mpsc::UnboundedReceiver; pub(crate) type CdpBackgroundNavigationCompletionReceiver = mpsc::UnboundedReceiver; +pub(crate) type CdpDocumentContinuationCompletionReceiver = + mpsc::UnboundedReceiver; pub(crate) type CdpRendererPublicationReceiver = moli_core::RendererOutputTransportReceiver; pub(crate) struct CdpSchedulerEventReceivers { pub(crate) background_event_rx: CdpBackgroundEventReceiver, pub(crate) background_navigation_completion_rx: CdpBackgroundNavigationCompletionReceiver, + pub(crate) document_continuation_completion_rx: CdpDocumentContinuationCompletionReceiver, pub(crate) renderer_publication_rx: CdpRendererPublicationReceiver, } @@ -244,9 +275,42 @@ pub(crate) enum CdpSchedulerInterleavedInput { } impl CdpSchedulerEventReceivers { + pub(crate) async fn recv_navigation_completion( + &mut self, + ) -> Option { + tokio::select! { + biased; + maybe_continuation = self.document_continuation_completion_rx.recv() => { + maybe_continuation.map( + BackgroundNavigationCompletion::document_continuation, + ) + } + maybe_completion = self.background_navigation_completion_rx.recv() => { + maybe_completion + } + } + } + + pub(crate) fn try_recv_navigation_completion( + &mut self, + ) -> Option { + self.document_continuation_completion_rx + .try_recv() + .ok() + .map(BackgroundNavigationCompletion::document_continuation) + .or_else(|| self.background_navigation_completion_rx.try_recv().ok()) + } + pub(crate) async fn recv_interleaved_input(&mut self) -> Option { tokio::select! { biased; + maybe_continuation = self.document_continuation_completion_rx.recv() => { + maybe_continuation.map(|completion| { + CdpSchedulerInterleavedInput::BackgroundNavigationCompletion( + BackgroundNavigationCompletion::document_continuation(completion), + ) + }) + } maybe_completion = self.background_navigation_completion_rx.recv() => { maybe_completion.map( CdpSchedulerInterleavedInput::BackgroundNavigationCompletion, @@ -692,6 +756,7 @@ impl CdpScheduler { fn new(conn: CdpConnection) -> Self { Self { conn, + document_continuation_gate: DocumentContinuationGate::default(), pending_navigation_background_events: VecDeque::new(), runtime_command_output_barriers: RuntimeCommandOutputBarriers::default(), queues: SchedulerQueues::default(), @@ -735,6 +800,11 @@ impl CdpScheduler { scheduler .conn .set_background_navigation_completion_sender(background_navigation_completion_tx); + let (document_continuation_completion_tx, document_continuation_completion_rx) = + mpsc::unbounded_channel(); + scheduler + .conn + .set_document_continuation_completion_sender(document_continuation_completion_tx); let (renderer_publication_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); scheduler @@ -745,6 +815,7 @@ impl CdpScheduler { CdpSchedulerEventReceivers { background_event_rx, background_navigation_completion_rx, + document_continuation_completion_rx, renderer_publication_rx, }, ) @@ -1968,10 +2039,14 @@ impl CdpScheduler { } pub(crate) fn command_waits_for_navigation_flush(&self, command: &ParsedCdpCommand) -> bool { - command.renderer_access() == CdpRendererCommandAccess::MainThread + (command.renderer_access() == CdpRendererCommandAccess::MainThread && self .conn - .renderer_document_navigation_is_suspended_for_session_owner(command.session_id()) + .renderer_document_navigation_is_suspended_for_session_owner(command.session_id())) + || (command.waits_for_document_continuation_to_finish() + && self + .document_continuation_gate + .has_inflight_navigation_for_session_owner(command.session_id())) } pub(crate) fn route_background_event_around_inflight_navigation( @@ -2102,6 +2177,9 @@ impl CdpScheduler { ); } match event { + CdpSchedulerEvent::DocumentContinuationStarted { key } => { + self.document_continuation_gate.note_navigation_started(key); + } CdpSchedulerEvent::ProtocolWorkPublished { work } => { if moli_trace::cdp_nav_timing_enabled() { tracing::info!( @@ -2669,6 +2747,10 @@ impl CdpScheduler { Option, ) { let trace_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now); + if let Some(key) = completion.document_continuation_gate_key() { + self.document_continuation_gate + .note_navigation_completion_drained(&key); + } let outcome = self .conn .drain_background_navigation_completion_turn_async(completion) @@ -2711,9 +2793,10 @@ impl CdpScheduler { self.drain_background_navigation_completion(completion) .await; assert!( - renderer_output_predecessor.is_none(), - "navigation completion must use its exact insertion boundary, not a command predecessor" + renderer_output_boundary.is_none() || renderer_output_predecessor.is_none(), + "navigation completion cannot carry both an insertion boundary and a continuation predecessor" ); + let renderer_output_fence = renderer_output_boundary.or(renderer_output_predecessor); prefix.append(completion_prefix); // The completion can still carry a renderer insertion boundary. While // that boundary is projected, later publications may contain the @@ -2722,7 +2805,7 @@ impl CdpScheduler { // prefix so those later publications cannot overtake it. self.append_navigation_gate_release_before_renderer_boundary(&mut prefix); suffix.append(self.drain_background_events_around_inflight_navigation(background_event_rx)); - (prefix, suffix, renderer_output_boundary) + (prefix, suffix, renderer_output_fence) } /// Completes one navigation owner turn together with the exact concrete diff --git a/moli-protocol-server/src/cdp_scheduler/actor.rs b/moli-protocol-server/src/cdp_scheduler/actor.rs index e99d34a2e3..f3cbc618b0 100644 --- a/moli-protocol-server/src/cdp_scheduler/actor.rs +++ b/moli-protocol-server/src/cdp_scheduler/actor.rs @@ -23,11 +23,11 @@ use crate::{ use super::frontend_control::CdpFrontendControlState; use super::{ CdpBackgroundEventReceiver, CdpBackgroundNavigationCompletionReceiver, CdpCookieSnapshot, - CdpOwnerActorLifecycle, CdpRendererPublicationReceiver, CdpScheduler, - CdpSchedulerEventReceivers, CommandDispatchState, CommandDispatchStepOutput, - CommandOutputReleasePermit, CommandStartAction, CommandTaskStep, CommandTurnOutput, - ProtocolAdapterScheduler, ProtocolAdapterSchedulerAdvance, ProtocolAdapterSchedulerInput, - ProtocolOutputSequence, + CdpDocumentContinuationCompletionReceiver, CdpOwnerActorLifecycle, + CdpRendererPublicationReceiver, CdpScheduler, CdpSchedulerEventReceivers, CommandDispatchState, + CommandDispatchStepOutput, CommandOutputReleasePermit, CommandStartAction, CommandTaskStep, + CommandTurnOutput, ProtocolAdapterScheduler, ProtocolAdapterSchedulerAdvance, + ProtocolAdapterSchedulerInput, ProtocolOutputSequence, }; struct PendingRuntimeDeferredReplyState { @@ -90,6 +90,7 @@ enum SchedulerInput { struct SchedulerInputReceivers { background_event_rx: CdpBackgroundEventReceiver, background_navigation_completion_rx: CdpBackgroundNavigationCompletionReceiver, + document_continuation_completion_rx: CdpDocumentContinuationCompletionReceiver, renderer_publication_rx: CdpRendererPublicationReceiver, buffered_renderer_publications: VecDeque, ready_background_inputs_before_runtime_response: VecDeque, @@ -100,6 +101,7 @@ impl SchedulerInputReceivers { Self { background_event_rx: receivers.background_event_rx, background_navigation_completion_rx: receivers.background_navigation_completion_rx, + document_continuation_completion_rx: receivers.document_continuation_completion_rx, renderer_publication_rx: receivers.renderer_publication_rx, buffered_renderer_publications: VecDeque::new(), ready_background_inputs_before_runtime_response: VecDeque::new(), @@ -119,6 +121,12 @@ impl SchedulerInputReceivers { // A correlated response carries an exact concrete output cursor; the // cursor fence below consumes only concrete stream traffic until that // position is admitted. + while let Ok(completion) = self.document_continuation_completion_rx.try_recv() { + self.ready_background_inputs_before_runtime_response + .push_back(SchedulerInput::BackgroundNavigationCompletion( + BackgroundNavigationCompletion::document_continuation(completion), + )); + } while let Ok(completion) = self.background_navigation_completion_rx.try_recv() { self.ready_background_inputs_before_runtime_response .push_back(SchedulerInput::BackgroundNavigationCompletion(completion)); @@ -177,6 +185,13 @@ impl SchedulerInputReceivers { self.queue_ready_background_inputs_before_runtime_response(response); self.ready_background_inputs_before_runtime_response.pop_front() } + maybe_continuation = self.document_continuation_completion_rx.recv() => { + maybe_continuation.map(|completion| { + SchedulerInput::BackgroundNavigationCompletion( + BackgroundNavigationCompletion::document_continuation(completion), + ) + }) + } maybe_completion = self.background_navigation_completion_rx.recv() => { maybe_completion.map(SchedulerInput::BackgroundNavigationCompletion) } @@ -193,6 +208,13 @@ impl SchedulerInputReceivers { } else { tokio::select! { biased; + maybe_continuation = self.document_continuation_completion_rx.recv() => { + maybe_continuation.map(|completion| { + SchedulerInput::BackgroundNavigationCompletion( + BackgroundNavigationCompletion::document_continuation(completion), + ) + }) + } maybe_completion = self.background_navigation_completion_rx.recv() => { maybe_completion.map(SchedulerInput::BackgroundNavigationCompletion) } @@ -2093,11 +2115,14 @@ mod tests { let (background_event_tx, background_event_rx) = mpsc::unbounded_channel(); let (_background_navigation_tx, background_navigation_completion_rx) = mpsc::unbounded_channel(); + let (_document_continuation_tx, document_continuation_completion_rx) = + mpsc::unbounded_channel(); let (_renderer_publication_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); let mut receivers = SchedulerInputReceivers::new(CdpSchedulerEventReceivers { background_event_rx, background_navigation_completion_rx, + document_continuation_completion_rx, renderer_publication_rx, }); let (runtime_response_tx, mut runtime_response_rx) = mpsc::unbounded_channel(); diff --git a/moli-protocol-server/src/cdp_scheduler/tests.rs b/moli-protocol-server/src/cdp_scheduler/tests.rs index b372b749f9..2cecd85378 100644 --- a/moli-protocol-server/src/cdp_scheduler/tests.rs +++ b/moli-protocol-server/src/cdp_scheduler/tests.rs @@ -993,11 +993,14 @@ async fn closed_renderer_transport_fails_an_unprojected_command_fence() { let (_background_event_tx, background_event_rx) = tokio::sync::mpsc::unbounded_channel(); let (_navigation_tx, background_navigation_completion_rx) = tokio::sync::mpsc::unbounded_channel(); + let (_document_continuation_tx, document_continuation_completion_rx) = + tokio::sync::mpsc::unbounded_channel(); let (renderer_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); drop(renderer_tx); let mut receivers = super::CdpSchedulerEventReceivers { background_event_rx, background_navigation_completion_rx, + document_continuation_completion_rx, renderer_publication_rx, }; let stream = diff --git a/moli-protocol-server/src/protocol_server/tests/websocket.rs b/moli-protocol-server/src/protocol_server/tests/websocket.rs index bfc44c7187..abe2ae6d15 100644 --- a/moli-protocol-server/src/protocol_server/tests/websocket.rs +++ b/moli-protocol-server/src/protocol_server/tests/websocket.rs @@ -1990,8 +1990,10 @@ async fn websocket_cdp_raw_client_runtime_evaluate_immediately_after_page_naviga // Send Page.navigate then Runtime.evaluate back-to-back without // waiting for any lifecycle event. This mirrors what raw-CDP clients // do. The socket loop must drain the pending background navigation - // completion BEFORE dispatching Runtime.evaluate, otherwise evaluate - // would observe `NoDocumentLoaded`. + // attachment BEFORE dispatching Runtime.evaluate, otherwise evaluate + // would observe `NoDocumentLoaded`. The committed Document's parser + // continuation is deliberately independent, so its body may still be + // incomplete when evaluate runs. socket .send(WsMessage::Text( json!({ @@ -2012,7 +2014,7 @@ async fn websocket_cdp_raw_client_runtime_evaluate_immediately_after_page_naviga "method": "Runtime.evaluate", "sessionId": session_id, "params": { - "expression": "document.querySelector('#link') ? document.querySelector('#link').textContent : ''", + "expression": "JSON.stringify({ url: document.URL, ready: document.readyState, hasLink: document.querySelector('#link') !== null })", "returnByValue": true } }) @@ -2034,10 +2036,27 @@ async fn websocket_cdp_raw_client_runtime_evaluate_immediately_after_page_naviga let value = evaluate_response["result"]["result"]["value"] .as_str() .expect("string value from evaluate"); + let observed: serde_json::Value = + serde_json::from_str(value).expect("Runtime.evaluate should return serialized JSON"); assert_eq!( - value, "link", - "evaluate must observe the new document body (got `{value}`)" + observed["url"], + json!(fixture_url), + "evaluate must observe the newly committed Document: {observed}" ); + assert!( + matches!( + observed["ready"].as_str(), + Some("loading" | "interactive" | "complete") + ), + "the committed Document must expose a valid readyState: {observed}" + ); + if observed["ready"] != json!("loading") { + assert_eq!( + observed["hasLink"], + json!(true), + "a parser-complete Document must contain its body link: {observed}" + ); + } let _ = socket.close(None).await; abort_test_cdp_server(protocol_server).await; @@ -2143,9 +2162,9 @@ async fn websocket_cdp_runtime_control_command_waits_for_navigation_attachment_c let new_isolate_id = resumed["result"]["id"] .as_str() .expect("replacement Runtime.getIsolateId"); - assert_ne!( + assert_eq!( new_isolate_id, old_isolate_id, - "queued command must bind to the replacement document's renderer attachment" + "stable Page replacement must preserve its script-agent isolate while the queued command crosses the renderer attachment cutover" ); let _ = socket.close(None).await; @@ -3881,11 +3900,17 @@ document.addEventListener('readystatechange', () => { .iter() .find(|message| message["id"] == json!(7_u64)) .expect("Runtime.evaluate response"); - assert_eq!( - response["result"]["result"]["value"], - json!( - "{\"marker\":\"committed-source\",\"ready\":\"interactive\",\"deferExecuted\":false}" - ) + let observed: serde_json::Value = serde_json::from_str( + response["result"]["result"]["value"] + .as_str() + .expect("serialized Runtime.evaluate result"), + ) + .expect("Runtime.evaluate should return serialized JSON"); + assert_eq!(observed["marker"], json!("committed-source")); + assert_eq!(observed["deferExecuted"], json!(false)); + assert!( + matches!(observed["ready"].as_str(), Some("loading" | "interactive")), + "the parser may race the command after starting the deferred fetch, but load must remain pending: {observed}" ); release_defer.notify_one(); diff --git a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs index aad4a1b7fa..652d0cfcab 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs @@ -195,6 +195,18 @@ async fn handle_bidi_session_socket_local( break; } } + maybe_continuation = receivers.document_continuation_completion_rx.recv() => { + let Some(continuation) = maybe_continuation else { + break; + }; + if !actor.handle_background_navigation_completion( + &mut scheduler, + &mut receivers, + moli_protocol::BackgroundNavigationCompletion::document_continuation(continuation), + ).await { + break; + } + } maybe_completion = receivers.background_navigation_completion_rx.recv() => { let Some(completion) = maybe_completion else { break; @@ -3173,7 +3185,7 @@ async fn drain_bidi_background_navigation_before_command( ) -> Result { let mut event_sources = drain_ready_bidi_background_navigation(scheduler, receivers).await?; while scheduler.has_inflight_background_navigation() { - let Some(completion) = receivers.background_navigation_completion_rx.recv().await else { + let Some(completion) = receivers.recv_navigation_completion().await else { return Ok(event_sources); }; match scheduler @@ -3202,7 +3214,7 @@ async fn drain_ready_bidi_background_navigation( scheduler .drain_background_events_around_inflight_navigation(&mut receivers.background_event_rx), ); - while let Ok(completion) = receivers.background_navigation_completion_rx.try_recv() { + while let Some(completion) = receivers.try_recv_navigation_completion() { match scheduler .drain_background_navigation_completion_with_progress_barrier(completion, receivers) .await diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs b/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs index 274c227184..70f133b59b 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs @@ -1362,6 +1362,19 @@ async fn classic_session_runtime_loop( adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked); tokio::select! { biased; + continuation = receivers.document_continuation_completion_rx.recv() => { + let Some(continuation) = continuation else { + break; + }; + if !attached.actor.handle_background_navigation_completion( + &mut scheduler, + &mut receivers, + moli_protocol::BackgroundNavigationCompletion::document_continuation(continuation), + ).await + { + detach_bidi = true; + } + } completion = receivers.background_navigation_completion_rx.recv() => { let Some(completion) = completion else { break; @@ -1497,6 +1510,21 @@ async fn classic_session_runtime_loop( adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked); tokio::select! { biased; + continuation = receivers.document_continuation_completion_rx.recv() => { + let Some(continuation) = continuation else { + break; + }; + if scheduler + .drain_background_navigation_completion_with_progress_barrier( + moli_protocol::BackgroundNavigationCompletion::document_continuation(continuation), + &mut receivers, + ) + .await + .is_err() + { + break; + } + } completion = receivers.background_navigation_completion_rx.recv() => { let Some(completion) = completion else { break; diff --git a/moli-protocol/src/conn.rs b/moli-protocol/src/conn.rs index 917d8dce28..68826bd276 100644 --- a/moli-protocol/src/conn.rs +++ b/moli-protocol/src/conn.rs @@ -506,6 +506,7 @@ pub(crate) use output::{ pub(crate) use page_state::{LoadedNavigationPageCommit, LoadedNavigationRendererAttachmentCommit}; pub(crate) use popup_activation_work::PopupTargetActivationAction; pub(crate) use popup_navigation_work::{ + PopupTargetNavigationAuthorityState, PopupTargetNavigationClaimIdentity, PopupTargetNavigationKind, PopupTargetNavigationOwnerAction, }; pub(crate) use runtime_eval::{ @@ -526,13 +527,16 @@ pub use runtime_eval::{ pub(crate) use runtime_load::decode_data_url_response; pub(crate) use runtime_load::{ BackgroundNavigationBodyCompletionSink, BackgroundNavigationEarlyResult, - BackgroundNavigationLoadJob, CompletedInitialDocumentPageBuild, FailedInitialDocumentPageBuild, - InitialDocumentPageInstallResult, InitialDocumentPageOwner, PausedResponsePreparedDocument, - PendingInitialDocumentPageBuild, ResponseCommitReady, + BackgroundNavigationLoadJob, CommittedStablePageNavigation, CompletedInitialDocumentPageBuild, + FailedInitialDocumentPageBuild, InitialDocumentPageInstallResult, InitialDocumentPageOwner, + PausedResponsePreparedDocument, PendingInitialDocumentPageBuild, ResponseCommitReady, + StablePageNavigationCommitTarget, }; use scheduler_hooks::CdpSchedulerHooks; use scheduler_state::CdpConnectionSchedulerState; -pub use scheduler_state::{CdpRendererOwnerTurnOutcome, CdpSchedulerEvent, CdpTurnOutcome}; +pub use scheduler_state::{ + BackgroundNavigationGateKey, CdpRendererOwnerTurnOutcome, CdpSchedulerEvent, CdpTurnOutcome, +}; #[cfg(test)] pub(crate) use site_data_manager_surface::{ BrowserContextReservedSiteDataOwnerState, BrowserContextSiteDataManagerOwnerState, @@ -542,8 +546,9 @@ pub use state::{ DocumentStartScript, DownloadNavigation, EmulatedDeviceMetrics, EmulatedGeolocationOverride, EmulatedGeolocationOverrideState, EmulatedMediaOverrides, IsolatedWorldDefinition, LoadedNavigation, NavigationDispatchState, NavigationLoadOutcome, NavigationRequestLoadPolicy, - PageNavigationHistoryEntry, ParkedFetchState, ParkedNetworkArtifacts, ParkedPageSessionState, - PendingNavigationHistoryUpdate, RuntimeBindingDefinition, TargetInfo, URL_BASE, + NoCommitResponseNavigation, PageNavigationHistoryEntry, ParkedFetchState, + ParkedNetworkArtifacts, ParkedPageSessionState, PendingNavigationHistoryUpdate, + RuntimeBindingDefinition, TargetInfo, URL_BASE, }; pub(crate) use state::{ BrowserContextPageStorageHandles, BrowserContextResourceStorageHandles, @@ -1422,6 +1427,16 @@ impl CdpConnection { .set_background_navigation_completion_sender(sender); } + pub fn set_document_continuation_completion_sender( + &mut self, + sender: tokio::sync::mpsc::UnboundedSender< + crate::domains::page::BackgroundDocumentContinuationCompletion, + >, + ) { + self.scheduler_hooks + .set_document_continuation_completion_sender(sender); + } + pub fn set_renderer_publication_sender( &mut self, sender: moli_core::RendererOutputTransportSender, @@ -2321,9 +2336,13 @@ impl CdpConnection { } fn can_run_background_navigation_for_session_owner(&self, session_id: Option<&str>) -> bool { - if !self - .scheduler_hooks - .has_background_navigation_completion_sender() + if !self.scheduler_hooks.has_background_event_sender() + || !self + .scheduler_hooks + .has_background_navigation_completion_sender() + || !self + .scheduler_hooks + .has_document_continuation_completion_sender() { return false; } @@ -2332,9 +2351,13 @@ impl CdpConnection { } fn can_run_background_navigation_for_active_session(&self) -> bool { - if !self - .scheduler_hooks - .has_background_navigation_completion_sender() + if !self.scheduler_hooks.has_background_event_sender() + || !self + .scheduler_hooks + .has_background_navigation_completion_sender() + || !self + .scheduler_hooks + .has_document_continuation_completion_sender() || !self.inactive_browser_contexts.is_empty() { return false; @@ -2401,6 +2424,32 @@ impl CdpConnection { } return command_context.take_protocol_events(); } + crate::domains::page::BackgroundNavigationCompletion::DocumentContinuation( + completion, + ) => { + let gate_key = completion.document_continuation_gate_key(); + let target_id = gate_key.target_id().map(str::to_owned); + let session_id = gate_key.session_id().map(str::to_owned); + let (renderer_output_predecessor, renderer_page_state) = + completion.into_renderer_completion_parts(); + if let Some(predecessor) = renderer_output_predecessor { + command_context.set_renderer_output_predecessor(predecessor); + } + let page_state_applied = renderer_page_state.is_some_and(|page_state| { + self.apply_renderer_document_continuation_page_state(&gate_key, page_state) + }); + if page_state_applied { + let _ = self + .target_session_owner_navigation_history_snapshot(session_id.as_deref()); + if let Some(target_id) = target_id { + command_context.protocol_events_mut().extend( + self.exact_target_info_changed_event_plan_for_target_delta(&target_id) + .into_background_events(), + ); + } + } + return command_context.take_protocol_events(); + } }; let previous_none_session_owner_route = completion.navigate_session_id().is_none().then(|| { @@ -2440,6 +2489,31 @@ impl CdpConnection { protocol_events } + pub(crate) fn schedule_background_document_continuation_completion( + &mut self, + completion: crate::domains::page::BackgroundDocumentContinuationCompletion, + observer: Option, + ) -> Option { + let Some(sender) = self + .scheduler_hooks + .document_continuation_completion_sender() + else { + return observer; + }; + let key = completion.document_continuation_gate_key(); + self.scheduler_state + .push_scheduler_event(CdpSchedulerEvent::DocumentContinuationStarted { key }); + let Some(observer) = observer else { + let _ = sender.send(completion); + return None; + }; + tokio::spawn(async move { + let renderer_completion = observer.wait().await; + let _ = sender.send(completion.with_renderer_completion(renderer_completion)); + }); + None + } + pub(crate) fn replace_navigation_engine(&mut self, engine: NavigationEngine) { let engine = engine; self.apply_scheduler_senders_to_navigation_engine(&engine); @@ -2850,9 +2924,13 @@ impl CdpConnection { ); } let is_current = completion.is_current_for_connection(self); - let (token, state, navigation, engine) = completion.into_parts(); + let (token, state, navigation, engine, background_document_continuation) = + completion.into_parts(); if !is_current { crate::domains::page::push_superseded_navigation_result(out, &state); + if let Some(completion) = background_document_continuation { + let _ = self.schedule_background_document_continuation_completion(completion, None); + } return; } let navigation_session_id = state.navigate_session_id.clone(); @@ -2863,6 +2941,7 @@ impl CdpConnection { state, navigation, command_context, + background_document_continuation, ) .await; if let Some(engine) = engine { @@ -3098,11 +3177,12 @@ impl CdpConnection { "documentIsolateAccounting": document_isolate_accounting, "estimatedWorkerIsolateCount": estimated_worker_isolate_count, "estimatedLiveV8IsolateCount": estimated_live_v8_isolate_count, - "runtimeGetHeapUsageV8HeapScope": "page-vm-document-isolate", - "runtimeGetHeapUsageV8HeapIsTargetLocal": true, + "runtimeGetHeapUsageV8HeapScope": "script-agent-document-isolate", + "runtimeGetHeapUsageV8HeapIsTargetLocal": false, + "runtimeGetHeapUsageV8HeapMayIncludeRelatedPages": true, "runtimeGetHeapUsageMoliCountersScope": "target-document", - "runtimeCollectGarbageScope": "page-vm-document-isolate", - "v8ForegroundTaskWakeScope": "page-vm-document-isolate", + "runtimeCollectGarbageScope": "script-agent-document-isolate", + "v8ForegroundTaskWakeScope": "script-agent-document-isolate", "v8ForegroundTaskWakeContextGroupIdAvailable": false, "v8ForegroundTaskWakeInternalPolicy": "page-runtime-queue-and-owner-page-tick", "v8ForegroundTaskWakeExternalPolicy": "page-owner-runtime-wake", diff --git a/moli-protocol/src/conn/browser_context/target_session_owner.rs b/moli-protocol/src/conn/browser_context/target_session_owner.rs index 76cb20e9b6..fb5e056eb0 100644 --- a/moli-protocol/src/conn/browser_context/target_session_owner.rs +++ b/moli-protocol/src/conn/browser_context/target_session_owner.rs @@ -1,11 +1,11 @@ use super::session_owner::TargetSessionOwner; use super::*; use crate::conn::state::{ - BrowserContextPageStorageHandles, BrowserContextResourceStorageHandles, DevToolsSessionState, - PageNavigationHistoryEntry, RendererMainDocumentCommitSeed, TargetFetchConfig, - TargetNetworkPolicyState, TargetOwnerState, TargetPageAbsenceReason, - TargetPageResidenceIdentity, TargetRuntimeSessionState, TargetRuntimeSlot, - page_bypass_csp_enabled_for_devtools_sessions, + BrowserContextPageStorageHandles, BrowserContextResourceStorageHandles, + CommittedRendererAgentAttachment, DevToolsSessionState, PageNavigationHistoryEntry, + RendererMainDocumentCommitSeed, TargetFetchConfig, TargetNetworkPolicyState, TargetOwnerState, + TargetPageAbsenceReason, TargetPageResidenceIdentity, TargetRuntimeSessionState, + TargetRuntimeSlot, page_bypass_csp_enabled_for_devtools_sessions, prepare_renderer_call_replacements_for_devtools_sessions, runtime_bindings_for_renderer, }; use crate::conn::{ @@ -205,6 +205,10 @@ pub(crate) struct TargetNavigationLoadInputs { } impl TargetNavigationLoadInputs { + pub(crate) fn has_main_document_commit_seed(&self) -> bool { + self.main_document_commit_seed.is_some() + } + pub(crate) fn with_main_document_commit_seed( mut self, seed: RendererMainDocumentCommitSeed, @@ -2242,6 +2246,31 @@ impl<'a> TargetSessionOwnerMut<'a> { } } + pub(super) fn resolve_no_commit_response_navigation_history(&mut self) -> Option<()> { + match self { + Self::ActiveTarget { + browser_context, .. + } => { + browser_context + .active_target + .owner_state + .resolve_no_commit_response_navigation_history(); + Some(()) + } + Self::BackgroundTarget { + browser_context, + target_id, + .. + } => { + browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { + owner_state.resolve_no_commit_response_navigation_history() + }); + Some(()) + } + Self::NoLoadedBrowserContext => None, + } + } + pub(super) async fn commit_loaded_navigation_page_async( &mut self, mut page: Page, @@ -2385,6 +2414,102 @@ impl<'a> TargetSessionOwnerMut<'a> { Self::NoLoadedBrowserContext => None, } } + + pub(super) fn commit_loaded_navigation_document_replacement( + &mut self, + renderer_attachment_commit: CommittedRendererAgentAttachment, + history_url: &Url, + ) -> Option> { + match self { + Self::ActiveTarget { + browser_context, .. + } => { + if let Some(target_id) = browser_context.active_target_id_owned() { + browser_context.mark_target_initial_empty_document_exited(&target_id); + } + Some( + browser_context.commit_loaded_navigation_document_replacement( + renderer_attachment_commit, + history_url, + ), + ) + } + Self::BackgroundTarget { + browser_context, + target_id, + .. + } => { + let primary_session_id = browser_context + .background_target(target_id)? + .session_id() + .map(str::to_owned); + let page_snapshot = browser_context + .background_target(target_id)? + .loaded_page() + .map(|page| (history_url.to_string(), page.document_title()))?; + browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { + owner_state.mark_initial_empty_document_exited(); + owner_state.record_loaded_page_navigation_history(page_snapshot); + owner_state.clear_committed_document_navigation_state(); + }); + browser_context.mutate_parked_page_session_state(target_id, |state| { + clear_parked_page_loaded_document_session_state(state); + }); + let ( + previous_attachment, + new_attachment_id, + committed_document_post_response_continuation, + ) = { + let target = browser_context.background_target_mut(target_id)?; + let previous_attachment = match target + .runtime_slot + .commit_loaded_navigation_document_replacement(&renderer_attachment_commit) + { + Ok(previous) => previous, + Err(error) => return Some(Err(error.into())), + }; + let page = target + .runtime_slot + .loaded_page_mut() + .expect("committed stable navigation must retain its Page"); + let new_attachment_id = page + .renderer_agent_attachment_id() + .expect("committed navigation Page must have a renderer attachment"); + let continuation = page.take_committed_document_post_response_continuation(); + (previous_attachment, new_attachment_id, continuation) + }; + if let Some(previous_attachment) = previous_attachment + && previous_attachment.id() != new_attachment_id + { + let replacements = + match browser_context.mutate_parked_page_session_state(target_id, |state| { + prepare_renderer_call_replacements_for_devtools_sessions( + primary_session_id.as_deref(), + &mut state.devtools_session_state, + &mut state.auxiliary_devtools_session_states, + previous_attachment.id(), + new_attachment_id, + ) + }) { + Ok(replacements) => replacements, + Err(error) => return Some(Err(error.into())), + }; + browser_context + .background_target_mut(target_id)? + .runtime_slot + .install_pending_renderer_call_replacements(replacements); + } + let target = browser_context.background_target_mut(target_id)?; + target.runtime_slot.reset_subresource_cursor(); + target.runtime_slot.clear_websocket_artifacts(); + Some(Ok(LoadedNavigationPageCommit { + replaced_page_owner: None, + committed_document_post_response_continuation, + })) + } + Self::NoLoadedBrowserContext => None, + } + } } impl CdpConnection { @@ -2483,6 +2608,16 @@ impl CdpConnection { .await } + pub(crate) fn commit_loaded_navigation_document_replacement_for_session_owner( + &mut self, + session_id: Option<&str>, + renderer_attachment_commit: CommittedRendererAgentAttachment, + history_url: &Url, + ) -> Option> { + self.target_session_owner_mut(session_id)? + .commit_loaded_navigation_document_replacement(renderer_attachment_commit, history_url) + } + pub(crate) fn initial_document_page_owner_for_session( &self, session_id: Option<&str>, @@ -2590,6 +2725,14 @@ impl CdpConnection { .clear_pending_navigation_history_update() } + pub(crate) fn resolve_no_commit_response_navigation_history_for_session_owner( + &mut self, + session_id: Option<&str>, + ) -> Option<()> { + self.target_session_owner_mut(session_id)? + .resolve_no_commit_response_navigation_history() + } + pub(crate) async fn mark_target_crashed_for_session_owner_async( &mut self, session_id: Option<&str>, @@ -3508,6 +3651,34 @@ impl CdpConnection { .frame_tree_loader_id() } + pub(crate) fn apply_renderer_document_continuation_page_state( + &mut self, + gate_key: &crate::conn::BackgroundNavigationGateKey, + page_state: std::sync::Arc, + ) -> bool { + let session_id = gate_key.session_id(); + let Some((_, routed_target_id)) = self.target_owner_identity_for_session(session_id) else { + return false; + }; + if gate_key.target_id() != routed_target_id.as_deref() + || self + .target_session_owner_frame_tree_loader_id(session_id) + .as_deref() + != Some(gate_key.loader_id()) + { + return false; + } + let Some(page) = self + .target_session_owner_mut(session_id) + .and_then(TargetSessionOwnerMut::into_runtime_slot_mut) + .and_then(TargetRuntimeSlot::loaded_page_mut) + else { + return false; + }; + page.apply_renderer_document_continuation_state(page_state); + true + } + pub(crate) fn target_session_owner_emulated_device_metrics( &self, session_id: Option<&str>, @@ -4367,6 +4538,7 @@ mod tests { source: "globalThis.fromParkedPreload = true;".to_owned(), world_name: Some("utility".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); diff --git a/moli-protocol/src/conn/dispatch_tests.rs b/moli-protocol/src/conn/dispatch_tests.rs index ae65412c7a..f5ae81b3db 100644 --- a/moli-protocol/src/conn/dispatch_tests.rs +++ b/moli-protocol/src/conn/dispatch_tests.rs @@ -67,6 +67,22 @@ fn bidi_fetch_command_context() -> DevToolsCommandContext { } } +fn direct_navigation_result_loader_id_for_test( + result: &crate::devtools_runtime::DevToolsNavigateResult, +) -> Option { + result + .loader_id + .as_ref() + .map(|loader_id| loader_id.as_str().to_owned()) + .or_else(|| { + result + .navigation_id + .as_ref() + .and_then(|navigation_id| navigation_id.as_str().strip_prefix("navigation-")) + .map(str::to_owned) + }) +} + async fn execute_direct_devtools_command_through_renderer_fence_for_test( ctx: &mut crate::testing::TestContext, command: DevToolsCommand, @@ -3446,12 +3462,29 @@ async fn devtools_command_executes_child_frame_navigation_without_cdp_response_s wait: DevToolsNavigationWait::Load, })) .await; - let (parent_result, _, _, parent_predecessor) = parent_outcome.into_complete_parts(); + let (parent_result, parent_scheduler_events, parent_protocol_events, parent_predecessor) = + parent_outcome.into_complete_parts(); + ctx.route_direct_command_output_for_test(Vec::new(), parent_scheduler_events) + .await; if let Some(predecessor) = parent_predecessor { ctx.route_direct_command_renderer_predecessor_for_test(predecessor) .await; } - parent_result.expect("parent navigation should succeed"); + ctx.route_direct_command_output_for_test(parent_protocol_events, Vec::new()) + .await; + let DevToolsCommandResult::Navigate(parent_navigation) = + parent_result.expect("parent navigation should succeed") + else { + panic!("expected parent navigation result"); + }; + let parent_loader_id = direct_navigation_result_loader_id_for_test(&parent_navigation) + .expect("parent navigation should expose a loader identity"); + ctx.wait_for_direct_navigation_load_for_test( + &target_context, + &parent_loader_id, + "protocol-neutral parent frame navigation load", + ) + .await; let (frame_tree_result, _) = ctx .conn @@ -3492,8 +3525,10 @@ async fn devtools_command_executes_child_frame_navigation_without_cdp_response_s }, )) .await; - let (navigate_result, _scheduler_events, protocol_events, child_predecessor) = + let (navigate_result, child_scheduler_events, protocol_events, child_predecessor) = child_outcome.into_complete_parts(); + ctx.route_direct_command_output_for_test(Vec::new(), child_scheduler_events) + .await; if let Some(predecessor) = child_predecessor { ctx.route_direct_command_renderer_predecessor_for_test(predecessor) .await; @@ -5512,57 +5547,56 @@ async fn devtools_command_executes_input_key_command_without_cdp_sidecar() { target_id: Some(target_id.clone()), ..context.clone() }; - // Programmatic focus is part of parser execution. In contrast, `autofocus` - // is a post-DOMContentLoaded rendering update and can race the first input - // command sent to this intentionally inactive target. - let url = "data:text/html,"; - let (navigate_result, scheduler_events, protocol_events) = ctx - .conn - .execute_devtools_command(DevToolsCommand::Navigate(DevToolsNavigateCommand { + let url = "data:text/html,"; + let navigate_result = execute_direct_devtools_command_through_renderer_fence_for_test( + &mut ctx, + DevToolsCommand::Navigate(DevToolsNavigateCommand { context: target_context.clone(), url: url.to_owned(), referrer: None, wait: DevToolsNavigationWait::Load, - })) - .await - .into_parts_with_protocol_events(); - navigate_result.expect("navigation should succeed"); - ctx.sent - .extend(crate::testing::protocol_events_into_internal_messages( - protocol_events, - )); - ctx.route_direct_command_output_for_test(Vec::new(), scheduler_events) - .await; - ctx.wait_for_direct_command_work_completion_for_test( - "protocol-neutral navigation load owner action", + }), ) .await; + navigate_result.expect("navigation should succeed"); + execute_direct_devtools_command_through_renderer_fence_for_test( + &mut ctx, + DevToolsCommand::EvaluateScript(DevToolsEvaluateScriptCommand { + context: target_context.clone(), + realm_id: None, + world_name: None, + expression: "document.getElementById('field').focus()".to_owned(), + await_promise: false, + user_gesture: false, + webdriver_bidi_file_prompt_handler: None, + result_ownership: DevToolsResultOwnership::None, + preserve_remote_metadata: false, + materialize_bidi_script_result: false, + serialization_options: None, + }), + ) + .await + .expect("input focus setup should succeed"); ctx.sent.clear(); - let (key_result, _scheduler_events, protocol_events) = ctx - .conn - .execute_devtools_command(DevToolsCommand::DispatchKeyEvent( - DevToolsDispatchKeyEventCommand { - context: target_context.clone(), - event_type: DevToolsKeyEventType::KeyPress, - key: "Z".to_owned(), - code: "KeyZ".to_owned(), - text: "Z".to_owned(), - modifiers: 0, - auto_repeat: false, - should_insert_text: true, - }, - )) - .await - .into_parts_with_protocol_events(); + let key_result = execute_direct_devtools_command_through_renderer_fence_for_test( + &mut ctx, + DevToolsCommand::DispatchKeyEvent(DevToolsDispatchKeyEventCommand { + context: target_context.clone(), + event_type: DevToolsKeyEventType::KeyPress, + key: "Z".to_owned(), + code: "KeyZ".to_owned(), + text: "Z".to_owned(), + modifiers: 0, + auto_repeat: false, + should_insert_text: true, + }), + ) + .await; assert_eq!( key_result.expect("key dispatch should succeed"), DevToolsCommandResult::Empty ); - assert!( - protocol_events.is_empty(), - "direct input key dispatch must not emit CDP-shaped sidecar messages: {protocol_events:?}" - ); let value_result = ctx .execute_devtools_command_through_renderer_fence_for_test(DevToolsCommand::EvaluateScript( diff --git a/moli-protocol/src/conn/inspector_route.rs b/moli-protocol/src/conn/inspector_route.rs index 7d73698308..fac93c15ef 100644 --- a/moli-protocol/src/conn/inspector_route.rs +++ b/moli-protocol/src/conn/inspector_route.rs @@ -164,6 +164,32 @@ impl CdpConnection { Ok(transaction) } + pub(crate) fn commit_renderer_agent_candidate_for_stable_page_replacement_for_session_owner( + &mut self, + session_id: Option<&str>, + candidate: PreparedRendererAgentAttachment, + expected: &super::StablePageNavigationCommitTarget, + ) -> Result { + self.validate_navigation_target_owner(session_id, candidate.navigation())?; + if !self.target_page_residence_identity_is_current_for_session( + session_id, + expected.target_page(), + ) || self.renderer_page_residence_identity_for_session_owner(session_id) + != Some(expected.renderer_page()) + { + return Err("StableNavigationPageSuperseded".to_owned()); + } + let transaction = self + .runtime_session_owner_slot_mut(session_id)? + .commit_renderer_agent_candidate_transaction(candidate, expected.renderer_page()) + .map_err(|error| error.to_string())?; + self.bind_renderer_page_output_owner( + expected.renderer_page(), + expected.target_page().clone(), + ); + Ok(transaction) + } + pub(crate) fn rollback_committed_renderer_agent_candidate_for_session_owner( &mut self, session_id: Option<&str>, diff --git a/moli-protocol/src/conn/page_state/loaded.rs b/moli-protocol/src/conn/page_state/loaded.rs index 4a6703e755..8b3bdd7338 100644 --- a/moli-protocol/src/conn/page_state/loaded.rs +++ b/moli-protocol/src/conn/page_state/loaded.rs @@ -316,6 +316,65 @@ impl BrowserContext { }) } + pub(crate) fn commit_loaded_navigation_document_replacement( + &mut self, + renderer_attachment_commit: CommittedRendererAgentAttachment, + history_url: &Url, + ) -> anyhow::Result { + let primary_session_id = self.active_session_id_owned(); + let previous_attachment = self + .active_target + .runtime_slot + .commit_loaded_navigation_document_replacement(&renderer_attachment_commit)?; + let page = self + .active_target + .runtime_slot + .loaded_page_mut() + .expect("committed stable navigation must retain its Page"); + let new_attachment_id = page + .renderer_agent_attachment_id() + .expect("committed navigation Page must have a renderer attachment"); + let committed_document_post_response_continuation = + page.take_committed_document_post_response_continuation(); + if let Some(previous_attachment) = previous_attachment + && previous_attachment.id() != new_attachment_id + { + let replacements = prepare_renderer_call_replacements_for_devtools_sessions( + primary_session_id.as_deref(), + &mut self.devtools_session_state, + &mut self.auxiliary_devtools_session_states, + previous_attachment.id(), + new_attachment_id, + )?; + self.active_target + .runtime_slot + .install_pending_renderer_call_replacements(replacements); + } + let page_snapshot = self + .active_target + .runtime_slot + .loaded_page() + .map(|page| (history_url.to_string(), page.document_title())) + .expect("committed stable navigation must retain its Page"); + self.active_target + .owner_state + .record_loaded_page_navigation_history(page_snapshot); + self.reset_subresource_network_cursor(); + self.clear_websocket_network_artifacts(); + self.active_target + .owner_state + .clear_committed_document_navigation_state(); + self.clear_active_target_loaded_document_session_state(); + self.clear_active_target_runtime_remote_object_tracking(); + Ok(LoadedNavigationPageCommit { + // A renderer Document replacement keeps the same typed Page + // attachment in the public repository. Page-owned work therefore + // remains resident; Document currentness is tracked separately. + replaced_page_owner: None, + committed_document_post_response_continuation, + }) + } + pub(crate) async fn clear_active_target_session_scoped_state_async( &mut self, ) -> Result<(), String> { diff --git a/moli-protocol/src/conn/page_state/parked.rs b/moli-protocol/src/conn/page_state/parked.rs index 03de3cbe53..572081597b 100644 --- a/moli-protocol/src/conn/page_state/parked.rs +++ b/moli-protocol/src/conn/page_state/parked.rs @@ -40,6 +40,7 @@ impl BrowserContext { initial_empty_document_url, creator, None, + None, session_storage_namespace, ); } @@ -51,6 +52,7 @@ impl BrowserContext { url: String, initial_empty_document_url: Option, creator: Option, + pending_auxiliary_page: Option, session_storage_store: Option, initial_empty_document_storage_key: Option, ) { @@ -63,6 +65,7 @@ impl BrowserContext { url, initial_empty_document_url, creator, + pending_auxiliary_page, initial_empty_document_storage_key, session_storage_namespace, ); @@ -88,6 +91,7 @@ impl BrowserContext { url: String, initial_empty_document_url: Option, creator: Option, + pending_auxiliary_page: Option, initial_empty_document_storage_key: Option, session_storage_namespace: Option, ) { @@ -101,6 +105,14 @@ impl BrowserContext { ); }); let mut target = BackgroundTarget::with_identity(target_id, session_id, target_identity); + if let Some(pending_auxiliary_page) = pending_auxiliary_page { + assert!( + target + .runtime_slot + .stage_pending_auxiliary_page(pending_auxiliary_page), + "new popup target must accept its renderer-owned auxiliary Page reservation" + ); + } if let Some(namespace) = session_storage_namespace { target.replace_session_storage_namespace(namespace); } @@ -2284,6 +2296,7 @@ mod tests { source: "globalThis.fromDocumentStart = true".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); @@ -2414,6 +2427,7 @@ mod tests { source: "globalThis.demoted = true".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); diff --git a/moli-protocol/src/conn/page_state/surfaces.rs b/moli-protocol/src/conn/page_state/surfaces.rs index 8b89d29643..0f78d0e9ec 100644 --- a/moli-protocol/src/conn/page_state/surfaces.rs +++ b/moli-protocol/src/conn/page_state/surfaces.rs @@ -612,6 +612,7 @@ impl BrowserContext { source, world_name: None, has_bidi_channel_argument: false, + browser_internal: true, bidi_channel_handoffs: Vec::new(), }) } diff --git a/moli-protocol/src/conn/popup_navigation_work.rs b/moli-protocol/src/conn/popup_navigation_work.rs index 0f6fe283a7..f522625e78 100644 --- a/moli-protocol/src/conn/popup_navigation_work.rs +++ b/moli-protocol/src/conn/popup_navigation_work.rs @@ -1,57 +1,23 @@ -use super::{CdpConnection, CdpSessionRoute, CommandOwnerScope}; +use super::{CdpConnection, CommandOwnerScope, TargetPageResidenceIdentity}; -/// Navigation requested by an already-accepted auxiliary browsing-context -/// action. +/// Immutable authority for one popup destination navigation. /// -/// Creating or resolving the target is part of the renderer output that -/// precedes the causing Runtime response. Loading the requested URL is not. -/// Blink's `LocalDOMWindow::open()` resolves the target, invokes `Navigate()`, -/// and returns the Window without waiting for the network load or Document -/// commit. Moli's navigation helper can itself become asynchronous, so -/// protocol projection must hand that work to the owner scheduler instead of -/// awaiting it while the opener's output cursor is being projected. Keeping -/// the frozen URL and exact target route in this move-only action makes that -/// boundary explicit. -#[derive(Debug)] -pub(crate) struct PopupTargetNavigationOwnerAction { - owner_scope: CommandOwnerScope, +/// A target id is not sufficient authorization: CDP sessions and target-local +/// state can survive replacement of the installed renderer `Page`. Retaining +/// the exact residence generation makes an activation captured for the +/// initial empty Document incapable of navigating a later replacement Page. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PopupTargetNavigationClaimIdentity { + page_owner: TargetPageResidenceIdentity, browser_context_id: String, target_id: String, url: String, kind: PopupTargetNavigationKind, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum PopupTargetNavigationKind { - InitialDocument, - NamedTargetReuse, -} - -impl PopupTargetNavigationOwnerAction { - pub(crate) fn capture( - conn: &CdpConnection, - browser_context_id: &str, - target_id: &str, - url: String, - kind: PopupTargetNavigationKind, - ) -> Option { - let route = conn.target_session_route_for_target_id(target_id)?; - (route.browser_context_id() == Some(browser_context_id)).then(|| Self { - // Activation has an independent owner action. Unlike a frozen - // BackgroundTarget route, AuxiliaryTarget keeps this exact target - // addressable if that action changes its residence first. - owner_scope: CommandOwnerScope::from_session_and_owner_route( - None, - Some(CdpSessionRoute::AuxiliaryTarget { - browser_context_id: browser_context_id.to_owned(), - target_id: target_id.to_owned(), - }), - ), - browser_context_id: browser_context_id.to_owned(), - target_id: target_id.to_owned(), - url, - kind, - }) +impl PopupTargetNavigationClaimIdentity { + pub(crate) fn page_owner(&self) -> &TargetPageResidenceIdentity { + &self.page_owner } pub(crate) fn browser_context_id(&self) -> &str { @@ -69,22 +35,177 @@ impl PopupTargetNavigationOwnerAction { pub(crate) fn kind(&self) -> PopupTargetNavigationKind { self.kind } +} - pub(crate) fn into_parts( - self, - ) -> ( - CommandOwnerScope, - String, - String, - String, - PopupTargetNavigationKind, - ) { - ( - self.owner_scope, - self.browser_context_id, - self.target_id, - self.url, - self.kind, - ) +/// Navigation requested by an already-accepted auxiliary browsing-context +/// action. +/// +/// Creating or resolving the target is part of the renderer output that +/// precedes the causing Runtime response. Loading the requested URL is not. +/// Blink's `LocalDOMWindow::open()` resolves the target, invokes `Navigate()`, +/// and returns the Window without waiting for the network load or Document +/// commit. Moli's navigation helper can itself become asynchronous, so +/// protocol projection must hand that work to the owner scheduler instead of +/// awaiting it while the opener's output cursor is being projected. Keeping +/// the frozen URL and exact target route in this move-only action makes that +/// boundary explicit. +#[derive(Debug)] +pub(crate) struct PopupTargetNavigationOwnerAction { + owner_scope: CommandOwnerScope, + claim: PopupTargetNavigationClaimIdentity, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PopupTargetNavigationKind { + InitialDocument, + NamedTargetReuse, +} + +/// Target-local lifecycle of the initial popup navigation authority. +/// +/// `Consumed` is deliberately retained as a tombstone. Once a captured +/// activation is stale, generic entry points such as `Page.enable` must not +/// reconstruct it from the mutable target URL and apply it to a newer Page. +#[derive(Debug)] +pub(crate) enum PopupTargetNavigationAuthorityState { + Held(PopupTargetNavigationOwnerAction), + Published(PopupTargetNavigationClaimIdentity), + Consumed(PopupTargetNavigationClaimIdentity), +} + +impl PopupTargetNavigationAuthorityState { + pub(crate) fn claim_identity(&self) -> &PopupTargetNavigationClaimIdentity { + match self { + Self::Held(action) => action.claim_identity(), + Self::Published(claim) | Self::Consumed(claim) => claim, + } + } +} + +impl PopupTargetNavigationOwnerAction { + pub(crate) fn capture( + conn: &mut CdpConnection, + browser_context_id: &str, + target_id: &str, + url: String, + kind: PopupTargetNavigationKind, + ) -> Option { + let route = conn.target_session_route_for_target_id(target_id)?; + if route.browser_context_id() != Some(browser_context_id) { + return None; + } + let owner_scope = + CommandOwnerScope::from_session_and_owner_route(None, Some(route.clone())); + let page_owner = { + let mut route_scope = conn.scoped_none_session_owner_route_override(route); + route_scope + .conn_mut() + .target_page_residence_identity_for_session(None)? + }; + if page_owner.browser_context_id() != browser_context_id + || page_owner.target_id() != Some(target_id) + { + return None; + } + Some(Self { + owner_scope, + claim: PopupTargetNavigationClaimIdentity { + page_owner, + browser_context_id: browser_context_id.to_owned(), + target_id: target_id.to_owned(), + url, + kind, + }, + }) + } + + pub(crate) fn browser_context_id(&self) -> &str { + self.claim.browser_context_id() + } + + pub(crate) fn target_id(&self) -> &str { + self.claim.target_id() + } + + pub(crate) fn url(&self) -> &str { + self.claim.url() + } + + pub(crate) fn kind(&self) -> PopupTargetNavigationKind { + self.claim.kind() + } + + pub(crate) fn page_owner(&self) -> &TargetPageResidenceIdentity { + self.claim.page_owner() + } + + pub(crate) fn claim_identity(&self) -> &PopupTargetNavigationClaimIdentity { + &self.claim + } + + pub(crate) fn into_parts(self) -> (CommandOwnerScope, PopupTargetNavigationClaimIdentity) { + (self.owner_scope, self.claim) + } +} + +impl CdpConnection { + /// Installs the initial popup destination as target-local held authority. + /// The action is accepted only while its exact Page residence is current. + pub(crate) fn stage_initial_popup_target_navigation_owner_action( + &mut self, + action: PopupTargetNavigationOwnerAction, + ) -> bool { + if action.kind() != PopupTargetNavigationKind::InitialDocument { + return false; + } + let owner_scope = action.owner_scope.clone(); + let page_owner = action.page_owner().clone(); + let mut route_scope = owner_scope.enter(self); + let conn = route_scope.conn_mut(); + if !conn.target_page_residence_identity_is_current_for_session(None, &page_owner) { + return false; + } + conn.runtime_session_owner_slot_mut(None) + .is_ok_and(|slot| slot.stage_initial_popup_target_navigation_owner_action(action)) + } + + /// Releases a held action through the exact target route used at target + /// admission. The target slot retains a published claim until completion. + pub(crate) fn take_held_popup_target_navigation_owner_action_for_target( + &mut self, + target_id: &str, + ) -> Option { + let route = self.target_session_route_for_target_id(target_id)?; + let mut route_scope = self.scoped_none_session_owner_route_override(route); + route_scope + .conn_mut() + .take_held_popup_target_navigation_owner_action_for_session_owner(None) + } + + /// Releases the held action addressed by a concrete Runtime/Page session. + pub(crate) fn take_held_popup_target_navigation_owner_action_for_session_owner( + &mut self, + session_id: Option<&str>, + ) -> Option { + self.runtime_session_owner_slot_mut(session_id) + .ok()? + .take_held_initial_popup_target_navigation_owner_action() + } + + pub(crate) fn consume_published_popup_target_navigation_claim_for_session_owner( + &mut self, + session_id: Option<&str>, + expected: &PopupTargetNavigationClaimIdentity, + ) -> bool { + self.runtime_session_owner_slot_mut(session_id) + .is_ok_and(|slot| slot.consume_published_popup_target_navigation_claim(expected)) + } + + pub(crate) fn runtime_session_owner_has_popup_target_navigation_authority( + &self, + session_id: Option<&str>, + ) -> bool { + self.runtime_session_owner_slot(session_id) + .is_ok_and(|slot| slot.has_popup_target_navigation_authority()) } } diff --git a/moli-protocol/src/conn/runtime_eval.rs b/moli-protocol/src/conn/runtime_eval.rs index a6a8b33e12..97705867c3 100644 --- a/moli-protocol/src/conn/runtime_eval.rs +++ b/moli-protocol/src/conn/runtime_eval.rs @@ -1,4 +1,8 @@ -use std::{collections::hash_map::Entry, future::Future, pin::Pin}; +use std::{ + collections::{HashSet, hash_map::Entry}, + future::Future, + pin::Pin, +}; use moli_page_types::{FrontendCommandId, RendererCallId, RendererInspectorResponseDelivery}; use moli_protocol_cdp::CdpRendererCommandReplayDispatch; @@ -1791,9 +1795,8 @@ impl CdpConnection { } } - pub(crate) fn fail_pending_inspector_awaits_for_session_owner_background_events_into( + pub(crate) fn fail_claimed_pending_inspector_awaits_for_session_owner_background_events_into( &mut self, - out: &mut Vec, claimed_background_events: &mut Vec, session_id: Option<&str>, reason: &'static str, @@ -1804,6 +1807,20 @@ impl CdpConnection { claimed, reason, ); + } + + pub(crate) fn fail_pending_inspector_awaits_for_session_owner_background_events_into( + &mut self, + out: &mut Vec, + claimed_background_events: &mut Vec, + session_id: Option<&str>, + reason: &'static str, + ) { + self.fail_claimed_pending_inspector_awaits_for_session_owner_background_events_into( + claimed_background_events, + session_id, + reason, + ); if let Some(owner_session_id) = session_id && self.shared_worker_target_for_session(session_id).is_some() { @@ -4274,6 +4291,8 @@ impl CdpConnection { let mut isolated_world_context_count = 0; let mut child_default_context_count = 0; let mut failed_page_snapshot_count = 0; + let mut loaded_document_script_agent_ids = HashSet::new(); + let mut loaded_document_missing_script_agent_id_count = 0; for completed in completed.completed { let Some(page) = self @@ -4299,6 +4318,12 @@ impl CdpConnection { failed_page_snapshot_count += 1; continue; }; + match snapshot.diagnostics.script_agent_id { + Some(script_agent_id) => { + loaded_document_script_agent_ids.insert(script_agent_id); + } + None => loaded_document_missing_script_agent_id_count += 1, + } document_context_count += snapshot.diagnostics.document_context_count; isolated_world_context_count += snapshot.diagnostics.isolated_world_context_count; child_default_context_count += snapshot.diagnostics.child_default_context_count; @@ -4308,13 +4333,15 @@ impl CdpConnection { .dedicated_worker_running_worker_isolate_count; } - let estimated_document_isolate_count = self + let pending_document_page_build_count = self .browser_contexts() - .map(|browser_context| { - browser_context.loaded_document_page_count() - + browser_context.pending_document_page_build_count() - }) + .map(BrowserContext::pending_document_page_build_count) .sum::(); + let loaded_document_script_agent_count = loaded_document_script_agent_ids.len(); + let estimated_document_isolate_count = loaded_document_script_agent_count + + loaded_document_missing_script_agent_id_count + + failed_page_snapshot_count + + pending_document_page_build_count; let shared_worker_running_worker_isolate_count = self .browser_contexts() .map(|browser_context| { @@ -4334,6 +4361,12 @@ impl CdpConnection { json!(isolated_world_context_count); diagnostics["isolateScope"]["childDefaultContextCount"] = json!(child_default_context_count); + diagnostics["isolateScope"]["loadedDocumentScriptAgentCount"] = + json!(loaded_document_script_agent_count); + diagnostics["isolateScope"]["loadedDocumentMissingScriptAgentIdCount"] = + json!(loaded_document_missing_script_agent_id_count); + diagnostics["isolateScope"]["estimatedDocumentIsolateCount"] = + json!(estimated_document_isolate_count); diagnostics["isolateScope"]["dedicatedWorkerLoadingCount"] = json!(dedicated_worker_loading_count); diagnostics["isolateScope"]["dedicatedWorkerRunningWorkerIsolateCount"] = diff --git a/moli-protocol/src/conn/runtime_load.rs b/moli-protocol/src/conn/runtime_load.rs index ee618eaa7f..3f90e1a090 100644 --- a/moli-protocol/src/conn/runtime_load.rs +++ b/moli-protocol/src/conn/runtime_load.rs @@ -9,9 +9,9 @@ use moli_core::{ }, }; use moli_fetch::{ - BrowserNavigationRequestKind, FetchCancelHandle, FetchConfig, NetworkFetchFailureContext, - NetworkFetchResult, NetworkObservationJournal, RawResponse, Request, ResponseHead, - StreamingRawResponse, url_pattern_matches, + BrowserNavigationRequestKind, FetchCancelHandle, FetchConfig, NET_ERR_ABORTED_ERROR_TEXT, + NetworkFetchFailureContext, NetworkFetchResult, NetworkObservationJournal, RawResponse, + Request, ResponseHead, StreamingRawResponse, url_pattern_matches, }; use serde_json::Value; use std::sync::Arc; @@ -24,8 +24,8 @@ use crate::conn::state::{ }; use crate::domains::network::{ CompletedDocumentProgressTransfer, CompletedDownloadProgressTransfer, - CompletedMainDocumentNetworkEvents, MainDocumentBodyNetworkProgress, - MainDocumentBodyProgressSource, + CompletedMainDocumentNetworkEvents, CompletedNoCommitResponseProgressTransfer, + MainDocumentBodyNetworkProgress, MainDocumentBodyProgressSource, }; const BLOCKED_BY_CLIENT_ERROR_TEXT: &str = "net::ERR_BLOCKED_BY_CLIENT"; @@ -86,6 +86,7 @@ fn response_status_may_use_http_error_page(status: u16) -> bool { async fn prepare_browser_owned_error_page_navigation_with_engine_async( engine: &mut NavigationEngine, page_reservation: RendererPageReservationToken, + stable_page_target: Option, load_inputs: &TargetNavigationLoadInputs, unreachable_url: Url, request_method: String, @@ -114,6 +115,7 @@ async fn prepare_browser_owned_error_page_navigation_with_engine_async( prepare_captured_document_response_with_engine_async( engine, page_reservation, + stable_page_target, load_inputs, unreachable_url.clone(), request_method, @@ -133,6 +135,7 @@ async fn prepare_browser_owned_error_page_navigation_with_engine_async( async fn prepare_network_error_page_navigation_with_engine_async( engine: &mut NavigationEngine, page_reservation: RendererPageReservationToken, + stable_page_target: Option, load_inputs: &TargetNavigationLoadInputs, unreachable_url: Url, request_method: String, @@ -144,6 +147,7 @@ async fn prepare_network_error_page_navigation_with_engine_async( prepare_browser_owned_error_page_navigation_with_engine_async( engine, page_reservation, + stable_page_target, load_inputs, unreachable_url, request_method, @@ -161,6 +165,24 @@ fn response_headers_indicate_xml_document(headers: &[(String, String)]) -> bool .is_some_and(|mime| moli_web_mime::is_dom_parser_xml_mime(&mime)) } +fn response_status_prevents_document_commit(status: u16) -> bool { + matches!(status, 204 | 205) +} + +fn no_commit_response_navigation( + final_url: Url, + body_progress_source: MainDocumentBodyProgressSource, + network_events: CompletedMainDocumentNetworkEvents, +) -> NavigationLoadOutcome { + body_progress_source.emit_body_failed(NET_ERR_ABORTED_ERROR_TEXT); + let network_progress = + body_progress_source.body_network_progress_for_completed_events(network_events); + NavigationLoadOutcome::no_commit_response(NoCommitResponseNavigation { + final_url, + progress_transfer: CompletedNoCommitResponseProgressTransfer::new(network_progress), + }) +} + #[derive(Clone, Debug)] pub(crate) struct InitialDocumentPageOwner { pub(crate) browser_context_id: String, @@ -280,6 +302,8 @@ impl PendingInitialDocumentPageBuild { pub(crate) struct LoadedPageCreationDiagnosticsParts { pub(crate) initial_runtime_realms: Vec, pub(crate) renderer_output_predecessor: Option, + pub(crate) document_continuation_observer: + Option, } fn loaded_page_creation_diagnostics_parts( @@ -288,11 +312,13 @@ fn loaded_page_creation_diagnostics_parts( LoadedPageCreationDiagnosticsParts { initial_runtime_realms: diagnostics.initial_runtime_realms, renderer_output_predecessor: diagnostics.renderer_output_predecessor, + document_continuation_observer: diagnostics.document_continuation_observer, } } pub struct ResponseCommitReady { prepared_page: Option, + stable_page_target: Option, body_capture: Option, body_completion_sink: Option, body_progress_source: MainDocumentBodyProgressSource, @@ -316,6 +342,67 @@ enum ResponseCommitBodyCapture { Ready(CapturedBody), } +struct CommittedNavigationDocument { + pending_download: Option, + page_creation_artifacts: moli_core::page::RendererPageCreationArtifacts, + requested_url: Url, + final_url: Url, + request_method: String, + request_headers: Vec<(String, String)>, + response_status: u16, + response_headers: Vec<(String, String)>, + response_from_cache: bool, + initial_runtime_realms: Vec, + renderer_output_predecessor: Option, + main_document_commit: Option>, + document_continuation_observer: Option, + document_progress_transfer: CompletedDocumentProgressTransfer, + navigation_engine: Option, + network_error_page: Option, +} + +pub(crate) struct CommittedStablePageNavigation { + pub(crate) stable_page_target: StablePageNavigationCommitTarget, + pub(crate) pending_download: Option, + pub(crate) page_creation_artifacts: moli_core::page::RendererPageCreationArtifacts, + pub(crate) final_url: Url, + pub(crate) response_headers: Vec<(String, String)>, + pub(crate) response_from_cache: bool, + pub(crate) initial_runtime_realms: Vec, + pub(crate) renderer_output_predecessor: Option, + pub(crate) main_document_commit: Option>, + pub(crate) document_continuation_observer: + Option, + pub(crate) document_progress_transfer: CompletedDocumentProgressTransfer, + pub(crate) navigation_engine: Option, + pub(crate) network_error_page: Option, +} + +pub(crate) struct StablePageNavigationCommitError { + error: moli_core::page::RendererPageReplacementCommitError, +} + +impl StablePageNavigationCommitError { + fn page_preserved(message: impl Into) -> Self { + Self { + error: moli_core::page::RendererPageReplacementCommitError::page_preserved( + anyhow::anyhow!(message.into()), + ), + } + } + + pub(crate) fn page_was_preserved(&self) -> bool { + self.error.disposition() + == moli_core::page::RendererPageReplacementCommitFailureDisposition::PagePreserved + } +} + +impl std::fmt::Display for StablePageNavigationCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.error, formatter) + } +} + impl ResponseCommitBodyCapture { fn abort(self) { if let Self::Pending(task) = self { @@ -375,6 +462,10 @@ impl ResponseCommitReady { ) } + pub(crate) fn stable_page_target(&self) -> Option<&StablePageNavigationCommitTarget> { + self.stable_page_target.as_ref() + } + pub(crate) async fn update_commit_configuration( &self, configuration: PreparedDocumentPageCommitConfiguration, @@ -408,6 +499,12 @@ impl ResponseCommitReady { mut self, permit: PreparedDocumentPageCommitPermit, ) -> Result { + if self.stable_page_target.is_some() { + return Err(format!( + "prepared replacement for page `{}` requires the stable Page commit boundary", + self.requested_url + )); + } let prepared_page = self .prepared_page .take() @@ -424,6 +521,100 @@ impl ResponseCommitReady { )); } }; + let diagnostics = loaded_page_creation_diagnostics_parts(built.page_creation_diagnostics); + let document = self + .finish_committed_document( + built.pending_download, + built.page_creation_artifacts, + diagnostics, + ) + .await?; + Ok(LoadedNavigation { + page: built.page, + pending_download: document.pending_download, + page_creation_artifacts: document.page_creation_artifacts, + requested_url: document.requested_url, + final_url: document.final_url, + request_method: document.request_method, + request_headers: document.request_headers, + response_status: document.response_status, + response_headers: document.response_headers, + response_from_cache: document.response_from_cache, + initial_runtime_realms: document.initial_runtime_realms, + renderer_output_predecessor: document.renderer_output_predecessor, + main_document_commit: document.main_document_commit, + document_continuation_observer: document.document_continuation_observer, + document_progress_transfer: document.document_progress_transfer, + navigation_engine: document.navigation_engine, + network_error_page: document.network_error_page, + }) + } + + pub(crate) async fn commit_page_replacement( + mut self, + permit: PreparedDocumentPageCommitPermit, + page: &mut moli_core::page::Page, + ) -> Result { + let stable_page_target = + self.stable_page_target + .take() + .ok_or_else(|| StablePageNavigationCommitError { + error: moli_core::page::RendererPageReplacementCommitError::page_preserved( + anyhow::anyhow!( + "prepared document does not target a stable Page replacement" + ), + ), + })?; + let prepared_page = self + .prepared_page + .take() + .expect("response commit-ready value must retain its prepared Page"); + let replacement = match prepared_page.commit_page_replacement(permit, page).await { + Ok(replacement) => replacement, + Err(error) => { + if let Some(body_capture) = self.body_capture.take() { + body_capture.abort(); + } + return Err(StablePageNavigationCommitError { error }); + } + }; + let diagnostics = + loaded_page_creation_diagnostics_parts(replacement.page_creation_diagnostics); + let document = self + .finish_committed_document( + replacement.pending_download, + replacement.page_creation_artifacts, + diagnostics, + ) + .await + .map_err(|error| StablePageNavigationCommitError { + error: moli_core::page::RendererPageReplacementCommitError::page_retired( + anyhow::anyhow!(error), + ), + })?; + Ok(CommittedStablePageNavigation { + stable_page_target, + pending_download: document.pending_download, + page_creation_artifacts: document.page_creation_artifacts, + final_url: document.final_url, + response_headers: document.response_headers, + response_from_cache: document.response_from_cache, + initial_runtime_realms: document.initial_runtime_realms, + renderer_output_predecessor: document.renderer_output_predecessor, + main_document_commit: document.main_document_commit, + document_continuation_observer: document.document_continuation_observer, + document_progress_transfer: document.document_progress_transfer, + navigation_engine: document.navigation_engine, + network_error_page: document.network_error_page, + }) + } + + async fn finish_committed_document( + &mut self, + pending_download: Option, + page_creation_artifacts: moli_core::page::RendererPageCreationArtifacts, + diagnostics: LoadedPageCreationDiagnosticsParts, + ) -> Result { let body_capture = self .body_capture .take() @@ -476,11 +667,9 @@ impl ResponseCommitReady { body_network_progress_state, ) }; - let diagnostics = loaded_page_creation_diagnostics_parts(built.page_creation_diagnostics); - Ok(LoadedNavigation { - page: built.page, - pending_download: built.pending_download, - page_creation_artifacts: built.page_creation_artifacts, + Ok(CommittedNavigationDocument { + pending_download, + page_creation_artifacts, requested_url: self.requested_url.clone(), final_url: self.final_url.clone(), request_method: self.request_method.clone(), @@ -491,6 +680,7 @@ impl ResponseCommitReady { initial_runtime_realms: diagnostics.initial_runtime_realms, renderer_output_predecessor: diagnostics.renderer_output_predecessor, main_document_commit: self.main_document_commit.take(), + document_continuation_observer: diagnostics.document_continuation_observer, document_progress_transfer, navigation_engine: self.navigation_engine.take(), network_error_page: self.network_error_page.take(), @@ -508,6 +698,7 @@ impl Drop for ResponseCommitReady { pub struct PausedResponsePreparedDocument { prepared_page: PreparedDocumentPage, + stable_page_target: Option, renderer_body_tx: mpsc::Sender>, renderer_completion_tx: oneshot::Sender>, body_progress_source: MainDocumentBodyProgressSource, @@ -576,6 +767,7 @@ impl PausedResponsePreparedDocument { ); let ready = ResponseCommitReady { prepared_page: Some(self.prepared_page), + stable_page_target: self.stable_page_target, body_capture: Some(ResponseCommitBodyCapture::Pending(body_capture_task)), body_completion_sink, body_progress_source: self.body_progress_source, @@ -693,7 +885,7 @@ fn spawn_captured_body_replay( pub(crate) struct BackgroundNavigationLoadJob { engine: NavigationEngine, - page_reservation: RendererPageReservationToken, + page_reservation: NavigationRendererPageReservation, cancellation: FetchCancelHandle, early_result: Option, load_inputs: TargetNavigationLoadInputs, @@ -711,7 +903,7 @@ pub(crate) struct BackgroundNavigationLoadJob { pub(crate) struct BackgroundStreamingResponseNavigationLoadJob { engine: NavigationEngine, - page_reservation: RendererPageReservationToken, + page_reservation: NavigationRendererPageReservation, load_inputs: TargetNavigationLoadInputs, requested_url: Url, request_method: String, @@ -724,6 +916,56 @@ pub(crate) struct BackgroundStreamingResponseNavigationLoadJob { shared_resource_runtime: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StablePageNavigationCommitTarget { + target_page: TargetPageResidenceIdentity, + renderer_page: RendererPageResidenceIdentity, +} + +impl StablePageNavigationCommitTarget { + pub(crate) fn target_page(&self) -> &TargetPageResidenceIdentity { + &self.target_page + } + + pub(crate) fn renderer_page(&self) -> RendererPageResidenceIdentity { + self.renderer_page + } +} + +enum NavigationRendererPageReservation { + Fresh(RendererPageReservationToken), + StableReplacement { + pending: moli_core::page::RendererPageReplacementReservationPending, + target: StablePageNavigationCommitTarget, + }, + Failed(String), +} + +struct ResolvedNavigationRendererPageReservation { + token: RendererPageReservationToken, + stable_replacement: Option, +} + +impl NavigationRendererPageReservation { + async fn resolve(self) -> Result { + match self { + Self::Fresh(token) => Ok(ResolvedNavigationRendererPageReservation { + token, + stable_replacement: None, + }), + Self::StableReplacement { pending, target } => pending + .await_ready() + .await + .map(|token| ResolvedNavigationRendererPageReservation { + token, + stable_replacement: Some(target), + }) + .map_err(|error| format!("failed to reserve stable Page replacement: {error:#}")), + Self::Failed(error) => Err(error), + } + } +} + pub(crate) struct BackgroundNavigationEarlyResult { sender: BackgroundEventSender, navigate_id: u64, @@ -846,11 +1088,16 @@ impl BackgroundNavigationLoadJob { { return (engine, Err(error.to_string()), false); } + let page_reservation = match self.page_reservation.resolve().await { + Ok(reservation) => reservation, + Err(error) => return (engine, Err(error), false), + }; let mut early_result_sent = false; let navigation = async { if let Some(navigation) = load_inline_html_navigation_with_engine_async( &mut engine, - self.page_reservation, + page_reservation.token, + page_reservation.stable_replacement.clone(), &self.load_inputs, &self.method, &self.raw_url, @@ -866,7 +1113,8 @@ impl BackgroundNavigationLoadJob { if let Some(navigation) = load_data_url_navigation_with_engine_async( &mut engine, - self.page_reservation, + page_reservation.token, + page_reservation.stable_replacement.clone(), &self.load_inputs, &self.method, &self.raw_url, @@ -935,7 +1183,8 @@ impl BackgroundNavigationLoadJob { ); return prepare_network_error_page_navigation_with_engine_async( &mut engine, - self.page_reservation, + page_reservation.token, + page_reservation.stable_replacement.clone(), &self.load_inputs, unreachable_url, self.method, @@ -957,13 +1206,15 @@ impl BackgroundNavigationLoadJob { response_status_may_use_http_error_page(response.status); if !super::downloads::response_headers_indicate_download(&response.headers) && !defer_early_result_for_http_error_body + && !response_status_prevents_document_commit(response.status) && let Some(early_result) = early_result.take() { early_result_sent = early_result.emit(); } let navigation = build_navigation_from_streaming_raw_response_with_engine_async( &mut engine, - self.page_reservation, + page_reservation.token, + page_reservation.stable_replacement, &self.load_inputs, requested_url, self.method, @@ -1009,9 +1260,14 @@ impl BackgroundStreamingResponseNavigationLoadJob { { return (engine, Err(error.to_string())); } + let page_reservation = match self.page_reservation.resolve().await { + Ok(reservation) => reservation, + Err(error) => return (engine, Err(error)), + }; let navigation = build_navigation_from_streaming_raw_response_with_engine_async( &mut engine, - self.page_reservation, + page_reservation.token, + page_reservation.stable_replacement, &self.load_inputs, self.requested_url, self.request_method, @@ -1150,6 +1406,7 @@ fn inline_html_navigation_source( async fn load_inline_html_navigation_with_engine_async( engine: &mut NavigationEngine, page_reservation: RendererPageReservationToken, + stable_page_target: Option, load_inputs: &TargetNavigationLoadInputs, method: &str, raw_url: &str, @@ -1178,6 +1435,7 @@ async fn load_inline_html_navigation_with_engine_async( prepare_navigation_from_captured_raw_response_with_engine_async( engine, page_reservation, + stable_page_target, load_inputs, document_url, method.to_owned(), @@ -1200,6 +1458,7 @@ async fn load_inline_html_navigation_with_engine_async( async fn load_data_url_navigation_with_engine_async( engine: &mut NavigationEngine, page_reservation: RendererPageReservationToken, + stable_page_target: Option, load_inputs: &TargetNavigationLoadInputs, method: &str, raw_url: &str, @@ -1218,6 +1477,7 @@ async fn load_data_url_navigation_with_engine_async( prepare_navigation_from_captured_raw_response_with_engine_async( engine, page_reservation, + stable_page_target, load_inputs, requested_url, method.to_owned(), @@ -1240,6 +1500,7 @@ async fn load_data_url_navigation_with_engine_async( async fn build_navigation_from_streaming_raw_response_with_engine_async( engine: &mut NavigationEngine, page_reservation: RendererPageReservationToken, + stable_page_target: Option, load_inputs: &TargetNavigationLoadInputs, requested_url: Url, request_method: String, @@ -1293,6 +1554,28 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( .with_negotiated_http_version(negotiated_http_version) .with_network_observation_journal(network_observation_journal.clone()); + if response_status_prevents_document_commit(response_status) { + body_progress_source.emit_response_metadata( + &request_method, + &request_headers, + initial_request_cookie_report.as_ref(), + &response.redirect_chain, + &final_url, + response_status, + &response_headers, + &response_cookie_reports, + &network_observation_journal, + network_extra_info_available, + response_from_cache, + negotiated_http_version, + ); + return Ok(no_commit_response_navigation( + final_url, + body_progress_source, + network_events, + )); + } + if super::downloads::response_headers_indicate_download(&response_headers) { return Ok(NavigationLoadOutcome::download(DownloadNavigation { final_url, @@ -1339,6 +1622,7 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( return prepare_browser_owned_error_page_navigation_with_engine_async( engine, page_reservation, + stable_page_target, load_inputs, final_url, request_method, @@ -1429,6 +1713,7 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( return Ok(NavigationLoadOutcome::response_commit_ready( ResponseCommitReady { prepared_page: Some(prepared_page), + stable_page_target, body_capture: Some(ResponseCommitBodyCapture::Ready(captured_body)), body_completion_sink, body_progress_source: body_progress_source_for_body_finish, @@ -1522,6 +1807,7 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( Ok(NavigationLoadOutcome::response_commit_ready( ResponseCommitReady { prepared_page: Some(prepared_page), + stable_page_target, body_capture: Some(ResponseCommitBodyCapture::Pending(body_capture_task)), body_completion_sink, body_progress_source: body_progress_source_for_body_finish, @@ -1543,6 +1829,45 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( } impl CdpConnection { + pub(crate) async fn commit_stable_page_navigation_document_for_session_owner_async( + &mut self, + session_id: Option<&str>, + expected: &StablePageNavigationCommitTarget, + navigation: ResponseCommitReady, + permit: PreparedDocumentPageCommitPermit, + ) -> Result { + if navigation.stable_page_target() != Some(expected) { + return Err(StablePageNavigationCommitError::page_preserved( + "prepared replacement target changed before protocol commit", + )); + } + if !self.target_page_residence_identity_is_current_for_session( + session_id, + expected.target_page(), + ) || self.renderer_page_residence_identity_for_session_owner(session_id) + != Some(expected.renderer_page()) + { + return Err(StablePageNavigationCommitError::page_preserved( + "stable navigation Page was superseded before protocol commit", + )); + } + let page = self + .runtime_session_owner_slot_mut(session_id) + .map_err(StablePageNavigationCommitError::page_preserved)? + .loaded_page_mut() + .ok_or_else(|| { + StablePageNavigationCommitError::page_preserved( + "stable navigation target no longer owns a live Page", + ) + })?; + if RendererPageResidenceIdentity::from_page(page) != expected.renderer_page() { + return Err(StablePageNavigationCommitError::page_preserved( + "stable navigation target resolved to a different renderer Page", + )); + } + navigation.commit_page_replacement(permit, page).await + } + fn navigation_load_inputs_for_navigation( &self, navigation: &NavigationDispatchState, @@ -1615,7 +1940,8 @@ impl CdpConnection { network_observation_journal: &NetworkObservationJournal, body_progress_source: MainDocumentBodyProgressSource, ) -> Result, String> { - if super::downloads::response_headers_indicate_download(&response.headers) + if response_status_prevents_document_commit(response.status) + || super::downloads::response_headers_indicate_download(&response.headers) || response_headers_indicate_xml_document(&response.headers) || response_status_may_use_http_error_page(response.status) { @@ -1679,14 +2005,17 @@ impl CdpConnection { let main_document_commit = load_inputs .main_document_commit_for_final_url(&final_url, None) .map(Arc::new); - let page_reservation = self.reserve_renderer_page_for_session_owner( - navigation.navigate_session_id.as_deref(), - &load_inputs, - &engine, - ); + let page_reservation = self + .start_navigation_renderer_page_reservation_for_session_owner( + navigation.navigate_session_id.as_deref(), + &load_inputs, + &mut engine, + ) + .resolve() + .await?; let prepared_page = engine .prepare_streaming_raw_page_from_external_body_with_storage_and_inspector_session_restores_async( - page_reservation, + page_reservation.token, page_storage.into_navigation_storage(), requested_url.clone(), final_url.clone(), @@ -1737,6 +2066,7 @@ impl CdpConnection { } Ok(Some(PausedResponsePreparedDocument { prepared_page, + stable_page_target: page_reservation.stable_replacement, renderer_body_tx, renderer_completion_tx, body_progress_source, @@ -1804,6 +2134,27 @@ impl CdpConnection { pub(crate) fn runtime_session_owner_should_start_initial_document_navigation( &self, session_id: Option<&str>, + ) -> bool { + // An accepted popup activation owns its frozen destination through a + // target-local typed claim. Generic Page/Runtime entry points must not + // reconstruct that navigation from the mutable target URL, including + // after the claim has gone stale and been consumed. + if self.runtime_session_owner_has_popup_target_navigation_authority(session_id) { + return false; + } + self.runtime_session_owner_should_start_unclaimed_initial_document_navigation(session_id) + } + + pub(crate) fn runtime_session_owner_should_start_claimed_popup_initial_document_navigation( + &self, + session_id: Option<&str>, + ) -> bool { + self.runtime_session_owner_should_start_unclaimed_initial_document_navigation(session_id) + } + + fn runtime_session_owner_should_start_unclaimed_initial_document_navigation( + &self, + session_id: Option<&str>, ) -> bool { if !self.runtime_session_owner_initial_empty_document_has_replacement_url(session_id) { return false; @@ -1863,13 +2214,23 @@ impl CdpConnection { .unwrap_or_else(|| Url::parse("about:blank").expect("about:blank should be valid")); let (fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type) = load_inputs.fetch_subresource_interception; - let mut engine = self.background_navigation_engine_for_load_inputs(&load_inputs); let page_storage = load_inputs.page_storage_handles(); let top_level_storage_key = self.runtime_session_owner_initial_empty_document_storage_key(session_id); + let staged_page_reservation = self + .runtime_session_owner_slot_mut(session_id)? + .take_initial_document_page_reservation(); + let mut engine = match staged_page_reservation { + Some(page_reservation) => self.background_navigation_engine_for_page_reservation( + &load_inputs, + page_reservation, + )?, + None => self.background_navigation_engine_for_load_inputs(&load_inputs), + }; self.runtime_session_owner_slot_mut(session_id)? .start_initial_document_page_build(); - let page_reservation = engine.reserve_page_for_creation(); + let page_reservation = + staged_page_reservation.unwrap_or_else(|| engine.reserve_page_for_creation()); let renderer_page = RendererPageResidenceIdentity::from_parts( page_reservation.local_host_id(), page_reservation.page_id(), @@ -2193,6 +2554,9 @@ impl CdpConnection { NavigationLoadOutcome::Download(_) => { Err("navigation resolved to a download".to_owned()) } + NavigationLoadOutcome::NoCommitResponse(_) => { + Err(NET_ERR_ABORTED_ERROR_TEXT.to_owned()) + } NavigationLoadOutcome::NetworkFailure(error_text) => Err(error_text), } } @@ -2287,6 +2651,7 @@ impl CdpConnection { if let Some(navigation) = load_data_url_navigation_with_engine_async( &mut self.engine, page_reservation, + None, &load_inputs, method, raw_url, @@ -2299,15 +2664,19 @@ impl CdpConnection { } } else { let mut inline_engine = self.background_navigation_engine_for_load_inputs(&load_inputs); - let page_reservation = self.reserve_renderer_page_for_session_owner( - session_id, - &load_inputs, - &inline_engine, - ); + let page_reservation = self + .start_navigation_renderer_page_reservation_for_session_owner( + session_id, + &load_inputs, + &mut inline_engine, + ) + .resolve() + .await?; if let Some(navigation) = self .load_inline_html_navigation_with_engine_async( &mut inline_engine, - page_reservation, + page_reservation.token, + page_reservation.stable_replacement.clone(), &load_inputs, method, raw_url, @@ -2321,7 +2690,8 @@ impl CdpConnection { } if let Some(navigation) = load_data_url_navigation_with_engine_async( &mut inline_engine, - page_reservation, + page_reservation.token, + page_reservation.stable_replacement, &load_inputs, method, raw_url, @@ -2380,11 +2750,11 @@ impl CdpConnection { // Page policy remains owned by the job's NavigationEngine. let shared_resource_runtime = self.shared_resource_runtime_for_navigation_load_inputs(&load_inputs); - let engine = self.background_navigation_engine_for_load_inputs(&load_inputs); - let page_reservation = self.reserve_renderer_page_for_session_owner( + let mut engine = self.background_navigation_engine_for_load_inputs(&load_inputs); + let page_reservation = self.start_navigation_renderer_page_reservation_for_session_owner( navigation.navigate_session_id.as_deref(), &load_inputs, - &engine, + &mut engine, ); Some(BackgroundNavigationLoadJob { engine, @@ -2430,11 +2800,11 @@ impl CdpConnection { let load_inputs = self.navigation_load_inputs_for_navigation(navigation); let shared_resource_runtime = self.shared_resource_runtime_for_navigation_load_inputs(&load_inputs); - let engine = self.background_navigation_engine_for_load_inputs(&load_inputs); - let page_reservation = self.reserve_renderer_page_for_session_owner( + let mut engine = self.background_navigation_engine_for_load_inputs(&load_inputs); + let page_reservation = self.start_navigation_renderer_page_reservation_for_session_owner( navigation.navigate_session_id.as_deref(), &load_inputs, - &engine, + &mut engine, ); BackgroundStreamingResponseNavigationLoadJob { engine, @@ -2459,19 +2829,57 @@ impl CdpConnection { /// navigation future is still running. The protocol target therefore must /// own the reservation at job construction time rather than trying to infer /// it from `ResponseCommitReady` after the future completes. - fn reserve_renderer_page_for_session_owner( + fn start_navigation_renderer_page_reservation_for_session_owner( &mut self, session_id: Option<&str>, load_inputs: &TargetNavigationLoadInputs, - engine: &NavigationEngine, - ) -> RendererPageReservationToken { + engine: &mut NavigationEngine, + ) -> NavigationRendererPageReservation { + if load_inputs.has_main_document_commit_seed() + && let (Some(target_page), Some(renderer_page)) = ( + self.target_page_residence_identity_for_session(session_id), + self.renderer_page_residence_identity_for_session_owner(session_id), + ) + { + assert_eq!( + load_inputs.browser_context_id.as_deref(), + Some(target_page.browser_context_id()), + "stable navigation Page replacement must stay in its captured browser context" + ); + let pending = self + .runtime_session_owner_slot(session_id) + .expect("stable navigation Page replacement must retain its target slot") + .loaded_page() + .expect("stable navigation Page replacement must retain its live Page") + .start_renderer_document_replacement_reservation(); + if engine.renderer_owner_local_host_id() != renderer_page.owner_local_host_id() { + let replacement_engine = match self + .background_navigation_engine_for_renderer_owner_local_host_id( + load_inputs, + renderer_page.owner_local_host_id(), + ) { + Ok(engine) => engine, + Err(error) => return NavigationRendererPageReservation::Failed(error), + }; + *engine = replacement_engine; + } + self.bind_renderer_page_output_owner(renderer_page, target_page.clone()); + return NavigationRendererPageReservation::StableReplacement { + pending, + target: StablePageNavigationCommitTarget { + target_page, + renderer_page, + }, + }; + } + let page_reservation = engine.reserve_page_for_creation(); self.bind_renderer_page_reservation_for_session_owner( session_id, load_inputs, page_reservation, ); - page_reservation + NavigationRendererPageReservation::Fresh(page_reservation) } fn bind_renderer_page_reservation_for_session_owner( @@ -2514,7 +2922,7 @@ impl CdpConnection { // NavigationEngine wrapper; their page contexts still live under the // same renderer owner. Different browser contexts keep distinct // renderer owners. - let mut engine = if self + let engine = if self .engine .browser_context_runtime() .shares_state_with(&load_inputs.renderer_runtime.runtime()) @@ -2536,6 +2944,48 @@ impl CdpConnection { ) .expect("navigation load BrowserContext owner must be live") }; + self.configure_background_navigation_engine(engine, load_inputs) + } + + fn background_navigation_engine_for_page_reservation( + &self, + load_inputs: &TargetNavigationLoadInputs, + page_reservation: RendererPageReservationToken, + ) -> Result { + self.background_navigation_engine_for_renderer_owner_local_host_id( + load_inputs, + page_reservation.local_host_id(), + ) + } + + fn background_navigation_engine_for_renderer_owner_local_host_id( + &self, + load_inputs: &TargetNavigationLoadInputs, + owner_local_host_id: moli_core::RendererOwnerLocalHostId, + ) -> Result { + let renderer_owner_source = std::iter::once(&*self.engine) + .chain(self.retained_background_navigation_engines.values()) + .find(|engine| engine.renderer_owner_local_host_id() == owner_local_host_id) + .ok_or_else(|| { + "renderer-owned Page reservation has no live NavigationEngine owner".to_owned() + })?; + let engine = NavigationEngine::new_with_fetch_config_and_shared_renderer_owner( + self.fetch_config_for_load_inputs(load_inputs), + renderer_owner_source, + self.engine.optional_resource_fetch_mask(), + self.engine.subframe_loading_enabled(), + ) + .map_err(|error| { + format!("failed to bind auxiliary Page reservation to its renderer owner: {error:#}") + })?; + Ok(self.configure_background_navigation_engine(engine, load_inputs)) + } + + fn configure_background_navigation_engine( + &self, + mut engine: NavigationEngine, + load_inputs: &TargetNavigationLoadInputs, + ) -> NavigationEngine { self.apply_fetch_overrides_to_background_navigation_engine(&mut engine, load_inputs); if !self.active_resource_runtime_matches_navigation_load_inputs(load_inputs) { // Sharing the renderer owner must not implicitly share a transport @@ -2690,6 +3140,7 @@ impl CdpConnection { load_inline_html_navigation_with_engine_async( &mut self.engine, page_reservation, + None, load_inputs, method, raw_url, @@ -2703,6 +3154,7 @@ impl CdpConnection { &mut self, engine: &mut NavigationEngine, page_reservation: RendererPageReservationToken, + stable_page_target: Option, load_inputs: &TargetNavigationLoadInputs, method: &str, raw_url: &str, @@ -2712,6 +3164,7 @@ impl CdpConnection { load_inline_html_navigation_with_engine_async( engine, page_reservation, + stable_page_target, load_inputs, method, raw_url, @@ -2896,29 +3349,18 @@ impl CdpConnection { from_cache: false, negotiated_http_version: None, }; - let page_reservation = self.engine.reserve_page_for_creation(); - self.bind_renderer_page_reservation_for_session_owner( + self.build_navigation_from_captured_raw_response_with_load_inputs_async( session_id, load_inputs, - page_reservation, - ); - prepare_navigation_from_captured_raw_response_with_engine_async( - &mut self.engine, - page_reservation, - load_inputs, requested_url, request_method, request_headers, head, response_body, - body_progress_source, network_observation_journal, - None, - false, - RendererReplyBoundary::Stage, + body_progress_source, ) .await - .map(NavigationLoadOutcome::response_commit_ready) } async fn build_loaded_navigation_from_buffered_response_with_request_cookie_report_async( @@ -3017,6 +3459,7 @@ impl CdpConnection { initial_runtime_realms: diagnostics.initial_runtime_realms, renderer_output_predecessor: diagnostics.renderer_output_predecessor, main_document_commit, + document_continuation_observer: diagnostics.document_continuation_observer, document_progress_transfer: CompletedDocumentProgressTransfer::new_captured( captured_response_body.unwrap_or_else(|| CapturedBody::from_string(response_body)), false, @@ -3307,6 +3750,7 @@ impl CdpConnection { initial_runtime_realms: diagnostics.initial_runtime_realms, renderer_output_predecessor: diagnostics.renderer_output_predecessor, main_document_commit, + document_continuation_observer: diagnostics.document_continuation_observer, document_progress_transfer: CompletedDocumentProgressTransfer::new_captured( captured_response_body, false, @@ -3387,7 +3831,9 @@ impl CdpConnection { ) -> Result { let (response, network_observation_journal) = response.into_parts_with_observation_journal(); - if super::downloads::response_headers_indicate_download(&response.headers) { + if !response_status_prevents_document_commit(response.status) + && super::downloads::response_headers_indicate_download(&response.headers) + { return Ok(NavigationLoadOutcome::download( self.build_download_from_raw_response( request_method, @@ -3450,6 +3896,43 @@ impl CdpConnection { network_observation_journal: NetworkObservationJournal, body_progress_source: MainDocumentBodyProgressSource, ) -> Result { + if response_status_prevents_document_commit(head.status) { + let network_extra_info_available = !network_observation_journal.is_empty(); + body_progress_source.emit_response_metadata( + &request_method, + &request_headers, + head.request_cookie_report.as_ref(), + &head.redirect_chain, + &head.final_url, + head.status, + &head.headers, + &head.cookie_set_reports, + &network_observation_journal, + network_extra_info_available, + head.from_cache, + head.negotiated_http_version, + ); + let final_url = head.final_url.clone(); + let network_events = CompletedMainDocumentNetworkEvents::new( + request_method, + request_headers, + head.request_cookie_report, + head.status, + head.headers, + head.cookie_set_reports, + head.redirect_chain.into_iter().map(Into::into).collect(), + network_extra_info_available, + head.from_cache, + ) + .with_negotiated_http_version(head.negotiated_http_version) + .with_network_observation_journal(network_observation_journal); + return Ok(no_commit_response_navigation( + final_url, + body_progress_source, + network_events, + )); + } + if super::downloads::response_headers_indicate_download(&head.headers) { let body_bytes = body.materialize_bytes().map_err(|error| { format!("failed to materialize captured download body: {error}") @@ -3464,15 +3947,41 @@ impl CdpConnection { )); } - let page_reservation = self.engine.reserve_page_for_creation(); - self.bind_renderer_page_reservation_for_session_owner( - session_id, - load_inputs, - page_reservation, - ); - prepare_navigation_from_captured_raw_response_with_engine_async( - &mut self.engine, - page_reservation, + if load_inputs.browser_context_id.is_none() { + let page_reservation = self.engine.reserve_page_for_creation(); + return prepare_navigation_from_captured_raw_response_with_engine_async( + &mut self.engine, + page_reservation, + None, + load_inputs, + requested_url, + request_method, + request_headers, + head, + body, + body_progress_source, + network_observation_journal, + None, + false, + RendererReplyBoundary::Stage, + ) + .await + .map(NavigationLoadOutcome::response_commit_ready); + } + + let mut engine = self.background_navigation_engine_for_load_inputs(load_inputs); + let page_reservation = self + .start_navigation_renderer_page_reservation_for_session_owner( + session_id, + load_inputs, + &mut engine, + ) + .resolve() + .await?; + let navigation = prepare_navigation_from_captured_raw_response_with_engine_async( + &mut engine, + page_reservation.token, + page_reservation.stable_replacement, load_inputs, requested_url, request_method, @@ -3485,8 +3994,10 @@ impl CdpConnection { false, RendererReplyBoundary::Stage, ) - .await - .map(NavigationLoadOutcome::response_commit_ready) + .await?; + Ok(NavigationLoadOutcome::response_commit_ready( + navigation.with_navigation_engine(engine), + )) } pub async fn build_navigation_from_streaming_raw_response_async( @@ -3596,6 +4107,7 @@ impl CdpConnection { return build_navigation_from_streaming_raw_response_with_engine_async( &mut self.engine, page_reservation, + None, load_inputs, requested_url, request_method, @@ -3614,11 +4126,18 @@ impl CdpConnection { } let mut engine = self.background_navigation_engine_for_load_inputs(load_inputs); - let page_reservation = - self.reserve_renderer_page_for_session_owner(session_id, load_inputs, &engine); + let page_reservation = self + .start_navigation_renderer_page_reservation_for_session_owner( + session_id, + load_inputs, + &mut engine, + ) + .resolve() + .await?; let navigation = build_navigation_from_streaming_raw_response_with_engine_async( &mut engine, - page_reservation, + page_reservation.token, + page_reservation.stable_replacement, load_inputs, requested_url, request_method, @@ -3706,6 +4225,7 @@ impl CdpConnection { async fn prepare_navigation_from_captured_raw_response_with_engine_async( engine: &mut NavigationEngine, page_reservation: RendererPageReservationToken, + stable_page_target: Option, load_inputs: &TargetNavigationLoadInputs, requested_url: Url, request_method: String, @@ -3743,6 +4263,7 @@ async fn prepare_navigation_from_captured_raw_response_with_engine_async( return prepare_browser_owned_error_page_navigation_with_engine_async( engine, page_reservation, + stable_page_target, load_inputs, unreachable_url, request_method, @@ -3756,6 +4277,7 @@ async fn prepare_navigation_from_captured_raw_response_with_engine_async( prepare_captured_document_response_with_engine_async( engine, page_reservation, + stable_page_target, load_inputs, requested_url, request_method, @@ -3775,6 +4297,7 @@ async fn prepare_navigation_from_captured_raw_response_with_engine_async( async fn prepare_captured_document_response_with_engine_async( engine: &mut NavigationEngine, page_reservation: RendererPageReservationToken, + stable_page_target: Option, load_inputs: &TargetNavigationLoadInputs, requested_url: Url, request_method: String, @@ -3891,6 +4414,7 @@ async fn prepare_captured_document_response_with_engine_async( Ok(ResponseCommitReady { prepared_page: Some(prepared_page), + stable_page_target, body_capture: Some(ResponseCommitBodyCapture::Pending(body_capture_task)), body_completion_sink: None, body_progress_source, diff --git a/moli-protocol/src/conn/scheduler_hooks.rs b/moli-protocol/src/conn/scheduler_hooks.rs index 7be1aeff0c..0489cfafd9 100644 --- a/moli-protocol/src/conn/scheduler_hooks.rs +++ b/moli-protocol/src/conn/scheduler_hooks.rs @@ -8,6 +8,11 @@ pub(super) struct CdpSchedulerHooks { background_navigation_completion_sender: Option< tokio::sync::mpsc::UnboundedSender, >, + document_continuation_completion_sender: Option< + tokio::sync::mpsc::UnboundedSender< + crate::domains::page::BackgroundDocumentContinuationCompletion, + >, + >, renderer_publication_sender: Option, runtime_inspector_response_ready_sender: Option, } @@ -21,6 +26,10 @@ impl CdpSchedulerHooks { self.background_event_sender.clone() } + pub(super) fn has_background_event_sender(&self) -> bool { + self.background_event_sender.is_some() + } + pub(super) fn set_runtime_inspector_response_ready_sender( &mut self, sender: RuntimeInspectorResponseReadySender, @@ -55,6 +64,29 @@ impl CdpSchedulerHooks { self.background_navigation_completion_sender.is_some() } + pub(super) fn set_document_continuation_completion_sender( + &mut self, + sender: tokio::sync::mpsc::UnboundedSender< + crate::domains::page::BackgroundDocumentContinuationCompletion, + >, + ) { + self.document_continuation_completion_sender = Some(sender); + } + + pub(super) fn document_continuation_completion_sender( + &self, + ) -> Option< + tokio::sync::mpsc::UnboundedSender< + crate::domains::page::BackgroundDocumentContinuationCompletion, + >, + > { + self.document_continuation_completion_sender.clone() + } + + pub(super) fn has_document_continuation_completion_sender(&self) -> bool { + self.document_continuation_completion_sender.is_some() + } + pub(super) fn set_renderer_publication_sender( &mut self, sender: RendererOutputTransportSender, diff --git a/moli-protocol/src/conn/scheduler_state.rs b/moli-protocol/src/conn/scheduler_state.rs index 6c05d6cdcb..4388495aea 100644 --- a/moli-protocol/src/conn/scheduler_state.rs +++ b/moli-protocol/src/conn/scheduler_state.rs @@ -7,8 +7,77 @@ use serde_json::{Value, json}; const RECENT_ACTIVITY_TRACE_LIMIT: usize = 128; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct BackgroundNavigationGateKey { + target_id: Option, + session_id: Option, + frame_id: String, + loader_id: String, + navigation_request_id: Option, +} + +impl BackgroundNavigationGateKey { + pub(crate) fn for_navigation( + token: &super::state::DocumentNavigationToken, + state: &super::state::NavigationDispatchState, + ) -> Self { + // Keep this key limited to fields that are stable from background task + // spawn through lifecycle completion. `navigate_id` is intentionally + // excluded because early Page.navigate replies clear it before the + // completion is sent. + Self { + target_id: Some(token.target_id.clone()), + session_id: state + .navigate_session_id + .clone() + .or_else(|| state.session_id.clone()), + frame_id: state.frame_id.clone(), + loader_id: token.loader_id.clone(), + navigation_request_id: Some(token.request_id.get()), + } + } + + /// Reports whether this gate belongs to the renderer/session owner used by + /// an incoming CDP command. + pub fn matches_session_owner(&self, session_id: Option<&str>) -> bool { + self.session_id.as_deref() == session_id + } + + pub(crate) fn target_id(&self) -> Option<&str> { + self.target_id.as_deref() + } + + pub(crate) fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + pub(crate) fn loader_id(&self) -> &str { + &self.loader_id + } + + #[cfg(feature = "test-support")] + pub(crate) fn from_test_parts( + target_id: Option, + session_id: Option, + frame_id: String, + loader_id: String, + navigation_request_id: Option, + ) -> Self { + Self { + target_id, + session_id, + frame_id, + loader_id, + navigation_request_id, + } + } +} + #[derive(Debug)] pub enum CdpSchedulerEvent { + DocumentContinuationStarted { + key: BackgroundNavigationGateKey, + }, ProtocolWorkPublished { work: crate::domains::activity::ProtocolSchedulerWork, }, diff --git a/moli-protocol/src/conn/state/attachment_identity.rs b/moli-protocol/src/conn/state/attachment_identity.rs index 163b7770f8..c4d4849f43 100644 --- a/moli-protocol/src/conn/state/attachment_identity.rs +++ b/moli-protocol/src/conn/state/attachment_identity.rs @@ -42,6 +42,10 @@ impl NavigationRequestId { "navigation request id", )) } + + pub(crate) fn get(self) -> u64 { + self.0.get() + } } fn allocate_nonzero_u64(counter: &AtomicU64, name: &str) -> NonZeroU64 { diff --git a/moli-protocol/src/conn/state/mod.rs b/moli-protocol/src/conn/state/mod.rs index 018774b0d3..9fe5d3e64f 100644 --- a/moli-protocol/src/conn/state/mod.rs +++ b/moli-protocol/src/conn/state/mod.rs @@ -138,7 +138,7 @@ pub(crate) use emulation::{ pub(crate) use navigation_outcome::{CompletedDownloadBody, CompletedDownloadBodyArtifact}; pub use navigation_outcome::{ DownloadNavigation, LoadedNavigation, NavigationDispatchState, NavigationLoadOutcome, - NavigationRequestLoadPolicy, TargetInfo, + NavigationRequestLoadPolicy, NoCommitResponseNavigation, TargetInfo, }; pub(crate) use navigation_outcome::{ NETWORK_ERROR_PAGE_URL, NavigationResultProjection, NavigationSourceDocumentSecurityContext, diff --git a/moli-protocol/src/conn/state/navigation.rs b/moli-protocol/src/conn/state/navigation.rs index e1d777e19f..373fa15625 100644 --- a/moli-protocol/src/conn/state/navigation.rs +++ b/moli-protocol/src/conn/state/navigation.rs @@ -125,6 +125,15 @@ impl TargetNavigationHistoryState { self.pending_update = None; } + pub(crate) fn retain_pending_initial_empty_document_replacement(&mut self) { + if !matches!( + self.pending_update, + Some(PendingNavigationHistoryUpdate::ReplaceInitialEmptyDocument) + ) { + self.pending_update = None; + } + } + pub(crate) fn entry_url(&self, entry_id: i32) -> Option { self.entries .iter() diff --git a/moli-protocol/src/conn/state/navigation_outcome.rs b/moli-protocol/src/conn/state/navigation_outcome.rs index 74259aaf95..420830f61d 100644 --- a/moli-protocol/src/conn/state/navigation_outcome.rs +++ b/moli-protocol/src/conn/state/navigation_outcome.rs @@ -13,6 +13,7 @@ use crate::conn::ResponseCommitReady; use crate::devtools_runtime::DevToolsProtocol; use crate::domains::network::{ CompletedDocumentProgressTransfer, CompletedDownloadProgressTransfer, + CompletedNoCommitResponseProgressTransfer, }; use super::browser_context::BrowserContext; @@ -193,6 +194,8 @@ pub struct LoadedNavigation { pub initial_runtime_realms: Vec, pub renderer_output_predecessor: Option, pub(crate) main_document_commit: Option>, + pub(crate) document_continuation_observer: + Option, pub(crate) document_progress_transfer: CompletedDocumentProgressTransfer, pub(crate) navigation_engine: Option, pub(crate) network_error_page: Option, @@ -228,11 +231,24 @@ pub struct DownloadNavigation { pub(crate) progress_transfer: CompletedDownloadProgressTransfer, } +/// A navigation response that is observable on the network but cannot commit +/// a new Document. +/// +/// HTTP 204 and 205 are the current producers. Keeping this distinct from a +/// transport failure is important: the response and redirect chain remain +/// observable, while the previously committed Document must stay resident. +#[derive(Debug)] +pub struct NoCommitResponseNavigation { + pub final_url: Url, + pub(crate) progress_transfer: CompletedNoCommitResponseProgressTransfer, +} + #[derive(Debug)] pub enum NavigationLoadOutcome { ResponseCommitReady(Box), Loaded(Box), Download(Box), + NoCommitResponse(Box), NetworkFailure(String), } @@ -249,6 +265,10 @@ impl NavigationLoadOutcome { Self::Download(Box::new(navigation)) } + pub(crate) fn no_commit_response(navigation: NoCommitResponseNavigation) -> Self { + Self::NoCommitResponse(Box::new(navigation)) + } + pub(crate) fn network_failure(error_text: String) -> Self { Self::NetworkFailure(error_text) } @@ -260,6 +280,7 @@ impl NavigationLoadOutcome { } Self::Loaded(navigation) => Self::loaded(navigation.with_navigation_engine(engine)), Self::Download(navigation) => Self::Download(navigation), + Self::NoCommitResponse(navigation) => Self::NoCommitResponse(navigation), Self::NetworkFailure(error_text) => Self::NetworkFailure(error_text), } } diff --git a/moli-protocol/src/conn/state/page_slot.rs b/moli-protocol/src/conn/state/page_slot.rs index 3ee27ba80a..43f81e78ce 100644 --- a/moli-protocol/src/conn/state/page_slot.rs +++ b/moli-protocol/src/conn/state/page_slot.rs @@ -6,8 +6,9 @@ use moli_core::page::{ RendererDocumentLifecycleSnapshot, RendererDocumentLifecycleWaitOutcome, RendererDocumentLifecycleWaiter, RendererDocumentToken, RendererFrameToken, RendererLifecycleEpoch, RendererLifecycleEventStamp, RendererLifecycleStartReason, - RendererLifecycleTerminationStamp, RendererPageCreationArtifacts, + RendererLifecycleTerminationStamp, RendererPageCreationArtifacts, RendererPendingAuxiliaryPage, }; +use moli_core::runtime::RendererPageReservationToken; use tokio::sync::watch; use super::document_lifecycle_observer::{ @@ -381,6 +382,8 @@ pub(crate) struct TargetPageSlot { renderer_document_lifecycle_waiters: Vec, root_post_load_observation: Option, initial_document_page_build_completion: Option>>>, + auxiliary_browsing_context_id: Option, + initial_document_page_reservation: Option, pending_renderer_page: Option, } @@ -451,6 +454,37 @@ impl TargetPageSlot { self.initial_document_page_build_completion = Some(sender); } + pub(crate) fn stage_pending_auxiliary_page( + &mut self, + pending: RendererPendingAuxiliaryPage, + ) -> bool { + if self.loaded_page.is_some() + || self.loaded_page_absence_reason + != TargetPageAbsenceReason::InitialDocumentPageBuildPending + || self.auxiliary_browsing_context_id.is_some() + || self.initial_document_page_reservation.is_some() + { + return false; + } + self.auxiliary_browsing_context_id = Some(pending.browsing_context_id()); + self.initial_document_page_reservation = Some(pending.page_reservation()); + true + } + + pub(crate) fn take_initial_document_page_reservation( + &mut self, + ) -> Option { + self.initial_document_page_reservation.take() + } + + pub(crate) fn auxiliary_browsing_context_id(&self) -> Option { + self.auxiliary_browsing_context_id + } + + pub(crate) fn has_staged_initial_document_page_reservation(&self) -> bool { + self.initial_document_page_reservation.is_some() + } + pub(crate) fn bind_initial_document_page_build_renderer_page( &mut self, renderer_page: RendererPageResidenceIdentity, diff --git a/moli-protocol/src/conn/state/parking.rs b/moli-protocol/src/conn/state/parking.rs index 9e18ac57f9..811c8bdfac 100644 --- a/moli-protocol/src/conn/state/parking.rs +++ b/moli-protocol/src/conn/state/parking.rs @@ -105,6 +105,13 @@ impl BackgroundTarget { self.session_id.is_some() } + #[cfg(test)] + pub(crate) fn auxiliary_browsing_context_id(&self) -> Option { + self.runtime_slot + .page_slot() + .auxiliary_browsing_context_id() + } + pub(crate) fn is_session(&self, session_id: &str) -> bool { self.session_id() == Some(session_id) } @@ -1115,6 +1122,15 @@ impl TargetOwnerState { self.navigation_history_state.clear_pending_update(); } + pub(crate) fn resolve_no_commit_response_navigation_history(&mut self) { + if self.is_on_initial_empty_document() == Some(true) { + self.navigation_history_state + .retain_pending_initial_empty_document_replacement(); + } else { + self.navigation_history_state.clear_pending_update(); + } + } + pub(crate) fn navigation_history_entry_url( &mut self, page_snapshot: Option<(String, String)>, diff --git a/moli-protocol/src/conn/state/runtime_slot.rs b/moli-protocol/src/conn/state/runtime_slot.rs index 8df55d07df..1d0ce82584 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -1,13 +1,18 @@ use moli_core::page::{ Page, RendererAgentAttachmentId, RendererDevToolsAgentToken, RendererDocumentLifecycleIdentity, - RendererRuntimeInspectorMessageBatch, ScriptNetworkOutputItem, SubresourceNetworkRequestHandle, + RendererPendingAuxiliaryPage, RendererRuntimeInspectorMessageBatch, ScriptNetworkOutputItem, + SubresourceNetworkRequestHandle, }; #[cfg(test)] use moli_core::page::{RendererPageDiagnosticsSnapshot, RendererRuntimeObservableSourceSummary}; +use moli_core::runtime::RendererPageReservationToken; use serde_json::{Value, json}; use crate::{ - conn::{CapturedBody, ConnectionNetworkRequestIdAllocator}, + conn::{ + CapturedBody, ConnectionNetworkRequestIdAllocator, PopupTargetNavigationAuthorityState, + PopupTargetNavigationClaimIdentity, PopupTargetNavigationOwnerAction, + }, domains::{ log_output_state::{TargetLogOutputQueueState, TargetNetworkLogEntry}, network::{ @@ -56,6 +61,7 @@ pub(in crate::conn::state) struct TargetNetworkRequestCounters { #[derive(Debug, Default)] pub(crate) struct TargetRuntimeSlot { page_slot: TargetPageSlot, + popup_target_navigation_authority: Option, devtools_renderer_channel: DevToolsRendererChannel, pending_renderer_call_replacements: PreparedRendererCallReplacements, javascript_dialog_scope: TargetJavaScriptDialogScope, @@ -151,6 +157,59 @@ impl TargetRuntimeSlot { self.page_slot.has_loaded_page() } + pub(crate) fn stage_initial_popup_target_navigation_owner_action( + &mut self, + action: PopupTargetNavigationOwnerAction, + ) -> bool { + if self.popup_target_navigation_authority.is_some() { + return false; + } + self.popup_target_navigation_authority = + Some(PopupTargetNavigationAuthorityState::Held(action)); + true + } + + pub(crate) fn take_held_initial_popup_target_navigation_owner_action( + &mut self, + ) -> Option { + let authority = self.popup_target_navigation_authority.take()?; + match authority { + PopupTargetNavigationAuthorityState::Held(action) => { + self.popup_target_navigation_authority = Some( + PopupTargetNavigationAuthorityState::Published(action.claim_identity().clone()), + ); + Some(action) + } + authority => { + self.popup_target_navigation_authority = Some(authority); + None + } + } + } + + pub(crate) fn consume_published_popup_target_navigation_claim( + &mut self, + expected: &PopupTargetNavigationClaimIdentity, + ) -> bool { + let matches = matches!( + self.popup_target_navigation_authority.as_ref(), + Some(PopupTargetNavigationAuthorityState::Published(actual)) if actual == expected + ); + if matches { + self.popup_target_navigation_authority = Some( + PopupTargetNavigationAuthorityState::Consumed(expected.clone()), + ); + } + matches + } + + pub(crate) fn has_popup_target_navigation_authority(&self) -> bool { + self.popup_target_navigation_authority + .as_ref() + .map(PopupTargetNavigationAuthorityState::claim_identity) + .is_some() + } + pub(crate) fn has_pending_initial_document_page_build(&self) -> bool { matches!( self.page_slot.loaded_page_absence_reason(), @@ -176,6 +235,19 @@ impl TargetRuntimeSlot { self.page_slot.start_initial_document_page_build(); } + pub(crate) fn stage_pending_auxiliary_page( + &mut self, + pending: RendererPendingAuxiliaryPage, + ) -> bool { + self.page_slot.stage_pending_auxiliary_page(pending) + } + + pub(crate) fn take_initial_document_page_reservation( + &mut self, + ) -> Option { + self.page_slot.take_initial_document_page_reservation() + } + pub(crate) fn bind_initial_document_page_build_renderer_page( &mut self, renderer_page: RendererPageResidenceIdentity, @@ -438,6 +510,27 @@ impl TargetRuntimeSlot { Ok(()) } + pub(crate) fn commit_loaded_navigation_document_replacement( + &mut self, + transaction: &CommittedRendererAgentAttachment, + ) -> Result, DevToolsRendererChannelError> { + let current = transaction.current(); + let page = self + .page_slot + .loaded_page_mut() + .ok_or(DevToolsRendererChannelError::CommittedCandidateMismatch)?; + if self.devtools_renderer_channel.current() != Some(current) + || page.renderer_devtools_agent_token() != current.agent_token() + { + return Err(DevToolsRendererChannelError::CommittedCandidateMismatch); + } + page.bind_renderer_agent_attachment(current.id()); + self.javascript_dialog_scope.retire(); + self.reset_document_output_state(); + self.ingest_owner_page_observable_output_updates(); + Ok(transaction.previous()) + } + pub(crate) fn commit_loaded_navigation_renderer_attachment( &mut self, page: &mut Page, @@ -604,6 +697,10 @@ impl TargetRuntimeSlot { .loaded_page_absence_reason() .map(TargetPageAbsenceReason::label), "pageAttachmentId": self.page_attachment_id().map(TargetPageAttachmentId::get), + "auxiliaryBrowsingContextId": self.page_slot.auxiliary_browsing_context_id(), + "hasStagedInitialDocumentPageReservation": self + .page_slot + .has_staged_initial_document_page_reservation(), "hasPendingDocumentNavigation": self.has_pending_document_navigation(), "rendererChannelClosed": self.devtools_renderer_channel.is_closed(), "rendererChannelHasCurrentAttachment": diff --git a/moli-protocol/src/conn/state/tests.rs b/moli-protocol/src/conn/state/tests.rs index 0ae9ab7ba0..52f93e3671 100644 --- a/moli-protocol/src/conn/state/tests.rs +++ b/moli-protocol/src/conn/state/tests.rs @@ -1274,6 +1274,7 @@ fn committed_document_navigation_state_clears_document_local_runtime_state() { source: "globalThis.fromPreload = true;".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); @@ -1333,6 +1334,7 @@ fn target_parking_store_mutates_target_owner_state_with_default_elision() { source: "globalThis.fromPreload = true;".to_owned(), world_name: Some("utility".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); @@ -1540,6 +1542,11 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() { .materialized() ); + // Model an auxiliary target whose requested initial navigation must + // replace its internally materialized about:blank entry when it finally + // commits, even if an earlier response did not commit a Document. + context.mark_target_initial_url_replaces_empty_document("TID-initial-active"); + let token = context .start_document_navigation_for_active_target("LOADER-initial-active".to_owned()) .expect("active target should start document navigation"); @@ -1555,6 +1562,10 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() { Some("TID-initial-active"), "LOADER-initial-active", ); + context + .active_target + .owner_state + .resolve_no_commit_response_navigation_history(); let cleared = context .active_target .owner_state @@ -1566,6 +1577,13 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() { let committed = context .start_document_navigation_for_active_target("LOADER-initial-active-2".to_owned()) .expect("active target should restart document navigation"); + context + .active_target + .owner_state + .record_loaded_page_navigation_history(( + "https://example.test/committed".to_owned(), + "committed".to_owned(), + )); context.commit_document_navigation_if_matches(&committed); let exited = context .active_target @@ -1579,6 +1597,13 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() { !context.accepts_pending_document_navigation_event(&token), "cleared navigation must stay rejected" ); + let (current_index, entries) = context + .active_target + .owner_state + .navigation_history_snapshot(None); + assert_eq!(current_index, 0); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].url, "https://example.test/committed"); } #[test] diff --git a/moli-protocol/src/conn/tests/mod.rs b/moli-protocol/src/conn/tests/mod.rs index 1b8ec90564..5f79babf4a 100644 --- a/moli-protocol/src/conn/tests/mod.rs +++ b/moli-protocol/src/conn/tests/mod.rs @@ -17,7 +17,7 @@ use crate::devtools_runtime::{ DevToolsTargetId, NavigationFrameEvent, NavigationFrameEventKind, }; use crate::domains::network::{ - FailedNavigationDocumentPolicy, FailedNavigationResponseMode, + FailedNavigationDocumentPolicy, FailedNavigationHistoryPolicy, FailedNavigationResponseMode, MaterializedFailedDocumentProgress, MaterializedNavigationLoadOutcome, empty_main_document_progress_gate_for_test, }; @@ -249,8 +249,12 @@ fn background_navigation_completion_sender_routes_explicit_session_owners() { inactive.attach_active_session("SID-inactive"); conn.inactive_browser_contexts.push(inactive); - let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel(); - conn.set_background_navigation_completion_sender(sender); + let (event_sender, _event_receiver) = tokio::sync::mpsc::unbounded_channel(); + let (completion_sender, _completion_receiver) = tokio::sync::mpsc::unbounded_channel(); + let (continuation_sender, _continuation_receiver) = tokio::sync::mpsc::unbounded_channel(); + conn.set_background_event_sender(event_sender); + conn.set_background_navigation_completion_sender(completion_sender); + conn.set_document_continuation_completion_sender(continuation_sender); assert!( conn.background_navigation_completion_sender_for_session_owner(Some("SID-active")) @@ -414,7 +418,7 @@ async fn removing_active_browser_context_switches_engine_to_promoted_context() { } #[tokio::test] -async fn memory_diagnostics_reports_page_vm_document_isolate_model() { +async fn memory_diagnostics_reports_script_agent_document_isolate_model() { let mut conn = CdpConnection::new(); conn.replace_navigation_engine( NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics(), @@ -465,7 +469,7 @@ async fn memory_diagnostics_reports_page_vm_document_isolate_model() { assert_eq!( diagnostics["isolateScope"]["documentIsolateModel"], - json!("page-vm") + json!("script-agent") ); assert_eq!( diagnostics["isolateScope"]["loadedDocumentPageCount"], @@ -482,7 +486,7 @@ async fn memory_diagnostics_reports_page_vm_document_isolate_model() { assert_eq!( diagnostics["isolateScope"]["estimatedDocumentIsolateCount"], json!(2), - "page-vm diagnostics should count one document isolate per loaded page" + "independent Pages should retain distinct document script agents" ); assert_eq!( diagnostics["isolateScope"]["estimatedWorkerIsolateCount"], @@ -1229,6 +1233,7 @@ async fn materialized_navigation_completion_drops_stale_token() { MaterializedNavigationLoadOutcome::Failed(MaterializedFailedDocumentProgress { error_text: "stale navigation should not emit".to_owned(), document_policy: FailedNavigationDocumentPolicy::InvalidateCommittedDocument, + history_policy: FailedNavigationHistoryPolicy::DiscardPendingUpdate, response_mode: FailedNavigationResponseMode::ProtocolError, progress_gate: empty_main_document_progress_gate_for_test(), }); @@ -1281,6 +1286,7 @@ async fn materialized_navigation_completion_drops_stale_token_without_navigate_i MaterializedNavigationLoadOutcome::Failed(MaterializedFailedDocumentProgress { error_text: "stale navigation should not emit without a navigate id".to_owned(), document_policy: FailedNavigationDocumentPolicy::InvalidateCommittedDocument, + history_policy: FailedNavigationHistoryPolicy::DiscardPendingUpdate, response_mode: FailedNavigationResponseMode::ProtocolError, progress_gate: empty_main_document_progress_gate_for_test(), }); @@ -1315,6 +1321,7 @@ async fn materialized_navigation_completion_drains_current_token() { MaterializedNavigationLoadOutcome::Failed(MaterializedFailedDocumentProgress { error_text: "current navigation should emit".to_owned(), document_policy: FailedNavigationDocumentPolicy::InvalidateCommittedDocument, + history_policy: FailedNavigationHistoryPolicy::DiscardPendingUpdate, response_mode: FailedNavigationResponseMode::ProtocolError, progress_gate: empty_main_document_progress_gate_for_test(), }); diff --git a/moli-protocol/src/conn/tests/resource_runtime.rs b/moli-protocol/src/conn/tests/resource_runtime.rs index 3ddf620dfd..1983abe394 100644 --- a/moli-protocol/src/conn/tests/resource_runtime.rs +++ b/moli-protocol/src/conn/tests/resource_runtime.rs @@ -58,6 +58,9 @@ async fn commit_navigation_outcome_for_test( NavigationLoadOutcome::Download(_) => { panic!("test navigation should not resolve to a download") } + NavigationLoadOutcome::NoCommitResponse(_) => { + panic!("test navigation should not resolve to a no-commit response") + } NavigationLoadOutcome::NetworkFailure(error_text) => { panic!("test navigation should not fail: {error_text}") } diff --git a/moli-protocol/src/domains/activity/scheduler_work.rs b/moli-protocol/src/domains/activity/scheduler_work.rs index 60184f2045..94c6f1c38d 100644 --- a/moli-protocol/src/domains/activity/scheduler_work.rs +++ b/moli-protocol/src/domains/activity/scheduler_work.rs @@ -115,6 +115,7 @@ impl fmt::Debug for ProtocolSchedulerWork { debug .field("browser_context_id", &action.browser_context_id()) .field("target_id", &action.target_id()) + .field("page_owner", &action.page_owner()) .field("url", &action.url()) .field("navigation_kind", &action.kind()); } diff --git a/moli-protocol/src/domains/debugger.rs b/moli-protocol/src/domains/debugger.rs index f601da7859..528af75f11 100644 --- a/moli-protocol/src/domains/debugger.rs +++ b/moli-protocol/src/domains/debugger.rs @@ -330,6 +330,7 @@ mod tests { #[tokio::test] async fn debugger_instrumentation_navigation_publishes_new_context_with_bound_origin() { let mut ctx = TestContext::new(); + ctx.enable_document_continuation_scheduler_for_test(); with_loaded_document(&mut ctx).await; let runtime = command(&mut ctx, json!({"id": 34, "method": "Runtime.enable"}), 34).await; diff --git a/moli-protocol/src/domains/fetch/navigation.rs b/moli-protocol/src/domains/fetch/navigation.rs index 781ebb3e4f..612f53951d 100644 --- a/moli-protocol/src/domains/fetch/navigation.rs +++ b/moli-protocol/src/domains/fetch/navigation.rs @@ -424,6 +424,7 @@ pub(super) async fn complete_tokened_materialized_navigation_into_buffer_async( navigation_state, navigation, &mut command_context, + None, )) .await; if let Some(predecessor) = command_context.take_renderer_output_predecessor() { diff --git a/moli-protocol/src/domains/fetch/tests/navigation_auth.rs b/moli-protocol/src/domains/fetch/tests/navigation_auth.rs index 2c4bb5ba3a..acea6ad1e2 100644 --- a/moli-protocol/src/domains/fetch/tests/navigation_auth.rs +++ b/moli-protocol/src/domains/fetch/tests/navigation_auth.rs @@ -443,6 +443,11 @@ async fn continue_with_auth_and_intercept_response_pauses_before_authorized_body json!({ "frameId": "TID-1", "loaderId": LOADER_ID }), Some("SID-1"), ); + ctx.wait_for_document_continuation_for_test( + Some("SID-1"), + "authorized response-stage navigation continuation", + ) + .await; assert!( loaded_page_html_for_test(&mut ctx) .await diff --git a/moli-protocol/src/domains/fetch/tests/navigation_response_stage.rs b/moli-protocol/src/domains/fetch/tests/navigation_response_stage.rs index 10c444cde1..ff2274511a 100644 --- a/moli-protocol/src/domains/fetch/tests/navigation_response_stage.rs +++ b/moli-protocol/src/domains/fetch/tests/navigation_response_stage.rs @@ -105,6 +105,11 @@ async fn response_stage_pause_happens_before_navigation_body_eof() { json!({ "frameId": "TID-1", "loaderId": LOADER_ID }), Some("SID-1"), ); + ctx.wait_for_document_continuation_for_test( + Some("SID-1"), + "response-stage navigation continuation", + ) + .await; { let page = ctx @@ -416,6 +421,11 @@ lateBinding("author-script"); ctx.expect_result(36_506, json!({}), Some("SID-1")); let navigate = take_response_by_id(&mut ctx, 36_502); assert_eq!(navigate["result"]["frameId"], json!("TID-1")); + ctx.wait_for_document_continuation_for_test( + Some("SID-1"), + "response-stage configured Document continuation", + ) + .await; ctx.process_async(json!({ "id": 36_507, @@ -890,6 +900,31 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document() .runtime_slot .enable_primary_network_events(); ctx.conn.browser_context = Some(bc); + ctx.install_navigation_fixture_for_session_owner("about:blank", Some("SID-1")) + .await; + let target_page_before = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("interleaved navigation target Page residence"); + let renderer_page_before = ctx + .conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")) + .expect("interleaved navigation renderer Page residence"); + let page_attachment_before = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::page_attachment_id) + .expect("interleaved navigation target Page attachment"); + let devtools_agent_before = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::loaded_page) + .map(moli_core::page::Page::renderer_devtools_agent_token) + .expect("interleaved navigation initial renderer agent"); + ctx.enable_page_events_for_test(Some("SID-1")); + ctx.sent.clear(); ctx.process_async(json!({ "id": 364, @@ -980,6 +1015,35 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document() attachment_before_continue, "a superseded response head must not switch the renderer channel" ); + assert_eq!( + ctx.conn + .target_page_residence_identity_for_session(Some("SID-1")), + Some(target_page_before.clone()), + "a superseded response head must preserve the target Page residence" + ); + assert_eq!( + ctx.conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")), + Some(renderer_page_before), + "a superseded response head must preserve the renderer Page residence" + ); + let browser_context = ctx + .conn + .browser_context + .as_ref() + .expect("interleaved navigation browser context"); + assert_eq!( + browser_context.page_attachment_id(), + Some(page_attachment_before) + ); + assert_eq!( + browser_context + .loaded_page() + .expect("superseded navigation must preserve the loaded Page") + .renderer_devtools_agent_token(), + devtools_agent_before, + "a superseded response head must preserve the live renderer agent" + ); ctx.process_async(json!({ "id": 368, @@ -995,6 +1059,7 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document() current_navigation["result"]["loaderId"], second_pause["params"]["networkId"] ); + wait_until_frame_stopped_loading(&mut ctx, "TID-1").await; let page = ctx .conn @@ -1002,8 +1067,28 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document() .as_ref() .and_then(|bc| bc.loaded_page()) .expect("current navigation should commit a page"); + assert_eq!( + ctx.conn + .target_page_residence_identity_for_session(Some("SID-1")), + Some(target_page_before), + "the current response head must replace the Document in the same target Page" + ); + assert_eq!( + ctx.conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")), + Some(renderer_page_before), + "the current response head must retain the renderer Page residence" + ); + assert_eq!( + ctx.conn + .browser_context + .as_ref() + .and_then(BrowserContext::page_attachment_id), + Some(page_attachment_before) + ); assert_eq!(page.renderer_devtools_agent_token(), second_agent); assert_ne!(page.renderer_devtools_agent_token(), first_agent); + assert_ne!(page.renderer_devtools_agent_token(), devtools_agent_before); let html = page .serialize_html_async() .await @@ -1389,6 +1474,548 @@ async fn continue_response_can_override_status_and_headers() { server.abort(); } +#[derive(Clone, Copy)] +enum NoCommitResponseStageAction { + Fulfill, + ContinueOverride, + ContinueOriginal, +} + +async fn assert_response_stage_no_commit_case( + ctx: &mut TestContext, + navigate_id: u64, + terminal_id: u64, + url: &str, + original_status: u16, + effective_status: u16, + action: NoCommitResponseStageAction, +) { + assert!( + ctx.sent.is_empty(), + "case must start with an empty event queue" + ); + let target_page_before = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("no-commit navigation target Page residence"); + let renderer_page_before = ctx + .conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")) + .expect("no-commit navigation renderer Page residence"); + let page_attachment_before = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::page_attachment_id) + .expect("no-commit navigation target Page attachment"); + let devtools_agent_before = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::loaded_page) + .map(moli_core::page::Page::renderer_devtools_agent_token) + .expect("no-commit navigation initial renderer agent"); + let html_before = loaded_page_html_for_test(ctx).await; + + ctx.process_async(json!({ + "id": navigate_id, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": url } + })) + .await; + + let paused = take_main_document_request_pause(ctx).await; + assert_eq!(paused["params"]["responseStatusCode"], original_status); + let fetch_request_id = paused["params"]["requestId"] + .as_str() + .expect("response-stage Fetch request id") + .to_owned(); + let network_request_id = paused["params"]["networkId"] + .as_str() + .expect("response-stage Network request id") + .to_owned(); + let prepared_agent = ctx + .conn + .browser_context + .as_ref() + .and_then(|browser_context| { + browser_context + .active_target + .fetch_owner + .pending_fetch_response_prepared_renderer_agent_for_test(&fetch_request_id) + }); + assert_eq!( + prepared_agent.is_some(), + !matches!(original_status, 204 | 205), + "only a response that can commit should reserve a renderer agent at response pause" + ); + assert!( + !ctx.sent + .iter() + .any(|message| message["id"] == json!(navigate_id)), + "Page.navigate must remain pending while the effective response is undecided: {:?}", + ctx.sent + ); + + let (method, params) = match action { + NoCommitResponseStageAction::Fulfill => ( + "Fetch.fulfillRequest", + json!({ + "requestId": fetch_request_id, + "responseCode": effective_status, + "responseHeaders": [ + { "name": "content-type", "value": "text/html; charset=utf-8" } + ] + }), + ), + NoCommitResponseStageAction::ContinueOverride => ( + "Fetch.continueResponse", + json!({ + "requestId": fetch_request_id, + "responseCode": effective_status, + "responseHeaders": [ + { "name": "content-type", "value": "text/html; charset=utf-8" } + ], + "responsePhrase": "No Content" + }), + ), + NoCommitResponseStageAction::ContinueOriginal => ( + "Fetch.continueResponse", + json!({ "requestId": fetch_request_id }), + ), + }; + ctx.process_async(json!({ + "id": terminal_id, + "method": method, + "sessionId": "SID-1", + "params": params + })) + .await; + ctx.expect_result(terminal_id, json!({}), Some("SID-1")); + + let navigate_response = take_response_by_id(ctx, navigate_id); + assert_eq!(navigate_response["sessionId"], "SID-1"); + assert_eq!(navigate_response["result"]["frameId"], "TID-1"); + assert!(navigate_response["result"]["loaderId"].as_str().is_some()); + assert_eq!( + navigate_response["result"]["errorText"], "net::ERR_ABORTED", + "unexpected Page.navigate terminal: {navigate_response:?}; remaining={:?}", + ctx.sent + ); + assert_eq!(navigate_response["result"]["isDownload"], false); + + let response_index = ctx + .sent + .iter() + .position(|message| { + message["method"] == json!("Network.responseReceived") + && message["params"]["requestId"].as_str() == Some(network_request_id.as_str()) + && message["params"]["response"]["status"] == json!(effective_status) + }) + .unwrap_or_else(|| { + panic!( + "missing effective {effective_status} response for {url}: {:?}", + ctx.sent + ) + }); + let failed_index = ctx + .sent + .iter() + .position(|message| { + message["method"] == json!("Network.loadingFailed") + && message["params"]["requestId"].as_str() == Some(network_request_id.as_str()) + && message["params"]["errorText"] == json!("net::ERR_ABORTED") + && message["params"]["canceled"] == json!(true) + }) + .unwrap_or_else(|| panic!("missing no-commit loadingFailed for {url}: {:?}", ctx.sent)); + assert!( + response_index < failed_index, + "the effective response must be observed before its canceled terminal" + ); + assert!(!ctx.sent.iter().any(|message| { + message["method"] == json!("Network.loadingFinished") + && message["params"]["requestId"].as_str() == Some(network_request_id.as_str()) + })); + assert!(!ctx.sent.iter().any(|message| { + (message["method"] == json!("Page.frameNavigated") + && message["params"]["frame"]["url"] == json!(url)) + || matches!( + message["method"].as_str(), + Some("DOM.documentUpdated" | "Page.domContentEventFired" | "Page.loadEventFired") + ) + })); + + assert_eq!( + ctx.conn + .target_page_residence_identity_for_session(Some("SID-1")), + Some(target_page_before), + "a no-commit response must retain the target Page residence" + ); + assert_eq!( + ctx.conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")), + Some(renderer_page_before), + "a no-commit response must retain the renderer Page residence" + ); + let browser_context = ctx + .conn + .browser_context + .as_ref() + .expect("no-commit browser context"); + assert_eq!( + browser_context.page_attachment_id(), + Some(page_attachment_before) + ); + assert_eq!( + browser_context + .loaded_page() + .expect("the committed Document must remain installed") + .renderer_devtools_agent_token(), + devtools_agent_before, + "a no-commit response must not replace the Document realm" + ); + assert_eq!(loaded_page_html_for_test(ctx).await, html_before); + assert!( + !ctx.conn + .has_pending_document_navigation_for_session_owner(Some("SID-1")), + "the no-commit terminal must clear its pending navigation" + ); + assert!( + !ctx.conn + .browser_context + .as_ref() + .expect("no-commit browser context") + .active_target + .fetch_owner + .pending_fetch_response_transfer_is_pending_for_test(&fetch_request_id), + "the response-stage transfer must be consumed exactly once" + ); + ctx.sent.clear(); +} + +// Chromium sources: +// content/browser/renderer_host/navigation_request.cc +// third_party/blink/public/devtools_protocol/domains/Fetch.pdl +// third_party/blink/web_tests/http/tests/inspector-protocol/fetch/ +// fetch-continue-response-with-overrides.js +#[tokio::test(flavor = "multi_thread")] +async fn response_stage_effective_no_content_statuses_abort_without_committing() { + async fn ok_page() -> impl IntoResponse { + ( + [(CONTENT_TYPE.as_str(), "text/html")], + "
network-body
", + ) + } + + async fn no_content() -> StatusCode { + StatusCode::NO_CONTENT + } + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route("/ok", get(ok_page)) + .route("/no-content", get(no_content)), + ) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + let mut browser_context = attached_browser_context(); + browser_context + .active_target + .runtime_slot + .enable_primary_network_events(); + ctx.conn.browser_context = Some(browser_context); + ctx.install_navigation_fixture_for_session_owner("about:blank", Some("SID-1")) + .await; + ctx.enable_page_events_for_test(Some("SID-1")); + ctx.enable_dom_events_for_test(Some("SID-1")); + wait_until_scheduler_message(&mut ctx, "initial Page.loadEventFired", |message| { + message["method"] == json!("Page.loadEventFired") && message["sessionId"] == json!("SID-1") + }) + .await; + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 43_000, + "method": "Fetch.enable", + "sessionId": "SID-1", + "params": { + "patterns": [{ + "urlPattern": "*", + "requestStage": "Response", + "resourceType": "Document" + }] + } + })) + .await; + ctx.expect_result(43_000, json!({}), Some("SID-1")); + + assert_response_stage_no_commit_case( + &mut ctx, + 43_001, + 43_002, + &format!("http://{addr}/ok"), + 200, + 204, + NoCommitResponseStageAction::Fulfill, + ) + .await; + assert_response_stage_no_commit_case( + &mut ctx, + 43_003, + 43_004, + &format!("http://{addr}/ok"), + 200, + 205, + NoCommitResponseStageAction::ContinueOverride, + ) + .await; + assert_response_stage_no_commit_case( + &mut ctx, + 43_005, + 43_006, + &format!("http://{addr}/no-content"), + 204, + 204, + NoCommitResponseStageAction::ContinueOriginal, + ) + .await; + + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn response_stage_fulfill_can_replace_original_no_content_with_committable_response() { + async fn no_content() -> StatusCode { + StatusCode::NO_CONTENT + } + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route("/no-content", get(no_content)), + ) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + let mut browser_context = attached_browser_context(); + browser_context + .active_target + .runtime_slot + .enable_primary_network_events(); + ctx.conn.browser_context = Some(browser_context); + ctx.install_navigation_fixture_for_session_owner("about:blank", Some("SID-1")) + .await; + let target_page_before = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("fulfilled navigation target Page residence"); + let renderer_page_before = ctx + .conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")) + .expect("fulfilled navigation renderer Page residence"); + let page_attachment_before = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::page_attachment_id) + .expect("fulfilled navigation target Page attachment"); + let devtools_agent_before = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::loaded_page) + .map(moli_core::page::Page::renderer_devtools_agent_token) + .expect("fulfilled navigation initial renderer agent"); + ctx.enable_page_events_for_test(Some("SID-1")); + ctx.enable_dom_events_for_test(Some("SID-1")); + wait_until_scheduler_message(&mut ctx, "initial Page.loadEventFired", |message| { + message["method"] == json!("Page.loadEventFired") && message["sessionId"] == json!("SID-1") + }) + .await; + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 43_010, + "method": "Fetch.enable", + "sessionId": "SID-1", + "params": { + "patterns": [{ + "urlPattern": "*", + "requestStage": "Response", + "resourceType": "Document" + }] + } + })) + .await; + ctx.expect_result(43_010, json!({}), Some("SID-1")); + + let url = format!("http://{addr}/no-content"); + ctx.process_async(json!({ + "id": 43_011, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": url } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + assert_eq!(paused["params"]["responseStatusCode"], 204); + let fetch_request_id = paused["params"]["requestId"] + .as_str() + .expect("response-stage Fetch request id") + .to_owned(); + let network_request_id = paused["params"]["networkId"] + .as_str() + .expect("response-stage Network request id") + .to_owned(); + assert!( + ctx.conn + .browser_context + .as_ref() + .and_then(|browser_context| { + browser_context + .active_target + .fetch_owner + .pending_fetch_response_prepared_renderer_agent_for_test(&fetch_request_id) + }) + .is_none(), + "the original 204 response must not reserve a renderer agent before interception" + ); + + let synthetic_body = base64::engine::general_purpose::STANDARD + .encode("
rescued-from-204
"); + ctx.process_async(json!({ + "id": 43_012, + "method": "Fetch.fulfillRequest", + "sessionId": "SID-1", + "params": { + "requestId": fetch_request_id, + "responseCode": 200, + "responseHeaders": [ + { "name": "content-type", "value": "text/html; charset=utf-8" } + ], + "body": synthetic_body + } + })) + .await; + ctx.expect_result(43_012, json!({}), Some("SID-1")); + ctx.expect_result( + 43_011, + json!({ "frameId": "TID-1", "loaderId": network_request_id }), + Some("SID-1"), + ); + ctx.wait_for_document_continuation_for_test( + Some("SID-1"), + "fulfilled response-stage Document continuation", + ) + .await; + wait_until_scheduler_message( + &mut ctx, + "fulfilled response-stage Network.loadingFinished", + |message| { + message["method"] == json!("Network.loadingFinished") + && message["params"]["requestId"] == json!(network_request_id) + }, + ) + .await; + + let response = ctx + .sent + .iter() + .find(|message| { + message["method"] == json!("Network.responseReceived") + && message["params"]["requestId"] == json!(network_request_id) + }) + .unwrap_or_else(|| panic!("missing synthetic response: {:?}", ctx.sent)); + assert_eq!(response["params"]["response"]["status"], 200); + assert!(ctx.sent.iter().any(|message| { + message["method"] == json!("Network.loadingFinished") + && message["params"]["requestId"] == json!(network_request_id) + })); + assert!(!ctx.sent.iter().any(|message| { + message["method"] == json!("Network.loadingFailed") + && message["params"]["requestId"] == json!(network_request_id) + })); + assert_eq!( + ctx.sent + .iter() + .filter(|message| { + message["method"] == json!("Page.frameNavigated") + && message["params"]["frame"]["url"] == json!(url) + }) + .count(), + 1, + "the effective 200 response must publish exactly one Document commit" + ); + assert!( + ctx.sent + .iter() + .any(|message| message["method"] == json!("DOM.documentUpdated")) + ); + assert!( + ctx.sent + .iter() + .any(|message| message["method"] == json!("Page.domContentEventFired")) + ); + assert!( + ctx.sent + .iter() + .any(|message| message["method"] == json!("Page.loadEventFired")) + ); + + assert_eq!( + ctx.conn + .target_page_residence_identity_for_session(Some("SID-1")), + Some(target_page_before), + "the fulfilled navigation must retain the target Page residence" + ); + assert_eq!( + ctx.conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")), + Some(renderer_page_before), + "the fulfilled navigation must retain the renderer Page residence" + ); + let browser_context = ctx + .conn + .browser_context + .as_ref() + .expect("fulfilled navigation browser context"); + assert_eq!( + browser_context.page_attachment_id(), + Some(page_attachment_before) + ); + assert_ne!( + browser_context + .loaded_page() + .expect("the synthetic 200 Document must be installed") + .renderer_devtools_agent_token(), + devtools_agent_before, + "the effective 200 response must install a new Document realm" + ); + assert!( + loaded_page_html_for_test(&mut ctx) + .await + .contains("rescued-from-204") + ); + assert!( + !ctx.conn + .has_pending_document_navigation_for_session_owner(Some("SID-1")) + ); + + server.abort(); +} + #[tokio::test(flavor = "multi_thread")] async fn continue_response_header_override_keeps_streaming_parser_body() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -2819,6 +3446,29 @@ async fn fulfill_request_completes_navigation_with_synthetic_response() { .runtime_slot .enable_primary_network_events(); ctx.conn.browser_context = Some(bc); + ctx.install_navigation_fixture_for_session_owner("about:blank", Some("SID-1")) + .await; + let target_page_before = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("fulfilled navigation target Page residence"); + let renderer_page_before = ctx + .conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")) + .expect("fulfilled navigation renderer Page residence"); + let page_attachment_before = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::page_attachment_id) + .expect("fulfilled navigation target Page attachment"); + let devtools_agent_before = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::loaded_page) + .map(moli_core::page::Page::renderer_devtools_agent_token) + .expect("fulfilled navigation initial renderer agent"); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_dom_events_for_test(Some("SID-1")); @@ -2890,6 +3540,36 @@ async fn fulfill_request_completes_navigation_with_synthetic_response() { assert_eq!(ctx.take_one()["method"], "Page.loadEventFired"); assert_eq!(ctx.take_one()["method"], "Page.frameStoppedLoading"); + assert_eq!( + ctx.conn + .target_page_residence_identity_for_session(Some("SID-1")), + Some(target_page_before), + "Fetch.fulfillRequest must replace the Document without replacing the target Page" + ); + assert_eq!( + ctx.conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")), + Some(renderer_page_before), + "Fetch.fulfillRequest must retain the renderer Page residence" + ); + let browser_context = ctx + .conn + .browser_context + .as_ref() + .expect("fulfilled navigation browser context"); + assert_eq!( + browser_context.page_attachment_id(), + Some(page_attachment_before) + ); + assert_ne!( + browser_context + .loaded_page() + .expect("fulfilled navigation loaded Page") + .renderer_devtools_agent_token(), + devtools_agent_before, + "Fetch.fulfillRequest must install a new renderer Document agent" + ); + ctx.process_async(json!({ "id": 39, "method": "Network.getResponseBody", diff --git a/moli-protocol/src/domains/network.rs b/moli-protocol/src/domains/network.rs index 221e7667e9..e981f50c41 100644 --- a/moli-protocol/src/domains/network.rs +++ b/moli-protocol/src/domains/network.rs @@ -92,17 +92,19 @@ pub(crate) use main_document_progress::FailedNavigationDocumentPolicy; pub(crate) use main_document_progress::empty_main_document_progress_gate_for_test; pub(crate) use main_document_progress::{ CompletedDocumentProgressTransfer, CompletedDownloadProgressTransfer, - CompletedMainDocumentNetworkEvents, FailedNavigationResponseMode, - MainDocumentBodyNetworkProgress, MainDocumentBodyProgressSource, - MainDocumentProgressBackgroundEventBarrier, MainDocumentProgressGate, - MaterializedDownloadDocumentProgress, MaterializedFailedDocumentProgress, - MaterializedLoadedDocumentProgress, MaterializedNavigationLoadOutcome, + CompletedMainDocumentNetworkEvents, CompletedNoCommitResponseProgressTransfer, + FailedNavigationHistoryPolicy, FailedNavigationResponseMode, MainDocumentBodyNetworkProgress, + MainDocumentBodyProgressSource, MainDocumentProgressBackgroundEventBarrier, + MainDocumentProgressGate, MaterializedDownloadDocumentProgress, + MaterializedFailedDocumentProgress, MaterializedLoadedDocumentProgress, + MaterializedNavigationLoadOutcome, MaterializedStablePageDocumentProgress, emit_child_document_navigation_network_background_events, emit_fetch_navigation_initial_request_for_pause_background_events, materialize_loaded_navigation_progress, materialize_navigation_failure_preserving_committed_document, - materialize_navigation_load_result, record_completed_main_document_response_body, - record_failed_main_document_response_body, record_main_document_request_body, + materialize_navigation_load_result, materialize_stable_page_navigation_progress, + record_completed_main_document_response_body, record_failed_main_document_response_body, + record_main_document_request_body, response_stage_main_document_navigation_network_progress, start_observed_main_document_navigation_progress_background_events, }; diff --git a/moli-protocol/src/domains/network/main_document_progress/gate.rs b/moli-protocol/src/domains/network/main_document_progress/gate.rs index ec73ed40ad..dc88d0d785 100644 --- a/moli-protocol/src/domains/network/main_document_progress/gate.rs +++ b/moli-protocol/src/domains/network/main_document_progress/gate.rs @@ -137,6 +137,11 @@ impl MainDocumentProgressGate { Self { queue } } + pub(super) fn make_body_finished_visible(&mut self) { + self.queue + .mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); + } + fn drain_visible_until_into_background_events( &mut self, boundary: MainDocumentProgressOutputBoundary, 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 8f1b15640e..b05718db9f 100644 --- a/moli-protocol/src/domains/network/main_document_progress/mod.rs +++ b/moli-protocol/src/domains/network/main_document_progress/mod.rs @@ -18,8 +18,9 @@ use url::Url; use crate::conn::{ BackgroundEventSender, BackgroundProtocolEvent, CapturedBody, CdpConnection, - CompletedDownloadBodyArtifact, DownloadNavigation, LoadedNavigation, NavigationDispatchState, - NavigationLoadOutcome, ResponseCommitReady, TargetRuntimeSlot, + CommittedStablePageNavigation, CompletedDownloadBodyArtifact, DownloadNavigation, + LoadedNavigation, NavigationDispatchState, NavigationLoadOutcome, NoCommitResponseNavigation, + ResponseCommitReady, StablePageNavigationCommitTarget, TargetRuntimeSlot, }; #[cfg(test)] @@ -41,6 +42,26 @@ pub(crate) struct MaterializedLoadedDocumentProgress { pub(crate) initial_runtime_realms: Vec, pub(crate) renderer_output_predecessor: Option, pub(crate) main_document_commit: Option>, + pub(crate) document_continuation_observer: + Option, + pub(crate) progress_gate: MainDocumentProgressGate, + pub(crate) navigation_engine: Option, + pub(crate) network_error_page: Option, +} + +pub(crate) struct MaterializedStablePageDocumentProgress { + pub(crate) stable_page_target: StablePageNavigationCommitTarget, + pub(crate) pending_download: Option, + pub(crate) page_creation_artifacts: RendererPageCreationArtifacts, + pub(crate) final_url: Url, + pub(crate) response_headers: Vec<(String, String)>, + pub(crate) response_from_cache: bool, + pub(crate) main_document_body: Option, + pub(crate) initial_runtime_realms: Vec, + pub(crate) renderer_output_predecessor: Option, + pub(crate) main_document_commit: Option>, + pub(crate) document_continuation_observer: + Option, pub(crate) progress_gate: MainDocumentProgressGate, pub(crate) navigation_engine: Option, pub(crate) network_error_page: Option, @@ -55,6 +76,7 @@ pub(crate) struct MaterializedDownloadDocumentProgress { pub(crate) struct MaterializedFailedDocumentProgress { pub(crate) error_text: String, pub(crate) document_policy: FailedNavigationDocumentPolicy, + pub(crate) history_policy: FailedNavigationHistoryPolicy, pub(crate) response_mode: FailedNavigationResponseMode, pub(crate) progress_gate: MainDocumentProgressGate, } @@ -71,6 +93,12 @@ pub(crate) enum FailedNavigationDocumentPolicy { PreserveCommittedDocument, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FailedNavigationHistoryPolicy { + DiscardPendingUpdate, + RetainInitialEmptyDocumentReplacement, +} + impl FailedNavigationDocumentPolicy { pub(crate) fn invalidates_committed_document(self) -> bool { matches!(self, Self::InvalidateCommittedDocument) @@ -172,6 +200,41 @@ pub(crate) struct CompletedDownloadProgressTransfer { network_events: CompletedMainDocumentNetworkEvents, } +#[derive(Debug)] +pub(crate) struct CompletedNoCommitResponseProgressTransfer { + network_progress: MainDocumentBodyNetworkProgress, +} + +impl CompletedNoCommitResponseProgressTransfer { + pub(crate) fn new(network_progress: MainDocumentBodyNetworkProgress) -> Self { + Self { network_progress } + } + + fn into_progress_gate( + self, + conn: &mut CdpConnection, + state: &NavigationDispatchState, + final_url: &Url, + error_text: &str, + ) -> MainDocumentProgressGate { + record_failed_main_document_response_body(conn, state, error_text.to_owned()); + let mut progress_gate = + MainDocumentProgressGate::from_queue(completed_no_commit_response_progress_queue( + self.network_progress, + conn, + state, + final_url, + error_text, + )); + // Unlike a committed Document, a no-commit response has no renderer + // lifecycle boundary that can reveal response/body phases later. Its + // terminal command turn must release responseReceived and + // loadingFailed together, in source order. + progress_gate.make_body_finished_visible(); + progress_gate + } +} + #[derive(Debug)] enum CompletedDownloadProgressBody { Buffered(Vec), @@ -288,6 +351,7 @@ fn materialize_failed_navigation_progress( MaterializedFailedDocumentProgress { error_text, document_policy, + history_policy: FailedNavigationHistoryPolicy::DiscardPendingUpdate, response_mode, progress_gate, } @@ -308,6 +372,7 @@ pub(crate) fn materialize_loaded_navigation_progress( initial_runtime_realms, renderer_output_predecessor, main_document_commit, + document_continuation_observer, document_progress_transfer, navigation_engine, network_error_page, @@ -329,6 +394,50 @@ pub(crate) fn materialize_loaded_navigation_progress( initial_runtime_realms, renderer_output_predecessor, main_document_commit, + document_continuation_observer, + progress_gate, + navigation_engine, + network_error_page, + } +} + +pub(crate) fn materialize_stable_page_navigation_progress( + conn: &mut CdpConnection, + state: &NavigationDispatchState, + navigation: CommittedStablePageNavigation, +) -> MaterializedStablePageDocumentProgress { + let CommittedStablePageNavigation { + stable_page_target, + pending_download, + page_creation_artifacts, + final_url, + response_headers, + response_from_cache, + initial_runtime_realms, + renderer_output_predecessor, + main_document_commit, + document_continuation_observer, + document_progress_transfer, + navigation_engine, + network_error_page, + } = navigation; + let main_document_body = document_progress_transfer.captured_body(); + let progress_gate = match network_error_page.as_ref() { + Some(error_page) => network_error_page_progress_gate(conn, state, error_page.error_text()), + None => document_progress_transfer.into_progress_gate(conn, state, &final_url), + }; + MaterializedStablePageDocumentProgress { + stable_page_target, + pending_download, + page_creation_artifacts, + final_url, + response_headers, + response_from_cache, + main_document_body, + initial_runtime_realms, + renderer_output_predecessor, + main_document_commit, + document_continuation_observer, progress_gate, navigation_engine, network_error_page, @@ -352,6 +461,23 @@ fn materialize_navigation_load_outcome( NavigationLoadOutcome::Download(navigation) => MaterializedNavigationLoadOutcome::Download( materialize_download_navigation_progress(conn, state, *navigation), ), + NavigationLoadOutcome::NoCommitResponse(navigation) => { + let NoCommitResponseNavigation { + final_url, + progress_transfer, + } = *navigation; + let error_text = moli_fetch::NET_ERR_ABORTED_ERROR_TEXT.to_owned(); + let progress_gate = + progress_transfer.into_progress_gate(conn, state, &final_url, &error_text); + MaterializedNavigationLoadOutcome::Failed(MaterializedFailedDocumentProgress { + error_text, + document_policy: FailedNavigationDocumentPolicy::PreserveCommittedDocument, + history_policy: + FailedNavigationHistoryPolicy::RetainInitialEmptyDocumentReplacement, + response_mode: FailedNavigationResponseMode::CdpErrorTextResult, + progress_gate, + }) + } NavigationLoadOutcome::NetworkFailure(error_text) => { MaterializedNavigationLoadOutcome::Failed(materialize_failed_navigation_progress( conn, @@ -502,6 +628,36 @@ fn completed_download_body_progress_queue( ) } +fn completed_no_commit_response_progress_queue( + progress: MainDocumentBodyNetworkProgress, + conn: &CdpConnection, + state: &NavigationDispatchState, + final_url: &Url, + error_text: &str, +) -> MainDocumentProgressQueueHandle { + let Some(events) = progress.into_completed_body_events() else { + return MainDocumentProgressQueueHandle::from_source( + MainDocumentProgressSource::streaming(), + ); + }; + let network_observed = main_document_network_observed(conn, state.session_id.as_deref()); + let context = CompletedMainDocumentProgressContext::new( + main_document_network_event_session_ids(conn, state.session_id.as_deref()), + completed_body_main_document_network_request_id(network_observed, state.request_id.clone()), + state.request_announced, + state.requested_url.clone(), + state.request_method.clone(), + state.request_body.clone(), + state.request_headers.clone(), + state.loader_id.clone(), + state.frame_id.clone(), + state.timestamp, + ); + MainDocumentProgressQueueHandle::from_source(MainDocumentProgressSource::completed_body( + context.no_commit_response_event_batches(&events, final_url, error_text), + )) +} + fn completed_or_streaming_document_progress_queue( conn: &CdpConnection, state: &NavigationDispatchState, @@ -773,6 +929,12 @@ impl MainDocumentBodyProgressSource { } } + pub(crate) fn emit_body_failed(&self, error_text: &str) { + if let Some(live_source) = self.live_source.as_ref() { + live_source.emit_body_failed(error_text); + } + } + pub(crate) fn body_network_progress_for_completed_events( &self, completed_events: CompletedMainDocumentNetworkEvents, @@ -1221,6 +1383,16 @@ impl MainDocumentLiveNetworkProgressSource { ); } + fn emit_body_failed(&self, error_text: &str) { + self.send_progress_events( + MainDocumentProgressPhase::BodyFinished, + vec![MainDocumentNavigationProgressEvent::LoadingFailed { + target: self.progress_target(), + error_text: error_text.to_owned(), + }], + ); + } + fn progress_target(&self) -> MainDocumentProgressEventTarget { MainDocumentProgressEventTarget { session_ids: self.session_ids.clone(), @@ -1599,6 +1771,19 @@ impl CompletedMainDocumentProgressContext { ) } + fn no_commit_response_event_batches( + &self, + events: &CompletedMainDocumentNetworkEvents, + final_url: &Url, + error_text: &str, + ) -> MainDocumentNavigationProgressEventBatches { + MainDocumentNavigationProgressEventBatches::new( + self.request_and_redirect_progress_events(events), + self.response_received_progress_events(events, final_url, 0), + self.loading_failed_progress_events(error_text), + ) + } + fn request_and_redirect_progress_events( &self, events: &CompletedMainDocumentNetworkEvents, @@ -1792,6 +1977,19 @@ impl CompletedMainDocumentProgressContext { }] } + fn loading_failed_progress_events( + &self, + error_text: &str, + ) -> Vec { + let Some(target) = self.progress_target() else { + return Vec::new(); + }; + vec![MainDocumentNavigationProgressEvent::LoadingFailed { + target, + error_text: error_text.to_owned(), + }] + } + fn progress_target(&self) -> Option { Some(MainDocumentProgressEventTarget { session_ids: self.session_ids.clone(), 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 961415fb43..0e9f78ed4c 100644 --- a/moli-protocol/src/domains/network/main_document_progress/tests.rs +++ b/moli-protocol/src/domains/network/main_document_progress/tests.rs @@ -1372,6 +1372,64 @@ fn progress_output_barrier_drains_source_generated_progress_before_cdp_output() assert_eq!(out[2]["method"], json!("Network.loadingFinished")); } +#[test] +fn completed_no_commit_response_progress_orders_response_before_canceled_failure() { + let final_url = Url::parse("http://example.test/no-content").unwrap(); + let events = CompletedMainDocumentNetworkEvents::new( + "GET".to_owned(), + vec![("Accept".to_owned(), "text/html".to_owned())], + None, + 204, + vec![("Content-Type".to_owned(), "text/plain".to_owned())], + Vec::new(), + Vec::new(), + false, + false, + ); + let batches = completed_progress_context().no_commit_response_event_batches( + &events, + &final_url, + "net::ERR_ABORTED", + ); + let mut drain = + MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); + + let mut out = drain_into_protocol_messages(&mut drain); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["method"], json!("Network.requestWillBeSent")); + + drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::ResponseMetadataVisible); + out = drain_into_protocol_messages(&mut drain); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["method"], json!("Network.responseReceived")); + assert_eq!(out[0]["params"]["response"]["status"], json!(204)); + + drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); + out = drain_into_protocol_messages(&mut drain); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["method"], json!("Network.loadingFailed")); + assert_eq!(out[0]["params"]["errorText"], json!("net::ERR_ABORTED")); + assert_eq!(out[0]["params"]["canceled"], json!(true)); + + let batches = completed_progress_context().no_commit_response_event_batches( + &events, + &final_url, + "net::ERR_ABORTED", + ); + let mut gate = MainDocumentProgressGate::new(MainDocumentProgressDrain::from_source( + MainDocumentProgressSource::completed_body(batches), + )); + gate.make_body_finished_visible(); + let mut terminal = Vec::new(); + MainDocumentProgressBackgroundEventBarrier::background_events(&mut terminal, &mut gate) + .drain_progress(); + let terminal = protocol_messages_from_background_events(terminal); + assert_eq!(terminal.len(), 3); + assert_eq!(terminal[0]["method"], json!("Network.requestWillBeSent")); + assert_eq!(terminal[1]["method"], json!("Network.responseReceived")); + assert_eq!(terminal[2]["method"], json!("Network.loadingFailed")); +} + #[test] fn failed_navigation_progress_drain_prequeues_loading_failed() { let target = MainDocumentProgressEventTarget { diff --git a/moli-protocol/src/domains/network/tests/mod.rs b/moli-protocol/src/domains/network/tests/mod.rs index 2a763fbbe3..4bf4904e8d 100644 --- a/moli-protocol/src/domains/network/tests/mod.rs +++ b/moli-protocol/src/domains/network/tests/mod.rs @@ -44,6 +44,19 @@ fn consume_main_document_navigation_start(ctx: &mut TestContext) { assert_eq!(ctx.take_one()["method"], "Page.frameStartedNavigating"); assert_eq!(ctx.take_one()["method"], "Page.frameStartedLoading"); } +async fn wait_for_network_error_document_load(ctx: &mut TestContext, session_id: &str) { + wait_until_messages( + ctx, + Some(session_id), + "network error Document stopped loading", + |messages| { + messages + .iter() + .any(|message| message["method"] == json!("Page.frameStoppedLoading")) + }, + ) + .await; +} async fn flush_until_subresource_finished( ctx: &mut TestContext, resource_type: &str, diff --git a/moli-protocol/src/domains/network/tests/navigation.rs b/moli-protocol/src/domains/network/tests/navigation.rs index 1da2d3963d..945927268c 100644 --- a/moli-protocol/src/domains/network/tests/navigation.rs +++ b/moli-protocol/src/domains/network/tests/navigation.rs @@ -1410,17 +1410,7 @@ async fn first_main_document_transport_failure_commits_error_document() { "params": { "url": url } })) .await; - wait_until_messages( - &mut ctx, - Some("SID-1"), - "first error Document load", - |messages| { - messages - .iter() - .any(|message| message["method"] == json!("Page.loadEventFired")) - }, - ) - .await; + wait_for_network_error_document_load(&mut ctx, "SID-1").await; let messages = ctx.take_all(); let request_index = messages @@ -1469,7 +1459,6 @@ async fn first_main_document_transport_failure_commits_error_document() { response["result"]["errorText"], failed["params"]["errorText"] ); - let frame_index = messages .iter() .position(|message| { @@ -1645,12 +1634,7 @@ async fn main_document_navigation_failure_emits_one_failed_and_finished_terminal })) .await; - wait_until_messages(&mut ctx, Some("SID-1"), "error Document load", |messages| { - messages - .iter() - .any(|message| message["method"] == json!("Page.loadEventFired")) - }) - .await; + wait_for_network_error_document_load(&mut ctx, "SID-1").await; let messages = ctx.take_all(); assert!(!messages.iter().any(|message| { matches!( diff --git a/moli-protocol/src/domains/page.rs b/moli-protocol/src/domains/page.rs index a514a756ad..5382017ca4 100644 --- a/moli-protocol/src/domains/page.rs +++ b/moli-protocol/src/domains/page.rs @@ -98,10 +98,10 @@ pub(in crate::domains) use main_document_commit::{ MainDocumentCommitPreparedOutput, append_renderer_main_document_commit_to_output_sink, project_main_document_commit_async, }; -pub use navigation::BackgroundNavigationCompletion; #[cfg(test)] pub(crate) use navigation::emit_prepared_child_frame_tree_background_events; pub(crate) use navigation::navigation_cookie_access_report; +pub use navigation::{BackgroundDocumentContinuationCompletion, BackgroundNavigationCompletion}; pub(crate) use navigation::{ MaterializedNavigationCompletion, complete_materialized_navigation_into_buffer_async, emit_prepared_child_frame_activity, push_superseded_navigation_result, @@ -2560,6 +2560,23 @@ mod producer_tests { work } + fn take_popup_target_navigation_work_for_test( + conn: &mut CdpConnection, + ) -> crate::domains::activity::ProtocolSchedulerWork { + let [event]: [crate::conn::CdpSchedulerEvent; 1] = conn + .take_scheduler_events() + .try_into() + .expect("popup activation should publish one concrete scheduler action"); + let crate::conn::CdpSchedulerEvent::ProtocolWorkPublished { work } = event else { + panic!("popup activation must publish a protocol owner action"); + }; + assert_eq!( + work.kind(), + crate::domains::activity::ProtocolSchedulerWorkKind::PopupTargetNavigationOwnerAction + ); + work + } + fn root_document_attachment_for_test( conn: &CdpConnection, session_id: &str, @@ -4807,6 +4824,103 @@ mod producer_tests { )); } + #[tokio::test(flavor = "multi_thread")] + async fn popup_navigation_owner_action_rejects_replaced_page_and_cannot_be_rescanned() { + let mut conn = CdpConnection::default(); + conn.set_root_target_discovery_enabled(true); + let mut bc = BrowserContext::new("BID-stale-popup".into()); + bc.set_active_target_id("TID-stale-opener"); + bc.attach_active_session("SID-stale-opener"); + conn.browser_context = Some(bc); + let page_owner = page_residence_identity_for_test(&mut conn, "SID-stale-opener"); + let source_document = renderer_document_identity_for_test(1, 1); + let popup_url = "data:text/html,%3Cmain%3Estale-popup%3C/main%3E"; + let mut prepared = + ProtocolOutputPayloads::from_slot(super::PagePreparedOutputSlot::from_outputs( + super::PagePreparedOutputs::from_popup_activations_for_test( + page_owner, + vec![RendererPendingPopupActivation::window( + source_document, + RendererWindowDocumentSource::RootFrame, + true, + None, + popup_url.to_owned(), + "_blank".to_owned(), + )], + ), + )); + let mut out = Vec::new(); + + super::emit_popup_activity_background_events_async( + &mut conn, + &mut out, + Some("SID-stale-opener"), + Some(&mut prepared), + ) + .await; + + let popup_target_id = out + .into_iter() + .map(BackgroundProtocolEvent::into_protocol_message) + .find(|message| message["method"] == json!("Target.targetCreated")) + .and_then(|message| { + message["params"]["targetInfo"]["targetId"] + .as_str() + .map(str::to_owned) + }) + .expect("popup target should be observable before its navigation runs"); + let work = take_popup_target_navigation_work_for_test(&mut conn); + + let route = conn + .target_session_route_for_target_id(&popup_target_id) + .expect("popup target should retain an exact route"); + { + let mut route_scope = conn.scoped_none_session_owner_route_override(route); + route_scope + .conn_mut() + .runtime_session_owner_slot_mut(None) + .expect("popup target should retain its runtime slot") + .replace_page_attachment_id_for_test(); + } + + let outcome = conn.complete_ready_protocol_scheduler_work_turn(work).await; + let (protocol_events, scheduler_events) = outcome.into_parts(); + assert!( + protocol_events.is_empty(), + "an activation captured for the retired Page residence must not navigate its replacement: {protocol_events:?}" + ); + assert!( + scheduler_events.is_empty(), + "rejecting stale popup authority must not manufacture replacement work: {scheduler_events:?}" + ); + + let route = conn + .target_session_route_for_target_id(&popup_target_id) + .expect("popup target should remain addressable after stale work is rejected"); + let mut rescanned_events = Vec::new(); + let restarted = { + let mut route_scope = conn.scoped_none_session_owner_route_override(route); + crate::domains::target::start_initial_document_target_url_navigation_if_needed_background_events_async( + route_scope.conn_mut(), + &mut rescanned_events, + None, + ) + .await + }; + assert!( + !restarted && rescanned_events.is_empty(), + "generic initial-navigation entry points must respect the consumed popup authority tombstone" + ); + assert!( + conn.browser_context + .as_ref() + .and_then(|browser_context| { browser_context.background_target(&popup_target_id) }) + .and_then(|target| target.loaded_page()) + .is_some_and(|page| moli_url::is_about_blank(page.final_url())), + "neither the stale claim nor a later target-URL scan may navigate the replacement Page" + ); + } + #[tokio::test(flavor = "multi_thread")] async fn popup_activation_publishes_automation_lifecycle_without_cdp_discovery() { let mut conn = CdpConnection::default(); diff --git a/moli-protocol/src/domains/page/navigation.rs b/moli-protocol/src/domains/page/navigation.rs index 21e48a4d03..59461d5359 100644 --- a/moli-protocol/src/domains/page/navigation.rs +++ b/moli-protocol/src/domains/page/navigation.rs @@ -47,7 +47,10 @@ use super::{ emit_child_frame_document_opened_background_events, emit_child_frame_lifecycle_terminal, emit_child_frame_navigation_commit, emit_navigation_started_background_events, }, - navigation_commit::{commit_download_navigation_async, commit_loaded_navigation_async}, + navigation_commit::{ + commit_download_navigation_async, commit_loaded_navigation_async, + commit_stable_page_navigation_async, + }, }; pub(super) struct PendingNavigateLoadCommand { @@ -394,6 +397,7 @@ pub(crate) struct MaterializedNavigationCompletion { state: NavigationDispatchState, navigation: network::MaterializedNavigationLoadOutcome, engine: Option, + background_document_continuation: Option, } impl MaterializedNavigationCompletion { @@ -407,6 +411,7 @@ impl MaterializedNavigationCompletion { state, navigation, engine: None, + background_document_continuation: None, } } @@ -415,6 +420,14 @@ impl MaterializedNavigationCompletion { self } + pub(crate) fn with_background_document_continuation( + mut self, + completion: BackgroundDocumentContinuationCompletion, + ) -> Self { + self.background_document_continuation = Some(completion); + self + } + pub(crate) fn is_current_for_connection(&self, conn: &CdpConnection) -> bool { conn.accepts_pending_document_navigation_for_session_owner( self.state.navigate_session_id.as_deref(), @@ -441,8 +454,15 @@ impl MaterializedNavigationCompletion { NavigationDispatchState, network::MaterializedNavigationLoadOutcome, Option, + Option, ) { - (self.token, self.state, self.navigation, self.engine) + ( + self.token, + self.state, + self.navigation, + self.engine, + self.background_document_continuation, + ) } } @@ -537,6 +557,7 @@ impl BackgroundMainDocumentBodyCompletion { pub enum BackgroundNavigationCompletion { Lifecycle(Box), MainDocumentBody(Box), + DocumentContinuation(Box), } impl BackgroundNavigationCompletion { @@ -544,6 +565,7 @@ impl BackgroundNavigationCompletion { match self { Self::Lifecycle(completion) => completion.state.requested_url.as_str(), Self::MainDocumentBody(completion) => completion.state.requested_url.as_str(), + Self::DocumentContinuation(completion) => completion.requested_url.as_str(), } } @@ -551,8 +573,101 @@ impl BackgroundNavigationCompletion { match self { Self::Lifecycle(_) => "lifecycle", Self::MainDocumentBody(_) => "main_document_body", + Self::DocumentContinuation(_) => "document_continuation", } } + + pub fn background_navigation_gate_key( + &self, + ) -> Option { + match self { + Self::DocumentContinuation(completion) => { + completion.background_navigation_gate_key.clone() + } + Self::Lifecycle(_) | Self::MainDocumentBody(_) => None, + } + } + + pub fn document_continuation_gate_key( + &self, + ) -> Option { + match self { + Self::DocumentContinuation(completion) => { + Some(completion.document_continuation_gate_key.clone()) + } + Self::Lifecycle(_) | Self::MainDocumentBody(_) => None, + } + } +} + +/// Typed terminal for the post-commit renderer continuation. +/// +/// `Page.navigate` may expose its response and install the committed renderer +/// attachment before parser/document-start work reaches DOMContentLoaded. The +/// navigation gate remains resident until that exact Document reaches the +/// milestone or terminates/supersedes. This lets Debugger/Fetch control +/// commands resume a commit-time V8 pause without allowing ordinary +/// document-observing commands to overtake the continuation. +pub struct BackgroundDocumentContinuationCompletion { + document_continuation_gate_key: crate::conn::BackgroundNavigationGateKey, + background_navigation_gate_key: Option, + requested_url: String, + renderer_output_predecessor: Option, + renderer_page_state: Option>, +} + +impl BackgroundDocumentContinuationCompletion { + pub(crate) fn new( + gate_key: crate::conn::BackgroundNavigationGateKey, + requested_url: String, + ) -> Self { + Self { + document_continuation_gate_key: gate_key.clone(), + background_navigation_gate_key: Some(gate_key), + requested_url, + renderer_output_predecessor: None, + renderer_page_state: None, + } + } + + fn for_foreground_navigation( + token: &DocumentNavigationToken, + state: &NavigationDispatchState, + ) -> Self { + Self { + document_continuation_gate_key: + crate::conn::BackgroundNavigationGateKey::for_navigation(token, state), + background_navigation_gate_key: None, + requested_url: state.requested_url.as_str().to_owned(), + renderer_output_predecessor: None, + renderer_page_state: None, + } + } + + pub(crate) fn document_continuation_gate_key( + &self, + ) -> crate::conn::BackgroundNavigationGateKey { + self.document_continuation_gate_key.clone() + } + + pub(crate) fn with_renderer_completion( + mut self, + completion: moli_core::page::RendererDocumentContinuationCompletion, + ) -> Self { + let (predecessor, page_state) = completion.into_parts(); + self.renderer_output_predecessor = predecessor; + self.renderer_page_state = page_state; + self + } + + pub(crate) fn into_renderer_completion_parts( + self, + ) -> ( + Option, + Option>, + ) { + (self.renderer_output_predecessor, self.renderer_page_state) + } } pub struct BackgroundNavigationLifecycleCompletion { @@ -605,6 +720,13 @@ impl BackgroundNavigationLifecycleCompletion { self.state.requested_url.as_str() } + pub(crate) fn continuation_completion(&self) -> BackgroundDocumentContinuationCompletion { + BackgroundDocumentContinuationCompletion::new( + crate::conn::BackgroundNavigationGateKey::for_navigation(&self.token, &self.state), + self.state.requested_url.as_str().to_owned(), + ) + } + pub(crate) fn ready_elapsed_ms(&self) -> u128 { self.ready_at.elapsed().as_millis() } @@ -620,6 +742,7 @@ impl BackgroundNavigationLifecycleCompletion { conn: &mut CdpConnection, retain_engine: bool, ) -> MaterializedNavigationCompletion { + let document_continuation = self.continuation_completion(); let Self { token, state, @@ -635,7 +758,8 @@ impl BackgroundNavigationLifecycleCompletion { | NavigationLoadOutcome::Loaded(_)) ); let navigation = network::materialize_navigation_load_result(conn, &state, navigation); - let completion = MaterializedNavigationCompletion::new(token, state, navigation); + let completion = MaterializedNavigationCompletion::new(token, state, navigation) + .with_background_document_continuation(document_continuation); if should_retain_engine { completion.with_navigation_engine(engine) } else { @@ -684,6 +808,10 @@ impl BackgroundNavigationCompletion { response_from_cache, ))) } + + pub fn document_continuation(completion: BackgroundDocumentContinuationCompletion) -> Self { + Self::DocumentContinuation(Box::new(completion)) + } } pub(crate) fn current_navigation_initiator_url(browser_context: &BrowserContext) -> Option { @@ -3384,64 +3512,158 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async( state: NavigationDispatchState, navigation: network::MaterializedNavigationLoadOutcome, command_context: &mut crate::conn::CommandDispatchContext, + mut background_document_continuation: Option, ) { let navigation_session_id = state.navigate_session_id.clone(); let navigation_loader_id = state.loader_id.clone(); + let document_continuation_gate_key = + crate::conn::BackgroundNavigationGateKey::for_navigation(&token, &state); + let mut foreground_document_continuation_observer = None; match navigation { network::MaterializedNavigationLoadOutcome::ResponseCommitReady(navigation) => { let navigation = *navigation; - let configuration = conn.prepared_document_commit_configuration_for_session_owner( + let stable_page_target = navigation.stable_page_target().cloned(); + let renderer_page = navigation.renderer_page_residence_identity(); + let candidate = match conn.prepare_renderer_agent_candidate_token_for_session_owner( state.navigate_session_id.as_deref(), - navigation.final_url(), - ); - if let Err(error) = navigation.update_commit_configuration(configuration).await { - push_navigation_commit_error(out, &state, error); - } else { - let renderer_page = navigation.renderer_page_residence_identity(); - let candidate = conn.prepare_renderer_agent_candidate_token_for_session_owner( + &token, + navigation.renderer_devtools_agent_token(), + ) { + Ok(candidate) => Some(candidate), + Err(error) => { + tracing::debug!( + %error, + session_id = state.navigate_session_id.as_deref(), + loader_id = token.loader_id, + "dropping superseded response commit-ready navigation" + ); + push_navigation_commit_error(out, &state, error); + None + } + }; + if let Some(candidate) = candidate { + let configuration = conn.prepared_document_commit_configuration_for_session_owner( state.navigate_session_id.as_deref(), - &token, - navigation.renderer_devtools_agent_token(), + navigation.final_url(), ); - match candidate.and_then(|candidate| { - conn.commit_renderer_agent_candidate_for_session_owner( - state.navigate_session_id.as_deref(), - candidate, - renderer_page, - ) - }) { + if let Err(error) = navigation.update_commit_configuration(configuration).await { + push_navigation_commit_error(out, &state, error); + } else { + match match stable_page_target.as_ref() { + Some(expected) => conn + .commit_renderer_agent_candidate_for_stable_page_replacement_for_session_owner( + state.navigate_session_id.as_deref(), + candidate, + expected, + ), + None => conn.commit_renderer_agent_candidate_for_session_owner( + state.navigate_session_id.as_deref(), + candidate, + renderer_page, + ), + } { Ok(transaction) => { let permit = navigation.issue_commit_permit(); - match navigation.commit(permit).await { - Ok(navigation) => { - let navigation = network::materialize_loaded_navigation_progress( - conn, &state, navigation, - ); - commit_loaded_navigation_async( - conn, - out, - Some(&token), - state, + if let Some(expected) = stable_page_target { + let failed_url = navigation.final_url().clone(); + match conn + .commit_stable_page_navigation_document_for_session_owner_async( + state.navigate_session_id.as_deref(), + &expected, navigation, - Some(transaction), - command_context, + permit, ) - .await; - } - Err(error) => { - if let Err(rollback_error) = conn - .rollback_committed_renderer_agent_candidate_for_session_owner( - state.navigate_session_id.as_deref(), + .await + { + Ok(navigation) => { + let mut navigation = + network::materialize_stable_page_navigation_progress( + conn, &state, navigation, + ); + foreground_document_continuation_observer = + schedule_renderer_document_continuation( + conn, + &token, + &state, + &mut background_document_continuation, + navigation.document_continuation_observer.take(), + ); + Box::pin(commit_stable_page_navigation_async( + conn, + out, + &token, + state, + navigation, transaction, + )) + .await; + } + Err(error) => { + if error.page_was_preserved() { + if let Err(rollback_error) = conn + .rollback_committed_renderer_agent_candidate_for_session_owner( + state.navigate_session_id.as_deref(), + transaction, + ) + { + tracing::warn!( + %rollback_error, + session_id = state.navigate_session_id.as_deref(), + "failed to roll back renderer channel after pre-commit Page replacement failure" + ); + } + } else { + let _ = conn + .discard_loaded_page_after_failed_navigation_for_session_owner_async( + state.navigate_session_id.as_deref(), + &failed_url, + ) + .await; + } + push_navigation_commit_error(out, &state, error.to_string()); + } + } + } else { + match navigation.commit(permit).await { + Ok(navigation) => { + let mut navigation = + network::materialize_loaded_navigation_progress( + conn, &state, navigation, + ); + foreground_document_continuation_observer = + schedule_renderer_document_continuation( + conn, + &token, + &state, + &mut background_document_continuation, + navigation.document_continuation_observer.take(), + ); + commit_loaded_navigation_async( + conn, + out, + Some(&token), + state, + navigation, + Some(transaction), + command_context, ) - { - tracing::warn!( - %rollback_error, - session_id = state.navigate_session_id.as_deref(), - "failed to roll back renderer channel after prepared document commit failure" - ); + .await; + } + Err(error) => { + if let Err(rollback_error) = conn + .rollback_committed_renderer_agent_candidate_for_session_owner( + state.navigate_session_id.as_deref(), + transaction, + ) + { + tracing::warn!( + %rollback_error, + session_id = state.navigate_session_id.as_deref(), + "failed to roll back renderer channel after prepared document commit failure" + ); + } + push_navigation_commit_error(out, &state, error); } - push_navigation_commit_error(out, &state, error); } } } @@ -3455,15 +3677,24 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async( push_navigation_commit_error(out, &state, error); } } + } } } network::MaterializedNavigationLoadOutcome::Loaded(navigation) => { + let mut navigation = *navigation; + foreground_document_continuation_observer = schedule_renderer_document_continuation( + conn, + &token, + &state, + &mut background_document_continuation, + navigation.document_continuation_observer.take(), + ); commit_loaded_navigation_async( conn, out, Some(&token), state, - *navigation, + navigation, None, command_context, ) @@ -3476,15 +3707,25 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async( commit_download_navigation_async(conn, out, state, navigation, command_context).await; } network::MaterializedNavigationLoadOutcome::Failed(navigation) => { - let _ = conn.clear_pending_navigation_history_update_for_session_owner( - state.navigate_session_id.as_deref(), - ); let network::MaterializedFailedDocumentProgress { error_text, document_policy, + history_policy, response_mode, progress_gate, } = navigation; + match history_policy { + network::FailedNavigationHistoryPolicy::DiscardPendingUpdate => { + let _ = conn.clear_pending_navigation_history_update_for_session_owner( + state.navigate_session_id.as_deref(), + ); + } + network::FailedNavigationHistoryPolicy::RetainInitialEmptyDocumentReplacement => { + let _ = conn.resolve_no_commit_response_navigation_history_for_session_owner( + state.navigate_session_id.as_deref(), + ); + } + } if document_policy.invalidates_committed_document() { let _ = conn .discard_loaded_page_after_failed_navigation_for_session_owner_async( @@ -3501,6 +3742,10 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async( .emit_navigation_error_into_buffer(out, &error_text); } } + if let Some(completion) = background_document_continuation { + let observer = conn.schedule_background_document_continuation_completion(completion, None); + debug_assert!(observer.is_none()); + } let primary_protocol_session_id = conn .runtime_session_owner_primary_session_id(navigation_session_id.as_deref()) .or_else(|| navigation_session_id.clone()); @@ -3544,6 +3789,47 @@ pub(crate) async fn complete_materialized_navigation_into_buffer_async( navigation_session_id.as_deref(), &navigation_loader_id, ); + if let Some(observer) = foreground_document_continuation_observer { + let (predecessor, page_state) = observer.wait().await.into_parts(); + let page_state_applied = page_state.is_some_and(|page_state| { + conn.apply_renderer_document_continuation_page_state( + &document_continuation_gate_key, + page_state, + ) + }); + if page_state_applied { + let _ = conn + .target_session_owner_navigation_history_snapshot(navigation_session_id.as_deref()); + if let Some(target_id) = document_continuation_gate_key.target_id() { + command_context.protocol_events_mut().extend( + conn.exact_target_info_changed_event_plan_for_target_delta(target_id) + .into_background_events(), + ); + } + } + if let Some(predecessor) = predecessor { + command_context.set_renderer_output_predecessor(predecessor); + } + } +} + +fn schedule_renderer_document_continuation( + conn: &mut CdpConnection, + token: &DocumentNavigationToken, + state: &NavigationDispatchState, + background_completion: &mut Option, + observer: Option, +) -> Option { + if let Some(completion) = background_completion.take() { + return conn.schedule_background_document_continuation_completion(completion, observer); + } + if let Some(observer) = observer { + return conn.schedule_background_document_continuation_completion( + BackgroundDocumentContinuationCompletion::for_foreground_navigation(token, state), + Some(observer), + ); + } + None } fn push_navigation_commit_error( diff --git a/moli-protocol/src/domains/page/navigation_commit.rs b/moli-protocol/src/domains/page/navigation_commit.rs index 8c8f562692..30877ed4d5 100644 --- a/moli-protocol/src/domains/page/navigation_commit.rs +++ b/moli-protocol/src/domains/page/navigation_commit.rs @@ -3,6 +3,7 @@ use url::Url; use crate::conn::{ CdpConnection, CommandDispatchContext, CommittedRendererAgentAttachment, DocumentNavigationToken, LoadedNavigationRendererAttachmentCommit, NavigationDispatchState, + StablePageNavigationCommitTarget, }; use crate::domains::activity::{ MainDocumentDownloadNavigationActivity, MainDocumentNavigationActivity, @@ -10,6 +11,7 @@ use crate::domains::activity::{ use crate::domains::command_output::CommandOutputBuffer; use crate::domains::network::{ MaterializedDownloadDocumentProgress, MaterializedLoadedDocumentProgress, + MaterializedStablePageDocumentProgress, }; use moli_core::page::{ Page, RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, @@ -41,6 +43,7 @@ pub(super) async fn commit_loaded_navigation_async( initial_runtime_realms, renderer_output_predecessor, main_document_commit, + document_continuation_observer: _, progress_gate, navigation_engine, network_error_page, @@ -160,6 +163,135 @@ pub(super) async fn commit_loaded_navigation_async( .await; } +pub(super) async fn commit_stable_page_navigation_async( + conn: &mut CdpConnection, + out: &mut CommandOutputBuffer, + token: &DocumentNavigationToken, + state: NavigationDispatchState, + navigation: MaterializedStablePageDocumentProgress, + committed_renderer_attachment: CommittedRendererAgentAttachment, +) { + let MaterializedStablePageDocumentProgress { + stable_page_target, + pending_download, + page_creation_artifacts, + final_url, + response_headers, + response_from_cache, + main_document_body, + initial_runtime_realms, + renderer_output_predecessor, + main_document_commit, + document_continuation_observer: _, + progress_gate, + navigation_engine, + network_error_page, + } = navigation; + let target_url = network_error_page + .as_ref() + .map(|error_page| error_page.unreachable_url().clone()) + .unwrap_or_else(|| final_url.clone()); + let Some(main_document_commit) = main_document_commit else { + let error = "stable Page navigation is missing its frozen main Document commit identity"; + if state.navigate_id.is_some() { + out.push_error_after_messages(-32000, error); + } else { + tracing::warn!( + session_id = state.navigate_session_id.as_deref(), + loader_id = state.loader_id, + "{error} after early Page.navigate result" + ); + } + return; + }; + let is_network_error_page = network_error_page.is_some(); + let navigation_session_id = state.navigate_session_id.clone(); + let (page_creation_artifacts, mut deferred_initial_renderer_document_lifecycle_events) = + split_renderer_page_creation_lifecycle_at_load_boundary(page_creation_artifacts); + let mut navigation_activity = MainDocumentNavigationActivity::new( + state, + final_url.clone(), + progress_gate, + Some(token.clone()), + ); + if let Some(error_page) = network_error_page.as_ref() { + navigation_activity = + navigation_activity.with_network_error_page_result(error_page.error_text().to_owned()); + } + // Keep the protocol-state transaction off the caller's navigation future: + // background target creation already carries the fetch and target-handoff + // state on its test thread stack. + let Some(_commit) = Box::pin(commit_stable_page_navigation_protocol_state_async( + conn, + out, + token, + navigation_activity.state(), + &stable_page_target, + &final_url, + &target_url, + &main_document_commit, + initial_runtime_realms, + committed_renderer_attachment, + )) + .await + else { + return; + }; + if !is_network_error_page { + let _ = conn.commit_main_document_resource_for_session_owner( + navigation_session_id.as_deref(), + navigation_activity.state().frame_id.clone(), + navigation_activity.state().loader_id.clone(), + final_url.clone(), + response_headers, + response_from_cache, + main_document_body, + ); + } + let (renderer_document_binding, mut initial_renderer_document_lifecycle_events) = conn + .bind_renderer_document_lifecycle_for_session_owner( + navigation_activity.state().navigate_session_id.as_deref(), + page_creation_artifacts, + Some(token.clone()), + navigation_activity.state().frame_id.clone(), + navigation_activity.state().loader_id.clone(), + ); + let load_visibility_barrier_armed = renderer_document_binding.is_some() + && conn.begin_renderer_document_load_visibility_barrier_for_session_owner( + navigation_activity.state().navigate_session_id.as_deref(), + &navigation_activity.state().loader_id, + ); + if load_visibility_barrier_armed { + let (_, visible_events) = conn.ingest_renderer_document_lifecycle_events_for_session_owner( + navigation_activity.state().navigate_session_id.as_deref(), + std::mem::take(&mut deferred_initial_renderer_document_lifecycle_events), + ); + initial_renderer_document_lifecycle_events.extend(visible_events); + } + navigation_activity.defer_initial_renderer_document_lifecycle_events_until_load_boundary( + deferred_initial_renderer_document_lifecycle_events, + ); + if let Some(engine) = navigation_engine { + conn.adopt_loaded_navigation_engine_for_session_owner( + navigation_session_id.as_deref(), + engine, + ); + } + Box::pin(async move { + navigation_activity + .emit_loaded_navigation_commit_async( + conn, + out, + pending_download, + renderer_document_binding, + initial_renderer_document_lifecycle_events, + renderer_output_predecessor, + ) + .await; + }) + .await; +} + fn split_renderer_page_creation_lifecycle_at_load_boundary( mut artifacts: RendererPageCreationArtifacts, ) -> ( @@ -557,6 +689,197 @@ async fn restore_and_commit_loaded_navigation_page_async( Some(outcome) } +async fn commit_stable_page_navigation_protocol_state_async( + conn: &mut CdpConnection, + out: &mut CommandOutputBuffer, + token: &DocumentNavigationToken, + state: &NavigationDispatchState, + expected: &StablePageNavigationCommitTarget, + final_url: &Url, + target_url: &Url, + main_document_commit: &moli_core::page::RendererMainDocumentCommit, + initial_runtime_realms: Vec, + committed_renderer_attachment: CommittedRendererAgentAttachment, +) -> Option { + if !conn.target_page_residence_identity_is_current_for_session( + state.navigate_session_id.as_deref(), + expected.target_page(), + ) || conn + .renderer_page_residence_identity_for_session_owner(state.navigate_session_id.as_deref()) + != Some(expected.renderer_page()) + || committed_renderer_attachment.navigation() != token + || conn.current_renderer_agent_attachment_id_for_session_owner( + state.navigate_session_id.as_deref(), + ) != Some(committed_renderer_attachment.current().id()) + { + tracing::warn!( + session_id = state.navigate_session_id.as_deref(), + loader_id = token.loader_id, + "stable Page replacement no longer matches its committed protocol owner" + ); + discard_irreversibly_committed_stable_page_if_current_async( + conn, state, expected, final_url, + ) + .await; + return None; + } + let Some(commit_state) = conn + .prepare_loaded_navigation_commit_for_session_owner(state.navigate_session_id.as_deref()) + else { + tracing::warn!( + session_id = state.navigate_session_id.as_deref(), + loader_id = token.loader_id, + "stable Page replacement lost its protocol commit state after renderer commit" + ); + discard_irreversibly_committed_stable_page_if_current_async( + conn, state, expected, final_url, + ) + .await; + return None; + }; + let page_commit = match conn.commit_loaded_navigation_document_replacement_for_session_owner( + state.navigate_session_id.as_deref(), + committed_renderer_attachment, + target_url, + ) { + Some(Ok(commit)) => commit, + Some(Err(error)) => { + if state.navigate_id.is_some() { + out.push_error_after_messages( + -32000, + format!("failed to commit stable Page protocol state: {error}"), + ); + } else { + tracing::warn!( + %error, + session_id = state.navigate_session_id.as_deref(), + "stable Page navigation failed after early Page.navigate result" + ); + } + let _ = conn + .discard_loaded_page_after_failed_navigation_for_session_owner_async( + state.navigate_session_id.as_deref(), + final_url, + ) + .await; + return None; + } + None => { + tracing::warn!( + session_id = state.navigate_session_id.as_deref(), + loader_id = token.loader_id, + "stable Page replacement lost its target owner during protocol state commit" + ); + discard_irreversibly_committed_stable_page_if_current_async( + conn, state, expected, final_url, + ) + .await; + return None; + } + }; + let mut claimed_inspector_await_events = Vec::new(); + for session_id in + conn.page_event_session_ids_for_session_owner(state.navigate_session_id.as_deref()) + { + conn.fail_claimed_pending_inspector_awaits_for_session_owner_background_events_into( + &mut claimed_inspector_await_events, + session_id.as_deref(), + "Inspected target navigated or closed", + ); + } + out.extend_background_events_after_messages(claimed_inspector_await_events); + if let Some(replaced_page_owner) = page_commit.replaced_page_owner.as_ref() { + let worker_retirement_events = + crate::domains::target::retire_dedicated_worker_targets_for_replaced_page_async( + conn, + replaced_page_owner, + ) + .await; + out.extend_background_events_after_messages(worker_retirement_events); + } + if conn + .commit_loaded_navigation_target_identity_for_session_owner( + state.navigate_session_id.as_deref(), + main_document_commit, + target_url, + ) + .is_none() + { + tracing::warn!( + session_id = state.navigate_session_id.as_deref(), + loader_id = token.loader_id, + "stable Page replacement lost its target identity commit route" + ); + discard_irreversibly_committed_stable_page_if_current_async( + conn, state, expected, final_url, + ) + .await; + return None; + } + if commit_state.runtime_frontend_enabled { + let _ = conn.set_renderer_runtime_agent_owns_page_console_api_events_for_session_owner( + state.navigate_session_id.as_deref(), + true, + ); + } + conn.commit_document_navigation_for_session_owner_if_matches( + state.navigate_session_id.as_deref(), + token, + ); + let preload_channel_execution_context_ids = if conn + .target_owner_has_bidi_channel_preload_script_for_session( + state.navigate_session_id.as_deref(), + ) { + dedupe_preload_channel_execution_context_ids( + initial_runtime_realms + .iter() + .filter_map(runtime_realm_execution_context_id) + .collect(), + ) + } else { + Vec::new() + }; + let mut preload_channel_listener_events = Vec::new(); + for execution_context_id in preload_channel_execution_context_ids { + Box::pin( + crate::domains::runtime::start_bidi_preload_channel_listeners_for_execution_context_background_events_async( + conn, + state.navigate_session_id.as_deref(), + execution_context_id, + &mut preload_channel_listener_events, + ), + ) + .await; + } + out.extend_background_events_after_messages(preload_channel_listener_events); + Some(LoadedPageCommitOutcome::default()) +} + +async fn discard_irreversibly_committed_stable_page_if_current_async( + conn: &mut CdpConnection, + state: &NavigationDispatchState, + expected: &StablePageNavigationCommitTarget, + final_url: &Url, +) { + if !conn.target_page_residence_identity_is_current_for_session( + state.navigate_session_id.as_deref(), + expected.target_page(), + ) { + tracing::error!( + session_id = state.navigate_session_id.as_deref(), + loader_id = state.loader_id, + "could not retire an irreversibly committed stable Page through its captured target route" + ); + return; + } + let _ = conn + .discard_loaded_page_after_failed_navigation_for_session_owner_async( + state.navigate_session_id.as_deref(), + final_url, + ) + .await; +} + fn runtime_realm_execution_context_id(realm: &RendererRuntimeRealmInfo) -> Option { runtime_realm_has_native_unique_id(realm).then_some(realm.context_id) } diff --git a/moli-protocol/src/domains/page/popup.rs b/moli-protocol/src/domains/page/popup.rs index 2db7b80e7d..342251e315 100644 --- a/moli-protocol/src/domains/page/popup.rs +++ b/moli-protocol/src/domains/page/popup.rs @@ -93,6 +93,7 @@ pub(super) async fn emit_prepared( popup_id, url, target_name, + pending_auxiliary_page, session_storage_store, initial_empty_document_storage_key, ) = activation.into_parts(); @@ -112,6 +113,7 @@ pub(super) async fn emit_prepared( opener, can_access_opener, disposition, + pending_auxiliary_page, session_storage_store, initial_empty_document_storage_key, ); diff --git a/moli-protocol/src/domains/page/preload.rs b/moli-protocol/src/domains/page/preload.rs index f1281fe62a..1e28dc8684 100644 --- a/moli-protocol/src/domains/page/preload.rs +++ b/moli-protocol/src/domains/page/preload.rs @@ -277,6 +277,7 @@ fn document_start_script_from_add_preload_command( source: source.clone(), world_name: command.world_name.clone(), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }), DevToolsPreloadScriptSource::FunctionDeclaration { @@ -301,6 +302,7 @@ fn document_start_script_from_add_preload_command( source: source.source, world_name: command.world_name.clone(), has_bidi_channel_argument, + browser_internal: false, bidi_channel_handoffs: source.channel_handoffs, }) } @@ -1853,6 +1855,7 @@ mod protocol_neutral_tests { source: "globalThis.ready = true;".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )]; @@ -1877,6 +1880,7 @@ mod protocol_neutral_tests { source: "globalThis.ready = true;".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )]; @@ -1902,6 +1906,7 @@ mod protocol_neutral_tests { source: "globalThis.ready = true;".to_owned(), world_name: Some("utility".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); @@ -1910,6 +1915,7 @@ mod protocol_neutral_tests { source: "globalThis.ready = true;".to_owned(), world_name: Some("utility".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }; @@ -1941,6 +1947,7 @@ mod protocol_neutral_tests { source: "globalThis.ready = true;".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); @@ -1949,6 +1956,7 @@ mod protocol_neutral_tests { source: "globalThis.ready = true;".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }; diff --git a/moli-protocol/src/domains/page/termination.rs b/moli-protocol/src/domains/page/termination.rs index f582c9ca10..9ab151058d 100644 --- a/moli-protocol/src/domains/page/termination.rs +++ b/moli-protocol/src/domains/page/termination.rs @@ -88,6 +88,7 @@ async fn complete_tokened_materialized_navigation_background_events_async( navigation_state, navigation, &mut command_context, + None, ) .await; let mut plan = output.into_plan(); diff --git a/moli-protocol/src/domains/page/tests/navigation.rs b/moli-protocol/src/domains/page/tests/navigation.rs index 0493218bec..cc4664591a 100644 --- a/moli-protocol/src/domains/page/tests/navigation.rs +++ b/moli-protocol/src/domains/page/tests/navigation.rs @@ -58,6 +58,38 @@ fn assert_runtime_navigation_context_reset( ); } +async fn wait_until_runtime_navigation_context_resets( + ctx: &mut TestContext, + session_ids: &[&str], + frame_id: &str, +) { + let description = format!( + "Runtime context replacement for {} in {frame_id}", + session_ids.join(", ") + ); + wait_until_messages( + ctx, + session_ids.first().copied(), + &description, + |messages| { + session_ids.iter().all(|session_id| { + let cleared = messages.iter().any(|message| { + message["sessionId"] == json!(session_id) + && message["method"] == json!("Runtime.executionContextsCleared") + }); + let default_created = messages.iter().any(|message| { + message["sessionId"] == json!(session_id) + && message["method"] == json!("Runtime.executionContextCreated") + && message["params"]["context"]["auxData"]["isDefault"] == json!(true) + && message["params"]["context"]["auxData"]["frameId"] == json!(frame_id) + }); + cleared && default_created + }) + }, + ) + .await; +} + #[tokio::test(flavor = "multi_thread")] async fn data_url_commit_applies_preloads_worlds_and_bindings_before_author_script() { let mut ctx = TestContext::new(); @@ -126,6 +158,16 @@ async fn data_url_commit_applies_preloads_worlds_and_bindings_before_author_scri take_response_by_id(&mut ctx, 90_104)["result"]["frameId"], json!("TID-1") ); + wait_until_message( + &mut ctx, + Some("SID-1"), + "data URL named-world execution context", + |message| { + message["method"] == json!("Runtime.executionContextCreated") + && message["params"]["context"]["name"] == json!("data-world") + }, + ) + .await; ctx.process_async(json!({ "id": 90_105, @@ -179,7 +221,8 @@ async fn data_url_commit_applies_preloads_worlds_and_bindings_before_author_scri assert_eq!( binding_payloads, vec![json!("named-preload"), json!("author-script")], - "data URL named-world preload must run before its first author script" + "data URL named-world preload must run before its first author script: sent={:#?}", + ctx.sent ); } @@ -226,6 +269,204 @@ async fn page_navigate_file_url_fails_before_navigation_events_or_document_repla ); } +#[tokio::test(flavor = "multi_thread")] +async fn cross_document_page_navigate_replaces_realm_in_stable_page_residence() { + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + ensure_initial_document_for_session(&mut ctx, Some("SID-1")).await; + + ctx.process_async(json!({ + "id": 90_110, + "method": "Runtime.enable", + "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 90_110)["result"], json!({})); + let old_context_id = ctx + .sent + .iter() + .rev() + .find(|message| { + message["method"] == json!("Runtime.executionContextCreated") + && message["sessionId"] == json!("SID-1") + && message["params"]["context"]["auxData"]["isDefault"] == json!(true) + }) + .and_then(|message| message["params"]["context"]["id"].as_i64()) + .expect("initial default execution context id"); + ctx.process_async(json!({ + "id": 90_111, + "method": "Runtime.evaluate", + "sessionId": "SID-1", + "params": { + "contextId": old_context_id, + "expression": "globalThis.__stablePageOldRealm = 'old'", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 90_111)["result"]["result"]["value"], + json!("old") + ); + + let before_target_page = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("initial target Page residence"); + let before_renderer_page = ctx + .conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")) + .expect("initial renderer Page residence"); + let before_page_attachment = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::page_attachment_id) + .expect("initial target Page attachment"); + let before_renderer_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(Some("SID-1")) + .expect("initial renderer agent attachment"); + let (before_devtools_agent, before_runtime) = { + let page = ctx + .conn + .browser_context + .as_mut() + .and_then(|browser_context| { + browser_context.active_target.runtime_slot.loaded_page_mut() + }) + .expect("initial loaded Page"); + let devtools_agent = page.renderer_devtools_agent_token(); + let runtime = page + .runtime_heap_usage_async() + .await + .expect("initial Page runtime diagnostics") + .moli + .runtime; + (devtools_agent, runtime) + }; + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 90_112, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { + "url": "data:text/html,
replacement document
" + } + })) + .await; + let navigate = take_response_by_id(&mut ctx, 90_112); + let replacement_loader_id = navigate["result"]["loaderId"] + .as_str() + .expect("replacement navigation loader id") + .to_owned(); + wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1"], "TID-1").await; + wait_until_renderer_document_load(&mut ctx, Some("SID-1"), "TID-1", &replacement_loader_id) + .await; + assert_runtime_navigation_context_reset(&ctx.sent, "SID-1", "TID-1"); + let new_context_id = ctx + .sent + .iter() + .rev() + .find(|message| { + message["method"] == json!("Runtime.executionContextCreated") + && message["sessionId"] == json!("SID-1") + && message["params"]["context"]["auxData"]["isDefault"] == json!(true) + }) + .and_then(|message| message["params"]["context"]["id"].as_i64()) + .expect("replacement default execution context id"); + assert_ne!(new_context_id, old_context_id); + + let after_target_page = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("replacement target Page residence"); + let after_renderer_page = ctx + .conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")) + .expect("replacement renderer Page residence"); + let after_page_attachment = ctx + .conn + .browser_context + .as_ref() + .and_then(BrowserContext::page_attachment_id) + .expect("replacement target Page attachment"); + let after_renderer_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(Some("SID-1")) + .expect("replacement renderer agent attachment"); + let (after_devtools_agent, after_runtime) = { + let page = ctx + .conn + .browser_context + .as_mut() + .and_then(|browser_context| { + browser_context.active_target.runtime_slot.loaded_page_mut() + }) + .expect("replacement loaded Page"); + let devtools_agent = page.renderer_devtools_agent_token(); + let runtime = page + .runtime_heap_usage_async() + .await + .expect("replacement Page runtime diagnostics") + .moli + .runtime; + (devtools_agent, runtime) + }; + + assert_eq!(after_target_page, before_target_page); + assert_eq!(after_renderer_page, before_renderer_page); + assert_eq!(after_page_attachment, before_page_attachment); + assert_ne!(after_renderer_attachment, before_renderer_attachment); + assert_ne!(after_devtools_agent, before_devtools_agent); + assert_eq!( + after_runtime.script_agent_id, + before_runtime.script_agent_id + ); + assert_ne!( + after_runtime.inspector_context_group_id, + before_runtime.inspector_context_group_id + ); + assert_eq!( + after_runtime.main_window_proxy_identity_hash, + before_runtime.main_window_proxy_identity_hash + ); + assert!(after_runtime.main_window_proxy_identity_hash.is_some()); + + ctx.process_async(json!({ + "id": 90_113, + "method": "Runtime.evaluate", + "sessionId": "SID-1", + "params": { + "expression": "JSON.stringify([document.querySelector('#stable-page-replacement').textContent, typeof globalThis.__stablePageOldRealm, globalThis.__stablePageNewRealm])", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 90_113)["result"]["result"]["value"], + json!(r#"["replacement document","undefined","new"]"#) + ); + ctx.process_async(json!({ + "id": 90_114, + "method": "Runtime.evaluate", + "sessionId": "SID-1", + "params": { + "contextId": old_context_id, + "expression": "globalThis.__stablePageOldRealm", + "returnByValue": true + } + })) + .await; + let stale_context = take_response_by_id(&mut ctx, 90_114); + assert_eq!(stale_context["error"]["code"], json!(-32000)); + assert_eq!( + stale_context["error"]["message"], + json!("Cannot find context with specified id") + ); +} + #[tokio::test(flavor = "multi_thread")] async fn renderer_navigation_background_events_keep_typed_sidecars() { let mut ctx = TestContext::new(); @@ -3390,6 +3631,8 @@ async fn navigate_after_real_runtime_enable_resets_before_creating_default_conte })) .await; + wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1"], "TID-1").await; + let sent = ctx.take_all(); assert_runtime_navigation_context_reset(&sent, "SID-1", "TID-1"); } @@ -3433,6 +3676,8 @@ async fn navigate_after_real_runtime_enable_fans_out_context_reset_to_auxiliary_ })) .await; + wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1", "SID-aux"], "TID-1").await; + let sent = ctx.take_all(); for session_id in ["SID-1", "SID-aux"] { assert_runtime_navigation_context_reset(&sent, session_id, "TID-1"); @@ -3470,6 +3715,8 @@ async fn navigate_from_auxiliary_session_keeps_primary_and_auxiliary_runtime_eve })) .await; + wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1", "SID-aux"], "TID-1").await; + let sent = ctx.take_all(); for session_id in ["SID-1", "SID-aux"] { assert_runtime_navigation_context_reset(&sent, session_id, "TID-1"); @@ -3519,6 +3766,8 @@ async fn navigate_after_auxiliary_runtime_disable_keeps_primary_runtime_enabled( })) .await; + wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1"], "TID-1").await; + let sent = ctx.take_all(); assert_runtime_navigation_context_reset(&sent, "SID-1", "TID-1"); assert!( @@ -5075,6 +5324,13 @@ async fn navigate_failure_commits_error_document_with_visible_unreachable_url() .accepts_document_body_completion_event(&committed_document_token), "ordinary navigation load failures must invalidate the previously committed document" ); + wait_until_message( + &mut ctx, + Some("SID-1"), + "network error Document stopped loading", + |message| message["method"] == json!("Page.frameStoppedLoading"), + ) + .await; let messages = ctx.take_all(); let frame_navigated = messages @@ -5117,21 +5373,37 @@ async fn navigate_failure_commits_error_document_with_visible_unreachable_url() ctx.process_async(json!({ "id": 233, + "method": "Page.getFrameTree", + "sessionId": "SID-1" + })) + .await; + let frame_tree = take_response_by_id(&mut ctx, 233); + assert_eq!( + frame_tree["result"]["frameTree"]["frame"]["url"], + NETWORK_ERROR_PAGE_URL + ); + assert_eq!( + frame_tree["result"]["frameTree"]["frame"]["unreachableUrl"], + unreachable_url + ); + + ctx.process_async(json!({ + "id": 234, "method": "Page.getNavigationHistory", "sessionId": "SID-1" })) .await; - let history = take_response_by_id(&mut ctx, 233); + let history = take_response_by_id(&mut ctx, 234); let current_index = history["result"]["currentIndex"] .as_u64() .expect("current history index") as usize; assert_eq!( - history["result"]["entries"][current_index]["url"], - unreachable_url + history["result"]["entries"][current_index]["url"], unreachable_url, + "history should expose the failed requested URL, not the internal error URL" ); } #[tokio::test(flavor = "multi_thread")] -async fn navigate_failure_creates_runtime_context_and_completes_lifecycle() { +async fn navigate_failure_replaces_runtime_context_and_completes_lifecycle() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); drop(listener); @@ -5139,19 +5411,29 @@ async fn navigate_failure_creates_runtime_context_and_completes_lifecycle() { let mut ctx = TestContext::new(); load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); ctx.enable_page_events_for_test(Some("SID-1")); - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.devtools_session_state - .runtime_session_state - .runtime_frontend_enabled = true; - bc.devtools_session_state + ensure_initial_document_for_session(&mut ctx, Some("SID-1")).await; + ctx.process_async(json!({ + "id": 230, + "method": "Runtime.enable", + "sessionId": "SID-1" + })) + .await; + let _ = take_response_by_id(&mut ctx, 230); + ctx.conn + .browser_context + .as_mut() + .unwrap() + .devtools_session_state .page_session_state .page_lifecycle_events = true; + ctx.sent.clear(); + let unreachable_url = format!("http://{addr}/missing"); ctx.process_async(json!({ "id": 232, "method": "Page.navigate", "sessionId": "SID-1", - "params": { "url": format!("http://{addr}/missing") } + "params": { "url": unreachable_url } })) .await; wait_until_message( @@ -5162,8 +5444,8 @@ async fn navigate_failure_creates_runtime_context_and_completes_lifecycle() { ) .await; - let response = ctx - .sent + let messages = ctx.take_all(); + let response = messages .iter() .find(|message| message["id"] == json!(232)) .expect("Page.navigate response"); @@ -5173,23 +5455,52 @@ async fn navigate_failure_creates_runtime_context_and_completes_lifecycle() { assert_eq!(response["result"]["isDownload"], false); assert!(response["result"]["errorText"].is_string()); - let messages = ctx.take_all(); - assert!( - messages.iter().any(|message| { + assert_runtime_navigation_context_reset(&messages, "SID-1", "TID-1"); + let frame_index = messages + .iter() + .position(|message| message["method"] == json!("Page.frameNavigated")) + .unwrap_or_else(|| panic!("missing error frame commit: {messages:?}")); + let default_context_index = messages + .iter() + .position(|message| { message["method"] == json!("Runtime.executionContextCreated") && message["params"]["context"]["auxData"]["isDefault"] == json!(true) - }), - "error Document should create a default runtime context: {messages:?}" - ); + }) + .unwrap_or_else(|| panic!("missing replacement default context: {messages:?}")); + let dom_content_loaded_index = messages + .iter() + .position(|message| message["method"] == json!("Page.domContentEventFired")) + .unwrap_or_else(|| panic!("missing error Document DCL: {messages:?}")); + let load_index = messages + .iter() + .position(|message| message["method"] == json!("Page.loadEventFired")) + .unwrap_or_else(|| panic!("missing error Document load: {messages:?}")); assert!( - messages.iter().any(|message| { - message["method"] == json!("Page.lifecycleEvent") - && message["params"]["name"] == json!("DOMContentLoaded") - }) && messages.iter().any(|message| { - message["method"] == json!("Page.lifecycleEvent") - && message["params"]["name"] == json!("load") - }), - "error Document should complete lifecycle: {messages:?}" + frame_index < default_context_index + && default_context_index < dom_content_loaded_index + && dom_content_loaded_index < load_index, + "error Document commit/context/lifecycle ordering should match a real replacement Document: {messages:?}" + ); + assert_eq!( + messages[frame_index]["params"]["frame"]["url"], + NETWORK_ERROR_PAGE_URL + ); + assert_eq!( + messages[frame_index]["params"]["frame"]["unreachableUrl"], + unreachable_url + ); + assert!(messages.iter().any(|message| { + message["method"] == json!("Page.lifecycleEvent") + && message["params"]["name"] == json!("DOMContentLoaded") + })); + assert!(messages.iter().any(|message| { + message["method"] == json!("Page.lifecycleEvent") + && message["params"]["name"] == json!("load") + })); + assert!( + messages + .iter() + .any(|message| message["method"] == json!("Page.frameStoppedLoading")) ); } #[tokio::test(flavor = "multi_thread")] @@ -6069,6 +6380,29 @@ async fn reload_targets_background_owner_without_promotion() { initial_html.contains(">1<"), "expected first background load to contain counter 1" ); + let target_page_before = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-background")) + .expect("background target Page residence before reload"); + let renderer_page_before = ctx + .conn + .renderer_page_residence_identity_for_session_owner(Some("SID-background")) + .expect("background renderer Page residence before reload"); + let (page_attachment_before, devtools_agent_before) = ctx + .conn + .browser_context + .as_ref() + .and_then(|browser_context| browser_context.background_target("TID-background")) + .map(|target| { + ( + target.page_attachment_id(), + target + .loaded_page() + .expect("background loaded Page before reload") + .renderer_devtools_agent_token(), + ) + }) + .expect("background target before reload"); ctx.sent.clear(); ctx.process_async(json!({ @@ -6090,6 +6424,19 @@ async fn reload_targets_background_owner_without_promotion() { let background = browser_context .background_target_mut("TID-background") .expect("background target should remain parked"); + assert_eq!( + background.page_attachment_id(), + page_attachment_before, + "background reload must retain its target Page attachment" + ); + assert_ne!( + background + .loaded_page() + .expect("background loaded Page after reload") + .renderer_devtools_agent_token(), + devtools_agent_before, + "background reload must install a new renderer Document agent" + ); let reloaded_html = background .loaded_page_mut() .expect("loaded background page after reload") @@ -6100,6 +6447,16 @@ async fn reload_targets_background_owner_without_promotion() { reloaded_html.contains(">2<"), "expected background reload html to contain counter 2, got {reloaded_html}" ); + assert_eq!( + ctx.conn + .target_page_residence_identity_for_session(Some("SID-background")), + Some(target_page_before) + ); + assert_eq!( + ctx.conn + .renderer_page_residence_identity_for_session_owner(Some("SID-background")), + Some(renderer_page_before) + ); server.abort(); } @@ -6348,6 +6705,8 @@ async fn repeated_http_navigation_after_runtime_enable_replaces_the_context_grou .expect("second HTTP navigation should have a loader") .to_owned(); + wait_until_runtime_navigation_context_resets(&mut ctx, &["SID-1"], "TID-1").await; + assert_ne!(first_loader_id, second_loader_id); assert_eq!( request_count.load(std::sync::atomic::Ordering::SeqCst), @@ -6546,66 +6905,76 @@ async fn parser_script_location_navigation_suppresses_aborted_document_dcl() { let mut ctx = TestContext::new(); load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); - ctx.process_async(json!({ - "id": 251, - "method": "Page.navigate", - "sessionId": "SID-1", - "params": { "url": format!("http://{addr}/challenge") } - })) - .await; - let _ = take_response_by_id(&mut ctx, 251); - wait_until_messages( - &mut ctx, - Some("SID-1"), - "successor document lifecycle after parser-script navigation", - |messages| { - messages - .iter() - .filter(|message| message["method"] == json!("Page.frameNavigated")) - .count() - >= 2 - && messages - .iter() - .any(|message| message["method"] == json!("Page.domContentEventFired")) - }, - ) - .await; + ctx.enable_background_navigation_scheduler_for_test(); + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_and_wait_for_response_async(json!({ + "id": 251, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/challenge") } + })) + .await; + let _ = take_response_by_id(&mut ctx, 251); + ctx.wait_for_document_continuation_for_test( + Some("SID-1"), + "parser-script successor navigation", + ) + .await; + wait_until_messages( + &mut ctx, + Some("SID-1"), + "successor document lifecycle after parser-script navigation", + |messages| { + messages + .iter() + .filter(|message| message["method"] == json!("Page.frameNavigated")) + .count() + >= 2 + && messages + .iter() + .any(|message| message["method"] == json!("Page.domContentEventFired")) + }, + ) + .await; - let events = ctx.take_all(); - let domcontentloaded = events - .iter() - .filter(|message| message["method"] == json!("Page.domContentEventFired")) - .collect::>(); - assert_eq!( - domcontentloaded.len(), - 1, - "the synchronously aborted challenge document must not emit DCL: {events:?}" - ); - let final_frame_commit_index = events - .iter() - .rposition(|message| message["method"] == json!("Page.frameNavigated")) - .expect("final document frame commit"); - let domcontentloaded_index = events - .iter() - .position(|message| message["method"] == json!("Page.domContentEventFired")) - .expect("final document DCL"); - assert!( - final_frame_commit_index < domcontentloaded_index, - "the only DCL must belong to the final document: {events:?}" - ); - assert_eq!( - ctx.conn - .browser_context - .as_ref() - .expect("browser context") - .target_url(), - format!("http://{addr}/final") - ); - assert!( - loaded_page_html_for_test(&mut ctx) - .await - .contains("final content") - ); + let events = ctx.take_all(); + let domcontentloaded = events + .iter() + .filter(|message| message["method"] == json!("Page.domContentEventFired")) + .collect::>(); + assert_eq!( + domcontentloaded.len(), + 1, + "the synchronously aborted challenge document must not emit DCL: {events:?}" + ); + let final_frame_commit_index = events + .iter() + .rposition(|message| message["method"] == json!("Page.frameNavigated")) + .expect("final document frame commit"); + let domcontentloaded_index = events + .iter() + .position(|message| message["method"] == json!("Page.domContentEventFired")) + .expect("final document DCL"); + assert!( + final_frame_commit_index < domcontentloaded_index, + "the only DCL must belong to the final document: {events:?}" + ); + assert_eq!( + ctx.conn + .browser_context + .as_ref() + .expect("browser context") + .target_url(), + format!("http://{addr}/final") + ); + assert!( + loaded_page_html_for_test(&mut ctx) + .await + .contains("final content") + ); + }) + .await; server.abort(); } diff --git a/moli-protocol/src/domains/page/tests/scripts.rs b/moli-protocol/src/domains/page/tests/scripts.rs index 6bb6edeec1..21eb71081c 100644 --- a/moli-protocol/src/domains/page/tests/scripts.rs +++ b/moli-protocol/src/domains/page/tests/scripts.rs @@ -199,6 +199,7 @@ async fn add_script_to_evaluate_on_new_document_reuses_identifier_for_duplicate_ .to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); @@ -270,6 +271,7 @@ async fn add_script_to_evaluate_on_new_document_restored_scripts_bump_from_max_i source: "globalThis.__lm_preload = 'seed';".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); @@ -327,6 +329,7 @@ async fn add_script_to_evaluate_on_new_document_bump_ignores_non_numeric_identif source: "globalThis.__lm_preload = 'legacy';".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); @@ -344,6 +347,7 @@ async fn add_script_to_evaluate_on_new_document_bump_ignores_non_numeric_identif source: "globalThis.__lm_preload = 'seed';".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, )); diff --git a/moli-protocol/src/domains/runtime/test_support.rs b/moli-protocol/src/domains/runtime/test_support.rs index dff833a530..19aecc58c1 100644 --- a/moli-protocol/src/domains/runtime/test_support.rs +++ b/moli-protocol/src/domains/runtime/test_support.rs @@ -4,13 +4,21 @@ use crate::conn::BrowserContext; use crate::testing::TestContext; pub(super) async fn with_loaded_document_async(ctx: &mut TestContext, html: &str) { + with_loaded_document_for_target_async(ctx, html, "TID-1").await; +} + +pub(super) async fn with_loaded_document_for_target_async( + ctx: &mut TestContext, + html: &str, + target_id: &str, +) { ctx.conn .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.conn .browser_context .as_mut() .expect("browser context should be installed before loading test page") - .set_active_target_id("TID-1".to_owned()); + .set_active_target_id(target_id.to_owned()); let data_url = format!("data:text/html,{html}"); ctx.install_navigation_fixture_for_session_owner(&data_url, None) .await; diff --git a/moli-protocol/src/domains/runtime/tests/console.rs b/moli-protocol/src/domains/runtime/tests/console.rs index 66adf35f35..5d78c2cd36 100644 --- a/moli-protocol/src/domains/runtime/tests/console.rs +++ b/moli-protocol/src/domains/runtime/tests/console.rs @@ -341,16 +341,12 @@ async fn failed_heap_profiler_sampling_command_does_not_create_typed_projection( #[tokio::test(flavor = "multi_thread")] async fn heap_profiler_sampling_and_tracking_are_restored_on_replacement_page_isolate() { let mut ctx = TestContext::new(); - with_loaded_document_async( + with_loaded_document_for_target_async( &mut ctx, "", + "TID-heap-profiler-restore", ) .await; - ctx.conn - .browser_context - .as_mut() - .expect("browser context should exist") - .set_active_target_id("TID-heap-profiler-restore"); for command in [ json!({"id": 206_840, "method": "HeapProfiler.enable"}), @@ -665,13 +661,13 @@ async fn heap_profiler_moli_diagnostics_reports_connection_state_without_loaded_ ); assert_eq!( isolate_scope["runtimeGetHeapUsageV8HeapScope"], - json!("page-vm-document-isolate"), + json!("script-agent-document-isolate"), "diagnostics should label Runtime.getHeapUsage heap scope: {response:?}" ); assert_eq!( isolate_scope["runtimeGetHeapUsageV8HeapIsTargetLocal"], - json!(true), - "diagnostics should make PageVM-local heap stats explicit: {response:?}" + json!(false), + "heap stats may include explicitly related Pages in the same script agent: {response:?}" ); assert_eq!( isolate_scope["runtimeGetHeapUsageMoliCountersScope"], @@ -680,12 +676,12 @@ async fn heap_profiler_moli_diagnostics_reports_connection_state_without_loaded_ ); assert_eq!( isolate_scope["runtimeCollectGarbageScope"], - json!("page-vm-document-isolate"), + json!("script-agent-document-isolate"), "diagnostics should label collectGarbage scope: {response:?}" ); assert_eq!( isolate_scope["v8ForegroundTaskWakeScope"], - json!("page-vm-document-isolate"), + json!("script-agent-document-isolate"), "diagnostics should label V8 foreground task wake scope: {response:?}" ); assert_eq!( diff --git a/moli-protocol/src/domains/runtime/tests/evaluate.rs b/moli-protocol/src/domains/runtime/tests/evaluate.rs index 0c94b7e4b5..ee1c7cb528 100644 --- a/moli-protocol/src/domains/runtime/tests/evaluate.rs +++ b/moli-protocol/src/domains/runtime/tests/evaluate.rs @@ -1,4 +1,5 @@ use super::*; +use crate::BackgroundNavigationCompletion; /// Runtime.enable is a no-op that succeeds. #[tokio::test] @@ -516,12 +517,12 @@ async fn loaded_page_runtime_agent_state_commands_dispatch_after_runtime_enable( #[tokio::test(flavor = "multi_thread")] async fn runtime_agent_configuration_is_restored_on_replacement_page_isolate() { let mut ctx = TestContext::new(); - with_loaded_document_async(&mut ctx, "before").await; - ctx.conn - .browser_context - .as_mut() - .expect("browser context should exist") - .set_active_target_id("TID-runtime-agent-restore"); + with_loaded_document_for_target_async( + &mut ctx, + "before", + "TID-runtime-agent-restore", + ) + .await; enable_runtime_and_take_execution_context_id_async(&mut ctx, 11_016).await; for (id, method, params) in [ @@ -606,12 +607,12 @@ async fn runtime_agent_configuration_is_restored_on_replacement_page_isolate() { #[tokio::test(flavor = "multi_thread")] async fn opaque_reattach_state_wins_over_conflicting_runtime_listener_configuration() { let mut ctx = TestContext::new(); - with_loaded_document_async(&mut ctx, "before").await; - ctx.conn - .browser_context - .as_mut() - .expect("browser context should exist") - .set_active_target_id("TID-runtime-reattach-precedence"); + with_loaded_document_for_target_async( + &mut ctx, + "before", + "TID-runtime-reattach-precedence", + ) + .await; ctx.process_async(json!({"id": 11_023, "method": "Runtime.enable"})) .await; @@ -2233,7 +2234,7 @@ async fn page_navigate_empty_http_error_commits_browser_error_document() { } #[tokio::test(flavor = "multi_thread")] -async fn page_navigate_network_failure_commits_error_document() { +async fn page_navigate_network_failure_commits_error_document_in_stable_page() { async fn first() -> impl IntoResponse { ( [(CONTENT_TYPE.as_str(), "text/html; charset=utf-8")], @@ -2497,23 +2498,23 @@ async fn page_navigate_network_failure_commits_error_document() { })) .await; ctx.expect_error(91, -32000, "No resource with given identifier found"); - assert_ne!( + assert_eq!( ctx.conn .target_page_residence_identity_for_session(Some("SID-1")), Some(before_target_page), - "current master installs navigation Documents as a new target Page attachment" + "the network error Document must stay in the target's stable Page residence" ); - assert_ne!( + assert_eq!( ctx.conn .renderer_page_residence_identity_for_session_owner(Some("SID-1")), Some(before_renderer_page), - "the error Document must not reuse the retired renderer Page" + "the network error Document must replace only the renderer Page's current realm" ); assert_ne!( ctx.conn .current_renderer_agent_attachment_id_for_session_owner(Some("SID-1")), Some(before_renderer_attachment), - "the error Document must own a replacement realm/Inspector attachment" + "the error Document must replace the previous realm/renderer agent" ); ctx.process_async(json!({ diff --git a/moli-protocol/src/domains/runtime/tests/mod.rs b/moli-protocol/src/domains/runtime/tests/mod.rs index 5ec7fa13be..7682181533 100644 --- a/moli-protocol/src/domains/runtime/tests/mod.rs +++ b/moli-protocol/src/domains/runtime/tests/mod.rs @@ -1,13 +1,13 @@ use super::test_support::{ create_isolated_world_async, enable_runtime_and_take_execution_context_id_async, take_response_by_id, wait_for_response_by_id_async, with_loaded_document_async, - with_loaded_document_for_active_target_async, with_loaded_http_document_async, + with_loaded_document_for_active_target_async, with_loaded_document_for_target_async, + with_loaded_http_document_async, }; use crate::conn::{ BrowserContext, CdpCommandTaskStep, CdpSchedulerEvent, NETWORK_ERROR_PAGE_URL, PendingCdpCommandDispatch, }; -use crate::domains::page::BackgroundNavigationCompletion; use crate::testing::{ TestContext, spawn_connection_drop_server, wait_until_message, wait_until_messages, }; diff --git a/moli-protocol/src/domains/runtime/tests/navigation.rs b/moli-protocol/src/domains/runtime/tests/navigation.rs index 7a088df9d7..0078f6b7f8 100644 --- a/moli-protocol/src/domains/runtime/tests/navigation.rs +++ b/moli-protocol/src/domains/runtime/tests/navigation.rs @@ -9,12 +9,7 @@ async fn enable_with_background_event_sender_defers_initial_document_page_build( bc.set_target_url("about:blank".into()); ctx.conn.browser_context = Some(bc); - let (background_tx, _) = tokio::sync::mpsc::unbounded_channel(); - let (completion_tx, _) = - tokio::sync::mpsc::unbounded_channel::(); - ctx.conn.set_background_event_sender(background_tx); - ctx.conn - .set_background_navigation_completion_sender(completion_tx); + ctx.enable_background_navigation_scheduler_for_test(); ctx.process_async(json!({ "id": 21, diff --git a/moli-protocol/src/domains/runtime/tests/shared_worker.rs b/moli-protocol/src/domains/runtime/tests/shared_worker.rs index e3055c81fe..d73f8e23ee 100644 --- a/moli-protocol/src/domains/runtime/tests/shared_worker.rs +++ b/moli-protocol/src/domains/runtime/tests/shared_worker.rs @@ -600,7 +600,7 @@ onconnect = event => { let response = take_response_by_id(&mut ctx, 90_022); let isolate_scope = &response["result"]["isolateScope"]; - assert_eq!(isolate_scope["documentIsolateModel"], json!("page-vm")); + assert_eq!(isolate_scope["documentIsolateModel"], json!("script-agent")); assert_eq!(isolate_scope["loadedDocumentPageCount"], json!(2)); assert_eq!( isolate_scope["loadedDocumentRendererOwnerCount"], @@ -775,8 +775,8 @@ onconnect = event => { ); assert_eq!( isolate_scope["estimatedDocumentIsolateCount"], - json!(2), - "popup PageVM must be counted as a second document isolate: {response:?}" + json!(1), + "a related opener and popup keep separate Window realms in one stable script-agent isolate: {response:?}" ); assert_eq!( isolate_scope["documentContextCount"], @@ -801,8 +801,8 @@ onconnect = event => { ); assert_eq!( isolate_scope["estimatedLiveV8IsolateCount"], - json!(4), - "combo should be two page document isolates plus two worker isolates: {response:?}" + json!(3), + "combo should be one related-page script-agent isolate plus two worker isolates: {response:?}" ); assert_eq!( response["result"]["activeBrowserContext"]["backgroundLoadedPageCount"], diff --git a/moli-protocol/src/domains/target/lifecycle.rs b/moli-protocol/src/domains/target/lifecycle.rs new file mode 100644 index 0000000000..4944e04ae7 --- /dev/null +++ b/moli-protocol/src/domains/target/lifecycle.rs @@ -0,0 +1,3095 @@ +use serde::Deserialize; + +use crate::conn::{ + BackgroundProtocolEvent, CdpTargetFilter, CdpTargetFilterEntry, PopupTargetNavigationKind, + PopupTargetNavigationOwnerAction, PreparedTargetAttach, TargetAttachSessionCommit, +}; +use crate::devtools_runtime::{ + DevToolsActivateTargetCommand, DevToolsBrowserContextId, DevToolsCloseTargetCommand, + DevToolsCloseTargetResult, DevToolsCommand, DevToolsCommandResult, DevToolsCreateTargetCommand, + DevToolsCreateTargetResult, DevToolsError, DevToolsErrorKind, DevToolsGetTargetInfoCommand, + DevToolsGetTargetInfoResult, DevToolsTargetId, DevToolsTargetInfo, +}; + +use crate::domains::command_output::CommandOutputPlan; + +use super::*; + +pub(super) struct DevToolsCreateTargetExecution { + pub(super) result: DevToolsCreateTargetResult, + pub(super) protocol_events: CreatedTargetProtocolEvents, +} + +pub(super) struct CreatedTargetProtocolEvents { + target_id: String, + attached_tab_sessions: Vec, + attached_sessions: Vec, +} + +impl CreatedTargetProtocolEvents { + pub(super) fn target_id(&self) -> &str { + &self.target_id + } +} + +pub(super) fn emit_created_target_protocol_events( + conn: &mut CdpConnection, + events: CreatedTargetProtocolEvents, + out: &mut impl events::CdpTargetAutomationEventSink, +) -> Result<(), DevToolsError> { + let has_discovery = conn.has_any_target_discovery(); + if !has_discovery + && events.attached_tab_sessions.is_empty() + && events.attached_sessions.is_empty() + { + return Ok(()); + } + let bc = conn + .browser_contexts() + .find(|browser_context| { + browser_context + .devtools_target_info(&events.target_id) + .is_some() + }) + .ok_or_else(|| DevToolsError::new(DevToolsErrorKind::NoSuchTarget, "UnknownTargetId"))?; + let target_info = bc + .devtools_target_info(&events.target_id) + .ok_or_else(|| DevToolsError::new(DevToolsErrorKind::NoSuchTarget, "UnknownTargetId"))?; + if let Some(message) = super::transient_no_page_devtools_target_info_error(conn, &target_info) { + return Err(DevToolsError::new(DevToolsErrorKind::Internal, message)); + } + if has_discovery { + for event in conn.target_created_event_plan(&events.target_id) { + out.push_target_background_event(event); + } + } + if let Some(tab_target_info) = conn.tab_target_info_for_page_target_info(&target_info) { + let tab_target_id = tab_target_info + .target_id + .as_ref() + .map(|target_id| target_id.as_str().to_owned()) + .ok_or_else(|| DevToolsError::new(DevToolsErrorKind::Internal, "MissingTabTargetId"))?; + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + tab_target_id, + tab_target_info, + events.attached_tab_sessions, + )); + for event in event_plan { + out.push_target_background_event(event); + } + } + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + events.target_id, + target_info, + events.attached_sessions, + )); + for event in event_plan { + out.push_target_background_event(event); + } + Ok(()) +} + +fn push_target_created_events( + conn: &mut CdpConnection, + out: &mut impl events::CdpTargetAutomationEventSink, + page_target_id: &str, +) { + for event in conn.target_created_event_plan(page_target_id) { + out.push_target_background_event(event); + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateTargetParams { + #[serde(default = "default_blank")] + url: String, + browser_context_id: Option, +} + +fn default_blank() -> String { + "about:blank".into() +} + +pub(super) fn start_create_target_command( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> TargetCommandTaskStep { + if let Some(session_id) = cmd.session_id + && matches!( + conn.session_route(Some(session_id)), + Some( + crate::conn::CdpSessionRoute::SharedWorkerTarget { .. } + | crate::conn::CdpSessionRoute::DedicatedWorkerTarget { .. } + ) + ) + { + return super::target_command_error(-31998, "DirectSessionRouteRequired"); + } + + let params: CreateTargetParams = match cmd.get_params() { + Ok(Some(p)) => p, + Ok(None) => CreateTargetParams { + url: "about:blank".into(), + browser_context_id: None, + }, + Err(e) => { + return super::target_command_error(-32602, e); + } + }; + let command = build_cdp_create_target_command(cmd, params); + super::start_devtools_target_command( + conn, + cmd.id, + cmd.session_id, + DevToolsCommand::CreateTarget(command), + ) +} + +fn build_cdp_create_target_command( + cmd: &Cmd<'_>, + params: CreateTargetParams, +) -> DevToolsCreateTargetCommand { + DevToolsCreateTargetCommand { + context: cmd.devtools_command_context(None::<&str>, params.browser_context_id.as_deref()), + url: params.url, + browser_context_id: params + .browser_context_id + .map(DevToolsBrowserContextId::from), + activate: false, + } +} + +pub(super) fn start_devtools_create_target_command( + conn: &mut CdpConnection, + command_id: Option, + command_session_id: Option<&str>, + command: DevToolsCreateTargetCommand, +) -> TargetCommandTaskStep { + let mut plan = CommandOutputPlan::default(); + let execution = execute_devtools_create_target_command(conn, command); + let (created_target_id, created_target_protocol_events) = match execution { + Ok(execution) => { + let target_id = execution.result.target_id.clone(); + plan.extend(CommandOutputPlan::from_devtools_result( + DevToolsCommandResult::CreateTarget(execution.result), + )); + (target_id, execution.protocol_events) + } + Err(error) => { + plan.extend(CommandOutputPlan::from_devtools_error(error)); + return TargetCommandTaskStep::Complete(plan); + } + }; + let initial_document_route = + conn.target_session_route_for_target_id(created_target_id.as_str()); + let pending_initial_document = if let Some(route) = initial_document_route.clone() { + let mut route_scope = conn.scoped_none_session_owner_route_override(route); + route_scope + .conn_mut() + .start_initial_document_page_ensure_for_session_owner(None) + } else { + Ok(None) + }; + match pending_initial_document { + Ok(Some(initial_document)) => { + TargetCommandTaskStep::Pending(PendingTargetCommandDispatch { + command_id, + session_id: command_session_id.map(str::to_owned), + kind: Box::new(PendingTargetCommandKind::CreateTarget { + response_plan: plan, + protocol_events: created_target_protocol_events, + initial_document_route, + initial_document: Some(Box::new(initial_document)), + }), + }) + } + Ok(None) => { + let mut output_plan = CommandOutputPlan::default(); + let mut protocol_events = Vec::new(); + if let Err(error) = emit_created_target_protocol_events( + conn, + created_target_protocol_events, + &mut protocol_events, + ) { + return TargetCommandTaskStep::Complete(CommandOutputPlan::from_devtools_error( + error, + )); + } + for event in protocol_events { + output_plan.push_background_event(event); + } + output_plan.extend(plan); + TargetCommandTaskStep::Complete(output_plan) + } + Err(message) => TargetCommandTaskStep::Complete(CommandOutputPlan::error(-32000, message)), + } +} + +pub(super) fn execute_devtools_create_target_command( + conn: &mut CdpConnection, + command: DevToolsCreateTargetCommand, +) -> Result { + let restore_browser_context_id = previously_active_browser_context_id(conn); + if let Err(error) = activate_browser_context_for_create_target(conn, &command) { + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + return Err(error); + } + if conn.browser_context.is_none() && conn.inactive_browser_contexts.is_empty() { + let id = conn.gen_bc_id(); + conn.insert_browser_context(conn.new_browser_context(id)); + } + + let target_id = conn.gen_target_id(); + let default_target_id = conn.default_target_id(); + let browser_context = conn + .browser_context + .as_ref() + .expect("browser context must exist before target creation"); + let has_active_target = browser_context.active_target_identity().is_some() + && !browser_context.active_target_is_unclaimed_default_placeholder(default_target_id); + let auto_attach_page_owners = top_level_page_auto_attach_owner_sessions(conn); + let auto_attach_tab_owners = top_level_tab_auto_attach_owner_sessions(conn); + let auto_attached_page_sessions = auto_attach_page_owners + .iter() + .map(|owner_session_id| (owner_session_id.clone(), conn.gen_session_id())) + .collect::>(); + let auto_attached_tab_sessions = auto_attach_tab_owners + .iter() + .map(|owner_session_id| (owner_session_id.clone(), conn.gen_session_id())) + .collect::>(); + let creating_background_target = has_active_target && !command.activate; + let auto_attached_background_session_id = if creating_background_target { + auto_attached_page_sessions + .first() + .map(|(_, session_id)| session_id.clone()) + } else { + None + }; + let activating_created_target = has_active_target && command.activate; + let initial_empty_document_url = create_target_initial_empty_document_url(&command.url); + if activating_created_target { + conn.handoff_navigation_engine_for_active_target_demotion(); + } + { + let bc = conn.browser_context.as_mut().unwrap(); + if creating_background_target { + bc.stage_background_target( + target_id.clone(), + auto_attached_background_session_id.clone(), + command.url.clone(), + Some(initial_empty_document_url.clone()), + None, + ); + } else if activating_created_target { + bc.stage_active_target_demoting_current( + target_id.clone(), + None, + command.url.clone(), + Some(initial_empty_document_url.clone()), + ); + } else { + bc.set_active_target_id(target_id.clone()); + bc.set_target_url(command.url.clone()); + bc.begin_active_target_initial_empty_document(initial_empty_document_url.clone()); + bc.active_target.owner_state.target_crash_state.clear(); + } + if command.url != initial_empty_document_url { + // Chromium gives Target.createTarget(url) one initial + // auto_toplevel history entry for url. The implementation still + // materializes an internal about:blank document for target setup, + // so replace that bookkeeping entry when the requested URL commits. + bc.mark_target_initial_url_replaces_empty_document(&target_id); + } + } + let tab_target_id = conn.register_top_level_page_target(&target_id); + let created_target_id = target_id.clone(); + + let mut attached_tab_sessions = Vec::new(); + for (owner_session_id, session_id) in &auto_attached_tab_sessions { + let waiting_for_debugger = + conn.auto_attach_owner_waits_for_debugger_on_start(owner_session_id.as_deref()); + let assigned = conn.prepare_auto_attached_tab_session_binding( + &tab_target_id, + session_id.clone(), + owner_session_id.as_deref(), + ); + debug_assert!(assigned, "created tab target must remain addressable"); + attached_tab_sessions.push(conn.prepare_auto_attach_session_commit( + session_id.clone(), + owner_session_id.clone(), + waiting_for_debugger, + )); + } + + let mut attached_sessions = Vec::new(); + for (index, (owner_session_id, session_id)) in auto_attached_page_sessions.iter().enumerate() { + let waiting_for_debugger = + conn.auto_attach_owner_waits_for_debugger_on_start(owner_session_id.as_deref()); + if creating_background_target && index == 0 { + attached_sessions.push(conn.prepare_auto_attach_session_commit( + session_id.clone(), + owner_session_id.clone(), + waiting_for_debugger, + )); + } else { + let assigned = + conn.prepare_auto_attached_page_session_binding(&target_id, session_id.clone()); + debug_assert!( + assigned, + "newly created target must remain addressable for auto attach" + ); + attached_sessions.push(conn.prepare_auto_attach_session_commit( + session_id.clone(), + owner_session_id.clone(), + waiting_for_debugger, + )); + } + } + if activating_created_target { + conn.apply_active_engine_fetch_overrides(); + conn.invalidate_resource_runtime(); + } + + // Chromium parity: Target.createTarget acks from target lifecycle, while + // the initial document page build remains target-owned pending work. CDP + // observation commands must only see an already-installed Page or a real + // no-document state; they do not create the initial document themselves. + // See content/browser/devtools/protocol/target_handler.cc:1291. + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + + Ok(DevToolsCreateTargetExecution { + result: DevToolsCreateTargetResult { + target_id: DevToolsTargetId::from(created_target_id), + }, + protocol_events: CreatedTargetProtocolEvents { + target_id, + attached_tab_sessions, + attached_sessions, + }, + }) +} + +fn create_target_initial_empty_document_url(target_url: &str) -> String { + if url::Url::parse(target_url) + .ok() + .as_ref() + .is_some_and(moli_url::is_about_blank) + { + target_url.to_owned() + } else { + "about:blank".to_owned() + } +} + +fn top_level_page_auto_attach_owner_sessions(conn: &CdpConnection) -> Vec> { + conn.auto_attach_owner_sessions_for_target_type("page") + .into_iter() + .filter(|owner_session_id| { + super::browser_level_auto_attach_owner_session_allowed( + conn, + owner_session_id.as_deref(), + ) + }) + .collect() +} + +fn top_level_tab_auto_attach_owner_sessions(conn: &CdpConnection) -> Vec> { + conn.auto_attach_owner_sessions_for_target_type("tab") + .into_iter() + .filter(|owner_session_id| { + super::browser_level_auto_attach_owner_session_allowed( + conn, + owner_session_id.as_deref(), + ) + }) + .collect() +} + +fn activate_browser_context_for_create_target( + conn: &mut CdpConnection, + command: &DevToolsCreateTargetCommand, +) -> Result<(), DevToolsError> { + if let Some(wanted_id) = command.browser_context_id.as_ref() { + if wanted_id.as_str() == conn.default_browser_context_id() + && !conn.has_browser_context_id(wanted_id.as_str()) + { + conn.insert_browser_context(conn.new_browser_context(wanted_id.as_str().to_owned())); + } + if conn.activate_browser_context_by_id(wanted_id.as_str()) { + return Ok(()); + } + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "UnknownBrowserContextId", + )); + } + + let Some(reference_target_id) = command.context.target_id.as_ref() else { + return Ok(()); + }; + let Some(route) = conn.target_session_route_for_target_id(reference_target_id.as_str()) else { + if conn + .target_session_route_for_child_frame_id(reference_target_id.as_str()) + .is_some() + { + return Err(DevToolsError::new( + DevToolsErrorKind::InvalidArgument, + "ReferenceContextNotTopLevel", + )); + } + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "NoSuchTarget", + )); + }; + let Some(browser_context_id) = route.browser_context_id() else { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "NoSuchTarget", + )); + }; + if conn.activate_browser_context_by_id(browser_context_id) { + Ok(()) + } else { + Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "NoSuchTarget", + )) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PopupTargetOpenerIdentity { + target_id: String, + frame_id: String, +} + +impl PopupTargetOpenerIdentity { + pub(crate) fn new(target_id: impl Into, frame_id: impl Into) -> Self { + Self { + target_id: target_id.into(), + frame_id: frame_id.into(), + } + } +} + +/// A renderer-accepted auxiliary browsing-context action whose destination +/// browser context, DevTools opener identity, and DOM opener access were +/// frozen before protocol emission. +/// +/// Unlike a protocol Target.createTarget command, this action must not select +/// the browser context or opener from whichever session happens to drain it. +#[derive(Clone, Debug)] +pub(crate) struct PopupTargetCreation { + browser_context_id: String, + popup_id: Option, + url: String, + target_name: String, + opener: Option, + can_access_opener: bool, + pending_auxiliary_page: Option, + session_storage_store: Option, + initial_empty_document_storage_key: Option, +} + +impl PopupTargetCreation { + pub(crate) fn new( + browser_context_id: String, + popup_id: Option, + url: String, + target_name: String, + opener: Option, + can_access_opener: bool, + pending_auxiliary_page: Option, + session_storage_store: Option, + initial_empty_document_storage_key: Option, + ) -> Self { + Self { + browser_context_id, + popup_id, + url, + target_name, + opener, + can_access_opener, + pending_auxiliary_page, + session_storage_store, + initial_empty_document_storage_key, + } + } +} + +pub(crate) async fn create_popup_target_from_renderer_output_background_events_async( + conn: &mut CdpConnection, + out: &mut Vec, + creation: PopupTargetCreation, +) -> Option { + let PopupTargetCreation { + browser_context_id, + popup_id, + url, + target_name, + opener, + can_access_opener, + pending_auxiliary_page, + session_storage_store, + initial_empty_document_storage_key, + } = creation; + let Some(browser_context) = conn.browser_context_by_id(&browser_context_id) else { + tracing::debug!( + browser_context_id, + ?popup_id, + ?target_name, + "dropping accepted popup action after its browser context was removed" + ); + return None; + }; + + if let Some(existing_target_id) = browser_context + .target_id_for_window_name(&target_name) + .map(str::to_owned) + { + let navigation = + popup_target_has_loaded_page(conn, &browser_context_id, &existing_target_id) + .then(|| { + PopupTargetNavigationOwnerAction::capture( + conn, + &browser_context_id, + &existing_target_id, + url.clone(), + PopupTargetNavigationKind::NamedTargetReuse, + ) + }) + .flatten(); + + let target_url_updated = conn + .browser_context_by_id_mut(&browser_context_id) + .is_some_and(|browser_context| { + browser_context.update_target_url(&existing_target_id, url.clone()) + }); + if target_url_updated { + emit_target_info_changed_for_target_background_event( + conn, + out, + &browser_context_id, + &existing_target_id, + ); + if let Some(navigation) = navigation { + conn.publish_popup_target_navigation_owner_action(navigation); + } + } + return (target_url_updated + && remember_resolved_popup_target( + conn, + &browser_context_id, + popup_id, + &existing_target_id, + )) + .then_some(existing_target_id); + } + + // The renderer has already accepted an auxiliary-context action. Even when + // noopener blocks script access, Chromium preserves the creator target and + // frame as DevTools attribution for the new auxiliary target. + let opener = opener.filter(|opener| { + conn.browser_context_by_id(&browser_context_id) + .and_then(|browser_context| browser_context.devtools_target_info(&opener.target_id)) + .is_some() + }); + let can_access_opener = can_access_opener && opener.is_some(); + let popup_creator = if can_access_opener { + opener.as_ref().and_then(|opener| { + conn.browser_context_by_id(&browser_context_id) + .and_then(|browser_context| { + browser_context.initial_empty_document_creator_for_target(&opener.target_id) + }) + }) + } else { + None + }; + let target_id = conn.gen_target_id(); + let auto_attach_page_owners = top_level_page_auto_attach_owner_sessions(conn); + let auto_attach_tab_owners = top_level_tab_auto_attach_owner_sessions(conn); + let auto_attached_page_sessions = auto_attach_page_owners + .iter() + .map(|owner_session_id| (owner_session_id.clone(), conn.gen_session_id())) + .collect::>(); + let auto_attached_tab_sessions = auto_attach_tab_owners + .iter() + .map(|owner_session_id| (owner_session_id.clone(), conn.gen_session_id())) + .collect::>(); + let auto_attached_background_session_id = auto_attached_page_sessions + .first() + .map(|(_, session_id)| session_id.clone()); + let requested_url = url.clone(); + + { + let browser_context = conn.browser_context_by_id_mut(&browser_context_id)?; + browser_context.stage_popup_background_target( + target_id.clone(), + auto_attached_background_session_id.clone(), + url, + Some("about:blank".to_owned()), + popup_creator, + pending_auxiliary_page, + session_storage_store, + initial_empty_document_storage_key, + ); + // A popup's synchronously exposed about:blank is its initial empty + // Document. The first cross-document commit replaces that entry; a + // no-commit response must leave this pending replacement intact. + browser_context.mark_target_initial_url_replaces_empty_document(&target_id); + if let Some(opener) = opener { + browser_context.remember_target_opener( + &target_id, + opener.target_id, + opener.frame_id, + can_access_opener, + ); + } + browser_context.remember_target_window_name(&target_name, &target_id); + browser_context.remember_target_popup_id(popup_id, &target_id); + } + + let tab_target_id = conn.register_top_level_page_target(&target_id); + for (owner_session_id, session_id) in &auto_attached_tab_sessions { + let assigned = conn.prepare_auto_attached_tab_session_binding( + &tab_target_id, + session_id.clone(), + owner_session_id.as_deref(), + ); + assert!(assigned, "created popup tab target must remain addressable"); + } + for (index, (_, session_id)) in auto_attached_page_sessions.iter().enumerate() { + if index != 0 || auto_attached_background_session_id.is_none() { + let assigned = conn.prepare_auto_attached_page_session_binding_in_browser_context( + &browser_context_id, + &target_id, + session_id.clone(), + ); + assert!( + assigned, + "newly created popup target must remain addressable" + ); + } + } + + if !ensure_popup_initial_document_page_async(conn, &target_id).await { + rollback_incomplete_popup_target_async(conn, Some(&browser_context_id), &target_id).await; + return None; + } + + let Some(navigation) = PopupTargetNavigationOwnerAction::capture( + conn, + &browser_context_id, + &target_id, + requested_url, + PopupTargetNavigationKind::InitialDocument, + ) else { + rollback_incomplete_popup_target_async(conn, Some(&browser_context_id), &target_id).await; + return None; + }; + if !conn.stage_initial_popup_target_navigation_owner_action(navigation) { + rollback_incomplete_popup_target_async(conn, Some(&browser_context_id), &target_id).await; + return None; + } + let navigation = if conn.auto_attach_wait_for_debugger_on_start { + None + } else { + let navigation = conn.take_held_popup_target_navigation_owner_action_for_target(&target_id); + if navigation.is_none() { + rollback_incomplete_popup_target_async(conn, Some(&browser_context_id), &target_id) + .await; + return None; + } + navigation + }; + + let Some(target_info) = conn + .browser_context_by_id(&browser_context_id) + .and_then(|browser_context| browser_context.devtools_target_info(&target_id)) + else { + rollback_incomplete_popup_target_async(conn, Some(&browser_context_id), &target_id).await; + return None; + }; + let Some(tab_target_info) = conn.tab_target_info(&tab_target_id) else { + rollback_incomplete_popup_target_async(conn, Some(&browser_context_id), &target_id).await; + return None; + }; + if conn.has_any_target_discovery() { + push_target_created_events(conn, out, &target_id); + } else { + // Chromium's BiDi mapper keeps a target observer alive independently + // of whether any frontend subscribed to `Target.targetCreated`. + // Preserve that separation here: CDP discovery controls only the CDP + // notification, while the accepted auxiliary browsing-context action + // always publishes one typed automation lifecycle fact. This is + // especially important for popup creation that settles after the + // causing Runtime command response. + out.push(BackgroundProtocolEvent::automation_only( + events::target_created_automation_event(target_info.clone()), + )); + } + push_committed_auto_attached_session_events( + conn, + out, + &auto_attached_tab_sessions, + &tab_target_id, + tab_target_info, + ); + push_committed_auto_attached_session_events( + conn, + out, + &auto_attached_page_sessions, + &target_id, + target_info, + ); + if let Some(navigation) = navigation { + conn.publish_popup_target_navigation_owner_action(navigation); + } + Some(target_id) +} + +fn remember_resolved_popup_target( + conn: &mut CdpConnection, + browser_context_id: &str, + popup_id: Option, + target_id: &str, +) -> bool { + conn.browser_context_by_id_mut(browser_context_id) + .is_some_and(|browser_context| { + if browser_context.devtools_target_info(target_id).is_none() { + return false; + } + browser_context.remember_target_popup_id(popup_id, target_id); + true + }) +} +async fn ensure_popup_initial_document_page_async( + conn: &mut CdpConnection, + target_id: &str, +) -> bool { + let Some(route) = conn.target_session_route_for_target_id(target_id) else { + return false; + }; + { + let mut route_scope = conn.scoped_none_session_owner_route_override(route.clone()); + let pending = match route_scope + .conn_mut() + .start_initial_document_page_ensure_for_session_owner(None) + { + Ok(pending) => pending, + Err(message) => { + tracing::debug!( + target_id, + ?message, + "failed to start popup initial document page ensure" + ); + return false; + } + }; + if let Some(pending) = pending { + let completed = match pending.wait().await { + Ok(completed) => completed, + Err(failed) => { + let message = route_scope + .conn_mut() + .reset_failed_initial_document_page_build_for_owner(failed); + tracing::debug!( + target_id, + ?message, + "failed to await popup initial document page ensure" + ); + return false; + } + }; + if let Err(message) = route_scope + .conn_mut() + .complete_initial_document_page_build_for_owner(completed) + .await + { + tracing::debug!( + target_id, + ?message, + "failed to complete popup initial document page ensure" + ); + return false; + } + } + } + true +} + +fn push_committed_auto_attached_session_events( + conn: &mut CdpConnection, + out: &mut impl events::CdpTargetAutomationEventSink, + sessions: &[(Option, String)], + target_id: &str, + target_info: DevToolsTargetInfo, +) { + let sessions = sessions + .iter() + .map(|(owner_session_id, session_id)| { + conn.prepare_auto_attach_session_commit( + session_id.clone(), + owner_session_id.clone(), + conn.auto_attach_owner_waits_for_debugger_on_start(owner_session_id.as_deref()), + ) + }) + .collect::>(); + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + target_id, + target_info, + sessions, + )); + for event in event_plan { + out.push_target_background_event(event); + } +} + +pub(super) async fn rollback_incomplete_popup_target_async( + conn: &mut CdpConnection, + browser_context_id: Option<&str>, + target_id: &str, +) { + conn.rollback_incomplete_popup_target_without_event_async(browser_context_id, target_id) + .await; +} + +pub(super) async fn start_target_url_navigation_if_allowed_background_events_async( + conn: &mut CdpConnection, + out: &mut Vec, + target_id: &str, +) { + if conn.auto_attach_wait_for_debugger_on_start { + return; + } + let Some(route) = conn.target_session_route_for_target_id(target_id) else { + return; + }; + let started_target_url_navigation = { + let mut route_scope = conn.scoped_none_session_owner_route_override(route); + start_initial_document_target_url_navigation_if_needed_background_events_async( + route_scope.conn_mut(), + out, + None, + ) + .await + }; + if started_target_url_navigation + && let Some(browser_context_id) = conn + .target_session_route_for_target_id(target_id) + .and_then(|route| route.browser_context_id().map(str::to_owned)) + { + emit_target_info_changed_for_target_background_event( + conn, + out, + &browser_context_id, + target_id, + ); + } +} + +pub(crate) async fn start_initial_document_target_url_navigation_if_needed_background_events_async( + conn: &mut CdpConnection, + out: &mut Vec, + session_id: Option<&str>, +) -> bool { + if let Some(action) = + conn.take_held_popup_target_navigation_owner_action_for_session_owner(session_id) + { + // `runIfWaitingForDebugger` is itself the explicit target-owner + // admission turn. Consume the same typed action here so the resumed + // request is observable with that command, while still forbidding a + // mutable target-URL rescan. + return Box::pin( + execute_popup_target_navigation_owner_action_background_events_async( + conn, out, session_id, action, + ), + ) + .await; + } + if conn.runtime_session_owner_has_popup_target_navigation_authority(session_id) { + return false; + } + if !conn.runtime_session_owner_should_start_initial_document_navigation(session_id) { + return false; + } + let Some(target_url) = conn.runtime_session_owner_target_url(session_id) else { + return false; + }; + // Target.createTarget reaches this boundary while its completed initial + // document build and response plan are still live. Keep the full Page + // navigation state machine out of that target-command future's stack. + Box::pin( + crate::domains::page::navigate_session_owner_from_renderer_background_events_async( + conn, + out, + session_id, + &target_url, + ), + ) + .await; + true +} + +fn popup_target_has_loaded_page( + conn: &CdpConnection, + browser_context_id: &str, + target_id: &str, +) -> bool { + let Some(browser_context) = conn.browser_context_by_id(browser_context_id) else { + return false; + }; + if browser_context.is_active_target(target_id) { + return browser_context.has_loaded_page(); + } + browser_context + .background_target(target_id) + .is_some_and(|target| target.has_loaded_page()) +} + +pub(crate) async fn complete_popup_target_navigation_owner_action_async( + conn: &mut CdpConnection, + action: PopupTargetNavigationOwnerAction, +) -> crate::conn::CdpTurnOutcome { + let mut protocol_events = Vec::new(); + Box::pin( + execute_popup_target_navigation_owner_action_background_events_async( + conn, + &mut protocol_events, + None, + action, + ), + ) + .await; + crate::conn::CdpTurnOutcome::new_with_protocol_events( + protocol_events, + conn.take_scheduler_events(), + ) +} + +async fn execute_popup_target_navigation_owner_action_background_events_async( + conn: &mut CdpConnection, + protocol_events: &mut Vec, + execution_session_id: Option<&str>, + action: PopupTargetNavigationOwnerAction, +) -> bool { + let (owner_scope, claim) = action.into_parts(); + let browser_context_id = claim.browser_context_id().to_owned(); + let target_id = claim.target_id().to_owned(); + let url = claim.url().to_owned(); + let kind = claim.kind(); + let mut route_scope = owner_scope.enter(conn); + let conn = route_scope.conn_mut(); + if kind == PopupTargetNavigationKind::InitialDocument + && !conn.consume_published_popup_target_navigation_claim_for_session_owner( + execution_session_id, + &claim, + ) + { + tracing::debug!( + browser_context_id, + target_id, + url, + ?kind, + "dropping popup navigation without its exact published target authority" + ); + return false; + } + let target_is_current = conn + .target_owner_identity_for_session(execution_session_id) + .is_some_and(|(current_browser_context_id, current_target_id)| { + current_browser_context_id == browser_context_id + && current_target_id.as_deref() == Some(target_id.as_str()) + }); + let page_is_current = conn.target_page_residence_identity_is_current_for_session( + execution_session_id, + claim.page_owner(), + ); + if !target_is_current + || !page_is_current + || !popup_target_has_loaded_page(conn, &browser_context_id, &target_id) + { + tracing::debug!( + browser_context_id, + target_id, + url, + ?kind, + page_owner = ?claim.page_owner(), + "dropping popup navigation after its exact target Page residence retired" + ); + return false; + } + + match kind { + PopupTargetNavigationKind::InitialDocument => { + if !conn.runtime_session_owner_should_start_claimed_popup_initial_document_navigation( + execution_session_id, + ) { + return false; + } + } + PopupTargetNavigationKind::NamedTargetReuse => {} + } + Box::pin( + crate::domains::page::navigate_session_owner_from_renderer_background_events_async( + conn, + protocol_events, + execution_session_id, + &url, + ), + ) + .await; + if kind == PopupTargetNavigationKind::InitialDocument { + emit_target_info_changed_for_target_background_event( + conn, + protocol_events, + &browser_context_id, + &target_id, + ); + } + true +} + +pub(crate) fn emit_target_info_changed_for_session_owner_background_event( + conn: &mut CdpConnection, + out: &mut Vec, + session_id: Option<&str>, +) { + out.extend(conn.target_info_changed_event_plan_for_session_owner(session_id)); +} + +fn emit_target_info_changed_for_target_background_event( + conn: &mut CdpConnection, + out: &mut Vec, + browser_context_id: &str, + target_id: &str, +) { + out.extend( + conn.target_info_changed_event_plan_for_observable_target(browser_context_id, target_id), + ); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CloseTargetParams { + target_id: String, +} + +pub(super) fn start_close_target_command( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> TargetCommandTaskStep { + let params: CloseTargetParams = match cmd.get_params() { + Ok(Some(p)) => p, + _ => { + return super::target_command_error(-32602, "InvalidParams"); + } + }; + let command = build_cdp_close_target_command(cmd, params); + super::start_devtools_target_command( + conn, + cmd.id, + cmd.session_id, + DevToolsCommand::CloseTarget(command), + ) +} + +fn build_cdp_close_target_command( + cmd: &Cmd<'_>, + params: CloseTargetParams, +) -> DevToolsCloseTargetCommand { + DevToolsCloseTargetCommand { + context: cmd.devtools_command_context(Some(params.target_id.as_str()), None::<&str>), + target_id: DevToolsTargetId::from(params.target_id), + } +} + +pub(super) fn start_devtools_close_target_command( + command_id: Option, + command_session_id: Option<&str>, + command: DevToolsCloseTargetCommand, +) -> TargetCommandTaskStep { + pending_close_target_command(command_id, command_session_id, command) +} + +pub(super) async fn complete_close_target_command_async( + conn: &mut CdpConnection, + command: DevToolsCloseTargetCommand, + command_context: &mut crate::conn::CommandDispatchContext, +) -> CommandOutputPlan { + let mut side_effects = events::TargetProtocolSideEffects::default(); + match execute_devtools_close_target_command_async( + conn, + command, + &mut side_effects, + command_context, + ) + .await + { + Ok(result) => { + let mut plan = + CommandOutputPlan::from_devtools_result(DevToolsCommandResult::CloseTarget(result)); + plan.extend(side_effects.into_plan()); + plan + } + Err(error) => CommandOutputPlan::from_devtools_error(error), + } +} + +pub(super) async fn execute_devtools_close_target_command_async( + conn: &mut CdpConnection, + command: DevToolsCloseTargetCommand, + out: &mut events::TargetProtocolSideEffects, + command_context: &mut crate::conn::CommandDispatchContext, +) -> Result { + let target_id = command.target_id.into_string(); + let restore_browser_context_id = previously_active_browser_context_id(conn); + let result = close_target_inner_async(conn, out, command_context, target_id).await; + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + result +} + +async fn close_target_inner_async( + conn: &mut CdpConnection, + out: &mut events::TargetProtocolSideEffects, + command_context: &mut crate::conn::CommandDispatchContext, + target_id: String, +) -> Result { + let target_id = conn + .page_target_id_for_tab_target_id(&target_id) + .map(str::to_owned) + .unwrap_or(target_id); + if let Err(message) = select_browser_context_for_target(conn, &target_id) { + return Err(DevToolsError::new(DevToolsErrorKind::NoSuchTarget, message)); + } + if conn + .browser_context + .as_ref() + .is_some_and(|bc| bc.has_shared_worker_target(&target_id)) + { + if !worker_target::close_shared_worker_target_for_target_close_async( + conn, + &target_id, + command_context, + ) + .await + { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "UnknownTargetId", + )); + } + return Ok(DevToolsCloseTargetResult { success: true }); + } + if conn + .browser_context + .as_ref() + .is_some_and(|bc| bc.has_dedicated_worker_target(&target_id)) + { + if !worker_target::close_dedicated_worker_target_for_target_close_async( + conn, + &target_id, + command_context, + ) + .await + { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "UnknownTargetId", + )); + } + return Ok(DevToolsCloseTargetResult { success: true }); + } + if conn + .browser_context + .as_ref() + .is_some_and(|bc| bc.has_service_worker_target(&target_id)) + { + return Ok(DevToolsCloseTargetResult { success: true }); + } + if conn.browser_context.as_ref().is_some_and(|bc| { + !matches!( + bc.active_target_identity(), + Some((ref active_target_id, _)) if active_target_id == &target_id + ) + }) { + let target_route = conn + .target_session_route_for_target_id(&target_id) + .ok_or_else(|| { + DevToolsError::new(DevToolsErrorKind::NoSuchTarget, "UnknownTargetId") + })?; + let session_id = conn + .browser_context + .as_ref() + .and_then(|browser_context| browser_context.background_target(&target_id)) + .and_then(|target| target.session_id().map(str::to_owned)); + let owner_scope = crate::conn::CommandOwnerScope::from_session_and_owner_route( + session_id.as_deref(), + session_id.is_none().then_some(target_route), + ); + let renderer_output_predecessor = + events::fail_pending_fetch_state_for_target_background_events_async( + conn, + out.background_events_mut(), + session_id.as_deref(), + "Target closed", + ) + .await; + settle_target_close_after_pending_fetches_async( + conn, + out, + command_context, + renderer_output_predecessor, + owner_scope, + target_id, + ) + .await; + return Ok(DevToolsCloseTargetResult { success: true }); + } + + let session_id = match conn.browser_context.as_ref() { + Some(bc) => { + if !bc.has_active_target() && bc.background_targets.is_empty() { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "TargetNotLoaded", + )); + } + match bc.active_target_identity() { + Some((_active_target_id, session_id)) => session_id, + None => { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "TargetNotLoaded", + )); + } + } + } + None => { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "BrowserContextNotLoaded", + )); + } + }; + + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + session_id.as_deref(), + "Target closed", + ); + let renderer_output_predecessor = + events::fail_pending_fetch_state_for_target_background_events_async( + conn, + out.background_events_mut(), + session_id.as_deref(), + "Target closed", + ) + .await; + let owner_scope = crate::conn::CommandOwnerScope::capture(conn, session_id.as_deref()); + settle_target_close_after_pending_fetches_async( + conn, + out, + command_context, + renderer_output_predecessor, + owner_scope, + target_id, + ) + .await; + Ok(DevToolsCloseTargetResult { success: true }) +} + +/// Preserves the final renderer publication without changing the ordinary +/// `Target.closeTarget` transaction boundary. +/// +/// Most target closes produce no renderer output. Those closes still complete +/// synchronously and return their detach/destroy side effects with the command, +/// matching Chromium's target-domain behavior. A paused request can first +/// produce a terminal renderer record, however. Only that case must defer +/// target retirement until the command's exact cursor has crossed ordered +/// ingress; otherwise retiring the route would discard that final record. +async fn settle_target_close_after_pending_fetches_async( + conn: &mut CdpConnection, + out: &mut events::TargetProtocolSideEffects, + command_context: &mut crate::conn::CommandDispatchContext, + renderer_output_predecessor: Option, + owner_scope: crate::conn::CommandOwnerScope, + target_id: String, +) { + let action = crate::domains::page::PageTargetTerminationOwnerAction::new( + owner_scope, + target_id, + crate::domains::page::PageTargetTerminationKind::TargetClose, + ); + if let Some(predecessor) = renderer_output_predecessor { + command_context.set_renderer_output_predecessor(predecessor); + conn.publish_page_target_termination_owner_action(action); + return; + } + + let outcome = + crate::domains::page::complete_page_target_termination_owner_action_async(conn, action) + .await; + let (events, scheduler_events) = outcome.into_protocol_event_parts(); + out.extend_background_events(events); + conn.extend_scheduler_events(scheduler_events); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetTargetInfoParams { + target_id: Option, +} + +pub(super) fn get_target_info(conn: &mut CdpConnection, cmd: &Cmd<'_>) -> CommandOutputPlan { + let params: GetTargetInfoParams = match cmd.get_params() { + Ok(Some(p)) => p, + Ok(None) => GetTargetInfoParams { target_id: None }, + Err(e) => { + return CommandOutputPlan::error_without_session(-32602, e); + } + }; + let command = build_cdp_get_target_info_command(cmd, params); + match super::start_devtools_target_command( + conn, + cmd.id, + cmd.session_id, + DevToolsCommand::GetTargetInfo(command), + ) { + TargetCommandTaskStep::Complete(plan) => plan, + TargetCommandTaskStep::Pending(_) => { + CommandOutputPlan::error_without_session(-32000, "UnexpectedPending") + } + } +} + +fn build_cdp_get_target_info_command( + cmd: &Cmd<'_>, + params: GetTargetInfoParams, +) -> DevToolsGetTargetInfoCommand { + DevToolsGetTargetInfoCommand { + context: cmd.devtools_command_context(params.target_id.as_deref(), None::<&str>), + target_id: params.target_id.map(DevToolsTargetId::from), + } +} + +pub(super) fn start_devtools_get_target_info_command( + conn: &CdpConnection, + command: DevToolsGetTargetInfoCommand, +) -> CommandOutputPlan { + match execute_devtools_get_target_info_command(conn, command) { + Ok(result) => { + CommandOutputPlan::from_devtools_result(DevToolsCommandResult::GetTargetInfo(result)) + } + Err(error) => CommandOutputPlan::from_devtools_error(error), + } +} + +pub(super) fn execute_devtools_get_target_info_command( + conn: &CdpConnection, + command: DevToolsGetTargetInfoCommand, +) -> Result { + let Some(target_id) = command.target_id.as_ref() else { + return Ok(DevToolsGetTargetInfoResult { + target_info: super::browser_context::devtools_browser_target_info(), + }); + }; + let wanted = target_id.as_str(); + if wanted == super::browser_context::DEVTOOLS_BROWSER_TARGET_ID { + return Ok(DevToolsGetTargetInfoResult { + target_info: super::browser_context::devtools_browser_target_info(), + }); + } + if conn.browser_context.is_none() && conn.inactive_browser_contexts.is_empty() { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "BrowserContextNotLoaded", + )); + } + let target_exists = conn.browser_contexts().any(|bc| { + bc.has_active_target() + || !bc.background_targets.is_empty() + || bc.has_any_shared_worker_targets() + || bc.has_any_dedicated_worker_targets() + || bc.has_any_service_worker_targets() + }); + if !target_exists { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "TargetNotLoaded", + )); + } + let target_info = conn.tab_target_info(wanted); + if let Some(target_info) = target_info { + return Ok(DevToolsGetTargetInfoResult { target_info }); + } + let target_info = conn + .browser_contexts() + .find_map(|browser_context| browser_context.devtools_target_info(wanted)) + .ok_or_else(|| DevToolsError::new(DevToolsErrorKind::NoSuchTarget, "UnknownTargetId"))?; + if let Some(message) = super::transient_no_page_devtools_target_info_error(conn, &target_info) { + return Err(DevToolsError::new(DevToolsErrorKind::Internal, message)); + } + Ok(DevToolsGetTargetInfoResult { target_info }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AutoAttachParams { + auto_attach: bool, + #[serde(rename = "waitForDebuggerOnStart")] + wait_for_debugger_on_start: bool, + filter: Option>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AutoAttachRelatedParams { + target_id: String, + #[serde(rename = "waitForDebuggerOnStart")] + wait_for_debugger_on_start: bool, + filter: Option>, +} + +#[derive(Deserialize)] +struct TargetFilterEntry { + #[serde(default)] + exclude: bool, + #[serde(rename = "type")] + target_type: Option, +} + +#[derive(Debug)] +struct ServiceWorkerAutoAttachRelatedTarget { + target_id: String, + browser_context_id: String, + registration_id: u64, + version_id: u64, + script_url: String, + scope_url: String, +} + +pub(super) fn auto_attach_related(conn: &mut CdpConnection, cmd: &Cmd<'_>) -> CommandOutputPlan { + let params: AutoAttachRelatedParams = match cmd.get_params() { + Ok(Some(p)) => p, + _ => { + return CommandOutputPlan::error_without_session(-32602, "InvalidParams"); + } + }; + if cmd.session_id.is_some() && !conn.is_browser_session_id(cmd.session_id) { + return CommandOutputPlan::error_without_session( + -32000, + "Target.autoAttachRelated is only supported on the Browser target", + ); + } + + let restore_browser_context_id = previously_active_browser_context_id(conn); + let target = match service_worker_auto_attach_related_target(conn, params.target_id.as_str()) { + Ok(target) => target, + Err(plan) => { + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + return plan; + } + }; + let allow_service_worker_targets = + cdp_target_filter_from_entries(params.filter).matches("service_worker"); + conn.replace_service_worker_auto_attach_related_owner( + cmd.session_id, + &target.browser_context_id, + target.registration_id, + target.version_id, + target.script_url, + target.scope_url, + allow_service_worker_targets, + params.wait_for_debugger_on_start, + ); + let mut plan = CommandOutputPlan::default(); + if allow_service_worker_targets + && !owner_already_auto_attached_to_target(conn, cmd.session_id, &target.target_id) + && let Some((session_id, target_info)) = + attach_service_worker_target_for_auto_attach_related(conn, &target.target_id) + { + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + &target.target_id, + target_info, + [conn.prepare_auto_attach_session_commit( + session_id, + cmd.session_id.map(str::to_owned), + false, + )], + )); + for event in event_plan { + plan.push_background_event(event); + } + } + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + // Chromium completes autoAttachRelated only after its existing-target + // attach pass. Existing targets are already running, so they cannot be + // paused on start even when the policy requests it. + plan.push_success(); + plan +} + +fn service_worker_auto_attach_related_target( + conn: &mut CdpConnection, + target_id: &str, +) -> Result { + if let Err(message) = select_browser_context_for_target(conn, target_id) { + return Err(CommandOutputPlan::error_without_session(-31998, message)); + } + let Some(browser_context) = conn.browser_context.as_ref() else { + return Err(CommandOutputPlan::error_without_session( + -31998, + "BrowserContextNotLoaded", + )); + }; + let Some(target) = browser_context.service_worker_target(target_id) else { + return Err(CommandOutputPlan::error_without_session( + -32000, + "Target does not support auto-attaching", + )); + }; + Ok(ServiceWorkerAutoAttachRelatedTarget { + target_id: target.target_id.clone(), + browser_context_id: browser_context.id.clone(), + registration_id: target.renderer_registration_id, + version_id: target.renderer_version_id, + script_url: target.script_url.clone(), + scope_url: target.scope_url.clone(), + }) +} + +fn attach_service_worker_target_for_auto_attach_related( + conn: &mut CdpConnection, + target_id: &str, +) -> Option<(String, DevToolsTargetInfo)> { + let session_id = conn.gen_session_id(); + let target_info = conn + .prepare_auto_attached_service_worker_session_binding_info(target_id, session_id.clone())?; + Some((session_id, target_info)) +} + +fn owner_already_auto_attached_to_target( + conn: &CdpConnection, + owner_session_id: Option<&str>, + target_id: &str, +) -> bool { + conn.auto_attached_sessions_for_owner(owner_session_id) + .into_iter() + .any(|session_id| { + matches!( + conn.session_route(Some(&session_id)), + Some(crate::conn::CdpSessionRoute::ServiceWorkerTarget { + target_id: attached_target_id, + .. + }) if attached_target_id == target_id + ) + }) +} + +fn owner_already_auto_attached_to_page_target( + conn: &CdpConnection, + owner_session_id: Option<&str>, + target_id: &str, +) -> bool { + conn.auto_attached_sessions_for_owner(owner_session_id) + .into_iter() + .any(|session_id| match conn.session_route(Some(&session_id)) { + Some(crate::conn::CdpSessionRoute::ActiveTarget { + browser_context_id, + target_id: route_target_id, + }) => { + route_target_id.as_deref() == Some(target_id) + || conn + .browser_context_by_id(&browser_context_id) + .and_then(BrowserContext::active_target_id) + == Some(target_id) + } + Some(crate::conn::CdpSessionRoute::BackgroundTarget { + target_id: route_target_id, + .. + }) + | Some(crate::conn::CdpSessionRoute::AuxiliaryTarget { + target_id: route_target_id, + .. + }) => route_target_id == target_id, + _ => false, + }) +} + +fn owner_already_auto_attached_to_tab_target( + conn: &CdpConnection, + owner_session_id: Option<&str>, + tab_target_id: &str, +) -> bool { + conn.auto_attached_sessions_for_owner(owner_session_id) + .into_iter() + .any(|session_id| { + matches!( + conn.session_route(Some(&session_id)), + Some(crate::conn::CdpSessionRoute::TabTarget { + tab_target_id: attached_target_id, + .. + }) if attached_target_id == tab_target_id + ) + }) +} + +fn should_auto_attach_page_target_for_owner( + conn: &CdpConnection, + owner_session_id: Option<&str>, + target_id: &str, + target_has_primary_session: bool, +) -> bool { + if owner_session_id.is_none() && target_has_primary_session { + return false; + } + if owner_session_is_attached_to_page_target(conn, owner_session_id, target_id) { + return false; + } + !owner_already_auto_attached_to_page_target(conn, owner_session_id, target_id) +} + +fn should_auto_attach_tab_target_for_owner( + conn: &CdpConnection, + owner_session_id: Option<&str>, + tab_target_id: &str, +) -> bool { + if owner_session_id.is_none() + && conn + .primary_session_id_for_tab_target_id(tab_target_id) + .is_some() + { + return false; + } + !owner_already_auto_attached_to_tab_target(conn, owner_session_id, tab_target_id) +} + +fn owner_session_is_attached_to_page_target( + conn: &CdpConnection, + owner_session_id: Option<&str>, + target_id: &str, +) -> bool { + let Some(owner_session_id) = owner_session_id else { + return false; + }; + page_session_route_matches_target(conn, owner_session_id, target_id) +} + +fn page_session_route_matches_target( + conn: &CdpConnection, + session_id: &str, + target_id: &str, +) -> bool { + match conn.session_route(Some(session_id)) { + Some(crate::conn::CdpSessionRoute::ActiveTarget { + browser_context_id, + target_id: route_target_id, + }) => { + route_target_id.as_deref() == Some(target_id) + || conn + .browser_context_by_id(&browser_context_id) + .and_then(BrowserContext::active_target_id) + == Some(target_id) + } + Some(crate::conn::CdpSessionRoute::BackgroundTarget { + target_id: route_target_id, + .. + }) + | Some(crate::conn::CdpSessionRoute::AuxiliaryTarget { + target_id: route_target_id, + .. + }) => route_target_id == target_id, + _ => false, + } +} + +fn cdp_target_filter_from_entries(filter: Option>) -> CdpTargetFilter { + match filter { + Some(entries) => CdpTargetFilter::from_entries( + entries + .into_iter() + .map(|entry| CdpTargetFilterEntry { + exclude: entry.exclude, + target_type: entry.target_type, + }) + .collect(), + ), + None => CdpTargetFilter::default_auto_attach(), + } +} + +pub(super) fn start_set_auto_attach_command( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> TargetCommandTaskStep { + let params: AutoAttachParams = match cmd.get_params() { + Ok(Some(p)) => p, + _ => { + return super::target_command_error(-32602, "InvalidParams"); + } + }; + if !params.auto_attach + && params + .filter + .as_ref() + .is_some_and(|filter| !filter.is_empty()) + { + return super::target_command_error( + -32602, + "Target filter should be empty when disabling auto-attach", + ); + } + let target_filter = cdp_target_filter_from_entries(params.filter); + let owner_is_browser_or_root = + cmd.session_id.is_none() || conn.is_browser_session_id(cmd.session_id); + if params.auto_attach + && owner_is_browser_or_root + && target_filter.matches("tab") + && target_filter.matches("page") + { + return super::target_command_error( + -32602, + "Filter should not simultaneously allow \"tab\" and \"page\", page targets are attached via tab targets", + ); + } + let owner_was_enabled = conn.has_auto_attach_owner(cmd.session_id); + let legacy_disable_all = !params.auto_attach + && !owner_was_enabled + && conn.auto_attach_owner_count() == 0 + && conn.auto_attach; + if params.auto_attach { + conn.install_default_browser_target_for_auto_attach_if_enabled(); + } + let pause_service_workers_on_start = params.auto_attach + && params.wait_for_debugger_on_start + && target_filter.matches("service_worker"); + let pause_dedicated_workers_on_start = + params.auto_attach && params.wait_for_debugger_on_start && target_filter.matches("worker"); + conn.set_auto_attach_owner( + cmd.session_id, + params.auto_attach, + params.wait_for_debugger_on_start, + target_filter, + ); + super::set_service_worker_pause_on_start_owner( + conn, + cmd.session_id, + pause_service_workers_on_start, + ); + super::set_dedicated_worker_pause_on_start_owner( + conn, + cmd.session_id, + pause_dedicated_workers_on_start, + ); + pending_set_auto_attach_command( + cmd.id, + cmd.session_id, + params.auto_attach, + cmd.session_id, + owner_was_enabled, + legacy_disable_all, + ) +} + +pub(super) async fn complete_set_auto_attach_command_async( + conn: &mut CdpConnection, + auto_attach: bool, + owner_session_id: Option<&str>, + owner_was_enabled: bool, + legacy_disable_all: bool, + command_context: &mut crate::conn::CommandDispatchContext, +) -> CommandOutputPlan { + let mut side_effects = events::TargetProtocolSideEffects::default(); + let restore_browser_context_id = previously_active_browser_context_id(conn); + set_auto_attach_inner_async( + conn, + &mut side_effects, + auto_attach, + owner_session_id, + owner_was_enabled, + legacy_disable_all, + command_context, + ) + .await; + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + // Chromium runs the AddClient existing-target sweep before invoking the + // SetAutoAttach completion callback. Puppeteer consumes attachedToTarget + // during that sweep and assumes every pre-existing session is registered + // by the time the command response resolves. + let mut plan = side_effects.into_plan(); + plan.push_success(); + plan +} + +async fn set_auto_attach_inner_async( + conn: &mut CdpConnection, + out: &mut events::TargetProtocolSideEffects, + auto_attach: bool, + owner_session_id: Option<&str>, + owner_was_enabled: bool, + legacy_disable_all: bool, + command_context: &mut crate::conn::CommandDispatchContext, +) { + if !auto_attach && !legacy_disable_all { + detach_auto_attached_sessions_for_owner_async(conn, out, owner_session_id, command_context) + .await; + return; + } + if auto_attach + && !owner_was_enabled + && let Some(owner_session_id) = owner_session_id + && let Some(tab_target_id) = conn + .tab_target_id_for_session_id(owner_session_id) + .map(str::to_owned) + { + auto_attach_child_page_for_tab_session_async(conn, out, owner_session_id, &tab_target_id) + .await; + return; + } + + let context_ids: Vec = conn.browser_contexts().map(|bc| bc.id.clone()).collect(); + for context_id in context_ids { + if !conn.activate_browser_context_by_id_async(&context_id).await { + continue; + } + if auto_attach { + if owner_was_enabled { + continue; + } + let attach_page_targets = conn + .auto_attach_owner_allows_target_type(owner_session_id, "page") + && super::browser_level_auto_attach_owner_session_allowed(conn, owner_session_id); + let attach_tab_targets = conn + .auto_attach_owner_allows_target_type(owner_session_id, "tab") + && super::browser_level_auto_attach_owner_session_allowed(conn, owner_session_id); + let attach_shared_worker_targets = conn + .auto_attach_owner_allows_target_type(owner_session_id, "shared_worker") + && super::browser_level_auto_attach_owner_session_allowed(conn, owner_session_id); + let attach_dedicated_worker_targets = + conn.auto_attach_owner_allows_target_type(owner_session_id, "worker"); + let attach_service_worker_targets = + conn.auto_attach_owner_allows_target_type(owner_session_id, "service_worker"); + // waitForDebuggerOnStart applies to targets created after the + // policy is installed. Chromium reports every target found by the + // initial AddClient sweep as already running. + let waiting_for_debugger = false; + let pending_attach_target_ids = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when attaching existing targets"); + if !attach_page_targets { + Vec::new() + } else { + let mut target_ids = Vec::new(); + if let Some(target_id) = bc.active_target_id_owned() + && should_auto_attach_page_target_for_owner( + conn, + owner_session_id, + &target_id, + bc.has_active_session(), + ) + { + target_ids.push(target_id); + } + target_ids.extend( + bc.background_targets + .iter() + .filter(|target| { + should_auto_attach_page_target_for_owner( + conn, + owner_session_id, + target.target_id(), + target.has_session(), + ) + }) + .map(|target| target.target_id().to_owned()), + ); + target_ids + } + }; + let pending_attach_tab_target_ids = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when attaching existing tab targets"); + if !attach_tab_targets { + Vec::new() + } else { + let mut tab_target_ids = Vec::new(); + if let Some(page_target_id) = bc.active_target_id() + && let Some(tab_target_id) = + conn.tab_target_id_for_page_target_id(page_target_id) + && should_auto_attach_tab_target_for_owner( + conn, + owner_session_id, + tab_target_id, + ) + { + tab_target_ids.push(tab_target_id.to_owned()); + } + tab_target_ids.extend(bc.background_targets.iter().filter_map(|target| { + let tab_target_id = + conn.tab_target_id_for_page_target_id(target.target_id())?; + should_auto_attach_tab_target_for_owner( + conn, + owner_session_id, + tab_target_id, + ) + .then(|| tab_target_id.to_owned()) + })); + tab_target_ids + } + }; + let pending_attach_shared_worker_target_ids = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when attaching existing targets"); + if attach_shared_worker_targets { + bc.shared_worker_targets + .values() + .map(|target| target.target_id.clone()) + .collect::>() + } else { + Vec::new() + } + }; + let pending_attach_service_worker_target_ids = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when attaching existing targets"); + if attach_service_worker_targets { + bc.service_worker_targets + .values() + .map(|target| target.target_id.clone()) + .collect::>() + } else { + Vec::new() + } + }; + let pending_attach_dedicated_worker_target_ids = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when attaching existing targets"); + if attach_dedicated_worker_targets { + bc.dedicated_worker_targets + .values() + .filter(|target| { + super::worker_target::dedicated_worker_auto_attach_owner_session_allowed( + conn, + owner_session_id, + &target.owner_page, + ) + }) + .map(|target| target.target_id.clone()) + .collect::>() + } else { + Vec::new() + } + }; + let attached_targets = pending_attach_target_ids + .into_iter() + .map(|target_id| (target_id, conn.gen_session_id())) + .collect::>(); + let attached_tab_targets = pending_attach_tab_target_ids + .into_iter() + .map(|target_id| (target_id, conn.gen_session_id())) + .collect::>(); + let attached_shared_worker_targets = pending_attach_shared_worker_target_ids + .into_iter() + .map(|target_id| (target_id, conn.gen_session_id())) + .collect::>(); + let attached_service_worker_targets = pending_attach_service_worker_target_ids + .into_iter() + .map(|target_id| (target_id, conn.gen_session_id())) + .collect::>(); + let attached_dedicated_worker_targets = pending_attach_dedicated_worker_target_ids + .into_iter() + .map(|target_id| (target_id, conn.gen_session_id())) + .collect::>(); + let promote_target_id = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when considering auto-attach promotion"); + if !attach_page_targets || bc.has_loaded_page() { + None + } else { + bc.background_targets + .iter() + .rposition(|target| !target.has_session() && target.has_loaded_page()) + .map(|index| bc.background_targets[index].target_id().to_owned()) + .or_else(|| { + bc.background_targets + .iter() + .find(|target| !target.has_session()) + .map(|target| target.target_id().to_owned()) + }) + } + }; + { + for (target_id, session_id) in &attached_targets { + let assigned = conn + .prepare_auto_attached_page_session_binding(target_id, session_id.clone()); + debug_assert!(assigned, "attached target must remain addressable"); + } + for (target_id, session_id) in &attached_shared_worker_targets { + let assigned = conn.prepare_auto_attached_shared_worker_session_binding( + target_id, + session_id.clone(), + ); + debug_assert!( + assigned, + "attached shared worker target must remain addressable" + ); + } + for (target_id, session_id) in &attached_dedicated_worker_targets { + let assigned = conn.prepare_auto_attached_dedicated_worker_session_binding( + target_id, + session_id.clone(), + ); + debug_assert!( + assigned, + "attached dedicated worker target must remain addressable" + ); + } + for (target_id, session_id) in &attached_service_worker_targets { + let assigned = conn.prepare_auto_attached_service_worker_session_binding( + target_id, + session_id.clone(), + ); + debug_assert!( + assigned, + "attached service worker target must remain addressable" + ); + } + } + for (target_id, session_id) in &attached_tab_targets { + let assigned = conn.prepare_auto_attached_tab_session_binding( + target_id, + session_id.clone(), + owner_session_id, + ); + debug_assert!(assigned, "attached tab target must remain addressable"); + } + if let Some(promote_target_id) = promote_target_id { + match conn + .promote_background_target_to_active_for_connection_async(&promote_target_id) + .await + { + Ok(true) => {} + Ok(false) => {} + Err(message) => { + panic!( + "same-context target should remain promotable during auto-attach: {message}" + ); + } + } + } + ensure_initial_document_for_attached_page_targets_async(conn, &attached_targets).await; + for (target_id, session_id) in &attached_targets { + if let Err(message) = conn + .apply_runtime_binding_state_for_session_owner_async(Some(session_id)) + .await + && message != "NoDocumentLoaded" + { + tracing::warn!( + %message, + target_id = target_id.as_str(), + session_id = session_id.as_str(), + "failed to apply renderer binding state during target auto-attach" + ); + } + } + for (target_id, session_id) in attached_tab_targets { + let ti = conn + .tab_target_info(&target_id) + .expect("attached tab target must remain addressable"); + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + &target_id, + ti, + [conn.prepare_auto_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + waiting_for_debugger, + )], + )); + out.extend_background_events(event_plan); + } + for (target_id, session_id) in attached_targets { + let ti = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when emitting attach events"); + bc.devtools_target_info(&target_id) + .expect("attached target must remain addressable") + }; + if let Some(message) = + super::transient_no_page_devtools_target_info_error(conn, &ti) + { + warn_target_protocol_side_effect_failure( + &target_id, + "emit_attached_to_target", + &message, + ); + } + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + &target_id, + ti, + [conn.prepare_auto_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + waiting_for_debugger, + )], + )); + out.extend_background_events(event_plan); + } + for (target_id, session_id) in attached_shared_worker_targets { + let ti = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when emitting attach events"); + bc.devtools_target_info(&target_id) + .expect("attached shared worker target must remain addressable") + }; + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + &target_id, + ti, + [conn.prepare_auto_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + waiting_for_debugger, + )], + )); + out.extend_background_events(event_plan); + } + for (target_id, session_id) in attached_dedicated_worker_targets { + let ti = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when emitting attach events"); + bc.devtools_target_info(&target_id) + .expect("attached dedicated worker target must remain addressable") + }; + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + &target_id, + ti, + [conn.prepare_auto_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + waiting_for_debugger, + )], + )); + out.extend_background_events(event_plan); + } + for (target_id, session_id) in attached_service_worker_targets { + let ti = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when emitting attach events"); + bc.devtools_target_info(&target_id) + .expect("attached service worker target must remain addressable") + }; + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + &target_id, + ti, + [conn.prepare_auto_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + waiting_for_debugger, + )], + )); + out.extend_background_events(event_plan); + } + continue; + } + + if let Some((target_id, Some(session_id))) = conn + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_identity) + { + events::fail_pending_fetch_state_for_target_background_events_async( + conn, + out.background_events_mut(), + Some(&session_id), + "Target detached", + ) + .await; + + let event_plan = conn + .detach_active_target_session_binding_event_plan_async( + crate::conn::TargetSessionDetachCleanupPlan::new( + target_id, session_id, None, None, + ), + ) + .await + .expect("clearing session-scoped state during auto-attach reset should succeed"); + out.extend_background_events(event_plan); + } + + let detached_background_targets = + conn.background_target_session_detach_cleanup_plans(None, None); + for cleanup_plan in detached_background_targets { + if let Some(event_plan) = conn + .detach_background_target_session_binding_event_plan_async(cleanup_plan) + .await + .expect("clearing background target session-scoped state during auto-attach reset should succeed") + { + out.extend_background_events(event_plan); + } + } + + let shared_worker_sessions_to_release = conn + .browser_context + .as_ref() + .map(|bc| { + bc.shared_worker_targets + .values() + .flat_map(|target| target.session_ids()) + .collect::>() + }) + .unwrap_or_default(); + for session_id in &shared_worker_sessions_to_release { + conn.release_shared_worker_runtime_remote_objects_for_session_best_effort_async( + session_id, + ) + .await; + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(session_id), + "Target detached", + ); + } + + let shared_worker_event_plan = conn + .detach_all_shared_worker_target_sessions_event_plan_async(None, None) + .await; + out.extend_background_events(shared_worker_event_plan); + + let dedicated_worker_sessions_to_release = conn + .browser_context + .as_ref() + .map(|bc| { + bc.dedicated_worker_targets + .values() + .flat_map(|target| target.session_ids()) + .collect::>() + }) + .unwrap_or_default(); + for session_id in &dedicated_worker_sessions_to_release { + conn.release_shared_worker_runtime_remote_objects_for_session_best_effort_async( + session_id, + ) + .await; + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(session_id), + "Target detached", + ); + } + let dedicated_worker_event_plan = conn + .detach_all_dedicated_worker_target_sessions_event_plan_async(None, None) + .await; + out.extend_background_events(dedicated_worker_event_plan); + + let service_worker_sessions_to_detach = conn + .browser_context + .as_ref() + .map(|bc| { + bc.service_worker_targets + .values() + .flat_map(|target| target.session_ids()) + .collect::>() + }) + .unwrap_or_default(); + let service_worker_event_plan = conn + .detach_all_service_worker_target_sessions_event_plan_async(None, None) + .await; + for session_id in service_worker_sessions_to_detach { + super::set_service_worker_pause_on_start_owner(conn, Some(&session_id), false); + } + out.extend_background_events(service_worker_event_plan); + } +} + +async fn auto_attach_child_page_for_tab_session_async( + conn: &mut CdpConnection, + out: &mut events::TargetProtocolSideEffects, + tab_session_id: &str, + tab_target_id: &str, +) { + if !conn.auto_attach_owner_allows_target_type(Some(tab_session_id), "page") { + return; + } + let Some(page_target_id) = conn + .page_target_id_for_tab_target_id(tab_target_id) + .map(str::to_owned) + else { + return; + }; + let Some(browser_context_id) = conn.browser_context_id_for_tab_target_id(tab_target_id) else { + return; + }; + if !conn + .activate_browser_context_by_id_async(&browser_context_id) + .await + { + return; + } + let target_has_primary_session = { + let Some(bc) = conn.browser_context.as_ref() else { + return; + }; + if bc.active_target_id() == Some(page_target_id.as_str()) { + bc.has_active_session() + } else { + bc.background_target(&page_target_id) + .is_some_and(|target| target.has_session()) + } + }; + if !should_auto_attach_page_target_for_owner( + conn, + Some(tab_session_id), + &page_target_id, + target_has_primary_session, + ) { + return; + } + let session_id = conn.gen_session_id(); + let assigned = + conn.prepare_auto_attached_page_session_binding(&page_target_id, session_id.clone()); + if !assigned { + return; + } + ensure_initial_document_for_attached_page_targets_async( + conn, + &[(page_target_id.clone(), session_id.clone())], + ) + .await; + if let Err(message) = conn + .apply_runtime_binding_state_for_session_owner_async(Some(&session_id)) + .await + && message != "NoDocumentLoaded" + { + tracing::warn!( + %message, + target_id = page_target_id.as_str(), + session_id = session_id.as_str(), + "failed to apply renderer binding state during tab child page auto-attach" + ); + } + let prepared_session = conn.prepare_auto_attach_session_commit( + session_id.clone(), + Some(tab_session_id.to_owned()), + false, + ); + let Some(target_info) = conn + .browser_context + .as_ref() + .and_then(|bc| bc.devtools_target_info(&page_target_id)) + else { + conn.rollback_prepared_attach_session_without_event_async(&prepared_session) + .await; + return; + }; + if let Some(message) = super::transient_no_page_devtools_target_info_error(conn, &target_info) { + warn_target_protocol_side_effect_failure( + &page_target_id, + "emit_tab_child_attached_to_target", + &message, + ); + conn.rollback_prepared_attach_session_without_event_async(&prepared_session) + .await; + return; + } + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + &page_target_id, + target_info, + [prepared_session], + )); + out.extend_background_events(event_plan); +} + +pub(super) async fn detach_auto_attached_sessions_for_owner_async( + conn: &mut CdpConnection, + out: &mut events::TargetProtocolSideEffects, + owner_session_id: Option<&str>, + command_context: &mut crate::conn::CommandDispatchContext, +) { + let session_ids = conn.auto_attached_session_cascade_for_owner(owner_session_id); + for session_id in session_ids { + detach_attached_session_for_owner_async(conn, out, &session_id, command_context).await; + } +} + +pub(super) async fn detach_attached_sessions_for_owner_async( + conn: &mut CdpConnection, + out: &mut events::TargetProtocolSideEffects, + owner_session_id: Option<&str>, + command_context: &mut crate::conn::CommandDispatchContext, +) { + let session_ids = conn.attached_session_cascade_for_owner(owner_session_id); + for session_id in session_ids { + detach_attached_session_for_owner_async(conn, out, &session_id, command_context).await; + } +} + +pub(super) async fn release_attached_sessions_for_root_frontend_async( + conn: &mut CdpConnection, + out: &mut events::TargetProtocolSideEffects, + command_context: &mut crate::conn::CommandDispatchContext, +) { + // Root-owned browser sessions include the scheduler's private page-control + // session and must outlive a browser frontend disconnect. A target session + // owned directly by that frontend still belongs to the release cascade. + let session_ids = conn.attached_session_cascade_for_root_frontend(); + for session_id in session_ids { + let detach_plan = conn.auto_attached_session_detach_plan(&session_id); + let preserves_other_frontends = matches!( + detach_plan.cleanup_plan().map(|plan| plan.action()), + Some( + crate::conn::TargetBindingCleanupAction::ActiveTargetPrimaryAutoAttached + | crate::conn::TargetBindingCleanupAction::BackgroundTargetPrimaryAutoAttached { + .. + } + ) + ); + if !preserves_other_frontends { + detach_attached_session_for_owner_async(conn, out, &session_id, command_context).await; + continue; + } + + let Some(browser_context_id) = detach_plan.browser_context_id().map(str::to_owned) else { + conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan); + continue; + }; + if !conn + .activate_browser_context_by_id_async(&browser_context_id) + .await + { + conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan); + continue; + } + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(&session_id), + "Target detached", + ); + super::clear_detached_target_fetch_state_background_events_async( + conn, + out.background_events_mut(), + &session_id, + ) + .await; + let _ = conn + .detach_runtime_inspector_session_for_session_owner_async(Some(&session_id)) + .await; + clear_emulated_media_for_detached_session_best_effort(conn, &session_id).await; + if !conn.release_primary_target_session_binding_without_event(&session_id) { + conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan); + } + } +} + +async fn detach_attached_session_for_owner_async( + conn: &mut CdpConnection, + out: &mut events::TargetProtocolSideEffects, + session_id: &str, + command_context: &mut crate::conn::CommandDispatchContext, +) { + if conn.is_browser_session_id(Some(session_id)) { + conn.cancel_tracing_for_session_owner_async(Some(session_id)) + .await; + let detached = conn.detach_browser_session_owner_without_event(session_id); + debug_assert!(detached.is_some()); + return; + } + let detach_plan = conn.auto_attached_session_detach_plan(session_id); + let Some(browser_context_id) = detach_plan.browser_context_id().map(str::to_owned) else { + conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan); + return; + }; + if !conn + .activate_browser_context_by_id_async(&browser_context_id) + .await + { + conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan); + return; + } + + let Some(cleanup_plan) = detach_plan.cleanup_plan() else { + conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan); + return; + }; + match cleanup_plan.action().clone() { + crate::conn::TargetBindingCleanupAction::ActiveTargetPrimaryAutoAttached => { + let target_id = conn + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_identity) + .and_then(|(target_id, current_session_id)| { + (current_session_id.as_deref() == Some(session_id)) + .then(|| target_id.to_owned()) + }); + let Some(target_id) = target_id else { + conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan); + return; + }; + // A parent-session detach cascade bypasses the direct + // Target.detachFromTarget path. Release the renderer inspector + // here before resetting protocol state so the replacement primary + // session gets a fresh Runtime.enable context inventory. + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(session_id), + "Target detached", + ); + let _ = conn + .detach_runtime_inspector_session_for_session_owner_async(Some(session_id)) + .await; + events::fail_pending_fetch_state_for_target_background_events_async( + conn, + out.background_events_mut(), + Some(session_id), + "Target detached", + ) + .await; + let event_plan = conn + .detach_session_with_binding_cleanup_event_plan_async( + crate::conn::TargetSessionDetachCleanupPlan::new( + target_id, session_id, None, None, + ), + ) + .await; + out.extend_background_events(event_plan); + } + crate::conn::TargetBindingCleanupAction::AuxiliaryTarget { target_id } => { + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(session_id), + "Target detached", + ); + let _ = conn + .detach_runtime_inspector_session_for_session_owner_async(Some(session_id)) + .await; + clear_emulated_media_for_detached_session_best_effort(conn, session_id).await; + super::clear_detached_target_fetch_state_background_events_async( + conn, + out.background_events_mut(), + session_id, + ) + .await; + let event_plan = conn + .detach_session_with_binding_cleanup_event_plan_async( + crate::conn::TargetSessionDetachCleanupPlan::new( + target_id, session_id, None, None, + ), + ) + .await; + out.extend_background_events(event_plan); + } + crate::conn::TargetBindingCleanupAction::BackgroundTargetPrimaryAutoAttached { + target_id, + } => { + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(session_id), + "Target detached", + ); + let _ = conn + .detach_runtime_inspector_session_for_session_owner_async(Some(session_id)) + .await; + clear_emulated_media_for_detached_session_best_effort(conn, session_id).await; + let event_plan = conn + .detach_session_with_binding_cleanup_event_plan_async( + crate::conn::TargetSessionDetachCleanupPlan::new( + target_id, session_id, None, None, + ), + ) + .await; + out.extend_background_events(event_plan); + } + crate::conn::TargetBindingCleanupAction::TabTarget { tab_target_id } => { + let event_plan = conn + .detach_session_with_binding_cleanup_event_plan_async( + crate::conn::TargetSessionDetachCleanupPlan::new( + tab_target_id, + session_id, + None, + None, + ), + ) + .await; + out.extend_background_events(event_plan); + } + crate::conn::TargetBindingCleanupAction::SharedWorkerTarget { target_id } => { + let renderer_detach = conn.browser_context.as_ref().and_then(|bc| { + bc.shared_worker_target(&target_id) + .map(|target| (bc.renderer_runtime(), target.renderer_instance_id)) + }); + conn.release_shared_worker_runtime_remote_objects_for_session_best_effort_async( + session_id, + ) + .await; + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(session_id), + "Target detached", + ); + if let Some((renderer_runtime, instance_id)) = renderer_detach { + renderer_runtime.detach_shared_worker_runtime_inspector_session( + instance_id, + Some(session_id.to_owned()), + ); + } + let event_plan = conn + .detach_session_with_binding_cleanup_event_plan_async( + crate::conn::TargetSessionDetachCleanupPlan::new( + target_id, session_id, None, None, + ), + ) + .await; + out.extend_background_events(event_plan); + } + crate::conn::TargetBindingCleanupAction::DedicatedWorkerTarget { target_id } => { + let renderer_detach = conn.browser_context.as_ref().and_then(|bc| { + bc.dedicated_worker_target(&target_id) + .map(|target| (bc.renderer_runtime(), target.renderer_instance_id)) + }); + conn.release_shared_worker_runtime_remote_objects_for_session_best_effort_async( + session_id, + ) + .await; + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(session_id), + "Target detached", + ); + if let Some((renderer_runtime, instance_id)) = renderer_detach { + renderer_runtime.detach_dedicated_worker_runtime_inspector_session( + instance_id, + Some(session_id.to_owned()), + ); + } + let event_plan = conn + .detach_session_with_binding_cleanup_event_plan_async( + crate::conn::TargetSessionDetachCleanupPlan::new( + target_id, session_id, None, None, + ), + ) + .await; + out.extend_background_events(event_plan); + } + crate::conn::TargetBindingCleanupAction::ServiceWorkerTarget { target_id } => { + conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( + out.background_events_mut(), + command_context.protocol_events_mut(), + Some(session_id), + "Target detached", + ); + super::set_service_worker_pause_on_start_owner(conn, Some(session_id), false); + let event_plan = conn + .detach_session_with_binding_cleanup_event_plan_async( + crate::conn::TargetSessionDetachCleanupPlan::new( + target_id, session_id, None, None, + ), + ) + .await; + out.extend_background_events(event_plan); + } + crate::conn::TargetBindingCleanupAction::None => { + conn.rollback_auto_attached_session_detach_plan_without_event(&detach_plan); + } + } +} + +pub(super) async fn clear_emulated_media_for_detached_session_best_effort( + conn: &mut CdpConnection, + session_id: &str, +) { + if let Err(error) = + crate::domains::emulation::clear_emulated_media_for_detached_session_async(conn, session_id) + .await + { + tracing::warn!( + session_id, + error, + "failed to clear emulated media while detaching target session" + ); + } +} + +async fn ensure_initial_document_for_attached_page_targets_async( + conn: &mut CdpConnection, + attached_targets: &[(String, String)], +) { + for (target_id, _session_id) in attached_targets { + if !conn.browser_contexts().any(|browser_context| { + browser_context.target_has_pending_initial_document_page_build(target_id) + }) { + continue; + } + let Some(route) = conn.target_session_route_for_target_id(target_id) else { + continue; + }; + let pending = { + let mut route_scope = conn.scoped_none_session_owner_route_override(route); + match route_scope + .conn_mut() + .start_initial_document_page_ensure_for_session_owner(None) + { + Ok(pending) => pending, + Err(message) => { + warn_target_protocol_side_effect_failure( + target_id, + "start_initial_document_page_ensure", + &message, + ); + continue; + } + } + }; + let Some(pending) = pending else { + continue; + }; + match pending.wait().await { + Ok(completed) => { + if let Err(message) = conn + .complete_initial_document_page_build_for_owner(completed) + .await + { + warn_target_protocol_side_effect_failure( + target_id, + "complete_initial_document_page_build", + &message, + ); + } + } + Err(failed) => { + let message = conn.reset_failed_initial_document_page_build_for_owner(failed); + warn_target_protocol_side_effect_failure( + target_id, + "reset_failed_initial_document_page_build", + &message, + ); + } + } + } +} + +fn warn_target_protocol_side_effect_failure( + target_id: &str, + operation: &'static str, + message: &str, +) { + tracing::warn!( + target_id, + operation, + %message, + "target protocol side effect failed; continuing auto-attach event emission" + ); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ActivateTargetParams { + target_id: String, +} + +pub(super) fn start_activate_target_command( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> TargetCommandTaskStep { + let params: ActivateTargetParams = match cmd.get_params() { + Ok(Some(params)) => params, + _ => { + return super::target_command_error(-32602, "InvalidParams"); + } + }; + let command = build_cdp_activate_target_command(cmd, params); + super::start_devtools_target_command( + conn, + cmd.id, + cmd.session_id, + DevToolsCommand::ActivateTarget(command), + ) +} + +fn build_cdp_activate_target_command( + cmd: &Cmd<'_>, + params: ActivateTargetParams, +) -> DevToolsActivateTargetCommand { + DevToolsActivateTargetCommand { + context: cmd + .devtools_command_context(Some(params.target_id.as_str()), Option::<&str>::None), + target_id: DevToolsTargetId::from(params.target_id), + } +} + +pub(super) async fn complete_activate_target_command_async( + conn: &mut CdpConnection, + command: DevToolsActivateTargetCommand, +) -> CommandOutputPlan { + match execute_devtools_activate_target_command_async(conn, command).await { + Ok(()) => CommandOutputPlan::success(), + Err(error) => CommandOutputPlan::from_devtools_error(error), + } +} + +pub(super) async fn execute_devtools_activate_target_command_async( + conn: &mut CdpConnection, + command: DevToolsActivateTargetCommand, +) -> Result<(), DevToolsError> { + let target_id = command.target_id.as_str().to_owned(); + let previously_active_browser_context_id = previously_active_browser_context_id(conn); + if let Err(message) = select_browser_context_for_target(conn, &target_id) { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Err(DevToolsError::new(DevToolsErrorKind::NoSuchTarget, message)); + } + let bc = match conn.browser_context.as_ref() { + Some(bc) => bc, + None => { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "BrowserContextNotLoaded", + )); + } + }; + if bc.has_shared_worker_target(&target_id) { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Ok(()); + } + if bc.has_dedicated_worker_target(&target_id) { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Ok(()); + } + if bc.has_service_worker_target(&target_id) { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Ok(()); + } + if !bc.has_active_target() && bc.background_targets.is_empty() { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "TargetNotLoaded", + )); + } + if !matches!( + bc.active_target_identity(), + Some((ref active_target_id, _)) if active_target_id == &target_id + ) && bc.background_target(&target_id).is_some() + { + conn.handoff_navigation_engine_for_target_activation(&target_id); + let promoted = match conn.browser_context.as_mut() { + Some(browser_context) => { + browser_context + .promote_background_target_to_active_slot_async(&target_id) + .await + } + None => { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "BrowserContextNotLoaded", + )); + } + }; + match promoted { + Ok(true) => conn.refresh_active_browser_context_loader_async().await, + Ok(false) => { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "UnknownTargetId", + )); + } + Err(message) => { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Err(DevToolsError::new(DevToolsErrorKind::NoSuchTarget, message)); + } + } + } else if !matches!( + bc.active_target_identity(), + Some((ref active_target_id, _)) if active_target_id == &target_id + ) { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "UnknownTargetId", + )); + } + + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + Ok(()) +} + +#[cfg(test)] +mod protocol_neutral_tests { + use crate::devtools_runtime::DevToolsProtocol; + use serde_json::Value; + + use crate::conn::Cmd; + + use super::{ + ActivateTargetParams, CloseTargetParams, CreateTargetParams, + build_cdp_activate_target_command, build_cdp_close_target_command, + build_cdp_create_target_command, + }; + + #[test] + fn cdp_create_target_builds_protocol_neutral_target_command() { + let params = Value::Null; + let cmd = Cmd::for_test( + Some(31), + "Target.createTarget", + ¶ms, + Some("SID-create"), + r#"{"id":31,"method":"Target.createTarget"}"#, + ); + + let command = build_cdp_create_target_command( + &cmd, + CreateTargetParams { + url: "https://example.com/new".to_owned(), + browser_context_id: Some("BID-create".to_owned()), + }, + ); + + assert_eq!(command.context.protocol, DevToolsProtocol::Cdp); + assert_eq!( + command.context.session_id.as_ref().map(|id| id.as_str()), + Some("SID-create") + ); + assert_eq!(command.context.target_id, None); + assert_eq!( + command + .context + .browser_context_id + .as_ref() + .map(|id| id.as_str()), + Some("BID-create") + ); + assert_eq!(command.url, "https://example.com/new"); + assert_eq!( + command.browser_context_id.as_ref().map(|id| id.as_str()), + Some("BID-create") + ); + assert!(!command.activate); + } + + #[test] + fn cdp_close_target_builds_protocol_neutral_target_command() { + let params = Value::Null; + let cmd = Cmd::for_test( + Some(32), + "Target.closeTarget", + ¶ms, + Some("SID-close"), + r#"{"id":32,"method":"Target.closeTarget"}"#, + ); + + let command = build_cdp_close_target_command( + &cmd, + CloseTargetParams { + target_id: "TID-close".to_owned(), + }, + ); + + assert_eq!(command.context.protocol, DevToolsProtocol::Cdp); + assert_eq!( + command.context.session_id.as_ref().map(|id| id.as_str()), + Some("SID-close") + ); + assert_eq!( + command.context.target_id.as_ref().map(|id| id.as_str()), + Some("TID-close") + ); + assert_eq!(command.context.browser_context_id, None); + assert_eq!(command.target_id.as_str(), "TID-close"); + } + + #[test] + fn cdp_activate_target_builds_protocol_neutral_target_command() { + let params = Value::Null; + let cmd = Cmd::for_test( + Some(33), + "Target.activateTarget", + ¶ms, + Some("SID-activate"), + r#"{"id":33,"method":"Target.activateTarget"}"#, + ); + + let command = build_cdp_activate_target_command( + &cmd, + ActivateTargetParams { + target_id: "TID-activate".to_owned(), + }, + ); + + assert_eq!(command.context.protocol, DevToolsProtocol::Cdp); + assert_eq!( + command.context.session_id.as_ref().map(|id| id.as_str()), + Some("SID-activate") + ); + assert_eq!( + command.context.target_id.as_ref().map(|id| id.as_str()), + Some("TID-activate") + ); + assert_eq!(command.context.browser_context_id, None); + assert_eq!(command.target_id.as_str(), "TID-activate"); + } +} diff --git a/moli-protocol/src/domains/target/tests/tests_background_staging.rs b/moli-protocol/src/domains/target/tests/tests_background_staging.rs index 63811bdfe2..c055dc5496 100644 --- a/moli-protocol/src/domains/target/tests/tests_background_staging.rs +++ b/moli-protocol/src/domains/target/tests/tests_background_staging.rs @@ -5045,6 +5045,11 @@ async fn same_context_loaded_background_window_open_self_navigates_owner_without }) }) .await; + ctx.wait_for_document_continuation_for_test( + Some(&owner.session_id), + "background _self Document continuation", + ) + .await; let emitted = ctx.take_all(); assert!( !emitted @@ -5134,6 +5139,11 @@ async fn same_context_named_popup_reuse_navigates_and_promotes_loaded_owner() { }, ) .await; + ctx.wait_for_document_continuation_for_test( + Some(&owner.session_id), + "named background popup Document continuation", + ) + .await; let emitted = ctx.take_all(); assert!( !emitted diff --git a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/p1_target_multipage.rs b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/p1_target_multipage.rs index 4d564b0bb6..bee2670ca5 100644 --- a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/p1_target_multipage.rs +++ b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/p1_target_multipage.rs @@ -1,5 +1,7 @@ use super::super::tests_cdp_smoke_fixture::SmokeFixtureServer; use super::super::*; +use crate::conn::NETWORK_ERROR_PAGE_URL; +use crate::testing::spawn_connection_drop_server; use crate::{CdpCommandTaskStep, CommandDispatchContext, ParsedCdpCommand}; use serde_json::{Value, json}; @@ -963,6 +965,724 @@ async fn rust_cdp_chromium_target_window_open_waiting_popup_routes_initial_docum ); } +// Chromium source/runtime evidence: +// content/browser/renderer_host/navigation_request.cc::CommitErrorPage +// content/browser/renderer_host/navigation_request_browsertest.cc +// A failed popup destination replaces the initial empty Document while the +// auxiliary browsing context, stable Page, WindowProxy, and opener relation survive. +#[tokio::test(flavor = "multi_thread")] +async fn popup_transport_failure_commits_error_document_in_stable_auxiliary_page() { + let (failing_addr, failing_server) = spawn_connection_drop_server().await; + let mut ctx = TestContext::new_with_target_discovery(false); + ctx.enable_background_navigation_scheduler_for_test(); + tokio::task::LocalSet::new() + .run_until(async { + load_bc_with_titled_page_async( + &mut ctx, + "BID-popup-network-error", + "TID-popup-network-error-opener", + "
opener
", + ) + .await; + set_auto_attach_waiting_for_debugger(&mut ctx, 260_223).await; + ctx.take_all(); + + let unreachable_url = format!("http://{failing_addr}/popup-error"); + let messages = open_popup_from_runtime( + &mut ctx, + 260_224, + &format!( + "(() => {{ const popup = window.open('{unreachable_url}', '_blank'); globalThis.__networkErrorPopup = popup; globalThis.__networkErrorPopupAlias = popup; popup.__openerImmediateMutation = 'old realm'; popup.document.body.dataset.openerMutation = 'old document'; return popup !== null; }})()" + ), + ) + .await; + assert_eq!( + response(&messages, 260_224)["result"]["result"]["value"], + true + ); + let popup = event(&messages, "Target.targetCreated"); + let popup_target_id = popup["params"]["targetInfo"]["targetId"] + .as_str() + .expect("popup target id") + .to_owned(); + assert_eq!(popup["params"]["targetInfo"]["url"], unreachable_url); + assert_eq!( + popup["params"]["targetInfo"]["openerId"], + "TID-popup-network-error-opener" + ); + assert_eq!(popup["params"]["targetInfo"]["canAccessOpener"], true); + let attached = event(&messages, "Target.attachedToTarget"); + let popup_session_id = attached["params"]["sessionId"] + .as_str() + .expect("popup session id") + .to_owned(); + + for (id, method, params) in [ + (260_225, "Page.enable", json!({})), + (260_226, "Network.enable", json!({})), + (260_227, "Runtime.enable", json!({})), + ( + 260_228, + "Page.setLifecycleEventsEnabled", + json!({ "enabled": true }), + ), + ] { + ctx.process_async(json!({ + "id": id, + "method": method, + "sessionId": popup_session_id, + "params": params + })) + .await; + ctx.expect_result(id, json!({}), Some(&popup_session_id)); + } + + ctx.process_async(json!({ + "id": 260_229, + "method": "Runtime.evaluate", + "sessionId": popup_session_id, + "params": { + "expression": "({ href: location.href, marker: __openerImmediateMutation, bodyMarker: document.body.dataset.openerMutation, historyLength: history.length })", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 260_229)["result"]["result"]["value"], + json!({ + "href": "about:blank", + "marker": "old realm", + "bodyMarker": "old document", + "historyLength": 1 + }) + ); + + let before_target_page = ctx + .conn + .target_page_residence_identity_for_session(Some(&popup_session_id)) + .expect("popup target Page residence"); + let before_renderer_page = ctx + .conn + .renderer_page_residence_identity_for_session_owner(Some(&popup_session_id)) + .expect("popup renderer Page residence"); + let before_renderer_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(Some(&popup_session_id)) + .expect("popup renderer attachment"); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 260_230, + "method": "Runtime.runIfWaitingForDebugger", + "sessionId": popup_session_id + })) + .await; + take_response_by_id(&mut ctx, 260_230); + crate::testing::wait_until_scheduler_message( + &mut ctx, + "popup network error Document load", + |message| { + message["method"] == json!("Page.loadEventFired") + && message["sessionId"] == json!(popup_session_id) + }, + ) + .await; + ctx.wait_until_scheduler_state("popup network error commit", |conn| { + !conn.has_pending_document_navigation_for_session_owner(Some(&popup_session_id)) + && conn + .browser_context_by_id("BID-popup-network-error") + .and_then(|browser_context| { + browser_context.background_target(&popup_target_id) + }) + .and_then(|target| target.loaded_page()) + .is_some_and(|page| page.final_url().as_str() == NETWORK_ERROR_PAGE_URL) + }) + .await; + + let request_index = ctx + .sent + .iter() + .position(|message| { + message["method"] == json!("Network.requestWillBeSent") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["request"]["url"] == json!(unreachable_url) + }) + .unwrap_or_else(|| panic!("missing popup request: {:?}", ctx.sent)); + let request_id = ctx.sent[request_index]["params"]["requestId"] + .as_str() + .expect("popup request id") + .to_owned(); + let failed_index = ctx + .sent + .iter() + .position(|message| { + message["method"] == json!("Network.loadingFailed") + && message["params"]["requestId"] == json!(request_id) + }) + .unwrap_or_else(|| panic!("missing popup loadingFailed: {:?}", ctx.sent)); + let frame_index = ctx + .sent + .iter() + .position(|message| { + message["method"] == json!("Page.frameNavigated") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["frame"]["url"] + == json!(NETWORK_ERROR_PAGE_URL) + }) + .unwrap_or_else(|| panic!("missing popup error frame: {:?}", ctx.sent)); + let finished_index = ctx + .sent + .iter() + .position(|message| { + message["method"] == json!("Network.loadingFinished") + && message["params"]["requestId"] == json!(request_id) + }) + .unwrap_or_else(|| panic!("missing popup loadingFinished: {:?}", ctx.sent)); + let dom_content_loaded_index = ctx + .sent + .iter() + .position(|message| { + message["method"] == json!("Page.domContentEventFired") + && message["sessionId"] == json!(popup_session_id) + }) + .expect("popup error Document DCL"); + assert!( + request_index < failed_index + && failed_index < frame_index + && frame_index < finished_index + && finished_index < dom_content_loaded_index, + "popup error-page event order should match Chromium: {:?}", + ctx.sent + ); + assert_eq!( + ctx.sent[frame_index]["params"]["frame"]["unreachableUrl"], + unreachable_url + ); + assert_eq!(ctx.sent[failed_index]["params"]["canceled"], false); + assert!(!ctx.sent.iter().any(|message| { + message["method"] == json!("Network.responseReceived") + && message["params"]["requestId"] == json!(request_id) + })); + + ctx.process_async(json!({ + "id": 260_231, + "method": "Runtime.evaluate", + "sessionId": popup_session_id, + "params": { + "expression": "({ href: location.href, origin: location.origin, oldMarkerType: typeof __openerImmediateMutation, oldBodyMarkerType: typeof document.body.dataset.openerMutation, title: document.title, readyState: document.readyState, historyLength: history.length, hasOpener: opener !== null })", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 260_231)["result"]["result"]["value"], + json!({ + "href": NETWORK_ERROR_PAGE_URL, + "origin": "null", + "oldMarkerType": "undefined", + "oldBodyMarkerType": "undefined", + "title": "127.0.0.1", + "readyState": "complete", + "historyLength": 1, + "hasOpener": true + }) + ); + + ctx.process_async(json!({ + "id": 260_232, + "method": "Page.getNavigationHistory", + "sessionId": popup_session_id + })) + .await; + let history = take_response_by_id(&mut ctx, 260_232); + assert_eq!(history["result"]["currentIndex"], json!(0)); + assert_eq!( + history["result"]["entries"] + .as_array() + .expect("popup error history entries") + .len(), + 1 + ); + assert_eq!(history["result"]["entries"][0]["url"], unreachable_url); + + ctx.process_async(json!({ + "id": 260_233, + "method": "Target.getTargetInfo", + "params": { "targetId": popup_target_id } + })) + .await; + let target_info = take_response_by_id(&mut ctx, 260_233); + assert_eq!(target_info["result"]["targetInfo"]["url"], unreachable_url); + assert_eq!( + target_info["result"]["targetInfo"]["openerId"], + "TID-popup-network-error-opener" + ); + assert_eq!( + target_info["result"]["targetInfo"]["canAccessOpener"], + true + ); + + assert_eq!( + ctx.conn + .target_page_residence_identity_for_session(Some(&popup_session_id)), + Some(before_target_page), + "popup error navigation should keep its target Page residence" + ); + assert_eq!( + ctx.conn + .renderer_page_residence_identity_for_session_owner(Some(&popup_session_id)), + Some(before_renderer_page), + "popup error navigation should keep its renderer Page/WindowProxy residence" + ); + assert_ne!( + ctx.conn + .current_renderer_agent_attachment_id_for_session_owner(Some( + &popup_session_id, + )), + Some(before_renderer_attachment), + "popup error navigation should replace only the renderer Document/realm" + ); + + ctx.process_async(json!({ + "id": 260_234, + "method": "Runtime.evaluate", + "params": { + "expression": "({ retainedWindowProxy: __networkErrorPopup !== null && __networkErrorPopup === __networkErrorPopupAlias })", + "returnByValue": true + } + })) + .await; + let opener_probe = take_response_by_id(&mut ctx, 260_234); + assert_eq!( + opener_probe["result"]["result"]["value"], + json!({ "retainedWindowProxy": true }), + "opener-side stable popup WindowProxy probe failed: {opener_probe:?}" + ); + }) + .await; + failing_server.abort(); +} + +// Chromium/WPT sources: +// third_party/blink/web_tests/external/wpt/html/browsers/browsing-the-web/ +// navigating-across-documents/initial-empty-document/window-open-204-fragment.html +// navigating-across-documents/initial-empty-document/ +// window-open-204-pushState-replaceState.html +// third_party/blink/web_tests/http/tests/inspector-protocol/page/navigate-204.js +// third_party/blink/web_tests/http/tests/inspector-protocol/network/ +// navigation-204-loading-failed.js +#[tokio::test(flavor = "multi_thread")] +async fn popup_no_commit_responses_preserve_initial_document_before_redirect_replacement() { + let fixture = SmokeFixtureServer::start().await; + let mut ctx = TestContext::new_with_target_discovery(false); + ctx.enable_background_navigation_scheduler_for_test(); + tokio::task::LocalSet::new() + .run_until(async { + load_bc_with_titled_page_async( + &mut ctx, + "BID-popup-terminal", + "TID-popup-terminal-opener", + "
opener
", + ) + .await; + set_auto_attach_waiting_for_debugger(&mut ctx, 260_230).await; + ctx.take_all(); + + let no_content_url = fixture.url("/no-content"); + let messages = open_popup_from_runtime( + &mut ctx, + 260_231, + &format!( + "(() => {{ const popup = window.open('{no_content_url}', '_blank'); popup.__openerImmediateMutation = 'kept'; popup.document.body.dataset.openerMutation = 'kept'; return popup !== null; }})()" + ), + ) + .await; + assert_eq!( + response(&messages, 260_231)["result"]["result"]["value"], + true + ); + let popup = event(&messages, "Target.targetCreated"); + let popup_target_id = popup["params"]["targetInfo"]["targetId"] + .as_str() + .expect("popup target id") + .to_owned(); + let attached = event(&messages, "Target.attachedToTarget"); + let popup_session_id = attached["params"]["sessionId"] + .as_str() + .expect("popup session id") + .to_owned(); + + for (id, method, params) in [ + (260_232, "Page.enable", json!({})), + (260_233, "Network.enable", json!({})), + ( + 260_234, + "Page.setLifecycleEventsEnabled", + json!({ "enabled": true }), + ), + ] { + ctx.process_async(json!({ + "id": id, + "method": method, + "sessionId": popup_session_id, + "params": params + })) + .await; + ctx.expect_result(id, json!({}), Some(&popup_session_id)); + } + + ctx.process_async(json!({ + "id": 260_235, + "method": "Runtime.evaluate", + "sessionId": popup_session_id, + "params": { + "expression": "({ href: location.href, globalMarker: __openerImmediateMutation, bodyMarker: document.body.dataset.openerMutation, historyLength: history.length })", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 260_235)["result"]["result"]["value"], + json!({ + "href": "about:blank", + "globalMarker": "kept", + "bodyMarker": "kept", + "historyLength": 1 + }) + ); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 260_236, + "method": "Runtime.runIfWaitingForDebugger", + "sessionId": popup_session_id + })) + .await; + take_response_by_id(&mut ctx, 260_236); + crate::testing::wait_until_scheduler_message( + &mut ctx, + "popup 204 navigation abort", + |message| { + message["method"] == json!("Network.loadingFailed") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["errorText"] == json!("net::ERR_ABORTED") + }, + ) + .await; + ctx.wait_until_scheduler_state("popup 204 terminal completion", |conn| { + !conn.has_pending_document_navigation_for_session_owner(Some(&popup_session_id)) + }) + .await; + + let response_204_index = ctx + .sent + .iter() + .position(|message| { + message["method"] == json!("Network.responseReceived") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["response"]["url"] == json!(no_content_url) + && message["params"]["response"]["status"] == json!(204) + }) + .unwrap_or_else(|| panic!("missing popup 204 response: {:?}", ctx.sent)); + let request_204 = ctx.sent[response_204_index]["params"]["requestId"] + .as_str() + .expect("204 request id") + .to_owned(); + let failed_204_index = ctx + .sent + .iter() + .position(|message| { + message["method"] == json!("Network.loadingFailed") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["requestId"] == json!(request_204) + && message["params"]["errorText"] == json!("net::ERR_ABORTED") + && message["params"]["canceled"] == json!(true) + }) + .unwrap_or_else(|| panic!("missing popup 204 loadingFailed: {:?}", ctx.sent)); + assert!(response_204_index < failed_204_index); + assert!(!ctx.sent.iter().any(|message| { + message["method"] == json!("Network.loadingFinished") + && message["params"]["requestId"] == json!(request_204) + })); + assert!(!ctx.sent.iter().any(|message| { + (message["method"] == json!("Page.frameNavigated") + && message["params"]["frame"]["url"] == json!(no_content_url)) + || (message["method"] == json!("Page.lifecycleEvent") + && matches!( + message["params"]["name"].as_str(), + Some("DOMContentLoaded" | "load") + )) + })); + + ctx.process_async(json!({ + "id": 260_237, + "method": "Runtime.evaluate", + "sessionId": popup_session_id, + "params": { + "expression": "location.hash = 'after-204'; const fragmentHistoryLength = history.length; history.pushState({ source: 'initial-empty' }, '', '#after-push-state'); ({ href: location.href, globalMarker: __openerImmediateMutation, bodyMarker: document.body.dataset.openerMutation, fragmentHistoryLength, historyLength: history.length, historyState: history.state.source })", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 260_237)["result"]["result"]["value"], + json!({ + "href": "about:blank#after-push-state", + "globalMarker": "kept", + "bodyMarker": "kept", + "fragmentHistoryLength": 1, + "historyLength": 1, + "historyState": "initial-empty" + }) + ); + + ctx.process_async(json!({ + "id": 260_243, + "method": "Page.getNavigationHistory", + "sessionId": popup_session_id + })) + .await; + let fragment_history = take_response_by_id(&mut ctx, 260_243); + assert_eq!(fragment_history["result"]["currentIndex"], json!(0)); + assert_eq!( + fragment_history["result"]["entries"] + .as_array() + .expect("post-204 fragment history entries") + .len(), + 1 + ); + assert_eq!( + fragment_history["result"]["entries"][0]["url"], + "about:blank#after-push-state" + ); + + ctx.sent.clear(); + let reset_content_url = fixture.url("/reset-content"); + ctx.process_async(json!({ + "id": 260_238, + "method": "Page.navigate", + "sessionId": popup_session_id, + "params": { "url": reset_content_url } + })) + .await; + crate::testing::wait_until_scheduler_message( + &mut ctx, + "popup 205 Page.navigate result", + |message| message["id"] == json!(260_238), + ) + .await; + let reset_result = take_response_by_id(&mut ctx, 260_238); + assert_eq!(reset_result["result"]["frameId"], popup_target_id); + assert!(reset_result["result"]["loaderId"].as_str().is_some()); + assert_eq!( + reset_result["result"]["errorText"], + json!("net::ERR_ABORTED") + ); + assert_eq!(reset_result["result"]["isDownload"], json!(false)); + let response_205 = ctx + .sent + .iter() + .find(|message| { + message["method"] == json!("Network.responseReceived") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["response"]["url"] == json!(reset_content_url) + && message["params"]["response"]["status"] == json!(205) + }) + .unwrap_or_else(|| panic!("missing popup 205 response: {:?}", ctx.sent)); + let request_205 = response_205["params"]["requestId"] + .as_str() + .expect("205 request id"); + assert!(ctx.sent.iter().any(|message| { + message["method"] == json!("Network.loadingFailed") + && message["params"]["requestId"] == json!(request_205) + && message["params"]["errorText"] == json!("net::ERR_ABORTED") + && message["params"]["canceled"] == json!(true) + })); + + ctx.process_async(json!({ + "id": 260_239, + "method": "Runtime.evaluate", + "sessionId": popup_session_id, + "params": { + "expression": "({ href: location.href, marker: __openerImmediateMutation, historyLength: history.length })", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 260_239)["result"]["result"]["value"], + json!({ + "href": "about:blank#after-push-state", + "marker": "kept", + "historyLength": 1 + }) + ); + + ctx.sent.clear(); + let redirect_start_url = fixture.url("/redirect-start"); + let redirect_final_url = fixture.url("/redirect-final"); + ctx.process_async(json!({ + "id": 260_240, + "method": "Page.navigate", + "sessionId": popup_session_id, + "params": { "url": redirect_start_url } + })) + .await; + crate::testing::wait_until_scheduler_message( + &mut ctx, + "popup redirect Page.navigate result", + |message| message["id"] == json!(260_240), + ) + .await; + let redirect_result = take_response_by_id(&mut ctx, 260_240); + let redirect_loader_id = redirect_result["result"]["loaderId"] + .as_str() + .expect("redirect loader id") + .to_owned(); + crate::testing::wait_until_scheduler_message( + &mut ctx, + "popup redirect load lifecycle", + |message| { + message["method"] == json!("Page.lifecycleEvent") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["frameId"] == json!(popup_target_id) + && message["params"]["loaderId"] == json!(redirect_loader_id) + && message["params"]["name"] == json!("load") + }, + ) + .await; + ctx.wait_until_scheduler_state("popup redirect authoritative load", |conn| { + conn.renderer_document_lifecycle_authoritative_state_for_session_owner(Some( + &popup_session_id, + )) + .is_some_and(|(binding, snapshot)| { + binding.loader_id == redirect_loader_id && snapshot.load.is_some() + }) + }) + .await; + + let document_requests = ctx + .sent + .iter() + .filter(|message| { + message["method"] == json!("Network.requestWillBeSent") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["type"] == json!("Document") + }) + .collect::>(); + assert_eq!( + document_requests + .iter() + .filter(|message| { + message["params"]["request"]["url"] == json!(redirect_start_url) + }) + .count(), + 1, + "redirect start must have one authoritative hop: {:?}", + ctx.sent + ); + assert_eq!( + document_requests + .iter() + .filter(|message| { + message["params"]["request"]["url"] == json!(redirect_final_url) + }) + .count(), + 1, + "redirect final must have one authoritative hop: {:?}", + ctx.sent + ); + let start_request = document_requests + .iter() + .find(|message| { + message["params"]["request"]["url"] == json!(redirect_start_url) + }) + .expect("redirect start request"); + let final_request = document_requests + .iter() + .find(|message| { + message["params"]["request"]["url"] == json!(redirect_final_url) + }) + .expect("redirect final request"); + assert_eq!( + start_request["params"]["requestId"], + final_request["params"]["requestId"] + ); + assert_eq!( + final_request["params"]["redirectResponse"]["url"], + redirect_start_url + ); + assert_eq!( + ctx.sent + .iter() + .filter(|message| { + message["method"] == json!("Page.frameNavigated") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["frame"]["loaderId"] + == json!(redirect_loader_id) + && message["params"]["frame"]["url"] == json!(redirect_final_url) + }) + .count(), + 1 + ); + for lifecycle_name in ["DOMContentLoaded", "load"] { + assert_eq!( + ctx.sent + .iter() + .filter(|message| { + message["method"] == json!("Page.lifecycleEvent") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["loaderId"] == json!(redirect_loader_id) + && message["params"]["name"] == json!(lifecycle_name) + }) + .count(), + 1, + "{lifecycle_name} must be published once: {:?}", + ctx.sent + ); + } + + ctx.process_async(json!({ + "id": 260_241, + "method": "Runtime.evaluate", + "sessionId": popup_session_id, + "params": { + "expression": "({ href: location.href, text: document.querySelector('main').textContent, historyLength: history.length, readyState: document.readyState, oldMarkerType: typeof __openerImmediateMutation })", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 260_241)["result"]["result"]["value"], + json!({ + "href": redirect_final_url, + "text": "redirect final", + "historyLength": 1, + "readyState": "complete", + "oldMarkerType": "undefined" + }) + ); + + ctx.process_async(json!({ + "id": 260_242, + "method": "Page.getNavigationHistory", + "sessionId": popup_session_id + })) + .await; + let history = take_response_by_id(&mut ctx, 260_242); + assert_eq!(history["result"]["currentIndex"], json!(0)); + assert_eq!( + history["result"]["entries"] + .as_array() + .expect("popup history entries") + .len(), + 1 + ); + assert_eq!(history["result"]["entries"][0]["url"], redirect_final_url); + }) + .await; +} + // Chromium source: // third_party/blink/web_tests/http/tests/inspector-protocol/target/target-setAutoAttach-windowOpen-empty-url.js #[tokio::test(flavor = "multi_thread")] diff --git a/moli-protocol/src/domains/target/tests/tests_cdp_smoke_fixture.rs b/moli-protocol/src/domains/target/tests/tests_cdp_smoke_fixture.rs index cd7448b3b4..6f1b628155 100644 --- a/moli-protocol/src/domains/target/tests/tests_cdp_smoke_fixture.rs +++ b/moli-protocol/src/domains/target/tests/tests_cdp_smoke_fixture.rs @@ -400,6 +400,18 @@ async fn fixture_handler( } "/redirect-start" => redirect("/redirect-final", &[("cache-control", "no-store")]), "/redirect-final" => html("
redirect final
"), + "/no-content" => response( + StatusCode::NO_CONTENT, + "text/plain; charset=utf-8", + Vec::new(), + &[("x-smoke-navigation-terminal", "no-content")], + ), + "/reset-content" => response( + StatusCode::RESET_CONTENT, + "text/plain; charset=utf-8", + Vec::new(), + &[("x-smoke-navigation-terminal", "reset-content")], + ), "/history-a" => html("
history a
"), "/history-b" => html("
history b
"), "/document-continue" => { @@ -749,6 +761,19 @@ async fn rust_smoke_fixture_serves_all_document_and_control_routes() { } } +#[tokio::test(flavor = "multi_thread")] +async fn rust_smoke_fixture_serves_navigation_no_commit_routes() { + let fixture = SmokeFixtureServer::start().await; + for (path, expected_status) in [("/no-content", 204), ("/reset-content", 205)] { + let response = fixture_get(&fixture, path).await; + assert_eq!(response.status, expected_status, "route {path}"); + assert!( + response.body.is_empty(), + "route {path} must not carry a body" + ); + } +} + #[tokio::test(flavor = "multi_thread")] async fn rust_smoke_fixture_serves_all_subresource_routes() { let fixture = SmokeFixtureServer::start().await; diff --git a/moli-protocol/src/domains/target/tests/tests_playwright_cdp.rs b/moli-protocol/src/domains/target/tests/tests_playwright_cdp.rs index 9756c249d0..bb0e1a7b4f 100644 --- a/moli-protocol/src/domains/target/tests/tests_playwright_cdp.rs +++ b/moli-protocol/src/domains/target/tests/tests_playwright_cdp.rs @@ -6079,6 +6079,7 @@ async fn playwright_over_cdp_script_execution_disabled_blocks_page_scripts_but_n let mut ctx = TestContext::new(); let attached = create_attached_page_session_async(&mut ctx, 240, 241, 242, 2393, 243).await; let session_id = attached.session_id; + let target_id = attached.target_id; ctx.process_async(json!({ "id": 244, @@ -6125,7 +6126,7 @@ async fn playwright_over_cdp_script_execution_disabled_blocks_page_scripts_but_n crate::testing::wait_until_renderer_document_load( &mut ctx, Some(session_id.as_str()), - &attached.target_id, + &target_id, &loader_id, ) .await; diff --git a/moli-protocol/src/domains/target/tests/tests_same_context_target_local.rs b/moli-protocol/src/domains/target/tests/tests_same_context_target_local.rs index f142dc88da..bb1976278b 100644 --- a/moli-protocol/src/domains/target/tests/tests_same_context_target_local.rs +++ b/moli-protocol/src/domains/target/tests/tests_same_context_target_local.rs @@ -171,7 +171,7 @@ async fn get_target_info_reports_background_target_in_same_browser_context() { } #[tokio::test(flavor = "multi_thread")] -async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() { +async fn popup_initial_about_blank_adopts_renderer_page_and_related_script_agent() { let mut ctx = TestContext::new(); ctx.conn.auto_attach = true; let browser_context = ctx @@ -195,6 +195,7 @@ async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() { ) .await .expect("opener page should load"); + let opener_renderer_page_id = opener_page.renderer_page_id().as_u64(); let opener_url = opener_page.final_url().as_str().to_owned(); { let browser_context = ctx @@ -251,25 +252,60 @@ async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() { }) .cloned() .unwrap_or_else(|| panic!("popup target should be auto-attached: {:?}", ctx.sent)); - let popup_session_id = attached["params"]["sessionId"] - .as_str() - .expect("popup session id should be present") - .to_owned(); + assert!( + attached["params"]["sessionId"].as_str().is_some(), + "popup session id should be present" + ); ctx.sent.clear(); - ctx.process_async(json!({ - "id": 104_216, - "method": "Page.navigate", - "sessionId": popup_session_id, - "params": { - "url": "data:text/html,popuppopup" - } - })) - .await; - consume_main_document_navigation_start(&mut ctx); - let navigation = take_response_by_id(&mut ctx, 104_216); - assert_eq!(navigation["result"]["frameId"], json!(popup_target_id)); - ctx.sent.clear(); + let opener_script_agent_id = ctx + .conn + .browser_context + .as_mut() + .and_then(|browser_context| browser_context.active_target.runtime_slot.loaded_page_mut()) + .expect("opener should remain loaded") + .runtime_heap_usage_async() + .await + .expect("opener heap diagnostics should be available") + .moli + .runtime + .script_agent_id; + let (popup_renderer_page_id, popup_browsing_context_id, popup_script_agent_id) = { + let popup = ctx + .conn + .browser_context + .as_mut() + .and_then(|browser_context| browser_context.background_target_mut(&popup_target_id)) + .expect("popup target should remain staged"); + let popup_browsing_context_id = popup + .auxiliary_browsing_context_id() + .expect("popup target should retain its renderer browsing-context identity"); + let popup_page = popup + .loaded_page_mut() + .expect("popup initial empty Document should be loaded"); + let popup_renderer_page_id = popup_page.renderer_page_id().as_u64(); + let popup_script_agent_id = popup_page + .runtime_heap_usage_async() + .await + .expect("popup heap diagnostics should be available") + .moli + .runtime + .script_agent_id; + ( + popup_renderer_page_id, + popup_browsing_context_id, + popup_script_agent_id, + ) + }; + assert_ne!(popup_renderer_page_id, opener_renderer_page_id); + assert_eq!( + popup_renderer_page_id, popup_browsing_context_id, + "protocol must build the popup's initial Document with the renderer-reserved Page identity" + ); + assert_eq!( + popup_script_agent_id, opener_script_agent_id, + "the renderer-reserved initial popup Page should join its opener's script agent" + ); ctx.process_async(json!({ "id": 104_217, @@ -284,15 +320,20 @@ async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() { json!(1), "opener and loaded popup target should share one renderer owner: {diagnostics:?}" ); + assert_eq!( + isolate_scope["loadedDocumentScriptAgentCount"], + json!(1), + "the renderer-reserved popup Page should join its opener's explicit script agent: {diagnostics:?}" + ); assert_eq!( isolate_scope["estimatedDocumentIsolateCount"], - json!(2), - "loaded popup PageVM must own a distinct document isolate: {diagnostics:?}" + json!(1), + "two related PageVM realms should be hosted by one document script-agent isolate: {diagnostics:?}" ); assert_eq!( isolate_scope["estimatedLiveV8IsolateCount"], - json!(2), - "opener plus popup without workers should report two live page document isolates: {diagnostics:?}" + json!(1), + "opener plus related popup without workers should report one live script-agent isolate: {diagnostics:?}" ); assert_eq!( isolate_scope["documentContextCount"], @@ -304,6 +345,79 @@ async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() { json!(1), "the popup should remain a loaded background target" ); + + ctx.sent.clear(); + ctx.process_async(json!({ + "id": 104_218, + "method": "Runtime.evaluate", + "sessionId": "SID-popup-opener", + "params": { + "expression": "window.open('about:blank#diagnostics-noopener', '_blank', 'noopener') === null", + "returnByValue": true + } + })) + .await; + let noopener_response = take_response_by_id(&mut ctx, 104_218); + assert_eq!( + noopener_response["result"]["result"]["value"], + json!(true), + "noopener window.open should return null after accepting the popup" + ); + let noopener_target = ctx + .sent + .iter() + .find(|message| { + message["method"] == json!("Target.targetCreated") + && message["params"]["targetInfo"]["canAccessOpener"] == json!(false) + }) + .cloned() + .unwrap_or_else(|| panic!("noopener popup target should be created: {:?}", ctx.sent)); + let noopener_target_id = noopener_target["params"]["targetInfo"]["targetId"] + .as_str() + .expect("noopener popup target id should be present") + .to_owned(); + let noopener_script_agent_id = { + let popup = ctx + .conn + .browser_context + .as_mut() + .and_then(|browser_context| browser_context.background_target_mut(&noopener_target_id)) + .expect("noopener popup target should remain staged"); + let popup_browsing_context_id = popup + .auxiliary_browsing_context_id() + .expect("noopener popup should retain its auxiliary browsing-context identity"); + let popup_page = popup + .loaded_page_mut() + .expect("noopener popup initial empty Document should be loaded"); + assert_eq!( + popup_page.renderer_page_id().as_u64(), + popup_browsing_context_id, + "noopener popup must also adopt its renderer-reserved Page identity" + ); + popup_page + .runtime_heap_usage_async() + .await + .expect("noopener popup heap diagnostics should be available") + .moli + .runtime + .script_agent_id + }; + assert_ne!( + noopener_script_agent_id, opener_script_agent_id, + "opener suppression must keep the auxiliary Page in a fresh script agent" + ); + + ctx.sent.clear(); + ctx.process_async(json!({ + "id": 104_219, + "method": "HeapProfiler.moliDiagnostics" + })) + .await; + let diagnostics = take_response_by_id(&mut ctx, 104_219); + let isolate_scope = &diagnostics["result"]["isolateScope"]; + assert_eq!(isolate_scope["loadedDocumentPageCount"], json!(3)); + assert_eq!(isolate_scope["loadedDocumentScriptAgentCount"], json!(2)); + assert_eq!(isolate_scope["estimatedDocumentIsolateCount"], json!(2)); } #[tokio::test(flavor = "multi_thread")] diff --git a/moli-protocol/src/domains/target/tests/tests_target_creation.rs b/moli-protocol/src/domains/target/tests/tests_target_creation.rs index e770a6316f..2fa49fd3c2 100644 --- a/moli-protocol/src/domains/target/tests/tests_target_creation.rs +++ b/moli-protocol/src/domains/target/tests/tests_target_creation.rs @@ -1004,7 +1004,10 @@ async fn timer_window_open_emits_page_event_from_runtime_work() { async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key() { async fn opener() -> impl IntoResponse { ( - [(CONTENT_TYPE.as_str(), "text/html")], + [ + (CONTENT_TYPE.as_str(), "text/html"), + ("content-security-policy", "script-src 'self'"), + ], "
session storage opener
", ) } @@ -1017,17 +1020,13 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( Arc, )>, ) -> impl IntoResponse { - // The opener's lightweight WindowProxy facade starts its mirrored - // load before the concrete popup action can reach protocol. Let that - // renderer-local request finish; gate the second request, which is the - // real auxiliary target navigation whose lifetime this test covers. + // The real auxiliary Page owns the only navigation. Gate that request + // so the test can inspect the synchronously created initial realm + // after target admission but before the replacement Document commits. let request_index = request_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if request_index == 0 { - return opener().await; - } assert_eq!( - request_index, 1, - "popup URL must have exactly two load owners" + request_index, 0, + "popup URL must have exactly one authoritative navigation owner" ); request_started.add_permits(1); let permit = response_release @@ -1108,7 +1107,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( "id": 1201, "method": "Runtime.evaluate", "params": { - "expression": "(() => { localStorage.clear(); localStorage.setItem('shared', 'opener'); sessionStorage.clear(); sessionStorage.setItem('phase', 'before'); const popup = window.open('about:blank', '_blank'); sessionStorage.setItem('phase', 'after'); return `${popup.sessionStorage.getItem('phase')}|${sessionStorage.getItem('phase')}|${popup.localStorage.getItem('shared')}`; })()", + "expression": "(() => { const openerOrigin = location.origin; const openerHref = location.href; const openerBase = document.baseURI; localStorage.clear(); localStorage.setItem('shared', 'opener'); sessionStorage.clear(); sessionStorage.setItem('phase', 'before'); const popup = window.open('about:blank', '_blank'); let popupEvalBlocked = false; try { popup.eval('1'); } catch (error) { popupEvalBlocked = error.name === 'EvalError'; } sessionStorage.setItem('phase', 'after'); return `${popup.sessionStorage.getItem('phase')}|${sessionStorage.getItem('phase')}|${popup.localStorage.getItem('shared')}|${popup.location.origin === openerOrigin}|${popup.origin === openerOrigin}|${popup.document.referrer === openerHref}|${popup.document.baseURI === openerBase}|${popup.name === ''}|${popupEvalBlocked}`; })()", "returnByValue": true } })) @@ -1118,7 +1117,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( json!({ "result": { "type": "string", - "value": "before|after|opener" + "value": "before|after|opener|true|true|true|true|true|true" } }), None, @@ -1150,12 +1149,40 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( .to_owned(); ctx.sent.clear(); + ctx.process_async(json!({ + "id": 12021, + "sessionId": popup_session_id, + "method": "Runtime.enable" + })) + .await; + ctx.expect_result(12021, json!({}), Some(&popup_session_id)); + let default_context = ctx + .sent + .iter() + .find(|message| { + message["method"] == json!("Runtime.executionContextCreated") + && message["sessionId"] == json!(popup_session_id) + && message["params"]["context"]["auxData"]["isDefault"] == json!(true) + }) + .unwrap_or_else(|| panic!("popup default execution context: {:?}", ctx.sent)); + assert_eq!( + default_context["params"]["context"]["origin"], + json!(format!("http://{addr}")), + "CDP must expose the creator-inherited origin for the real initial about:blank realm: {default_context:?}" + ); + assert_eq!( + default_context["params"]["context"]["auxData"]["frameId"], + json!(popup_target_id) + ); + ctx.sent.clear(); + ctx.process_async(json!({ "id": 1203, "sessionId": popup_session_id, "method": "Runtime.evaluate", "params": { - "expression": "`${String(sessionStorage.getItem('phase'))}|${String(localStorage.getItem('shared'))}`", + "expression": "(() => { let evalBlocked = false; try { eval('1'); } catch (error) { evalBlocked = error.name === 'EvalError'; } return `${String(sessionStorage.getItem('phase'))}|${String(localStorage.getItem('shared'))}|${evalBlocked}`; })()", + "allowUnsafeEvalBlockedByCSP": false, "returnByValue": true } })) @@ -1165,7 +1192,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( json!({ "result": { "type": "string", - "value": "before|opener" + "value": "before|opener|true" } }), Some(&popup_session_id), @@ -1215,6 +1242,19 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( Some(&popup_session_id), ); + ctx.sent.clear(); + ctx.process_async(json!({ + "id": 12033, + "method": "Target.setAutoAttach", + "params": { + "autoAttach": true, + "waitForDebuggerOnStart": true, + "flatten": true + } + })) + .await; + ctx.expect_result(12033, json!({}), None); + ctx.sent.clear(); let cross_origin_url = format!("http://{cross_origin_addr}/popup"); ctx.process_async(json!({ @@ -1223,7 +1263,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( "sessionId": opener_session_id, "params": { "expression": format!( - "(() => {{ sessionStorage.setItem('cross-origin-secret', 'opener-only'); return window.open({cross_origin_url:?}, '_blank') !== null; }})()" + "(() => {{ sessionStorage.setItem('cross-origin-secret', 'opener-only'); const popup = window.open({cross_origin_url:?}, '_blank'); if (popup === null) return false; popup.__initialNonEmptyMarker = 'before-navigation'; popup.document.body.setAttribute('data-before-navigation', 'preserved'); window.__pendingNonEmptyPopup = popup; window.__pendingNonEmptyDocument = popup.document; return popup.location.href === 'about:blank'; }})()" ), "returnByValue": true } @@ -1251,6 +1291,67 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( .as_str() .expect("cross-origin popup target id") .to_owned(); + let cross_origin_attached = ctx + .sent + .iter() + .find(|message| { + message["method"] == json!("Target.attachedToTarget") + && message["params"]["targetInfo"]["targetId"] + == json!(cross_origin_target_id) + }) + .unwrap_or_else(|| panic!("cross-origin popup auto-attach event: {:?}", ctx.sent)); + assert_eq!( + cross_origin_attached["params"]["waitingForDebugger"], + json!(true) + ); + let cross_origin_session_id = cross_origin_attached["params"]["sessionId"] + .as_str() + .expect("cross-origin popup session id") + .to_owned(); + ctx.sent.clear(); + + assert!( + !ctx.conn + .browser_context_by_id("BID-popup-storage") + .is_some_and(|browser_context| browser_context + .has_pending_document_navigation_for_target(Some(&cross_origin_target_id))), + "waitForDebuggerOnStart must hold the target-owned navigation before it starts" + ); + assert_eq!( + cross_origin_request_count.load(std::sync::atomic::Ordering::SeqCst), + 0, + "target admission must precede the only popup network request" + ); + + ctx.process_async(json!({ + "id": 12051, + "sessionId": cross_origin_session_id, + "method": "Runtime.evaluate", + "params": { + "expression": "`${window.opener.__pendingNonEmptyPopup === window}|${window.opener.__pendingNonEmptyDocument === document}|${window.__initialNonEmptyMarker}|${document.body.getAttribute('data-before-navigation')}|${sessionStorage.getItem('cross-origin-secret')}`", + "returnByValue": true + } + })) + .await; + ctx.expect_result( + 12051, + json!({ + "result": { + "type": "string", + "value": "true|true|before-navigation|preserved|opener-only" + } + }), + Some(&cross_origin_session_id), + ); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 12052, + "sessionId": cross_origin_session_id, + "method": "Runtime.runIfWaitingForDebugger" + })) + .await; + ctx.expect_result(12052, json!({}), Some(&cross_origin_session_id)); ctx.sent.clear(); ctx.wait_until_scheduler_state("cross-origin popup navigation starts", |conn| { @@ -1270,28 +1371,6 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( .expect("cross-origin popup request gate must remain open"); request_started.forget(); - // Attach while the target's requested URL is still loading. Session - // attachment belongs to the browsing context and must not race the - // replacement Document's commit. - ctx.process_and_wait_for_response_async(json!({ - "id": 1205, - "method": "Target.attachToTarget", - "params": { "targetId": cross_origin_target_id } - })) - .await; - let attach_response = take_response_by_id(&mut ctx, 1205); - let cross_origin_session_id = attach_response["result"]["sessionId"] - .as_str() - .unwrap_or_else(|| panic!("cross-origin popup session id: {attach_response:?}")) - .to_owned(); - assert!( - ctx.conn - .browser_context_by_id("BID-popup-storage") - .is_some_and(|browser_context| browser_context - .has_pending_document_navigation_for_target(Some(&cross_origin_target_id))), - "attaching a DevTools session must not replace the target-owned navigation" - ); - ctx.sent.clear(); cross_origin_response_release.add_permits(1); let response_returned = tokio::time::timeout( std::time::Duration::from_secs(5), @@ -1314,6 +1393,12 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( ) .await; + assert_eq!( + cross_origin_request_count.load(std::sync::atomic::Ordering::SeqCst), + 1, + "non-empty popup navigation must issue exactly one network request" + ); + ctx.process_async(json!({ "id": 1206, "sessionId": cross_origin_session_id, @@ -1611,6 +1696,118 @@ async fn popup_initial_empty_document_frame_tree_inherits_opener_origin() { assert!(initial.is_on_initial_empty_document()); } +#[tokio::test(flavor = "multi_thread")] +async fn opener_window_handle_projects_the_renderer_owned_auxiliary_realm() { + let mut ctx = TestContext::new(); + load_bc_with_titled_page_async( + &mut ctx, + "BID-popup-window-proxy", + "TID-opener-window-proxy", + "
popup WindowProxy opener
", + ) + .await; + + ctx.process_async(json!({ + "id": 1250, + "method": "Runtime.evaluate", + "params": { + "expression": "(() => { const popup = window.open('about:blank', '_blank'); globalThis.__lmPopupWindow = popup; globalThis.__lmPopupDocument = popup.document; popup.__lmSynchronousRealmMarker = 'before-target-activation'; popup.document.body.textContent = 'synchronous-document'; return `${popup !== null}|${__lmPopupDocument === popup.document}|${popup.__lmSynchronousRealmMarker}|${popup.document.body.textContent}|${popup.name === ''}|${popup.location.origin === location.origin}|${popup.document.referrer === location.href}|${popup.document.baseURI === document.baseURI}`; })()", + "returnByValue": true + } + })) + .await; + let open_response = take_response_by_id(&mut ctx, 1250); + assert_eq!( + open_response["result"]["result"]["value"], + json!("true|true|before-target-activation|synchronous-document|true|true|true|true") + ); + let popup_target_id = ctx + .sent + .iter() + .find(|message| message["method"] == json!("Target.targetCreated")) + .and_then(|message| message["params"]["targetInfo"]["targetId"].as_str()) + .expect("window.open should create its auxiliary target") + .to_owned(); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 1251, + "method": "Target.attachToTarget", + "params": { "targetId": popup_target_id } + })) + .await; + let popup_session_id = take_response_by_id(&mut ctx, 1251)["result"]["sessionId"] + .as_str() + .expect("popup session id") + .to_owned(); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 1252, + "sessionId": popup_session_id, + "method": "Runtime.evaluate", + "params": { + "expression": "const synchronousDocumentSurvived = window.opener.__lmPopupDocument === document; const synchronousRealmSurvived = __lmSynchronousRealmMarker === 'before-target-activation'; globalThis.__lmAuxiliaryRealmMarker = 'renderer-owned'; document.body.textContent = 'auxiliary-document'; `${synchronousDocumentSurvived}|${synchronousRealmSurvived}|${__lmAuxiliaryRealmMarker}|${document.body.textContent}|${window === globalThis}|${name === ''}|${document.referrer === window.opener.location.href}|${document.baseURI === window.opener.document.baseURI}`", + "returnByValue": true + } + })) + .await; + let popup_evaluate = take_response_by_id(&mut ctx, 1252); + assert_eq!( + popup_evaluate["result"]["result"]["value"], + json!("true|true|renderer-owned|auxiliary-document|true|true|true|true"), + "target adoption must retain the exact Document and realm created synchronously by window.open: {popup_evaluate:?}" + ); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 1253, + "method": "Runtime.evaluate", + "params": { + "expression": "`${__lmPopupDocument === __lmPopupWindow.document}|${__lmPopupWindow.__lmAuxiliaryRealmMarker}|${__lmPopupDocument.body.textContent}|${__lmPopupWindow === __lmPopupWindow.window}`", + "returnByValue": true + } + })) + .await; + let opener_projection = take_response_by_id(&mut ctx, 1253); + assert_eq!( + opener_projection["result"]["result"]["value"], + json!("true|renderer-owned|auxiliary-document|true"), + "the opener-retained objects must be the auxiliary Page's actual stable WindowProxy and Document: {opener_projection:?}" + ); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 1254, + "method": "Runtime.evaluate", + "params": { + "expression": "__lmPopupWindow.__lmProjectedFromOpener = 'same-proxy'; true", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 1254)["result"]["result"]["value"], + json!(true) + ); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 1255, + "sessionId": popup_session_id, + "method": "Runtime.evaluate", + "params": { + "expression": "String(globalThis.__lmProjectedFromOpener)", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 1255)["result"]["result"]["value"], + json!("same-proxy") + ); +} + #[tokio::test(flavor = "multi_thread")] async fn window_open_self_navigates_current_target_without_popup_target() { let mut ctx = TestContext::new(); @@ -3266,6 +3463,12 @@ async fn create_target_without_debugger_wait_starts_requested_url_navigation() { "Target.createTarget response should retain the created target id: {messages:?}" ); + ctx.wait_for_document_continuation_for_test( + Some(session_id), + "created target requested-URL Document continuation", + ) + .await; + ctx.process_async(json!({ "id": 9010, "sessionId": session_id, diff --git a/moli-protocol/src/lib.rs b/moli-protocol/src/lib.rs index c1b1020c59..a72172329f 100644 --- a/moli-protocol/src/lib.rs +++ b/moli-protocol/src/lib.rs @@ -18,10 +18,10 @@ pub mod version; pub use devtools_runtime::*; pub use conn::{ - BackgroundCommandResponsePayload, BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection, - CdpInitialStoragePartition, CdpRendererCommandAccess, CdpRendererCommandReplacement, - CdpRendererCommandReplayDispatch, CdpRendererOwnerTurnOutcome, CdpSchedulerEvent, - CdpTargetHostLifecycleDelta, CdpTargetHostLifecycleObserver, CdpTurnOutcome, + BackgroundCommandResponsePayload, BackgroundNavigationGateKey, BackgroundProtocolEvent, + CdpCommandTaskStep, CdpConnection, CdpInitialStoragePartition, CdpRendererCommandAccess, + CdpRendererCommandReplacement, CdpRendererCommandReplayDispatch, CdpRendererOwnerTurnOutcome, + CdpSchedulerEvent, CdpTargetHostLifecycleDelta, CdpTargetHostLifecycleObserver, CdpTurnOutcome, CommandDispatchContext, CommandResponseFlushContext, CommandResponseFlushPermit, CompletedCdpCommandDispatch, CompletedDeferredMainDocumentLoadCompletion, CompletedRuntimeProtocolMessageDispatch, DEFAULT_CDP_PAGE_TARGET_ID, DEFAULT_CDP_TAB_TARGET_ID, @@ -39,9 +39,10 @@ pub use domains::activity::{ RuntimeCommandOutputBarrierTerminal, RuntimeCommandOutputBarriers, }; pub use domains::page::{ - BackgroundNavigationCompletion, CompletedPageScreencastCapture, - PageScreencastCaptureCompletion, PageScreencastCaptureStart, PageScreencastRegistration, - PageScreencastSubscriptionStatus, PendingPageScreencastCapture, build_default_raster_pdf, + BackgroundDocumentContinuationCompletion, BackgroundNavigationCompletion, + CompletedPageScreencastCapture, PageScreencastCaptureCompletion, PageScreencastCaptureStart, + PageScreencastRegistration, PageScreencastSubscriptionStatus, PendingPageScreencastCapture, + build_default_raster_pdf, }; pub use domains::runtime::{ CompletedDevToolsRuntimeCommandDispatch, DevToolsRuntimeCommandTaskStep, diff --git a/moli-protocol/src/test_support.rs b/moli-protocol/src/test_support.rs index f147819a28..54fbcf5f20 100644 --- a/moli-protocol/src/test_support.rs +++ b/moli-protocol/src/test_support.rs @@ -9,11 +9,29 @@ use moli_core::{RendererDocumentLifecycleIdentity, RendererOutputResidenceIdentity}; use crate::{ - DeferredMainDocumentLoadCompletionOutputInterest, DeferredMainDocumentLoadObservationId, - ProtocolSchedulerWork, + BackgroundNavigationGateKey, DeferredMainDocumentLoadCompletionOutputInterest, + DeferredMainDocumentLoadObservationId, ProtocolSchedulerWork, conn::{CdpConnection, DocumentNavigationToken, RendererPageResidenceIdentity}, }; +/// Constructs the exact key carried by a synthetic Document-continuation +/// completion in scheduler-only tests. +pub fn background_navigation_gate_key( + target_id: Option, + session_id: Option, + frame_id: String, + loader_id: String, + navigation_request_id: Option, +) -> BackgroundNavigationGateKey { + BackgroundNavigationGateKey::from_test_parts( + target_id, + session_id, + frame_id, + loader_id, + navigation_request_id, + ) +} + /// Opaque exact-token fixture for scheduler tests that need a real /// target-owned background navigation request. pub struct BackgroundNavigationRequestFixture { diff --git a/moli-protocol/src/testing.rs b/moli-protocol/src/testing.rs index 977e3a02b1..d6190e4bad 100644 --- a/moli-protocol/src/testing.rs +++ b/moli-protocol/src/testing.rs @@ -4,7 +4,7 @@ //! focused assertions over emitted results, events, and errors. use serde_json::{Value, json}; -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::time::Duration; use super::conn::{ @@ -53,7 +53,15 @@ pub struct TestContext { tokio::sync::mpsc::UnboundedSender, background_navigation_completion_rx: tokio::sync::mpsc::UnboundedReceiver, + document_continuation_completion_tx: tokio::sync::mpsc::UnboundedSender< + crate::domains::page::BackgroundDocumentContinuationCompletion, + >, + document_continuation_completion_rx: tokio::sync::mpsc::UnboundedReceiver< + crate::domains::page::BackgroundDocumentContinuationCompletion, + >, + document_continuation_scheduler_explicitly_enabled: bool, background_navigation_scheduler_enabled: bool, + pending_document_continuation_gates: HashSet, } struct PendingTestRuntimeDeferredReply { @@ -209,8 +217,13 @@ impl TestContext { let (background_event_tx, background_event_rx) = tokio::sync::mpsc::unbounded_channel(); let (background_navigation_completion_tx, background_navigation_completion_rx) = tokio::sync::mpsc::unbounded_channel(); + let (document_continuation_completion_tx, document_continuation_completion_rx) = + tokio::sync::mpsc::unbounded_channel(); conn.set_renderer_publication_sender(renderer_publication_tx); conn.set_runtime_inspector_response_ready_sender(runtime_inspector_response_ready_tx); + conn.set_document_continuation_completion_sender( + document_continuation_completion_tx.clone(), + ); Self { conn, sent: Vec::new(), @@ -223,7 +236,11 @@ impl TestContext { background_event_rx, background_navigation_completion_tx, background_navigation_completion_rx, + document_continuation_completion_tx, + document_continuation_completion_rx, + document_continuation_scheduler_explicitly_enabled: false, background_navigation_scheduler_enabled: false, + pending_document_continuation_gates: HashSet::new(), } } @@ -239,6 +256,7 @@ impl TestContext { if self.background_navigation_scheduler_enabled { return; } + self.enable_document_continuation_scheduler_for_test(); self.conn .set_background_event_sender(self.background_event_tx.clone()); self.conn.set_background_navigation_completion_sender( @@ -247,6 +265,20 @@ impl TestContext { self.background_navigation_scheduler_enabled = true; } + /// Enables only the typed post-commit continuation lane. + /// + /// Most compatibility tests keep foreground navigation synchronous through + /// its first stable renderer-owner boundary. Tests that intentionally park + /// author script execution behind Debugger control need the command reply + /// to remain independently routable so the controlling command can resume + /// that same continuation. + pub(crate) fn enable_document_continuation_scheduler_for_test(&mut self) { + self.conn.set_document_continuation_completion_sender( + self.document_continuation_completion_tx.clone(), + ); + self.document_continuation_scheduler_explicitly_enabled = true; + } + /// Loads and installs one production-shaped navigation fixture for the /// exact Page target addressed by `session_id`. /// @@ -409,6 +441,39 @@ impl TestContext { let response_start = self.sent.len(); Box::pin(self.process_parsed_command_like_scheduler(&command, true)).await; Box::pin(self.wait_for_test_command_response(command_id, response_start)).await; + if !self.document_continuation_scheduler_explicitly_enabled + && test_command_starts_document_navigation(&command) + { + Box::pin(self.wait_for_document_continuation_for_test( + command.session_id(), + "foreground navigation command continuation", + )) + .await; + } + } + + #[cfg(test)] + pub(crate) async fn wait_for_document_continuation_for_test( + &mut self, + session_id: Option<&str>, + description: &str, + ) { + while self + .conn + .has_pending_document_navigation_for_session_owner(session_id) + || self + .pending_document_continuation_gates + .iter() + .any(|key| key.matches_session_owner(session_id)) + { + assert!( + matches!( + Box::pin(self.wait_for_one_test_scheduler_turn()).await, + TestSchedulerTurnOutcome::Processed(_) + ), + "test scheduler lost all input while waiting for {description}" + ); + } } #[cfg(test)] @@ -687,11 +752,106 @@ impl TestContext { } } + /// Mirrors the external-load wait used by the WebDriver adapters for one + /// protocol-neutral navigation command. + /// + /// A raw `CdpConnection` returns at the command boundary. The production + /// scheduler then waits for the exact target's typed Document continuation + /// and main-Document load residence before exposing a `wait: Load` result. + /// Direct-command tests must drive that same boundary rather than relying + /// on the renderer owner happening to finish first. + #[cfg(test)] + pub(crate) async fn wait_for_direct_navigation_load_for_test( + &mut self, + context: &crate::devtools_runtime::DevToolsCommandContext, + expected_loader_id: &str, + description: &str, + ) { + while self + .conn + .has_inflight_background_navigation_for_devtools_context(context) + || self + .pending_document_continuation_gates + .iter() + .any(|key| navigation_gate_matches_devtools_context(key, context)) + { + assert!( + matches!( + Box::pin(self.wait_for_one_test_scheduler_turn()).await, + TestSchedulerTurnOutcome::Processed(_) + ), + "test scheduler lost all external input while waiting for {description}" + ); + } + + let wait_key = self + .conn + .capture_devtools_document_lifecycle_wait_key( + context, + expected_loader_id, + moli_core::page::RendererDocumentLifecycleMilestone::Load, + ) + .unwrap_or_else(|| { + panic!( + "failed to capture the exact load lifecycle waiter for {description}; loader={expected_loader_id}" + ) + }); + let lifecycle_state = tokio::time::timeout(TEST_SCHEDULER_INPUT_TIMEOUT, async { + loop { + let state = self + .conn + .devtools_document_lifecycle_wait_state(context, &wait_key); + if state != crate::conn::DevToolsDocumentLifecycleWaitState::Pending { + return state; + } + assert!( + matches!( + Box::pin(self.wait_for_one_test_scheduler_turn()).await, + TestSchedulerTurnOutcome::Processed(_) + ), + "test scheduler lost all external input while waiting for {description}" + ); + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {description}")); + let _ = self + .conn + .release_devtools_document_lifecycle_wait_key(context, &wait_key); + assert_eq!( + lifecycle_state, + crate::conn::DevToolsDocumentLifecycleWaitState::Reached, + "exact load lifecycle did not reach its requested milestone for {description}" + ); + + let waited = tokio::time::timeout(TEST_SCHEDULER_INPUT_TIMEOUT, async { + while self.pending_protocol_scheduler_work.iter().any(|work| { + work.observes_main_document_load_for_devtools_context(&self.conn, context) + }) { + assert!( + matches!( + Box::pin(self.wait_for_one_test_scheduler_turn()).await, + TestSchedulerTurnOutcome::Processed(_) + ), + "test scheduler lost all external input while waiting for {description}" + ); + } + }) + .await; + if waited.is_err() { + panic!( + "timed out waiting for {description}; pending protocol work={:?}", + self.pending_protocol_scheduler_work + ); + } + } + async fn process_parsed_command_like_scheduler( &mut self, command: &ParsedCdpCommand, drain_after_command: bool, ) -> Vec { + Box::pin(self.wait_for_document_navigation_gate_before_command(command)).await; let output_session_id = command.command_output_session_id().map(str::to_owned); let mut protocol_events = Vec::new(); let mut scheduler_events = Vec::new(); @@ -767,6 +927,28 @@ impl TestContext { } } + async fn wait_for_document_navigation_gate_before_command( + &mut self, + command: &ParsedCdpCommand, + ) { + if !command.waits_for_document_continuation_to_finish() { + return; + } + while self + .pending_document_continuation_gates + .iter() + .any(|key| key.matches_session_owner(command.session_id())) + { + assert!( + matches!( + Box::pin(self.wait_for_one_test_scheduler_turn()).await, + TestSchedulerTurnOutcome::Processed(_) + ), + "CDP command waiting for the committed Document continuation lost all scheduler input" + ); + } + } + fn admit_runtime_output_barrier( &mut self, command: &ParsedCdpCommand, @@ -1174,6 +1356,12 @@ impl TestContext { Box::pin(self.route_protocol_events_like_scheduler(prefix, work)).await; } + if let Some(key) = completion.document_continuation_gate_key() { + assert!( + self.pending_document_continuation_gates.remove(&key), + "document-continuation completion must settle an admitted background navigation gate" + ); + } let outcome = self .conn .drain_background_navigation_completion_turn_async(completion) @@ -1187,8 +1375,8 @@ impl TestContext { renderer_output_predecessor, ) = outcome.into_renderer_owner_turn_parts(); assert!( - renderer_output_predecessor.is_none(), - "background navigation completion must use an insertion boundary" + renderer_output_boundary.is_none() || renderer_output_predecessor.is_none(), + "background navigation completion cannot carry both an insertion boundary and a continuation predecessor" ); if !completion_prefix.is_empty() { Box::pin(self.route_protocol_events_like_scheduler( @@ -1197,9 +1385,8 @@ impl TestContext { )) .await; } - if let Some(boundary) = renderer_output_boundary { - Box::pin(self.route_renderer_output_predecessor_before_command_response(boundary)) - .await; + if let Some(fence) = renderer_output_boundary.or(renderer_output_predecessor) { + Box::pin(self.route_renderer_output_predecessor_before_command_response(fence)).await; } completion_suffix.append(&mut post_response_events); while let Ok(event) = self.background_event_rx.try_recv() { @@ -1215,7 +1402,15 @@ impl TestContext { async fn run_one_ready_test_scheduler_turn(&mut self) -> TestSchedulerTurnOutcome { let mut work = VecDeque::new(); - let input_kind = if self.background_navigation_scheduler_enabled + let input_kind = if let Ok(completion) = self.document_continuation_completion_rx.try_recv() + { + work.push_back(TestSchedulerWork::BackgroundNavigationCompletion( + crate::domains::page::BackgroundNavigationCompletion::document_continuation( + completion, + ), + )); + TestSchedulerInputKind::BackgroundNavigationCompletion + } else if self.background_navigation_scheduler_enabled && let Ok(completion) = self.background_navigation_completion_rx.try_recv() { work.push_back(TestSchedulerWork::BackgroundNavigationCompletion( @@ -1325,6 +1520,15 @@ impl TestContext { work.push_back(TestSchedulerWork::RuntimeDeferredReplyReady(response)); TestSchedulerInputKind::RuntimeDeferredReply } + maybe_continuation = self.document_continuation_completion_rx.recv() => { + let Some(completion) = maybe_continuation else { + return TestSchedulerTurnOutcome::Idle; + }; + work.push_back(TestSchedulerWork::BackgroundNavigationCompletion( + crate::domains::page::BackgroundNavigationCompletion::document_continuation(completion), + )); + TestSchedulerInputKind::BackgroundNavigationCompletion + } maybe_completion = self.background_navigation_completion_rx.recv(), if background_navigation_scheduler_enabled => { let Some(completion) = maybe_completion else { return TestSchedulerTurnOutcome::Idle; @@ -1350,6 +1554,15 @@ impl TestContext { } else { tokio::select! { biased; + maybe_continuation = self.document_continuation_completion_rx.recv() => { + let Some(completion) = maybe_continuation else { + return TestSchedulerTurnOutcome::Idle; + }; + work.push_back(TestSchedulerWork::BackgroundNavigationCompletion( + crate::domains::page::BackgroundNavigationCompletion::document_continuation(completion), + )); + TestSchedulerInputKind::BackgroundNavigationCompletion + } maybe_completion = self.background_navigation_completion_rx.recv(), if background_navigation_scheduler_enabled => { let Some(completion) = maybe_completion else { return TestSchedulerTurnOutcome::Idle; @@ -1485,7 +1698,17 @@ impl TestContext { work: &mut VecDeque, ) { let mut queue = VecDeque::new(); - enqueue_scheduler_events_like_scheduler(&mut queue, scheduler_events); + for event in scheduler_events { + match event { + CdpSchedulerEvent::DocumentContinuationStarted { key } => { + self.pending_document_continuation_gates.insert(key); + } + CdpSchedulerEvent::ProtocolWorkPublished { work } => { + queue.push_back(TestDeferredSchedulerWork(work)); + } + CdpSchedulerEvent::PageScreencastStarted { .. } => {} + } + } while let Some(TestDeferredSchedulerWork(protocol_work)) = queue.pop_front() { self.pending_protocol_scheduler_work .push_back(protocol_work); @@ -1501,8 +1724,16 @@ impl TestContext { work: &mut VecDeque, ) { loop { + let can_execute = |work: &ProtocolSchedulerWork| { + self.background_navigation_scheduler_enabled + || !work.requires_background_navigation_scheduler() + }; + let front_is_fixture_parked = self + .pending_protocol_scheduler_work + .front() + .is_some_and(|work| work.is_ready() && !can_execute(work)); let selected_index = match self.pending_protocol_scheduler_work.front() { - Some(front) if front.is_ready() => 0, + Some(front) if front.is_ready() && can_execute(front) => 0, Some(front) if front.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction => { @@ -1511,36 +1742,51 @@ impl TestContext { .iter() .position(|candidate| { candidate.is_ready() - && candidate.is_top_level_location_navigation_owner_action() + && can_execute(candidate) + && (candidate.is_top_level_location_navigation_owner_action() + || candidate.is_command_followup()) }) else { return; }; // Production checks the pending load observer out of the - // FIFO while it waits for the renderer. An unconstrained - // location owner action may then run and replace that - // exact source Document, which completes the observer as - // Superseded. Keeping the pending observer at the front in - // this protocol-only harness would deadlock the action - // needed to make it terminal. + // FIFO while it waits for the renderer. A navigation owner + // action may then replace that exact source Document and + // make the observer terminal. index } - Some(_) | None => return, + Some(_) if front_is_fixture_parked => { + let Some(index) = self + .pending_protocol_scheduler_work + .iter() + .position(|candidate| candidate.is_ready() && can_execute(candidate)) + else { + return; + }; + index + } + Some(_) => { + let Some(index) = + self.pending_protocol_scheduler_work + .iter() + .position(|candidate| { + candidate.is_ready() + && can_execute(candidate) + && candidate.is_command_followup() + }) + else { + return; + }; + index + } + None => return, }; - if !self.background_navigation_scheduler_enabled - && self - .pending_protocol_scheduler_work - .get(selected_index) - .is_some_and(ProtocolSchedulerWork::requires_background_navigation_scheduler) - { - // The default protocol fixture has no owner task lane. Keep - // independent popup navigation resident rather than invoking - // the production function's synchronous fallback while the - // exact renderer cursor is still being projected. Tests that - // assert navigation progress opt into the production-shaped - // background scheduler and drive its typed completions. - return; - } + // The default protocol fixture has no owner task lane. Keep an + // independent popup navigation resident instead of invoking its + // synchronous fallback while the opener cursor is projected, but + // do not let that fixture-only limitation block ready work for a + // different target. Tests that assert popup navigation progress + // opt into the production-shaped background scheduler. let protocol_work = self .pending_protocol_scheduler_work .remove(selected_index) @@ -1830,6 +2076,29 @@ impl TestContext { } } +#[cfg(test)] +fn navigation_gate_matches_devtools_context( + key: &super::conn::BackgroundNavigationGateKey, + context: &crate::devtools_runtime::DevToolsCommandContext, +) -> bool { + if let Some(target_id) = context.target_id.as_ref() { + return key.target_id() == Some(target_id.as_str()); + } + key.matches_session_owner( + context + .session_id + .as_ref() + .map(|session_id| session_id.as_str()), + ) +} + +fn test_command_starts_document_navigation(command: &ParsedCdpCommand) -> bool { + matches!( + command.method(), + "Page.navigate" | "Page.reload" | "Page.navigateToHistoryEntry" + ) +} + fn protocol_events_into_messages(events: Vec) -> Vec { events .into_iter() @@ -1925,6 +2194,7 @@ fn enqueue_scheduler_events_like_scheduler( queue.push_back(TestDeferredSchedulerWork(work)); } CdpSchedulerEvent::PageScreencastStarted { .. } => {} + CdpSchedulerEvent::DocumentContinuationStarted { key: _ } => {} } } } @@ -2150,8 +2420,13 @@ mod tests { let (background_event_tx, background_event_rx) = tokio::sync::mpsc::unbounded_channel(); let (background_navigation_completion_tx, background_navigation_completion_rx) = tokio::sync::mpsc::unbounded_channel(); + let (document_continuation_completion_tx, document_continuation_completion_rx) = + tokio::sync::mpsc::unbounded_channel(); conn.set_renderer_publication_sender(publication_tx.clone()); conn.set_runtime_inspector_response_ready_sender(runtime_response_tx); + conn.set_document_continuation_completion_sender( + document_continuation_completion_tx.clone(), + ); let mut ctx = TestContext { conn, sent: Vec::new(), @@ -2164,7 +2439,11 @@ mod tests { background_event_rx, background_navigation_completion_tx, background_navigation_completion_rx, + document_continuation_completion_tx, + document_continuation_completion_rx, + document_continuation_scheduler_explicitly_enabled: false, background_navigation_scheduler_enabled: false, + pending_document_continuation_gates: HashSet::new(), }; let opened = |page_id| { RendererOutputTransportMessage::from(RendererOutputStreamControl::Opened { diff --git a/moli-renderer-v8/src/browsing_context_model.rs b/moli-renderer-v8/src/browsing_context_model.rs index 6a885f011a..1988873c13 100644 --- a/moli-renderer-v8/src/browsing_context_model.rs +++ b/moli-renderer-v8/src/browsing_context_model.rs @@ -28,10 +28,10 @@ pub(crate) struct BrowsingContextId { /// Stable identity of the V8/script execution agent hosting one or more realms. /// -/// The current production policy assigns one agent to each concurrently live -/// top-level Page, while nested realms and same-Page navigation generations -/// reuse that agent. Related auxiliary Pages may share an id only after the -/// selective shared-agent admission path is implemented. +/// Fresh top-level Pages receive a new agent, while nested realms and +/// same-Page navigation generations reuse their Page's agent. An explicitly +/// related auxiliary Page may join its opener's agent; unrelated and +/// opener-suppressed Pages stay isolated. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub(crate) struct ScriptAgentId(u64); @@ -47,13 +47,9 @@ impl ScriptAgentId { } /// Admission scope currently retaining a script agent. -/// -/// `RelatedPageGroup` is used only by the Phase 2 feasibility path until -/// auxiliary browsing-context admission becomes a production policy. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ScriptAgentScope { PageScriptEnvironment, - #[cfg(test)] RelatedPageGroup, } @@ -61,7 +57,6 @@ impl ScriptAgentScope { pub(crate) const fn as_str(self) -> &'static str { match self { Self::PageScriptEnvironment => "page-script-environment", - #[cfg(test)] Self::RelatedPageGroup => "related-page-group", } } @@ -83,7 +78,6 @@ impl BrowsingContextId { } } - #[allow(dead_code)] pub(crate) const fn auxiliary_top_level(value: u64) -> Self { assert!(value != 0, "auxiliary browsing-context id must be non-zero"); Self { @@ -96,7 +90,6 @@ impl BrowsingContextId { self.kind } - #[cfg(test)] pub(crate) const fn value(self) -> u64 { self.value } diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 78e31ca250..534b338b5d 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -353,11 +353,13 @@ use self::range_live::{ update_live_ranges_for_text_split, }; pub(super) use self::runtime_state::finish_context_bootstrap; +pub(crate) use self::runtime_state::inherit_auxiliary_window_viewport_surface; pub(crate) use self::runtime_state::install_child_window_eval_runtime_state; pub(crate) use self::runtime_state::install_webassembly_runtime_state; #[cfg(feature = "wpt-extensions")] pub(crate) use self::runtime_state::install_wpt_webdriver_runtime_state; pub(crate) use self::runtime_state::set_window_origin_runtime_state; +pub(crate) use self::runtime_state::window_origin_runtime_state; pub(crate) use self::runtime_state::{ ORIGINAL_WEBASSEMBLY_COMPILE_ERROR_CONSTRUCTOR_SLOT, ORIGINAL_WEBASSEMBLY_GLOBAL_VALUE_GETTER_SLOT, ORIGINAL_WEBASSEMBLY_INSTANCE_CONSTRUCTOR_SLOT, @@ -371,7 +373,7 @@ pub(in crate::context_bootstrap) use self::shared::*; pub(crate) use self::shared::{ CHILD_BROWSING_CONTEXT_HANDLE_SLOT, DOCUMENT_SELECTION_CHANGE_LISTENER_SLOT, READABLE_STREAM_CHILD_REALM_HANDLED_REJECTION_SLOT, WINDOW_CUSTOM_ELEMENTS_SLOT, - WINDOW_NAME_SLOT, + WINDOW_NAME_SLOT, WINDOW_OPENER_SLOT, }; pub(crate) use self::shared::{ RuntimeMessageSourceSecurity, current_runtime_message_agent_cluster, diff --git a/moli-renderer-v8/src/context_bootstrap/history_mutation.rs b/moli-renderer-v8/src/context_bootstrap/history_mutation.rs index 5c062e9868..dc2f7b5760 100644 --- a/moli-renderer-v8/src/context_bootstrap/history_mutation.rs +++ b/moli-renderer-v8/src/context_bootstrap/history_mutation.rs @@ -23,8 +23,8 @@ use super::navigation_result::{ }; use super::navigation_serialize::sync_child_navigation_entry_seed_from_owner; use super::navigation_window::{ - runtime_window_is_global, runtime_window_owner, window_location_for_holder, - window_navigation_for_holder, + navigation_document_is_initial_empty, runtime_window_is_global, runtime_window_owner, + url_is_about_blank_document, window_location_for_holder, window_navigation_for_holder, }; use super::*; use crate::webidl; @@ -140,7 +140,7 @@ fn mutate_history_object<'s>( ); return; }; - if !moli_url::same_origin(&url, ¤t_url) { + if !history_target_is_same_origin(scope, owner, ¤t_href, ¤t_url, &url) { throw_history_security_error( scope, "Failed to execute 'pushState' or 'replaceState' on 'History': A history state object with URL of a different origin cannot be created in a document with origin.", @@ -178,6 +178,18 @@ fn mutate_history_object<'s>( navigate_outcome = Some(outcome); } + // The initial empty Document remains initial across same-document URL + // mutations. The URL and history update steps therefore convert pushState + // to replacement until a different Document commits (or document.open() + // explicitly exits the initial state). + let kind = if matches!(kind, HistoryMutationKind::Push) + && navigation_document_is_initial_empty(scope, owner) + { + HistoryMutationKind::Replace + } else { + kind + }; + let entries = history_entries(scope, history).unwrap_or_else(|| v8::Array::new(scope, 0)); let current_index = history_index(scope, history); let current_navigation_index = navigation_current_entry_index(scope, owner).unwrap_or(0); @@ -350,18 +362,49 @@ fn history_same_origin_reference_url<'s>( if history_url_inherits_origin(scope, owner, current_href) && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { - return Ok(unsafe { &mut *host_ptr }.host_document().url().clone()); + let host = unsafe { &mut *host_ptr }; + return Ok(host + .dom_host() + .document_base_url() + .unwrap_or_else(|| host.host_document().url().clone())); } url::Url::parse(current_href) } +fn history_target_is_same_origin<'s>( + scope: &mut v8::PinScope<'s, '_>, + owner: v8::Local<'s, v8::Object>, + current_href: &str, + current_url: &url::Url, + target_url: &url::Url, +) -> bool { + if !history_url_inherits_origin(scope, owner, current_href) { + return moli_url::same_origin(target_url, current_url); + } + let Some(current_origin) = window_origin_runtime_state(scope, owner) else { + return moli_url::same_origin(target_url, current_url); + }; + if url_is_about_blank_document(target_url) { + // An initial about:blank Document and its same-document URL variants + // retain the creator origin, including a shared opaque identity that + // cannot be reconstructed from either serialized URL. + return true; + } + moli_url::origin_ascii_serialization(target_url) == current_origin +} + fn history_url_inherits_origin<'s>( scope: &mut v8::PinScope<'s, '_>, owner: v8::Local<'s, v8::Object>, current_href: &str, ) -> bool { - !runtime_window_is_global(scope, owner) - && matches!(current_href, "about:blank" | "about:srcdoc") + let current_url_inherits_origin = current_href == "about:srcdoc" + || url::Url::parse(current_href) + .ok() + .is_some_and(|url| url_is_about_blank_document(&url)); + current_url_inherits_origin + && (!runtime_window_is_global(scope, owner) + || navigation_document_is_initial_empty(scope, owner)) } fn throw_history_security_error(scope: &mut v8::PinScope<'_, '_>, message: &str) { diff --git a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs index 2cb92c0e0b..059dfe9c7b 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs @@ -36,9 +36,10 @@ use super::navigation_seed::history_entry_seed_for_reload; use super::navigation_serialize::serialize_history_entries; use super::navigation_window::{ child_browsing_context_handle_for_runtime_owner, navigation_document_can_update_current_entry, - navigation_document_has_opaque_origin, navigation_unload_event_active, - runtime_window_is_global, runtime_window_owner, runtime_window_uses_top_level_history_model, - url_is_about_blank_document, window_history_for_holder, window_location_for_holder, + navigation_document_has_opaque_origin, navigation_document_is_initial_empty, + navigation_unload_event_active, runtime_window_is_global, runtime_window_owner, + runtime_window_uses_top_level_history_model, url_is_about_blank_document, + window_history_for_holder, window_location_for_holder, }; use super::*; use crate::native_bridge::NavigationHistoryEntrySeed; @@ -305,6 +306,11 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( super::navigation_window::window_navigation_for_holder(scope, owner) }; let effective_kind = match kind { + LocationNavigationKind::Assign + if navigation_document_is_initial_empty(scope, owner) => + { + LocationNavigationKind::Replace + } LocationNavigationKind::Assign if source_element.is_some() && exact_same_href => { LocationNavigationKind::Replace } @@ -1193,6 +1199,9 @@ fn history_entry_seed_for_cross_document_location<'s>( let current_index = history_index(scope, history); let current_navigation_index = navigation_current_entry_index(scope, owner).unwrap_or(0); let mutation = match kind { + LocationNavigationKind::Assign if navigation_document_is_initial_empty(scope, owner) => { + NavigationHistoryMutation::Replace + } LocationNavigationKind::Assign => NavigationHistoryMutation::Push, LocationNavigationKind::Replace => NavigationHistoryMutation::Replace, LocationNavigationKind::Reload => return None, diff --git a/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs b/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs index 3b5954a28f..251443b19d 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs @@ -1,6 +1,7 @@ use super::super::navigation_window::{ navigation_document_has_opaque_origin, runtime_window_owner, }; +use super::super::window_origin_runtime_state; use super::helpers::{ location_host_string, navigate_modified_location_url, parsed_location_url, require_location_href_slot, set_return_string, v8_value_to_string, @@ -355,7 +356,14 @@ fn location_attribute_getter<'s>( "null".to_owned() } else { parsed_location_url(scope, holder) - .map(|url| moli_url::origin_ascii_serialization(&url)) + .map(|url| { + if moli_url::is_about_blank(&url) { + window_origin_runtime_state(scope, owner) + .unwrap_or_else(|| moli_url::origin_ascii_serialization(&url)) + } else { + moli_url::origin_ascii_serialization(&url) + } + }) .unwrap_or_default() }; set_return_string(scope, rv, &origin); diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_cross_document.rs b/moli-renderer-v8/src/context_bootstrap/navigation_cross_document.rs index fa0787e0e1..aee10b5a28 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_cross_document.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_cross_document.rs @@ -15,8 +15,8 @@ use super::navigation_serialize::{ serialize_navigation_entry_object, }; use super::navigation_window::{ - child_browsing_context_handle_for_runtime_owner, runtime_window_is_global, - window_location_for_holder, + child_browsing_context_handle_for_runtime_owner, navigation_document_is_initial_empty, + runtime_window_is_global, window_location_for_holder, }; use super::*; use moli_page_types::{NavigationHistoryMutation, cross_document_navigation_seed}; @@ -53,11 +53,17 @@ pub(super) fn handle_navigation_navigate_cross_document<'s>( .iter() .find(|entry| entry.history_index == current_index) .is_some_and(|entry| entry.url == "about:blank"); + let replace_initial_empty_document = navigation_document_is_initial_empty(scope, owner); let mutation = match navigate_history_kind { + NavigationNavigateHistoryKind::Push if replace_initial_empty_document => { + NavigationHistoryMutation::Replace + } NavigationNavigateHistoryKind::Push => NavigationHistoryMutation::Push, NavigationNavigateHistoryKind::Replace => NavigationHistoryMutation::Replace, NavigationNavigateHistoryKind::Default => { - if current_href.as_deref() == Some("about:blank") && current_entry_is_about_blank { + if replace_initial_empty_document + || (current_href.as_deref() == Some("about:blank") && current_entry_is_about_blank) + { NavigationHistoryMutation::Replace } else { NavigationHistoryMutation::Push diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_mutation.rs b/moli-renderer-v8/src/context_bootstrap/navigation_mutation.rs index 3ecf9e8e59..19ce5f8c9a 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_mutation.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_mutation.rs @@ -29,8 +29,8 @@ use super::navigation_serialize::{ sync_child_pending_navigation_entry_seed_from_owner, }; use super::navigation_window::{ - runtime_top_window_owner, runtime_window_is_global, window_history_for_holder, - window_location_for_holder, window_navigation_for_holder, + navigation_document_is_initial_empty, runtime_top_window_owner, runtime_window_is_global, + window_history_for_holder, window_location_for_holder, window_navigation_for_holder, }; use super::*; use crate::native_bridge::NavigationActivationSeed; diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_mutation/same_document.rs b/moli-renderer-v8/src/context_bootstrap/navigation_mutation/same_document.rs index 2fb2fc86d8..b748d15223 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_mutation/same_document.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_mutation/same_document.rs @@ -4,8 +4,13 @@ pub(in crate::context_bootstrap) fn update_navigation_current_entry_for_same_doc scope: &mut v8::PinScope<'s, '_>, owner: v8::Local<'s, v8::Object>, href: &str, - kind: LocationNavigationKind, + mut kind: LocationNavigationKind, ) { + if matches!(kind, LocationNavigationKind::Assign) + && navigation_document_is_initial_empty(scope, owner) + { + kind = LocationNavigationKind::Replace; + } let Some(history) = window_history_for_holder(scope, owner) else { return; }; @@ -130,9 +135,14 @@ pub(in crate::context_bootstrap) fn apply_navigation_navigate_same_document<'s>( scope: &mut v8::PinScope<'s, '_>, owner: v8::Local<'s, v8::Object>, href: &str, - kind: LocationNavigationKind, + mut kind: LocationNavigationKind, navigation_state: Option>, ) { + if matches!(kind, LocationNavigationKind::Assign) + && navigation_document_is_initial_empty(scope, owner) + { + kind = LocationNavigationKind::Replace; + } let Some(history) = window_history_for_holder(scope, owner) else { return; }; diff --git a/moli-renderer-v8/src/context_bootstrap/navigation_window.rs b/moli-renderer-v8/src/context_bootstrap/navigation_window.rs index 656910275c..065ef12b8e 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigation_window.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigation_window.rs @@ -44,6 +44,15 @@ pub(super) fn runtime_window_is_global<'s>( } } +pub(super) fn navigation_document_is_initial_empty<'s>( + scope: &mut v8::PinScope<'s, '_>, + owner: v8::Local<'s, v8::Object>, +) -> bool { + runtime_window_is_global(scope, owner) + && context_host_ptr_from_global_bridge(scope) + .is_some_and(|host_ptr| unsafe { &*host_ptr }.root_document_is_initial_empty()) +} + pub(super) fn runtime_window_uses_top_level_history_model<'s>( scope: &mut v8::PinScope<'s, '_>, window: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs index 44d571af80..d3dbb7a68e 100644 --- a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs +++ b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs @@ -60,6 +60,13 @@ pub(crate) const ORIGINAL_WEBASSEMBLY_GLOBAL_VALUE_GETTER_SLOT: &str = const WINDOW_INDEXED_DB_SURFACE_SLOT: &str = "moli.Window.indexedDB"; const WINDOW_ORIGIN_RUNTIME_SLOT: &str = "__moliWindowOriginRuntime"; const WINDOW_INTRINSIC_EVAL_SLOT: &str = "__moliWindowIntrinsicEval"; +const AUXILIARY_WINDOW_VIEWPORT_SURFACE_PROPERTIES: &[&str] = &[ + "innerWidth", + "innerHeight", + "outerWidth", + "outerHeight", + "devicePixelRatio", +]; pub(in crate::context_bootstrap) const WINDOW_SECURE_CONTEXT_AVAILABLE_SLOT: &str = "__moliWindowSecureContextAvailable"; @@ -2060,6 +2067,30 @@ pub(crate) fn set_window_origin_runtime_state( Ok(()) } +pub(crate) fn inherit_auxiliary_window_viewport_surface<'s>( + scope: &mut v8::PinScope<'s, '_>, + opener: v8::Local<'s, v8::Object>, + window: v8::Local<'s, v8::Object>, +) { + for property in AUXILIARY_WINDOW_VIEWPORT_SURFACE_PROPERTIES { + let Some(value) = opener.get(scope, v8str(scope, property).into()) else { + continue; + }; + if value.is_number() { + native_bridge::set_object_slot(scope, window, property, value); + } + } +} + +pub(crate) fn window_origin_runtime_state<'s>( + scope: &mut v8::PinScope<'s, '_>, + window: v8::Local<'s, v8::Object>, +) -> Option { + get_private_value(scope, window, WINDOW_ORIGIN_RUNTIME_SLOT) + .and_then(|value| value.to_string(scope)) + .map(|value| value.to_rust_string_lossy(scope)) +} + pub(crate) fn install_webassembly_runtime_state<'s>( scope: &mut v8::PinScope<'s, '_>, global: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/context_bootstrap/shared/slots.rs b/moli-renderer-v8/src/context_bootstrap/shared/slots.rs index 92186b1b37..062d543afd 100644 --- a/moli-renderer-v8/src/context_bootstrap/shared/slots.rs +++ b/moli-renderer-v8/src/context_bootstrap/shared/slots.rs @@ -30,6 +30,7 @@ pub(in crate::context_bootstrap) const WINDOW_SESSION_STORAGE_SLOT: &str = "__moliWindowSessionStorage"; pub(crate) const WINDOW_CUSTOM_ELEMENTS_SLOT: &str = "__moliWindowCustomElements"; pub(crate) const WINDOW_NAME_SLOT: &str = "__moliWindowName"; +pub(crate) const WINDOW_OPENER_SLOT: &str = "__moliWindowOpener"; pub(crate) const CHILD_BROWSING_CONTEXT_HANDLE_SLOT: &str = "__moliChildBrowsingContextHandle"; pub(in crate::context_bootstrap) const WINDOW_SELF_SLOT: &str = "__moliWindowSelf"; pub(in crate::context_bootstrap) const WINDOW_PARENT_SLOT: &str = "__moliWindowParent"; diff --git a/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs b/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs index 9fd97a50da..14b33c312f 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_accessors/surface.rs @@ -104,9 +104,13 @@ pub(in crate::context_bootstrap) fn window_opener_getter<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { - if window_receiver(scope, &args).is_some() { - rv.set_null(); - } + let Some(receiver) = window_receiver(scope, &args) else { + return; + }; + rv.set( + window_hidden_value(scope, receiver, WINDOW_OPENER_SLOT) + .unwrap_or_else(|| v8::null(scope).into()), + ); } pub(in crate::context_bootstrap) fn window_inner_width_getter<'s>( diff --git a/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs b/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs index 507300f34b..caed56179a 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs @@ -244,14 +244,21 @@ pub(crate) fn window_open_callback<'s>( opener_child_handle, &parsed.target_name, &url, + Some(!suppress_opener), entered_base_url, creator_policy_container, ) { let popup_id = opened_popup.popup_id; - let session_storage_store = host.lightweight_popup_session_storage_store(popup_id); - let initial_empty_document_storage_key = - host.lightweight_popup_initial_empty_document_storage_key(popup_id); + let session_storage_store = opened_popup + .captured_session_storage_store + .clone() + .or_else(|| host.lightweight_popup_session_storage_store(popup_id)); + let initial_empty_document_storage_key = opened_popup + .captured_initial_empty_document_storage_key + .clone() + .or_else(|| host.lightweight_popup_initial_empty_document_storage_key(popup_id)); + let pending_auxiliary_page = opened_popup.pending_auxiliary_page; let window_open_event = opened_popup .created_new_browsing_context .then_some(window_open_event); @@ -265,10 +272,8 @@ pub(crate) fn window_open_callback<'s>( parsed.target_name, popup_disposition, ) - .with_initial_auxiliary_state( - session_storage_store, - initial_empty_document_storage_key, - ), + .with_initial_auxiliary_state(session_storage_store, initial_empty_document_storage_key) + .with_pending_auxiliary_page(pending_auxiliary_page), window_open_event, ); if suppress_opener { diff --git a/moli-renderer-v8/src/document_runtime/security_policy.rs b/moli-renderer-v8/src/document_runtime/security_policy.rs index 616d6624cd..7bff49258b 100644 --- a/moli-renderer-v8/src/document_runtime/security_policy.rs +++ b/moli-renderer-v8/src/document_runtime/security_policy.rs @@ -503,6 +503,15 @@ impl DocumentRuntime { self.policy_container = policy; } + /// Installs the creator-derived policy state for a real initial-empty + /// Document before its realm becomes observable to author code. + pub(crate) fn set_initial_document_policy_container( + &mut self, + policy_container: DocumentPolicyContainer, + ) { + self.policy_container = policy_container; + } + pub(crate) fn set_bypass_content_security_policy(&mut self, bypass: bool) { self.bypass_content_security_policy = bypass; } diff --git a/moli-renderer-v8/src/host_bindings.rs b/moli-renderer-v8/src/host_bindings.rs index 4dc83b4703..c88d8b387e 100644 --- a/moli-renderer-v8/src/host_bindings.rs +++ b/moli-renderer-v8/src/host_bindings.rs @@ -310,12 +310,16 @@ fn host_lightweight_popup_id_for_object_callback( args: v8::FunctionCallbackArguments<'_>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + let Ok(host_ptr) = context_host_ptr_from_callback_data(args.data()) else { + rv.set_null(); + return; + }; let Some(object) = args.get(0).to_object(scope) else { rv.set_null(); return; }; - let Some(popup_id) = crate::native_bridge::lightweight_popup_id_from_window(scope, object) - else { + let host = unsafe { &mut *host_ptr }; + let Some(popup_id) = host.lightweight_popup_id_for_window_proxy(scope, object) else { rv.set_null(); return; }; diff --git a/moli-renderer-v8/src/lib.rs b/moli-renderer-v8/src/lib.rs index 3a53ee39d8..ba912880cf 100644 --- a/moli-renderer-v8/src/lib.rs +++ b/moli-renderer-v8/src/lib.rs @@ -231,6 +231,7 @@ pub use runtime::{ RendererDevToolsAgentToken, RendererDevToolsMainCommandEnvelope, RendererDocumentBoxModel, RendererDocumentChildNodeSnapshotEvent, RendererDocumentChildNodeSnapshotEvents, RendererDocumentChildNodeSnapshots, RendererDocumentCommitPermit, + RendererDocumentContinuationCompletion, RendererDocumentContinuationObserver, RendererDocumentFrontendNodeIdsResolution, RendererDocumentHitTestResult, RendererDocumentIsolateAccountingDiagnostics, RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, RendererDocumentLifecycleIdentity, @@ -276,8 +277,10 @@ pub use runtime::{ RendererPageCookieFacadeSnapshotReply, RendererPageCreationArtifacts, RendererPageCreationDiagnostics, RendererPageCreationPhase, RendererPageCreationProgress, RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, RendererPageDumpOptions, - RendererPageDumpStripOptions, RendererPageHandle, RendererPageReply, - RendererPageReservationToken, RendererPageState, RendererPageTestingHandle, RendererPageView, + RendererPageDumpStripOptions, RendererPageHandle, RendererPageReplacementCommit, + RendererPageReplacementCommitError, RendererPageReplacementCommitFailureDisposition, + RendererPageReplacementReservationPending, RendererPageReply, RendererPageReservationToken, + RendererPageState, RendererPageTestingHandle, RendererPageView, RendererPendingAuxiliaryPage, RendererPendingDownloadActivation, RendererPendingDownloadResponse, RendererPendingFileChooserActivation, RendererPendingJavaScriptDialog, RendererPendingPopupActivation, RendererPendingSameDocumentNavigation, diff --git a/moli-renderer-v8/src/native_bridge/bindings.rs b/moli-renderer-v8/src/native_bridge/bindings.rs index 93d741b491..78721daf6d 100644 --- a/moli-renderer-v8/src/native_bridge/bindings.rs +++ b/moli-renderer-v8/src/native_bridge/bindings.rs @@ -190,6 +190,26 @@ impl NativeBridgeBindings { v8::Local::new(scope, &self.window_global_template) } + /// Builds per-Context bridge bindings while the owning isolate is already + /// entered by an outer native callback. + /// + /// Related auxiliary Pages share the opener's script-agent isolate but + /// require independent wrapper templates for their own `JsContextHost`. + /// Reusing only the isolate-level Window templates is intentional; every + /// mutable bridge/cache object below that boundary is rebuilt per realm. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn build_peer_in_scope(&self, scope: &mut v8::PinScope<'_, '_>) -> Self { + let window_global_template = v8::Local::new(scope, &self.window_global_template); + let cross_origin_window_global_template = + v8::Local::new(scope, &self.cross_origin_window_global_template); + Self::build( + scope, + self.isolate_ptr, + window_global_template, + cross_origin_window_global_template, + ) + } + /// Install the native bridge global object with two internal fields: /// - field 0: `*mut JsContextHost` (for `runtime_ptr_from_object` compatibility) /// - field 1: `*const RefCell` from the per-context bridge-ref token @@ -353,6 +373,40 @@ impl NativeBridgeBindings { (global, v8::Global::new(scope, context)) } + pub(super) fn instantiate_same_origin_window_proxy_shell<'s, 'i>( + &mut self, + scope: &mut v8::PinScope<'s, 'i>, + host_ptr: *mut JsContextHost, + ) -> (v8::Local<'s, v8::Object>, v8::Global) { + // An accepted same-agent popup needs a real V8 global proxy before + // protocol has materialized its auxiliary Page. A temporary context + // owns that proxy synchronously; the related Page later detaches it + // and supplies the exact object as ContextOptions::global_object. + let global_template = v8::Local::new(scope, &self.window_global_template); + let parent_security_token = scope.get_current_context().get_security_token(scope); + let context = v8::Context::new( + scope, + v8::ContextOptions { + global_template: Some(global_template), + ..Default::default() + }, + ); + context.set_security_token(parent_security_token); + crate::util::install_context_host_pointer_slot(context, host_ptr); + { + let facade_scope = &mut v8::ContextScope::new(scope, context); + let global = context.global(facade_scope); + crate::context_bootstrap::exposed_interfaces::capture_eager_intrinsic_interfaces( + facade_scope, + global, + crate::context_bootstrap::exposed_interfaces::RealmKind::Window, + ) + .expect("same-origin WindowProxy facade must initialize intrinsic interfaces"); + } + let global = context.global(scope); + (global, v8::Global::new(scope, context)) + } + pub(super) fn attach_window_proxy_shell_to_facade<'s, 'i>( &self, scope: &mut v8::PinScope<'s, 'i, ()>, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs index 36eff2f902..b6d39b0ccd 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window.rs @@ -350,7 +350,7 @@ impl ChildWindowProxyRecords { ) -> bool { // A replacement Document reuses the browsing-context handle. Its context // binding must survive delayed retirement of the previous LocalWindow. - let Some(record) = self.records.get_mut(&handle) else { + let Some(record) = self.existing_record_mut(handle) else { return false; }; if record.default_execution_context_id != Some(expected_execution_context_id) { diff --git a/moli-renderer-v8/src/native_bridge/context_host/core.rs b/moli-renderer-v8/src/native_bridge/context_host/core.rs index a4d060102d..978a7232c1 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/core.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/core.rs @@ -76,6 +76,78 @@ impl JsContextHost { } } + pub(crate) fn bind_page_script_environment( + &mut self, + environment: crate::script_vm::RendererPageScriptEnvironment, + ) { + assert!( + self.page_script_environment.is_none(), + "context host Page script environment must be bound only once" + ); + self.bind_auxiliary_page_reservation_allocator( + environment.auxiliary_page_reservation_allocator(), + ); + self.page_script_environment = Some(environment); + } + + pub(crate) fn bind_auxiliary_page_reservation_allocator( + &mut self, + allocator: RendererAuxiliaryPageReservationAllocator, + ) { + assert!( + self.auxiliary_page_reservation_allocator + .replace(allocator) + .is_none(), + "context host auxiliary Page allocator must be bound only once" + ); + } + + pub(crate) fn reserve_pending_auxiliary_page( + &self, + exposes_opener: bool, + ) -> Option { + self.auxiliary_page_reservation_allocator + .as_ref() + .map(|allocator| allocator.reserve(exposes_opener)) + } + + pub(crate) fn stage_pending_auxiliary_window_proxy( + &self, + scope: &mut v8::PinScope<'_, '_>, + pending: RendererPendingAuxiliaryPage, + window_proxy: v8::Global, + facade_context: v8::Global, + inherits_creator_security_token: bool, + ) -> anyhow::Result<()> { + let inherited_security_token = inherits_creator_security_token.then(|| { + let facade_context = v8::Local::new(scope, &facade_context); + v8::Global::new(scope, facade_context.get_security_token(scope)) + }); + self.auxiliary_page_reservation_allocator + .as_ref() + .ok_or_else(|| anyhow::anyhow!("context host is missing its auxiliary Page allocator"))? + .stage_related_window_proxy( + pending, + RendererStagedAuxiliaryWindowProxy::new( + window_proxy, + facade_context, + inherited_security_token, + ), + ) + } + + pub(crate) fn stage_related_initial_empty_page_in_scope( + &self, + scope: &mut v8::PinScope<'_, '_>, + pending: RendererPendingAuxiliaryPage, + init: crate::runtime::RendererRelatedInitialEmptyPageRealmInit, + ) -> anyhow::Result<()> { + self.page_script_environment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("context host is missing its Page script environment"))? + .stage_related_initial_empty_page_in_scope(scope, &self.bridge.bindings, pending, init) + } + pub(crate) fn install_page_default_context( &mut self, scope: &mut v8::PinScope<'_, '_, ()>, @@ -209,7 +281,10 @@ impl JsContextHost { #[cfg(test)] force_fresh_layout_reads_for_test: false, root_document_lifecycle: None, + root_document_is_initial_empty: false, output_journal: None, + auxiliary_page_reservation_allocator: None, + page_script_environment: None, page_context_resources_closed: false, page_default_context: None, v8_finalizers: crate::v8_finalizer::V8FinalizerRegistry::default(), @@ -484,6 +559,18 @@ impl JsContextHost { self.root_document_lifecycle = Some(lifecycle); } + pub(crate) fn mark_root_document_initial_empty(&mut self) { + debug_assert!( + moli_url::is_about_blank(self.document_url()), + "only an about:blank root Document can be marked initial empty" + ); + self.root_document_is_initial_empty = true; + } + + pub(crate) fn root_document_is_initial_empty(&self) -> bool { + self.root_document_is_initial_empty + } + /// Returns the exact root Document that owns Page-scoped protocol /// handoffs produced by this host. /// @@ -499,6 +586,11 @@ impl JsContextHost { } pub(crate) fn open_root_document(&mut self, scope: &mut v8::PinScope<'_, '_>) { + // Blink's Document::open() explicitly makes an initial empty Document + // cease to be initial. Keep this state on the Document owner rather + // than inferring it from the current URL, which same-document + // navigation is allowed to change. + self.root_document_is_initial_empty = false; let descendant_count_before = self.child_browsing_contexts.len(); for child_handle in self.top_level_child_browsing_context_handles_in_document_order() { self.drop_child_browsing_context_subtree_with_window_realm(scope, child_handle); diff --git a/moli-renderer-v8/src/native_bridge/context_host/mod.rs b/moli-renderer-v8/src/native_bridge/context_host/mod.rs index dc84dbd3ea..11e9670c0d 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/mod.rs @@ -29,8 +29,10 @@ use crate::{ reflector::ReflectorId, renderer_resource_scheduler::RendererResourceScheduler, runtime::{ - RendererBrowserContextRuntime, RendererDocumentLifecycleJournalHandle, - RendererPageContextCancelReceiver, SharedRendererBackendNodeRegistry, + RendererAuxiliaryPageReservationAllocator, RendererBrowserContextRuntime, + RendererDocumentLifecycleJournalHandle, RendererPageContextCancelReceiver, + RendererPendingAuxiliaryPage, RendererStagedAuxiliaryWindowProxy, + SharedRendererBackendNodeRegistry, }, script_provenance::CompiledStringProvenance, shared_worker_runtime::SharedWorkerClientEndpointOwner, @@ -208,6 +210,7 @@ pub(crate) use popups::{ enter_top_level_lightweight_popup_scope, javascript_url_csp_source, lightweight_popup_id_from_window, restore_active_lightweight_popup_scope, restore_deferred_active_lightweight_popup_scope_if_present, + set_renderer_owned_auxiliary_popup_id, }; pub(crate) use range_records::{RangeBoundarySide, RangeRecordHandle}; pub(crate) use runtime_observable::{ @@ -810,7 +813,10 @@ pub(crate) struct JsContextHost { #[cfg(test)] force_fresh_layout_reads_for_test: bool, root_document_lifecycle: Option, + root_document_is_initial_empty: bool, output_journal: Option, + auxiliary_page_reservation_allocator: Option, + page_script_environment: Option, page_context_resources_closed: bool, page_default_context: Option>, pub(crate) v8_finalizers: crate::v8_finalizer::V8FinalizerRegistry, diff --git a/moli-renderer-v8/src/native_bridge/context_host/popups.rs b/moli-renderer-v8/src/native_bridge/context_host/popups.rs index f57b0d9000..e01093d4d3 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/popups.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/popups.rs @@ -4,8 +4,9 @@ use super::{ use crate::{ content_security_policy::content_security_policy_forces_opaque_origin, context_bootstrap::{ - SharedWebStorageStore, WINDOW_NAME_SLOT, apply_local_window_location_navigation, - deep_clone_shared_web_storage_store, dispatch_simple_event_target_event, + SharedWebStorageStore, WINDOW_NAME_SLOT, WINDOW_OPENER_SLOT, + apply_local_window_location_navigation, deep_clone_shared_web_storage_store, + dispatch_simple_event_target_event, inherit_auxiliary_window_viewport_surface, install_navigation_bootstrap_entry_for_holder, install_simple_event_target_methods, install_storage_aliases_for_window, install_window_location_history_navigation_runtime_state, new_shared_web_storage_store, @@ -32,7 +33,10 @@ use crate::{ set_object_value, }, }, - runtime::RendererPendingPopupActivation, + runtime::{ + PageVmEnvConfig, RendererPendingAuxiliaryPage, RendererPendingPopupActivation, + RendererRelatedInitialEmptyPageRealmInit, RendererStagedAuxiliaryWindowProxy, + }, types::{ LoadedChildDocument, LoadedChildScriptSource, PopupClassicScriptLoadCompletion, PopupDocumentLoadCompletion, PopupDocumentLoadOutcome, @@ -58,6 +62,7 @@ use url::Url; const LIGHTWEIGHT_POPUP_EVENT_LISTENERS_SLOT: &str = "__lmLightweightPopupEventListeners"; const LIGHTWEIGHT_POPUP_ID_SLOT: &str = "__lmLightweightPopupId"; +const RENDERER_OWNED_AUXILIARY_POPUP_ID_SLOT: &str = "__lmRendererOwnedAuxiliaryPopupId"; const ACTIVE_LIGHTWEIGHT_POPUP_ID_SLOT: &str = "__lmActiveLightweightPopupId"; const LIGHTWEIGHT_POPUP_JAVASCRIPT_POPUP_ID_SLOT: &str = "__lmLightweightPopupJavascriptPopupId"; const LIGHTWEIGHT_POPUP_JAVASCRIPT_DOCUMENT_ID_SLOT: &str = @@ -67,14 +72,6 @@ const LIGHTWEIGHT_POPUP_JAVASCRIPT_NAVIGATION_ID_SLOT: &str = const LIGHTWEIGHT_POPUP_JAVASCRIPT_SOURCE_SLOT: &str = "__lmLightweightPopupJavascriptSource"; const LIGHTWEIGHT_POPUP_DOCUMENT_WRITE_SESSION_SLOT: &str = "__lmLightweightPopupDocumentWriteSession"; -const LIGHTWEIGHT_POPUP_VIEWPORT_SURFACE_PROPERTIES: &[&str] = &[ - "innerWidth", - "innerHeight", - "outerWidth", - "outerHeight", - "devicePixelRatio", -]; - /// Whether applying a popup response entered author code or an event-dispatch /// algorithm before returning to the enclosing Page resource task. /// @@ -413,6 +410,9 @@ pub(crate) struct OpenedLightweightPopup<'scope> { pub(crate) window: v8::Local<'scope, v8::Object>, pub(crate) popup_id: u64, pub(crate) created_new_browsing_context: bool, + pub(crate) pending_auxiliary_page: Option, + pub(crate) captured_session_storage_store: Option, + pub(crate) captured_initial_empty_document_storage_key: Option, } fn inherit_lightweight_popup_opener_sandbox( @@ -609,6 +609,7 @@ impl JsContextHost { opener_child_handle: Option, target_name: &str, href: &str, + auxiliary_page_exposes_opener: Option, creator_base_url: Url, creator_policy_container: DocumentPolicyContainer, ) -> Option> { @@ -630,24 +631,184 @@ impl JsContextHost { window, popup_id, created_new_browsing_context: false, + pending_auxiliary_page: None, + captured_session_storage_store: None, + captured_initial_empty_document_storage_key: None, }); } - let (window, popup_id) = self.create_lightweight_popup_window( + let pending_auxiliary_page = auxiliary_page_exposes_opener + .and_then(|exposes_opener| self.reserve_pending_auxiliary_page(exposes_opener)); + let stages_same_agent_window_proxy = + pending_auxiliary_page.is_some() && auxiliary_page_exposes_opener == Some(true); + let opener_sandbox_policy = opener_child_handle + .and_then(|handle| self.child_browsing_context_popup_opener_sandbox_policy(handle)); + let inherits_creator_security_token = + !opener_sandbox_policy.is_some_and(|policy| policy.forces_opaque_origin); + if let (Some(pending_auxiliary_page), Some(opener)) = (pending_auxiliary_page, opener) + && auxiliary_page_exposes_opener == Some(true) + && trackable_lightweight_popup_window_name(target_name).is_none() + && let Ok(requested_url) = Url::parse(href) + && requested_url.scheme() != "javascript" + { + return self.create_renderer_owned_initial_empty_window( + scope, + host_ptr, + opener, + opener_child_handle, + requested_url, + pending_auxiliary_page, + opener_sandbox_policy, + creator_base_url.clone(), + creator_policy_container.clone(), + inherits_creator_security_token, + ); + } + let (window, popup_id, facade_context) = self.create_lightweight_popup_window( scope, host_ptr, opener, opener_child_handle, target_name, href, - opener_child_handle - .and_then(|handle| self.child_browsing_context_popup_opener_sandbox_policy(handle)), + stages_same_agent_window_proxy, + opener_sandbox_policy, creator_base_url, creator_policy_container, )?; + if let (Some(pending_auxiliary_page), Some(facade_context)) = + (pending_auxiliary_page, facade_context) + { + self.stage_pending_auxiliary_window_proxy( + scope, + pending_auxiliary_page, + v8::Global::new(scope, window), + facade_context, + inherits_creator_security_token, + ) + .expect("related auxiliary Page must stage its exact opener WindowProxy"); + } Some(OpenedLightweightPopup { window, popup_id, created_new_browsing_context: true, + pending_auxiliary_page, + captured_session_storage_store: None, + captured_initial_empty_document_storage_key: None, + }) + } + + #[allow(clippy::too_many_arguments)] + fn create_renderer_owned_initial_empty_window<'s>( + &mut self, + scope: &mut v8::PinScope<'s, '_>, + host_ptr: *mut JsContextHost, + opener: v8::Local<'s, v8::Object>, + opener_child_handle: Option, + requested_url: Url, + pending_auxiliary_page: RendererPendingAuxiliaryPage, + opener_sandbox_policy: Option, + creator_base_url: Url, + mut creator_policy_container: DocumentPolicyContainer, + inherits_creator_security_token: bool, + ) -> Option> { + let initial_url = if moli_url::is_about_blank(&requested_url) { + requested_url + } else { + // A non-empty destination is not the initial Document. It remains + // on the immutable popup activation until target admission, then + // the auxiliary Page owner starts its one replacement navigation. + about_blank_url() + }; + let initial_base_url = lightweight_popup_initial_base_url(&initial_url, creator_base_url); + inherit_lightweight_popup_opener_sandbox( + &mut creator_policy_container.sandbox, + opener_sandbox_policy, + ); + let creator_resource_authority = self + .document_resource_loader_for_dispatch_scope(self.entered_owner_dispatch_scope(scope))? + .clone(); + let storage_scope = self.lightweight_popup_storage_scope_for_initiated_navigation( + scope, + Some(opener), + opener_child_handle, + &initial_url, + opener_sandbox_policy.is_some_and(|policy| policy.forces_opaque_origin), + ); + let session_storage_store = self.cloned_lightweight_popup_session_storage_store( + scope, + opener, + opener_child_handle, + &storage_scope, + ); + let (window, facade_context) = self + .bridge + .bindings + .instantiate_same_origin_window_proxy_shell(scope, host_ptr); + let inherited_security_token = inherits_creator_security_token.then(|| { + let facade_context = v8::Local::new(scope, &facade_context); + v8::Global::new(scope, facade_context.get_security_token(scope)) + }); + let staged_window_proxy = RendererStagedAuxiliaryWindowProxy::new( + v8::Global::new(scope, window), + facade_context, + inherited_security_token, + ); + let mut dom_host = crate::dom::native::DomHost::from_dom( + crate::parser::HtmlParser::with_scripting_enabled( + creator_policy_container.sandbox.allows_scripts, + ) + .parse( + initial_url.clone(), + "".to_owned(), + ), + ); + let document_handle = dom_host.document_handle(); + let _ = dom_host.set_document_fallback_base_url_for_handle( + document_handle, + Some(initial_base_url.clone()), + ); + let env = PageVmEnvConfig::related_initial_empty( + crate::RendererWebStorageHandles::new( + self.web_storage_store(), + session_storage_store.clone(), + ), + storage_scope.storage_key().clone(), + &creator_policy_container, + self.indexed_db_manager(), + self.storage_bucket_store(), + ); + let popup_id = self.next_lightweight_popup_id; + self.next_lightweight_popup_id = self.next_lightweight_popup_id.wrapping_add(1).max(1); + let init = RendererRelatedInitialEmptyPageRealmInit { + dom_host, + loader: creator_resource_authority + .request_client() + .fork_with_isolated_page_network_policy(), + env, + inherited_origin: storage_scope.origin().to_owned(), + policy_container: creator_policy_container, + auxiliary_popup_id: popup_id, + staged_window_proxy, + opener: Some(v8::Global::new(scope, opener)), + window_name: String::new(), + }; + if let Err(error) = + self.stage_related_initial_empty_page_in_scope(scope, pending_auxiliary_page, init) + { + tracing::debug!( + error = %error, + "failed to stage renderer-owned initial auxiliary realm; rejecting synchronous proxy path" + ); + return None; + } + + Some(OpenedLightweightPopup { + window, + popup_id, + created_new_browsing_context: true, + pending_auxiliary_page: Some(pending_auxiliary_page), + captured_session_storage_store: Some(session_storage_store), + captured_initial_empty_document_storage_key: Some(storage_scope.storage_key().clone()), }) } @@ -659,10 +820,15 @@ impl JsContextHost { opener_child_handle: Option, target_name: &str, href: &str, + detachable_window_proxy: bool, opener_sandbox_policy: Option, creator_base_url: Url, creator_policy_container: DocumentPolicyContainer, - ) -> Option<(v8::Local<'s, v8::Object>, u64)> { + ) -> Option<( + v8::Local<'s, v8::Object>, + u64, + Option>, + )> { let parsed_url = Url::parse(href).ok()?; let initial_url = if parsed_url.scheme() == "javascript" { about_blank_url() @@ -693,10 +859,20 @@ impl JsContextHost { .next_lightweight_popup_id .checked_add(1) .expect("lightweight popup id space exhausted"); - let window = self - .bridge - .bindings - .instantiate_window_shell(scope, host_ptr); + let (window, facade_context) = if detachable_window_proxy { + let (window, context) = self + .bridge + .bindings + .instantiate_same_origin_window_proxy_shell(scope, host_ptr); + (window, Some(context)) + } else { + ( + self.bridge + .bindings + .instantiate_window_shell(scope, host_ptr), + None, + ) + }; let popup_id_private_value = v8::BigInt::new_from_u64(scope, popup_id); set_private_value( scope, @@ -734,12 +910,48 @@ impl JsContextHost { set_object_slot(scope, window, "name", value.into()); } if let Some(opener) = opener { + set_private_value(scope, window, WINDOW_OPENER_SLOT, opener.into()); set_object_slot(scope, window, "opener", opener.into()); - install_lightweight_popup_viewport_surface_from_opener(scope, opener, window); + inherit_auxiliary_window_viewport_surface(scope, opener, window); } else { let opener = v8::null(scope); + set_private_value(scope, window, WINDOW_OPENER_SLOT, opener.into()); set_object_slot(scope, window, "opener", opener.into()); } + if let Some(facade_context) = facade_context.as_ref() { + // The legacy lightweight loader continues to execute until the + // initial Document owner is unified. Give its temporary realm an + // observable token and install realm-local identity slots from + // inside that realm; setting a V8 private on its global proxy + // while entered in the opener realm can otherwise target the + // wrong inner global. + let facade_context = v8::Local::new(scope, facade_context); + let context_token = self.allocate_runtime_observable_context_token(); + crate::native_bridge::install_runtime_observable_context_token_for_context( + facade_context, + context_token, + ); + let opener_value: v8::Local<'s, v8::Value> = opener + .map(Into::into) + .unwrap_or_else(|| v8::null(scope).into()); + let opener_value = v8::Global::new(scope, opener_value); + let facade_scope = &mut v8::ContextScope::new(scope, facade_context); + let facade_window = facade_context.global(facade_scope); + let popup_id_value = v8::BigInt::new_from_u64(facade_scope, popup_id); + set_private_value( + facade_scope, + facade_window, + LIGHTWEIGHT_POPUP_ID_SLOT, + popup_id_value.into(), + ); + let opener_value = v8::Local::new(facade_scope, &opener_value); + set_private_value( + facade_scope, + facade_window, + WINDOW_OPENER_SLOT, + opener_value, + ); + } if let Ok(navigator) = crate::context_bootstrap::build_lightweight_popup_window_navigator_object( scope, popup_id, @@ -905,7 +1117,7 @@ impl JsContextHost { if queue_synthetic_load { self.queue_lightweight_popup_load_event(navigation_task); } - Some((window, popup_id)) + Some((window, popup_id, facade_context)) } fn reopen_lightweight_popup_window<'s>( @@ -1030,6 +1242,28 @@ impl JsContextHost { .map(|record| v8::Local::new(scope, &record.window_proxy)) } + pub(crate) fn lightweight_popup_id_for_window_proxy<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + window: v8::Local<'s, v8::Object>, + ) -> Option { + lightweight_popup_id_from_window(scope, window) + .filter(|popup_id| { + self.lightweight_popup_browsing_contexts + .contains_key(popup_id) + }) + .or_else(|| { + self.lightweight_popup_browsing_contexts + .iter() + .find_map(|(popup_id, record)| { + v8::Local::new(scope, &record.window_proxy) + .strict_equals(window.into()) + .then_some(*popup_id) + }) + }) + .or_else(|| renderer_owned_auxiliary_popup_id_from_window(scope, window)) + } + pub(in crate::native_bridge::context_host) fn lightweight_popup_opener_endpoint( &self, popup_id: u64, @@ -3912,6 +4146,28 @@ pub(crate) fn lightweight_popup_id_from_window<'s>( lightweight_popup_id_from_value(scope, value) } +pub(crate) fn set_renderer_owned_auxiliary_popup_id<'s>( + scope: &mut v8::PinScope<'s, '_>, + window: v8::Local<'s, v8::Object>, + popup_id: u64, +) { + let value = v8::BigInt::new_from_u64(scope, popup_id); + set_private_value( + scope, + window, + RENDERER_OWNED_AUXILIARY_POPUP_ID_SLOT, + value.into(), + ); +} + +fn renderer_owned_auxiliary_popup_id_from_window<'s>( + scope: &mut v8::PinScope<'s, '_>, + window: v8::Local<'s, v8::Object>, +) -> Option { + let value = get_private_value(scope, window, RENDERER_OWNED_AUXILIARY_POPUP_ID_SLOT)?; + lightweight_popup_id_from_value(scope, value) +} + pub(crate) fn active_lightweight_popup_id(scope: &mut v8::PinScope<'_, '_>) -> Option { let global = scope.get_current_context().global(scope); let value = get_private_value(scope, global, ACTIVE_LIGHTWEIGHT_POPUP_ID_SLOT)?; @@ -4029,21 +4285,6 @@ fn install_lightweight_popup_indexed_db_factory<'s>( true } -fn install_lightweight_popup_viewport_surface_from_opener<'s>( - scope: &mut v8::PinScope<'s, '_>, - opener: v8::Local<'s, v8::Object>, - window: v8::Local<'s, v8::Object>, -) { - for property in LIGHTWEIGHT_POPUP_VIEWPORT_SURFACE_PROPERTIES { - let Some(value) = opener.get(scope, v8str(scope, property).into()) else { - continue; - }; - if value.is_number() { - set_object_slot(scope, window, property, value); - } - } -} - fn lightweight_popup_close_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, diff --git a/moli-renderer-v8/src/native_bridge/document.rs b/moli-renderer-v8/src/native_bridge/document.rs index d447b08a7d..9d2f5ae3f4 100644 --- a/moli-renderer-v8/src/native_bridge/document.rs +++ b/moli-renderer-v8/src/native_bridge/document.rs @@ -965,10 +965,17 @@ fn document_referrer_getter_function<'s>( return; }; let runtime = unsafe { &*runtime_ptr }; - let referrer = runtime - .child_browsing_context_referrer_for_document_handle(handle) - .or_else(|| runtime.lightweight_popup_referrer_for_document_handle(handle)) - .unwrap_or(""); + let referrer = if handle == runtime.document_handle() { + runtime + .document_policy_container() + .document_referrer + .as_str() + } else { + runtime + .child_browsing_context_referrer_for_document_handle(handle) + .or_else(|| runtime.lightweight_popup_referrer_for_document_handle(handle)) + .unwrap_or("") + }; set_document_string_return_value(scope, &mut rv, referrer); } diff --git a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs index eb5c34c7a6..bec10cae22 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs @@ -226,6 +226,7 @@ fn navigate_hyperlink_popup_target( None, target_name, resolved_url, + Some(!relations.suppress_opener), creator.base_url, creator.policy_container, ) else { @@ -253,6 +254,7 @@ fn navigate_hyperlink_popup_target( let session_storage_store = runtime.lightweight_popup_session_storage_store(popup_id); let initial_empty_document_storage_key = runtime.lightweight_popup_initial_empty_document_storage_key(popup_id); + let pending_auxiliary_page = opened_popup.pending_auxiliary_page; let user_gesture = runtime.protocol_user_gesture_activation(); let window_open_event = opened_popup.created_new_browsing_context.then(|| { RendererPendingWindowOpenEvent::browser_window(resolved_url, target_name, user_gesture) @@ -267,7 +269,8 @@ fn navigate_hyperlink_popup_target( target_name.to_owned(), disposition, ) - .with_initial_auxiliary_state(session_storage_store, initial_empty_document_storage_key), + .with_initial_auxiliary_state(session_storage_store, initial_empty_document_storage_key) + .with_pending_auxiliary_page(pending_auxiliary_page), window_open_event, ); true diff --git a/moli-renderer-v8/src/native_bridge/mod.rs b/moli-renderer-v8/src/native_bridge/mod.rs index afa7545edf..8fb80b3119 100644 --- a/moli-renderer-v8/src/native_bridge/mod.rs +++ b/moli-renderer-v8/src/native_bridge/mod.rs @@ -16,6 +16,7 @@ pub(crate) use current_input::{ pub(crate) mod document; pub(crate) mod element; mod helpers; +pub(crate) use helpers::set_object_slot; mod history_queue; pub(super) mod identity; mod node; @@ -61,7 +62,8 @@ pub(crate) use context_host::{ install_runtime_observable_context_token_for_context, is_cross_origin_top_window_proxy, lightweight_popup_id_from_window, restore_active_lightweight_popup_scope, restore_deferred_active_lightweight_popup_scope_if_present, - throw_cross_origin_location_security_error, throw_cross_origin_type_error, + set_renderer_owned_auxiliary_popup_id, throw_cross_origin_location_security_error, + throw_cross_origin_type_error, }; pub(crate) const ACTIVE_CHILD_WINDOW_HANDLE_SLOT: &str = "__moliActiveChildWindowHandle"; diff --git a/moli-renderer-v8/src/network/context/document.rs b/moli-renderer-v8/src/network/context/document.rs index 3d0419e660..3768fdcc3c 100644 --- a/moli-renderer-v8/src/network/context/document.rs +++ b/moli-renderer-v8/src/network/context/document.rs @@ -246,6 +246,10 @@ impl DocumentResourceLoader { &self.request_client } + pub(crate) fn document_origin(&self) -> String { + self.authority.lifecycle.lock().context.origin().to_owned() + } + #[cfg(test)] pub(crate) fn owner(&self) -> crate::native_bridge::WindowDocumentOwner { self.authority.lifecycle.lock().context.owner() diff --git a/moli-renderer-v8/src/page_task_queue/senders.rs b/moli-renderer-v8/src/page_task_queue/senders.rs index bc78b7c375..52ce44384e 100644 --- a/moli-renderer-v8/src/page_task_queue/senders.rs +++ b/moli-renderer-v8/src/page_task_queue/senders.rs @@ -316,6 +316,16 @@ impl PageRuntimeTaskSource { Rc::as_ptr(&self.page_task_producer_routes) as usize } + /// Allocates one exact cross-Document commit identity from this stable + /// Page source. Prepared browser-owned navigation does not originate in a + /// ScriptVm, but it must share the same identity namespace as script + /// navigation so two replacement publications can never alias. + pub(crate) fn next_top_level_navigation_handoff( + &self, + ) -> super::RendererTopLevelNavigationHandoff { + self.wake.next_top_level_navigation_handoff() + } + pub(crate) fn v8_foreground_task_sender(&self) -> Option { self.page_task_producer_routes .borrow() diff --git a/moli-renderer-v8/src/runtime/mod.rs b/moli-renderer-v8/src/runtime/mod.rs index 10bbb7872a..a3280d11fb 100644 --- a/moli-renderer-v8/src/runtime/mod.rs +++ b/moli-renderer-v8/src/runtime/mod.rs @@ -4,6 +4,7 @@ use std::{ cell::RefCell, collections::HashMap, fmt, + rc::Rc, sync::Arc, sync::atomic::{AtomicU64, Ordering}, time::Instant, @@ -14,6 +15,7 @@ use crate::DocumentStartScript; #[cfg(test)] use crate::dom::native::NativeDom; use crate::{ + browsing_context_model::BrowsingContextId, dom::NodeId, network::{ResourceRequestClient, context::DocumentResourceLoader}, }; @@ -286,7 +288,8 @@ pub use self::owner::{ }; pub use self::owner_local::RendererPageTestingHandle; pub use self::owner_local::{ - RendererPageCommandPending, RendererPageHandle, RendererRuntimeInspectorSessionDetachGuard, + RendererPageCommandPending, RendererPageHandle, RendererPageReplacementReservationPending, + RendererRuntimeInspectorSessionDetachGuard, }; pub(crate) use self::owner_local_store::RendererPageToken; pub use self::page::{JsRuntime, JsRuntimeOwner, PendingHtmlPage, PreparedRendererDocument}; @@ -528,21 +531,27 @@ impl PageId { } } -/// Opaque owner-local reservation for one renderer Page identity. -/// -/// The reservation is allocated before a queued full-body build or prepared -/// document can enter parser or author-script execution. This lets external -/// observers bind work to the future Page without guessing from the currently -/// installed Page. +/// Selects the script environment admitted when a Page reservation is +/// consumed by document bootstrap. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) enum RendererScriptAgentAdmission { Fresh, - #[cfg(test)] - RelatedPageForExperiment { - source_page_id: PageId, + RelatedAuxiliaryPage { + opener_page_id: PageId, + }, + ExistingPageReplacement { + expected_vm_creation_id: u64, + reservation_nonce: u64, }, } +/// Opaque owner-local reservation for one renderer Page admission. +/// +/// Initial creation reserves a future Page identity. A live replacement +/// reserves one exact committed `PageVm` generation of an existing identity. +/// Both are allocated before a queued full-body build or prepared document can +/// enter parser or author-script execution, so external observers never infer +/// ownership from whichever Page happens to be installed later. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct RendererPageReservationToken { local_host_id: RendererOwnerLocalHostId, @@ -559,17 +568,32 @@ impl RendererPageReservationToken { } } - #[cfg(test)] - pub(crate) fn new_related_page_for_experiment( + pub(crate) fn new_related_auxiliary_page( local_host_id: RendererOwnerLocalHostId, page_id: PageId, - source_page_id: PageId, + opener_page_id: PageId, ) -> Self { Self { local_host_id, page_id, - script_agent_admission: RendererScriptAgentAdmission::RelatedPageForExperiment { - source_page_id, + script_agent_admission: RendererScriptAgentAdmission::RelatedAuxiliaryPage { + opener_page_id, + }, + } + } + + pub(crate) fn new_existing_page_replacement( + local_host_id: RendererOwnerLocalHostId, + page_id: PageId, + expected_vm_creation_id: u64, + reservation_nonce: u64, + ) -> Self { + Self { + local_host_id, + page_id, + script_agent_admission: RendererScriptAgentAdmission::ExistingPageReplacement { + expected_vm_creation_id, + reservation_nonce, }, } } @@ -587,6 +611,228 @@ impl RendererPageReservationToken { } } +/// Renderer-owned identity reserved synchronously for a newly accepted +/// auxiliary top-level browsing context. +/// +/// The typed browsing-context identity remains stable across Document +/// replacements, while the Page reservation is consumed exactly once by the +/// protocol target's initial empty-Document build. Reserving here prevents +/// protocol code from manufacturing a second Page identity for a popup the +/// renderer has already accepted. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct RendererPendingAuxiliaryPage { + browsing_context_id: BrowsingContextId, + page_reservation: RendererPageReservationToken, +} + +impl RendererPendingAuxiliaryPage { + pub fn browsing_context_id(self) -> u64 { + self.browsing_context_id.value() + } + + pub fn page_reservation(self) -> RendererPageReservationToken { + self.page_reservation + } +} + +/// Owner-local V8 state staged between synchronous popup acceptance and the +/// related auxiliary Page's initial realm bootstrap. +pub(crate) struct RendererStagedAuxiliaryWindowProxy { + window_proxy: v8::Global, + facade_context: v8::Global, + inherited_security_token: Option>, +} + +/// Inputs captured from the creator Document for one real synchronous +/// auxiliary initial-empty Page realm. +pub(crate) struct RendererRelatedInitialEmptyPageRealmInit { + pub(crate) dom_host: crate::dom::native::DomHost, + pub(crate) loader: ResourceRequestClient, + pub(crate) env: PageVmEnvConfig, + pub(crate) inherited_origin: String, + pub(crate) policy_container: crate::document_runtime::DocumentPolicyContainer, + pub(crate) auxiliary_popup_id: u64, + pub(crate) staged_window_proxy: RendererStagedAuxiliaryWindowProxy, + pub(crate) opener: Option>, + pub(crate) window_name: String, +} + +impl RendererStagedAuxiliaryWindowProxy { + pub(crate) fn new( + window_proxy: v8::Global, + facade_context: v8::Global, + inherited_security_token: Option>, + ) -> Self { + Self { + window_proxy, + facade_context, + inherited_security_token, + } + } + + pub(crate) fn into_parts( + self, + ) -> ( + v8::Global, + v8::Global, + Option>, + ) { + ( + self.window_proxy, + self.facade_context, + self.inherited_security_token, + ) + } +} + +#[derive(Default)] +struct RendererStagedAuxiliaryWindowProxyRegistry { + by_page_id: HashMap, +} + +/// Page-local allocation and handoff capability for auxiliary browsing +/// contexts created synchronously by one opener realm. +/// +/// The registry intentionally stays owner-local: V8 handles never cross the +/// renderer/protocol transport carried by [`RendererPendingAuxiliaryPage`]. A +/// related Page admitted to the same script agent consumes its exact staged +/// proxy once during its initial realm bootstrap. +#[derive(Clone)] +pub(crate) struct RendererAuxiliaryPageReservationAllocator { + owner: Option, + local_host_id: RendererOwnerLocalHostId, + opener_page_id: PageId, + next_page_id: Arc, + staged_window_proxies: Rc>, +} + +impl fmt::Debug for RendererAuxiliaryPageReservationAllocator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RendererAuxiliaryPageReservationAllocator") + .field("local_host_id", &self.local_host_id) + .field("opener_page_id", &self.opener_page_id) + .field( + "staged_window_proxy_count", + &self.staged_window_proxies.borrow().by_page_id.len(), + ) + .finish_non_exhaustive() + } +} + +impl RendererAuxiliaryPageReservationAllocator { + pub(in crate::runtime) fn new_for_owner( + owner: owner_local_store::RendererOwnerLocalContext, + opener_page_id: PageId, + ) -> Self { + let local_host_id = owner.local_host_id; + let next_page_id = owner.owner_state.next_page_id.clone(); + Self { + owner: Some(owner), + local_host_id, + opener_page_id, + next_page_id, + staged_window_proxies: Rc::new(RefCell::new( + RendererStagedAuxiliaryWindowProxyRegistry::default(), + )), + } + } + + #[cfg(test)] + pub(crate) fn new_for_test( + local_host_id: RendererOwnerLocalHostId, + opener_page_id: PageId, + next_page_id: Arc, + ) -> Self { + Self { + owner: None, + local_host_id, + opener_page_id, + next_page_id, + staged_window_proxies: Rc::new(RefCell::new( + RendererStagedAuxiliaryWindowProxyRegistry::default(), + )), + } + } + + pub(crate) fn reserve(&self, exposes_opener: bool) -> RendererPendingAuxiliaryPage { + let page_id = PageId::new(self.next_page_id.fetch_add(1, Ordering::Relaxed)); + let page_reservation = if exposes_opener { + RendererPageReservationToken::new_related_auxiliary_page( + self.local_host_id, + page_id, + self.opener_page_id, + ) + } else { + RendererPageReservationToken::new(self.local_host_id, page_id) + }; + RendererPendingAuxiliaryPage { + browsing_context_id: BrowsingContextId::auxiliary_top_level(page_id.as_u64()), + page_reservation, + } + } + + pub(crate) fn stage_related_window_proxy( + &self, + pending: RendererPendingAuxiliaryPage, + staged: RendererStagedAuxiliaryWindowProxy, + ) -> Result<()> { + let reservation = pending.page_reservation(); + ensure!( + reservation.local_host_id() == self.local_host_id, + "auxiliary WindowProxy reservation belongs to a different renderer owner" + ); + ensure!( + matches!( + reservation.script_agent_admission(), + RendererScriptAgentAdmission::RelatedAuxiliaryPage { opener_page_id } + if opener_page_id == self.opener_page_id + ), + "only a related auxiliary Page may adopt its opener-created WindowProxy" + ); + let mut registry = self.staged_window_proxies.borrow_mut(); + let std::collections::hash_map::Entry::Vacant(entry) = + registry.by_page_id.entry(reservation.page_id()) + else { + return Err(anyhow!( + "auxiliary Page already has a staged opener WindowProxy" + )); + }; + entry.insert(staged); + Ok(()) + } + + pub(crate) fn take_related_window_proxy( + &self, + page_id: PageId, + ) -> Option { + self.staged_window_proxies + .borrow_mut() + .by_page_id + .remove(&page_id) + } + + pub(crate) fn stage_related_initial_empty_page_in_scope( + &self, + scope: &mut v8::PinScope<'_, '_>, + pending: RendererPendingAuxiliaryPage, + source_environment: &crate::script_vm::RendererPageScriptEnvironment, + source_bridge_bindings: &crate::native_bridge::bindings::NativeBridgeBindings, + init: RendererRelatedInitialEmptyPageRealmInit, + ) -> Result<()> { + let owner = self.owner.as_ref().ok_or_else(|| { + anyhow!("standalone auxiliary allocator cannot stage a production Page realm") + })?; + owner_local_store::stage_related_initial_empty_page_on_bound_owner_local_store( + owner, + scope, + pending, + source_environment, + source_bridge_bindings, + init, + ) + } +} + /// Typed authority to consume one matching prepared document and enter its /// renderer bootstrap. #[derive(Debug)] @@ -604,6 +850,233 @@ impl RendererDocumentCommitPermit { } } +/// Result of committing a prepared Document into an already-owned renderer +/// Page. +/// +/// Unlike initial Page creation, this value deliberately carries no +/// [`RendererPageHandle`]. The existing handle remains the sole Page close and +/// command authority; callers only adopt the replacement Document's state and +/// DevTools agent identity. +pub struct RendererPageReplacementCommit { + pub(crate) local_host_id: RendererOwnerLocalHostId, + pub(crate) page_id: PageId, + pub(crate) renderer_devtools_agent_token: RendererDevToolsAgentToken, + pub(crate) javascript_dialog_broker: RendererJavaScriptDialogBroker, + pub(crate) devtools_target: crate::devtools::target::RendererDevToolsTargetHandle, + pub(crate) page_state: Arc, + pub(crate) creation_diagnostics: RendererPageCreationDiagnostics, + pub(crate) creation_artifacts: RendererPageCreationArtifacts, + pub(crate) pending_download: Option, +} + +#[derive(Clone, Debug)] +enum RendererDocumentContinuationState { + Pending, + Settled(RendererDocumentContinuationCompletion), +} + +/// State captured by the exact owner turn that settles a committed Document's +/// requested continuation target. +/// +/// The output predecessor and PageState belong to the same owner turn. This +/// lets protocol adapters first project every lifecycle/Inspector record up to +/// the target and then replace their cached Page view without issuing a later +/// renderer command that could observe a different turn. +#[derive(Clone, Debug)] +pub struct RendererDocumentContinuationCompletion { + renderer_output_predecessor: Option, + page_state: Option>, +} + +impl RendererDocumentContinuationCompletion { + fn settled( + renderer_output_predecessor: Option, + page_state: Option>, + ) -> Self { + Self { + renderer_output_predecessor, + page_state, + } + } + + fn canceled() -> Self { + Self::settled(None, None) + } + + pub fn into_parts(self) -> (Option, Option>) { + (self.renderer_output_predecessor, self.page_state) + } +} + +/// Exact observer for the owner turn that finishes a DocumentCommit +/// continuation at its requested lifecycle target. +/// +/// The lifecycle journal may publish DOMContentLoaded while that owner turn is +/// still committing PageState and settling Inspector output. This observer is +/// resolved only after that concrete output publication has been sent, and +/// carries its fence when one exists. +#[derive(Clone)] +pub struct RendererDocumentContinuationObserver { + receiver: tokio::sync::watch::Receiver, +} + +impl std::fmt::Debug for RendererDocumentContinuationObserver { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RendererDocumentContinuationObserver") + .finish_non_exhaustive() + } +} + +impl PartialEq for RendererDocumentContinuationObserver { + fn eq(&self, other: &Self) -> bool { + self.receiver.same_channel(&other.receiver) + } +} + +impl RendererDocumentContinuationObserver { + pub async fn wait(mut self) -> RendererDocumentContinuationCompletion { + loop { + match self.receiver.borrow_and_update().clone() { + RendererDocumentContinuationState::Pending => {} + RendererDocumentContinuationState::Settled(completion) => return completion, + } + if self.receiver.changed().await.is_err() { + return RendererDocumentContinuationCompletion::canceled(); + } + } + } +} + +pub(in crate::runtime) struct RendererDocumentContinuationPublisher { + sender: Option>, +} + +impl RendererDocumentContinuationPublisher { + fn channel() -> (Self, RendererDocumentContinuationObserver) { + let (sender, receiver) = + tokio::sync::watch::channel(RendererDocumentContinuationState::Pending); + ( + Self { + sender: Some(sender), + }, + RendererDocumentContinuationObserver { receiver }, + ) + } + + pub(in crate::runtime) fn settle( + mut self, + renderer_output_predecessor: Option, + page_state: Option>, + ) { + if let Some(sender) = self.sender.take() { + sender.send_replace(RendererDocumentContinuationState::Settled( + RendererDocumentContinuationCompletion::settled( + renderer_output_predecessor, + page_state, + ), + )); + } + } +} + +impl Drop for RendererDocumentContinuationPublisher { + fn drop(&mut self) { + if let Some(sender) = self.sender.take() { + sender.send_replace(RendererDocumentContinuationState::Settled( + RendererDocumentContinuationCompletion::canceled(), + )); + } + } +} + +pub(in crate::runtime) fn renderer_document_continuation_channel() -> ( + RendererDocumentContinuationPublisher, + RendererDocumentContinuationObserver, +) { + RendererDocumentContinuationPublisher::channel() +} + +/// Whether a failed prepared replacement left the stable Page's old Document +/// usable or crossed the renderer's irreversible retirement boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RendererPageReplacementCommitFailureDisposition { + PagePreserved, + PageRetired, +} + +#[derive(Debug)] +pub struct RendererPageReplacementCommitError { + disposition: RendererPageReplacementCommitFailureDisposition, + source: anyhow::Error, +} + +impl RendererPageReplacementCommitError { + #[doc(hidden)] + pub fn page_preserved(source: anyhow::Error) -> Self { + Self { + disposition: RendererPageReplacementCommitFailureDisposition::PagePreserved, + source, + } + } + + #[doc(hidden)] + pub fn page_retired(source: anyhow::Error) -> Self { + Self { + disposition: RendererPageReplacementCommitFailureDisposition::PageRetired, + source, + } + } + + pub fn disposition(&self) -> RendererPageReplacementCommitFailureDisposition { + self.disposition + } +} + +impl std::fmt::Display for RendererPageReplacementCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.source, formatter) + } +} + +impl std::error::Error for RendererPageReplacementCommitError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.source.as_ref()) + } +} + +impl RendererPageReplacementCommit { + pub fn owner_local_host_id(&self) -> RendererOwnerLocalHostId { + self.local_host_id + } + + pub fn page_id(&self) -> PageId { + self.page_id + } + + pub fn renderer_devtools_agent_token(&self) -> RendererDevToolsAgentToken { + self.renderer_devtools_agent_token + } + + pub fn into_parts( + self, + ) -> ( + RendererDevToolsAgentToken, + Arc, + RendererPageCreationDiagnostics, + RendererPageCreationArtifacts, + Option, + ) { + ( + self.renderer_devtools_agent_token, + self.page_state, + self.creation_diagnostics, + self.creation_artifacts, + self.pending_download, + ) + } +} + #[cfg(test)] pub(in crate::runtime) enum PageVmNavigationTurnOutcome { Completed(Box), diff --git a/moli-renderer-v8/src/runtime/owner.rs b/moli-renderer-v8/src/runtime/owner.rs index f6ca8c6abc..78cfacfac1 100644 --- a/moli-renderer-v8/src/runtime/owner.rs +++ b/moli-renderer-v8/src/runtime/owner.rs @@ -9,12 +9,12 @@ use super::owner_local_store::{ LivePageNavigationFailureRecipient, LivePageNavigationFollowEntryAdvance, LivePageNavigationFollowOutcome, LivePageNavigationFollowTurn, LivePagePendingNavigationCompletion, LivePagePendingNavigationPhaseOneAdvance, - NavigationReplyPolicy, PendingPhaseOneEntryAdvance, RendererDisplacedOrdinaryTurn, - RendererDocumentIsolateAllocator, RendererDocumentIsolateReservation, - RendererOwnerLocalContext, RendererOwnerLocalStore, RendererPageCommandDispatch, - RendererPageCreationResolution, RendererPageScheduledTurn, RendererPageToken, - RendererPageTurnAdmission, RendererPageTurnCheckoutError, RendererPendingPageCreation, - RendererPreparedDocumentResidence, RetiringPageEntry, + NavigationReplyPolicy, PendingPhaseOneEntryAdvance, PreparedReplacementDocumentMetadata, + RendererDisplacedOrdinaryTurn, RendererDocumentIsolateAllocator, + RendererDocumentIsolateReservation, RendererOwnerLocalContext, RendererOwnerLocalStore, + RendererPageCommandDispatch, RendererPageCreationResolution, RendererPageScheduledTurn, + RendererPageToken, RendererPageTurnAdmission, RendererPageTurnCheckoutError, + RendererPendingPageCreation, RendererPreparedDocumentResidence, RetiringPageEntry, advance_document_lifecycle_one_page_turn_via_local_task, advance_dom_stable_wait_turn_on_entry_via_local_task, advance_network_idle_wait_turn_on_entry_via_local_task, @@ -25,7 +25,9 @@ use super::owner_local_store::{ advance_script_truthy_wait_turn_on_entry_via_local_task, advance_selector_wait_turn_on_entry_via_local_task, advance_subresource_response_wait_turn_on_entry_via_local_task, - begin_post_parse_lifecycle_on_entry_via_local_task, bind_render_runtime_owner_local_store, + begin_post_parse_lifecycle_on_entry_via_local_task, + begin_prepared_document_replacement_on_entry_via_local_task, + bind_render_runtime_owner_local_store, checkout_entry_for_owner_turn_on_bound_owner_local_store, checkout_scheduled_page_turn_on_bound_owner_local_store, claim_due_owner_maintenance_task_on_bound_owner_local_store, @@ -33,9 +35,12 @@ use super::owner_local_store::{ commit_page_state_on_entry_via_local_task_with_policy, dispatch_async_command_on_entry_via_local_task, finalize_pending_page_creation_on_bound_owner_local_store, + finalize_prepared_page_replacement_on_entry_via_local_task, follow_pending_location_navigation_one_turn_on_entry_via_local_task, has_pending_document_lifecycle_turn_on_entry, install_page_vm_on_bound_owner_local_store, install_phase_one_blocked_page_on_bound_owner_local_store, + install_prepared_replacement_page_vm_on_entry_via_local_task, + install_prepared_replacement_pending_phase_one_on_entry_via_local_task, next_owner_maintenance_deadline_on_bound_owner_local_store, next_page_task_deadline_on_bound_owner_local_store, observe_document_lifecycle_on_entry, owner_local_store_session, page_turn_readiness_after_restore_on_bound_owner_local_store, @@ -221,6 +226,9 @@ pub struct RendererCreateStreamingRawPageRequest { pub enum RendererOwnerCommand { CreateHtmlPage(RendererCreateHtmlPageRequest), + ReserveLivePageReplacement { + token: RendererPageToken, + }, PrepareStreamingRawDocument { token: RendererPageReservationToken, request: RendererCreateStreamingRawPageRequest, @@ -232,6 +240,9 @@ pub enum RendererOwnerCommand { CommitPreparedRendererDocument { permit: RendererDocumentCommitPermit, }, + CommitPreparedRendererPageReplacement { + permit: RendererDocumentCommitPermit, + }, CancelPreparedRendererDocument { token: RendererPageReservationToken, }, @@ -291,11 +302,13 @@ pub enum RendererOwnerCommand { pub enum RendererOwnerReply { PageCreated(Box), + LivePageReplacementReserved(RendererPageReservationToken), PreparedRendererDocumentStored { renderer_devtools_agent_token: RendererDevToolsAgentToken, }, PreparedRendererDocumentCommitConfigurationUpdated, PreparedRendererDocumentCanceled, + PageReplacementCommitted(Box), AsyncPageCommandRan(Box), RuntimeInspectorSessionResponseSettled { output: Box, @@ -326,6 +339,10 @@ enum RenderRuntimeDispatchOutcome { page: Box, continuation: RenderRuntimePageCreationContinuation, }, + ReplyAndContinueNavigation { + reply: Box, + turn: Box, + }, BackgroundComplete(Result<()>), PageCreationNavigationFailurePublished { token: RendererPageToken, @@ -379,6 +396,17 @@ impl RenderRuntimePageCreationContinuation { } } + fn install_document_continuation_publisher( + &mut self, + publisher: RendererDocumentContinuationPublisher, + ) -> bool { + match self { + Self::NextTurn(turn) | Self::AfterCommittedDocumentResponse { turn, .. } => { + turn.install_document_continuation_publisher(publisher) + } + } + } + fn requires_committed_document_response_release(&self) -> bool { matches!(self, Self::AfterCommittedDocumentResponse { .. }) } @@ -386,6 +414,7 @@ impl RenderRuntimePageCreationContinuation { enum LivePageNavigationFailureDisposition { ReturnToInitiator(anyhow::Error), + ReturnPageReplacementToInitiator(anyhow::Error), PublishToPageCreation(PageNavigationOwnerFailure), ReportBackground(PageNavigationOwnerFailure), } @@ -393,7 +422,9 @@ enum LivePageNavigationFailureDisposition { impl std::fmt::Display for LivePageNavigationFailureDisposition { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::ReturnToInitiator(error) => std::fmt::Display::fmt(error, f), + Self::ReturnToInitiator(error) | Self::ReturnPageReplacementToInitiator(error) => { + std::fmt::Display::fmt(error, f) + } Self::PublishToPageCreation(failure) | Self::ReportBackground(failure) => { std::fmt::Display::fmt(failure, f) } @@ -410,7 +441,9 @@ impl LivePageNavigationFailureDisposition { Self::PublishToPageCreation(failure) => Some( publish_page_navigation_failure_on_bound_owner_local_store(token, *failure), ), - Self::ReturnToInitiator(_) | Self::ReportBackground(_) => None, + Self::ReturnToInitiator(_) + | Self::ReturnPageReplacementToInitiator(_) + | Self::ReportBackground(_) => None, } } @@ -441,7 +474,9 @@ impl LivePageNavigationFailureDisposition { Err(error) => RenderRuntimeDispatchOutcome::BackgroundComplete(Err(error)), } } - Self::ReturnToInitiator(error) => Err(error).into(), + Self::ReturnToInitiator(error) | Self::ReturnPageReplacementToInitiator(error) => { + Err(error).into() + } Self::ReportBackground(failure) => { RenderRuntimeDispatchOutcome::BackgroundComplete(Err(anyhow!(failure.to_string()))) } @@ -449,6 +484,17 @@ impl LivePageNavigationFailureDisposition { } } +fn live_page_navigation_initiator_failure( + completion: &LivePagePendingNavigationCompletion, + error: anyhow::Error, +) -> LivePageNavigationFailureDisposition { + if completion.is_prepared_page_replacement_commit() { + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator(error) + } else { + LivePageNavigationFailureDisposition::ReturnToInitiator(error) + } +} + pub(super) struct RenderRuntimePendingTurn { /// `None` means the pending turn is detached/background work (originated /// from an idle-tick) and no caller is waiting on a reply. @@ -643,6 +689,36 @@ fn checked_live_page_wait_deadline(timeout_ms: u64, operation: &str) -> Result), + PageReplacement(Box), +} + enum RenderRuntimeTurn { FinishHtmlCreatePage { requested_url: Url, @@ -773,6 +849,15 @@ enum RenderRuntimeTurn { follow_count: usize, completion: LivePagePendingNavigationCompletion, }, + BeginLivePageNavigationPostParseLifecycle { + token: RendererPageToken, + page_tasks: Vec, + stage: PageVmInitStage, + started: Instant, + target_stage: PageVmInitStage, + follow_count: usize, + completion: LivePagePendingNavigationCompletion, + }, ContinueLivePageNavigationPostParseLifecycle { token: RendererPageToken, document: RendererDocumentLifecycleIdentity, @@ -790,6 +875,33 @@ impl RenderRuntimeTurn { ) } + /// A committed parser residence may yield because its next author-script + /// task was published, not because parsing is externally blocked. Keep + /// that exact navigation continuation ahead of ordinary commands until + /// phase one either finishes or parks on a real source wait. Inspector + /// pause commands use the isolate bridge and do not depend on this queue. + fn must_precede_ready_command(&self) -> bool { + matches!( + self, + Self::ContinueLivePagePendingLocationNavigationPhaseOne { .. } + ) + } + + fn install_document_continuation_publisher( + &mut self, + publisher: super::RendererDocumentContinuationPublisher, + ) -> bool { + match self { + Self::FollowLivePagePendingLocationNavigation { completion, .. } + | Self::ContinueLivePagePendingLocationNavigationPhaseOne { completion, .. } + | Self::BeginLivePageNavigationPostParseLifecycle { completion, .. } + | Self::ContinueLivePageNavigationPostParseLifecycle { completion, .. } => { + completion.install_document_continuation_publisher(publisher) + } + _ => false, + } + } + /// Return the Page whose committed view this host-facing command needs. /// /// A same-Page cross-document navigation installs its replacement PageVm @@ -882,6 +994,29 @@ impl RenderRuntimeTurn { detached, ) } + Self::BeginLivePageNavigationPostParseLifecycle { + token, + page_tasks, + stage, + started, + target_stage, + follow_count, + completion, + } => { + let (completion, detached) = completion.detach_command_observer(); + ( + Self::BeginLivePageNavigationPostParseLifecycle { + token, + page_tasks, + stage, + started, + target_stage, + follow_count, + completion, + }, + detached, + ) + } Self::ContinueLivePageNavigationPostParseLifecycle { token, document, @@ -1415,6 +1550,7 @@ impl RendererOwnerHandle { completion: LivePagePendingNavigationCompletion::PublishedPageCreation { navigation_reply_policy, + document_continuation_publisher: None, }, }, ), @@ -1504,6 +1640,7 @@ impl RendererOwnerHandle { follow_count: 0, completion: LivePagePendingNavigationCompletion::PublishedPageCreation { navigation_reply_policy, + document_continuation_publisher: None, }, }, ), @@ -1591,9 +1728,15 @@ impl RendererOwnerHandle { async fn publish_pending_page_creation_and_continue( &self, pending: RendererPendingPageCreation, - continuation: RenderRuntimePageCreationContinuation, + mut continuation: RenderRuntimePageCreationContinuation, ) -> RenderRuntimeDispatchOutcome { let token = pending.token; + let (continuation_publisher, continuation_observer) = + super::renderer_document_continuation_channel(); + assert!( + continuation.install_document_continuation_publisher(continuation_publisher), + "a DocumentCommit page-creation continuation must own its typed completion publisher" + ); if matches!( continuation.turn(), RenderRuntimeTurn::FollowLivePagePendingLocationNavigation { @@ -1612,10 +1755,15 @@ impl RendererOwnerHandle { .finalize_pending_page_creation_on_owner_lane(pending) .await { - Ok(attached_page) => RenderRuntimeDispatchOutcome::PageCreatedAndContinueNavigation { - page: Box::new(attached_page), - continuation, - }, + Ok(mut attached_page) => { + attached_page + .creation_diagnostics + .document_continuation_observer = Some(continuation_observer); + RenderRuntimeDispatchOutcome::PageCreatedAndContinueNavigation { + page: Box::new(attached_page), + continuation, + } + } Err(error) => self.retire_failed_page_creation(token, error).await, } } @@ -1694,7 +1842,7 @@ impl RendererOwnerHandle { document: RendererDocumentLifecycleIdentity, target_stage: PageVmInitStage, follow_count: usize, - completion: LivePagePendingNavigationCompletion, + mut completion: LivePagePendingNavigationCompletion, ) -> RenderRuntimeDispatchOutcome { let retire_page_on_failure = completion.retires_page_on_navigation_failure(); let mut entry = match take_entry_for_command_on_bound_owner_local_store(token) { @@ -1722,7 +1870,12 @@ impl RendererOwnerHandle { completion, ), DocumentLifecycleObserverOutcome::Pending => { - self.restore_live_page_entry(token, entry); + self.restore_live_page_entry_and_settle_document_continuation( + token, + entry, + &mut completion, + ) + .await; RenderRuntimeDispatchOutcome::ContinueAfterPageWake { turn: Box::new( RenderRuntimeTurn::ContinueLivePageNavigationPostParseLifecycle { @@ -1737,7 +1890,12 @@ impl RendererOwnerHandle { } } DocumentLifecycleObserverOutcome::DocumentReplaced { document } => { - self.restore_live_page_entry(token, entry); + self.restore_live_page_entry_and_settle_document_continuation( + token, + entry, + &mut completion, + ) + .await; RenderRuntimeDispatchOutcome::ContinueAfterPageWake { turn: Box::new( RenderRuntimeTurn::ContinueLivePageNavigationPostParseLifecycle { @@ -1919,12 +2077,13 @@ impl RendererOwnerHandle { } /// Completes the protocol-side owner reservation after renderer bootstrap - /// has either opened its concrete stream or failed before doing so. + /// has either established its output residence or failed before doing so. /// - /// The journal and this marker share one FIFO transport. Protocol therefore - /// observes `Opened` before this release on success, while an early failure - /// produces only the release. Never move this to the navigation completion - /// channel: that independent channel cannot order against stream opening. + /// Initial Page creation opens a concrete stream before this marker. A + /// same-Page replacement reuses the already-open stable Page stream. Never + /// move this to the navigation completion channel: that independent + /// channel cannot order against initial stream opening or replacement + /// validation. fn release_page_output_reservation(&self, reservation: RendererPageReservationToken) { if let Some(sender) = self .state @@ -2398,6 +2557,12 @@ impl RendererOwnerHandle { self.release_page_output_reservation(reservation); outcome } + RendererOwnerCommand::ReserveLivePageReplacement { token } => { + owner_local_store_session(owner_local_store) + .reserve_live_page_replacement(token) + .map(RendererOwnerReply::LivePageReplacementReserved) + .into() + } RendererOwnerCommand::PrepareStreamingRawDocument { token, request } => { let outcome = self .prepare_renderer_document_on_owner_local_store( @@ -2440,18 +2605,62 @@ impl RendererOwnerHandle { )) .into(); } + if matches!( + token.script_agent_admission(), + RendererScriptAgentAdmission::ExistingPageReplacement { .. } + ) { + owner_local_store.cancel_prepared_document(token); + return Err(anyhow!( + "prepared live Page replacement commit is not installed yet" + )) + .into(); + } match owner_local_store.take_prepared_document(token) { Ok(residence) => { self.create_page_reply_from_prepared_document_on_owner_local_store( token.page_id(), residence, - owner_local_store, ) .await } Err(error) => Err(error).into(), } } + RendererOwnerCommand::CommitPreparedRendererPageReplacement { permit } => { + let token = permit.prepared_document(); + if token.local_host_id() != self.state.owner_local_host_id { + return Err(anyhow!( + "prepared Page replacement permit belongs to renderer owner {}, not {}", + token.local_host_id().as_u64(), + self.state.owner_local_host_id.as_u64() + )) + .into(); + } + let RendererScriptAgentAdmission::ExistingPageReplacement { + expected_vm_creation_id, + .. + } = token.script_agent_admission() + else { + owner_local_store.cancel_prepared_document(token); + return Err(anyhow::Error::new( + RendererPageReplacementCommitError::page_preserved(anyhow!( + "prepared initial Document cannot commit as a live Page replacement" + )), + )) + .into(); + }; + match owner_local_store.take_current_prepared_page_replacement(token) { + Ok(residence) => { + self.commit_prepared_page_replacement_on_owner_local_store( + token, + expected_vm_creation_id, + residence, + ) + .await + } + Err(error) => Err(anyhow::Error::new(error)).into(), + } + } RendererOwnerCommand::CancelPreparedRendererDocument { token } => { if token.local_host_id() != self.state.owner_local_host_id { return Err(anyhow!( @@ -2903,6 +3112,34 @@ impl RendererOwnerHandle { } } } + RenderRuntimeDispatchOutcome::ReplyAndContinueNavigation { + reply, + turn, + } => match pending_turn.reply_tx { + Some(reply_tx) => { + if reply_tx.send(Ok(*reply)).is_ok() { + pending_turns.push_back(RenderRuntimePendingTurn { + reply_tx: None, + turn: *turn, + allow_command_overtake: false, + command_admission_output_predecessor: None, + }); + } else { + self.detach_navigation_or_cancel_pending_turn( + *turn, + &mut pending_turns, + ); + } + } + None => { + pending_turns.push_back(RenderRuntimePendingTurn { + reply_tx: None, + turn: *turn, + allow_command_overtake: false, + command_admission_output_predecessor: None, + }); + } + }, RenderRuntimeDispatchOutcome::BackgroundComplete(result) | RenderRuntimeDispatchOutcome::PageTurnComplete { result, .. } => { if let Some(reply_tx) = pending_turn.reply_tx { @@ -2949,10 +3186,11 @@ impl RendererOwnerHandle { &mut pending_turns, ); } else { + let allow_command_overtake = !turn.must_precede_ready_command(); pending_turns.push_back(RenderRuntimePendingTurn { reply_tx: pending_turn.reply_tx, turn: *turn, - allow_command_overtake: true, + allow_command_overtake, command_admission_output_predecessor: pending_turn .command_admission_output_predecessor .take(), @@ -3224,6 +3462,18 @@ impl RendererOwnerHandle { remove_page_on_bound_owner_local_store(token); } } + RenderRuntimeDispatchOutcome::ReplyAndContinueNavigation { reply, turn } => { + if reply_tx.send(Ok(*reply)).is_ok() { + pending_turns.push_back(RenderRuntimePendingTurn { + reply_tx: None, + turn: *turn, + allow_command_overtake: false, + command_admission_output_predecessor: None, + }); + } else { + self.detach_navigation_or_cancel_pending_turn(*turn, pending_turns); + } + } RenderRuntimeDispatchOutcome::BackgroundComplete(result) | RenderRuntimeDispatchOutcome::PageTurnComplete { result, .. } => { let reply = match result { @@ -3321,7 +3571,8 @@ impl RendererOwnerHandle { drop(page); self.cancel_pending_turn_on_owner_local_store(continuation.into_turn()); } - RenderRuntimeDispatchOutcome::ContinueNextTurn(turn) + RenderRuntimeDispatchOutcome::ReplyAndContinueNavigation { turn, .. } + | RenderRuntimeDispatchOutcome::ContinueNextTurn(turn) | RenderRuntimeDispatchOutcome::ContinueAfterPageWakeOrDeadline { turn, .. } | RenderRuntimeDispatchOutcome::ContinueAfterPageWake { turn, .. } | RenderRuntimeDispatchOutcome::ContinueCommittedDocumentParserAfterPageWake { @@ -3366,15 +3617,53 @@ impl RendererOwnerHandle { } } - fn restore_live_page_entry(&self, token: RendererPageToken, mut entry: LivePageEntry) { + fn restore_live_page_entry(&self, token: RendererPageToken, entry: LivePageEntry) { + let _ = self.restore_live_page_entry_with_output_fence(token, entry); + } + + fn restore_live_page_entry_with_output_fence( + &self, + token: RendererPageToken, + mut entry: LivePageEntry, + ) -> Option { // Freeze this owner turn before making the Page resident again. The // concrete publication is already source-bound, so a later lifecycle // turn never needs to rescan or publish output on this turn's behalf. - let output = entry.page_vm_mut().settle_renderer_output_publication(); + let output = entry + .active_page_vm() + .is_some_and(PageVm::has_live_script_vm) + .then(|| entry.page_vm_mut().settle_renderer_output_publication()) + .flatten(); + // A DocumentCommit continuation can span several admitted owner + // turns. Earlier turns may already have settled Runtime/lifecycle + // output, so the terminal fence must cover the complete Page stream + // tail rather than only the optional batch settled by this turn. + let output_fence = entry + .page_vm() + .renderer_output_tail_cursor() + .map(|cursor| entry.page_vm().declare_renderer_output_fence(cursor)); restore_entry_after_command_on_bound_owner_local_store(token, entry); if let Some(output) = output { self.publish_renderer_output(output); } + output_fence + } + + async fn restore_live_page_entry_and_settle_document_continuation( + &self, + token: RendererPageToken, + entry: LivePageEntry, + completion: &mut LivePagePendingNavigationCompletion, + ) { + let Some(publisher) = completion.take_document_continuation_publisher() else { + self.restore_live_page_entry(token, entry); + return; + }; + let (entry, page_state_result) = + commit_page_state_on_entry_via_local_task(self.state.local_executor.clone(), entry) + .await; + let output_fence = self.restore_live_page_entry_with_output_fence(token, entry); + publisher.settle(output_fence, page_state_result.ok()); } fn retire_pending_phase_one_page_entry( @@ -3477,7 +3766,41 @@ impl RendererOwnerHandle { } entry.settle_standalone_navigation_follow(false); self.restore_live_page_entry(token, entry); - disposition.into_dispatch_outcome(token, page_creation_publication) + match disposition { + LivePageNavigationFailureDisposition::PublishToPageCreation(failure) => { + match page_creation_publication + .expect("Page-creation failure disposition must publish before retirement") + { + Ok(PageCreationNavigationFailurePublication::Recorded) => { + RenderRuntimeDispatchOutcome::PageCreationNavigationFailurePublished { + token, + failure, + } + } + Ok(PageCreationNavigationFailurePublication::AlreadyRecorded) => { + RenderRuntimeDispatchOutcome::BackgroundComplete(Ok(())) + } + Ok(PageCreationNavigationFailurePublication::NoActiveCreationObserver) => { + RenderRuntimeDispatchOutcome::BackgroundComplete(Err(anyhow!( + "unobserved background navigation failure: {failure}" + ))) + } + Err(error) => RenderRuntimeDispatchOutcome::BackgroundComplete(Err(error)), + } + } + LivePageNavigationFailureDisposition::ReturnToInitiator(error) => Err(error).into(), + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator(error) => { + let error = if should_retire_page || had_uncommitted_page_vm { + RendererPageReplacementCommitError::page_retired(error) + } else { + RendererPageReplacementCommitError::page_preserved(error) + }; + Err(anyhow::Error::new(error)).into() + } + LivePageNavigationFailureDisposition::ReportBackground(failure) => { + RenderRuntimeDispatchOutcome::BackgroundComplete(Err(anyhow!(failure.to_string()))) + } + } } /// Compute the earliest Page-owned task deadline. This combines JavaScript @@ -3966,7 +4289,8 @@ impl RendererOwnerHandle { self.detach_navigation_or_cancel_pending_turn(parked_turn.turn, pending_turns); None } else { - let allow_command_overtake = parked_turn.condition.allows_command_overtake(); + let allow_command_overtake = parked_turn.condition.allows_command_overtake() + && !parked_turn.turn.must_precede_ready_command(); Some(RenderRuntimePendingTurn { reply_tx: parked_turn.reply_tx, turn: parked_turn.turn, @@ -4674,7 +4998,20 @@ impl RendererOwnerHandle { ); } } - RenderRuntimeTurn::ContinueLivePageNavigationPostParseLifecycle { token, .. } => { + RenderRuntimeTurn::BeginLivePageNavigationPostParseLifecycle { + token, + completion, + .. + } + | RenderRuntimeTurn::ContinueLivePageNavigationPostParseLifecycle { + token, + completion, + .. + } => { + debug_assert!( + completion.retires_page_on_navigation_failure(), + "live navigation with a detached command observer should continue as background work" + ); remove_page_on_bound_owner_local_store(token); } RenderRuntimeTurn::FinishHtmlCreatePage { .. } @@ -4900,12 +5237,24 @@ impl RendererOwnerHandle { &self, token: RendererPageToken, mut entry: LivePageEntry, - completion: LivePagePendingNavigationCompletion, + mut completion: LivePagePendingNavigationCompletion, ) -> RenderRuntimeDispatchOutcome { entry.settle_standalone_navigation_follow(true); + let document_continuation_publisher = match &mut completion { + LivePagePendingNavigationCompletion::PublishedPageCreation { + document_continuation_publisher, + .. + } + | LivePagePendingNavigationCompletion::PublishedPreparedPageReplacement { + document_continuation_publisher, + .. + } => document_continuation_publisher.take(), + _ => None, + }; match completion { LivePagePendingNavigationCompletion::Background - | LivePagePendingNavigationCompletion::PublishedPageCreation { .. } => { + | LivePagePendingNavigationCompletion::PublishedPageCreation { .. } + | LivePagePendingNavigationCompletion::PublishedPreparedPageReplacement { .. } => { let lifecycle_to_resume = entry.active_page_vm().and_then(|page_vm| { let lifecycle = page_vm.document_lifecycle.current_snapshot(); (lifecycle.load.is_none() && lifecycle.terminated.is_none()) @@ -4916,7 +5265,10 @@ impl RendererOwnerHandle { entry, ) .await; - self.restore_live_page_entry(token, entry); + let output_fence = self.restore_live_page_entry_with_output_fence(token, entry); + if let Some(publisher) = document_continuation_publisher { + publisher.settle(output_fence, page_state_result.as_ref().ok().cloned()); + } match page_state_result { Ok(_) => { if let Some(document) = lifecycle_to_resume { @@ -4936,6 +5288,16 @@ impl RendererOwnerHandle { self.restore_live_page_entry(token, entry); self.finish_pending_page_creation(pending).await } + LivePagePendingNavigationCompletion::CommitPreparedPageReplacement { .. } => { + self.finish_prepared_page_replacement_reply( + token, + entry, + None, + PreparedPageReplacementAfterRestore::None, + None, + ) + .await + } LivePagePendingNavigationCompletion::ReplyWithSnapshot { reply, capture_policy, @@ -4995,6 +5357,330 @@ impl RendererOwnerHandle { } } + fn signal_prepared_replacement_document_commit( + &self, + token: RendererPageToken, + document_commit: super::owner_local_store::PublishedReplacementDocument, + ) { + tracing::debug!( + page_id = token.page_id.as_u64(), + navigation_handoff = ?document_commit.navigation_handoff, + vm_creation_id = document_commit.vm_creation_id, + view_generation = document_commit.view_generation, + "published browser-prepared replacement Document" + ); + self.signal_replacement_document_view_settled(token, document_commit.vm_creation_id); + } + + async fn finish_prepared_page_replacement_reply( + &self, + token: RendererPageToken, + entry: LivePageEntry, + mut continuation: Option, + after_restore: PreparedPageReplacementAfterRestore, + pending_download: Option, + ) -> RenderRuntimeDispatchOutcome { + let continuation_observer = continuation.as_mut().map(|turn| { + let (publisher, observer) = super::renderer_document_continuation_channel(); + assert!( + turn.install_document_continuation_publisher(publisher), + "a DocumentCommit replacement continuation must own its typed completion publisher" + ); + observer + }); + let (entry, result) = finalize_prepared_page_replacement_on_entry_via_local_task( + self.state.local_executor.clone(), + token, + entry, + ) + .await; + match result { + Ok((mut replacement, renderer_output)) => { + replacement.pending_download = pending_download; + replacement + .creation_diagnostics + .document_continuation_observer = continuation_observer; + self.restore_live_page_entry(token, entry); + if let Some(output) = renderer_output { + self.publish_renderer_output(output); + } + match after_restore { + PreparedPageReplacementAfterRestore::None => {} + PreparedPageReplacementAfterRestore::SignalPendingPhaseOne { wake_token } => { + let admission = + pending_phase_one_admission_after_restore_on_bound_owner_local_store( + wake_token, + ); + self.signal_pending_phase_one_admission(wake_token, admission); + } + } + let reply = RendererOwnerReply::PageReplacementCommitted(Box::new(replacement)); + match continuation { + Some(turn) => RenderRuntimeDispatchOutcome::ReplyAndContinueNavigation { + reply: Box::new(reply), + turn: Box::new(turn), + }, + None => Ok(reply).into(), + } + } + Err(error) => { + self.finish_live_page_navigation_failure( + token, + entry, + false, + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator(error), + ) + .await + } + } + } + + async fn finish_prepared_page_replacement_bootstrap_failure( + &self, + target: PreparedPageReplacementInstallation, + error: anyhow::Error, + ) -> RenderRuntimeDispatchOutcome { + self.finish_live_page_navigation_failure( + target.token, + target.entry, + false, + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator(error), + ) + .await + } + + async fn install_streaming_prepared_page_replacement( + &self, + target: PreparedPageReplacementInstallation, + metadata: PreparedReplacementDocumentMetadata, + outcome: ParseTimePageVmCreationOutcome, + requested_stage: PageVmInitStage, + reply_boundary: crate::RendererReplyBoundary, + navigation_reply_policy: NavigationReplyPolicy, + ) -> RenderRuntimeDispatchOutcome { + let PreparedPageReplacementInstallation { + token, + mut entry, + navigation_handoff, + } = target; + let completion = LivePagePendingNavigationCompletion::CommitPreparedPageReplacement { + navigation_reply_policy, + }; + match outcome { + ParseTimePageVmCreationOutcome::PendingPhaseOne(residence) => { + let pending = PageVmPendingPhaseOneNavigation::new( + residence, + PageVmFollowedNavigationMetadata::default(), + ); + let (returned_entry, install_result) = + install_prepared_replacement_pending_phase_one_on_entry_via_local_task( + self.state.local_executor.clone(), + entry, + pending, + navigation_handoff, + metadata, + ) + .await; + entry = returned_entry; + let (wake_token, document_commit) = match install_result { + Ok(installed) => installed, + Err(error) => { + return self + .finish_live_page_navigation_failure( + token, + entry, + false, + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator(error), + ) + .await; + } + }; + self.signal_prepared_replacement_document_commit(token, document_commit); + if matches!(reply_boundary, crate::RendererReplyBoundary::DocumentCommit) { + let turn = + RenderRuntimeTurn::ContinueLivePagePendingLocationNavigationPhaseOne { + token, + follow_count: 0, + completion: LivePagePendingNavigationCompletion::PublishedPreparedPageReplacement { + navigation_reply_policy, + document_continuation_publisher: None, + }, + }; + self.finish_prepared_page_replacement_reply( + token, + entry, + Some(turn), + PreparedPageReplacementAfterRestore::SignalPendingPhaseOne { wake_token }, + None, + ) + .await + } else { + let turn = + RenderRuntimeTurn::ContinueLivePagePendingLocationNavigationPhaseOne { + token, + follow_count: 0, + completion, + }; + self.restore_live_page_entry(token, entry); + let admission = + pending_phase_one_admission_after_restore_on_bound_owner_local_store( + wake_token, + ); + self.signal_pending_phase_one_admission(wake_token, admission); + RenderRuntimeDispatchOutcome::ContinueAfterPageWake { + turn: Box::new(turn), + wake_token, + } + } + } + ParseTimePageVmCreationOutcome::TriggeredNavigation { page_vm, stage } => { + let (returned_entry, install_result) = + install_prepared_replacement_page_vm_on_entry_via_local_task( + self.state.local_executor.clone(), + entry, + page_vm, + navigation_handoff, + metadata, + ) + .await; + entry = returned_entry; + let document_commit = match install_result { + Ok(document_commit) => document_commit, + Err(error) => { + return self + .finish_live_page_navigation_failure( + token, + entry, + false, + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator(error), + ) + .await; + } + }; + self.signal_prepared_replacement_document_commit(token, document_commit); + if navigation_reply_policy.returns_with_pending_navigation() { + return self + .finish_prepared_page_replacement_reply( + token, + entry, + None, + PreparedPageReplacementAfterRestore::None, + None, + ) + .await; + } + if matches!(reply_boundary, crate::RendererReplyBoundary::DocumentCommit) { + let turn = RenderRuntimeTurn::FollowLivePagePendingLocationNavigation { + token, + stage, + follow_count: 0, + completion: + LivePagePendingNavigationCompletion::PublishedPreparedPageReplacement { + navigation_reply_policy, + document_continuation_publisher: None, + }, + }; + self.finish_prepared_page_replacement_reply( + token, + entry, + Some(turn), + PreparedPageReplacementAfterRestore::None, + None, + ) + .await + } else { + self.continue_live_page_pending_navigation(token, entry, stage, 0, completion) + } + } + ParseTimePageVmCreationOutcome::ContinuePhaseTwo { + page_vm, + page_tasks, + stage, + started, + } => { + let (returned_entry, install_result) = + install_prepared_replacement_page_vm_on_entry_via_local_task( + self.state.local_executor.clone(), + entry, + page_vm, + navigation_handoff, + metadata, + ) + .await; + entry = returned_entry; + let document_commit = match install_result { + Ok(document_commit) => document_commit, + Err(error) => { + return self + .finish_live_page_navigation_failure( + token, + entry, + false, + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator(error), + ) + .await; + } + }; + self.signal_prepared_replacement_document_commit(token, document_commit); + if matches!(reply_boundary, crate::RendererReplyBoundary::DocumentCommit) { + let continuation = + RenderRuntimeTurn::BeginLivePageNavigationPostParseLifecycle { + token, + page_tasks, + stage, + started, + target_stage: requested_stage, + follow_count: 0, + completion: + LivePagePendingNavigationCompletion::PublishedPreparedPageReplacement { + navigation_reply_policy, + document_continuation_publisher: None, + }, + }; + return self + .finish_prepared_page_replacement_reply( + token, + entry, + Some(continuation), + PreparedPageReplacementAfterRestore::None, + None, + ) + .await; + } + let (entry, lifecycle_result) = begin_post_parse_lifecycle_on_entry_via_local_task( + self.state.local_executor.clone(), + entry, + page_tasks, + stage, + started, + ) + .await; + let lifecycle_outcome = match lifecycle_result { + Ok(outcome) => outcome, + Err(error) => { + return self + .finish_live_page_navigation_failure( + token, + entry, + false, + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator(error), + ) + .await; + } + }; + self.handle_live_page_post_parse_lifecycle_outcome( + token, + entry, + requested_stage, + 0, + completion, + lifecycle_outcome, + ) + .await + } + } + } + /// Capture the already-published replacement before a host-facing wait /// resumes. Cross-creation publication is deliberately forbidden here; /// it belongs to the typed response/Document commit transition. @@ -5015,14 +5701,29 @@ impl RendererOwnerHandle { &self, token: RendererPageToken, mut entry: LivePageEntry, - completion: LivePagePendingNavigationCompletion, + mut completion: LivePagePendingNavigationCompletion, download: RendererPendingDownloadActivation, ) -> RenderRuntimeDispatchOutcome { entry.settle_standalone_navigation_follow(true); + let document_continuation_publisher = match &mut completion { + LivePagePendingNavigationCompletion::PublishedPageCreation { + document_continuation_publisher, + .. + } + | LivePagePendingNavigationCompletion::PublishedPreparedPageReplacement { + document_continuation_publisher, + .. + } => document_continuation_publisher.take(), + _ => None, + }; match completion { LivePagePendingNavigationCompletion::Background - | LivePagePendingNavigationCompletion::PublishedPageCreation { .. } => { - self.restore_live_page_entry(token, entry); + | LivePagePendingNavigationCompletion::PublishedPageCreation { .. } + | LivePagePendingNavigationCompletion::PublishedPreparedPageReplacement { .. } => { + let output_fence = self.restore_live_page_entry_with_output_fence(token, entry); + if let Some(publisher) = document_continuation_publisher { + publisher.settle(output_fence, None); + } drop(download); RenderRuntimeDispatchOutcome::BackgroundComplete(Ok(())) } @@ -5031,6 +5732,16 @@ impl RendererOwnerHandle { self.finish_pending_page_creation(pending.with_pending_download(download)) .await } + LivePagePendingNavigationCompletion::CommitPreparedPageReplacement { .. } => { + self.finish_prepared_page_replacement_reply( + token, + entry, + None, + PreparedPageReplacementAfterRestore::None, + Some(download), + ) + .await + } LivePagePendingNavigationCompletion::ReplyWithSnapshot { reply, capture_policy, @@ -6118,9 +6829,10 @@ impl RendererOwnerHandle { LivePageNavigationFailureDisposition::ReportBackground(failure) } LivePageNavigationFailureRecipient::Initiator => { - LivePageNavigationFailureDisposition::ReturnToInitiator(anyhow!( - failure.to_string() - )) + live_page_navigation_initiator_failure( + &completion, + anyhow!(failure.to_string()), + ) } }; return self @@ -6159,7 +6871,7 @@ impl RendererOwnerHandle { token, entry, retire_page_on_failure, - LivePageNavigationFailureDisposition::ReturnToInitiator(error), + live_page_navigation_initiator_failure(&completion, error), ) .await; } @@ -6233,7 +6945,7 @@ impl RendererOwnerHandle { entry: LivePageEntry, target_stage: PageVmInitStage, follow_count: usize, - completion: LivePagePendingNavigationCompletion, + mut completion: LivePagePendingNavigationCompletion, outcome: DocumentLifecycleTurnOutcome, ) -> RenderRuntimeDispatchOutcome { match outcome { @@ -6277,7 +6989,12 @@ impl RendererOwnerHandle { readiness: DocumentLifecycleTurnReadiness::Blocked { document }, .. } => { - self.restore_live_page_entry(token, entry); + self.restore_live_page_entry_and_settle_document_continuation( + token, + entry, + &mut completion, + ) + .await; RenderRuntimeDispatchOutcome::ContinueAfterPageWake { turn: Box::new( RenderRuntimeTurn::ContinueLivePageNavigationPostParseLifecycle { @@ -6301,6 +7018,53 @@ impl RendererOwnerHandle { } } + async fn begin_live_page_navigation_post_parse_lifecycle_turn( + &self, + token: RendererPageToken, + page_tasks: Vec, + stage: PageVmInitStage, + started: Instant, + target_stage: PageVmInitStage, + follow_count: usize, + completion: LivePagePendingNavigationCompletion, + ) -> RenderRuntimeDispatchOutcome { + let retire_page_on_failure = completion.retires_page_on_navigation_failure(); + let entry = match take_entry_for_command_on_bound_owner_local_store(token) { + Ok(entry) => entry, + Err(error) => return Err(error).into(), + }; + let (entry, lifecycle_result) = begin_post_parse_lifecycle_on_entry_via_local_task( + self.state.local_executor.clone(), + entry, + page_tasks, + stage, + started, + ) + .await; + match lifecycle_result { + Ok(outcome) => { + self.handle_live_page_post_parse_lifecycle_outcome( + token, + entry, + target_stage, + follow_count, + completion, + outcome, + ) + .await + } + Err(error) => { + self.finish_live_page_navigation_failure( + token, + entry, + retire_page_on_failure, + live_page_navigation_initiator_failure(&completion, error), + ) + .await + } + } + } + async fn run_pending_turn_on_owner_local_store( &self, turn: RenderRuntimeTurn, @@ -6363,6 +7127,7 @@ impl RendererOwnerHandle { completion: LivePagePendingNavigationCompletion::PublishedPageCreation { navigation_reply_policy, + document_continuation_publisher: None, }, }, ), @@ -6402,6 +7167,7 @@ impl RendererOwnerHandle { completion: LivePagePendingNavigationCompletion::PublishedPageCreation { navigation_reply_policy, + document_continuation_publisher: None, }, }, ), @@ -6441,6 +7207,7 @@ impl RendererOwnerHandle { completion: LivePagePendingNavigationCompletion::PublishedPageCreation { navigation_reply_policy, + document_continuation_publisher: None, }, }, ), @@ -6570,12 +7337,32 @@ impl RendererOwnerHandle { token, entry, retire_page_on_failure, - LivePageNavigationFailureDisposition::ReturnToInitiator(error), + live_page_navigation_initiator_failure(&completion, error), ) .await } } } + RenderRuntimeTurn::BeginLivePageNavigationPostParseLifecycle { + token, + page_tasks, + stage, + started, + target_stage, + follow_count, + completion, + } => { + self.begin_live_page_navigation_post_parse_lifecycle_turn( + token, + page_tasks, + stage, + started, + target_stage, + follow_count, + completion, + ) + .await + } RenderRuntimeTurn::ContinueLivePageNavigationPostParseLifecycle { token, document, @@ -6787,7 +7574,7 @@ impl RendererOwnerHandle { async fn create_page_reply_from_html_request_on_owner_local_store( &self, request: RendererCreateHtmlPageRequest, - _owner_local_store: &mut RendererOwnerLocalStore, + owner_local_store: &mut RendererOwnerLocalStore, ) -> RenderRuntimeDispatchOutcome { let RendererCreateHtmlPageRequest { page_reservation, @@ -6864,6 +7651,19 @@ impl RendererOwnerHandle { )) .into(); } + if owner_local_store.has_staged_related_initial_empty_page(page_reservation) + && (!moli_url::is_about_blank(&final_url) || !moli_url::is_about_blank(&requested_url)) + { + return Err(anyhow!( + "staged auxiliary initial realm can only adopt its matching about:blank request" + )) + .into(); + } + let staged_related_initial_empty_page = + match owner_local_store.take_staged_related_initial_empty_page(page_reservation) { + Ok(staged) => staged, + Err(error) => return Err(error).into(), + }; let document_policy_container = DocumentPolicyContainer::from_navigation_response_headers( &response_headers, &final_url, @@ -6873,22 +7673,17 @@ impl RendererOwnerHandle { crate::document_language::document_default_language_from_headers(&response_headers); let document_last_modified = crate::document_last_modified::document_last_modified_from_headers(&response_headers); + let reserved_service_worker_client_id = if staged_related_initial_empty_page.is_some() { + drop(reserved_service_worker_client); + None + } else { + reserved_service_worker_client.map(RendererReservedServiceWorkerClient::release) + }; let owner = self.clone(); let phase_one_result = self .run_owner_lane_local_task(async move { let page_id = page_reservation.page_id(); let owner_local_context = owner.owner_local_context()?; - let owner_wake = owner.owner_wake_sender_for_page(&owner_local_context, page_id); - let renderer_document_isolate_allocator = RendererDocumentIsolateAllocator::new( - owner_local_context.clone(), - page_id, - page_reservation.script_agent_admission(), - ); - let runtime_hooks = PageVmRuntimeHooks::with_owner_wake( - owner_wake, - owner.state.browser_context_runtime.clone(), - ) - .with_renderer_document_isolate_allocator(renderer_document_isolate_allocator); let local_executor = owner.state.local_executor.clone(); debug!(stage = ?stage, %final_url, "starting page VM creation from html"); let env = PageVmEnvConfig { @@ -6922,9 +7717,34 @@ impl RendererOwnerHandle { main_document_commit, top_level_storage_key, navigation_bootstrap_entry: None, - reserved_service_worker_client_id: reserved_service_worker_client - .map(RendererReservedServiceWorkerClient::release), + reserved_service_worker_client_id, }; + if let Some(mut page_vm) = staged_related_initial_empty_page { + let started = Instant::now(); + let triggered_navigation = + page_vm.adopt_staged_related_initial_empty(&loader, &env)?; + return Ok(if triggered_navigation { + ParseTimePageVmCreationOutcome::TriggeredNavigation { page_vm, stage } + } else { + ParseTimePageVmCreationOutcome::ContinuePhaseTwo { + page_vm, + page_tasks: Vec::new(), + stage, + started, + } + }); + } + let owner_wake = owner.owner_wake_sender_for_page(&owner_local_context, page_id); + let renderer_document_isolate_allocator = RendererDocumentIsolateAllocator::new( + owner_local_context, + page_id, + page_reservation.script_agent_admission(), + ); + let runtime_hooks = PageVmRuntimeHooks::with_owner_wake( + owner_wake, + owner.state.browser_context_runtime.clone(), + ) + .with_renderer_document_isolate_allocator(renderer_document_isolate_allocator); let bootstrap = Box::pin(async move { let started = Instant::now(); ConcurrentParseTimeRuntime::finish_creation_from_html_bootstrap( @@ -7078,7 +7898,6 @@ impl RendererOwnerHandle { &self, page_id: PageId, residence: RendererPreparedDocumentResidence, - owner_local_store: &mut RendererOwnerLocalStore, ) -> RenderRuntimeDispatchOutcome { let RendererPreparedDocumentResidence { request, @@ -7086,13 +7905,96 @@ impl RendererOwnerHandle { isolate_bootstrap, isolate_reservation, } = residence; + let commit_target = + PreparedDocumentCommitTarget::InitialPage(Box::new(PreparedInitialPageTarget { + isolate_allocator, + isolate_bootstrap, + isolate_reservation, + })); self.create_page_reply_from_streaming_raw_request_on_owner_local_store( page_id, request, - isolate_allocator, + commit_target, + ) + .await + } + + async fn commit_prepared_page_replacement_on_owner_local_store( + &self, + reservation_token: RendererPageReservationToken, + expected_vm_creation_id: u64, + residence: RendererPreparedDocumentResidence, + ) -> RenderRuntimeDispatchOutcome { + let owner_local_context = match self.owner_local_context() { + Ok(context) => context, + Err(error) => return Err(error).into(), + }; + let token = renderer_page_token_for_owner_context( + &owner_local_context, + reservation_token.page_id(), + ); + let mut entry = match take_entry_for_command_on_bound_owner_local_store(token) { + Ok(entry) => entry, + Err(error) => { + drop(residence); + return Err(error).into(); + } + }; + if let Err(error) = entry.validate_prepared_replacement_generation(expected_vm_creation_id) + { + drop(residence); + return self + .finish_live_page_navigation_failure( + token, + entry, + false, + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator(error), + ) + .await; + } + + let top_level_navigation_dispatch = residence.request.top_level_navigation_dispatch; + entry.set_top_level_navigation_dispatch(top_level_navigation_dispatch); + let RendererPreparedDocumentResidence { + request, + isolate_allocator: _, isolate_bootstrap, isolate_reservation, - owner_local_store, + } = residence; + let (entry, begin_result) = begin_prepared_document_replacement_on_entry_via_local_task( + self.state.local_executor.clone(), + entry, + isolate_bootstrap, + isolate_reservation, + ) + .await; + let (runtime_hooks, navigation_handoff) = match begin_result { + Ok(result) => result, + Err(error) => { + return self + .finish_live_page_navigation_failure( + token, + entry, + false, + LivePageNavigationFailureDisposition::ReturnPageReplacementToInitiator( + error, + ), + ) + .await; + } + }; + let commit_target = PreparedDocumentCommitTarget::PageReplacement(Box::new( + PreparedPageReplacementTarget { + token, + entry, + runtime_hooks, + navigation_handoff, + }, + )); + self.create_page_reply_from_streaming_raw_request_on_owner_local_store( + reservation_token.page_id(), + request, + commit_target, ) .await } @@ -7102,10 +8004,7 @@ impl RendererOwnerHandle { &self, page_id: PageId, request: RendererCreateStreamingRawPageRequest, - isolate_allocator: RendererDocumentIsolateAllocator, - isolate_bootstrap: RendererDocumentIsolateBootstrap, - isolate_reservation: RendererDocumentIsolateReservation, - _owner_local_store: &mut RendererOwnerLocalStore, + commit_target: PreparedDocumentCommitTarget, ) -> RenderRuntimeDispatchOutcome { let RendererCreateStreamingRawPageRequest { root_frame_id, @@ -7181,17 +8080,51 @@ impl RendererOwnerHandle { crate::document_language::document_default_language_from_headers(&response_headers); let document_last_modified = crate::document_last_modified::document_last_modified_from_headers(&response_headers); + let (runtime_hooks, replacement_target) = match commit_target { + PreparedDocumentCommitTarget::InitialPage(target) => { + let PreparedInitialPageTarget { + isolate_allocator, + isolate_bootstrap, + isolate_reservation, + } = *target; + let owner_local_context = match self.owner_local_context() { + Ok(context) => context, + Err(error) => return Err(error).into(), + }; + let owner_wake = self.owner_wake_sender_for_page(&owner_local_context, page_id); + let runtime_hooks = PageVmRuntimeHooks::with_owner_wake( + owner_wake, + self.state.browser_context_runtime.clone(), + ) + .with_renderer_document_isolate_allocator(isolate_allocator); + let runtime_hooks = match runtime_hooks + .with_prepared_renderer_document_isolate(isolate_bootstrap, isolate_reservation) + { + Ok(runtime_hooks) => runtime_hooks, + Err(error) => return Err(error).into(), + }; + (runtime_hooks, None) + } + PreparedDocumentCommitTarget::PageReplacement(target) => { + let PreparedPageReplacementTarget { + token, + entry, + runtime_hooks, + navigation_handoff, + } = *target; + ( + runtime_hooks, + Some(PreparedPageReplacementInstallation { + token, + entry, + navigation_handoff, + }), + ) + } + }; let owner = self.clone(); let phase_one_result = self .run_owner_lane_local_task(async move { - let owner_local_context = owner.owner_local_context()?; - let owner_wake = owner.owner_wake_sender_for_page(&owner_local_context, page_id); - let runtime_hooks = PageVmRuntimeHooks::with_owner_wake( - owner_wake, - owner.state.browser_context_runtime.clone(), - ) - .with_renderer_document_isolate_allocator(isolate_allocator) - .with_prepared_renderer_document_isolate(isolate_bootstrap, isolate_reservation)?; let local_executor = owner.state.local_executor.clone(); let env = PageVmEnvConfig { web_storage, @@ -7254,16 +8187,43 @@ impl RendererOwnerHandle { }) .await; match phase_one_result { - Ok(StreamingNavigationPageCreationResult::Download(_)) => Err(anyhow!( - "external raw streaming page request produced a download; CDP navigation must branch downloads before renderer page creation" - )) - .into(), + Ok(StreamingNavigationPageCreationResult::Download(_)) => { + let error = anyhow!( + "external raw streaming page request produced a download; CDP navigation must branch downloads before renderer page creation" + ); + match replacement_target { + Some(target) => { + self.finish_prepared_page_replacement_bootstrap_failure(target, error) + .await + } + None => Err(error).into(), + } + } Ok(StreamingNavigationPageCreationResult::Html(result)) => { let StreamingHtmlPageCreationResult { response_status, response_headers, outcome, } = *result; + if let Some(target) = replacement_target { + return self + .install_streaming_prepared_page_replacement( + target, + PreparedReplacementDocumentMetadata { + requested_url, + navigation_initiator_url, + navigation_redirected, + navigation_redirect_count, + response_status, + response_headers, + }, + outcome, + stage, + reply_boundary, + navigation_reply_policy, + ) + .await; + } match outcome { ParseTimePageVmCreationOutcome::PendingPhaseOne(residence) => { let committed_navigation_response = Some(PageVmNavigationResponse { @@ -7296,10 +8256,7 @@ impl RendererOwnerHandle { ) .await } - ParseTimePageVmCreationOutcome::TriggeredNavigation { - mut page_vm, - stage, - } => { + ParseTimePageVmCreationOutcome::TriggeredNavigation { mut page_vm, stage } => { page_vm::attach_navigation_response_to_page_vm( &mut page_vm, PageVmNavigationResponse { @@ -7365,10 +8322,17 @@ impl RendererOwnerHandle { } } } - Err(error) => Err(error).into(), + Err(error) => match replacement_target { + Some(target) => { + self.finish_prepared_page_replacement_bootstrap_failure(target, error) + .await + } + None => Err(error).into(), + }, } } + #[allow(clippy::too_many_arguments)] pub fn record(&self, page_id: PageId) -> Option { self.state.page_table.record(page_id) } diff --git a/moli-renderer-v8/src/runtime/owner_local.rs b/moli-renderer-v8/src/runtime/owner_local.rs index e760f5fb65..f1718d7b85 100644 --- a/moli-renderer-v8/src/runtime/owner_local.rs +++ b/moli-renderer-v8/src/runtime/owner_local.rs @@ -107,6 +107,33 @@ enum RendererPageCommandPendingDispatch { InspectorMain(Box), } +/// A non-owning request for reserving the next Document in one exact live +/// renderer Page. +/// +/// This carries only the renderer owner route and stable Page token. It may be +/// moved into protocol background work without cloning the Page handle or its +/// close authority. +pub struct RendererPageReplacementReservationPending { + render_runtime: RenderRuntimeHandle, + token: RendererPageToken, + _not_send: PhantomData>, +} + +impl RendererPageReplacementReservationPending { + pub async fn await_ready(self) -> Result { + match self + .render_runtime + .dispatch(RendererOwnerCommand::ReserveLivePageReplacement { token: self.token }) + .await? + { + RendererOwnerReply::LivePageReplacementReserved(reservation) => Ok(reservation), + _ => Err(anyhow!( + "renderer owner returned non-reservation reply for live Page replacement" + )), + } + } +} + #[derive(Clone)] pub struct RendererPageTestingHandle { render_runtime: RenderRuntimeHandle, @@ -172,6 +199,52 @@ impl RendererPageHandle { self.committed_document_post_response_continuation.take() } + /// Adopts the current Document's inspector identity without replacing the + /// Page handle or its close/command authority. + pub fn adopt_page_replacement( + &mut self, + replacement: &RendererPageReplacementCommit, + ) -> anyhow::Result<()> { + anyhow::ensure!( + replacement.owner_local_host_id() == self.owner_local_host_id() + && replacement.page_id() == self.renderer_page_id(), + "renderer Page replacement belongs to a different stable Page" + ); + self.devtools_target.detach_page( + self.renderer_page_id(), + "Inspector Page document was replaced", + ); + self.javascript_dialog_broker.dismiss_pending(); + self.devtools_agent_token = replacement.renderer_devtools_agent_token(); + self.javascript_dialog_broker = replacement.javascript_dialog_broker.clone(); + self.devtools_target = replacement.devtools_target.clone(); + Ok(()) + } + + /// Reserves a replacement Document for this exact live Page generation. + /// + /// The renderer owner serializes this snapshot with Page turns. Preparing + /// the replacement fails closed if another cross-document commit changes + /// the resident `PageVm` before the reservation is consumed. A newer + /// unconsumed reservation for the same Page supersedes an older one. + pub async fn reserve_replacement_document_for_navigation( + &self, + ) -> Result { + self.start_replacement_document_reservation() + .await_ready() + .await + } + + pub fn start_replacement_document_reservation( + &self, + ) -> RendererPageReplacementReservationPending { + RendererPageReplacementReservationPending { + render_runtime: self.render_runtime.clone(), + token: self.token(), + _not_send: PhantomData, + } + } + pub fn take_pending_modal_javascript_dialogs(&self) -> Vec { self.javascript_dialog_broker.take_pending() } diff --git a/moli-renderer-v8/src/runtime/owner_local_store/bound.rs b/moli-renderer-v8/src/runtime/owner_local_store/bound.rs index 2fe4c88ca4..3087931970 100644 --- a/moli-renderer-v8/src/runtime/owner_local_store/bound.rs +++ b/moli-renderer-v8/src/runtime/owner_local_store/bound.rs @@ -145,6 +145,26 @@ pub(super) fn reserve_renderer_document_isolate_on_bound_owner_local_store( }) } +pub(crate) fn stage_related_initial_empty_page_on_bound_owner_local_store( + owner: &RendererOwnerLocalContext, + scope: &mut v8::PinScope<'_, '_>, + pending: RendererPendingAuxiliaryPage, + source_environment: &RendererPageScriptEnvironment, + source_bridge_bindings: &crate::native_bridge::bindings::NativeBridgeBindings, + init: RendererRelatedInitialEmptyPageRealmInit, +) -> Result<()> { + with_bound_render_runtime_owner_local_store_session(|session| { + session.store.stage_related_initial_empty_page_for_owner( + owner, + scope, + pending, + source_environment, + source_bridge_bindings, + init, + ) + }) +} + pub(super) fn remove_reserved_renderer_document_isolate_on_bound_owner_local_store( token: RendererPageToken, reservation_id: u64, @@ -481,6 +501,66 @@ pub(in crate::runtime) async fn begin_post_parse_lifecycle_on_entry_via_local_ta .await } +pub(in crate::runtime) async fn begin_prepared_document_replacement_on_entry_via_local_task( + local_executor: JsLocalExecutor, + entry: LivePageEntry, + bootstrap: RendererDocumentIsolateBootstrap, + reservation: RendererDocumentIsolateReservation, +) -> ( + LivePageEntry, + Result<( + PageVmRuntimeHooks, + crate::page_task_queue::RendererTopLevelNavigationHandoff, + )>, +) { + run_entry_on_bound_owner_local_store_local_task(local_executor, entry, move |entry| { + Box::pin(async move { + entry.retire_document_lifecycle_turn(); + entry + .page_vm_mut() + .begin_prepared_document_replacement(bootstrap, reservation) + }) + }) + .await +} + +pub(in crate::runtime) async fn install_prepared_replacement_page_vm_on_entry_via_local_task( + local_executor: JsLocalExecutor, + entry: LivePageEntry, + page_vm: PageVm, + navigation_handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff, + metadata: PreparedReplacementDocumentMetadata, +) -> (LivePageEntry, Result) { + run_entry_on_bound_owner_local_store_local_task(local_executor, entry, move |entry| { + Box::pin(async move { + entry.install_prepared_replacement_page_vm(page_vm, navigation_handoff, metadata) + }) + }) + .await +} + +pub(in crate::runtime) async fn install_prepared_replacement_pending_phase_one_on_entry_via_local_task( + local_executor: JsLocalExecutor, + entry: LivePageEntry, + pending: PageVmPendingPhaseOneNavigation, + navigation_handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff, + metadata: PreparedReplacementDocumentMetadata, +) -> ( + LivePageEntry, + Result<(RendererPageToken, PublishedReplacementDocument)>, +) { + run_entry_on_bound_owner_local_store_local_task(local_executor, entry, move |entry| { + Box::pin(async move { + entry.install_prepared_replacement_pending_phase_one( + pending, + navigation_handoff, + metadata, + ) + }) + }) + .await +} + pub(in crate::runtime) async fn commit_page_state_on_entry_via_local_task( local_executor: JsLocalExecutor, entry: LivePageEntry, @@ -510,6 +590,53 @@ pub(in crate::runtime) async fn commit_page_state_on_entry_via_local_task_with_p .await } +pub(in crate::runtime) async fn finalize_prepared_page_replacement_on_entry_via_local_task( + local_executor: JsLocalExecutor, + token: RendererPageToken, + entry: LivePageEntry, +) -> ( + LivePageEntry, + Result<( + RendererPageReplacementCommit, + Option, + )>, +) { + run_entry_on_bound_owner_local_store_local_task(local_executor, entry, move |entry| { + Box::pin(async move { + let page_state = RendererOwnerLocalStore::commit_current_vm_page_state_on_entry(entry)?; + let initial_runtime_realms = entry.page_vm_mut().vm_mut().runtime_realm_inventory(); + let renderer_devtools_agent_token = entry.page_vm().devtools_agent_token(); + let javascript_dialog_broker = entry.page_vm().javascript_dialog_broker(); + let devtools_target = entry.page_vm().devtools_target(); + let creation_artifacts = entry.page_vm_mut().take_page_creation_artifacts(); + let renderer_output = entry.page_vm_mut().settle_renderer_output_publication(); + let renderer_output_predecessor = entry + .page_vm() + .renderer_output_tail_cursor() + .map(|cursor| entry.page_vm().declare_renderer_output_fence(cursor)); + Ok(( + RendererPageReplacementCommit { + local_host_id: token.local_host_id, + page_id: token.page_id, + renderer_devtools_agent_token, + javascript_dialog_broker, + devtools_target, + page_state, + creation_diagnostics: RendererPageCreationDiagnostics { + initial_runtime_realms, + renderer_output_predecessor, + document_continuation_observer: None, + }, + creation_artifacts, + pending_download: None, + }, + renderer_output, + )) + }) + }) + .await +} + pub(in crate::runtime) async fn advance_network_idle_wait_turn_on_entry_via_local_task( local_executor: JsLocalExecutor, entry: LivePageEntry, diff --git a/moli-renderer-v8/src/runtime/owner_local_store/entry.rs b/moli-renderer-v8/src/runtime/owner_local_store/entry.rs index 47f8af595d..0f21cfcbe9 100644 --- a/moli-renderer-v8/src/runtime/owner_local_store/entry.rs +++ b/moli-renderer-v8/src/runtime/owner_local_store/entry.rs @@ -324,6 +324,7 @@ impl LivePageEntry { } let current = self .active_page_vm() + .filter(|page_vm| page_vm.has_live_script_vm()) .and_then(|page_vm| page_vm.vm().pending_location_navigation_handoff()); self.standalone_navigation_follow.claim(current, requested) } @@ -331,6 +332,7 @@ impl LivePageEntry { pub(in crate::runtime) fn settle_standalone_navigation_follow(&mut self, succeeded: bool) { let current = self .active_page_vm() + .filter(|page_vm| page_vm.has_live_script_vm()) .and_then(|page_vm| page_vm.vm().pending_location_navigation_handoff()); self.standalone_navigation_follow.settle(current, succeeded); } @@ -370,6 +372,13 @@ impl LivePageEntry { pub(super) fn publish_replacement_document_commit( &mut self, + ) -> Result { + self.publish_replacement_document_commit_with_metadata(None) + } + + fn publish_replacement_document_commit_with_metadata( + &mut self, + metadata: Option, ) -> Result { let stable_before = self.slot.entry(); ensure!( @@ -401,7 +410,16 @@ impl LivePageEntry { self.page_vm_mut() .settle_replacement_document_commit(navigation_handoff)?; - RendererOwnerLocalStore::commit_active_vm_page_state_on_entry(self)?; + match metadata { + Some(metadata) => { + RendererOwnerLocalStore::commit_active_vm_page_state_on_entry_with_metadata( + self, metadata, + )?; + } + None => { + RendererOwnerLocalStore::commit_active_vm_page_state_on_entry(self)?; + } + } let stable_after = self.slot.entry(); ensure!( stable_after.vm_creation_id() == vm_creation_id, @@ -427,6 +445,72 @@ impl LivePageEntry { Ok(published) } + pub(in crate::runtime) fn validate_prepared_replacement_generation( + &self, + expected_vm_creation_id: u64, + ) -> Result<()> { + let stable = self.slot.entry(); + ensure!( + stable.is_active(), + "prepared replacement cannot commit into a retired Page slot" + ); + ensure!( + !self.has_uncommitted_page_vm(), + "prepared replacement cannot overlap another uncommitted Document" + ); + let resident = self + .active_page_vm() + .ok_or_else(|| anyhow!("prepared replacement lost the resident PageVm"))?; + ensure!( + stable.vm_creation_id() == expected_vm_creation_id + && resident.creation_id == expected_vm_creation_id, + "stale prepared replacement expected PageVm {expected_vm_creation_id}, current stable PageVm {} and resident PageVm {}", + stable.vm_creation_id(), + resident.creation_id + ); + ensure!( + resident.has_live_script_vm(), + "prepared replacement cannot detach an already-retired Document realm" + ); + Ok(()) + } + + pub(super) fn install_prepared_replacement_page_vm( + &mut self, + mut page_vm: PageVm, + navigation_handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff, + metadata: PreparedReplacementDocumentMetadata, + ) -> Result { + ensure!( + self.pending_phase_one_navigation.is_none(), + "prepared replacement cannot install over a phase-one residence" + ); + ensure!( + self.vm + .as_ref() + .is_some_and(|page_vm| !page_vm.has_live_script_vm()), + "prepared replacement PageVm must install after the old Document realm is detached" + ); + page_vm.prepare_replacement_document_commit(navigation_handoff)?; + self.retire_document_lifecycle_turn(); + self.vm = Some(page_vm); + self.publish_replacement_document_commit_with_metadata(Some(metadata)) + } + + pub(super) fn install_prepared_replacement_pending_phase_one( + &mut self, + mut pending: PageVmPendingPhaseOneNavigation, + navigation_handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff, + metadata: PreparedReplacementDocumentMetadata, + ) -> Result<(RendererPageToken, PublishedReplacementDocument)> { + pending + .page_vm_mut() + .prepare_replacement_document_commit(navigation_handoff)?; + let wake_token = self.install_new_pending_phase_one_navigation(pending)?; + let published = self.publish_replacement_document_commit_with_metadata(Some(metadata))?; + Ok((wake_token, published)) + } + pub(in crate::runtime) fn active_page_vm(&self) -> Option<&PageVm> { self.pending_phase_one_navigation .as_ref() diff --git a/moli-renderer-v8/src/runtime/owner_local_store/mod.rs b/moli-renderer-v8/src/runtime/owner_local_store/mod.rs index cd7cc1bda9..7dd659dfba 100644 --- a/moli-renderer-v8/src/runtime/owner_local_store/mod.rs +++ b/moli-renderer-v8/src/runtime/owner_local_store/mod.rs @@ -314,15 +314,32 @@ impl RendererPageCreationCommit { pub(super) type NavigationReplyPolicy = RendererNavigationReplyPolicy; +pub(super) struct PreparedReplacementDocumentMetadata { + pub(super) requested_url: Url, + pub(super) navigation_initiator_url: Option, + pub(super) navigation_redirected: bool, + pub(super) navigation_redirect_count: usize, + pub(super) response_status: u16, + pub(super) response_headers: Vec<(String, String)>, +} + pub(super) enum LivePagePendingNavigationCompletion { Background, PublishedPageCreation { navigation_reply_policy: NavigationReplyPolicy, + document_continuation_publisher: Option, + }, + PublishedPreparedPageReplacement { + navigation_reply_policy: NavigationReplyPolicy, + document_continuation_publisher: Option, }, CompletePageCreation { pending: RendererPendingPageCreation, navigation_reply_policy: NavigationReplyPolicy, }, + CommitPreparedPageReplacement { + navigation_reply_policy: NavigationReplyPolicy, + }, ReplyWithSnapshot { reply: Box, capture_policy: super::RendererPageStateCapturePolicy, @@ -351,16 +368,63 @@ pub(super) enum LivePageNavigationFailureRecipient { } impl LivePagePendingNavigationCompletion { + pub(super) fn install_document_continuation_publisher( + &mut self, + publisher: RendererDocumentContinuationPublisher, + ) -> bool { + let slot = match self { + Self::PublishedPageCreation { + document_continuation_publisher, + .. + } + | Self::PublishedPreparedPageReplacement { + document_continuation_publisher, + .. + } => document_continuation_publisher, + _ => return false, + }; + assert!( + slot.is_none(), + "a DocumentCommit continuation publisher may be installed only once" + ); + *slot = Some(publisher); + true + } + + pub(super) fn take_document_continuation_publisher( + &mut self, + ) -> Option { + match self { + Self::PublishedPageCreation { + document_continuation_publisher, + .. + } + | Self::PublishedPreparedPageReplacement { + document_continuation_publisher, + .. + } => document_continuation_publisher.take(), + _ => None, + } + } + + pub(super) fn is_prepared_page_replacement_commit(&self) -> bool { + matches!(self, Self::CommitPreparedPageReplacement { .. }) + } + pub(super) fn continues_committed_document_parser_prefix(&self) -> bool { - matches!(self, Self::PublishedPageCreation { .. }) + matches!( + self, + Self::PublishedPageCreation { .. } | Self::PublishedPreparedPageReplacement { .. } + ) } pub(super) fn chain_limit_error_context(&self) -> &'static str { match self { - Self::Background | Self::PublishedPageCreation { .. } => { - "running background navigation" - } + Self::Background + | Self::PublishedPageCreation { .. } + | Self::PublishedPreparedPageReplacement { .. } => "running background navigation", Self::CompletePageCreation { .. } => "creating page", + Self::CommitPreparedPageReplacement { .. } => "committing prepared Page replacement", Self::ReplyWithSnapshot { .. } => "refreshing page", Self::ContinueNetworkIdle { .. } => "waiting for networkidle", Self::ContinueDomStable { .. } => "waiting for domstable", @@ -375,8 +439,11 @@ impl LivePagePendingNavigationCompletion { pub(super) fn failure_recipient(&self) -> LivePageNavigationFailureRecipient { match self { Self::Background => LivePageNavigationFailureRecipient::PageCreationObserver, - Self::PublishedPageCreation { .. } => LivePageNavigationFailureRecipient::Background, + Self::PublishedPageCreation { .. } | Self::PublishedPreparedPageReplacement { .. } => { + LivePageNavigationFailureRecipient::Background + } Self::CompletePageCreation { .. } + | Self::CommitPreparedPageReplacement { .. } | Self::ReplyWithSnapshot { .. } | Self::ContinueNetworkIdle { .. } | Self::ContinueDomStable { .. } @@ -390,10 +457,18 @@ impl LivePagePendingNavigationCompletion { match self { Self::PublishedPageCreation { navigation_reply_policy, + .. + } + | Self::PublishedPreparedPageReplacement { + navigation_reply_policy, + .. } | Self::CompletePageCreation { navigation_reply_policy, .. + } + | Self::CommitPreparedPageReplacement { + navigation_reply_policy, } => navigation_reply_policy.returns_with_pending_navigation(), Self::Background | Self::ReplyWithSnapshot { .. } @@ -408,6 +483,8 @@ impl LivePagePendingNavigationCompletion { Self::CompletePageCreation { .. } => (self, false), Self::Background | Self::PublishedPageCreation { .. } + | Self::PublishedPreparedPageReplacement { .. } + | Self::CommitPreparedPageReplacement { .. } | Self::ReplyWithSnapshot { .. } | Self::ContinueNetworkIdle { .. } | Self::ContinueDomStable { .. } @@ -440,10 +517,16 @@ impl Clone for RendererOwnerLocalContext { pub(super) struct RendererOwnerLocalStore { page_hosts: HashMap, prepared_documents: HashMap, + pending_live_page_replacement_reservations: + HashMap<(RendererOwnerLocalHostId, PageId), RendererLivePageReplacementReservation>, + latest_live_page_replacement_reservations: + HashMap<(RendererOwnerLocalHostId, PageId), RendererLivePageReplacementReservation>, + staged_related_initial_empty_pages: HashMap<(RendererOwnerLocalHostId, PageId), PageVm>, page_task_deadline_index: OwnerDeadlineIndex, owner_maintenance_deadline_index: OwnerDeadlineIndex, next_host_instance_key: usize, next_renderer_document_isolate_reservation_id: u64, + next_live_page_replacement_reservation_nonce: u64, } pub(super) struct RendererPreparedDocumentResidence { @@ -631,11 +714,13 @@ impl Drop for RendererOwnerLocalPageSlot { struct RendererDocumentIsolateReservationEntry { id: u64, handle: RendererDocumentIsolateHandle, - /// The stream opens together with the isolate reservation, before that - /// isolate is attached to a stable Page slot. Until attachment transfers - /// lifetime ownership to `RendererPageScriptEnvironmentPin`, this entry - /// must close the stream on every cancellation/failure path. + /// Initial creation opens a stream before its isolate is attached to a + /// stable Page slot. Replacement preparation instead borrows the stream + /// already owned by `RendererPageScriptEnvironmentPin`. output_journal: RendererTurnOutputJournal, + /// A replacement reservation borrows the stable Page's journal. Dropping + /// that reservation must not close the still-live Page output stream. + retire_output_journal_on_drop: bool, /// Initial page creation owns the not-yet-attached consumer set here. /// A same-Page replacement reservation reuses the live slot's producer /// routes and therefore must not manufacture a second consumer set. @@ -643,6 +728,12 @@ struct RendererDocumentIsolateReservationEntry { _accounting: RendererDocumentIsolateReservationAccounting, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct RendererLivePageReplacementReservation { + expected_vm_creation_id: u64, + reservation_nonce: u64, +} + #[derive(Clone)] pub(crate) struct RendererDocumentIsolateAllocator { owner: RendererOwnerLocalContext, @@ -703,6 +794,11 @@ impl RendererDocumentIsolateReservation { self.inner.active.set(false); } + fn rearm_after_staged_construction(&self) { + debug_assert!(!self.inner.active.get()); + self.inner.active.set(true); + } + pub(crate) fn is_active(&self) -> bool { self.inner.active.get() } @@ -731,6 +827,13 @@ impl Drop for RendererDocumentIsolateReservationState { } impl RendererOwnerLocalStoreSession<'_> { + pub(super) fn reserve_live_page_replacement( + &mut self, + token: RendererPageToken, + ) -> Result { + self.store.reserve_live_page_replacement(token) + } + fn reserve_renderer_document_isolate( &mut self, owner: &RendererOwnerLocalContext, @@ -891,6 +994,198 @@ impl RendererOwnerLocalStoreSession<'_> { } impl RendererOwnerLocalStore { + fn stage_related_initial_empty_page_for_owner( + &mut self, + owner: &RendererOwnerLocalContext, + scope: &mut v8::PinScope<'_, '_>, + pending: RendererPendingAuxiliaryPage, + source_environment: &RendererPageScriptEnvironment, + source_bridge_bindings: &crate::native_bridge::bindings::NativeBridgeBindings, + init: RendererRelatedInitialEmptyPageRealmInit, + ) -> Result<()> { + let RendererRelatedInitialEmptyPageRealmInit { + dom_host, + loader, + env, + inherited_origin, + policy_container, + auxiliary_popup_id, + staged_window_proxy, + opener, + window_name, + } = init; + debug_assert!( + is_on_named_owner_execution_lane_for(&owner.owner_state.local_executor), + "synchronous auxiliary Page staging must run on its named owner lane" + ); + let reservation = pending.page_reservation(); + ensure!( + reservation.local_host_id() == owner.local_host_id, + "auxiliary Page realm reservation belongs to a different renderer owner" + ); + let page_id = reservation.page_id(); + ensure!( + matches!( + reservation.script_agent_admission(), + RendererScriptAgentAdmission::RelatedAuxiliaryPage { opener_page_id } + if opener_page_id.as_u64() == source_environment.page_id() + ), + "synchronous auxiliary Page realm requires its exact related opener admission" + ); + let key = (owner.local_host_id, page_id); + ensure!( + !self.staged_related_initial_empty_pages.contains_key(&key), + "auxiliary Page already has a staged initial realm" + ); + ensure!( + self.page_hosts + .get(&owner.local_host_id) + .and_then(|host| host.pages.get(&page_id)) + .is_none(), + "cannot stage an auxiliary initial realm for an already resident Page" + ); + + let token = renderer_page_token_for_owner_context(owner, page_id); + let owner_wake = crate::page_task_queue::RendererOwnerWakeSender::new( + owner.owner_state.page_wake_tx.clone(), + token, + ); + let page_runtime_task_source = + crate::page_task_queue::PageRuntimeTaskSource::new(Some(owner_wake.clone())); + let (runtime_wake, stable_owner_wake) = page_runtime_task_source + .owner_attached_page_source_wakes() + .ok_or_else(|| anyhow!("staged auxiliary Page is missing its stable owner wake"))?; + let (initial_task_sources, producer_routes) = + RendererPageOwnedTaskSources::new(runtime_wake, stable_owner_wake); + page_runtime_task_source.bind_page_task_producer_routes(producer_routes)?; + let v8_foreground_task_sender = page_runtime_task_source + .v8_foreground_task_sender() + .ok_or_else(|| anyhow!("staged auxiliary Page is missing its V8 foreground route"))?; + let bootstrap = source_environment.bootstrap_related_page_document_isolate_in_scope( + scope, + source_bridge_bindings, + v8_foreground_task_sender, + )?; + let host_handle = bootstrap.clone_renderer_document_isolate_handle_for_owner_retention(); + let inspector_isolate_backend = bootstrap.inspector_isolate_backend_handle(); + let page_inspector = DocumentInspectorBinding::new(inspector_isolate_backend.clone()); + let output_stream = RendererOutputStreamIdentity::new_page( + owner.local_host_id, + page_id, + page_inspector.agent_token(), + ); + let output_journal = match owner + .owner_state + .browser_context_runtime + .renderer_output_transport_sender() + { + Some(transport) => { + RendererTurnOutputJournal::new_with_transport(output_stream, transport) + } + None => RendererTurnOutputJournal::new(output_stream), + }; + let page_inspector = page_inspector.with_output_journal(output_journal.clone()); + let script_agent_page_membership = bootstrap + .script_agent_page_membership() + .ok_or_else(|| anyhow!("related auxiliary bootstrap lost its agent membership"))?; + let auxiliary_page_reservation_allocator = + RendererAuxiliaryPageReservationAllocator::new_for_owner(owner.clone(), page_id); + let page_script_environment = RendererPageScriptEnvironment::new( + page_id.as_u64(), + auxiliary_page_reservation_allocator, + host_handle.clone(), + inspector_isolate_backend, + script_agent_page_membership, + page_runtime_task_source, + output_journal.clone(), + )?; + page_script_environment.install_staged_initial_main_window_proxy(staged_window_proxy)?; + let bootstrap = bootstrap + .with_page_inspector(page_inspector) + .with_renderer_page_script_environment(page_script_environment) + .with_reused_main_window_proxy(); + + let reservation_id = self.next_renderer_document_isolate_reservation_id; + self.next_renderer_document_isolate_reservation_id = self + .next_renderer_document_isolate_reservation_id + .checked_add(1) + .ok_or_else(|| anyhow!("renderer document isolate reservation id exhausted"))?; + self.host_for_id(owner.local_host_id) + .reserved_renderer_document_isolates + .entry(page_id) + .or_default() + .push(RendererDocumentIsolateReservationEntry { + id: reservation_id, + handle: host_handle, + output_journal, + retire_output_journal_on_drop: true, + initial_task_sources: Some(initial_task_sources), + _accounting: RendererDocumentIsolateReservationAccounting::new(), + }); + let isolate_reservation = RendererDocumentIsolateReservation { + inner: Rc::new(RendererDocumentIsolateReservationState { + token, + reservation_id, + active: std::cell::Cell::new(true), + }), + }; + let runtime_hooks = PageVmRuntimeHooks::with_owner_wake( + owner_wake, + owner.owner_state.browser_context_runtime.clone(), + ) + .with_validated_staged_renderer_document_isolate(bootstrap, isolate_reservation.clone()); + // PageVm construction owns another clone. Keep its exceptional drop + // local to this store session, then retire the reservation directly + // instead of recursively borrowing the bound owner-local store. + isolate_reservation.disarm_for_attach(); + let page_vm = match PageVm::new_related_initial_empty_in_scope( + scope, + page_id, + owner.owner_state.local_executor.clone(), + &loader, + &env, + runtime_hooks, + dom_host, + opener.as_ref(), + &window_name, + &inherited_origin, + policy_container, + auxiliary_popup_id, + std::time::Instant::now(), + ) { + Ok(page_vm) => page_vm, + Err(error) => { + self.remove_reserved_renderer_document_isolate(token, reservation_id); + return Err(error); + } + }; + isolate_reservation.rearm_after_staged_construction(); + let previous = self.staged_related_initial_empty_pages.insert(key, page_vm); + debug_assert!(previous.is_none()); + Ok(()) + } + + pub(super) fn take_staged_related_initial_empty_page( + &mut self, + reservation: RendererPageReservationToken, + ) -> Result> { + let RendererScriptAgentAdmission::RelatedAuxiliaryPage { .. } = + reservation.script_agent_admission() + else { + return Ok(None); + }; + let key = (reservation.local_host_id(), reservation.page_id()); + Ok(self.staged_related_initial_empty_pages.remove(&key)) + } + + pub(super) fn has_staged_related_initial_empty_page( + &self, + reservation: RendererPageReservationToken, + ) -> bool { + self.staged_related_initial_empty_pages + .contains_key(&(reservation.local_host_id(), reservation.page_id())) + } + pub(super) fn store_prepared_document( &mut self, token: RendererPageReservationToken, @@ -920,6 +1215,50 @@ impl RendererOwnerLocalStore { }) } + pub(super) fn take_current_prepared_page_replacement( + &mut self, + token: RendererPageReservationToken, + ) -> std::result::Result< + RendererPreparedDocumentResidence, + crate::runtime::RendererPageReplacementCommitError, + > { + let RendererScriptAgentAdmission::ExistingPageReplacement { + expected_vm_creation_id, + reservation_nonce, + } = token.script_agent_admission() + else { + self.cancel_prepared_document(token); + return Err( + crate::runtime::RendererPageReplacementCommitError::page_preserved(anyhow!( + "prepared initial Document cannot commit as a live Page replacement" + )), + ); + }; + let key = (token.local_host_id(), token.page_id()); + let expected = RendererLivePageReplacementReservation { + expected_vm_creation_id, + reservation_nonce, + }; + if self + .latest_live_page_replacement_reservations + .get(&key) + .copied() + != Some(expected) + { + self.cancel_prepared_document(token); + return Err( + crate::runtime::RendererPageReplacementCommitError::page_preserved(anyhow!( + "renderer Page {} prepared Document was superseded by a newer replacement", + token.page_id().as_u64() + )), + ); + } + self.latest_live_page_replacement_reservations.remove(&key); + self.take_prepared_document(token).map_err(|error| { + crate::runtime::RendererPageReplacementCommitError::page_preserved(error) + }) + } + pub(super) fn update_prepared_document_commit_configuration( &mut self, token: RendererPageReservationToken, @@ -963,6 +1302,25 @@ impl RendererOwnerLocalStore { } pub(super) fn cancel_prepared_document(&mut self, token: RendererPageReservationToken) { + if let RendererScriptAgentAdmission::ExistingPageReplacement { + expected_vm_creation_id, + reservation_nonce, + } = token.script_agent_admission() + { + let key = (token.local_host_id(), token.page_id()); + let expected = RendererLivePageReplacementReservation { + expected_vm_creation_id, + reservation_nonce, + }; + if self + .latest_live_page_replacement_reservations + .get(&key) + .copied() + == Some(expected) + { + self.latest_live_page_replacement_reservations.remove(&key); + } + } if let Some(residence) = self.prepared_documents.remove(&token) { self.drop_prepared_document_residence(residence); } @@ -1109,6 +1467,177 @@ impl RendererOwnerLocalStore { }) } + fn reserve_live_page_replacement( + &mut self, + token: RendererPageToken, + ) -> Result { + #[cfg(debug_assertions)] + Self::ensure_token_thread(&token)?; + let expected_vm_creation_id = { + let host = self.page_hosts.get(&token.local_host_id).ok_or_else(|| { + anyhow!( + "renderer owner local runtime no longer tracks host {}", + token.local_host_id.as_u64() + ) + })?; + let page_slot = host.pages.get(&token.page_id).ok_or_else(|| { + anyhow!( + "renderer owner local runtime no longer tracks page {}", + token.page_id.as_u64() + ) + })?; + let resident = page_slot.resident_entry().ok_or_else(|| { + anyhow!( + "renderer Page {} is not resident while reserving a replacement Document", + token.page_id.as_u64() + ) + })?; + let stable = page_slot.owner_slot.entry(); + ensure!( + stable.is_active(), + "renderer Page {} is retiring while reserving a replacement Document", + token.page_id.as_u64() + ); + ensure!( + !resident.has_uncommitted_page_vm(), + "renderer Page {} already has an uncommitted replacement Document", + token.page_id.as_u64() + ); + ensure!( + stable.vm_creation_id() == resident.page_vm().creation_id, + "renderer Page {} stable PageVm generation does not match its resident runtime", + token.page_id.as_u64() + ); + stable.vm_creation_id() + }; + let reservation_nonce = self.next_live_page_replacement_reservation_nonce; + self.next_live_page_replacement_reservation_nonce = self + .next_live_page_replacement_reservation_nonce + .checked_add(1) + .ok_or_else(|| anyhow!("renderer live Page replacement reservation nonce exhausted"))?; + let key = (token.local_host_id, token.page_id); + let reservation = RendererLivePageReplacementReservation { + expected_vm_creation_id, + reservation_nonce, + }; + self.pending_live_page_replacement_reservations + .insert(key, reservation); + self.latest_live_page_replacement_reservations + .insert(key, reservation); + Ok(RendererPageReservationToken::new_existing_page_replacement( + token.local_host_id, + token.page_id, + expected_vm_creation_id, + reservation_nonce, + )) + } + + fn reserve_existing_page_replacement_document_isolate_for_owner( + &mut self, + owner: &RendererOwnerLocalContext, + page_id: PageId, + expected_vm_creation_id: u64, + reservation_nonce: u64, + ) -> Result<( + RendererDocumentIsolateBootstrap, + RendererDocumentIsolateReservation, + )> { + let key = (owner.local_host_id, page_id); + let expected_reservation = RendererLivePageReplacementReservation { + expected_vm_creation_id, + reservation_nonce, + }; + let issued_reservation = self + .pending_live_page_replacement_reservations + .get(&key) + .copied() + .ok_or_else(|| { + anyhow!( + "renderer Page {} replacement reservation is no longer current", + page_id.as_u64() + ) + })?; + ensure!( + issued_reservation == expected_reservation, + "renderer Page {} replacement reservation was superseded", + page_id.as_u64() + ); + self.pending_live_page_replacement_reservations.remove(&key); + + let page_script_environment = { + let host = self.page_hosts.get(&owner.local_host_id).ok_or_else(|| { + anyhow!( + "renderer owner local runtime no longer tracks host {}", + owner.local_host_id.as_u64() + ) + })?; + let page_slot = host.pages.get(&page_id).ok_or_else(|| { + anyhow!( + "renderer owner local runtime no longer tracks page {}", + page_id.as_u64() + ) + })?; + let resident = page_slot.resident_entry().ok_or_else(|| { + anyhow!( + "renderer Page {} is not resident while preparing its replacement Document", + page_id.as_u64() + ) + })?; + let stable = page_slot.owner_slot.entry(); + ensure!( + stable.is_active(), + "renderer Page {} retired before its replacement Document was prepared", + page_id.as_u64() + ); + ensure!( + !resident.has_uncommitted_page_vm(), + "renderer Page {} acquired another uncommitted replacement Document", + page_id.as_u64() + ); + ensure!( + stable.vm_creation_id() == expected_vm_creation_id + && resident.page_vm().creation_id == expected_vm_creation_id, + "stale renderer Page {} replacement reservation expected PageVm {}, current stable PageVm {} and resident PageVm {}", + page_id.as_u64(), + expected_vm_creation_id, + stable.vm_creation_id(), + resident.page_vm().creation_id + ); + page_slot.script_environment_pin.environment.clone() + }; + let bootstrap = page_script_environment.bootstrap_replacement_document_isolate()?; + let host_handle = bootstrap.clone_renderer_document_isolate_handle_for_owner_retention(); + let output_journal = page_script_environment.output_journal(); + let reservation_id = self.next_renderer_document_isolate_reservation_id; + self.next_renderer_document_isolate_reservation_id = self + .next_renderer_document_isolate_reservation_id + .checked_add(1) + .ok_or_else(|| anyhow!("renderer document isolate reservation id exhausted"))?; + let token = renderer_page_token_for_owner_context(owner, page_id); + self.host_for_id(owner.local_host_id) + .reserved_renderer_document_isolates + .entry(page_id) + .or_default() + .push(RendererDocumentIsolateReservationEntry { + id: reservation_id, + handle: host_handle, + output_journal, + retire_output_journal_on_drop: false, + initial_task_sources: None, + _accounting: RendererDocumentIsolateReservationAccounting::new(), + }); + Ok(( + bootstrap, + RendererDocumentIsolateReservation { + inner: Rc::new(RendererDocumentIsolateReservationState { + token, + reservation_id, + active: std::cell::Cell::new(true), + }), + }, + )) + } + fn reserve_renderer_document_isolate_for_owner( &mut self, owner: &RendererOwnerLocalContext, @@ -1126,6 +1655,18 @@ impl RendererOwnerLocalStore { let token = renderer_page_token_for_owner_context(owner, page_id); #[cfg(debug_assertions)] Self::ensure_token_thread(&token)?; + if let RendererScriptAgentAdmission::ExistingPageReplacement { + expected_vm_creation_id, + reservation_nonce, + } = script_agent_admission + { + return self.reserve_existing_page_replacement_document_isolate_for_owner( + owner, + page_id, + expected_vm_creation_id, + reservation_nonce, + ); + } let existing_page_routes = self .page_hosts .get(&owner.local_host_id) @@ -1146,40 +1687,49 @@ impl RendererOwnerLocalStore { let v8_foreground_task_sender = page_runtime_task_source .v8_foreground_task_sender() .ok_or_else(|| anyhow!("owner-reserved Page is missing its V8 foreground source"))?; - let bootstrap = match script_agent_admission { - RendererScriptAgentAdmission::Fresh => { - RendererDocumentIsolateHandle::new_owner_reserved_page(v8_foreground_task_sender)? - } - #[cfg(test)] - RendererScriptAgentAdmission::RelatedPageForExperiment { source_page_id } => { + let (bootstrap, staged_window_proxy) = match script_agent_admission { + RendererScriptAgentAdmission::Fresh => ( + RendererDocumentIsolateHandle::new_owner_reserved_page(v8_foreground_task_sender)?, + None, + ), + RendererScriptAgentAdmission::RelatedAuxiliaryPage { opener_page_id } => { ensure!( - source_page_id != page_id, + opener_page_id != page_id, "a Page cannot use itself as its related script-agent source" ); let source_environment = self .page_hosts .get(&owner.local_host_id) - .and_then(|host| host.pages.get(&source_page_id)) + .and_then(|host| host.pages.get(&opener_page_id)) .map(|slot| slot.script_environment_pin.environment.clone()) .ok_or_else(|| { anyhow!( "related script-agent source Page {} is not live on renderer owner {}", - source_page_id.as_u64(), + opener_page_id.as_u64(), owner.local_host_id.as_u64() ) })?; - source_environment.bootstrap_related_page_document_isolate_for_experiment( - v8_foreground_task_sender, - )? + let staged_window_proxy = source_environment + .auxiliary_page_reservation_allocator() + .take_related_window_proxy(page_id); + ( + source_environment + .bootstrap_related_page_document_isolate(v8_foreground_task_sender)?, + staged_window_proxy, + ) + } + RendererScriptAgentAdmission::ExistingPageReplacement { .. } => { + unreachable!("live Page replacement admission returns before fresh allocation") } }; let host_handle = bootstrap.clone_renderer_document_isolate_handle_for_owner_retention(); let reservation_id = self.next_renderer_document_isolate_reservation_id; self.next_renderer_document_isolate_reservation_id = self .next_renderer_document_isolate_reservation_id - .saturating_add(1); - let page_inspector = - DocumentInspectorBinding::new(bootstrap.inspector_isolate_backend_handle()); + .checked_add(1) + .ok_or_else(|| anyhow!("renderer document isolate reservation id exhausted"))?; + let inspector_isolate_backend = bootstrap.inspector_isolate_backend_handle(); + let page_inspector = DocumentInspectorBinding::new(inspector_isolate_backend.clone()); let output_stream = RendererOutputStreamIdentity::new_page( owner.local_host_id, page_id, @@ -1200,13 +1750,24 @@ impl RendererOwnerLocalStore { bootstrap.script_agent_page_membership().ok_or_else(|| { anyhow!("initial Page isolate bootstrap is missing its script-agent membership") })?; + let auxiliary_page_reservation_allocator = + RendererAuxiliaryPageReservationAllocator::new_for_owner(owner.clone(), page_id); let page_script_environment = RendererPageScriptEnvironment::new( page_id.as_u64(), + auxiliary_page_reservation_allocator, host_handle.clone(), + inspector_isolate_backend, script_agent_page_membership, page_runtime_task_source, output_journal.clone(), )?; + let bootstrap = if let Some(staged_window_proxy) = staged_window_proxy { + page_script_environment + .install_staged_initial_main_window_proxy(staged_window_proxy)?; + bootstrap.with_reused_main_window_proxy() + } else { + bootstrap + }; let host = self.host_for_id(owner.local_host_id); host.reserved_renderer_document_isolates .entry(page_id) @@ -1215,6 +1776,7 @@ impl RendererOwnerLocalStore { id: reservation_id, handle: host_handle, output_journal, + retire_output_journal_on_drop: true, initial_task_sources, _accounting: RendererDocumentIsolateReservationAccounting::new(), }); @@ -1289,9 +1851,11 @@ impl RendererOwnerLocalStore { reservations: impl IntoIterator, ) { for reservation in reservations { - reservation - .output_journal - .retire(RendererOutputStreamCloseReason::ResidenceRetired); + if reservation.retire_output_journal_on_drop { + reservation + .output_journal + .retire(RendererOutputStreamCloseReason::ResidenceRetired); + } } } @@ -1863,6 +2427,7 @@ impl RendererOwnerLocalStore { let creation_diagnostics = RendererPageCreationDiagnostics { initial_runtime_realms, renderer_output_predecessor: None, + document_continuation_observer: None, }; let creation_artifacts = entry.page_vm_mut().take_page_creation_artifacts(); Ok(( @@ -1939,6 +2504,10 @@ impl RendererOwnerLocalStore { return; } } + self.pending_live_page_replacement_reservations + .remove(&(token.local_host_id, token.page_id)); + self.latest_live_page_replacement_reservations + .remove(&(token.local_host_id, token.page_id)); self.page_task_deadline_index.remove(token); self.owner_maintenance_deadline_index.remove(token); let should_remove_host = if let Ok(host) = self.host_by_id_mut(token.local_host_id) { @@ -2362,9 +2931,14 @@ impl RendererOwnerLocalStore { initial_task_sources, handle: _, output_journal: _, + retire_output_journal_on_drop, id: _, _accounting: _, } = reserved_isolate; + debug_assert!( + retire_output_journal_on_drop, + "initial renderer Page attach must own its reserved output stream" + ); let task_sources = initial_task_sources .expect("initial renderer page attach must own its reserved Page task sources"); reservation.disarm_for_attach(); @@ -2528,10 +3102,15 @@ impl RendererOwnerLocalStore { initial_task_sources, handle: _, output_journal: _, + retire_output_journal_on_drop, id: _, _accounting: _, } = reserved_isolate; debug_assert!(initial_task_sources.is_none()); + debug_assert!( + !retire_output_journal_on_drop, + "replacement renderer isolate reservation must borrow its stable Page output stream" + ); reservation.disarm_for_attach(); Ok(Some(RendererPageScriptEnvironmentPin::new( page_script_environment, @@ -2629,6 +3208,30 @@ impl RendererOwnerLocalStore { entry.slot.active_page_state() } + fn commit_active_vm_page_state_on_entry_with_metadata( + entry: &mut LivePageEntry, + metadata: PreparedReplacementDocumentMetadata, + ) -> Result> { + debug_assert_eq!( + entry.slot.page_id().as_u64(), + entry.page_vm().page_id.as_u64() + ); + let state_capture = entry + .page_vm_mut() + .capture_page_state_on_named_owner_lane()?; + let page_state = RendererPageState::from_vm_state_capture( + metadata.requested_url, + metadata.navigation_initiator_url, + metadata.navigation_redirected, + metadata.navigation_redirect_count, + metadata.response_status, + metadata.response_headers, + state_capture, + ); + Self::commit_next_page_state_on_entry(entry, entry.page_vm().creation_id, page_state)?; + entry.slot.active_page_state() + } + fn commit_current_vm_page_state_on_entry( entry: &mut LivePageEntry, ) -> Result> { @@ -2923,6 +3526,19 @@ impl RendererOwnerLocalStore { impl Drop for RendererOwnerLocalStore { fn drop(&mut self) { + let staged_pages = std::mem::take(&mut self.staged_related_initial_empty_pages); + for (_, mut page_vm) in staged_pages { + if let Some(reservation) = + page_vm.take_renderer_document_isolate_reservation_for_attach() + { + self.remove_reserved_renderer_document_isolate( + reservation.token(), + reservation.reservation_id(), + ); + reservation.disarm_for_attach(); + } + drop(page_vm); + } let prepared_documents = std::mem::take(&mut self.prepared_documents); for (_, residence) in prepared_documents { self.drop_prepared_document_residence(residence); diff --git a/moli-renderer-v8/src/runtime/owner_local_store/tests.rs b/moli-renderer-v8/src/runtime/owner_local_store/tests.rs index f0b7046847..f4bfc0a36d 100644 --- a/moli-renderer-v8/src/runtime/owner_local_store/tests.rs +++ b/moli-renderer-v8/src/runtime/owner_local_store/tests.rs @@ -280,6 +280,7 @@ mod navigation_dispatch_tests { fn published_page_creation_discards_reply_policy_when_observer_detaches() { let completion = LivePagePendingNavigationCompletion::PublishedPageCreation { navigation_reply_policy: NavigationReplyPolicy::ReturnWithPendingNavigation, + document_continuation_publisher: None, }; let (completion, detached) = completion.detach_command_observer(); @@ -303,6 +304,7 @@ mod navigation_dispatch_tests { fn already_published_page_creation_reports_later_navigation_failure_as_background() { let completion = LivePagePendingNavigationCompletion::PublishedPageCreation { navigation_reply_policy: NavigationReplyPolicy::FollowBeforeReply, + document_continuation_publisher: None, }; assert_eq!( diff --git a/moli-renderer-v8/src/runtime/page.rs b/moli-renderer-v8/src/runtime/page.rs index 4a5ced7ca1..36f7604b86 100644 --- a/moli-renderer-v8/src/runtime/page.rs +++ b/moli-renderer-v8/src/runtime/page.rs @@ -18,10 +18,11 @@ use super::{ RendererBrowserContextRuntime, RendererBrowserContextRuntimeOwner, RendererBrowserContextRuntimeOwnerAccess, RendererDocumentCommitPermit, RendererDocumentIsolateAccountingDiagnostics, RendererInspectorSessionRestoreSnapshot, - RendererOwnerCommand, RendererOwnerHandle, RendererOwnerReply, RendererPageCreationArtifacts, - RendererPageCreationDiagnostics, RendererPageHandle, RendererPageReservationToken, - RendererPageState, RendererPendingDownloadActivation, RendererPerformanceMetricSnapshot, - RendererReservedServiceWorkerClient, + RendererOwnerCommand, RendererOwnerHandle, RendererOwnerLocalHostId, RendererOwnerReply, + RendererPageCreationArtifacts, RendererPageCreationDiagnostics, RendererPageHandle, + RendererPageReplacementCommit, RendererPageReplacementCommitError, + RendererPageReservationToken, RendererPageState, RendererPendingDownloadActivation, + RendererPerformanceMetricSnapshot, RendererReservedServiceWorkerClient, }; pub(crate) struct PageVmStateCapture { @@ -293,7 +294,7 @@ impl JsRuntime { } pub fn document_isolate_model_for_diagnostics(&self) -> &'static str { - "page-vm" + "script-agent" } pub fn document_isolate_accounting_for_diagnostics( @@ -303,7 +304,11 @@ impl JsRuntime { } pub fn renderer_owner_id_for_diagnostics(&self) -> u64 { - self.inner.renderer_owner.state.owner_local_host_id.as_u64() + self.renderer_owner_local_host_id().as_u64() + } + + pub fn renderer_owner_local_host_id(&self) -> RendererOwnerLocalHostId { + self.inner.renderer_owner.state.owner_local_host_id } pub fn shares_renderer_owner_with(&self, other: &Self) -> bool { @@ -645,8 +650,18 @@ impl JsRuntime { self.inner.renderer_owner.allocate_page_reservation_token() } + /// Returns whether this runtime can consume an existing Page reservation. + /// + /// Auxiliary browsing contexts reserve their Page while the opener is + /// still executing. Protocol code must then select another engine wrapper + /// for this exact renderer owner instead of allocating a new owner from + /// browser-context metadata. + pub fn owns_page_reservation(&self, reservation: RendererPageReservationToken) -> bool { + reservation.local_host_id() == self.inner.renderer_owner.state.owner_local_host_id + } + #[cfg(test)] - pub(crate) fn reserve_related_page_for_creation_experiment( + pub(crate) fn reserve_related_page_for_creation_for_test( &self, source_page: &RendererPageHandle, ) -> Result { @@ -655,13 +670,11 @@ impl JsRuntime { source_page.owner_local_host_id() == local_host_id, "related-page script-agent admission requires the same renderer owner" ); - Ok( - RendererPageReservationToken::new_related_page_for_experiment( - local_host_id, - self.inner.renderer_owner.allocate_page_id(), - source_page.renderer_page_id(), - ), - ) + Ok(RendererPageReservationToken::new_related_auxiliary_page( + local_host_id, + self.inner.renderer_owner.allocate_page_id(), + source_page.renderer_page_id(), + )) } pub async fn create_streaming_raw_page_from_external_body( @@ -1069,6 +1082,47 @@ impl PreparedRendererDocument { .materialize_page_created_reply_parts(reply) } + /// Commits this prepared Document into the stable Page that was used to + /// reserve it. The existing [`RendererPageHandle`] remains the only owning + /// handle; the returned value contains only replacement Document state. + pub async fn commit_page_replacement( + mut self, + permit: RendererDocumentCommitPermit, + ) -> std::result::Result + { + if permit.prepared_document() != self.token { + return Err(RendererPageReplacementCommitError::page_preserved(anyhow!( + "renderer document commit permit does not belong to this prepared document" + ))); + } + self.cancel_on_drop = false; + let reply = match self + .runtime + .inner + .renderer_owner + .dispatch_command( + RendererOwnerCommand::CommitPreparedRendererPageReplacement { permit }, + ) + .await + { + Ok(reply) => reply, + Err(error) => { + return Err( + match error.downcast::() { + Ok(error) => error, + Err(error) => RendererPageReplacementCommitError::page_retired(error), + }, + ); + } + }; + match reply { + RendererOwnerReply::PageReplacementCommitted(replacement) => Ok(*replacement), + _ => Err(RendererPageReplacementCommitError::page_retired(anyhow!( + "renderer owner returned non-replacement reply for prepared Page replacement" + ))), + } + } + pub async fn cancel(mut self) -> Result<()> { let reply = self .runtime diff --git a/moli-renderer-v8/src/runtime/page_surface.rs b/moli-renderer-v8/src/runtime/page_surface.rs index 123606aab6..b0cc6189f2 100644 --- a/moli-renderer-v8/src/runtime/page_surface.rs +++ b/moli-renderer-v8/src/runtime/page_surface.rs @@ -821,6 +821,7 @@ pub struct RendererMainDocumentCommit { pub struct RendererPageCreationDiagnostics { pub initial_runtime_realms: Vec, pub renderer_output_predecessor: Option, + pub document_continuation_observer: Option, } #[derive(Debug, Clone, PartialEq)] @@ -1694,6 +1695,7 @@ pub struct RendererDocumentIsolateAccountingDiagnostics { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RendererActivityDiagnostics { + pub script_agent_id: Option, pub document_context_count: usize, pub isolated_world_context_count: usize, pub child_default_context_count: usize, diff --git a/moli-renderer-v8/src/runtime/page_surface/popup_activation.rs b/moli-renderer-v8/src/runtime/page_surface/popup_activation.rs index c0babd6c61..c80c2c253e 100644 --- a/moli-renderer-v8/src/runtime/page_surface/popup_activation.rs +++ b/moli-renderer-v8/src/runtime/page_surface/popup_activation.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::{RendererDocumentLifecycleIdentity, RendererWindowDocumentSource}; -use crate::SharedWebStorageStore; +use crate::{SharedWebStorageStore, runtime::RendererPendingAuxiliaryPage}; /// Exact renderer-side initiator of one auxiliary browsing-context action. /// @@ -49,6 +49,7 @@ pub struct RendererPendingPopupActivation { popup_id: Option, url: String, target_name: String, + pending_auxiliary_page: Option, session_storage_store: Option, initial_empty_document_storage_key: Option, } @@ -77,6 +78,7 @@ impl RendererPendingPopupActivation { popup_id, url, target_name, + pending_auxiliary_page: None, session_storage_store: None, initial_empty_document_storage_key: None, } @@ -98,6 +100,7 @@ impl RendererPendingPopupActivation { popup_id, url, target_name, + pending_auxiliary_page: None, session_storage_store: None, initial_empty_document_storage_key: None, } @@ -122,6 +125,16 @@ impl RendererPendingPopupActivation { self } + /// Binds the renderer-owned browsing-context and Page identities reserved + /// when this action synchronously created a new auxiliary context. + pub fn with_pending_auxiliary_page( + mut self, + pending_auxiliary_page: Option, + ) -> Self { + self.pending_auxiliary_page = pending_auxiliary_page; + self + } + pub fn source(&self) -> &RendererPopupActivationSource { &self.source } @@ -142,6 +155,10 @@ impl RendererPendingPopupActivation { &self.target_name } + pub fn pending_auxiliary_page(&self) -> Option { + self.pending_auxiliary_page + } + #[allow(clippy::type_complexity)] pub fn into_parts( self, @@ -151,6 +168,7 @@ impl RendererPendingPopupActivation { Option, String, String, + Option, Option, Option, ) { @@ -160,6 +178,7 @@ impl RendererPendingPopupActivation { self.popup_id, self.url, self.target_name, + self.pending_auxiliary_page, self.session_storage_store, self.initial_empty_document_storage_key, ) @@ -173,6 +192,7 @@ impl PartialEq for RendererPendingPopupActivation { && self.popup_id == other.popup_id && self.url == other.url && self.target_name == other.target_name + && self.pending_auxiliary_page == other.pending_auxiliary_page && match (&self.session_storage_store, &other.session_storage_store) { (None, None) => true, (Some(left), Some(right)) => Arc::ptr_eq(left, right), diff --git a/moli-renderer-v8/src/runtime/page_vm/document_lifecycle_turn.rs b/moli-renderer-v8/src/runtime/page_vm/document_lifecycle_turn.rs index 9c064b7fbc..b808b9779f 100644 --- a/moli-renderer-v8/src/runtime/page_vm/document_lifecycle_turn.rs +++ b/moli-renderer-v8/src/runtime/page_vm/document_lifecycle_turn.rs @@ -223,6 +223,18 @@ impl PageVm { self.activate_ready_document_replacement_lifecycle_admission(admission)?; } let document = self.document_lifecycle.identity(); + let milestone = renderer_document_lifecycle_milestone_for_stage(stage); + if work.is_empty() + && matches!( + self.document_lifecycle_wait_outcome(milestone), + RendererDocumentLifecycleWaitOutcome::Reached(_) + ) + { + self.set_target_stage(stage); + return Ok(DocumentLifecycleTurnOutcome::idle( + DocumentLifecycleTurnAction::None, + )); + } let has_sealed_main_parser_script_queue = work .iter() .any(|item| item.main_parser_deferred_scripts_owner().is_some()); diff --git a/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs b/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs index 9c32522c19..b13384bc96 100644 --- a/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs +++ b/moli-renderer-v8/src/runtime/page_vm/followed_navigation.rs @@ -987,7 +987,7 @@ impl PageVm { }) } - fn prepare_replacement_document_commit( + pub(in crate::runtime) fn prepare_replacement_document_commit( &mut self, handoff: crate::page_task_queue::RendererTopLevelNavigationHandoff, ) -> Result<()> { diff --git a/moli-renderer-v8/src/runtime/page_vm/mod.rs b/moli-renderer-v8/src/runtime/page_vm/mod.rs index fa2bd5c661..1c82895f1a 100644 --- a/moli-renderer-v8/src/runtime/page_vm/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/mod.rs @@ -1122,6 +1122,68 @@ impl PageVmEnvConfig { self.document_last_modified = crate::document_last_modified::document_last_modified_from_headers(headers); } + + pub(crate) fn related_initial_empty( + web_storage: crate::RendererWebStorageHandles, + top_level_storage_key: moli_storage_key::MoliStorageKey, + policy: &crate::document_runtime::DocumentPolicyContainer, + indexed_db_manager: Option, + storage_bucket_store: crate::context_bootstrap::SharedStorageBucketStore, + ) -> Self { + Self { + root_frame_id: None, + main_document_commit: None, + top_level_storage_key: Some(top_level_storage_key), + web_storage, + document_start_scripts: Vec::new(), + runtime_bindings: Vec::new(), + runtime_inspector_session_restore_snapshots: Vec::new(), + runtime_isolated_worlds: Vec::new(), + permission_overrides: Vec::new(), + extra_http_headers: Vec::new(), + document_policy_container: policy.clone(), + document_default_language: None, + document_last_modified: None, + locale_override: None, + timezone_override: None, + script_execution_disabled: !policy.sandbox.allows_scripts, + bypass_content_security_policy: false, + cpu_throttling_rate: 1.0, + emulated_media: crate::protocol_types::EmulatedMediaOverrides::default(), + idle_override: None, + viewport_surface: None, + network_offline: false, + blocked_url_patterns: Vec::new(), + indexed_db_manager, + storage_bucket_store: Some(storage_bucket_store), + fetch_subresource_interception_enabled: false, + fetch_subresource_interception_resource_type: None, + layout_policy: LayoutPolicy::default(), + wpt_extensions_enabled: false, + navigation_bootstrap_entry: None, + reserved_service_worker_client_id: None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PageVmEnvironmentConfigApplyMode { + NormalConstruction, + StagedInitialConstruction, + StagedInitialAdoption, +} + +impl PageVmEnvironmentConfigApplyMode { + fn applies_context_entering_configuration(self) -> bool { + matches!(self, Self::NormalConstruction | Self::StagedInitialAdoption) + } + + fn applies_document_policy(self) -> bool { + matches!( + self, + Self::NormalConstruction | Self::StagedInitialConstruction + ) + } } /// Runtime wiring for a live `PageVm`. @@ -1371,6 +1433,25 @@ impl PageVmRuntimeHooks { Ok(self) } + pub(in crate::runtime) fn with_validated_staged_renderer_document_isolate( + mut self, + bootstrap: RendererDocumentIsolateBootstrap, + reservation: RendererDocumentIsolateReservation, + ) -> Self { + assert!( + reservation.is_active(), + "staged renderer document isolate reservation must be active" + ); + let environment = bootstrap + .renderer_page_script_environment() + .expect("staged renderer document isolate must retain its Page environment"); + self.renderer_page_script_environment = Some(environment); + self.renderer_document_isolate_reservation = Some(reservation); + self.prepared_renderer_document_isolate_bootstrap = + Some(Rc::new(std::cell::RefCell::new(Some(bootstrap)))); + self + } + fn for_cross_document_commit(mut self) -> Self { self.document_lifecycle_install = PageVmDocumentLifecycleInstall::CrossDocumentCommit; self @@ -2045,6 +2126,56 @@ impl PageVm { self.runtime_hooks.renderer_page_script_environment.clone() } + /// Transfers the stable Page wiring into one browser-prepared replacement + /// Document and commits the old main realm before the new realm is + /// bootstrapped. + /// + /// Constructing the hooks is fallible but non-destructive. Once + /// `commit_main_window_proxy_navigation` succeeds, failure is necessarily + /// post-commit and the Page owner must retire the Page instead of restoring + /// the detached old Document. + pub(super) fn begin_prepared_document_replacement( + &mut self, + bootstrap: RendererDocumentIsolateBootstrap, + reservation: RendererDocumentIsolateReservation, + ) -> Result<( + PageVmRuntimeHooks, + crate::page_task_queue::RendererTopLevelNavigationHandoff, + )> { + let environment = self + .runtime_hooks + .renderer_page_script_environment + .as_ref() + .ok_or_else(|| { + anyhow::anyhow!( + "live Page replacement is missing its stable Page script environment" + ) + })?; + let navigation_handoff = environment + .page_runtime_task_source() + .next_top_level_navigation_handoff(); + let runtime_hooks = self + .runtime_hooks + .clone() + .for_cross_document_commit() + .with_prepared_renderer_document_isolate(bootstrap, reservation)?; + self.commit_main_window_proxy_navigation()?; + let termination = self.document_lifecycle.request_termination( + self.document_lifecycle.identity(), + RendererDocumentTerminationReason::SupersededByCrossDocumentNavigation, + ); + ensure!( + matches!( + termination, + RendererDocumentLifecycleTransition::Recorded(_) + | RendererDocumentLifecycleTransition::Deferred + | RendererDocumentLifecycleTransition::Duplicate + ), + "prepared cross-document commit could not terminate the old Document lifecycle: {termination:?}" + ); + Ok((runtime_hooks, navigation_handoff)) + } + pub(super) fn javascript_dialog_broker(&self) -> RendererJavaScriptDialogBroker { self.runtime_hooks.javascript_dialog_runtime.broker() } @@ -2355,14 +2486,31 @@ impl PageVm { } for script in document_start_scripts { let script_started = Instant::now(); - match script.world_name.as_deref() { - Some(world_name) => { - let execution_context_id = - self.ensure_named_world_ready_for_document_start_script(world_name)?; - self.vm_mut() - .exec_in_execution_context(execution_context_id, &script.source)?; + if script.browser_internal { + match script.world_name.as_deref() { + Some(world_name) => { + let execution_context_id = + self.ensure_named_world_ready_for_document_start_script(world_name)?; + self.vm_mut() + .exec_browser_internal_bootstrap_script_in_execution_context( + execution_context_id, + &script.source, + )?; + } + None => self + .vm_mut() + .exec_browser_internal_bootstrap_script(&script.source)?, + } + } else { + match script.world_name.as_deref() { + Some(world_name) => { + let execution_context_id = + self.ensure_named_world_ready_for_document_start_script(world_name)?; + self.vm_mut() + .exec_in_execution_context(execution_context_id, &script.source)?; + } + None => self.vm_mut().exec_runtime_turn(&script.source, None)?, } - None => self.vm_mut().exec_runtime_turn(&script.source, None)?, } tracing::debug!( phase = "document start script", @@ -4248,6 +4396,168 @@ impl PageVm { vm.set_layout_policy(env.layout_policy); vm.install_page_task_capabilities(page_task_capabilities); vm.set_root_document_lifecycle(document_lifecycle.clone()); + Ok(Self::finish_construction( + page_id, + local_executor, + env, + runtime_hooks, + document_lifecycle, + page_task_queue, + vm, + started, + PageVmEnvironmentConfigApplyMode::NormalConstruction, + )) + } + + #[allow(clippy::too_many_arguments)] + pub(in crate::runtime) fn new_related_initial_empty_in_scope( + scope: &mut v8::PinScope<'_, '_>, + page_id: PageId, + local_executor: JsLocalExecutor, + loader: &ResourceRequestClient, + env: &PageVmEnvConfig, + mut runtime_hooks: PageVmRuntimeHooks, + bootstrap_document: DomHost, + opener: Option<&v8::Global>, + window_name: &str, + inherited_origin: &str, + initial_document_policy_container: crate::document_runtime::DocumentPolicyContainer, + auxiliary_popup_id: u64, + started: Instant, + ) -> Result { + let (document_lifecycle, document_lifecycle_identity) = + runtime_hooks.install_document_lifecycle(page_id)?; + let page_runtime_task_source = runtime_hooks + .renderer_page_script_environment + .as_ref() + .map(|environment| environment.page_runtime_task_source()) + .ok_or_else(|| { + anyhow::anyhow!("staged auxiliary Page is missing its script environment") + })?; + let page_task_queue = + PageTaskQueue::new_with_page_runtime_task_source(page_runtime_task_source.clone()); + let PageVmRendererDocumentIsolateBootstrap { + renderer_document_isolate_bootstrap, + } = runtime_hooks + .create_renderer_document_isolate_bootstrap(page_runtime_task_source.clone())?; + let task_producer_senders = page_runtime_task_source + .bound_task_producer_senders(document_lifecycle_identity.document) + .ok_or_else(|| { + anyhow::anyhow!( + "staged auxiliary Page is missing its complete typed producer route set" + ) + })?; + let ( + page_task_capabilities, + main_document_runtime, + resource_completion, + main_parser_continuation, + stylesheet, + service_worker, + ) = task_producer_senders.into_parts(); + let resource_task_runner = runtime_hooks.resource_task_runner.clone().ok_or_else(|| { + anyhow::anyhow!("staged auxiliary Page is missing its resource runner") + })?; + let post_domcontentloaded_page_task_sender = page_task_queue + .owner_attached_post_domcontentloaded_runtime_page_task_sender( + main_document_runtime, + main_parser_continuation, + stylesheet, + service_worker, + ); + let resource_completion_sender = RendererResourceCompletionSender::for_page_scheduler( + resource_completion, + document_lifecycle_identity.document, + ); + let backend_node_registry = new_shared_renderer_backend_node_registry(); + let initial_document_loader_bootstrap = + crate::network::context::DocumentResourceLoaderBootstrap::new( + loader.clone(), + resource_task_runner, + ); + let preinspector = ScriptVmDefaultWorldBootstrap::prebootstrap_from_dom_host_in_scope( + scope, + bootstrap_document, + env.bypass_content_security_policy, + post_domcontentloaded_page_task_sender, + page_task_queue.parser_boundary_sender(), + resource_completion_sender, + initial_document_loader_bootstrap, + runtime_hooks.browser_context_runtime.clone(), + runtime_hooks.javascript_dialog_runtime.clone(), + renderer_document_isolate_bootstrap, + backend_node_registry, + env.top_level_storage_key.clone(), + env.reserved_service_worker_client_id, + inherited_origin.to_owned(), + initial_document_policy_container, + ) + .map_err(|error| error.0)?; + preinspector.initialize_auxiliary_window_in_scope( + scope, + opener, + window_name, + inherited_origin, + auxiliary_popup_id, + )?; + let mut vm = preinspector + .finish_without_inspector() + .map_err(|error| error.0)?; + vm.mark_root_document_initial_empty(); + vm.set_initial_storage_backends_in_scope( + scope, + env.indexed_db_manager.clone(), + env.storage_bucket_store.clone(), + ); + vm.install_page_task_capabilities(page_task_capabilities); + vm.set_root_document_lifecycle(document_lifecycle.clone()); + vm.set_document_ready_state(crate::dom::native::DocumentReadyState::Complete)?; + for milestone in [ + RendererDocumentLifecycleMilestone::DomContentLoaded, + RendererDocumentLifecycleMilestone::Load, + ] { + anyhow::ensure!( + matches!( + document_lifecycle + .begin_milestone_dispatch(document_lifecycle_identity, milestone), + RendererDocumentLifecycleTransition::DispatchStarted + ), + "staged auxiliary initial Document rejected {milestone:?} dispatch" + ); + anyhow::ensure!( + matches!( + document_lifecycle + .complete_milestone_dispatch(document_lifecycle_identity, milestone), + RendererDocumentLifecycleTransition::Recorded(_) + ), + "staged auxiliary initial Document rejected {milestone:?} completion" + ); + } + Ok(Self::finish_construction( + page_id, + local_executor, + env, + runtime_hooks, + document_lifecycle, + page_task_queue, + vm, + started, + PageVmEnvironmentConfigApplyMode::StagedInitialConstruction, + )) + } + + #[allow(clippy::too_many_arguments)] + fn finish_construction( + page_id: PageId, + local_executor: JsLocalExecutor, + env: &PageVmEnvConfig, + runtime_hooks: PageVmRuntimeHooks, + document_lifecycle: RendererDocumentLifecycleJournalHandle, + page_task_queue: PageTaskQueue, + vm: ScriptVm, + started: Instant, + environment_apply_mode: PageVmEnvironmentConfigApplyMode, + ) -> Self { let dom_agent_state = vm.renderer_dom_agent_state(); let report = ScriptExecutionReport::default(); let creation_id = register_page_vm_creation(); @@ -4320,67 +4630,173 @@ impl PageVm { elapsed_ms = started.elapsed().as_millis(), "page vm runtime initialized" ); + page_vm.apply_environment_config(env, environment_apply_mode); page_vm - .vm_mut() - .set_indexed_db_manager(env.indexed_db_manager.clone()); - if let Some(storage_bucket_store) = env.storage_bucket_store.clone() { - page_vm - .vm_mut() - .set_storage_bucket_store(storage_bucket_store); + } + + fn apply_environment_config( + &mut self, + env: &PageVmEnvConfig, + mode: PageVmEnvironmentConfigApplyMode, + ) { + if mode.applies_context_entering_configuration() { + self.vm_mut() + .set_indexed_db_manager(env.indexed_db_manager.clone()); + if let Some(storage_bucket_store) = env.storage_bucket_store.clone() { + self.vm_mut().set_storage_bucket_store(storage_bucket_store); + } } - page_vm.vm_mut().set_web_storage_handles(&env.web_storage); - page_vm - .vm_mut() - .set_script_execution_disabled(env.script_execution_disabled); - page_vm - .vm_mut() + self.vm_mut().set_web_storage_handles(&env.web_storage); + let script_execution_disabled = env.script_execution_disabled; + self.vm_mut() + .set_script_execution_disabled(script_execution_disabled); + self.vm_mut() .set_permission_overrides(&env.permission_overrides); - page_vm - .vm_mut() + self.vm_mut() .set_extra_http_headers(&env.extra_http_headers); - page_vm - .vm_mut() - .set_main_navigation_policy_container(env.document_policy_container.clone()); - page_vm - .vm_mut() - .document_runtime - .set_document_default_language(env.document_default_language.clone()); - page_vm - .vm_mut() - .document_runtime - .set_document_source_last_modified(env.document_last_modified); - page_vm - .vm_mut() + if mode.applies_document_policy() { + match mode { + PageVmEnvironmentConfigApplyMode::NormalConstruction => self + .vm_mut() + .set_main_navigation_policy_container(env.document_policy_container.clone()), + PageVmEnvironmentConfigApplyMode::StagedInitialConstruction => self + .vm_mut() + .document_runtime + .set_initial_document_policy_container(env.document_policy_container.clone()), + PageVmEnvironmentConfigApplyMode::StagedInitialAdoption => unreachable!( + "staged initial adoption must preserve its creator-derived document policy" + ), + } + self.vm_mut() + .document_runtime + .set_document_default_language(env.document_default_language.clone()); + self.vm_mut() + .document_runtime + .set_document_source_last_modified(env.document_last_modified); + } + self.vm_mut() .set_stored_document_start_scripts(&env.document_start_scripts); - page_vm - .vm_mut() + self.vm_mut() .set_stored_runtime_bindings(&env.runtime_bindings); - page_vm - .vm_mut() + self.vm_mut() .set_locale_override(env.locale_override.as_deref()); - page_vm - .vm_mut() + self.vm_mut() .set_timezone_override(env.timezone_override.as_deref()); - page_vm.vm_mut().set_emulated_media(&env.emulated_media); - page_vm.vm_mut().set_idle_override(env.idle_override); - page_vm - .vm_mut() - .set_viewport_surface_for_bootstrap(env.viewport_surface); - page_vm.vm_mut().set_network_offline(env.network_offline); - page_vm - .vm_mut() + self.vm_mut().set_idle_override(env.idle_override); + self.vm_mut().set_network_offline(env.network_offline); + self.vm_mut() .set_blocked_url_patterns(&env.blocked_url_patterns); - page_vm.vm_mut().set_fetch_subresource_interception( + self.vm_mut().set_fetch_subresource_interception( env.fetch_subresource_interception_enabled, env.fetch_subresource_interception_resource_type, ); - page_vm - .vm_mut() - .install_navigation_bootstrap_entry(env.navigation_bootstrap_entry.clone()); - Ok(page_vm) + self.vm_mut().set_layout_policy(env.layout_policy); + if mode.applies_context_entering_configuration() { + self.vm_mut().set_emulated_media(&env.emulated_media); + if mode == PageVmEnvironmentConfigApplyMode::NormalConstruction { + self.vm_mut() + .set_viewport_surface_for_bootstrap(env.viewport_surface); + } + self.vm_mut() + .install_navigation_bootstrap_entry(env.navigation_bootstrap_entry.clone()); + } } - pub(super) fn new_from_parser_stream_and_run_document_start( + fn replace_environment_config_fields(&mut self, env: &PageVmEnvConfig) { + self.runtime_isolated_worlds = env.runtime_isolated_worlds.clone(); + self.permission_overrides = env.permission_overrides.clone(); + self.document_start_scripts = env.document_start_scripts.clone(); + self.runtime_bindings = env.runtime_bindings.clone(); + self.runtime_inspector_protocol_configurations = env + .runtime_inspector_session_restore_snapshots + .iter() + .filter(|restore| restore.protocol_configuration.requires_restore()) + .map(|restore| { + ( + DevToolsSessionKey::from_wire_session_id( + restore + .inspector_session_id + .as_deref() + .filter(|session_id| !session_id.is_empty()), + ), + restore.protocol_configuration.clone(), + ) + }) + .collect(); + self.extra_http_headers = env.extra_http_headers.clone(); + self.locale_override = env.locale_override.clone(); + self.timezone_override = env.timezone_override.clone(); + self.bypass_content_security_policy = env.bypass_content_security_policy; + self.cpu_throttling_rate = env.cpu_throttling_rate; + self.emulated_media = env.emulated_media.clone(); + self.idle_override = env.idle_override; + self.viewport_surface = env.viewport_surface; + self.network_offline = env.network_offline; + self.blocked_url_patterns = env.blocked_url_patterns.clone(); + self.indexed_db_manager = env.indexed_db_manager.clone(); + self.storage_bucket_store = env.storage_bucket_store.clone(); + self.fetch_subresource_interception_enabled = env.fetch_subresource_interception_enabled; + self.fetch_subresource_interception_resource_type = + env.fetch_subresource_interception_resource_type; + self.wpt_extensions_enabled = env.wpt_extensions_enabled; + } + + /// Adopts the synchronously created initial empty realm into its protocol + /// target. The stable Page, WindowProxy, Context, and Document are kept; + /// only frame/Inspector metadata and target configuration are installed. + pub(in crate::runtime) fn adopt_staged_related_initial_empty( + &mut self, + loader: &ResourceRequestClient, + env: &PageVmEnvConfig, + ) -> Result { + anyhow::ensure!( + moli_url::is_about_blank(self.vm().document_runtime.document_url()), + "only a staged initial about:blank Document can enter initial target adoption" + ); + anyhow::ensure!( + env.reserved_service_worker_client_id.is_none(), + "staged auxiliary Page must retain its synchronously allocated service-worker client" + ); + let environment = self + .runtime_hooks + .renderer_page_script_environment + .as_ref() + .ok_or_else(|| { + anyhow::anyhow!("staged auxiliary Page lost its script environment before adoption") + })?; + self.document_lifecycle + .bind_output_journal(environment.output_journal()); + self.vm_mut() + .materialize_staged_initial_default_inspector_context( + env.root_frame_id.clone(), + env.main_document_commit.clone(), + &env.runtime_inspector_session_restore_snapshots, + )?; + let document_loader = self.vm_mut().replace_document_resource_runtime(loader); + self.request_client = document_loader.request_client().clone(); + let inherited_script_execution_disabled = self.script_execution_disabled(); + self.replace_environment_config_fields(env); + self.vm_mut() + .document_runtime + .set_bypass_content_security_policy(env.bypass_content_security_policy); + self.apply_environment_config(env, PageVmEnvironmentConfigApplyMode::StagedInitialAdoption); + if inherited_script_execution_disabled { + self.vm_mut().set_script_execution_disabled(true); + } + self.vm_mut().set_viewport_surface(env.viewport_surface)?; + self.vm_mut() + .set_wpt_extensions_enabled(env.wpt_extensions_enabled)?; + self.restore_runtime_inspector_sessions_on_named_owner_lane( + &env.runtime_inspector_session_restore_snapshots, + )?; + self.install_stored_runtime_isolated_worlds_on_named_owner_lane()?; + self.install_stored_runtime_bindings_on_named_owner_lane()?; + let document_start_scripts = self.document_start_scripts.clone(); + self.run_document_start_scripts_on_named_owner_lane(&document_start_scripts, |_| {})?; + Ok(self.vm_mut().has_pending_location_navigation()) + } + + pub(super) fn new_from_parser_stream( page_id: PageId, local_executor: JsLocalExecutor, loader: &ResourceRequestClient, @@ -4388,6 +4804,7 @@ impl PageVm { runtime_hooks: PageVmRuntimeHooks, parser_session: &mut DocumentParserSession, started: Instant, + defer_document_start_scripts: bool, before_document_start: impl FnOnce(&mut Self) -> Result<()>, ) -> Result<(Self, bool)> { let mut page_vm = Self::bootstrap_page_vm_from_stream( @@ -4409,6 +4826,9 @@ impl PageVm { &null_custom_element_registry_elements, )?; before_document_start(&mut page_vm)?; + if defer_document_start_scripts { + return Ok((page_vm, false)); + } // Run document-start scripts directly on the runtime's live document. // The DomHost stays in the runtime; the next parser step borrows its // runtime DOM consumer instead of taking DOM ownership away. diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/child_realm_materialization_completion.rs b/moli-renderer-v8/src/runtime/page_vm/tests/child_realm_materialization_completion.rs index 4663d313ad..2a2ff83b1d 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/child_realm_materialization_completion.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/child_realm_materialization_completion.rs @@ -19,6 +19,7 @@ Promise.resolve().then(() => { .to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, crate::DocumentStartScript { @@ -29,6 +30,7 @@ parent.__childRealmCompletionOrder.push("second-body"); .to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, ]); @@ -137,6 +139,7 @@ async fn child_realm_materialization_creates_named_preload_world_in_the_selected source: "globalThis.__childNamedWorldReady = 'ready';".to_owned(), world_name: Some("child-utility".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }]); page_vm.set_stored_runtime_bindings(&[ @@ -256,6 +259,7 @@ Promise.resolve().then(() => { .to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }]); page_vm diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs b/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs index 1fc987fb2d..31bd146c20 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs @@ -6762,19 +6762,25 @@ async fn run_audio_worklet_static_invalid_module_type_import_test( module_type: &'static str, dependency_file: &'static str, ) { - let (base_url, dependency_request_rx, server) = - spawn_audio_worklet_static_invalid_module_type_server(module_type, dependency_file).await; - let document_url = Url::parse(&format!("{base_url}/page.html")).expect("document url"); - let module_url = format!("{base_url}/worklet/entry.js"); - let module_url_literal = - serde_json::to_string(&module_url).expect("serialize worklet module URL"); - let mut page_vm = test_page_vm_with_document_url(document_url); - let local_executor = page_vm.local_executor.clone(); + run_page_vm_async_test(async move { + let (base_url, dependency_request_rx, server) = + spawn_audio_worklet_static_invalid_module_type_server(module_type, dependency_file) + .await; + let document_url = Url::parse(&format!("{base_url}/page.html")).expect("document url"); + let module_url = format!("{base_url}/worklet/entry.js"); + let module_url_literal = + serde_json::to_string(&module_url).expect("serialize worklet module URL"); + let mut page_vm = test_page_vm_with_document_url(document_url); + let local_executor = page_vm.local_executor.clone(); - let result = local_executor - .run(async move { - page_vm.vm_mut().eval(&format!( - r#" + let result = local_executor + // Keep the module-graph owner future heap-bound. The invalid + // import path enters V8 from a deeply nested owner turn, and + // embedding this future in every caller can exhaust nextest's + // test-thread stack before V8 gets its own usable stack budget. + .run(Box::pin(async move { + page_vm.vm_mut().eval(&format!( + r#" (() => {{ globalThis.__audioWorkletInvalidTypeResult = null; globalThis.__audioWorkletInvalidTypeDone = false; @@ -6791,37 +6797,39 @@ async fn run_audio_worklet_static_invalid_module_type_import_test( }} ); }})() - "# - ))?; - drive_websocket_until_done( - &mut page_vm, - "String(globalThis.__audioWorkletInvalidTypeDone === true)", - "AudioWorklet addModule invalid static module type should settle", - ) - .await?; - page_vm - .vm_mut() - .eval("globalThis.__audioWorkletInvalidTypeResult") - }) - .await - .expect("AudioWorklet invalid static module type test should run on owner lane"); + "# + ))?; + drive_websocket_until_done( + &mut page_vm, + "String(globalThis.__audioWorkletInvalidTypeDone === true)", + "AudioWorklet addModule invalid static module type should settle", + ) + .await?; + page_vm + .vm_mut() + .eval("globalThis.__audioWorkletInvalidTypeResult") + })) + .await + .expect("AudioWorklet invalid static module type test should run on owner lane"); - let dependency_request = dependency_request_rx - .await - .expect("AudioWorklet invalid static module type dependency probe should finish"); - server - .await - .expect("AudioWorklet invalid static module type server should finish"); - assert!( - result.contains(&format!( - "module type `{module_type}` is not a valid module type" - )), - "unexpected AudioWorklet invalid static module type result: {result}" - ); - assert_eq!( - dependency_request, None, - "AudioWorklet invalid static module type must fail before fetching dependency" - ); + let dependency_request = dependency_request_rx + .await + .expect("AudioWorklet invalid static module type dependency probe should finish"); + server + .await + .expect("AudioWorklet invalid static module type server should finish"); + assert!( + result.contains(&format!( + "module type `{module_type}` is not a valid module type" + )), + "unexpected AudioWorklet invalid static module type result: {result}" + ); + assert_eq!( + dependency_request, None, + "AudioWorklet invalid static module type must fail before fetching dependency" + ); + }) + .await; } async fn spawn_audio_worklet_static_invalid_module_type_server( diff --git a/moli-renderer-v8/src/runtime/phase_one/bootstrap.rs b/moli-renderer-v8/src/runtime/phase_one/bootstrap.rs index 5af62b8219..8f780be0b3 100644 --- a/moli-renderer-v8/src/runtime/phase_one/bootstrap.rs +++ b/moli-renderer-v8/src/runtime/phase_one/bootstrap.rs @@ -18,6 +18,7 @@ impl ConcurrentParseTimeRuntime { runtime_hooks: PageVmRuntimeHooks, mut state: ParseTimeDriverState, started: Instant, + defer_document_start_scripts: bool, closed_message: &'static str, ) -> Result<(ParseTimeDriverState, PageVm, bool)> { let bootstrap_executor = local_executor.clone(); @@ -25,7 +26,7 @@ impl ConcurrentParseTimeRuntime { let (page_vm, triggered_navigation) = { let buffered_document_preloads = &mut state.buffered_document_preloads; let service_worker_preload_context = state.service_worker_preload_context.as_ref(); - PageVm::new_from_parser_stream_and_run_document_start( + PageVm::new_from_parser_stream( page_id, local_executor, &loader, @@ -33,6 +34,7 @@ impl ConcurrentParseTimeRuntime { runtime_hooks, &mut state.parser_session, started, + defer_document_start_scripts, |page_vm| { admit_pending_preloads( page_vm, @@ -114,6 +116,7 @@ impl ConcurrentParseTimeRuntime { runtime_hooks, state, started, + false, "html bootstrap local task channel closed", ) .await?; @@ -192,6 +195,7 @@ impl ConcurrentParseTimeRuntime { runtime_hooks, state, started, + false, "XML bootstrap local task channel closed", ) .await?; diff --git a/moli-renderer-v8/src/runtime/phase_one/mod.rs b/moli-renderer-v8/src/runtime/phase_one/mod.rs index 4a4b408fb3..acc1854be9 100644 --- a/moli-renderer-v8/src/runtime/phase_one/mod.rs +++ b/moli-renderer-v8/src/runtime/phase_one/mod.rs @@ -2217,7 +2217,7 @@ document.body.setAttribute('data-error-state', [ } #[test] - fn buffered_modulepreload_does_not_feed_later_module_script_text_cache() { + fn buffered_module_requests_do_not_feed_later_module_script_text_cache() { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -2230,14 +2230,17 @@ document.body.setAttribute('data-error-state', [ cache.append_to_main_document_scan( &final_url, - r#""#, + r#" + + + "#, &loader, ); let consumer = prepared_external_module("https://example.test/entry.mjs"); assert!( cache.shared_preload_for_script(&consumer).is_none(), - "modulepreload should reserve the native module map entry instead of becoming reusable script text" + "modulepreload and module-script roots must stay out of the legacy script-text preload cache" ); }); } @@ -2287,7 +2290,6 @@ document.body.setAttribute('data-error-state', [ vec![ Url::parse("https://example.test/normal.js").expect("normal url"), Url::parse("https://example.test/defer.js").expect("defer url"), - Url::parse("https://example.test/module.mjs").expect("module url"), ] ); } diff --git a/moli-renderer-v8/src/runtime/phase_one/scaffold.rs b/moli-renderer-v8/src/runtime/phase_one/scaffold.rs index 9d205ab944..a74da4066b 100644 --- a/moli-renderer-v8/src/runtime/phase_one/scaffold.rs +++ b/moli-renderer-v8/src/runtime/phase_one/scaffold.rs @@ -88,6 +88,12 @@ async fn run_phase_one_creation_session_on_execution_context( scope_phase_one_execution_context_backend(&local_executor, async move { debug_assert_phase_one_execution_context_for(&run_executor, operation); let mut runtime = runtime; + if runtime.run_deferred_document_start_scripts()? { + return Ok(ParseTimeOwnerCompletion::TriggeredNavigation { + page_vm: Box::new(runtime.page_vm), + stage: runtime.stage, + }); + } let main_parser_continuation_is_admitted = runtime .page_vm .vm() diff --git a/moli-renderer-v8/src/runtime/phase_one/state.rs b/moli-renderer-v8/src/runtime/phase_one/state.rs index 1b84dcb16a..66f0e58397 100644 --- a/moli-renderer-v8/src/runtime/phase_one/state.rs +++ b/moli-renderer-v8/src/runtime/phase_one/state.rs @@ -97,6 +97,7 @@ pub(in crate::runtime) struct ConcurrentParseTimeRuntime { pub(super) owner: ParseTimeOwner, pub(super) parser_step_ready: bool, pub(super) pending_parsing_blocking_wait: PendingParsingBlockingWait, + pub(super) document_start_scripts_pending: bool, } impl ConcurrentParseTimeRuntime { @@ -220,9 +221,23 @@ impl ConcurrentParseTimeRuntime { owner: ParseTimeOwner::Parser, parser_step_ready: false, pending_parsing_blocking_wait: PendingParsingBlockingWait::None, + document_start_scripts_pending: false, } } + pub(super) fn defer_document_start_scripts(&mut self) { + self.document_start_scripts_pending = true; + } + + pub(super) fn run_deferred_document_start_scripts(&mut self) -> Result { + if !std::mem::take(&mut self.document_start_scripts_pending) { + return Ok(false); + } + let document_start_scripts = self.page_vm.document_start_scripts.clone(); + self.page_vm + .run_document_start_scripts_on_named_owner_lane(&document_start_scripts, |_| {}) + } + pub(super) fn has_pending_parser_blocking_source_load(&self) -> bool { self.pending_parser_blocking_source_load().is_some() } diff --git a/moli-renderer-v8/src/runtime/phase_one/streaming.rs b/moli-renderer-v8/src/runtime/phase_one/streaming.rs index c7c7d3c21e..6564cfff94 100644 --- a/moli-renderer-v8/src/runtime/phase_one/streaming.rs +++ b/moli-renderer-v8/src/runtime/phase_one/streaming.rs @@ -148,6 +148,10 @@ impl ConcurrentParseTimeRuntime { state, stage, started, + matches!( + boundary, + CommittedNavigationBootstrapBoundary::DocumentCommit + ), ) .await?; let outcome = settle_streaming_raw_response_at_boundary( @@ -386,6 +390,10 @@ impl ConcurrentParseTimeRuntime { state, stage, started, + matches!( + boundary, + CommittedNavigationBootstrapBoundary::DocumentCommit + ), ) .await?; let outcome = settle_streaming_raw_response_at_boundary( @@ -416,6 +424,7 @@ impl ConcurrentParseTimeRuntime { state: ParseTimeDriverState, stage: PageVmInitStage, started: Instant, + defer_document_start_scripts: bool, ) -> Result { let (state, page_vm, triggered_navigation) = Self::bootstrap_page_vm_from_state_on_fresh_local_task( @@ -426,6 +435,7 @@ impl ConcurrentParseTimeRuntime { runtime_hooks, state, started, + defer_document_start_scripts, "streaming html bootstrap local task channel closed", ) .await?; @@ -437,8 +447,12 @@ impl ConcurrentParseTimeRuntime { }, ); } + let mut runtime = Self::new_parser_owner(loader.clone(), stage, state, page_vm); + if defer_document_start_scripts { + runtime.defer_document_start_scripts(); + } Ok(ParseTimePageVmStreamingBootstrapOutcome::Runtime(Box::new( - Self::new_parser_owner(loader.clone(), stage, state, page_vm), + runtime, ))) } diff --git a/moli-renderer-v8/src/runtime/phase_one/streaming_admission_tests.rs b/moli-renderer-v8/src/runtime/phase_one/streaming_admission_tests.rs index e30087354d..547ccd2a4f 100644 --- a/moli-renderer-v8/src/runtime/phase_one/streaming_admission_tests.rs +++ b/moli-renderer-v8/src/runtime/phase_one/streaming_admission_tests.rs @@ -252,6 +252,15 @@ fn followed_navigation_returns_at_document_commit_before_parsing_buffered_body() .expect("complete response should publish its terminal"); let creation_executor = local_executor.clone(); + let mut env = super::tests::default_test_page_vm_env_config(); + env.document_start_scripts.push(crate::DocumentStartScript { + registry_key: Some("commit-boundary-probe".to_owned()), + source: "globalThis.__documentStartAfterCommit = true".to_owned(), + world_name: None, + has_bidi_channel_argument: false, + browser_internal: false, + bidi_channel_handoffs: Vec::new(), + }); let creation = super::access::run_named_owner_local_task( local_executor, "followed-navigation commit bootstrap channel closed", @@ -260,7 +269,7 @@ fn followed_navigation_returns_at_document_commit_before_parsing_buffered_body() page_id, creation_executor, &loader, - &super::tests::default_test_page_vm_env_config(), + &env, hooks, PageVmInitStage::Load, Instant::now(), @@ -300,6 +309,64 @@ fn followed_navigation_returns_at_document_commit_before_parsing_buffered_body() !parsed_target, "buffered response bytes must remain parser input until after Document publication" ); + let observation_executor = residence.page_vm().local_executor.clone(); + let (residence, document_start_before_commit) = + super::access::run_named_owner_local_task( + observation_executor, + "followed-navigation pre-commit document-start observation closed", + async move { + let mut residence = residence; + let observed = residence.page_vm_mut().vm_mut().eval( + "String(globalThis.__documentStartAfterCommit === true)", + )?; + Ok::<_, anyhow::Error>((residence, observed)) + }, + ) + .await + .expect("pre-commit document-start observation should run"); + assert_eq!( + document_start_before_commit, "false", + "document-start scripts must remain in the phase-one residence until after Document publication" + ); + + let resumed = resume_phase_one_once( + residence, + "followed-navigation post-commit document-start continuation closed", + ) + .await; + let observation_executor = match &resumed { + ParseTimePageVmCreationOutcome::PendingPhaseOne(residence) => residence.page_vm(), + ParseTimePageVmCreationOutcome::TriggeredNavigation { page_vm, .. } + | ParseTimePageVmCreationOutcome::ContinuePhaseTwo { page_vm, .. } => page_vm, + } + .local_executor + .clone(); + let (_, document_start_after_commit) = super::access::run_named_owner_local_task( + observation_executor, + "followed-navigation post-commit document-start observation closed", + async move { + let mut resumed = resumed; + let page_vm = match &mut resumed { + ParseTimePageVmCreationOutcome::PendingPhaseOne(residence) => { + residence.page_vm_mut() + } + ParseTimePageVmCreationOutcome::TriggeredNavigation { page_vm, .. } + | ParseTimePageVmCreationOutcome::ContinuePhaseTwo { page_vm, .. } => { + page_vm + } + }; + let observed = page_vm + .vm_mut() + .eval("String(globalThis.__documentStartAfterCommit === true)")?; + Ok::<_, anyhow::Error>((resumed, observed)) + }, + ) + .await + .expect("post-commit document-start observation should run"); + assert_eq!( + document_start_after_commit, "true", + "the exact phase-one continuation must execute deferred document-start scripts after commit" + ); })); }); } diff --git a/moli-renderer-v8/src/runtime/protocol_output/turn_journal.rs b/moli-renderer-v8/src/runtime/protocol_output/turn_journal.rs index 8e34fd5a1a..47dd21e090 100644 --- a/moli-renderer-v8/src/runtime/protocol_output/turn_journal.rs +++ b/moli-renderer-v8/src/runtime/protocol_output/turn_journal.rs @@ -166,6 +166,31 @@ impl RendererTurnOutputJournal { expected_cursor } + /// Temporarily removes records produced by a synchronously staged realm + /// before its initial Inspector context is adopted by protocol. + /// + /// The staged auxiliary Page already has a live Window/Document, so + /// opener code can synchronously produce console or DOM observations + /// before the target controller has supplied its frame id and Inspector + /// sessions. Adoption must still publish the canonical + /// `executionContextsCleared -> frame commit -> contextCreated` prefix + /// before those author observations. This operation is deliberately + /// unavailable after any stream prefix has settled. + pub(crate) fn take_unpublished_records_for_initial_context_adoption( + &self, + ) -> anyhow::Result> { + let mut state = self.state.lock(); + anyhow::ensure!( + !state.closed, + "cannot adopt an initial Inspector context after renderer output closed" + ); + anyhow::ensure!( + state.last_published_sequence.is_none() && state.deferred_publications.is_empty(), + "cannot reorder initial Inspector context output after a stream prefix was published" + ); + Ok(std::mem::take(&mut state.records)) + } + #[cfg(test)] pub(crate) fn settle(&self) -> Option { let mut state = self.state.lock(); diff --git a/moli-renderer-v8/src/runtime/script_preloads.rs b/moli-renderer-v8/src/runtime/script_preloads.rs index c5e97b8d13..58b25d8c59 100644 --- a/moli-renderer-v8/src/runtime/script_preloads.rs +++ b/moli-renderer-v8/src/runtime/script_preloads.rs @@ -228,14 +228,34 @@ impl BufferedDocumentPreloadState { requests: Vec, loader: &ResourceRequestClient, service_worker_context: Option<&ServiceWorkerScriptPreloadContext>, - ) { + ) -> usize { if requests.is_empty() { - return; + return 0; + } + let requests = requests + .into_iter() + .filter(|request| { + if request.kind_hint != crate::types::ScriptKind::Module { + return true; + } + // The native module map is the sole fetch/result authority for + // module-script roots. A legacy shared source load cannot join + // that map or account for import-map integrity discovered by + // the parser. Starting both can overlap after DocumentCommit + // and issue the same root request twice. Until scanner results + // can be published into the native module map, leave module + // roots to the parser-owned graph. + false + }) + .collect::>(); + if requests.is_empty() { + return 0; } let resource_task_runner = self .resource_task_runner .clone() .expect("script preloads require the navigation resource task runner"); + let mut scheduled_count = 0; for request in requests { let key = request.cache_key(); if self.entries.contains_key(&key) { @@ -268,7 +288,9 @@ impl BufferedDocumentPreloadState { }; self.entries .insert(key, BufferedScriptPreloadEntry { request, load }); + scheduled_count += 1; } + scheduled_count } fn queue_script_preloads_for_owner_admission( @@ -395,9 +417,9 @@ impl BufferedDocumentPreloadState { .note_scanner_seen(batch.discovered_meta_csp_count); let discovered_count = batch.script_requests.len(); // Bootstrap-time scanning runs before the parser can execute inline code. - // Keep it narrow to requests that can affect DCL: parser-blocking classic, - // defer classic, and modules. Async classic scripts should keep their - // normal background progress instead of competing with critical startup. + // Keep the legacy source-preload lane narrow to parser-blocking and defer + // classic scripts. Async classics retain normal background progress, and + // module roots stay under the native module-map owner. let requests = batch .script_requests .into_iter() @@ -1135,9 +1157,10 @@ pub(super) fn prebootstrap_preload_request_is_dcl_relevant( ) -> bool { // Async classic scripts are observable before DCL when ready, but they do not // block DCL. Prebootstrap read-ahead is intentionally reserved for work that - // can otherwise hold parser completion or defer/module execution. - matches!(request.kind_hint, crate::types::ScriptKind::Module) - || matches!( + // can otherwise hold parser completion or defer execution. Module scripts use + // the native module map instead of this legacy shared-source preload lane. + request.kind_hint == crate::types::ScriptKind::Classic + && matches!( request.mode_hint, crate::types::ScriptMode::Normal | crate::types::ScriptMode::Defer ) diff --git a/moli-renderer-v8/src/runtime/tests.rs b/moli-renderer-v8/src/runtime/tests.rs index 1af4390452..30c4ac9e35 100644 --- a/moli-renderer-v8/src/runtime/tests.rs +++ b/moli-renderer-v8/src/runtime/tests.rs @@ -9,13 +9,14 @@ use super::{ RendererPageTestingHandle, RendererPendingPopupActivation, RendererPointerEventProperties, RendererPreparedDocumentCommitConfiguration, RendererProtocolObservation, RendererRuntimeCommandOutput, RendererRuntimeInspectorMessage, - RendererRuntimeInspectorResponseSender, + RendererRuntimeInspectorResponseSender, RendererScriptAgentAdmission, }; use crate::local_executor::{is_on_script_execution_lane_for, scope_on_scaffold_js_local_executor}; use crate::network::ResourceRequestClient; use crate::{ RendererDocumentLifecycleEventKind, RendererDocumentLifecycleMilestone, - RendererNavigationReplyPolicy, RendererReplyBoundary, RendererTopLevelNavigationDispatch, + RendererNavigationReplyPolicy, RendererPageReplacementCommitFailureDisposition, + RendererReplyBoundary, RendererTopLevelNavigationDispatch, }; use parking_lot::Mutex; use std::collections::HashSet; @@ -34,14 +35,54 @@ async fn prepare_test_external_raw_document( url: url::Url, raw_body: ExternalRawDocumentBodyStream, ) -> PreparedRendererDocument { - prepare_test_external_raw_document_with_content_type( + prepare_test_external_raw_document_with_reservation( runtime, + runtime.reserve_page_for_creation(), loader, url, - "text/html", raw_body, ) .await + .expect("external raw document should prepare") +} + +async fn prepare_test_external_raw_document_with_reservation( + runtime: &JsRuntime, + reservation: super::RendererPageReservationToken, + loader: &ResourceRequestClient, + url: url::Url, + raw_body: ExternalRawDocumentBodyStream, +) -> anyhow::Result { + prepare_test_external_raw_document_with_reservation_and_boundary( + runtime, + reservation, + loader, + url, + raw_body, + RendererReplyBoundary::Stage, + ) + .await +} + +async fn prepare_test_external_raw_document_with_reservation_and_boundary( + runtime: &JsRuntime, + reservation: super::RendererPageReservationToken, + loader: &ResourceRequestClient, + url: url::Url, + raw_body: ExternalRawDocumentBodyStream, + reply_boundary: RendererReplyBoundary, +) -> anyhow::Result { + prepare_test_external_raw_document_with_reservation_boundary_and_navigation_policy( + runtime, + reservation, + loader, + url, + raw_body, + reply_boundary, + RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter, + RendererNavigationReplyPolicy::FollowBeforeReply, + ) + .await } async fn prepare_test_external_raw_document_with_content_type( @@ -70,9 +111,60 @@ async fn prepare_test_external_raw_document_with_content_type_and_reply_boundary raw_body: ExternalRawDocumentBodyStream, reply_boundary: RendererReplyBoundary, ) -> PreparedRendererDocument { + prepare_test_external_raw_document_with_reservation_content_type_boundary_and_navigation_policy( + runtime, + runtime.reserve_page_for_creation(), + loader, + url, + content_type, + raw_body, + reply_boundary, + RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter, + RendererNavigationReplyPolicy::FollowBeforeReply, + ) + .await + .expect("external raw document should prepare") +} + +async fn prepare_test_external_raw_document_with_reservation_boundary_and_navigation_policy( + runtime: &JsRuntime, + reservation: super::RendererPageReservationToken, + loader: &ResourceRequestClient, + url: url::Url, + raw_body: ExternalRawDocumentBodyStream, + reply_boundary: RendererReplyBoundary, + top_level_navigation_dispatch: RendererTopLevelNavigationDispatch, + navigation_reply_policy: RendererNavigationReplyPolicy, +) -> anyhow::Result { + prepare_test_external_raw_document_with_reservation_content_type_boundary_and_navigation_policy( + runtime, + reservation, + loader, + url, + "text/html", + raw_body, + reply_boundary, + top_level_navigation_dispatch, + navigation_reply_policy, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn prepare_test_external_raw_document_with_reservation_content_type_boundary_and_navigation_policy( + runtime: &JsRuntime, + reservation: super::RendererPageReservationToken, + loader: &ResourceRequestClient, + url: url::Url, + content_type: &str, + raw_body: ExternalRawDocumentBodyStream, + reply_boundary: RendererReplyBoundary, + top_level_navigation_dispatch: RendererTopLevelNavigationDispatch, + navigation_reply_policy: RendererNavigationReplyPolicy, +) -> anyhow::Result { runtime .prepare_streaming_raw_document_from_external_body_with_inspector_session_restores( - runtime.reserve_page_for_creation(), + reservation, url.clone(), url, None, @@ -104,15 +196,41 @@ async fn prepare_test_external_raw_document_with_content_type_and_reply_boundary false, PageVmInitStage::Load, reply_boundary, - RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter, - RendererNavigationReplyPolicy::FollowBeforeReply, + top_level_navigation_dispatch, + navigation_reply_policy, None, None, None, None, ) .await - .expect("external raw document should prepare") +} + +fn completed_empty_external_raw_document_body() -> ExternalRawDocumentBodyStream { + let (completion_tx, completion_rx) = oneshot::channel(); + let (body_tx, raw_body) = ExternalRawDocumentBodyStream::channel(completion_rx); + drop(body_tx); + completion_tx + .send(Ok(())) + .expect("empty external raw document completion should send"); + raw_body +} + +fn completed_external_raw_document_body(html: &str) -> ExternalRawDocumentBodyStream { + let (completion_tx, completion_rx) = oneshot::channel(); + let (body_tx, raw_body) = ExternalRawDocumentBodyStream::channel(completion_rx); + let body = html.as_bytes().to_vec(); + tokio::spawn(async move { + body_tx + .send(body) + .await + .expect("external raw document body should send"); + drop(body_tx); + completion_tx + .send(Ok(())) + .expect("external raw document completion should send"); + }); + raw_body } struct RendererExternalActivityTestReceiver(RendererOutputTransportReceiver); @@ -1493,6 +1611,7 @@ async fn streaming_unstyled_xml_converts_live_document_before_domcontentloaded() ) .to_owned(), world_name: None, + browser_internal: false, has_bidi_channel_argument: false, bidi_channel_handoffs: Vec::new(), }], @@ -1602,6 +1721,7 @@ async fn prepared_streaming_xml_document_waits_for_permit_and_uses_latest_config source: r#"globalThis.__nativePreload = "ready";"#.to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, crate::DocumentStartScript { @@ -1609,6 +1729,7 @@ async fn prepared_streaming_xml_document_waits_for_permit_and_uses_latest_config source: r#"globalThis.__nativeWorldPreload = "ready";"#.to_owned(), world_name: Some("native-world".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, ], @@ -2485,6 +2606,7 @@ globalThis.__preparedCommitObserved = JSON.stringify([ source: r#"globalThis.__latestPreload = "ready";"#.to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, crate::DocumentStartScript { @@ -2492,6 +2614,7 @@ globalThis.__preparedCommitObserved = JSON.stringify([ source: r#"globalThis.__latestWorld = "ready";"#.to_owned(), world_name: Some("latest-world".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, ], @@ -2800,6 +2923,976 @@ async fn canceled_prepared_document_closes_its_ordered_output_stream() { )); } +#[tokio::test(flavor = "current_thread")] +async fn canceling_prepared_live_page_replacement_preserves_page_environment_and_output_stream() { + let runtime = JsRuntime::initialize(); + let (output_tx, mut output_rx) = renderer_external_activity_test_channel(); + runtime.set_renderer_output_transport_sender(output_tx); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); + let initial_url = + url::Url::parse("https://example.test/live-replacement-cancel").expect("initial URL"); + let mut page = create_test_html_page( + &runtime, + &loader, + initial_url, + "stable live Page", + ) + .await; + let page_residence = RendererOutputResidenceIdentity::Page { + owner_local_host_id: page.owner_local_host_id(), + page_id: page.renderer_page_id(), + }; + let mut live_stream = None; + let mut initial_reservation_released = false; + while live_stream.is_none() || !initial_reservation_released { + match output_rx.recv_message().await { + RendererOutputTransportMessage::StreamControl( + super::RendererOutputStreamControl::Opened { stream }, + ) if stream.residence() == page_residence => live_stream = Some(stream), + RendererOutputTransportMessage::PageReservationReleased { + owner_local_host_id, + page_id, + } if owner_local_host_id == page.owner_local_host_id() + && page_id == page.renderer_page_id() => + { + initial_reservation_released = true; + } + _ => {} + } + } + let live_stream = live_stream.expect("initial Page output stream should open"); + let testing = RendererPageTestingHandle::new_for_testing(&page); + let initial_view = testing + .renderer_page_view_async() + .await + .expect("initial live Page view"); + let initial_heap = runtime_heap_usage_for_test(&page).await; + let initial_runtime = &initial_heap["moli"]["runtime"]; + let initial_script_agent_id = initial_runtime["scriptAgentId"].clone(); + let initial_window_proxy = initial_runtime["mainWindowProxyIdentityHash"].clone(); + let baseline_isolates = runtime.document_isolate_accounting_for_diagnostics(); + + let replacement = page + .reserve_replacement_document_for_navigation() + .await + .expect("live Page replacement should reserve"); + assert_eq!(replacement.page_id(), page.renderer_page_id()); + assert_eq!(replacement.local_host_id(), page.owner_local_host_id()); + assert!(matches!( + replacement.script_agent_admission(), + RendererScriptAgentAdmission::ExistingPageReplacement { + expected_vm_creation_id, + .. + } if expected_vm_creation_id == initial_view.vm_creation_id + )); + let prepared = prepare_test_external_raw_document_with_reservation( + &runtime, + replacement, + &loader, + url::Url::parse("https://example.test/live-replacement-cancel/next") + .expect("replacement URL"), + completed_empty_external_raw_document_body(), + ) + .await + .expect("live Page replacement should prepare in its existing environment"); + let prepared_isolates = runtime.document_isolate_accounting_for_diagnostics(); + assert_eq!(prepared_isolates.created, baseline_isolates.created); + assert_eq!(prepared_isolates.destroyed, baseline_isolates.destroyed); + assert_eq!(prepared_isolates.live, baseline_isolates.live); + assert_eq!(prepared_isolates.reserved, baseline_isolates.reserved + 1); + + loop { + match output_rx.recv_message().await { + RendererOutputTransportMessage::PageReservationReleased { + owner_local_host_id, + page_id, + } if owner_local_host_id == page.owner_local_host_id() + && page_id == page.renderer_page_id() => + { + break; + } + RendererOutputTransportMessage::StreamControl( + super::RendererOutputStreamControl::Opened { stream }, + ) if stream.residence() == page_residence => { + panic!("replacement prepare must reuse the live Page output stream") + } + RendererOutputTransportMessage::StreamControl( + super::RendererOutputStreamControl::Closed { stream, .. }, + ) if stream == live_stream => { + panic!("replacement prepare must not close the live Page output stream") + } + _ => {} + } + } + prepared + .cancel() + .await + .expect("prepared live Page replacement should cancel"); + let canceled_isolates = runtime.document_isolate_accounting_for_diagnostics(); + assert_eq!(canceled_isolates.created, baseline_isolates.created); + assert_eq!(canceled_isolates.destroyed, baseline_isolates.destroyed); + assert_eq!(canceled_isolates.live, baseline_isolates.live); + assert_eq!(canceled_isolates.reserved, baseline_isolates.reserved); + while let Ok(message) = output_rx.0.try_recv() { + assert!( + !matches!( + message, + RendererOutputTransportMessage::StreamControl( + super::RendererOutputStreamControl::Closed { stream, .. } + ) if stream == live_stream + ), + "canceling a borrowed replacement residence must not retire the stable Page stream" + ); + } + + let canceled_heap = runtime_heap_usage_for_test(&page).await; + let canceled_runtime = &canceled_heap["moli"]["runtime"]; + assert_eq!(canceled_runtime["scriptAgentId"], initial_script_agent_id); + assert_eq!( + canceled_runtime["mainWindowProxyIdentityHash"], + initial_window_proxy + ); + let uninstalled_commit_reservation = page + .reserve_replacement_document_for_navigation() + .await + .expect("the live Page should remain reservable after cancellation"); + let uninstalled_commit = prepare_test_external_raw_document_with_reservation( + &runtime, + uninstalled_commit_reservation, + &loader, + url::Url::parse("https://example.test/live-replacement-cancel/uninstalled-commit") + .expect("uninstalled commit URL"), + completed_empty_external_raw_document_body(), + ) + .await + .expect("the follow-up live Page replacement should prepare"); + let permit = uninstalled_commit.issue_commit_permit(); + let commit_error = match uninstalled_commit.commit(permit).await { + Ok(_) => panic!("the not-yet-installed live replacement commit must fail closed"), + Err(error) => error, + }; + assert!( + commit_error.to_string().contains("is not installed yet"), + "the provisional commit boundary should return an explicit error: {commit_error:#}" + ); + assert_eq!( + runtime.document_isolate_accounting_for_diagnostics(), + baseline_isolates, + "rejecting the provisional commit must release only its borrowed reservation" + ); + while let Ok(message) = output_rx.0.try_recv() { + assert!( + !matches!( + message, + RendererOutputTransportMessage::StreamControl( + super::RendererOutputStreamControl::Closed { stream, .. } + ) if stream == live_stream + ), + "rejecting an uninstalled replacement commit must not retire the stable Page stream" + ); + } + let (reply, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: "console.log('live-after-replacement-cancel'); 'alive'".to_owned(), + await_promise: false, + }) + .await + .expect("the original Page should remain executable after replacement cancellation"); + assert_eq!(renderer_json_value(reply), Some(serde_json::json!("alive"))); + loop { + match output_rx.recv_message().await { + RendererOutputTransportMessage::Publication(publication) + if publication.cursor().stream() == live_stream => + { + break; + } + RendererOutputTransportMessage::StreamControl( + super::RendererOutputStreamControl::Closed { stream, .. }, + ) if stream == live_stream => { + panic!("the stable Page stream retired after replacement cancellation") + } + _ => {} + } + } + page.close_async() + .await + .expect("live Page should close after cancellation probe"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prepared_live_page_replacement_commits_in_stable_page_slot_without_new_handle() { + let runtime = JsRuntime::initialize(); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); + let initial_url = + url::Url::parse("https://example.test/prepared-replacement/initial").expect("initial URL"); + let mut page = create_test_html_page( + &runtime, + &loader, + initial_url, + "old Document", + ) + .await; + let page_id = page.renderer_page_id(); + let owner_local_host_id = page.owner_local_host_id(); + let initial_devtools_agent = page.devtools_agent_token(); + let testing = RendererPageTestingHandle::new_for_testing(&page); + let initial_view = testing + .renderer_page_view_async() + .await + .expect("initial Page view"); + let initial_heap = runtime_heap_usage_for_test(&page).await; + let initial_runtime = &initial_heap["moli"]["runtime"]; + let initial_script_agent_id = initial_runtime["scriptAgentId"].clone(); + let initial_context_group_id = initial_runtime["inspectorContextGroupId"].clone(); + let initial_window_proxy = initial_runtime["mainWindowProxyIdentityHash"].clone(); + let baseline_isolates = runtime.document_isolate_accounting_for_diagnostics(); + let (old_marker, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: "globalThis.__preparedReplacementOldRealm = 'old'; 'old'".to_owned(), + await_promise: false, + }) + .await + .expect("old realm marker should evaluate"); + assert_eq!( + renderer_json_value(old_marker), + Some(serde_json::json!("old")) + ); + + let replacement_url = + url::Url::parse("https://example.test/prepared-replacement/next").expect("replacement URL"); + let replacement_reservation = page + .reserve_replacement_document_for_navigation() + .await + .expect("replacement should reserve the current Page generation"); + let prepared = prepare_test_external_raw_document_with_reservation( + &runtime, + replacement_reservation, + &loader, + replacement_url.clone(), + completed_external_raw_document_body( + "
new Document
", + ), + ) + .await + .expect("replacement should prepare in the stable Page environment"); + let replacement_devtools_agent = prepared.renderer_devtools_agent_token(); + assert_ne!(replacement_devtools_agent, initial_devtools_agent); + let permit = prepared.issue_commit_permit(); + let replacement = prepared + .commit_page_replacement(permit) + .await + .expect("prepared replacement should commit in the stable Page slot"); + + assert_eq!(replacement.page_id(), page_id); + assert_eq!(replacement.owner_local_host_id(), owner_local_host_id); + assert_eq!( + replacement.renderer_devtools_agent_token(), + replacement_devtools_agent + ); + assert_eq!( + page.devtools_agent_token(), + initial_devtools_agent, + "replacement commit must not manufacture or silently mutate a second Page handle" + ); + page.adopt_page_replacement(&replacement) + .expect("the original Page handle should adopt the new Document agent"); + assert_eq!(page.devtools_agent_token(), replacement_devtools_agent); + let (committed_agent, page_state, creation_diagnostics, creation_artifacts, pending_download) = + replacement.into_parts(); + assert_eq!(committed_agent, replacement_devtools_agent); + assert_eq!(page_state.requested_url, replacement_url); + assert_eq!(page_state.status, 200); + assert!(pending_download.is_none()); + assert!( + creation_diagnostics + .initial_runtime_realms + .iter() + .any(|realm| realm.is_default), + "replacement reply should expose its new default realm" + ); + assert!( + creation_artifacts.lifecycle_snapshot.load.is_some(), + "LifecycleTarget replacement commit should wait for load" + ); + + let committed_view = testing + .renderer_page_view_async() + .await + .expect("committed replacement Page view"); + assert_eq!(committed_view.page_id, initial_view.page_id); + assert_ne!(committed_view.vm_creation_id, initial_view.vm_creation_id); + assert!(committed_view.view_generation > initial_view.view_generation); + assert_eq!(committed_view.page_state.final_url, replacement_url); + + let (replacement_observation, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: "JSON.stringify([document.querySelector('#replacement-body').textContent, document.body.getAttribute('data-prepared-replacement'), typeof globalThis.__preparedReplacementOldRealm, globalThis.__preparedReplacementNewRealm])".to_owned(), + await_promise: false, + }) + .await + .expect("replacement Document should remain executable through the original handle"); + assert_eq!( + renderer_json_value(replacement_observation), + Some(serde_json::json!( + "[\"new Document\",\"yes\",\"undefined\",\"new\"]" + )) + ); + let replacement_heap = runtime_heap_usage_for_test(&page).await; + let replacement_runtime = &replacement_heap["moli"]["runtime"]; + assert_eq!( + replacement_runtime["scriptAgentId"], + initial_script_agent_id + ); + assert_ne!( + replacement_runtime["inspectorContextGroupId"], + initial_context_group_id + ); + assert_eq!( + replacement_runtime["mainWindowProxyIdentityHash"], + initial_window_proxy + ); + assert_eq!( + runtime.document_isolate_accounting_for_diagnostics(), + baseline_isolates, + "same-Page replacement must reuse its isolate and consume its borrowed reservation" + ); + + page.close_async() + .await + .expect("stable replacement Page should close through its original handle"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prepared_live_page_replacement_document_commit_replies_before_stream_completion() { + let runtime = JsRuntime::initialize(); + let (output_tx, mut output_rx) = renderer_external_activity_test_channel(); + runtime.set_renderer_output_transport_sender(output_tx); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); + let initial_url = url::Url::parse("https://example.test/prepared-document-commit/initial") + .expect("initial URL"); + let mut page = create_test_html_page( + &runtime, + &loader, + initial_url, + "initial Document", + ) + .await; + output_rx.drain(); + + let replacement_url = url::Url::parse("https://example.test/prepared-document-commit/next") + .expect("replacement URL"); + let reservation = page + .reserve_replacement_document_for_navigation() + .await + .expect("replacement should reserve"); + let (completion_tx, completion_rx) = oneshot::channel(); + let (body_tx, raw_body) = ExternalRawDocumentBodyStream::channel(completion_rx); + let (first_chunk_sent_tx, first_chunk_sent_rx) = oneshot::channel(); + let (release_tail_tx, release_tail_rx) = oneshot::channel(); + let producer = tokio::spawn(async move { + body_tx + .send(b"
replacement".to_vec()) + .await + .expect("replacement first chunk should send"); + first_chunk_sent_tx + .send(()) + .expect("first chunk barrier should send"); + release_tail_rx + .await + .expect("replacement tail should be released"); + body_tx + .send(b" complete
".to_vec()) + .await + .expect("replacement tail should send"); + drop(body_tx); + completion_tx + .send(Ok(())) + .expect("replacement body completion should send"); + }); + first_chunk_sent_rx + .await + .expect("replacement first chunk should become available"); + let prepared = prepare_test_external_raw_document_with_reservation_and_boundary( + &runtime, + reservation, + &loader, + replacement_url, + raw_body, + RendererReplyBoundary::DocumentCommit, + ) + .await + .expect("streaming replacement should prepare"); + let permit = prepared.issue_commit_permit(); + let replacement = match tokio::time::timeout( + Duration::from_secs(1), + prepared.commit_page_replacement(permit), + ) + .await + { + Ok(result) => result.expect("DocumentCommit replacement should commit"), + Err(_) => { + let _ = release_tail_tx.send(()); + let _ = producer.await; + panic!("DocumentCommit replacement waited for the still-open response body"); + } + }; + assert!( + replacement + .creation_artifacts + .lifecycle_snapshot + .load + .is_none(), + "DocumentCommit reply must not pretend the still-open Document reached load" + ); + page.adopt_page_replacement(&replacement) + .expect("original handle should adopt the committed replacement"); + release_tail_tx + .send(()) + .expect("replacement tail should be released after commit reply"); + producer + .await + .expect("replacement body producer should finish"); + let lifecycle_events = recv_page_lifecycle_until( + &mut output_rx, + &page, + RendererDocumentLifecycleMilestone::Load, + ) + .await; + assert!(lifecycle_events.iter().any(|event| { + event.kind + == RendererDocumentLifecycleEventKind::Milestone( + RendererDocumentLifecycleMilestone::DomContentLoaded, + ) + })); + let (tail_observation, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: "JSON.stringify([document.querySelector('#document-commit').textContent, globalThis.__documentCommitTailRan])".to_owned(), + await_promise: false, + }) + .await + .expect("background phase-one continuation should finish the replacement Document"); + assert_eq!( + renderer_json_value(tail_observation), + Some(serde_json::json!("[\"replacement complete\",\"yes\"]")) + ); + page.close_async() + .await + .expect("DocumentCommit replacement Page should close"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prepared_live_page_replacement_document_commit_preserves_browser_owned_tail_navigation() { + let runtime = JsRuntime::initialize(); + let (output_tx, mut output_rx) = renderer_external_activity_test_channel(); + runtime.set_renderer_output_transport_sender(output_tx); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); + let initial_url = url::Url::parse("https://example.test/prepared-tail-navigation/initial") + .expect("initial URL"); + let mut page = create_test_html_page( + &runtime, + &loader, + initial_url, + "initial Document", + ) + .await; + let testing = RendererPageTestingHandle::new_for_testing(&page); + output_rx.drain(); + + let replacement_url = + url::Url::parse("https://example.test/prepared-tail-navigation/replacement") + .expect("replacement URL"); + let reservation = page + .reserve_replacement_document_for_navigation() + .await + .expect("replacement should reserve"); + let (completion_tx, completion_rx) = oneshot::channel(); + let (body_tx, raw_body) = ExternalRawDocumentBodyStream::channel(completion_rx); + let (first_chunk_sent_tx, first_chunk_sent_rx) = oneshot::channel(); + let (release_tail_tx, release_tail_rx) = oneshot::channel(); + let producer = tokio::spawn(async move { + body_tx + .send(b"
replacement".to_vec()) + .await + .expect("replacement first chunk should send"); + first_chunk_sent_tx + .send(()) + .expect("first chunk barrier should send"); + release_tail_rx + .await + .expect("replacement tail should be released"); + body_tx + .send( + b"
" + .to_vec(), + ) + .await + .expect("replacement navigation tail should send"); + drop(body_tx); + completion_tx + .send(Ok(())) + .expect("replacement body completion should send"); + }); + first_chunk_sent_rx + .await + .expect("replacement first chunk should become available"); + let prepared = + prepare_test_external_raw_document_with_reservation_boundary_and_navigation_policy( + &runtime, + reservation, + &loader, + replacement_url.clone(), + raw_body, + RendererReplyBoundary::DocumentCommit, + RendererTopLevelNavigationDispatch::DelegateToBrowser, + RendererNavigationReplyPolicy::ReturnWithPendingNavigation, + ) + .await + .expect("protocol-owned streaming replacement should prepare"); + let permit = prepared.issue_commit_permit(); + let replacement = tokio::time::timeout( + Duration::from_secs(1), + prepared.commit_page_replacement(permit), + ) + .await + .expect("DocumentCommit replacement should reply before its tail") + .expect("DocumentCommit replacement should commit"); + let expected_source_document = replacement.creation_artifacts.lifecycle_snapshot.into(); + page.adopt_page_replacement(&replacement) + .expect("original handle should adopt the committed replacement"); + + release_tail_tx + .send(()) + .expect("navigation tail should be released after commit reply"); + producer + .await + .expect("replacement body producer should finish"); + let navigation = tokio::time::timeout( + Duration::from_secs(1), + output_rx.recv_top_level_location_navigation(), + ) + .await + .expect("tail navigation should publish without standalone follow") + .expect("renderer output transport should stay open"); + assert_eq!(navigation.source_document(), expected_source_document); + assert_eq!(navigation.url(), "https://example.test/protocol-owned-next"); + assert_eq!( + has_pending_location_navigation_for_test(&page).await, + Some(false), + "browser-owned navigation must settle into concrete output instead of being followed" + ); + assert_eq!( + testing + .renderer_page_view_async() + .await + .expect("replacement Page view") + .page_state + .final_url, + replacement_url, + "background continuation must not install a standalone-followed Document" + ); + page.close_async() + .await + .expect("protocol-owned replacement Page should close"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prepared_live_page_replacement_revalidates_generation_at_commit() { + let runtime = JsRuntime::initialize(); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); + let initial_url = + url::Url::parse("https://example.test/prepared-commit-stale/initial").expect("initial URL"); + let mut page = create_test_html_page( + &runtime, + &loader, + initial_url, + "initial generation", + ) + .await; + let testing = RendererPageTestingHandle::new_for_testing(&page); + let initial_view = testing + .renderer_page_view_async() + .await + .expect("initial Page view"); + let baseline_isolates = runtime.document_isolate_accounting_for_diagnostics(); + let reservation = page + .reserve_replacement_document_for_navigation() + .await + .expect("replacement should reserve"); + let prepared = prepare_test_external_raw_document_with_reservation( + &runtime, + reservation, + &loader, + url::Url::parse("https://example.test/prepared-commit-stale/external") + .expect("external replacement URL"), + completed_external_raw_document_body( + "stale externally prepared Document", + ), + ) + .await + .expect("replacement should prepare before the generation changes"); + assert_eq!( + runtime + .document_isolate_accounting_for_diagnostics() + .reserved, + baseline_isolates.reserved + 1 + ); + + let internal_url = "data:text/html;charset=utf-8,%3C!doctype%20html%3E%3Cbody%3Ecurrent%20internal%20generation%3C/body%3E"; + page.run_async_command( + RendererPageCommand::EvaluateExpressionAndFollowPendingNavigation { + expression: format!("location.href = {internal_url:?}; 'navigating'"), + await_promise: false, + }, + ) + .await + .expect("an internal navigation should supersede the prepared generation"); + let current_view = testing + .renderer_page_view_async() + .await + .expect("current Page view"); + assert_ne!(current_view.vm_creation_id, initial_view.vm_creation_id); + + let permit = prepared.issue_commit_permit(); + let error = match prepared.commit_page_replacement(permit).await { + Ok(_) => panic!("commit must reject a prepared previous PageVm generation"), + Err(error) => error, + }; + assert!( + error.to_string().contains("stale prepared replacement"), + "commit-time rejection should identify its exact generation boundary: {error:#}" + ); + assert_eq!( + error.disposition(), + RendererPageReplacementCommitFailureDisposition::PagePreserved, + "a generation mismatch detected before old-realm teardown must preserve the live Page" + ); + assert_eq!( + runtime.document_isolate_accounting_for_diagnostics(), + baseline_isolates, + "stale commit rejection must release the borrowed isolate reservation" + ); + let (body, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: "document.body.textContent".to_owned(), + await_promise: false, + }) + .await + .expect("the current generation should survive stale prepared commit rejection"); + assert_eq!( + renderer_json_value(body), + Some(serde_json::json!("current internal generation")) + ); + let final_view = testing + .renderer_page_view_async() + .await + .expect("final current Page view"); + assert_eq!(final_view.vm_creation_id, current_view.vm_creation_id); + page.close_async() + .await + .expect("stale commit probe Page should close"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_live_page_replacement_reservation_fails_before_isolate_bootstrap() { + let runtime = JsRuntime::initialize(); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); + let initial_url = + url::Url::parse("https://example.test/live-replacement-stale").expect("initial URL"); + let mut page = create_test_html_page( + &runtime, + &loader, + initial_url, + "stale reservation probe", + ) + .await; + let testing = RendererPageTestingHandle::new_for_testing(&page); + let initial_view = testing + .renderer_page_view_async() + .await + .expect("initial live Page view"); + let stale_reservation = page + .reserve_replacement_document_for_navigation() + .await + .expect("live Page replacement should reserve"); + assert!(matches!( + stale_reservation.script_agent_admission(), + RendererScriptAgentAdmission::ExistingPageReplacement { + expected_vm_creation_id, + .. + } if expected_vm_creation_id == initial_view.vm_creation_id + )); + let baseline_isolates = runtime.document_isolate_accounting_for_diagnostics(); + let replacement_url = + "data:text/html;charset=utf-8,%3C!doctype%20html%3E%3Cbody%3Enew%20generation%3C/body%3E"; + page.run_async_command( + RendererPageCommand::EvaluateExpressionAndFollowPendingNavigation { + expression: format!("location.href = {replacement_url:?}; 'navigating'"), + await_promise: false, + }, + ) + .await + .expect("an internal navigation should replace the live Document"); + let navigated_view = testing + .renderer_page_view_async() + .await + .expect("navigated live Page view"); + assert_ne!(navigated_view.vm_creation_id, initial_view.vm_creation_id); + + let error = match prepare_test_external_raw_document_with_reservation( + &runtime, + stale_reservation, + &loader, + url::Url::parse("https://example.test/live-replacement-stale/external") + .expect("stale replacement URL"), + completed_empty_external_raw_document_body(), + ) + .await + { + Ok(_) => panic!("a reservation for the previous PageVm generation must fail closed"), + Err(error) => error, + }; + assert!( + error.to_string().contains("stale renderer Page"), + "stale replacement failure should identify its generation boundary: {error:#}" + ); + let after_stale = runtime.document_isolate_accounting_for_diagnostics(); + assert_eq!(after_stale.created, baseline_isolates.created); + assert_eq!(after_stale.destroyed, baseline_isolates.destroyed); + assert_eq!(after_stale.live, baseline_isolates.live); + assert_eq!(after_stale.reserved, baseline_isolates.reserved); + + let current_reservation = page + .reserve_replacement_document_for_navigation() + .await + .expect("the current Page generation should remain reservable"); + let prepared = prepare_test_external_raw_document_with_reservation( + &runtime, + current_reservation, + &loader, + url::Url::parse("https://example.test/live-replacement-stale/current") + .expect("current replacement URL"), + completed_empty_external_raw_document_body(), + ) + .await + .expect("the current Page generation should prepare"); + prepared + .cancel() + .await + .expect("current replacement probe should cancel"); + let (reply, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: "document.body.textContent".to_owned(), + await_promise: false, + }) + .await + .expect("the navigated Page should remain usable after stale rejection"); + assert_eq!( + renderer_json_value(reply), + Some(serde_json::json!("new generation")) + ); + page.close_async() + .await + .expect("stale reservation probe Page should close"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn newer_live_page_replacement_reservation_supersedes_unconsumed_nonce() { + let runtime = JsRuntime::initialize(); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); + let initial_url = + url::Url::parse("https://example.test/live-replacement-supersede").expect("initial URL"); + let mut page = create_test_html_page( + &runtime, + &loader, + initial_url, + "superseded reservation probe", + ) + .await; + let baseline_isolates = runtime.document_isolate_accounting_for_diagnostics(); + let first = page + .reserve_replacement_document_for_navigation() + .await + .expect("first live Page replacement should reserve"); + let current = page + .reserve_replacement_document_for_navigation() + .await + .expect("newer live Page replacement should reserve"); + assert_ne!( + first, current, + "same-generation reservations need distinct capabilities" + ); + let first_admission = first.script_agent_admission(); + let current_admission = current.script_agent_admission(); + assert!(matches!( + (first_admission, current_admission), + ( + RendererScriptAgentAdmission::ExistingPageReplacement { + expected_vm_creation_id: first_generation, + reservation_nonce: first_nonce, + }, + RendererScriptAgentAdmission::ExistingPageReplacement { + expected_vm_creation_id: current_generation, + reservation_nonce: current_nonce, + }, + ) if first_generation == current_generation && first_nonce != current_nonce + )); + + let superseded_error = match prepare_test_external_raw_document_with_reservation( + &runtime, + first, + &loader, + url::Url::parse("https://example.test/live-replacement-supersede/old") + .expect("superseded replacement URL"), + completed_empty_external_raw_document_body(), + ) + .await + { + Ok(_) => panic!("an older same-generation reservation must fail closed"), + Err(error) => error, + }; + assert!( + superseded_error.to_string().contains("was superseded"), + "superseded replacement failure should identify the exact capability: {superseded_error:#}" + ); + assert_eq!( + runtime.document_isolate_accounting_for_diagnostics(), + baseline_isolates, + "rejecting an unconsumed nonce must not allocate or reserve an isolate" + ); + + let prepared = prepare_test_external_raw_document_with_reservation( + &runtime, + current, + &loader, + url::Url::parse("https://example.test/live-replacement-supersede/current") + .expect("current replacement URL"), + completed_empty_external_raw_document_body(), + ) + .await + .expect("the newest same-generation reservation should prepare"); + prepared + .cancel() + .await + .expect("current replacement probe should cancel"); + assert_eq!( + runtime.document_isolate_accounting_for_diagnostics(), + baseline_isolates, + "preparing and canceling the current replacement must preserve isolate accounting" + ); + page.close_async() + .await + .expect("superseded reservation probe Page should close"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn newer_live_page_replacement_supersedes_prepared_candidate_without_retiring_page() { + let runtime = JsRuntime::initialize(); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); + let initial_url = + url::Url::parse("https://example.test/live-replacement-prepared-supersede/initial") + .expect("initial URL"); + let mut page = create_test_html_page( + &runtime, + &loader, + initial_url, + "live Document survives prepared supersede", + ) + .await; + let baseline_isolates = runtime.document_isolate_accounting_for_diagnostics(); + + let first_reservation = page + .reserve_replacement_document_for_navigation() + .await + .expect("first live Page replacement should reserve"); + let first = prepare_test_external_raw_document_with_reservation( + &runtime, + first_reservation, + &loader, + url::Url::parse("https://example.test/live-replacement-prepared-supersede/first") + .expect("first replacement URL"), + completed_empty_external_raw_document_body(), + ) + .await + .expect("first replacement should prepare"); + + let current_reservation = page + .reserve_replacement_document_for_navigation() + .await + .expect("newer replacement should reserve while the first is prepared"); + let current = prepare_test_external_raw_document_with_reservation( + &runtime, + current_reservation, + &loader, + url::Url::parse("https://example.test/live-replacement-prepared-supersede/current") + .expect("current replacement URL"), + completed_empty_external_raw_document_body(), + ) + .await + .expect("newer replacement should prepare beside the superseded candidate"); + assert_eq!( + runtime + .document_isolate_accounting_for_diagnostics() + .reserved, + baseline_isolates.reserved + 2, + "both pre-commit candidates retain isolated reservations until their owner settles them" + ); + + let first_permit = first.issue_commit_permit(); + let error = match first.commit_page_replacement(first_permit).await { + Ok(_) => panic!("the older prepared candidate must not commit"), + Err(error) => error, + }; + assert!( + error.to_string().contains("superseded"), + "the rejected candidate should identify its supersede boundary: {error:#}" + ); + assert_eq!( + error.disposition(), + RendererPageReplacementCommitFailureDisposition::PagePreserved + ); + assert_eq!( + runtime + .document_isolate_accounting_for_diagnostics() + .reserved, + baseline_isolates.reserved + 1, + "rejecting the old candidate should release only its isolate reservation" + ); + let (body, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: "document.body.textContent".to_owned(), + await_promise: false, + }) + .await + .expect("the live Document should remain usable after prepared supersede"); + assert_eq!( + renderer_json_value(body), + Some(serde_json::json!( + "live Document survives prepared supersede" + )) + ); + + current + .cancel() + .await + .expect("the current replacement probe should cancel"); + assert_eq!( + runtime.document_isolate_accounting_for_diagnostics(), + baseline_isolates, + "settling both candidates must restore isolate accounting" + ); + page.close_async() + .await + .expect("prepared supersede probe Page should close"); +} + #[tokio::test(flavor = "multi_thread")] async fn dropping_prepared_external_raw_document_releases_only_its_residence() { let runtime = JsRuntime::initialize(); @@ -3781,6 +4874,16 @@ async fn per_page_isolate_policy_uses_distinct_isolates_and_isolates_contexts() let second_heap = runtime_heap_usage_for_test(&second_page).await; let first_runtime = &first_heap["moli"]["runtime"]; let second_runtime = &second_heap["moli"]["runtime"]; + assert_eq!( + first_heap["moli"]["scope"]["v8HeapIsTargetLocal"], + serde_json::json!(true), + "a fresh single-Page script agent should expose target-local heap scope" + ); + assert_eq!( + second_heap["moli"]["scope"]["v8HeapIsTargetLocal"], + serde_json::json!(true), + "an unrelated Page's fresh agent should remain target-local" + ); let first_script_agent_id = first_runtime["scriptAgentId"] .as_u64() .expect("first page should expose a script-agent id"); @@ -3833,13 +4936,13 @@ async fn per_page_isolate_policy_uses_distinct_isolates_and_isolates_contexts() ); assert_eq!( first_runtime["inspectorDefaultContextRegistryScope"], - serde_json::json!("page-vm-document-isolate"), - "default context registry should be page-isolate scoped: {first_heap:?}" + serde_json::json!("script-agent-document-isolate"), + "default context registry should be script-agent scoped: {first_heap:?}" ); assert_eq!( first_runtime["v8ForegroundTaskWakeScope"], - serde_json::json!("page-vm-document-isolate"), - "V8 foreground task wakes should be labelled as page-isolate scoped: {first_heap:?}" + serde_json::json!("script-agent-document-isolate"), + "V8 foreground task wakes should be labelled as script-agent scoped: {first_heap:?}" ); assert_eq!( first_runtime["v8ForegroundTaskWakeContextGroupIdAvailable"], @@ -4000,8 +5103,8 @@ async fn per_page_isolate_policy_uses_distinct_isolates_and_isolates_contexts() #[tokio::test(flavor = "multi_thread")] async fn related_page_script_agent_experiment_shares_isolate_and_survives_source_close() { let runtime = JsRuntime::initialize(); - let loader = ResourceRequestClient::new(&lightmount_fetch::FetchConfig::default()) - .expect("default loader"); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); let first_url = url::Url::parse("https://example.test/related-agent-a").unwrap(); let second_url = url::Url::parse("https://example.test/related-agent-b").unwrap(); let mut first_page = create_test_html_page( @@ -4034,8 +5137,18 @@ async fn related_page_script_agent_experiment_shares_isolate_and_survives_source let first_heap = runtime_heap_usage_for_test(&first_page).await; let second_heap = runtime_heap_usage_for_test(&second_page).await; - let first_runtime = &first_heap["lightmount"]["runtime"]; - let second_runtime = &second_heap["lightmount"]["runtime"]; + let first_runtime = &first_heap["moli"]["runtime"]; + let second_runtime = &second_heap["moli"]["runtime"]; + assert_eq!( + first_heap["moli"]["scope"]["v8HeapIsTargetLocal"], + serde_json::json!(false), + "a related multi-Page agent heap is not target-local" + ); + assert_eq!( + second_heap["moli"]["scope"]["v8HeapIsTargetLocal"], + serde_json::json!(false), + "each related Page should report the shared agent heap scope" + ); let script_agent_id = first_runtime["scriptAgentId"] .as_u64() .expect("related source Page should expose a script-agent id"); @@ -4158,7 +5271,7 @@ globalThis.__lm_related_agent_marker"# .await .expect("related source Page should close independently"); let remaining_heap = runtime_heap_usage_for_test(&second_page).await; - let remaining_runtime = &remaining_heap["lightmount"]["runtime"]; + let remaining_runtime = &remaining_heap["moli"]["runtime"]; assert_eq!( remaining_runtime["scriptAgentId"], serde_json::json!(script_agent_id), @@ -4197,7 +5310,7 @@ globalThis.__lm_related_agent_marker"# Some(serde_json::json!("navigating")) ); let after_navigation_heap = runtime_heap_usage_for_test(&second_page).await; - let after_navigation_runtime = &after_navigation_heap["lightmount"]["runtime"]; + let after_navigation_runtime = &after_navigation_heap["moli"]["runtime"]; assert_eq!( after_navigation_runtime["scriptAgentId"], serde_json::json!(script_agent_id), @@ -4247,8 +5360,8 @@ globalThis.__lm_related_agent_marker"# #[tokio::test(flavor = "multi_thread")] async fn related_page_script_agent_keeps_async_work_and_rejections_page_local() { let runtime = JsRuntime::initialize(); - let loader = ResourceRequestClient::new(&lightmount_fetch::FetchConfig::default()) - .expect("default loader"); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); let first_url = url::Url::parse("https://example.test/related-async-a").unwrap(); let second_url = url::Url::parse("https://example.test/related-async-b").unwrap(); let mut first_page = create_test_html_page( @@ -4338,8 +5451,8 @@ async fn related_page_script_agent_keeps_inspector_objects_and_bindings_page_loc let runtime = JsRuntime::initialize(); let (output_tx, mut output_rx) = renderer_external_activity_test_channel(); runtime.set_renderer_output_transport_sender(output_tx); - let loader = ResourceRequestClient::new(&lightmount_fetch::FetchConfig::default()) - .expect("default loader"); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); let first_url = url::Url::parse("https://example.test/related-inspector-a").unwrap(); let second_url = url::Url::parse("https://example.test/related-inspector-b").unwrap(); let mut first_page = create_test_html_page( @@ -4547,8 +5660,8 @@ async fn related_page_script_agent_keeps_inspector_objects_and_bindings_page_loc #[tokio::test(flavor = "multi_thread")] async fn related_page_script_agent_transfers_stable_window_proxy_objects_and_dom_wrappers() { let runtime = JsRuntime::initialize(); - let loader = ResourceRequestClient::new(&lightmount_fetch::FetchConfig::default()) - .expect("default loader"); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); let (base_url, replacement_server) = spawn_owner_wake_server_with_content_type( "/replacement", r#" @@ -4712,8 +5825,8 @@ async fn related_page_script_agent_transfers_stable_window_proxy_objects_and_dom #[tokio::test(flavor = "multi_thread")] async fn related_page_script_agent_keeps_dedicated_worker_events_page_local_after_source_close() { let runtime = JsRuntime::initialize(); - let loader = ResourceRequestClient::new(&lightmount_fetch::FetchConfig::default()) - .expect("default loader"); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); let first_url = url::Url::parse("https://example.test/related-worker-a").unwrap(); let second_url = url::Url::parse("https://example.test/related-worker-b").unwrap(); let mut first_page = create_test_html_page( @@ -4827,13 +5940,12 @@ async fn related_page_script_agent_keeps_dedicated_worker_events_page_local_afte #[tokio::test(flavor = "multi_thread")] async fn related_page_script_agent_keeps_indexed_db_manager_routes_page_local() { let runtime = JsRuntime::initialize(); - let loader = ResourceRequestClient::new(&lightmount_fetch::FetchConfig::default()) - .expect("default loader"); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); let first_url = url::Url::parse("https://related-idb.example/app-a").unwrap(); let second_url = url::Url::parse("https://related-idb.example/app-b").unwrap(); - let storage_key = - lightmount_storage_key::LightmountStorageKey::first_party_from_url(&first_url, None) - .serialized_storage_key(); + let storage_key = moli_storage_key::MoliStorageKey::first_party_from_url(&first_url, None) + .serialized_storage_key(); let first_root = runtime_indexed_db_test_root("related-agent-a"); let second_root = runtime_indexed_db_test_root("related-agent-b"); let first_manager = crate::new_indexed_db_manager(Some(first_root.clone())) @@ -4895,8 +6007,8 @@ async fn related_page_script_agent_keeps_indexed_db_manager_routes_page_local() async fn related_page_script_agent_survives_non_lifo_member_churn() { let runtime = JsRuntime::initialize(); let baseline_isolates = runtime.document_isolate_accounting_for_diagnostics(); - let loader = ResourceRequestClient::new(&lightmount_fetch::FetchConfig::default()) - .expect("default loader"); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); let first_url = url::Url::parse("https://example.test/related-churn-a").unwrap(); let second_url = url::Url::parse("https://example.test/related-churn-b").unwrap(); let third_url = url::Url::parse("https://example.test/related-churn-c").unwrap(); @@ -4935,7 +6047,7 @@ async fn related_page_script_agent_survives_non_lifo_member_churn() { ); let three_member_heap = runtime_heap_usage_for_test(&third_page).await; assert_eq!( - three_member_heap["lightmount"]["runtime"]["scriptAgentPageCount"], + three_member_heap["moli"]["runtime"]["scriptAgentPageCount"], serde_json::json!(3) ); let three_member_accounting = runtime.document_isolate_accounting_for_diagnostics(); @@ -4955,7 +6067,7 @@ async fn related_page_script_agent_survives_non_lifo_member_churn() { .expect("middle related Page should close first"); let after_middle_close = runtime_heap_usage_for_test(&third_page).await; assert_eq!( - after_middle_close["lightmount"]["runtime"]["scriptAgentPageCount"], + after_middle_close["moli"]["runtime"]["scriptAgentPageCount"], serde_json::json!(2), "non-LIFO retirement must remove only the middle Page route" ); @@ -4966,7 +6078,7 @@ async fn related_page_script_agent_survives_non_lifo_member_churn() { .expect("original admission source should close second"); let after_source_close = runtime_heap_usage_for_test(&third_page).await; assert_eq!( - after_source_close["lightmount"]["runtime"]["scriptAgentPageCount"], + after_source_close["moli"]["runtime"]["scriptAgentPageCount"], serde_json::json!(1) ); @@ -4988,7 +6100,7 @@ async fn related_page_script_agent_survives_non_lifo_member_churn() { ); let replacement_member_heap = runtime_heap_usage_for_test(&fourth_page).await; assert_eq!( - replacement_member_heap["lightmount"]["runtime"]["scriptAgentPageCount"], + replacement_member_heap["moli"]["runtime"]["scriptAgentPageCount"], serde_json::json!(2) ); let replacement_member_accounting = runtime.document_isolate_accounting_for_diagnostics(); @@ -5015,7 +6127,7 @@ async fn related_page_script_agent_survives_non_lifo_member_churn() { ); let final_member_heap = runtime_heap_usage_for_test(&fourth_page).await; assert_eq!( - final_member_heap["lightmount"]["runtime"]["scriptAgentPageCount"], + final_member_heap["moli"]["runtime"]["scriptAgentPageCount"], serde_json::json!(1) ); @@ -5033,8 +6145,8 @@ async fn related_page_script_agent_survives_non_lifo_member_churn() { async fn related_page_script_agent_releases_replaced_and_closed_peer_realms() { let runtime = JsRuntime::initialize(); let baseline_isolates = runtime.document_isolate_accounting_for_diagnostics(); - let loader = ResourceRequestClient::new(&lightmount_fetch::FetchConfig::default()) - .expect("default loader"); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); let anchor_url = url::Url::parse("https://example.test/related-realm-anchor").unwrap(); let mut anchor_page = create_test_html_page( &runtime, @@ -5049,7 +6161,7 @@ async fn related_page_script_agent_releases_replaced_and_closed_peer_realms() { let anchor_detached_contexts = related_script_agent_memory_u64(&anchor_heap, &["numberOfDetachedContexts"]); let script_agent_id = - related_script_agent_memory_u64(&anchor_heap, &["lightmount", "runtime", "scriptAgentId"]); + related_script_agent_memory_u64(&anchor_heap, &["moli", "runtime", "scriptAgentId"]); for iteration in 1..=3 { let peer_url = url::Url::parse(&format!( @@ -5071,10 +6183,7 @@ async fn related_page_script_agent_releases_replaced_and_closed_peer_realms() { "iteration {iteration} should add exactly one live peer realm" ); assert_eq!( - related_script_agent_memory_u64( - &active_heap, - &["lightmount", "runtime", "scriptAgentId"] - ), + related_script_agent_memory_u64(&active_heap, &["moli", "runtime", "scriptAgentId"]), script_agent_id ); @@ -5129,18 +6238,14 @@ async fn related_page_script_agent_releases_replaced_and_closed_peer_realms() { assert_eq!( related_script_agent_memory_u64( &post_close_heap, - &["lightmount", "runtime", "scriptAgentPageCount"] + &["moli", "runtime", "scriptAgentPageCount"] ), 1 ); assert_eq!( related_script_agent_memory_u64( &post_close_heap, - &[ - "lightmount", - "runtime", - "inspectorDefaultContextRegistryCount", - ] + &["moli", "runtime", "inspectorDefaultContextRegistryCount",] ), 1 ); @@ -5168,8 +6273,8 @@ async fn related_page_script_agent_release_memory_acceptance() { let started = Instant::now(); let runtime = JsRuntime::initialize(); let baseline_isolates = runtime.document_isolate_accounting_for_diagnostics(); - let loader = ResourceRequestClient::new(&lightmount_fetch::FetchConfig::default()) - .expect("default loader"); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); let anchor_url = url::Url::parse("https://example.test/related-memory-anchor").unwrap(); let mut anchor_page = create_test_html_page( &runtime, @@ -5180,7 +6285,7 @@ async fn related_page_script_agent_release_memory_acceptance() { .await; let anchor_heap = runtime_heap_usage_for_test(&anchor_page).await; let script_agent_id = - related_script_agent_memory_u64(&anchor_heap, &["lightmount", "runtime", "scriptAgentId"]); + related_script_agent_memory_u64(&anchor_heap, &["moli", "runtime", "scriptAgentId"]); let anchor_native_contexts = related_script_agent_memory_u64(&anchor_heap, &["numberOfNativeContexts"]); let anchor_detached_contexts = @@ -5190,11 +6295,7 @@ async fn related_page_script_agent_release_memory_acceptance() { assert_eq!( related_script_agent_memory_u64( &anchor_heap, - &[ - "lightmount", - "runtime", - "inspectorDefaultContextRegistryCount", - ], + &["moli", "runtime", "inspectorDefaultContextRegistryCount",], ), 1 ); @@ -5353,7 +6454,7 @@ async fn related_page_script_agent_release_memory_acceptance() { .expect("test executable path") .display() .to_string(), - "sourceCommit": std::env::var("LIGHTMOUNT_RELATED_AGENT_MEMORY_COMMIT") + "sourceCommit": std::env::var("MOLI_RELATED_AGENT_MEMORY_COMMIT") .unwrap_or_else(|_| "unknown".to_owned()), "configuration": configuration, "anchorRealmBaseline": { @@ -6028,6 +7129,87 @@ async fn per_page_isolate_policy_keeps_window_open_routes_page_owned() { .. } )); + let pending_auxiliary_page = second_popups[0] + .pending_auxiliary_page() + .expect("new opener-preserving popup should reserve its renderer Page identity"); + let popup_page_reservation = pending_auxiliary_page.page_reservation(); + assert_eq!( + pending_auxiliary_page.browsing_context_id(), + popup_page_reservation.page_id().as_u64(), + "the first auxiliary identity slice should bind one typed browsing context to its exact reserved Page" + ); + assert_ne!( + popup_page_reservation.page_id(), + second_page.renderer_page_id(), + "popup and opener must retain distinct Page identities" + ); + assert!(matches!( + popup_page_reservation.script_agent_admission(), + RendererScriptAgentAdmission::RelatedAuxiliaryPage { opener_page_id } + if opener_page_id == second_page.renderer_page_id() + )); + + let (noopener_result, _) = second_page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: r#"window.open("about:blank#fresh-agent", "_blank", "noopener") === null"# + .to_owned(), + await_promise: false, + }) + .await + .expect("noopener window.open should run"); + assert_eq!( + renderer_json_value(noopener_result), + Some(serde_json::json!(true)) + ); + let noopener_publications = output_rx.drain(); + let noopener_popups = popup_activations_for_page(&noopener_publications, &second_page); + assert_eq!(noopener_popups.len(), 1); + let noopener_page_reservation = noopener_popups[0] + .pending_auxiliary_page() + .expect("new noopener popup should still reserve a renderer Page") + .page_reservation(); + assert_eq!( + noopener_page_reservation.script_agent_admission(), + RendererScriptAgentAdmission::Fresh, + "opener suppression must not admit the popup to the opener's script agent" + ); + + let (first_named_result, _) = second_page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: r#"window.open("about:blank#named-first", "stable-popup") !== null"# + .to_owned(), + await_promise: false, + }) + .await + .expect("first named window.open should run"); + assert_eq!( + renderer_json_value(first_named_result), + Some(serde_json::json!(true)) + ); + let first_named_publications = output_rx.drain(); + let first_named_popups = popup_activations_for_page(&first_named_publications, &second_page); + assert_eq!(first_named_popups.len(), 1); + assert!(first_named_popups[0].pending_auxiliary_page().is_some()); + + let (reused_named_result, _) = second_page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: r#"window.open("about:blank#named-reused", "stable-popup") !== null"# + .to_owned(), + await_promise: false, + }) + .await + .expect("reused named window.open should run"); + assert_eq!( + renderer_json_value(reused_named_result), + Some(serde_json::json!(true)) + ); + let reused_named_publications = output_rx.drain(); + let reused_named_popups = popup_activations_for_page(&reused_named_publications, &second_page); + assert_eq!(reused_named_popups.len(), 1); + assert!( + reused_named_popups[0].pending_auxiliary_page().is_none(), + "named target reuse must not manufacture another auxiliary Page reservation" + ); let (self_result, _) = first_page .run_async_command(RendererPageCommand::EvaluateExpression { @@ -12209,6 +13391,7 @@ async fn per_page_isolate_policy_scopes_document_start_scripts_to_page_worlds() source: r#"globalThis.__sharedPreloadOwner = "first-page";"#.to_owned(), world_name: Some("shared-preload-world".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }; let first_preload_result = first_page @@ -12356,6 +13539,7 @@ async fn per_page_isolate_policy_keeps_stored_document_start_scripts_page_local( source: r#"globalThis.__sharedStoredPreloadOwner = "first-page";"#.to_owned(), world_name: Some("shared-stored-preload-world".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }; set_stored_document_start_scripts_for_test(&first_page, vec![stored_script]) @@ -12494,6 +13678,7 @@ async fn stored_document_start_script_remove_uses_registry_key_namespace() { source: r#"globalThis.__defaultPreload = "default";"#.to_owned(), world_name: Some("registry-key-world".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, crate::DocumentStartScript { @@ -12501,6 +13686,7 @@ async fn stored_document_start_script_remove_uses_registry_key_namespace() { source: r#"globalThis.__targetPreload = "target";"#.to_owned(), world_name: Some("registry-key-world".to_owned()), has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }, ], @@ -17752,14 +18938,17 @@ async fn document_commit_background_dcl_completion_resumes_owner_to_load() { let (body_tx, raw_body) = ExternalRawDocumentBodyStream::channel(completion_rx); let producer = tokio::spawn(async move { body_tx - .send(b"ready".to_vec()) + .send( + b"ready" + .to_vec(), + ) .await - .expect("html chunk should send"); + .expect("document-commit body should send"); drop(body_tx); completion_tx.send(Ok(())).expect("completion should send"); }); - let (mut page, _, _, creation_artifacts, pending_download) = runtime + let (mut page, _, mut creation_diagnostics, creation_artifacts, pending_download) = runtime .create_streaming_raw_page_from_external_body_with_inspector_session_restores( page_url.clone(), page_url, @@ -17775,7 +18964,10 @@ async fn document_commit_background_dcl_completion_resumes_owner_to_load() { None, None, Vec::new(), - Vec::new(), + vec![crate::protocol_types::RuntimeBindingRegistration { + name: "continuationBinding".to_owned(), + execution_context_name: None, + }], Vec::new(), None, None, @@ -17801,7 +18993,6 @@ async fn document_commit_background_dcl_completion_resumes_owner_to_load() { ) .await .expect("page should attach at document commit"); - producer.await.expect("producer should finish"); assert!(pending_download.is_none()); assert!( creation_artifacts @@ -17810,6 +19001,10 @@ async fn document_commit_background_dcl_completion_resumes_owner_to_load() { .is_none() ); assert!(creation_artifacts.lifecycle_snapshot.load.is_none()); + let continuation = creation_diagnostics + .document_continuation_observer + .take() + .expect("DocumentCommit must expose its exact renderer continuation"); predecessor .close_async() .await @@ -17818,6 +19013,61 @@ async fn document_commit_background_dcl_completion_resumes_owner_to_load() { .expect("DocumentCommit should defer parser continuation") .release(); + producer.await.expect("producer should finish"); + let continuation = tokio::time::timeout(Duration::from_millis(500), continuation.wait()) + .await + .expect("document continuation should settle at DOMContentLoaded"); + let (continuation_fence, continuation_page_state) = continuation.into_parts(); + let continuation_fence = + continuation_fence.expect("a live Document continuation should retain its output fence"); + assert_eq!( + continuation_page_state + .expect("a live Document continuation should capture its settled PageState") + .document_title(), + "" + ); + + let mut continuation_milestones = Vec::new(); + let mut continuation_binding_payloads = Vec::new(); + loop { + let publication = tokio::time::timeout(Duration::from_millis(500), activity_wake_rx.recv()) + .await + .expect("continuation fence publication should arrive") + .expect("renderer output transport should remain open"); + if !publication_is_for_page(&publication, &page) { + continue; + } + for record in publication.records() { + match record.item() { + RendererOutputItem::Observation( + RendererProtocolObservation::DocumentLifecycle(event), + ) => { + if let RendererDocumentLifecycleEventKind::Milestone(milestone) = event.kind { + continuation_milestones.push(milestone); + } + } + RendererOutputItem::Observation(RendererProtocolObservation::RuntimeBinding( + call, + )) if call.name == "continuationBinding" => { + continuation_binding_payloads.push(call.payload.clone()); + } + _ => {} + } + } + if publication.cursor() == continuation_fence.cursor() { + break; + } + } + assert_eq!( + continuation_binding_payloads, + vec!["author-script".to_owned()], + "the continuation fence must cover author-script binding output" + ); + assert!( + continuation_milestones.contains(&RendererDocumentLifecycleMilestone::DomContentLoaded), + "the continuation terminal must include its requested DOMContentLoaded milestone" + ); + let observed = tokio::time::timeout( Duration::from_millis(500), recv_page_lifecycle_until( @@ -17835,13 +19085,7 @@ async fn document_commit_background_dcl_completion_resumes_owner_to_load() { _ => None, }) .collect::>(); - assert_eq!( - milestones, - vec![ - RendererDocumentLifecycleMilestone::DomContentLoaded, - RendererDocumentLifecycleMilestone::Load, - ] - ); + assert_eq!(milestones, vec![RendererDocumentLifecycleMilestone::Load]); page.close_async() .await @@ -18743,37 +19987,37 @@ struct RelatedScriptAgentMemoryConfiguration { impl RelatedScriptAgentMemoryConfiguration { fn from_environment() -> Self { let iterations = related_script_agent_memory_environment_usize( - "LIGHTMOUNT_RELATED_AGENT_MEMORY_ITERATIONS", + "MOLI_RELATED_AGENT_MEMORY_ITERATIONS", 120, ); assert!( iterations >= 4, - "LIGHTMOUNT_RELATED_AGENT_MEMORY_ITERATIONS must be at least 4" + "MOLI_RELATED_AGENT_MEMORY_ITERATIONS must be at least 4" ); let peer_navigation_every = related_script_agent_memory_environment_usize( - "LIGHTMOUNT_RELATED_AGENT_MEMORY_PEER_NAVIGATION_EVERY", + "MOLI_RELATED_AGENT_MEMORY_PEER_NAVIGATION_EVERY", 12, ); assert!( peer_navigation_every > 0, - "LIGHTMOUNT_RELATED_AGENT_MEMORY_PEER_NAVIGATION_EVERY must be positive" + "MOLI_RELATED_AGENT_MEMORY_PEER_NAVIGATION_EVERY must be positive" ); - let output_path = std::env::var_os("LIGHTMOUNT_RELATED_AGENT_MEMORY_OUTPUT") + let output_path = std::env::var_os("MOLI_RELATED_AGENT_MEMORY_OUTPUT") .map(std::path::PathBuf::from) - .expect("LIGHTMOUNT_RELATED_AGENT_MEMORY_OUTPUT is required for the ignored test"); + .expect("MOLI_RELATED_AGENT_MEMORY_OUTPUT is required for the ignored test"); Self { iterations, peer_navigation_every, payload_objects: related_script_agent_memory_environment_usize( - "LIGHTMOUNT_RELATED_AGENT_MEMORY_PAYLOAD_OBJECTS", + "MOLI_RELATED_AGENT_MEMORY_PAYLOAD_OBJECTS", 24_000, ), dom_nodes: related_script_agent_memory_environment_usize( - "LIGHTMOUNT_RELATED_AGENT_MEMORY_DOM_NODES", + "MOLI_RELATED_AGENT_MEMORY_DOM_NODES", 4_000, ), promises: related_script_agent_memory_environment_usize( - "LIGHTMOUNT_RELATED_AGENT_MEMORY_PROMISES", + "MOLI_RELATED_AGENT_MEMORY_PROMISES", 1_000, ), output_path, @@ -18965,19 +20209,15 @@ async fn related_script_agent_memory_sample( ), script_agent_id: related_script_agent_memory_u64( &heap, - &["lightmount", "runtime", "scriptAgentId"], + &["moli", "runtime", "scriptAgentId"], ), script_agent_page_count: related_script_agent_memory_u64( &heap, - &["lightmount", "runtime", "scriptAgentPageCount"], + &["moli", "runtime", "scriptAgentPageCount"], ), inspector_default_context_registry_count: related_script_agent_memory_u64( &heap, - &[ - "lightmount", - "runtime", - "inspectorDefaultContextRegistryCount", - ], + &["moli", "runtime", "inspectorDefaultContextRegistryCount"], ), } } @@ -19387,7 +20627,7 @@ async fn create_related_test_html_page_with_optional_indexed_db_manager_for_scri indexed_db_manager: Option, ) -> RendererPageHandle { let reservation = runtime - .reserve_related_page_for_creation_experiment(source_page) + .reserve_related_page_for_creation_for_test(source_page) .expect("related Page reservation should use the source renderer owner"); let pending = runtime .start_create_html_page_from_response_with_inspector_session_restores( diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index 05e31b7363..0c0ffdeeaf 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -9,6 +9,7 @@ use std::{ use crate::{ DocumentStartScript, content_security_policy::ContentSecurityPolicyScriptElementRequest, + context_bootstrap::{WINDOW_NAME_SLOT, WINDOW_OPENER_SLOT}, dom::{ NodeId, native::{Attribute, DomHost, DomMutationEffects, NativeDom, NativeNodeId, NodeData}, @@ -18,6 +19,7 @@ use crate::{ FrameRealmId, FrameScriptJob, FrameScriptJobKind, }, inspector_microtasks::with_scoped_inspector_microtasks, + native_bridge::set_object_slot, network::{ResourceRequestClient, context::DocumentResourceLoader}, page_task_queue::{ PageRuntimeWakeSender, PageTask, PageTaskSender, PostParseLifecycleWork, @@ -37,6 +39,7 @@ use crate::{ runtime_binding_data::{build_runtime_binding_data, runtime_binding_callback}, script_provenance::CompiledStringProvenance, types::ScriptObservableOutput, + util::set_private_value, v8_platform::RendererScriptAgentPageMembership, }; use anyhow::{Context, Result, anyhow}; @@ -858,7 +861,8 @@ use document_isolate::*; pub(crate) use document_isolate::{ RendererDocumentIsolateBootstrap, RendererDocumentIsolateHandle, RendererDocumentIsolateReservationAccounting, RendererPageScriptEnvironment, - ScriptVmDefaultWorldBootstrap, renderer_document_isolate_accounting_diagnostics, + ScriptVmDefaultWorldBootstrap, ScriptVmPreinspectorDefaultWorldBootstrap, + renderer_document_isolate_accounting_diagnostics, }; pub(crate) use eval_exec::execute_source_text_on_current_stack; pub(crate) use input_helpers::*; @@ -890,28 +894,19 @@ fn recover_bootstrap_dom_host_from_holder( document_runtime.into_dom_host() } -fn register_main_window_execution_context_for_bootstrap( - renderer_document_isolate: &RendererDocumentIsolateHandle, +fn register_main_window_execution_context_for_bootstrap_in_scope( + scope: &mut v8::PinScope<'_, '_>, context_host: &Rc>, - context: &v8::Global, ) -> Result<()> { - renderer_document_isolate - .with_entered_renderer_document_isolate(|isolate| { - let scope = pin!(v8::HandleScope::new(isolate)); - let scope = &mut scope.init(); - let context = v8::Local::new(scope, context); - let scope = &mut v8::ContextScope::new(scope, context); - let host = &mut *context_host.borrow_mut(); - let binding = host - .current_window_execution_context_binding( - scope, - crate::native_bridge::OwnerDispatchScope::Top, - ) - .ok_or_else(|| anyhow!("main LocalWindow execution context is unavailable"))?; - host.register_window_execution_context(binding); - Ok(()) - }) - .context("failed to register main LocalWindow execution context") + let host = &mut *context_host.borrow_mut(); + let binding = host + .current_window_execution_context_binding( + scope, + crate::native_bridge::OwnerDispatchScope::Top, + ) + .ok_or_else(|| anyhow!("main LocalWindow execution context is unavailable"))?; + host.register_window_execution_context(binding); + Ok(()) } pub(super) struct ScriptVm { @@ -1078,6 +1073,12 @@ impl ScriptVm { .set_root_document_lifecycle(lifecycle); } + pub(super) fn mark_root_document_initial_empty(&mut self) { + self._context_host + .borrow_mut() + .mark_root_document_initial_empty(); + } + pub(super) fn run_v8_foreground_task( &mut self, task: moli_v8_platform::V8ForegroundTask, @@ -1155,6 +1156,31 @@ impl ScriptVm { }); } + pub(super) fn set_initial_storage_backends_in_scope( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + indexed_db_manager: Option, + storage_bucket_store: Option, + ) { + self.indexed_db_manager = indexed_db_manager.clone(); + self._context_host + .borrow_mut() + .set_indexed_db_manager(indexed_db_manager.clone()); + if let Some(store) = storage_bucket_store { + self.storage_bucket_store = store.clone(); + self._context_host + .borrow_mut() + .set_storage_bucket_store(store.clone()); + crate::context_bootstrap::set_storage_bucket_store_for_context( + v8::Local::new(scope, &self.page_default_context), + Some(store), + ); + } + + let context = v8::Local::new(scope, &self.page_default_context); + crate::context_bootstrap::set_indexed_db_manager_for_context(context, indexed_db_manager); + } + pub(super) fn set_storage_bucket_store( &mut self, store: crate::context_bootstrap::SharedStorageBucketStore, @@ -1865,6 +1891,11 @@ impl ScriptVm { } } +struct ScriptVmInitialDocumentEnvironment { + origin: String, + policy_container: crate::document_runtime::DocumentPolicyContainer, +} + impl ScriptVmPageRealmBootstrap { fn new_from_dom_host( dom_host: DomHost, @@ -1885,6 +1916,7 @@ impl ScriptVmPageRealmBootstrap { reserved_service_worker_client_id: Option< crate::service_worker_runtime::ServiceWorkerClientId, >, + initial_document_environment: Option, ) -> std::result::Result { let document_handle = dom_host.document_handle(); let document_url = dom_host @@ -1924,6 +1956,12 @@ impl ScriptVmPageRealmBootstrap { main_parser_continuation_sender, )); document_runtime.set_bypass_content_security_policy(bypass_content_security_policy); + let initial_document_origin = initial_document_environment + .as_ref() + .map(|environment| environment.origin.clone()); + if let Some(environment) = initial_document_environment { + document_runtime.set_initial_document_policy_container(environment.policy_container); + } let (page_context_cancel_tx, page_context_cancel_rx) = renderer_page_context_cancel_channel(); @@ -1931,15 +1969,20 @@ impl ScriptVmPageRealmBootstrap { renderer_document_isolate, bridge_bindings, renderer_document_isolate_teardown, + inspector_isolate_backend: _, page_inspector, script_agent_page_membership, renderer_page_script_environment, reuse_main_window_proxy, } = renderer_document_isolate_bootstrap; - renderer_document_isolate.with_renderer_document_isolate_and_inspector_mut(|_, backend| { - page_inspector - .reattach_v8_sessions(backend, runtime_inspector_session_restore_snapshots); - }); + if !runtime_inspector_session_restore_snapshots.is_empty() { + renderer_document_isolate.with_renderer_document_isolate_and_inspector_mut( + |_, backend| { + page_inspector + .reattach_v8_sessions(backend, runtime_inspector_session_restore_snapshots); + }, + ); + } if let (Some(environment), Some(commit)) = ( renderer_page_script_environment.as_ref(), main_document_commit, @@ -1984,7 +2027,8 @@ impl ScriptVmPageRealmBootstrap { crate::native_bridge::WindowDocumentOwner::Frame(main_document_owner), document_url.clone(), context_host.document_base_url_for_handle(document_handle), - moli_url::origin_ascii_serialization(&document_url), + initial_document_origin + .unwrap_or_else(|| moli_url::origin_ascii_serialization(&document_url)), ) }; let initial_document_loader = @@ -2039,10 +2083,42 @@ impl ScriptVmPageRealmBootstrap { fn bootstrap_default_world( self, ) -> std::result::Result { + Ok(self + .prebootstrap_default_world()? + .materialize_default_inspector_context()) + } + + fn prebootstrap_default_world( + self, + ) -> std::result::Result + { + let renderer_document_isolate = self.renderer_document_isolate.clone(); + renderer_document_isolate.with_renderer_document_isolate_and_bootstrap_mut( + |isolate, isolate_bootstrap| { + let scope = pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let global_template = isolate_bootstrap.global_template(scope); + self.bootstrap_default_world_in_scope(scope, global_template) + }, + ) + } + + /// Prebootstraps a main default realm in an already-entered V8 scope. + /// + /// This is the top-level Page analogue of child-frame default-context + /// prebootstrap. It creates and binds the real Context, WindowProxy, + /// native host, and Document while deliberately leaving Inspector + /// attachment for a later owner-local materialization boundary. + fn bootstrap_default_world_in_scope<'s>( + self, + scope: &mut v8::PinScope<'s, '_, ()>, + global_template: v8::Local<'s, v8::ObjectTemplate>, + ) -> std::result::Result + { let ScriptVmPageRealmBootstrap { resource_owner_id, promise_reject_dispatch, - mut page_inspector, + page_inspector, renderer_document_isolate, renderer_document_isolate_teardown, document_runtime, @@ -2057,20 +2133,17 @@ impl ScriptVmPageRealmBootstrap { script_agent_page_membership, reuse_main_window_proxy, } = self; - let context_bootstrap = match renderer_document_isolate - .with_entered_renderer_document_isolate_and_bootstrap(|isolate, isolate_bootstrap| { - ScriptVmContextBootstrap::new_main_default( - isolate, - isolate_bootstrap, - context_host.clone(), - resource_owner_id, - &promise_reject_dispatch, - None, - Some(storage_bucket_store.clone()), - renderer_page_script_environment.clone(), - reuse_main_window_proxy, - ) - }) { + let context_bootstrap = match ScriptVmContextBootstrap::new_main_default_in_scope( + scope, + global_template, + context_host.clone(), + resource_owner_id, + &promise_reject_dispatch, + None, + Some(storage_bucket_store.clone()), + renderer_page_script_environment.clone(), + reuse_main_window_proxy, + ) { Ok(context) => context, Err(error) => { return Err(Box::new(( @@ -2086,59 +2159,36 @@ impl ScriptVmPageRealmBootstrap { }; let runtime_observable_context_token = context_bootstrap.runtime_observable_context_token; let (context, bridge_ref) = context_bootstrap.into_context_and_bridge_ref(); - if let Err(error) = - renderer_document_isolate.with_entered_renderer_document_isolate(|isolate| { - let scope = pin!(v8::HandleScope::new(isolate)); - let scope = &mut scope.init(); - let local_context = v8::Local::new(scope, &context); - context_host - .borrow_mut() - .install_page_default_context(scope, local_context); - Ok(()) - }) - { - return Err(Box::new(( - error, - recover_bootstrap_dom_host_from_holder( - renderer_document_isolate, - renderer_document_isolate_teardown, - context_host, - document_runtime, - ), - ))); - } - let inspector_document_isolate = renderer_document_isolate.clone(); - let baseline_globals = match renderer_document_isolate - .with_renderer_document_isolate_and_inspector_mut(|isolate, inspector| { - ScriptVmDefaultWorldBootstrap::attach_context_and_capture_baseline_globals( - inspector_document_isolate, - isolate, - inspector, - &mut page_inspector, - &context, - document_runtime.document_url(), - root_frame_id.as_deref(), - ) - }) { - Ok(baseline_globals) => baseline_globals, - Err(error) => { - drop(page_inspector); - return Err(Box::new(( - error, - recover_bootstrap_dom_host_from_holder( - renderer_document_isolate, - renderer_document_isolate_teardown, - context_host, - document_runtime, - ), - ))); - } - }; - if let Err(error) = register_main_window_execution_context_for_bootstrap( - &renderer_document_isolate, + let local_context = v8::Local::new(scope, &context); + context_host + .borrow_mut() + .install_page_default_context(scope, local_context); + let realm_scope = &mut v8::ContextScope::new(scope, local_context); + let baseline_globals = + match ScriptVmDefaultWorldBootstrap::capture_baseline_globals_in_scope(realm_scope) { + Ok(baseline_globals) => baseline_globals, + Err(error) => { + drop(page_inspector); + drop(bridge_ref); + drop(context); + drop(promise_reject_dispatch); + return Err(Box::new(( + error, + recover_bootstrap_dom_host_from_holder( + renderer_document_isolate, + renderer_document_isolate_teardown, + context_host, + document_runtime, + ), + ))); + } + }; + if let Err(error) = register_main_window_execution_context_for_bootstrap_in_scope( + realm_scope, &context_host, - &context, - ) { + ) + .context("failed to register main LocalWindow execution context") + { drop(page_inspector); drop(bridge_ref); drop(context); @@ -2153,31 +2203,81 @@ impl ScriptVmPageRealmBootstrap { ), ))); } - Ok(ScriptVmDefaultWorldBootstrap { - resource_owner_id, - promise_reject_dispatch, - page_inspector, - renderer_document_isolate, - renderer_document_isolate_teardown, - page_default_context: context, - bridge_ref, - runtime_observable_context_token, - baseline_globals, - document_runtime, - root_frame_id, - context_host, - prebootstrapped_child_default_contexts, - page_context_cancel_tx, - post_domcontentloaded_page_task_tx, - page_runtime_wake_tx, - storage_bucket_store, - renderer_page_script_environment, - script_agent_page_membership, + Ok(ScriptVmPreinspectorDefaultWorldBootstrap { + inner: ScriptVmDefaultWorldBootstrap { + resource_owner_id, + promise_reject_dispatch, + page_inspector, + renderer_document_isolate, + renderer_document_isolate_teardown, + page_default_context: context, + bridge_ref, + runtime_observable_context_token, + baseline_globals, + document_runtime, + root_frame_id, + context_host, + prebootstrapped_child_default_contexts, + page_context_cancel_tx, + post_domcontentloaded_page_task_tx, + page_runtime_wake_tx, + storage_bucket_store, + renderer_page_script_environment, + script_agent_page_membership, + }, }) } } impl ScriptVmDefaultWorldBootstrap { + #[allow(clippy::too_many_arguments)] + pub(super) fn prebootstrap_from_dom_host_in_scope( + scope: &mut v8::PinScope<'_, '_>, + bootstrap_dom_host: DomHost, + bypass_content_security_policy: bool, + page_task_tx: RuntimePageTaskSender, + page_task_parser_boundary_injection_tx: tokio::sync::mpsc::UnboundedSender, + resource_completion_tx: RendererResourceCompletionSender, + initial_document_loader_bootstrap: crate::network::context::DocumentResourceLoaderBootstrap, + browser_context_runtime: RendererBrowserContextRuntime, + javascript_dialog_runtime: crate::runtime::RendererJavaScriptDialogRuntime, + renderer_document_isolate_bootstrap: RendererDocumentIsolateBootstrap, + backend_node_registry: SharedRendererBackendNodeRegistry, + top_level_storage_key: Option, + reserved_service_worker_client_id: Option< + crate::service_worker_runtime::ServiceWorkerClientId, + >, + initial_document_origin: String, + initial_document_policy_container: crate::document_runtime::DocumentPolicyContainer, + ) -> std::result::Result + { + let global_template = renderer_document_isolate_bootstrap + .bridge_bindings + .window_global_template(scope); + ScriptVmPageRealmBootstrap::new_from_dom_host( + bootstrap_dom_host, + bypass_content_security_policy, + page_task_tx, + page_task_parser_boundary_injection_tx, + resource_completion_tx, + initial_document_loader_bootstrap, + browser_context_runtime, + javascript_dialog_runtime, + renderer_document_isolate_bootstrap, + &[], + backend_node_registry, + None, + None, + top_level_storage_key, + reserved_service_worker_client_id, + Some(ScriptVmInitialDocumentEnvironment { + origin: initial_document_origin, + policy_container: initial_document_policy_container, + }), + )? + .bootstrap_default_world_in_scope(&mut *scope, global_template) + } + #[cfg(test)] fn standalone_from_dom_host_with_resource_completion_sender_and_browser_context_runtime_for_test_with_current_runtime( bootstrap_dom_host: DomHost, @@ -2213,6 +2313,42 @@ impl ScriptVmDefaultWorldBootstrap { ) } + #[cfg(test)] + fn standalone_page_realm_from_dom_host_with_resource_completion_sender_and_browser_context_runtime_for_test_with_current_runtime( + bootstrap_dom_host: DomHost, + page_task_tx: RuntimePageTaskSender, + page_task_parser_boundary_injection_tx: tokio::sync::mpsc::UnboundedSender, + resource_completion_tx: RendererResourceCompletionSender, + initial_document_loader_bootstrap: crate::network::context::DocumentResourceLoaderBootstrap, + browser_context_runtime: RendererBrowserContextRuntime, + ) -> std::result::Result { + let renderer_document_isolate_bootstrap = + match RendererDocumentIsolateHandle::new_standalone_without_owner_reservation_for_test( + page_task_tx.v8_foreground_task_sender(), + ) { + Ok(bootstrap) => bootstrap, + Err(error) => return Err(Box::new((error, bootstrap_dom_host))), + }; + ScriptVmPageRealmBootstrap::new_from_dom_host( + bootstrap_dom_host, + false, + page_task_tx, + page_task_parser_boundary_injection_tx, + resource_completion_tx, + initial_document_loader_bootstrap, + browser_context_runtime, + crate::runtime::RendererJavaScriptDialogRuntime::default(), + renderer_document_isolate_bootstrap, + &[], + crate::runtime::new_shared_renderer_backend_node_registry(), + None, + None, + None, + None, + None, + ) + } + pub(super) fn from_dom_host_with_resource_completion_sender_browser_context_runtime_and_document_isolate( bootstrap_dom_host: DomHost, bypass_content_security_policy: bool, @@ -2249,35 +2385,14 @@ impl ScriptVmDefaultWorldBootstrap { main_document_commit, top_level_storage_key, reserved_service_worker_client_id, + None, )? .bootstrap_default_world() } - fn attach_context_and_capture_baseline_globals( - renderer_document_isolate: RendererDocumentIsolateHandle, - isolate: &mut v8::OwnedIsolate, - inspector: &mut RendererInspectorIsolateBackend, - page_inspector: &mut DocumentInspectorBinding, - context: &v8::Global, - document_url: &Url, - root_frame_id: Option<&str>, + fn capture_baseline_globals_in_scope( + scope: &mut v8::PinScope<'_, '_>, ) -> Result { - let scope = pin!(v8::HandleScope::new(isolate)); - let scope = &mut scope.init(); - let local_context = v8::Local::new(scope, context); - let default_context = v8::Global::new(scope.as_ref(), local_context); - let registered_context = v8::Global::new(scope.as_ref(), local_context); - let scope = &mut v8::ContextScope::new(scope, local_context); - page_inspector.attach_context( - renderer_document_isolate, - inspector, - local_context, - default_context, - registered_context, - document_url, - root_frame_id, - ); - #[cfg(not(any(test, feature = "test-support")))] { let _ = scope; @@ -2382,15 +2497,161 @@ impl ScriptVmDefaultWorldBootstrap { .borrow_mut() .set_storage_bucket_store(vm.storage_bucket_store.clone()); if let Some(environment) = &vm.renderer_page_script_environment { - vm._context_host - .borrow_mut() - .bind_output_journal(environment.output_journal()); + let mut context_host = vm._context_host.borrow_mut(); + context_host.bind_output_journal(environment.output_journal()); + context_host.bind_page_script_environment(environment.clone()); } Ok(vm) } } +impl ScriptVmPreinspectorDefaultWorldBootstrap { + fn materialize_default_inspector_context(mut self) -> ScriptVmDefaultWorldBootstrap { + let renderer_document_isolate = self.inner.renderer_document_isolate.clone(); + let inspector_document_isolate = renderer_document_isolate.clone(); + let context = &self.inner.page_default_context; + let document_url = self.inner.document_runtime.document_url().clone(); + let document_origin = self + .inner + .context_host + .borrow() + .current_main_document_resource_loader() + .map(|loader| loader.document_origin()) + .unwrap_or_else(|| moli_url::origin_ascii_serialization(&document_url)); + let root_frame_id = self.inner.root_frame_id.clone(); + let page_inspector = &mut self.inner.page_inspector; + renderer_document_isolate.with_renderer_document_isolate_and_inspector_mut( + |isolate, inspector| { + let scope = pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let local_context = v8::Local::new(scope, context); + let default_context = v8::Global::new(scope.as_ref(), local_context); + let registered_context = v8::Global::new(scope.as_ref(), local_context); + let _scope = &mut v8::ContextScope::new(scope, local_context); + page_inspector.attach_context( + inspector_document_isolate, + inspector, + local_context, + default_context, + registered_context, + &document_url, + &document_origin, + root_frame_id.as_deref(), + ); + }, + ); + self.inner + } + + /// Binds the top-level auxiliary relationship while the target Context is + /// already available in the opener's entered isolate scope. + pub(super) fn initialize_auxiliary_window_in_scope<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + opener: Option<&v8::Global>, + name: &str, + inherited_origin: &str, + auxiliary_popup_id: u64, + ) -> Result<()> { + let context = v8::Local::new(scope, &self.inner.page_default_context); + let scope = &mut v8::ContextScope::new(scope, context); + let global = context.global(scope); + let opener: v8::Local<'s, v8::Value> = match opener { + Some(opener) => { + let opener = v8::Local::new(scope, opener); + crate::context_bootstrap::inherit_auxiliary_window_viewport_surface( + scope, opener, global, + ); + opener.into() + } + None => v8::null(scope).into(), + }; + set_private_value(scope, global, WINDOW_OPENER_SLOT, opener); + if let Some(name) = crate::util::v8_string(scope, name) { + set_object_slot(scope, global, WINDOW_NAME_SLOT, name.into()); + } + crate::native_bridge::set_renderer_owned_auxiliary_popup_id( + scope, + global, + auxiliary_popup_id, + ); + crate::context_bootstrap::set_window_origin_runtime_state(scope, global, inherited_origin) + } + + /// Finishes Rust ownership without registering the already-live Context + /// with Inspector. Protocol adoption performs that one remaining step. + pub(super) fn finish_without_inspector( + self, + ) -> std::result::Result { + self.inner.finish() + } +} + impl ScriptVm { + /// Materializes Inspector ownership for a synchronously staged initial + /// Page without creating a second Context, WindowProxy, or Document. + pub(super) fn materialize_staged_initial_default_inspector_context( + &mut self, + root_frame_id: Option, + main_document_commit: Option, + runtime_inspector_session_restore_snapshots: + &[crate::runtime::RendererInspectorSessionRestoreSnapshot], + ) -> Result<()> { + let environment = self + .renderer_page_script_environment + .as_ref() + .ok_or_else(|| anyhow!("staged initial Page is missing its script environment"))? + .clone(); + let deferred_author_records = environment + .output_journal() + .take_unpublished_records_for_initial_context_adoption()?; + self.root_frame_id = root_frame_id; + let renderer_document_isolate = self.renderer_document_isolate.clone(); + let inspector_document_isolate = renderer_document_isolate.clone(); + let context = &self.page_default_context; + let document_url = self.document_runtime.document_url().clone(); + let document_origin = self + .current_main_document_resource_loader() + .map(|loader| loader.document_origin()) + .unwrap_or_else(|| moli_url::origin_ascii_serialization(&document_url)); + let root_frame_id = self.root_frame_id.clone(); + let page_inspector = &mut self.page_inspector; + renderer_document_isolate.with_renderer_document_isolate_and_inspector_mut( + |isolate, inspector| { + page_inspector + .reattach_v8_sessions(inspector, runtime_inspector_session_restore_snapshots); + if let Some(commit) = main_document_commit { + environment.output_journal().append( + crate::runtime::PendingRendererOutputRecord::observation( + None, + crate::runtime::RendererProtocolObservation::MainDocumentCommit(commit), + ), + ); + } + let scope = pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let local_context = v8::Local::new(scope, context); + let default_context = v8::Global::new(scope.as_ref(), local_context); + let registered_context = v8::Global::new(scope.as_ref(), local_context); + let _scope = &mut v8::ContextScope::new(scope, local_context); + page_inspector.attach_context( + inspector_document_isolate, + inspector, + local_context, + default_context, + registered_context, + &document_url, + &document_origin, + root_frame_id.as_deref(), + ); + }, + ); + environment + .output_journal() + .append_records(deferred_author_records); + Ok(()) + } + pub(crate) fn current_main_document_resource_loader(&self) -> Option { self._context_host .borrow() @@ -3098,6 +3359,7 @@ impl ScriptVm { let scope = pin!(v8::HandleScope::new(isolate)); let scope = &mut scope.init(); let context = unsafe { v8::Local::new(scope, &*context_ptr) }; + let scope = &mut v8::ContextScope::new(scope, context); let global_proxy = context.global(scope); let is_stable_proxy = environment.with_main_window_proxy(|stable_proxy| { v8::Local::new(scope, stable_proxy).strict_equals(global_proxy.into()) @@ -3107,6 +3369,7 @@ impl ScriptVm { "main context global does not match its page-owned WindowProxy" )); } + environment.capture_main_window_opener_for_navigation(scope, global_proxy); context.detach_global(); Ok(()) })?; @@ -3360,11 +3623,15 @@ impl ScriptVm { fn default_runtime_realm_info(&self) -> Option { let context_id = self.runtime_observable_default_execution_context_id()?; let document_url = self.document_runtime.document_url(); + let origin = self + .current_main_document_resource_loader() + .map(|loader| loader.document_origin()) + .unwrap_or_else(|| moli_url::origin_ascii_serialization(document_url)); Some(RendererRuntimeRealmInfo { context_id, realm_id: self.runtime_observable_default_execution_context_realm_id(), frame_id: self.root_frame_id.clone(), - origin: moli_url::origin_ascii_serialization(document_url), + origin, name: document_url.as_str().to_owned(), is_default: true, context_type: "default".to_owned(), @@ -4402,6 +4669,26 @@ impl ScriptVm { &mut self, script: &DocumentStartScript, ) -> Result> { + if script.browser_internal { + match script.world_name.as_deref() { + Some(world_name) => { + let created = self + .page_isolated_world_contexts + .execution_context_id_for_scope(None, world_name) + .is_none(); + let execution_context_id = self.ensure_isolated_world(world_name, false)?; + self.exec_browser_internal_bootstrap_script_in_execution_context( + execution_context_id, + &script.source, + )?; + return Ok(Some((execution_context_id, created))); + } + None => { + self.exec_browser_internal_bootstrap_script(&script.source)?; + return Ok(None); + } + } + } match script.world_name.as_deref() { Some(world_name) => { let created = self @@ -4424,6 +4711,12 @@ impl ScriptVm { execution_context_id: i64, script: &DocumentStartScript, ) -> Result<()> { + if script.browser_internal { + return self.exec_browser_internal_bootstrap_script_in_execution_context( + execution_context_id, + &script.source, + ); + } self.exec_in_execution_context(execution_context_id, &script.source) } @@ -6783,18 +7076,19 @@ impl ScriptVm { }) .ok(); let host = self._context_host.borrow(); + let script_agent_page_count = self.renderer_document_isolate.script_agent_page_count(); RendererMoliMemoryDiagnostics { scope: RendererMoliMemoryScopeDiagnostics { - v8_heap: "page-vm-document-isolate", - v8_heap_is_target_local: true, + v8_heap: "script-agent-document-isolate", + v8_heap_is_target_local: script_agent_page_count == 1, counters: "target-document", - garbage_collection: "page-vm-document-isolate", + garbage_collection: "script-agent-document-isolate", }, dom, runtime: RendererMoliRuntimeMemoryDiagnostics { script_agent_id: self.renderer_document_isolate.script_agent_id().value(), script_agent_scope: self.renderer_document_isolate.script_agent_scope().as_str(), - script_agent_page_count: self.renderer_document_isolate.script_agent_page_count(), + script_agent_page_count, runtime_observable_context_count: self.page_runtime_observable_contexts().len(), isolated_context_count: self.page_isolated_world_contexts.len(), child_default_context_count: self.child_frame_realm_store.len(), @@ -6827,8 +7121,8 @@ impl ScriptVm { inspector_default_context_registry_count: self .renderer_document_isolate .renderer_document_isolate_inspector_default_context_registry_count(), - inspector_default_context_registry_scope: "page-vm-document-isolate", - v8_foreground_task_wake_scope: "page-vm-document-isolate", + inspector_default_context_registry_scope: "script-agent-document-isolate", + v8_foreground_task_wake_scope: "script-agent-document-isolate", v8_foreground_task_wake_context_group_id_available: false, v8_foreground_task_wake_internal_policy: "typed-page-source-and-owner-scheduler", v8_foreground_task_wake_external_policy: "post-turn-runtime-output", @@ -7146,6 +7440,7 @@ impl ScriptVm { let host = self._context_host.borrow(); let mut snapshot = RendererPageDiagnosticsSnapshot::from_diagnostics(RendererActivityDiagnostics { + script_agent_id: Some(self.renderer_document_isolate.script_agent_id().value()), document_context_count: 1 + self.page_isolated_world_contexts.len() + self.child_frame_realm_store.len(), @@ -7576,6 +7871,28 @@ impl ScriptVm { self.exec_in_isolated_context(execution_context_id, source) } + pub(super) fn exec_browser_internal_bootstrap_script_in_execution_context( + &mut self, + execution_context_id: i64, + source: &str, + ) -> Result<()> { + let context_ptr = if self + .child_frame_realm_store + .contains_key(&execution_context_id) + { + self.child_frame_realm_context_ptr_for_execution_context_id(execution_context_id)? + } else { + let world = self + .page_isolated_world_contexts + .context(execution_context_id) + .ok_or_else(|| { + anyhow!("unknown isolated execution context `{execution_context_id}`") + })?; + &world.context as *const _ + }; + self.exec_browser_internal_bootstrap_script_in_context_ptr(context_ptr, source) + } + fn runtime_binding_document_owner( &mut self, execution_context_id: Option, diff --git a/moli-renderer-v8/src/script_vm/child_frame_realm_materialization.rs b/moli-renderer-v8/src/script_vm/child_frame_realm_materialization.rs index 5277734443..9c2ab05afb 100644 --- a/moli-renderer-v8/src/script_vm/child_frame_realm_materialization.rs +++ b/moli-renderer-v8/src/script_vm/child_frame_realm_materialization.rs @@ -344,6 +344,18 @@ impl ScriptVm { if script.source.trim().is_empty() { continue; } + if script.browser_internal { + activity = ChildRealmMaterializationBodyActivity::DocumentStartScript; + if let Err(error) = self + .exec_browser_internal_bootstrap_script_in_execution_context( + execution_context_id, + &script.source, + ) + { + return (activity, Err(error)); + } + continue; + } let job = match self.child_frame_source_script_job_for_execution_context_id( execution_context_id, crate::frame_owner_model::FrameScriptJobKind::Eval, @@ -540,8 +552,15 @@ impl ScriptVm { } }; activity = ChildRealmMaterializationBodyActivity::DocumentStartScript; - if let Err(error) = self.exec_in_execution_context(execution_context_id, &script.source) - { + let execution = if script.browser_internal { + self.exec_browser_internal_bootstrap_script_in_execution_context( + execution_context_id, + &script.source, + ) + } else { + self.exec_in_execution_context(execution_context_id, &script.source) + }; + if let Err(error) = execution { self.record_runtime_warning(format_args!( "child preload script in world `{world_name}` failed: {error}" )); diff --git a/moli-renderer-v8/src/script_vm/document_isolate.rs b/moli-renderer-v8/src/script_vm/document_isolate.rs index 33f3eb5fb9..051399e32b 100644 --- a/moli-renderer-v8/src/script_vm/document_isolate.rs +++ b/moli-renderer-v8/src/script_vm/document_isolate.rs @@ -10,7 +10,7 @@ use super::{ }; use crate::{ browsing_context_model::ScriptAgentId, - context_bootstrap::ContextBootstrapAssets, + context_bootstrap::{ContextBootstrapAssets, WINDOW_OPENER_SLOT}, document_runtime::DocumentRuntime, exception_reporting::v8_message_listener, module_runtime::{ @@ -27,7 +27,11 @@ use crate::{ RendererPageV8ForegroundTaskSender, }, resource_owner::ResourceOwnerId, - runtime::RendererPageContextCancelSender, + runtime::{ + RendererAuxiliaryPageReservationAllocator, RendererPageContextCancelSender, + RendererStagedAuxiliaryWindowProxy, + }, + util::{get_private_value, set_private_value}, v8_platform::{ RendererScriptAgentPageMembership, RendererScriptAgentV8ForegroundTaskRouter, V8ForegroundTaskWake, V8PlatformIsolateRegistration, @@ -129,6 +133,7 @@ pub(crate) struct RendererDocumentIsolateBootstrap { pub(super) renderer_document_isolate: RendererDocumentIsolateHandle, pub(super) bridge_bindings: NativeBridgeBindings, pub(super) renderer_document_isolate_teardown: RendererDocumentIsolateTeardown, + pub(super) inspector_isolate_backend: RendererInspectorIsolateBackendHandle, pub(super) page_inspector: DocumentInspectorBinding, pub(super) script_agent_page_membership: Option, pub(super) renderer_page_script_environment: Option, @@ -157,8 +162,7 @@ impl RendererDocumentIsolateBootstrap { } pub(crate) fn inspector_isolate_backend_handle(&self) -> RendererInspectorIsolateBackendHandle { - self.renderer_document_isolate - .inspector_isolate_backend_handle() + self.inspector_isolate_backend.clone() } pub(crate) fn with_renderer_page_script_environment( @@ -173,16 +177,26 @@ impl RendererDocumentIsolateBootstrap { self.page_inspector = page_inspector; self } + + pub(crate) fn with_reused_main_window_proxy(mut self) -> Self { + self.reuse_main_window_proxy = true; + self + } } #[derive(Clone)] pub(crate) struct RendererPageScriptEnvironment { page_id: u64, + auxiliary_page_reservation_allocator: RendererAuxiliaryPageReservationAllocator, renderer_document_isolate: RendererDocumentIsolateHandle, + inspector_isolate_backend: RendererInspectorIsolateBackendHandle, script_agent_page_membership: RendererScriptAgentPageMembership, page_runtime_task_source: PageRuntimeTaskSource, output_journal: crate::runtime::RendererTurnOutputJournal, global_proxy: Rc>>, + initial_global_proxy_facade_context: Rc>>>, + initial_global_proxy_security_token: Rc>>>, + navigation_persistent_opener: Rc>>>, } impl std::fmt::Debug for RendererPageScriptEnvironment { @@ -200,6 +214,10 @@ impl std::fmt::Debug for RendererPageScriptEnvironment { ) .field("output_stream", &self.output_journal.stream()) .field("has_global_proxy", &self.global_proxy.get().is_some()) + .field( + "has_navigation_persistent_opener", + &self.navigation_persistent_opener.borrow().is_some(), + ) .finish() } } @@ -207,7 +225,9 @@ impl std::fmt::Debug for RendererPageScriptEnvironment { impl RendererPageScriptEnvironment { pub(crate) fn new( page_id: u64, + auxiliary_page_reservation_allocator: RendererAuxiliaryPageReservationAllocator, renderer_document_isolate: RendererDocumentIsolateHandle, + inspector_isolate_backend: RendererInspectorIsolateBackendHandle, script_agent_page_membership: RendererScriptAgentPageMembership, page_runtime_task_source: PageRuntimeTaskSource, output_journal: crate::runtime::RendererTurnOutputJournal, @@ -218,11 +238,16 @@ impl RendererPageScriptEnvironment { ); Ok(Self { page_id, + auxiliary_page_reservation_allocator, renderer_document_isolate, + inspector_isolate_backend, script_agent_page_membership, page_runtime_task_source, output_journal, global_proxy: Rc::new(OnceCell::new()), + initial_global_proxy_facade_context: Rc::new(RefCell::new(None)), + initial_global_proxy_security_token: Rc::new(RefCell::new(None)), + navigation_persistent_opener: Rc::new(RefCell::new(None)), }) } @@ -230,6 +255,12 @@ impl RendererPageScriptEnvironment { self.page_id } + pub(crate) fn auxiliary_page_reservation_allocator( + &self, + ) -> RendererAuxiliaryPageReservationAllocator { + self.auxiliary_page_reservation_allocator.clone() + } + pub(crate) fn page_runtime_task_source(&self) -> PageRuntimeTaskSource { self.page_runtime_task_source.clone() } @@ -238,6 +269,23 @@ impl RendererPageScriptEnvironment { self.output_journal.clone() } + pub(crate) fn stage_related_initial_empty_page_in_scope( + &self, + scope: &mut v8::PinScope<'_, '_>, + source_bridge_bindings: &NativeBridgeBindings, + pending: crate::runtime::RendererPendingAuxiliaryPage, + init: crate::runtime::RendererRelatedInitialEmptyPageRealmInit, + ) -> Result<()> { + self.auxiliary_page_reservation_allocator + .stage_related_initial_empty_page_in_scope( + scope, + pending, + self, + source_bridge_bindings, + init, + ) + } + pub(crate) fn clear_page_runtime_tasks(&self) { self.page_runtime_task_source.clear(); } @@ -263,15 +311,13 @@ impl RendererPageScriptEnvironment { &self, ) -> Result { let bridge_bindings = self.renderer_document_isolate.build_bridge_bindings()?; - let isolate_backend = self - .renderer_document_isolate - .inspector_isolate_backend_handle(); Ok(RendererDocumentIsolateBootstrap { renderer_document_isolate: self.renderer_document_isolate.clone(), bridge_bindings, renderer_document_isolate_teardown: RendererDocumentIsolateTeardown::owner_reserved_page(), - page_inspector: DocumentInspectorBinding::new(isolate_backend) + inspector_isolate_backend: self.inspector_isolate_backend.clone(), + page_inspector: DocumentInspectorBinding::new(self.inspector_isolate_backend.clone()) .with_output_journal(self.output_journal()), script_agent_page_membership: None, renderer_page_script_environment: Some(self.clone()), @@ -279,13 +325,62 @@ impl RendererPageScriptEnvironment { }) } - #[cfg(test)] - pub(crate) fn bootstrap_related_page_document_isolate_for_experiment( + pub(crate) fn bootstrap_related_page_document_isolate( &self, v8_foreground_task_sender: RendererPageV8ForegroundTaskSender, ) -> Result { - self.renderer_document_isolate - .bootstrap_related_page_document_isolate(v8_foreground_task_sender) + let script_agent_page_membership = self + .script_agent_page_membership + .admit_related_page(v8_foreground_task_sender)?; + let bridge_bindings = match self.renderer_document_isolate.build_bridge_bindings() { + Ok(bindings) => bindings, + Err(error) => { + script_agent_page_membership.retire(); + return Err(error); + } + }; + Ok(self + .related_page_document_isolate_bootstrap(bridge_bindings, script_agent_page_membership)) + } + + /// Prepares an explicitly related Page isolate bootstrap without + /// re-entering or re-borrowing the document-isolate holder. + /// + /// This is the admission half of synchronous auxiliary realm creation. + /// The caller owns an already-entered opener scope, so the source Page's + /// retained membership and bridge templates are the only authorities this + /// operation may use. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn bootstrap_related_page_document_isolate_in_scope( + &self, + scope: &mut v8::PinScope<'_, '_>, + source_bridge_bindings: &NativeBridgeBindings, + v8_foreground_task_sender: RendererPageV8ForegroundTaskSender, + ) -> Result { + let script_agent_page_membership = self + .script_agent_page_membership + .admit_related_page(v8_foreground_task_sender)?; + let bridge_bindings = source_bridge_bindings.build_peer_in_scope(scope); + Ok(self + .related_page_document_isolate_bootstrap(bridge_bindings, script_agent_page_membership)) + } + + fn related_page_document_isolate_bootstrap( + &self, + bridge_bindings: NativeBridgeBindings, + script_agent_page_membership: RendererScriptAgentPageMembership, + ) -> RendererDocumentIsolateBootstrap { + RendererDocumentIsolateBootstrap { + renderer_document_isolate: self.renderer_document_isolate.clone(), + bridge_bindings, + renderer_document_isolate_teardown: + RendererDocumentIsolateTeardown::owner_reserved_page(), + inspector_isolate_backend: self.inspector_isolate_backend.clone(), + page_inspector: DocumentInspectorBinding::new(self.inspector_isolate_backend.clone()), + script_agent_page_membership: Some(script_agent_page_membership), + renderer_page_script_environment: None, + reuse_main_window_proxy: false, + } } pub(super) fn install_initial_main_window_proxy( @@ -297,6 +392,43 @@ impl RendererPageScriptEnvironment { .map_err(|_| anyhow!("page script environment already retains its main WindowProxy")) } + pub(crate) fn install_staged_initial_main_window_proxy( + &self, + staged: RendererStagedAuxiliaryWindowProxy, + ) -> Result<()> { + anyhow::ensure!( + self.initial_global_proxy_facade_context.borrow().is_none(), + "page script environment already retains a WindowProxy facade context" + ); + let (window_proxy, facade_context, security_token) = staged.into_parts(); + self.install_initial_main_window_proxy(window_proxy)?; + *self.initial_global_proxy_facade_context.borrow_mut() = Some(facade_context); + *self.initial_global_proxy_security_token.borrow_mut() = security_token; + Ok(()) + } + + pub(super) fn take_main_window_proxy_for_context<'s>( + &self, + scope: &mut v8::PinScope<'s, '_, ()>, + ) -> Result> { + let window_proxy = + self.with_main_window_proxy(|window_proxy| v8::Local::new(scope, window_proxy))?; + if let Some(facade_context) = self.initial_global_proxy_facade_context.borrow_mut().take() { + v8::Local::new(scope, &facade_context).detach_global(); + } + Ok(window_proxy) + } + + pub(super) fn take_initial_main_window_security_token<'s>( + &self, + scope: &mut v8::PinScope<'s, '_, ()>, + ) -> Option> { + self.initial_global_proxy_security_token + .borrow_mut() + .take() + .map(|token| v8::Local::new(scope, &token)) + } + pub(super) fn with_main_window_proxy( &self, op: impl FnOnce(&v8::Global) -> T, @@ -306,6 +438,29 @@ impl RendererPageScriptEnvironment { })?; Ok(op(global_proxy)) } + + pub(super) fn capture_main_window_opener_for_navigation<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + window_proxy: v8::Local<'s, v8::Object>, + ) { + *self.navigation_persistent_opener.borrow_mut() = + get_private_value(scope, window_proxy, WINDOW_OPENER_SLOT) + .map(|opener| v8::Global::new(scope, opener)); + } + + pub(super) fn restore_main_window_opener_after_navigation<'s>( + &self, + scope: &mut v8::PinScope<'s, '_>, + window_proxy: v8::Local<'s, v8::Object>, + ) { + let opener = self.navigation_persistent_opener.borrow(); + let Some(opener) = opener.as_ref() else { + return; + }; + let opener = v8::Local::new(scope, opener); + set_private_value(scope, window_proxy, WINDOW_OPENER_SLOT, opener); + } } pub(crate) struct ScriptVmDefaultWorldBootstrap { @@ -320,16 +475,31 @@ pub(crate) struct ScriptVmDefaultWorldBootstrap { pub(super) bridge_ref: JsContextHostBridgeRef, pub(super) runtime_observable_context_token: RuntimeObservableContextToken, pub(super) baseline_globals: super::ScriptGlobalsBaseline, - pub(super) document_runtime: Box, pub(super) root_frame_id: Option, - pub(super) context_host: Rc>, pub(super) prebootstrapped_child_default_contexts: SharedPrebootstrappedChildDefaultContexts, + // `JsContextHost` stores a non-owning pointer into `document_runtime`. + // Keep every realm/bridge owner before the host and the host before the + // runtime so cancellation of a staged preinspector bootstrap is safe. + pub(super) context_host: Rc>, + pub(super) document_runtime: Box, pub(super) page_context_cancel_tx: RendererPageContextCancelSender, pub(super) post_domcontentloaded_page_task_tx: PageTaskSender, pub(super) page_runtime_wake_tx: PageRuntimeWakeSender, pub(super) storage_bucket_store: crate::context_bootstrap::SharedStorageBucketStore, } +/// A fully bootstrapped main Page realm whose Inspector default-context +/// registration has deliberately not happened yet. +/// +/// The V8 Context, stable WindowProxy, native bridge, and Document host are +/// already live at this boundary. Keeping Inspector attachment as a distinct +/// materialization step mirrors child-frame prebootstrap and is what makes it +/// possible to create an auxiliary realm synchronously from an opener callback +/// without re-entering the shared document isolate. +pub(crate) struct ScriptVmPreinspectorDefaultWorldBootstrap { + pub(super) inner: ScriptVmDefaultWorldBootstrap, +} + #[derive(Clone, Copy, Debug)] pub(super) struct RendererDocumentIsolateTeardown { unregister_platform_on_context_teardown: bool, @@ -429,30 +599,7 @@ impl RendererDocumentIsolateHandle { renderer_document_isolate, bridge_bindings, renderer_document_isolate_teardown, - page_inspector: DocumentInspectorBinding::new(isolate_backend), - script_agent_page_membership: Some(script_agent_page_membership), - renderer_page_script_environment: None, - reuse_main_window_proxy: false, - }) - } - - #[cfg(test)] - pub(crate) fn bootstrap_related_page_document_isolate( - &self, - v8_foreground_task_sender: RendererPageV8ForegroundTaskSender, - ) -> Result { - let script_agent_page_membership = self - .inner - .borrow() - .script_agent_foreground_router - .admit_related_page(v8_foreground_task_sender)?; - let bridge_bindings = self.build_bridge_bindings()?; - let isolate_backend = self.inspector_isolate_backend_handle(); - Ok(RendererDocumentIsolateBootstrap { - renderer_document_isolate: self.clone(), - bridge_bindings, - renderer_document_isolate_teardown: - RendererDocumentIsolateTeardown::owner_reserved_page(), + inspector_isolate_backend: isolate_backend.clone(), page_inspector: DocumentInspectorBinding::new(isolate_backend), script_agent_page_membership: Some(script_agent_page_membership), renderer_page_script_environment: None, @@ -568,6 +715,17 @@ impl RendererDocumentIsolateHandle { with_entered_owned_isolate(isolate, |isolate| op(isolate, &*bootstrap)) } + pub(super) fn with_renderer_document_isolate_and_bootstrap_mut( + &self, + op: impl FnOnce(&mut v8::OwnedIsolate, &IsolateBootstrapCache) -> T, + ) -> T { + let mut holder = self.inner.borrow_mut(); + let RendererDocumentIsolateHolder { + isolate, bootstrap, .. + } = &mut *holder; + with_entered_owned_isolate_value(isolate, |isolate| op(isolate, &*bootstrap)) + } + pub(super) fn unregister_renderer_document_isolate_platform(&self) { self.inner.borrow_mut()._platform_registration.unregister(); } diff --git a/moli-renderer-v8/src/script_vm/eval_exec.rs b/moli-renderer-v8/src/script_vm/eval_exec.rs index fd186bedc9..7289c953bf 100644 --- a/moli-renderer-v8/src/script_vm/eval_exec.rs +++ b/moli-renderer-v8/src/script_vm/eval_exec.rs @@ -718,6 +718,28 @@ impl ScriptVm { .into_inner_for_internal_snapshot() } + /// Executes browser-owned realm bootstrap without exposing an + /// implementation script to page-facing Inspector instrumentation. + pub(super) fn exec_browser_internal_bootstrap_script_in_context_ptr( + &mut self, + context_ptr: *const v8::Global, + source: &str, + ) -> Result<()> { + self.eval_string_in_context_ptr_without_turn_drain_with_kind( + context_ptr, + source, + ContextStringEvaluationKind::InspectorInternal, + EvalStringMicrotaskCheckpoint::Perform, + ) + .into_inner_for_internal_snapshot() + .map(drop) + } + + pub(crate) fn exec_browser_internal_bootstrap_script(&mut self, source: &str) -> Result<()> { + let context_ptr: *const v8::Global = &self.page_default_context as *const _; + self.exec_browser_internal_bootstrap_script_in_context_ptr(context_ptr, source) + } + /// Read a test probe without manufacturing an intervening task checkpoint. /// /// P5 body/completion witnesses use this only between an already-selected diff --git a/moli-renderer-v8/src/script_vm/inspector/mod.rs b/moli-renderer-v8/src/script_vm/inspector/mod.rs index 7533e9fb85..1fa16de258 100644 --- a/moli-renderer-v8/src/script_vm/inspector/mod.rs +++ b/moli-renderer-v8/src/script_vm/inspector/mod.rs @@ -322,6 +322,7 @@ impl DocumentInspectorBinding { default_context: v8::Global, registered_context: v8::Global, final_url: &Url, + document_origin: &str, root_frame_id: Option<&str>, ) { self.agent.assert_isolate_backend(backend); @@ -339,7 +340,7 @@ impl DocumentInspectorBinding { } let aux_data = serde_json::to_string(&Value::Object(aux_data)) .expect("default execution context aux data should serialize"); - let origin = inspector_execution_context_origin(final_url); + let origin = inspector_execution_context_origin(document_origin); let context_unique_id = backend.context_created_with_unique_id( context, context_group_id, @@ -471,7 +472,8 @@ impl DocumentInspectorBinding { "frameId": frame_id, }))?; let context_group_id = self.agent.context_group_id(); - let origin = inspector_execution_context_origin(document_url); + let document_origin = moli_url::origin_ascii_serialization(document_url); + let origin = inspector_execution_context_origin(&document_origin); let context_unique_id = backend.context_created_with_unique_id( context, context_group_id, @@ -539,12 +541,11 @@ impl DocumentInspectorBinding { } } -fn inspector_execution_context_origin(url: &Url) -> String { - let origin = moli_url::origin_ascii_serialization(url); - if origin == "null" { +fn inspector_execution_context_origin(document_origin: &str) -> String { + if document_origin == "null" { "://".to_owned() } else { - origin + document_origin.to_owned() } } diff --git a/moli-renderer-v8/src/script_vm/post_parse.rs b/moli-renderer-v8/src/script_vm/post_parse.rs index a642f2fd5f..8714916b17 100644 --- a/moli-renderer-v8/src/script_vm/post_parse.rs +++ b/moli-renderer-v8/src/script_vm/post_parse.rs @@ -37,9 +37,17 @@ pub(super) fn dynamic_script_execute_is_runnable_before_dom_content_loaded( } impl ScriptVmContextBootstrap { - pub(super) fn new_main_default( - isolate: &mut v8::OwnedIsolate, - isolate_bootstrap: &IsolateBootstrapCache, + /// Creates a main default world inside a V8 scope that is already entered. + /// + /// Native callbacks such as `window.open()` cannot borrow the owning + /// document isolate a second time while author script is on the stack. + /// Keeping the in-scope primitive next to the child-default equivalent + /// lets a caller prebootstrap a distinct Page realm without re-entering + /// the isolate; Inspector materialization remains a later owner action. + #[allow(clippy::too_many_arguments)] + pub(super) fn new_main_default_in_scope<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + global_template: v8::Local<'s, v8::ObjectTemplate>, context_host: Rc>, resource_owner_id: ResourceOwnerId, promise_reject_dispatch: &PromiseRejectDispatchSlot, @@ -48,9 +56,9 @@ impl ScriptVmContextBootstrap { renderer_page_script_environment: Option, reuse_main_window_proxy: bool, ) -> Result { - Self::new_with_mode( - isolate, - isolate_bootstrap, + Self::new_in_scope( + scope, + global_template, context_host, resource_owner_id, promise_reject_dispatch, @@ -171,7 +179,7 @@ impl ScriptVmContextBootstrap { "replacement main context is missing its page script environment" ) })? - .with_main_window_proxy(|proxy| v8::Local::new(scope, proxy))?, + .take_main_window_proxy_for_context(scope)?, ), WindowContextBootstrapMode::MainDefault => None, WindowContextBootstrapMode::ChildDefault { child_handle, .. } => { @@ -237,6 +245,18 @@ impl ScriptVmContextBootstrap { "failed to allocate internalized Window security token; using unique context token" ); } + if matches!(mode, WindowContextBootstrapMode::MainDefault) + && reusable_window_proxy.is_some() + && let Some(security_token) = renderer_page_script_environment + .as_ref() + .and_then(|environment| environment.take_initial_main_window_security_token(scope)) + { + // Initial about:blank inherits its creator's exact effective + // origin. Reusing the creator-visible proxy is insufficient for + // opaque or document.domain-mutated origins because V8 otherwise + // assigns the new context a distinct default token. + local_context.set_security_token(security_token); + } if matches!( mode, WindowContextBootstrapMode::MainDefault @@ -304,6 +324,12 @@ impl ScriptVmContextBootstrap { } => unsafe { &*host_ptr }.document_url().clone(), }; finish_context_bootstrap(scope, unsafe { &mut *host_ptr }, &secure_context_url)?; + if matches!(mode, WindowContextBootstrapMode::MainDefault) + && reusable_window_proxy.is_some() + && let Some(environment) = renderer_page_script_environment.as_ref() + { + environment.restore_main_window_opener_after_navigation(scope, global); + } match mode { WindowContextBootstrapMode::Isolated { child_handle: Some(child_handle), diff --git a/moli-renderer-v8/src/script_vm/service_worker_internal_client_request_body.rs b/moli-renderer-v8/src/script_vm/service_worker_internal_client_request_body.rs index d261a40ddc..363e2815ae 100644 --- a/moli-renderer-v8/src/script_vm/service_worker_internal_client_request_body.rs +++ b/moli-renderer-v8/src/script_vm/service_worker_internal_client_request_body.rs @@ -253,6 +253,7 @@ impl ScriptVm { None, "_blank", &url, + None, creator_base_url.clone(), crate::document_runtime::DocumentPolicyContainer::default(), ) @@ -335,6 +336,7 @@ impl ScriptVm { None, "_blank", &url, + None, creator_base_url.clone(), crate::document_runtime::DocumentPolicyContainer::default(), ) diff --git a/moli-renderer-v8/src/script_vm/standalone_test_harness.rs b/moli-renderer-v8/src/script_vm/standalone_test_harness.rs index 21652263fa..357f7fade8 100644 --- a/moli-renderer-v8/src/script_vm/standalone_test_harness.rs +++ b/moli-renderer-v8/src/script_vm/standalone_test_harness.rs @@ -239,3 +239,249 @@ impl DerefMut for StandaloneScriptVmHarness { &mut self.vm } } + +#[cfg(test)] +mod tests { + use std::{ + pin::pin, + sync::{Arc, atomic::AtomicU64}, + }; + + use moli_parser::HtmlParser; + use url::Url; + + use super::*; + use crate::{ + JsRuntime, + page_task_queue::{ + PageTaskQueueTestHarness, RendererOwnerWakeSender, RendererPageTaskTestResidence, + RendererResourceCompletionSender, + }, + runtime::{ + PageId, RendererAuxiliaryPageReservationAllocator, RendererOutputStreamIdentity, + RendererOwnerLocalHostId, RendererPageToken, RendererTurnOutputJournal, + }, + script_vm::{RendererDocumentIsolateHandle, RendererPageScriptEnvironment}, + }; + + fn evaluate_preinspector_realm( + bootstrap: &mut super::super::ScriptVmPreinspectorDefaultWorldBootstrap, + source: &str, + ) -> anyhow::Result { + let renderer_document_isolate = bootstrap.inner.renderer_document_isolate.clone(); + let context = &bootstrap.inner.page_default_context; + renderer_document_isolate.with_entered_renderer_document_isolate(|isolate| { + let scope = pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let context = v8::Local::new(scope, context); + let scope = &mut v8::ContextScope::new(scope, context); + let source = crate::util::v8_string(scope, source) + .ok_or_else(|| anyhow::anyhow!("failed to allocate preinspector test source"))?; + let script = v8::Script::compile(scope, source, None) + .ok_or_else(|| anyhow::anyhow!("failed to compile preinspector test source"))?; + let value = script + .run(scope) + .ok_or_else(|| anyhow::anyhow!("failed to run preinspector test source"))?; + Ok(value + .to_string(scope) + .ok_or_else(|| anyhow::anyhow!("preinspector test value was not stringifiable"))? + .to_rust_string_lossy(scope)) + }) + } + + #[test] + fn main_default_realm_prebootstrap_preserves_window_and_document_until_inspector_materialization() + { + let _js_runtime = JsRuntime::initialize(); + let standalone_runtime = StandaloneTestRuntime::new(); + let _runtime_guard = standalone_runtime.runtime().enter(); + let page_task_queue = PageTaskQueueTestHarness::new(); + let page_task_tx = page_task_queue.owner_attached_runtime_page_task_sender_for_test(); + let parser_boundary_tx = page_task_queue.parser_boundary_sender(); + let document = HtmlParser.parse( + Url::parse("https://prebootstrap.example/").expect("test URL"), + "initial".to_owned(), + ); + let resource_loader_owner = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()) + .expect("standalone test loader"); + let browser_context_owner = RendererBrowserContextRuntime::new(); + let initial_document_loader_bootstrap = DocumentResourceLoaderBootstrap::new( + resource_loader_owner.handle(), + standalone_runtime.resource_task_runner(), + ); + let page_realm = ScriptVmDefaultWorldBootstrap::standalone_page_realm_from_dom_host_with_resource_completion_sender_and_browser_context_runtime_for_test_with_current_runtime( + DomHost::from_dom(document), + page_task_tx, + parser_boundary_tx, + RendererResourceCompletionSender::direct_completion_only(), + initial_document_loader_bootstrap, + browser_context_owner.handle(), + ) + .expect("main Page realm scaffold should bootstrap"); + let renderer_document_isolate = page_realm.renderer_document_isolate.clone(); + let mut preinspector = renderer_document_isolate + .with_renderer_document_isolate_and_bootstrap_mut(|isolate, isolate_bootstrap| { + let scope = pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let opener_context = v8::Context::new(scope, Default::default()); + let scope = &mut v8::ContextScope::new(scope, opener_context); + let global_template = isolate_bootstrap.global_template(scope); + page_realm.bootstrap_default_world_in_scope(scope, global_template) + }) + .expect("main default realm should prebootstrap inside the entered opener scope"); + assert_eq!( + preinspector + .inner + .renderer_document_isolate + .renderer_document_isolate_inspector_default_context_registry_count(), + 0, + "prebootstrap must not publish an Inspector default context while the opener callback is active" + ); + assert_eq!( + evaluate_preinspector_realm( + &mut preinspector, + "globalThis.__prebootstrapDocument = document; globalThis.__prebootstrapArray = Array; document.body.textContent = 'written-before-materialization'; String(document.body.textContent)", + ) + .expect("preinspector realm should already be script-visible"), + "written-before-materialization" + ); + + let bootstrap = preinspector.materialize_default_inspector_context(); + assert_eq!( + bootstrap + .renderer_document_isolate + .renderer_document_isolate_inspector_default_context_registry_count(), + 1, + "materialization must publish exactly the prebootstrapped realm" + ); + let mut vm = bootstrap + .finish() + .expect("materialized realm should finish"); + assert_eq!( + vm.eval("`${__prebootstrapDocument === document}|${__prebootstrapArray === Array}|${document.body.textContent}`") + .expect("materialized realm state should remain observable"), + "true|true|written-before-materialization", + "Inspector materialization must not replace the Window global, intrinsics, or initial Document" + ); + drop(vm); + drop(_runtime_guard); + drop(standalone_runtime); + } + + #[test] + fn related_page_isolate_admission_builds_peer_bindings_inside_entered_opener_scope() { + let _js_runtime = JsRuntime::initialize(); + let source_page_id = PageId::new_for_testing(701); + let target_page_id = PageId::new_for_testing(702); + let (source_wake_tx, _source_wake_rx) = tokio::sync::mpsc::unbounded_channel(); + let source_residence = + RendererPageTaskTestResidence::new(Some(RendererOwnerWakeSender::new( + source_wake_tx, + RendererPageToken::new_for_testing(source_page_id), + ))); + let source_runtime_task_source = source_residence.runtime_source(); + let source_v8_sender = source_runtime_task_source + .v8_foreground_task_sender() + .expect("source Page test residence should expose its V8 route"); + let source_bootstrap = source_residence + .with_owner_runtime(|| { + RendererDocumentIsolateHandle::new_standalone_without_owner_reservation_for_test( + source_v8_sender, + ) + }) + .expect("source Page document isolate should bootstrap"); + let source_isolate = + source_bootstrap.clone_renderer_document_isolate_handle_for_owner_retention(); + let source_inspector_backend = source_bootstrap.inspector_isolate_backend_handle(); + let source_membership = source_bootstrap + .script_agent_page_membership() + .expect("source Page bootstrap should retain its script-agent membership"); + let source_environment = RendererPageScriptEnvironment::new( + source_page_id.as_u64(), + RendererAuxiliaryPageReservationAllocator::new_for_test( + RendererOwnerLocalHostId::new_for_testing(1), + source_page_id, + Arc::new(AtomicU64::new(703)), + ), + source_isolate.clone(), + source_inspector_backend, + source_membership, + source_runtime_task_source, + RendererTurnOutputJournal::new( + RendererOutputStreamIdentity::new_page_for_protocol_test(source_page_id), + ), + ) + .expect("source Page script environment should bind its exact membership"); + + let (target_wake_tx, _target_wake_rx) = tokio::sync::mpsc::unbounded_channel(); + let target_residence = + RendererPageTaskTestResidence::new(Some(RendererOwnerWakeSender::new( + target_wake_tx, + RendererPageToken::new_for_testing(target_page_id), + ))); + let target_v8_sender = target_residence + .runtime_source() + .v8_foreground_task_sender() + .expect("target Page test residence should expose its V8 route"); + + let target_bootstrap = source_isolate + .with_renderer_document_isolate_and_bootstrap_mut(|isolate, _| { + let scope = pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let opener_context = v8::Context::new(scope, Default::default()); + let opener_global = opener_context.global(scope); + let scope = &mut v8::ContextScope::new(scope, opener_context); + let target_bootstrap = source_environment + .bootstrap_related_page_document_isolate_in_scope( + scope, + &source_bootstrap.bridge_bindings, + target_v8_sender, + )?; + let target_global_template = target_bootstrap + .bridge_bindings + .window_global_template(scope); + let target_context = v8::Context::new( + scope, + v8::ContextOptions { + global_template: Some(target_global_template), + ..Default::default() + }, + ); + anyhow::ensure!( + !target_context + .global(scope) + .strict_equals(opener_global.into()), + "related Page bridge bindings must construct an independent Context global" + ); + Ok::<_, anyhow::Error>(target_bootstrap) + }) + .expect("related Page admission must not re-borrow the already-entered isolate holder"); + + assert_eq!( + source_isolate.script_agent_page_count(), + 2, + "the source membership capability should admit exactly one related Page route" + ); + assert_eq!( + target_bootstrap + .clone_renderer_document_isolate_handle_for_owner_retention() + .identity_key(), + source_isolate.identity_key(), + "related Page admission must retain the opener's exact script-agent isolate" + ); + assert_eq!( + target_bootstrap + .script_agent_page_membership() + .expect("related bootstrap should retain its target membership") + .page_id(), + target_page_id + ); + + drop(target_bootstrap); + assert_eq!( + source_isolate.script_agent_page_count(), + 1, + "dropping an unadopted related bootstrap must roll back its Page route" + ); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation.rs index 279eccaec4..33e456e62e 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation.rs @@ -1294,6 +1294,93 @@ fn history_navigation_arguments_use_webidl_conversion() { ); } +#[test] +fn root_initial_empty_document_replaces_same_and_cross_document_history_updates() { + let mut vm = new_storage_test_vm("about:blank"); + vm.mark_root_document_initial_empty(); + + let same_document = vm + .eval( + r##" +(() => { + location.hash = "#fragment"; + const fragmentLength = history.length; + history.pushState({ step: "state" }, "", "#state"); + return JSON.stringify({ + href: location.href, + fragmentLength, + historyLength: history.length, + navigationLength: navigation.entries().length, + state: history.state.step + }); +})() +"##, + ) + .expect("initial empty same-document navigation should evaluate"); + assert_eq!( + same_document, + r##"{"href":"about:blank#state","fragmentLength":1,"historyLength":1,"navigationLength":1,"state":"state"}"## + ); + + assert_eq!( + vm.eval(r#"location.href = "https://initial-empty-next.test/committed"; "queued""#) + .expect("initial empty cross-document navigation should queue"), + "queued" + ); + let pending = vm + .take_pending_location_navigation_with_seed() + .expect("initial empty cross-document navigation should be pending"); + let seed = pending + .entry_seed + .expect("initial empty cross-document navigation should carry a seed"); + assert_eq!(seed.current_index, 0); + assert_eq!(seed.entries.len(), 1); + assert_eq!( + seed.entries[0].url, + "https://initial-empty-next.test/committed" + ); + assert_eq!( + seed.activation + .as_ref() + .and_then(|activation| activation.navigation_type.as_deref()), + Some("replace") + ); +} + +#[test] +fn document_open_exits_root_initial_empty_history_replacement_mode() { + let mut vm = new_storage_test_vm("about:blank"); + vm.mark_root_document_initial_empty(); + + assert_eq!( + vm.eval( + r#" +document.open(); +document.close(); +location.href = "https://after-document-open.test/committed"; +"queued" +"#, + ) + .expect("document.open initial-empty history probe should evaluate"), + "queued" + ); + + let pending = vm + .take_pending_location_navigation_with_seed() + .expect("post-document.open navigation should be pending"); + let seed = pending + .entry_seed + .expect("post-document.open navigation should carry a seed"); + assert_eq!(seed.current_index, 1); + assert_eq!(seed.entries.len(), 2); + assert_eq!( + seed.activation + .as_ref() + .and_then(|activation| activation.navigation_type.as_deref()), + Some("push") + ); +} + #[test] fn history_state_preserves_structured_clone_values_not_representable_as_json() { let mut vm = new_storage_test_vm("https://example.com/base"); 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 103bec066c..362ec4bf3f 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 @@ -1376,6 +1376,7 @@ fn document_start_run_immediately_drains_pending_style_invalidations_without_com source: "globalThis.__runImmediatelyDrainTarget.className = 'active';".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }) .expect("run-immediately document-start script should execute"); diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index d07fabe4a3..a8c67d9072 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -6286,6 +6286,7 @@ async fn pending_child_navigation_does_not_materialize_initial_empty_preload_rea source: "globalThis.__documentStartUrl = document.URL;".to_owned(), world_name: None, has_bidi_channel_argument: false, + browser_internal: false, bidi_channel_handoffs: Vec::new(), }]); diff --git a/moli-renderer-v8/src/v8_platform.rs b/moli-renderer-v8/src/v8_platform.rs index e217c01b8b..e583d8d9c6 100644 --- a/moli-renderer-v8/src/v8_platform.rs +++ b/moli-renderer-v8/src/v8_platform.rs @@ -24,9 +24,9 @@ use std::sync::{ }; /// Isolate-lifetime routing authority for the Pages admitted to one script -/// agent. The production policy currently installs one member; the Phase 2 -/// experiment can add explicitly related Pages without widening admission to -/// the whole renderer owner. +/// agent. Fresh Pages install one member. A renderer-accepted, opener-related +/// auxiliary Page may explicitly join that agent without widening admission +/// to the whole renderer owner. #[derive(Clone)] pub(crate) struct RendererScriptAgentV8ForegroundTaskRouter { inner: Arc>, @@ -68,10 +68,7 @@ impl RendererScriptAgentV8ForegroundTaskCompletion { self.router.enqueue_peer_checkpoints(task_page_id); } - pub(crate) fn redispatch_after_page_retirement( - self, - task: moli_v8_platform::V8ForegroundTask, - ) { + pub(crate) fn redispatch_after_page_retirement(self, task: moli_v8_platform::V8ForegroundTask) { self.router.dispatch(task); } } @@ -88,6 +85,23 @@ impl RendererScriptAgentPageMembership { self.inner.page_id } + /// Admits one explicitly related Page through the capability retained by + /// a live source Page. + /// + /// Native callbacks can invoke this while the shared document isolate is + /// already mutably borrowed. Keeping admission on the membership avoids a + /// second borrow of the isolate holder merely to reach its router. + pub(crate) fn admit_related_page( + &self, + page_route: RendererPageV8ForegroundTaskSender, + ) -> anyhow::Result { + self.inner.router.admit_related_page_from( + self.inner.page_id, + &self.inner.active, + page_route, + ) + } + pub(crate) fn retire(&self) { if self.inner.active.swap(false, Ordering::AcqRel) { self.inner.router.remove_page(self.inner.page_id); @@ -121,12 +135,26 @@ impl RendererScriptAgentV8ForegroundTaskRouter { (router, membership) } - #[cfg(test)] - pub(crate) fn admit_related_page( + fn admit_related_page_from( &self, + source_page_id: PageId, + source_active: &AtomicBool, page_route: RendererPageV8ForegroundTaskSender, ) -> anyhow::Result { - self.admit_page(page_route, ScriptAgentScope::RelatedPageGroup) + let mut state = self.inner.lock(); + anyhow::ensure!( + source_active.load(Ordering::Acquire) + && state + .page_routes + .iter() + .any(|(page_id, _)| *page_id == source_page_id), + "retired script-agent Page membership cannot admit a related Page" + ); + self.admit_page_with_locked_state( + &mut state, + page_route, + ScriptAgentScope::RelatedPageGroup, + ) } fn admit_page( @@ -134,8 +162,17 @@ impl RendererScriptAgentV8ForegroundTaskRouter { page_route: RendererPageV8ForegroundTaskSender, scope: ScriptAgentScope, ) -> anyhow::Result { - let page_id = page_route.page_id(); let mut state = self.inner.lock(); + self.admit_page_with_locked_state(&mut state, page_route, scope) + } + + fn admit_page_with_locked_state( + &self, + state: &mut RendererScriptAgentV8ForegroundTaskRouterState, + page_route: RendererPageV8ForegroundTaskSender, + scope: ScriptAgentScope, + ) -> anyhow::Result { + let page_id = page_route.page_id(); anyhow::ensure!( !state .page_routes @@ -147,7 +184,6 @@ impl RendererScriptAgentV8ForegroundTaskRouter { ); state.scope = scope; state.page_routes.push((page_id, page_route)); - drop(state); Ok(RendererScriptAgentPageMembership { inner: Arc::new(RendererScriptAgentPageMembershipState { router: self.clone(),