diff --git a/Cargo.lock b/Cargo.lock index b7967d6474..ca702f0680 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2366,6 +2366,7 @@ name = "moli-core" version = "0.1.0" dependencies = [ "anyhow", + "moli-bounded-buffer", "moli-browser-profile", "moli-cookie-jar", "moli-dom", @@ -2377,6 +2378,7 @@ dependencies = [ "moli-storage-key", "moli-storage-service", "moli-test-support", + "moli-trace", "moli-url", "moli-web-mime", "moli-wpt-compat", @@ -2707,7 +2709,6 @@ dependencies = [ "data-url", "http", "indexmap", - "moli-bounded-buffer", "moli-browser-profile", "moli-cookie-jar", "moli-core", @@ -3037,6 +3038,11 @@ dependencies = [ [[package]] name = "moli-trace" version = "0.1.0" +dependencies = [ + "parking_lot", + "serde", + "serde_json", +] [[package]] name = "moli-url" diff --git a/docs/browser-core-protocol-frontend-separation-plan-current.md b/docs/browser-core-protocol-frontend-separation-plan-current.md new file mode 100644 index 0000000000..9d8b274538 --- /dev/null +++ b/docs/browser-core-protocol-frontend-separation-plan-current.md @@ -0,0 +1,6851 @@ +# Browser Core 与协议前端分离改造计划 + +最后更新:2026-08-06 + +状态:核心改造已完成,进入稳定期。本文保留迁移顺序和历史实现记录;当前验收以 authority、 +lifetime 和 progress dependency 为准,不再以统一类型名、消除所有 `await` 或让 standalone +`Browser` 经过 DevTools Host 作为完成条件。类型名是工作名,所有权边界比命名优先。 + +## 结论 + +Moli 长期要把当前混在 `CdpConnection` / `CdpScheduler` 里的两种责任拆开: + +1. **Browser Core / Browser Owner** 自主拥有 BrowserContext、Target、Page、NavigationEngine、 + navigation/replacement/termination、history、网络和 profile/storage 运行时; +2. **协议前端** 只拥有 transport、frontend session、domain enable/subscription、command correlation、 + wire shape、DevTools renderer channel 和 browser fact 的协议投影。 + +这里的“拆开”首先指 authority、state、lifetime 和 scheduler lane 分开,不要求立即拆 OS +process。第一阶段仍使用同一进程、同一 dedicated current-thread runtime / local executor,避免 +增加 V8 thread-affinity 风险和进程级资源成本。只有 typed command/fact 边界稳定、且性能或隔离 +证据支持时,才重新评估 IPC 或多进程。 + +这不是把当前的跨线程 CDP 和 Browser Core 搬到一起。当前 CDP scheduler 与承担 Browser Core +职责的 `CdpConnection` / `NavigationEngine` 控制状态本来就在同一个 `moli-cdp-owner` +线程。改造目标是在这个线程内先拆出两个独立的逻辑 owner queue: + +```text +Browser Owner queue:唯一执行 browser action,并自主推进 navigation +CDP frontend queue:提交 typed command、订阅 fact,并生成 CDP response/event +``` + +因此第一阶段的完成标准是 execution authority 和 progress dependency 分离,而不是线程数发生 +变化。两个 queue 可以长期共用同一个 current-thread executor;同线程只保证 turn 串行,不自动 +保证 ownership 和 progress 正确。 + +最终原则是: + +```text +CDP / BiDi / Classic / CLI command ----+ + | +renderer navigation intent ------------+--> Browser/Page Owner + | + +-> network / history + +-> navigation / replacement + +-> target / page lifetime + +-> renderer lifecycle + | + v + browser facts/outcomes + | + +-------------------+-------------------+ + v v v + CDP BiDi high-level wait +``` + +CDP 仍然可以发起 `Page.navigate`,但它只提交命令;renderer 的 `location.href` 也只提交 intent。 +两种来源都由同一个 Browser Owner 安装并执行 navigation。协议 observation、WebSocket flush、慢 +客户端和 pending DCL/load attachment 都不能成为 browser progress 的隐式开关。 + +## 这份文档解决什么问题 + +本文集中回答以下长期问题: + +- `CdpConnection` 中哪些状态属于浏览器,哪些属于协议 frontend; +- 页面 JS 导航和 `Page.navigate` 最终由谁执行; +- Browser Owner 如何在没有下一条 frontend command 时继续推进; +- DCL/load、navigation commit、target lifecycle 和 network activity 如何投影到多个协议; +- frontend disconnect、慢 socket、pending command 和 event backpressure 是否影响页面; +- 现有 `moli-core`、`moli-protocol`、renderer 和应用层怎样分阶段迁移; +- 如何避免一次性重写 `CdpConnection`,以及每一阶段用什么证据验收。 + +本文不重新设计 V8 Inspector session restore,也不重新定义 DCL/Load/Done。相关语义分别由: + +- [`chromium-aligned-devtools-navigation-redesign-2026-07-27.md`](chromium-aligned-devtools-navigation-redesign-2026-07-27.md); +- [`document-milestone-navigation-completion-design-current.md`](document-milestone-navigation-completion-design-current.md); +- [`page-lifecycle-current.md`](page-lifecycle-current.md) + +继续负责。本文只定义它们与 Browser Core / protocol frontend 的交界。 + +## 当前线程拓扑与问题定性 + +截至 2026-08-03,`serve` 路径的相关执行拓扑是: + +```text +application Tokio multi-thread runtime + -> protocol transport / registry / service tasks + +lm-protocol-seq shared protocol OS thread + -> Tokio current-thread runtime + LocalSet + -> independent application owner task / input composition + -> BrowserHostActor (Core mailbox + turn capability owner) + -> BrowserHostTurn + -> BrowserHostExecutionLane (application pending/completion residence) + -> CdpScheduler + -> protocol frontend residences + -> CdpConnection + -> BrowserHostTurn short physical start/completion adapter + -> BrowserHostHandle (producer endpoint only) + -> BrowserContext / Target state + -> active/background NavigationEngine control + +render_runtime dedicated OS thread + -> renderer owner loop + -> V8 / DOM / parser / timer / microtask / Document lifecycle +``` + +也就是说: + +- CDP transport 不必和 owner 在同一线程;输入通过 channel 进入 owner; +- `CdpScheduler`、DevTools Host adapter 与 Browser Host owner task 在同一个 `lm-protocol-seq` + current-thread runtime 上独立调度;共享物理线程不等于共享 actor/state,frontend wait 会正常 yield; +- renderer 已经是另一个专用线程,通过 command、publication 和 completion channel 与 owner 通信; +- 第20切片建立了第一条 protocol-neutral Browser Owner queue;第31切片进一步把 mailbox receiver/selection + 收进 Core `BrowserHostActor`,并让 `CdpConnection` 在该 input boundary 只保存 cloneable + `BrowserHostHandle` producer endpoint。 + renderer top-level navigation 现在直接 publish 到 handle,不再经过 protocol scheduler envelope; +- 第32切片删除公开的 raw-input pop/complete API。只有 Core actor 能从 mailbox 构造 + `BrowserHostTurn` capability,并在持有唯一 mutable turn 权限期间调用 protocol-neutral executor trait; + `CdpScheduler` 和行为 fixture 的调度路径不再取得或转交裸 `BrowserOwnerInput`; +- 第33切片把 `BrowserHostActor` residence 从 `CdpScheduler` 字段移到 application input composition;CDP、BiDi、 + Classic 的 blocking select 以及 direct command wait 都直接监听 Core mailbox。actor 在 receive 时内部保留 exact + selected turn,application 只得到无 payload marker,再于 select branch 外非取消地调用 executor; +- 第34切片把 `BrowserHostTurnExecutor` 改成同步短 turn:renderer/network 等 participant wait 被封装成 + move-owned `PendingBrowserHostTurn`,由 application `BrowserHostExecutionLane` 登记并异步等待,再把 + `CompletedBrowserHostTurn` 作为独立 wake 回投。Browser Host actor 和 `CdpConnection` 都不会跨该 participant + wait 被借用,后续 mailbox input 可以继续启动; +- 第35切片把 response-ready navigation completion 再拆成 network load、prepared-Document configuration、 + renderer Document commit 三个 move-owned phase。direct Host completion 和 production background navigation + 共用 `domains/page/navigation_completion.rs` 状态机;background gate 只在最终 commit/error phase 结算,两个中间 + participant wake 不再借用 `CdpConnection`,也不会伪装成 terminal navigation; +- 第36切片继续把 response-ready navigation tail 中的 renderer Inspector replay 拆成 move-owned participant。 + 每次只启动一个 exact Page dispatch,wait 不借用 `CdpConnection`,completion apply 后才启动下一 replay;同一个 + background gate 贯穿 commit 与全部 replay,只有 replay batch 终止后才结算。target-keyed `NavigationEngine` 也改为 + 在 Document commit 的同步 apply turn 内 adoption,不能被迟到的旧 replay tail 写进 successor Page; +- 第37切片把 generic materialized `Loaded` / `Download` / `Failed` outcome 也接入同一个 navigation-tail + participant seam。body apply 结束后先在当前 Document commit turn 内 adoption engine,再逐项发布 Inspector replay; + background lifecycle completion 不再把整个 replay batch inline 等完,exact gate 继续到最后一个 replay 才 terminal; +- 第38切片把 generic loaded Page 的 Runtime/Fetch/permission restore 拆成 move-owned participant;第39切片又把 + physical replacement 与退出 residence 的旧 Page close 拆成独立 disposal participant。两个 wait 都不持有 + `CdpConnection`,engine/Page residence 在各自 exact completion apply turn 提交; +- 第40切片确认 disposal 后的 exact DedicatedWorker retirement 没有真实异步 teardown:旧 Page close 已先结算 + renderer lifetime,剩余 worker target/session registry commit 在同一个 owner apply turn 同步完成。它不再借用 + generic async binding-cleanup wrapper,也没有为了形式拆分新增一个永远 ready 的 participant; +- 第41切片继续删除 loaded lifecycle/activity projection 的伪异步 future:response、Target/network、DCL prefix 在同一 + owner apply turn 同步投影,真正的 load wait 以 `MainDocumentLoadOwnerAction` 在函数返回前发布; +- 第42切片把 loaded-navigation 的 BiDi preload listener startup 拆成 exact Page-owned participant:commit 时冻结 + realm inventory,按 `realm × handoff` FIFO 每次只启动一个 proxy/listener/cleanup renderer operation;pending wait + move-own Page dispatch,不借用 `CdpConnection`,completion apply 每次先重验 Page generation。listener 首条 deferred + reply 继续走独立 response-ready lane,不再成为 navigation terminal 条件; +- 第43切片继续把这些 preload operation 共用的 Runtime output normalization 拆成 exact renderer-attachment + participant。context-id compatibility 与 DOM-node subtype lookup 每次只启动一个 Page command,wait move-own + completion,apply 同步重验冻结的 BrowserContext/Target/renderer attachment;loaded-navigation 的 proxy、listener 与 + cleanup completion 因而不再通过通用 async normalization 借用 `CdpConnection`; +- 第44切片把 `Page.createIsolatedWorld` 的 post-create BiDi preload listener startup 也接入命令自身的 participant + 状态机。world 创建结果先冻结 execution-context id,再由 exact Page realm-inventory participant 和第42切片的 + preload batch 逐步推进;所有 wait 都 move-own pending participant,不借用 `CdpConnection`。realm inventory apply 前若 + Page 已 replacement,旧命令仍返回已经创建出的 context id,但不会进入 successor Page 启动 proxy/listener; +- 第45切片把 execution-context 到 BiDi preload listener 的 realm inventory 与 listener batch 收敛成 Runtime + `BidiPreloadListenerSetup`,并让 `Page.addScriptToEvaluateOnNewDocument` 的当前 Page 安装、run-immediately + execution-context 处理和 listener startup 进入独立 `preload/add_command.rs` 状态机。脚本注册先提交到 Target; + renderer wait 冻结 exact attachment,replacement 后丢弃旧 completion 但保留注册成功,不能把旧结果应用到 + successor Page。Page command 的每次 wait 都 move-own participant;protocol-neutral direct 入口只保留显式 + compatibility drain; +- 第46切片把普通 Runtime command 与 `Runtime.add/removeBinding` 的 Inspector output normalization 也并入 + command-owned participant chain。context-id/DOM-node lookup wait 不再发生在 completion apply 内;participant + 冻结原 command owner、deferred-response receiver 与 Page attachment,replacement 后以 exact stale error 终止旧 + command 并清理 await,不能等待旧 response channel 或进入 successor Page; +- 第47切片把 scheduler-facing protocol-neutral `ReleaseObjects` 从 start 阶段的多 handle inline drain 改成逐 handle + command-owned participant chain。每个 `Runtime.releaseObject` 复用 exact attachment/stale completion 语义,外层事务 + move-own 剩余 handles、当前 correlation、protocol events 与 renderer predecessor;首个 stale completion 会终止整个 + release command,不能继续进入 successor Page; +- 第48切片完成 Phase 3 exit audit。renderer top-level navigation 的唯一 execution authority 已是 Core + `BrowserHostActor`;production `ProtocolSchedulerWork` 不再包含该 action,Page domain subscription、下一 frontend + command、socket writer readiness 与 frontend detach 都不是它的 progress trigger。剩余 popup/termination owner work + 归 Phase 4,fact journal 和 Host lifetime 分别归 Phase 5/6,不再反向阻塞 Phase 3; +- 第49切片开始 Phase 4 的 command lane cutover。raw CDP 顶层跨 Document `Page.navigate` 现在只在 frontend + 解析/冻结参数并 publish `BrowserFrontendCommand::Navigate`;Core actor 选择 exact `BrowserHostTurn` 后才启动 + navigation。Core input 只携带 opaque Browser command id、exact Page residence、URL/referrer,CDP id/session/result + payload 留在 Protocol projection;Browser Host 不可用时返回 typed error,没有 direct fallback。non-flattened + `Target.sendMessageToTarget` 也已把 nested command 改成逐段 participant chain,不能在 Target handler 内 inline drain + Browser Owner command。same-document、child-frame、BiDi/Classic direct navigation 以及已启动 navigation 的 neutral + command completion 仍是 Phase 4 后续切片; +- 第50切片把 actor-selected raw CDP `Page.navigate` 的 participant lifetime 也交给 Browser Host。frontend 不再收到 + `PageCommandTaskStep::Pending` 并代替 owner drain;`PendingBrowserHostTurn` move-own exact Page participant、detached + command context 与 terminal projection sender,每个 completion 都作为独立 Browser Host wake 回到应用 owner loop。 + frontend 慢或在 selection 前后丢弃 wait 都不再改变导航 completion;live frontend 仍在原 command/nested Target + wrapper 内投影一次 response。terminal payload 尚是 Protocol `CommandOutputPlan`,下一切片继续拆成 neutral outcome; +- 第51切片把 Browser Host terminal envelope 中已有的 command response 拆成 Core + `BrowserNavigateCommandOutcome` 与 Protocol `BrowserNavigateCommandProjection`。outcome 只保存 requested URL、Target/ + loader、navigation error/download classification 或 typed rejection,不包含 CDP id/session、JSON、wire error code 或 + output queue;sidecar 只保存 response 插入位置、session routing、wire decoration、扩展字段和 renderer boundary 相对侧。 + live frontend 在原 wrapper 内重新投影,detached frontend 只丢 response 而保留 Browser effects。普通成功导航当时仍由 + `BackgroundNavigationEarlyResult` 稍后回包,因此 Host terminal 对该路径明确返回 deferred `None`,没有伪造 completed + outcome; +- 第52切片把上述 early-response producer 改为 `BackgroundNavigationEarlyOutcome`。network job 在 response headers ready + 时发布 Core `BrowserNavigateCommandOutcome`;CDP command id/session、未知 result 字段与 wire error decoration 只留在 + `BrowserNavigateCommandOutcomeDelivery` projection sidecar。它与 response-head Network progress 继续共用同一个物理 FIFO, + receiver/frontend ingress 才恢复 `BackgroundProtocolEvent`,因此没有用第二个 channel 引入跨 channel 猜序;普通成功 + `Page.navigate` 的 early completion 已 neutral 化,但 lifecycle/Network/Target facts 仍是既有 Protocol event,不能据此 + 宣称 Phase 5 fact journal 已建立; +- 第53切片把 raw CDP 顶层 same-Document `Page.navigate` 也汇入同一个 `BrowserFrontendCommand::Navigate`。frontend + 不再依据 mutable current URL 预分类 fragment navigation;Core actor 先选择 exact Page,physical Browser Host executor + 重验 residence 后才在该 owner route 内分类并启动既有 same-Document participant。该分支用 owner Target 重建无 + `loaderId` 的结果,只把 response correlation 留给 frontend;renderer publication 继续由独立 scheduler ingress 推进, + 不依赖 command receiver poll。child-frame、reload/history 与 BiDi/Classic direct adapter 仍待后续汇流; +- 第54切片把 raw CDP 顶层 `Page.reload` 迁入新增的 protocol-neutral `BrowserFrontendCommand::Reload`。Core input 只携带 + opaque command id、exact Page residence 与 reload options,刻意不携带 frontend 读取的 current URL;Browser Host 选中并 + 重验 exact Page 后才在 owner route 内解析 URL、标记 history replace 并持有 reload participant。navigate/reload 共用一个 + frontend-navigation correlation map 和 neutral outcome/projection seam,没有新增平行 owner queue;slow frontend 不再延迟 + reload HTTP request 或 replacement。history traversal、child-frame 与 BiDi/Classic direct adapter 仍待后续汇流; +- 第55切片把 raw CDP `Page.navigateToHistoryEntry` 迁入 + `BrowserFrontendCommand::TraverseHistory`。frontend 只提交 entry id 与 exact Page,不再预读 destination URL;Core history + owner 新增唯一的 entry/delta resolver,依据当前 cursor 与 Document sequence 返回 no-op、same-Document delta 或 + cross-Document destination。Browser Host 选中 exact turn 后才完成解析、分类并持有 participant;same-Document renderer fact + 与 cross-Document replacement 都不依赖 frontend receiver poll。BiDi/Classic direct adapter 已复用 Core resolver,但其 admission + 尚未汇入 owner mailbox;child-frame 也仍由 Page owner direct route 处理; +- 第56切片把带 DCL/load wait 的 BiDi/Classic 顶层 navigate/reload 也接到同一个 Browser Owner mailbox。Protocol start + adapter 只冻结 exact Page 和 frontend result projection;pending wait move-own neutral Browser completion,不借用 + `CdpConnection`。application scheduler 在等待 typed reply 时持续处理 Browser Host turn、participant completion 与 background + output;新 Page renderer stream 在 exact terminal commit boundary 前由显式 ingress gate 保留,不能抢先进入尚未安装 replacement + projection 的 Protocol 状态。direct frontend 不再靠 inline drain 取得 Browser execution authority。child-frame navigate 仍由 + Page/renderer owner 处理;`wait:none` 暂留既有 background outcome FIFO,避免在 Phase 5 前再造第二套 correlation queue; +- 第57切片把带 DCL/load wait 的 BiDi/Classic 顶层 history traversal 也接入同一 mailbox。Core command input 现在接受 + entry 或 delta;Browser Host 选中 exact Page 后才依据 browser-owned current cursor 解析 delta、查 URL 并分类 no-op、 + same-Document 或 cross-Document。Classic back/forward 删除了 frontend `GetNavigationHistory -> entry` 预计算,越界只在 + Classic result projector 映射为成功 no-op。same-Document renderer 拒绝并回退 URL load 时,terminal neutral outcome 会从 + `SameDocument` 改记为 `CrossDocument`,frontend 不再从 URL 或旧 history snapshot 猜测 realm 是否 replacement;direct + `wait:none` history 仍与其他 `wait:none` navigation 一样暂留 Phase 5 前的 mixed outcome FIFO; +- 第58切片把 Page 发起的 crash/close terminal action 接入同一个 Browser Owner mailbox。Core input 只保存 move-only + `BrowserTargetTerminationRequest`,其中冻结 BrowserContext、Target 与 Page generation;actor-selected turn 同步提交 Core + authority 与 physical Page absence,只有真实 retired Page destruction 才作为独立 Host participant 返回 completion mailbox; +- 第59切片把显式 top-level `Target.closeTarget` 的 Core commit、retired-Page disposal 与 retained-Target promotion 接入同一 + Host participant lane;第60切片再把 popup/auxiliary Target navigation 从 Protocol scheduler 搬到 Browser Owner input; + 第61切片最终删除 Page/Target termination 的 paused-fetch `ProtocolSchedulerWork` admission。command completion 仍先投影 + exact renderer predecessor,但 termination input 已直接进入 Host mailbox,真正执行只由稍后的 actor selection 授权; +- 第62切片把 direct BiDi/Classic `wait:none` 顶层 navigate/reload/history 也接入既有 Browser Owner mailbox。 + frontend 不再因 wait policy 回退到 Page direct path;exact Host turn 选中并启动 action 后即返回 protocol-neutral accepted + outcome,detached load 再独立推进 commit/DCL/load。旧 background command correlation 在 admission 时清除,因此后台完成 + 不能生成第二个 frontend response;detached load completion 仍暂住既有 background completion/output transport,尚不是 + Phase 5 Browser fact channel; +- 第63切片把 raw CDP `Page.stopLoading` 的 admission、当前 Document 选择与首个 renderer stop participant 迁入同一 + Browser Owner mailbox。frontend 只提交 opaque command id 和 stable Target/Page-slot capability;Host turn 被选中后才解析 + slot 的当前 generation。renderer wait 期间不借用 `CdpConnection`,replacement 后的旧 completion 按 exact generation + stale-drop,frontend 丢失也不取消 Browser action。旧的 frontend fake-pending/direct completion path 已删除;paused Fetch + cancellation 的逐项 materialization tail 当时仍是迁移期 inline apply; +- 第64切片继续把该 paused-Fetch cancellation tail 拆成 Browser Host 可见的逐项 participant chain。主文档 + request/auth/response failure 复用既有 navigation completion participant,subresource request/auth/response failure 各自 + start 一个 exact Page renderer participant;每次 completion apply 都重新验证 frozen Page generation,旧 Document 的 + cancellation 不能进入 replacement Page。原 frontend session 只保留为 response/event projection destination,不再参与 + action route 选择;终止和 BrowserContext disposal 等尚未迁移的调用方暂时通过同一状态机的 compatibility drain 执行, + 避免维护第二套 cancellation 语义; +- 第65切片把 raw CDP request-stage 主文档 `Fetch.failRequest` 也改成 Browser Owner input。Core 只接收 opaque command id、 + exact Page residence 与 protocol-neutral failure decision;move-owned `PendingFetchNavigation`、CDP response correlation 和 + Network/Page event projection 留在 Protocol sidecar。Host selection 后通过独立 `domains/fetch/navigation_decision.rs` + participant chain materialize/apply failure,frontend wait 丢失不取消动作,replacement 后旧 completion 只结算原 + navigation 的 stale error,不能 invalidate successor Page。Host publication 失败会恢复已取走的 paused request,不存在 + direct fallback;typed BiDi/Classic DevTools command 和 response-stage transfer 仍明确保留 compatibility completion,等待其 + frontend scheduler task seam 一并迁移; +- 第66切片继续把 raw/nested CDP request-stage 主文档 `Fetch.continueRequest` 接到同一个 + `ResolvePausedNavigation` owner input。request URL/method/body/header 与 response-stage intent 只在 exact Host turn 被选中并重验 + Page generation 后应用;Host admission 失败会原样恢复 paused request。普通 load 复用既有 + `BackgroundNavigationLoadJob`,intercepted network fetch、auth challenge body collection、buffered/streaming Document build 与 + response-stage prepared Document 各自成为 move-owned Host participant,等待时不借用 `CdpConnection`;frontend receiver 丢失 + 只丢 `Fetch.continueRequest` wire response,不取消 navigation。exact pending command 同时持有 raw renderer publication gate,覆盖 + Host completion 到 frontend command insertion boundary 的 handoff,避免 `MainDocumentCommit` 在 physical Page install 前按旧 + loader stale-drop;它不阻止 Host 自身推进,也不缓存/伪造 DCL。typed BiDi/Classic continue、`continueWithAuth`、response-stage + continue/fulfill/fail 仍保留 compatibility 路径; +- 第67切片继续把 raw/nested CDP 主文档 `Fetch.continueWithAuth` 接到同一个 + `ResolvePausedNavigation` owner input。Core auth decision 只表达 abort、expose challenged response 或以 browser credentials + retry,不携带 Fetch request id、CDP action/session 或 401/407 response;Protocol sidecar move-own 原 auth pause、response body 与 + projection route。Host turn 在 apply auth decision、网络 retry、Document build 和每个 renderer completion 前重验 exact Page; + frontend receiver 丢失只丢 auth ACK,不能取消 retry/commit,Host admission 失败则把同一个 auth pause 连同原 response `Arc` + 原样放回 registry。Digest retry 保留 libcurl buffered path,Basic response-stage interception 保留 streaming path;raw + Default/Cancel/ProvideCredentials 都进入 owner lane,但 chained multi-session auth 的下一次纯 projection、typed BiDi/Classic auth 与 + response-stage credentials compatibility 路径未在本切片顺带迁移; +- 第68切片补齐 BrowserContext exact-instance capability。Core-issued `BrowserContextHandle` 以单调 instance identity 区分 public id + 复用,registration/removal 与 physical projection 都校验 exact handle;因此排队的旧 Context command 不能在同 id 重建后命中新 + instance。该能力是 whole-Context owner action 的 admission 前置条件,不是仅为 disposal 增加的 Protocol token; +- 第69切片把 production raw CDP 和 typed BiDi/Classic `Target.disposeBrowserContext` 接入同一个 Browser Owner mailbox。 + Host selection 后才建立 move-only Context disposal reservation,阻止新 Target/navigation/Page replacement 进入;paused-Fetch + cancellation、每个 exact Target/Page close 与 residual Page disposal 都成为 Host participant,terminal turn 才依据当前 topology + 删除 exact Context 并选择 successor。frontend timeout/drop 只丢 reply,不能取消已经 accepted 的 cleanup; +- 第70切片把 raw/nested CDP response-stage 主文档 `Fetch.continueResponse` 接入既有 + `ResolvePausedNavigation` owner input。response transfer 与 Fetch/projection identity 留在 Protocol sidecar,Core 只看到 exact Page + 和 status/header decision;Host selection 后才释放 response pause,并把 buffered/captured/streaming Document build 表示为 + move-owned participant。frontend 丢失只丢 Fetch ACK,不能取消已经接受的 response replay;Host admission 失败会原样恢复 transfer, + active body stream 则作为 command rejection 返回,不能错投给原 `Page.navigate`; +- 第71切片把 raw/nested CDP 主文档 request/response-stage `Fetch.fulfillRequest` 与 response-stage + `Fetch.failRequest` 收入同一 `ResolvePausedNavigation` owner lane。Core synthetic-response decision 只保存 status/header/body, + exact Fetch identity、paused request/transfer 与 projection route 仍在 Protocol sidecar;Host selection 后才能消费 pause, + synthetic Document build 作为 move-owned participant 继续运行。frontend 丢失只丢当前 Fetch ACK,原 navigation 仍会 + commit/fail;Host admission 失败会恢复 exact sidecar,不存在 direct fallback; +- 第72切片把 production typed BiDi/Classic 的五类 terminal Fetch decision 接入 scheduler-visible task seam。 + `ContinueRequest`、`ContinueResponse`、`ContinueWithAuth`、`FailRequest` 与 `FulfillRequest` 不再由一个借用 + `CdpConnection` 的 frontend future 隐藏 participant wait:主文档 decision 进入同一 Browser Owner mailbox,subresource + decision 保留 exact Page participant;application scheduler 在等待 typed result 时继续选择 Host turn、participant completion + 与独立 protocol ingress。direct `CdpConnection` 入口只为没有 application Host loop 的测试/嵌入调用保留显式 compatibility + drain,不再是 production BiDi/Classic 路径; +- 第73切片把 download action progress 与 frontend response flush 拆开。网络、流式写盘、artifact rename 与 registry terminal + 立即推进;有界 projection gate 只在 response permit 后释放 start/progress event,permit cancellation 也不能取消下载。每个被 gate + 挡住的 download 暂有一个短生命周期 projection waiter,但没有新增 Browser Owner drain/pump;Phase 5 统一 fact projector 建立后必须 + 替换该 watcher,不能扩散成逐功能投影机制; +- 第74切片把 `Target.createTarget`、`Runtime.runIfWaitingForDebugger` 与 `Page.enable` 的 initial target URL replacement 归一为 + `BrowserInitialTargetNavigationInput`。三个 trigger 只发布 exact Page + immutable URL;Host selection 后再由 Core 检查 initial + Document、pending request 与 generation,并启动既有 navigation participant。Host 不可用时返回 typed error,不存在 Protocol direct + start fallback; +- 第75切片把 renderer-produced top-level joint-session history traversal 也汇入同一 mailbox。renderer output ingress 冻结 exact + Page residence 与 delta,Browser Host 选中后才按 Core authoritative cursor 解析 entry/URL 和 same-/cross-Document 分类;stale Page + generation、越界 delta 或已消失的 destination 都只在 owner turn no-op/drop。Runtime command response 不再 inline 完成 traversal,旧的 + session-routed direct completion path 已删除; +- 第76切片把 `Page.createIsolatedWorld` 的 requested initial URL prerequisite 改为 + `BrowserFrontendCommand::EnsureInitialTargetNavigation`。Core input 只携带 opaque `BrowserCommandId`、exact Page 与 immutable URL; + command task 只保存 Protocol sidecar receiver,Browser Host selection、navigation participant 与 terminal projection 不再由 CDP task + 启动或推进。frontend wait 丢失时 Host 仍完成 action,并保留 exact renderer insertion boundary;旧的 nested navigate/Fetch continuation + phase 及其最后两个 direct navigation helper 已删除。Phase 4 exit audit 因而通过;fact/outcome journal 与 Host lifetime 继续分别归 + Phase 5/6; +- actor 创建/teardown 目前仍与 frontend owner loop 同 lifetime,物理执行/投影 adapter 也仍借用 + `CdpConnection`;BrowserContext disposal 与 Fetch 的 direct-call compatibility wrapper、runtime + realm-created/initial-target 的 BiDi listener + compatibility 入口、targeted preload direct adapter 以及部分无 scheduler participant loop 的 direct command adapter + 仍可能在 Protocol 内 `await`,输出也仍是 `CdpTurnOutcome`。 + bounded writer 已保证慢 frontend 不阻塞 Browser Owner;独立 Host lifetime 和 protocol-neutral fact channel 尚未完成, + 但它们分别是 Phase 6 和 Phase 5 的 gate; +- `CdpConnection` 仍混合 protocol projection state 和尚未迁出的 physical BrowserContext/Target/Page payload。 + +所以 403 Document 的 DCL 后 successor navigation 曾经不推进,不是 CDP 与 Browser Core 跨线程 +造成的 mutex/thread deadlock,而是同一个 owner thread 内的**逻辑 progress starvation**:renderer +已经发布 navigation intent,但它曾被表示为 `ProtocolSchedulerWork`;protocol residence 没有选择该 +work 时,Browser navigation 就没有获得 execution authority。第20切片已把 renderer top-level navigation +从该 residence 移除;第49切片又把 raw CDP 顶层跨 Document `Page.navigate` 的 start authority 移入同一 actor。 +第62切片继续把 direct `wait:none` navigation 的 admission/start authority 汇入该 actor;第63切片再把 +`Page.stopLoading` 的 action selection 与 renderer stop participant 汇入该 actor;第64切片又把 stop-loading Fetch +cancellation 的 renderer waits 暴露给同一 Host participant loop;第65切片再把 raw CDP +request-stage 主文档 `Fetch.failRequest` 的 action authority 汇入该 lane,第66切片继续把 raw/nested CDP request-stage +`Fetch.continueRequest` 的 request mutation、network fetch 与 Document build 汇入同一 participant lane,第67切片再把同一条 +navigation 的 raw/nested CDP auth decision、credential retry 与 challenged-response handling 收回该 lane。detached navigation +completion、direct-call compatibility wrapper 和完整 Browser Host lifetime 仍按后续 phase 继续迁移;production raw CDP 与 typed +application scheduler 的 whole-Context action 已由第69切片迁入 Host,raw/nested CDP response-stage continue 已由第70切片迁入同一 +lane,其余 main-Document terminal fail/fulfill decision 又由第71切片收口;第72切片再让 production typed BiDi/Classic terminal +Fetch 通过 application scheduler 驱动同一 owner/participant chain。第73切片继续把 download action progress 与 frontend +response-flush projection 拆开:网络、流式写盘和 registry terminal 不再等待 CDP response,事件 gate 只保留有限 start prefix 与最新 +progress。第74切片把三个 initial target URL trigger 归一成 exact Browser Owner input,第75切片又把 renderer top-level history delta +从 session-routed direct completion 搬入同一 mailbox。第76切片再把 `Page.createIsolatedWorld` 的 requested initial URL prerequisite +改为 opaque Browser command + Protocol continuation sidecar,Browser Host 独立拥有 selection、participant 与 terminal,旧 direct wrapper +已删除。Phase 4 exit audit 已通过:`ProtocolSchedulerWork` 不含 navigation/replacement/popup/termination payload;现存 +`MainDocumentLoadOwnerAction` 与 detached outcome transport 是 Phase 5 fact/outcome 工作,完整 Browser Host lifetime 是 Phase 6 工作,不能为了 +“零 Protocol await”把这些边界重新误算成 Phase 4 action。 + +2026-08-02 的短期修复已经消除已知的 `pending_load` 错误全局阻塞。长期改造不再重复修这个具体 +guard,而是让普通 CDP observation 从类型和所有权上失去阻塞 Browser Owner progress 的能力。 + +## 术语 + +### Browser Core + +协议无关的浏览器运行时和所有权边界。它不是 GUI、paint/compositor 或完整 Chromium browser +process 的同义词。对 Moli 而言,它至少包含: + +- browser instance 和 BrowserContext lifetime; +- Target/Page registry; +- active/background `NavigationEngine`; +- top-level navigation、history traversal、reload、popup 和 termination owner; +- profile、cookie、storage partition、network policy 和 download 的浏览器级状态; +- renderer intent 的消费与 exact browser fact 的发布。 + +初始实现放在 `moli-core` 的 browser/page orchestration 边界,不先新增 crate。 + +### Browser Host / Browser Owner + +`Browser Host` 指一个运行中的 Browser Core 实例;`Browser Owner` 指它的单 owner actor/lane。 +二者可以是同一结构的不同视角。工作名可以调整,但必须保持: + +- browser mutable state 只有一个执行 owner; +- action 只能在 owner lane 执行一次; +- frontend 只能通过 typed command/handle 访问。 + +### Protocol Frontend + +把外部或产品 API 翻译为 Browser Core command,并把 browser facts 投影为对应结果的适配器: + +- raw CDP WebSocket; +- WebDriver BiDi; +- WebDriver Classic。 + +CDP frontend 额外拥有 DevToolsSession、Target domain subscription、V8 Inspector command +correlation、CDP error/event shape 和 socket flush ordering。这些状态不能搬进 Browser Core。 + +standalone CLI / MCP 是 `moli-core::runtime::Browser` 的高层调用方,不是 DevTools 协议 +frontend。它们可以采用一次调用级 Browser lifetime,并按书面 policy 返回 `Page`/raw document; +它们必须复用 exact Document、typed navigation handoff、replacement 和 wait 语义,但不要求经过 +`DevToolsHostAdapter`、`CdpScheduler` 或同一种 application service actor。把这条 public API +机械包装成 channel 只会多一层 task,并不会减少 Browser authority。 + +### Renderer Owner + +拥有 V8 isolate/context、DOM、parser、timer、microtask、page task 和 exact Document lifecycle 的 +renderer lane。renderer 可以产生 browser intent 和 lifecycle fact,但不拥有网络导航、Target +route、BrowserContext 或 Page replacement。 + +### Browser Fact + +Browser Core 已确认发生、带 exact identity 和 sequence 的不可变事实,例如 navigation accepted、 +committed、Page replaced、Document DCL、Target closed。fact 不是 CDP JSON,也不携带 session id。 + +### Observer + +读取 browser fact 或 renderer lifecycle terminal 的消费者。DCL/load waiter、CDP event +subscription、benchmark listener 都是 observer。observer 的等待、超时和断开只影响自身。 + +### Commit Participant + +少数按浏览器语义明确允许阻塞特定 navigation/request 阶段的参与者,例如 Fetch +interception、`waitForDebuggerOnStart` 或 author JS 前必须完成的 DevTools renderer attachment。 +participant 必须持有 exact request-scoped typed permit;它和普通 observer 完全不同。 + +## 当前实现基线 + +### 当前 stack lifetime + +raw CDP 已通过 application-owned `SharedCdpOwnerRegistry` 复用 owner actor;单个 raw CDP WebSocket +只 attach/detach frontend,断开不会直接销毁 shared/default owner。owner actor 内部仍是: + +```text +Shared CDP owner task + -> BrowserHostOwnerLane + -> BrowserHostActor / BrowserHostState + -> BrowserNavigationOwner / NavigationEngine + -> browser-global behavior policy + -> CdpScheduler + -> DevToolsHostAdapter + -> CdpConnection (legacy inner name) + -> BrowserContext / Target renderer-DevTools projection + -> BrowserHostState access capability + -> ProtocolAdapterScheduler + -> CdpFrontendEndpoint / CdpFrontendRouter + -> per-socket command queue and output sink +``` + +raw CDP 使用 `SharedCdpOwnerRegistry` 的 owner actor;standalone BiDi 与每个 Classic session 则通过共同的 +application-owned `DevToolsHostService` 创建 session-owned Host,Classic 升级出的 BiDi attach 到 exact 同一 +service。三条 WebDriver 路径不再各自复制 Browser wake、renderer publication、navigation completion pump。 +raw CDP 中必须区分 actual socket frontend 与 owner-task-resident DevTools adapter:前者是 +`CdpFrontendEndpoint/Router/Receivers`,后者是跨 socket 存活、负责 renderer/DevTools 投影的 +`DevToolsHostAdapter`。`CdpConnection` 是后者的 legacy inner name,不代表一个 WebSocket connection。 +因此完整 `BrowserContext`/Target renderer-DevTools projection 不要求搬进 Browser Core;只有在 frontend 不存在时仍决定 +browser fact、navigation 或 runtime lifetime 的 authoritative state 才必须进入 Core。把合法的 DevToolsAgentHost 类投影也机械 +拆进 Core,会混淆 Browser owner 与 DevTools adapter,而不是加强二者边界。 + +### DevTools Host adapter 当前包含的迁移状态 + +`moli-protocol/src/conn.rs` 的 legacy `CdpConnection` 当前由 application-owned +`DevToolsHostAdapter` 唯一持有;它不是 socket frontend。其内部同时持有: + +- browser/session routing、Target control 和 auto-attach policy; +- BrowserContext、active/inactive context、Target/session/attachment projection 与 non-owning Page access; +- download、global IO、network collector 和 physical BrowserContext 内的 applied policy projection; +- network collectors、storage-partition handle 和 download registry; +- scheduler hooks、scheduler-visible queues; +- application-owned `BrowserHostState` 的迁移期 strong capability; +- pending Runtime/Inspector command 与 frontend session state。 + +这些字段目前位于一个结构里不等于它们属于 Browser Core,也不等于每个 socket frontend 各有一份。Phase 7 仍应把真正的 +per-frontend wait/subscription/routing 继续收敛到 frontend;但 renderer DevTools agent、output projection 与 non-owning Core access +可以合法驻留 application-owned adapter。 + +### `CdpConnection` 初始字段归属清单 + +下面是基于当前 struct 的第一轮 owner inventory。标记为“拆分”的字段不能整体搬迁,需要先把 +authoritative browser state 与 frontend projection 分开。 + +| 当前字段/字段组 | 最终方向 | 预计阶段 | 备注 | +| --- | --- | --- | --- | +| `browser_context` / `inactive_browser_contexts` | DevTools Host adapter projection;authoritative 部分 Browser Core | Phase 2/6 | authoritative topology、Page runtime/lifetime、Target `sessionStorage` namespace 与 per-context renderer/network root 已进 Core;剩余 renderer channel、Inspector/Network/Log/worker projection 属于 application-owned adapter,不等于 socket frontend,也不要求机械搬进 Core | +| `browser_session_ids` / `next_session_id` | CDP frontend | Phase 6 | 纯 frontend session identity | +| `auto_attach*` / discovery/filter/listener state | CDP frontend | Phase 5/6 | policy 可以向 host 注册,但 subscription truth 留 frontend | +| `target_control` | 拆分 | Phase 1/6 | BrowserTarget registry 进 core;session/agent-host route 留 protocol | +| ServiceWorker auto-attach related owner state | protocol frontend | Phase 6/7 | service worker runtime identity 来自 browser facts,attach policy 属于 frontend | +| `next_bc_id` / `next_target_id` / shared target allocator | Browser Core | Phase 6 | 已完成:Context/command sequence 属于单 Host;Target sequence 可由 application 跨 Host 共享 | +| Page subscription generation | CDP frontend | Phase 5 | 只影响事件投影 | +| internal Runtime command id / pending Inspector await state | protocol/renderer channel | 保持 | 不进入 Browser Core command identity | +| no-session route override | 删除或收敛为 frontend typed route | Phase 1/8 | 不能被 renderer intent 依赖 | +| network request id allocator | Browser Core/network runtime | Phase 6 | 已完成;protocol 使用 Host 分配的只读 request identity | +| `window_bounds` / permission / geolocation / network condition | Browser Core policy state | Phase 6 | browser-global 部分已进入 `BrowserHostPolicyState`;context/Target override 仍随 physical runtime 提取 | +| user agent / headers / cache / proxy / TLS config | Browser Core/network runtime | Phase 6 | browser-global truth 已完成;现有 Page 的 applied projection 仍由迁移期 Protocol participant 更新 | +| initial storage partition / cookie/profile state | Browser Core/storage services | Phase 6 | 第一模块已完成:共享 application-owned live store;frontend close 只请求 flush,不再反向 merge snapshot | +| network data collectors | 拆分 | Phase 5/6 | producer/body store 进 browser/network owner;subscription/projector 留 frontend | +| global IO streams / download registry | 拆分 | Phase 6 | 下载/数据 owner 与 CDP IO handle/stream projection 分开 | +| `scheduler_hooks` / target-host lifecycle observer | typed host/frontend channels | Phase 3/5 | 不保留反向执行 authority | +| `scheduler_state` | protocol frontend scheduler | Phase 5/8 | 删除其中 browser-owner payload | +| `engine` / retained background engines | Browser Core | Phase 2 | 最早提取的 strong runtime owner | + +这张表是迁移起点,不是通过移动字段就能完成的机械 checklist。每个“拆分”项都必须先写出 +authoritative state、projection/cache、command 和 fact 的边界。 + +### 当前 renderer 导航链路 + +页面 JS 执行 `location.href` 时,当前路径是: + +```text +renderer turn + -> freeze RendererDocumentSourcedTopLevelLocationNavigation + -> protocol output ingestion + -> capture exact protocol-neutral Page residence + -> BrowserHostHandle::publish + -> Core BrowserHostActor mailbox + -> application select wakes directly from Host mailbox + -> actor retains one exact selected turn + -> Core-issued BrowserHostTurn capability + -> CdpConnection executor migration adapter projects Page-owned navigation +``` + +发布阶段明确不执行 navigation,Core `BrowserHostActor` mailbox 是 renderer action 的唯一 selection +authority;该 input 不再进入 protocol scheduler event/residence,也不继承 client-turn/load-observation +predecessor。当前 `CdpConnection` 在 publication 边界只保存 producer handle,并实现一个只能消费 +Core-issued turn capability 的 physical Page/event executor。actor 已不再是 `CdpScheduler` 字段,且 mailbox wake +不依赖 frontend/renderer channel;但 actor teardown、physical execution 和 output flush 仍在同一个 application +owner loop 内,尚未形成独立 Host lifetime。 + +### 已完成的短期 containment + +2026-08-02 的短期修复已经把 `pending_load` 从“所有 protocol residence 的全局锁”收窄为: + +- 一个 exact main-document load observation attachment 的容量约束; +- residence 自身声明的 explicit `load_predecessors`。 + +它修复了 passive CDP wait 的确定性死锁,但 owner action 仍在 protocol scheduler 中。长期改造 +不能把这次 guard 收窄误写成最终架构。 + +## 根本问题 + +### 1. 协议 observation 拥有了 browser execution authority + +只要 navigation/termination action 是 `ProtocolSchedulerWork`,protocol queue 的 pending state、 +client turn、command response flush 或 adapter attachment 就可能再次影响 Browser Owner progress。 +短期可以修一个 guard,不能穷举未来所有环路。 + +### 2. renderer intent 携带了 frontend route + +renderer 产生的页面导航属于 `{browser context, target, page residence, source document}`,不属于 +某个 CDP session。当前 action 捕获 `CommandOwnerScope` 是迁移期便利,也让浏览器行为依赖 frontend +attach/detach 状态。 + +### 3. browser lifetime 绑定 frontend lifetime + +每个 WebSocket 拥有独立 BrowserContext/NavigationEngine,会造成: + +- frontend disconnect 与浏览器 teardown 难以区分; +- 多 frontend 无法自然观察同一 Browser Host; +- BiDi/Classic/CLI 复用时容易复制 owner loop; +- browser profile 和 service-level state 需要在 connection close 时反向 merge。 + +### 4. browser fact 与 protocol event 尚未彻底分层 + +当前不少 lifecycle/network/target 输出在执行 browser action 的同时直接生产 protocol event。 +这使“事实是否发生”“哪个 frontend 订阅”“event 排在 response 前还是后”落在同一个调用栈。 + +### 5. 协议 backpressure 仍有机会传导到浏览器 + +CDP writer 使用 bounded queue 和 flush acknowledgement 是正确的 transport 约束,但慢 frontend +只能阻塞该 frontend 的输出/命令完成,不能阻塞 Browser Host 的 renderer、network 和 navigation +owner lane。 + +## Chromium 的三种核心角色 + +针对本文问题,可以把 Chromium 理解为三种角色,但不能把它们写成三个同等级、各自独立的 +“浏览器运行核心”: + +```text + CDP client + | + DevTools / CDP + control & observe + / \ + v v + Browser owner <------> Renderer owner +``` + +更准确的描述是:**两套运行时 owner,加一套横跨两边的控制/观察面**。 + +### Renderer + +Renderer 拥有当前 Document 的页面执行: + +- Blink DOM、HTML parser 和 style/layout 相关页面状态; +- V8、页面 JS、timer、microtask 和 task source; +- DCL/load 等 exact Document lifecycle; +- `location.href`、form submit、history 等 renderer navigation intent; +- renderer-side Blink Inspector Agent 和 V8 Inspector endpoint。 + +Renderer 可以请求顶层导航,但不拥有 browser-level network request、FrameTree/Target lifetime 或 +Page replacement。 + +### Browser + +Browser owner 拥有浏览器级状态和动作: + +- FrameTree/BrowserContext/Target/Page lifetime; +- `NavigationRequest`、redirect、commit、replacement 和 history; +- network、cookie、storage partition 和 browser policy; +- renderer/Document candidate 选择与切换; +- 接收 renderer `BeginNavigation` 并执行真正的顶层导航。 + +### DevTools + +DevTools 是跨 Browser 和 Renderer 的 control/observation plane,不一定对应独立 OS process: + +- browser-side DevToolsAgentHost、DevToolsSession 和 Page/Target/Network handler; +- renderer-side Blink Inspector Agent、V8 Inspector 和 frontend channel; +- 根据 command 类型路由到 Browser 或 Renderer owner; +- 把两边产生的 response/fact 投影成 CDP event; +- 只在 Fetch interception、pause-on-start 等显式功能中持有 exact request permit。 + +三个典型流程说明权限边界: + +```text +Page.navigate + -> browser-side DevTools PageHandler + -> Browser NavigationRequest + -> commit renderer/Document + +Runtime.evaluate + -> DevToolsSession / renderer channel + -> renderer-side V8Inspector + -> current JS context executes + +DOMContentLoaded + -> renderer Document::FinishedParsing() + -> renderer/browser instrumentation fact + -> DevTools frontend projection + -> CDP Page.lifecycleEvent +``` + +因此: + +- DevTools 可以命令 Browser 导航,但不亲自执行导航; +- Renderer 可以产生导航意图,但不亲自管理顶层网络和 Page replacement; +- DevTools 可以观察 DCL,但 pending observer 不拥有 Browser navigation queue; +- renderer-side DevTools agent 物理上靠近 Renderer,也不等于 DevTools 获得 Renderer owner; +- Browser/Renderer 没有 frontend 订阅时仍然必须正常运行。 + +Chromium 实际还有 Network Service、Storage Service、GPU 等更多 service/process。本文采用三角色 +模型是为了冻结 lifecycle/navigation 权限,不复制完整进程数量。 + +Moli 的长期映射是: + +| Chromium 角色 | Moli 目标边界 | +| --- | --- | +| Browser owner | `moli-core` Browser Host/Page owner + fetch/storage/profile services | +| Renderer owner | `moli-renderer-v8` + DOM/parser/WebAPI runtime | +| DevTools control/observation | `moli` frontend actors + `moli-protocol` + renderer inspector endpoint | + +本文真正要复制的是这三种责任边界,而不是 Chromium 的 Mojo 或多进程拓扑。 + +## 方案比较与决策 + +### 方案 A:继续强化 shared protocol scheduler + +做法:保留所有 browser owner action 作为 `ProtocolSchedulerWork`,不断补 explicit predecessor、 +priority 和 adapter guard。 + +判断:只适合作为短期 containment。它可以修复已知环路,但 protocol observation 和 browser action +仍共享 execution lane,未来 Fetch、termination、multi-session、writer backpressure 仍可能形成新 +的隐式依赖。 + +### 方案 B:让 renderer 自己执行顶层导航 + +做法:`location.href` 在 renderer 内直接启动 network 并替换 Document,protocol 只观察。 + +判断:拒绝。renderer 不拥有 BrowserContext/Target/history/cookie/storage partition、跨 target route、 +download 和 network policy。这样会复制 browser state,并让 command navigation 与页面 navigation +再次分叉。 + +### 方案 C:独立 Browser Owner,同进程 typed boundary + +做法:Browser Core 成为唯一 browser execution owner;renderer 和所有 frontend 都向它提交 typed +command/intent,结果通过 outcome/fact 返回。初期同进程、同 executor。 + +判断:采用。这一方案直接修正 authority 和 lifetime,同时保留 Moli 的轻量资源目标,并为 +未来 IPC 留出边界。 + +### 方案 D:立即复制 Chromium 多进程 + +做法:Browser、Renderer、DevTools 立即拆成多个 OS process 和 IPC channel。 + +判断:暂不采用。进程数量不会自动修复 owner 错位,反而先引入 serialization、crash recovery、 +shared profile/service 和性能成本。等逻辑边界稳定后再按证据评估。 + +### 方案 E:每个协议各自持有 browser runtime + +做法:CDP、BiDi、Classic 各保留一套 Page/navigation owner,只共享 renderer 或低层 helper。 + +判断:拒绝。同一个 served logical browser 的三个协议必须进入同一个 Browser Host,不能让每个 +协议复制 owner。standalone `Browser` 是另一种 Browser Core deployment,不是第四个协议 owner; +它与 hosted 路径共享 renderer lifecycle/navigation contracts,但可以拥有不同的书面 completion +policy,例如 `FollowBeforeReply`。 + +## 目标架构 + +### 进程内逻辑结构 + +第一版目标保持同进程: + +```text +moli application runtime +| ++-- BrowserHostActor (single owner, current-thread/local executor) +| | +| +-- BrowserCoreState +| | +-- BrowserContextRegistry +| | +-- TargetPageRegistry +| | +-- NavigationOwner +| | +-- active/background NavigationEngine +| | +-- Profile/Storage/NetworkPolicy owners +| | `-- Download/IO browser state +| | +| +-- BrowserOwnerQueue +| +-- CommitParticipantRegistry +| `-- BrowserFactJournal +| ++-- CDP frontend actor(s) +| +-- socket/session/domain state +| +-- DevTools renderer channel +| +-- pending command registry +| `-- CDP fact projector/output queue +| ++-- BiDi / Classic frontend actor(s) +| +`-- standalone Browser high-level facade (separate one-shot deployment) +``` + +Browser Host 通过 typed handle 接收命令。frontend 不获得 `&mut NavigationEngine`、`&mut Page` 或 +可执行 owner action 的引用。最后一行不是 hosted Browser Host 的第四个 frontend;standalone +facade 自己就是一次调用级 Browser Core owner,并按高层 API 合法返回 `Page`。 + +### 两条逻辑队列的职责 + +`queue` 在这里首先是 execution lane / ownership mailbox,不承诺独立 OS thread。第一版可以让两者 +在同一个 `moli-cdp-owner` current-thread runtime 上交替运行,但不得共享执行权限: + +| 队列 | 唯一职责 | 可以持有 | 不得持有或决定 | +| --- | --- | --- | --- | +| Browser Owner queue | 执行并自主推进 browser action | BrowserContext、Target、Page、NavigationEngine、request/replacement/termination state、fact sequence | CDP session、command id、domain subscription、socket flush 状态 | +| CDP frontend queue | 提交 typed command,观察 fact,投影 CDP wire result/event | DevToolsSession、domain enable、command correlation、observer、output ordering | mutable Page/NavigationEngine、owner action、下一轮 navigation 是否可运行 | + +两条输入路径必须在 Browser Owner queue 汇合: + +```text +CDP Page.navigate --typed command--+ + +--> Browser Owner queue --> navigation request state machine +renderer location.href ----intent---+ +``` + +Browser Owner 接受命令后自行运行到 accepted、commit、replacement、failure 等 terminal,并发布 +带 exact identity 的 fact。CDP frontend 可以等待某个 terminal 来完成自己的 response,但这个 +waiter 只是 observer;它的 pending、timeout、disconnect 和 output backpressure 都不能改变 Browser +Owner 是否继续运行。 + +只有 Fetch interception、`waitForDebuggerOnStart`、document-start DevTools attachment/commit +participant 等明确功能可以暂停 exact request。即使如此,request 和 pause state 仍由 Browser +Owner 拥有;frontend 只能持有和完成一个 request-scoped permit,不能获得通用 scheduler gate。 + +### 最终 ownership 矩阵 + +| 状态/行为 | 当前主要位置 | 最终 owner | 说明 | +| --- | --- | --- | --- | +| Browser instance lifetime | CDP socket stack / CLI call | served:application + Browser Host;standalone:一次调用级 Browser Core | served frontend detach 不隐式销毁 host;standalone drop 显式结束自身 instance | +| BrowserContext / Target / Page | `CdpConnection` | Browser Core | protocol 只保留投影和 subscription | +| `NavigationEngine` / retained engines | `CdpConnection` | Browser Core | 仍保持 thread affine | +| 页面 JS top-level navigation | renderer -> neutral Browser Owner input;application queue,physical projection adapter 暂在 protocol | Browser Owner queue | renderer intent 不含 session id,且不进入 protocol residence | +| `Page.navigate` / reload / history | CDP dispatcher + connection | Browser Owner queue | command 与 renderer intent 汇合 | +| Page replacement / termination | protocol owner action | Browser Core | exact generation 验证后执行一次 | +| cookie/storage/profile | connection + service merge | Browser Core / storage services | frontend 通过命令修改 policy | +| network request state | connection/domain/runtime 混合 | Browser Core/network runtime | protocol collector 是只读投影 | +| CDP session / domain enable | `CdpConnection` | CDP frontend | 永不进入 Browser Core | +| DevTools renderer channel / V8 cookie | protocol + renderer | protocol target agent host | 遵循现有 DevTools redesign | +| command id / response flush | CDP scheduler | CDP frontend actor | 不进入 Browser Owner queue identity | +| DCL/load 产生 | renderer | renderer exact Document owner | Browser Core 记录/转发 fact | +| lifecycle/network/target event shape | protocol call stack | protocol projector | browser fact 不含 CDP JSON | +| high-level fetch wait policy | standalone/core caller | adapter/wait policy | 只能观察,不能控制 owner progress | + +### Browser target 与 DevTools target 的区别 + +现有 DevTools redesign 写“protocol target owner”,指 DevToolsAgentHost、frontend session、renderer +inspection channel 和 attachment route 的 owner。本文写“Browser Core owns Target/Page”,指浏览 +上下文、页面执行、导航和 lifetime 的 owner。两者不是同一个概念: + +```text +BrowserTarget + -> owns Page/navigation/lifetime + -> exposes renderer endpoint + browser facts + +DevToolsTargetAgentHost projection + -> refers to BrowserTargetId + -> owns DevToolsSession[] and renderer inspection channel + -> projects browser/renderer facts to subscribed frontends +``` + +Browser Core 不保存 `sessionId`;protocol target agent host 不执行浏览器 navigation。 + +## 必须成立的核心不变量 + +### Authority + +1. 一个 browser action 只有一个 Browser Owner execution authority。 +2. renderer 和 frontend 只能发布 intent/command,不能直接替换 current Page。 +3. protocol subscription、pending observation 和 socket state 没有 Browser Owner queue 权限。 +4. 有意暂停必须是 exact request-scoped permit,不能靠连接级布尔状态。 + +### Identity + +1. action、completion 和 fact 都绑定 exact browser context、target、Page generation 和必要的 + navigation/Document identity。 +2. old Page/request/renderer agent 的 late input 只能释放自身或 stale-drop。 +3. frontend attach/detach 不改变 browser generation。 +4. DevTools attachment identity 不替代 Browser Page identity,反之亦然。 + +### Progress + +1. ready browser action 不依赖下一条 frontend command。 +2. Browser Owner 不等待 WebSocket flush 或普通 observer terminal。 +3. frontend command flood、renderer wake、network completion 之间有书面 fairness。 +4. no subscriber、slow subscriber 和 disconnected subscriber 下 browser trace 一致。 +5. Browser Owner 每轮处理完 command、renderer publication、task 或 microtask 后,只根据自己的 + request/intent/permit 状态决定是否继续;不得查询 CDP pending observation 来计算 done。 + +### Lifecycle + +1. DCL/load 是 renderer exact Document fact。 +2. Page replacement 原子切换 current residence,旧 Page 可异步清理。 +3. navigation accepted、commit、DCL/load 和 high-level wait 是不同 terminal。 +4. high-level waiter 可以跟随 successor,但不能阻止 successor 被创建。 + +### Projection + +1. Browser Core 不构造 CDP/BiDi wire shape。 +2. frontend 是否 enable domain 只影响 projection。 +3. command response correlation 与 browser fact occurrence 分开。 +4. slow projector 的 bounded failure 不能反压 Browser Host。 + +## Typed contract 设计 + +### Identity + +优先复用并移动现有 exact identity,不制造另一套 generation truth。长期至少需要: + +```text +BrowserInstanceId +BrowserContextId +BrowserTargetId +PageResidenceIdentity { target, slot_instance, loaded_page_generation } +NavigationRequestId +RendererAgentAttachmentId +RendererDocumentLifecycleIdentity +BrowserFactSequence +``` + +约束: + +- 外部 CDP target id 可以保持现有 wire 值,但内部 owner identity 必须是 typed; +- `PageResidenceIdentity` 决定旧 Page action 是否 stale; +- `NavigationRequestId` 决定 redirect/auth/interception completion 是否仍属于 current request; +- `RendererDocumentLifecycleIdentity` 决定 DCL/load fact 的 Document 归属; +- `RendererAgentAttachmentId` 只服务 DevTools channel,不代替 browser Page identity; +- generation 不能因为 protocol attach/detach 递增。 + +如果 renderer 和 protocol 都需要某个 neutral identity,而 crate dependency 不允许放在 +`moli-core`,只把最小 carrier 放进 `moli-page-types`。不能为了迁移让 renderer 反向依赖 +`moli-core`。 + +### Browser command + +工作形状: + +```text +BrowserCommandEnvelope { + browser_request_id, + owner_identity, + command, + reply, +} + +BrowserCommand = + CreateBrowserContext + | DeleteBrowserContext + | CreateTarget + | Navigate + | Reload + | TraverseHistory + | CloseTarget + | SetBrowserPolicy + | ... +``` + +`browser_request_id` 是内部 correlation,不是 CDP command id。frontend 用 oneshot/ticket 将自己的 +command id 与 Browser Core reply 关联,Browser Core 永远不解析 CDP/BiDi JSON。 + +command reply 只表达 owner-level 结果,例如: + +```text +Accepted { navigation_id, loader_token } +Completed +TargetGone +StalePageResidence +NavigationRejected +InvalidInput +Canceled +``` + +协议适配器负责映射 CDP/BiDi error;standalone high-level facade 单独把 Core/renderer error 映射为 CLI status。 + +### Renderer intent + +renderer 在 producing turn 边界冻结并移动 immutable intent: + +```text +RendererBrowserIntent = + TopLevelNavigate { + page_residence, + source_document, + url, + cause, + } + | TopLevelHistoryTraverse { ... } + | OpenAuxiliaryTarget { ... } + | RequestTargetTermination { ... } + | ... +``` + +intent 不允许包含: + +- CDP/BiDi session id; +- WebSocket command id; +- protocol subscription state; +- “下一条 frontend command 后执行”一类 ordering flag。 + +Browser Owner 接收后先验证 `page_residence`,再决定 accept、drop stale、merge same-document 或启动 +replacement。renderer 不直接调用 network/history/target registry。 + +### Browser fact + +第一阶段只迁移 navigation/lifecycle/target 必需事实: + +```text +BrowserFactEnvelope { + sequence, + browser_instance, + context, + target, + page_residence, + fact, +} + +BrowserFact = + TargetCreated + | NavigationAccepted + | NavigationCommitted + | NavigationFailed + | PageReplaced + | DocumentLifecycleReached { document, milestone, stamp } + | DocumentLifecycleTerminated { document, last_reached, termination } + | TargetCrashed + | TargetClosed +``` + +这里的名称表达 Browser-owned transition,不要求复刻 CDP event 名称。当前 request state machine 中 +`NavigationAccepted` 已经是唯一的 Browser navigation start commit;除非以后出现一个可独立失败、可由 Browser +观察的 network-start transition,否则不再为了对齐 `NavigationStarted` 名字发布重复事实。 +`Target.targetInfoChanged` 更不能整体进入 Core:URL/title 等 Browser metadata、DevTools attached 状态和 frontend +discovery subscription 必须先拆开,只把确有 Browser authority 的 metadata transition 另立 neutral fact。`TargetCreated` +对应 Target topology commit,仍是 Phase 5 后续应补的 producer;它不能由现有 CDP discovery event 反向证明。 + +后续再按证据迁移 network/download/storage facts。不能先创建一个复制全部 CDP event 字段的巨型 +`BrowserEvent`;那只是把 CDP JSON 换成 Rust enum,没有完成语义分层。 + +fact 必须: + +- immutable; +- 带 exact source identity; +- 在 Browser Host 内分配单调 sequence; +- 不包含 protocol listener 判断; +- 不因没有 frontend subscriber 而省略 browser state transition; +- 可以被 trace、测试和多个 projector 重放。 + +不是所有 renderer/protocol 消息都要变成 Browser Fact: + +- V8 Inspector command response; +- Runtime/Console/Debugger notification; +- renderer-local DOM/CSS/Accessibility command result; +- 大型 DOM snapshot、network body、download stream payload + +继续由它们各自的 renderer/browser owner 和 DevTools renderer channel 传递。Browser Fact journal +只承载协议无关的 browser state transition。`BrowserFactSequence` 是 browser fact 的顺序,不是把 +Inspector call response 和所有 transport message 强行排成一个全局序列;跨来源的 command/event +ordering 仍由 typed causal attachment 和 frontend actor 处理。 + +### Command reply 与 browser fact 分离 + +`Page.navigate` reply 和 DCL/load 不是同一 terminal: + +```text +CDP Page.navigate + -> BrowserCommand::Navigate + -> NavigationAccepted + -> CDP command response + +later: + response/commit + -> NavigationCommitted fact + -> renderer DCL fact + -> CDP Page.frameNavigated / Page.lifecycleEvent +``` + +BiDi 的 `wait` 和 Classic page-load strategy 可以在 hosted command accepted 后附加各自 observer; +standalone CLI wait 绑定自己 deployment 中的 exact Document observer。两者都不能让 observer 获得 +navigation execution authority。 + +## Browser Owner scheduler + +### 单 owner,不阻塞等待 + +Browser Host actor 必须保持 run-to-completion 的短 turn。network、renderer、download 或 protocol +participant 的长等待不能直接 `await` 在 owner turn 中;应注册 exact pending operation,释放 turn, +由 completion input 再推进。 + +owner 输入至少包括: + +```text +BrowserOwnerInput = + FrontendCommand + | RendererIntent + | NavigationNetworkCompletion + | CommitParticipantCompletion + | RendererLifecycleFact + | TargetLifetimeCompletion + | OwnerWake +``` + +每个输入必须带 owner/request generation。late completion 只能完成自己的 pending operation,不能 +扫描“当前 target”并修改新 Page。 + +### 自主 progress + +一旦 action ready,Browser Owner 自己安排下一 turn。以下行为都不能成为 progress trigger: + +- 下一条 CDP/BiDi command; +- frontend 读取 socket; +- `Runtime.evaluate("0")` / `Page.getNavigationHistory` 等 noop; +- lifecycle observer 注册/释放; +- benchmark heartbeat; +- 固定 sleep、polling 或反复 `yield_now`。 + +### 显式 predecessor + +真正需要等待的 dependency 必须挂在具体 owner action/request 上: + +```text +NavigationRequest N + waits for response interception permit P + waits for renderer commit attachment permit A +``` + +不能在 adapter 层增加: + +```text +pending load observation precedes all browser actions +frontend command response precedes all browser actions +one slow subscriber precedes all facts +``` + +capacity constraint 也必须局部表达,例如“一个 adapter 同时只能附着一个 exact load waiter”或 +“一个 target 同时只有一个 current cross-document navigation”,不能升级成全局 execution lock。 + +### Fairness 与优先级 + +本文不先冻结完整优先级表,但必须满足: + +- 当前 owner turn 完成后才进入下一输入; +- exact terminal 与它完成的 request 原子结算; +- ready replacement/termination 不能被无关 observer 饿死; +- frontend command flood 不能永久饿死 renderer/network completion; +- renderer wake flood 不能永久饿死 explicit browser command; +- 同一 target 的冲突 navigation 按明确 request order/cancellation policy 处理; +- 不依赖 Tokio 偶然 poll 顺序证明协议语义。 + +Phase 1 trace 应先记录现有 input order,再通过行为回归冻结真正需要的 ordering,而不是复制当前 +队列的所有 incidental order。 + +## Commit participant 与合法暂停 + +协议 observation 不得阻塞 Browser Owner,不代表协议永远不能影响 navigation。以下行为按浏览器 +语义可以显式暂停特定 request: + +- CDP Fetch request/response-stage interception; +- auth challenge decision; +- `waitForDebuggerOnStart`; +- final response commit 前的 DevTools renderer channel/session attachment; +- 用户显式 `Page.stopLoading`、cancel 或 close target。 + +统一模型应是 request-scoped permit: + +```text +CommitParticipantPermit { + navigation_request_id, + participant_kind, + owner_generation, + resolution_policy, + deadline/cancellation, +} +``` + +约束: + +- permit 只能阻塞其绑定 request/stage; +- frontend disconnect 时按书面 policy `continue`、`cancel` 或 `fail`,不能留下 orphan permit; +- passive `Page.enable`、lifecycle subscription、DCL/load waiter 不创建 permit; +- DevTools session attachment 是 protocol agent-host 对 Browser Core commit barrier 的显式参与,不是 + protocol scheduler 获得整个 browser lane 的执行权; +- 没有 DevTools frontend 或无需 document-start 配置时,attachment participant 立即完成; +- deadline 是产品/协议 timeout,不用短 sleep 模拟 readiness。 + +这与现有 DevTools navigation redesign 的 `ResponseCommitReady -> ReadyToCommit -> renderer commit +permit` 对齐。Browser Core 拥有 request 和 barrier;protocol DevTools agent host 只完成自己的 +participant。 + +## Browser fact journal 与 backpressure + +### Journal + +Browser Host 为已提交 fact 分配 sequence,并向订阅者提供 cursor。第一版可以是同进程 bounded +ring/fanout,不要求持久化。 + +用途: + +- CDP/BiDi/Classic event projection; +- deterministic trace 和 differential test; +- frontend attach 后的有限 bootstrap/snapshot。 + +journal 不是第二份 browser mutable state。需要 current snapshot 的 command 仍向 Browser Host 请求 +typed snapshot;不能从 event replay 猜当前 Page。 + +### 慢 frontend + +慢 frontend 的策略必须是 bounded: + +- frontend projector 有自己的 cursor/output queue; +- Browser Host 不等待 WebSocket flush; +- subscriber 落后超过 retention 时,frontend 明确 disconnect、报告 lag 或重新 snapshot; +- 不无限复制 DOM/network body;大 payload 使用已有 owner store/handle; +- command response correlation 由 frontend 自己维护,不让其它 subscriber backpressure 影响它。 + +CDP 对 command response 与紧邻 event 的 wire ordering 仍由 CDP actor 负责,但 actor 消费的是已经 +发生的 facts/outcomes,不能反向冻结 Browser Host。 + +### 无 subscriber + +没有 protocol subscriber 时: + +- Browser Host 仍推进 navigation、renderer 和 target lifetime; +- 可以不构造昂贵的 protocol projection; +- 不能省略 state transition 或 exact lifecycle fact; +- retention policy 可以只保留诊断需要的最小窗口。 + +## 关键控制流 + +### CDP `Page.navigate` + +```text +CDP frontend parses command + -> validate frontend session/domain parameters + -> resolve session route to BrowserTargetId + -> send BrowserCommand::Navigate(target, url, cause) + +Browser Owner turn + -> validate target/page owner + -> cancel/replace conflicting request by policy + -> allocate NavigationRequestId/loader token + -> install request + -> reply NavigationAccepted + -> launch network work + +CDP frontend + -> map accepted reply to Page.navigate response + +later Browser Owner / renderer facts + -> commit / frame navigation / DCL / load + -> CDP projector emits events +``` + +`Page.navigate` response timing 不能偷偷变成“等 DCL”。如果 command 选择 wait,waiter 是独立 observer。 + +### renderer `location.href` + +```text +renderer JS turn + -> create immutable RendererBrowserIntent + -> move intent at producing turn boundary + -> notify Browser Owner + +Browser Owner turn + -> validate exact PageResidenceIdentity/source Document + -> drop stale or install navigation + -> launch network work + -> publish browser facts +``` + +该路径不解析 session route,也不经过 protocol residence。CDP frontend 是否连接只影响 facts 是否 +被投影。 + +### DCL/load + +```text +renderer exact Document task + -> renderer lifecycle terminal(document X, milestone) + -> Browser Host records fact(sequence, target, page, document X) + -> each frontend projector checks its own subscription + -> emit CDP/BiDi/Classic shape + -> high-level wait evaluates its policy +``` + +DCL/load observation 没有 navigation queue 权限。若 A 已经 DCL 后请求 B,可以依次发布 +`DCL_A`、replacement、`DCL_B`;高层 wait 是否跟随 B 是产品 policy。 + +### Page replacement + +```text +NavigationRequest N wins + -> validate N is current + -> create/commit successor PageResidence B + -> atomically mark A retired and B current + -> publish PageReplaced(A, B, N) + -> old A cleanup may finish later +``` + +旧 A 的 intent、network completion、timer/lifecycle wake 和 DevTools attachment output 必须按 exact +identity 变成 stale。不能要求先析构 A 才允许 B 成为 current。 + +Phase 2 迁移期曾允许 physical `Page` 暂时留在 protocol slot,但只能使用下面的 single-turn bridge: + +```text +Browser Core prepare exact replacement permit + -> protocol prepares renderer/DevTools participant (may fail) + -> Browser Core commits request + Page generation + history + -> protocol synchronously projects the physical successor Page + -> old Page closes asynchronously +``` + +core commit 与 physical projection 之间禁止 `await`、frontend flush、observer callback 或 fallible +participant work。Phase 6 的 physical Page runtime owner 模块已 supersede 这条迁移形状:Core transaction 直接发布 +move-owned Page payload owner,Protocol slot 只持 non-owning access;Target `sessionStorage` namespace 也已进入 exact +Target registry。剩余 physical Target/frontend projection 拆分仍不得把旧 bridge 扩展成另一套 replacement owner。 + +### Target termination + +`Page.crash`、`Page.close`、`Target.closeTarget` 和 BrowserContext disposal 必须汇合到同一个 +Browser-owned Target transaction,而不是分别从 session route 清理一组 protocol 字段: + +```text +capture {context, target, exact Page residence, crash|close} + -> Browser Core prepare exact permit + -> protocol prepares Inspector/session projection inputs + -> Browser Core commits Page generation + request/runtime/history/Target state + -> protocol synchronously projects physical Page/Target absence + -> retired Page close / active-target promotion may await + -> frontend projects response, detach and Target/Inspector events +``` + +和 Page replacement 一样,从 core commit 到 physical absence projection 之间禁止 `await`、frontend +flush 或 callback。迁移期 protocol projection 可以收集 session/event 所需数据,但不能重新判断 +termination authority,也不能再次推进 Page generation。 + +Crash 不能建模成 Close:crashed Target 仍可查询、关闭或通过新 navigation 恢复。Browser Core 至少要 +区分 `Crashed` 与 `Recovering(exact BrowserDocumentNavigation)`:重复 crash 被拒绝;只有那条 exact +recovery request 可以安装 successor Page;失败或被替代的 recovery 回到 `Crashed`;成功后 Target +重新成为可 crash 的 live 状态。`Target.closeTarget` 在 `Crashed` 状态仍必须可用。Close 自身也必须 +one-shot;迁移期即使 protocol 物理 slot 尚未删除,Core 的 `Closed` gate 也要独立拒绝第二次 capture。 + +termination action payload 不得保存 `CommandOwnerScope`、`sessionId` 或发命令的 frontend lifetime。 +frontend detach/reattach 只影响最终 response/event 投影,不能取消已经 capture 的 Browser action;反过来, +旧 Page generation 的 delayed action 必须 stale-drop,不能关闭 replacement Page。 + +### frontend disconnect + +```text +frontend disconnect + -> cancel that frontend's pending command replies + -> remove subscriptions/projector cursor + -> resolve its explicit interception/debug permits by policy + -> detach DevTools sessions + -> Browser Host continues +``` + +WebDriver session 或 incognito context 若声明“frontend owns context”,adapter 应发送显式 +`DeleteBrowserContext`;不能依赖 drop `CdpConnection` 顺带销毁 browser state。 + +### browser close + +browser close 是 Browser Host command/lifecycle,不是 socket close 的别名: + +```text +BrowserHostClose + -> stop accepting new commands + -> retire contexts/targets/pages + -> resolve pending browser operations + -> publish terminal facts + -> detach frontends + -> stop renderer/network owners +``` + +## 高层 wait 与 `done` + +Browser Core 分离不改变 DCL/load 的 exact-Document 语义。hosted wait 观察 Browser fact journal,standalone +wait 消费 renderer/Core 的 typed exact-Document terminal;两者保持同一个终止门禁: + +```text +observe milestone for bound Document + -> evaluate typed replacement/navigation outcome + -> if policy follows replacement, bind successor + -> otherwise complete +``` + +必须保持: + +- DCL 不等待未来 timer; +- `Done` 在产品另行决策前仍是现有兼容语义; +- `domstable`/selector/script wait 可以继续驱动页面,但每个 bounded turn 后先观察 navigation; +- waiter timeout 只结束 waiter; +- waiter cancellation 不取消已 accepted navigation,除非调用方发送显式 stop/cancel command。 + +分离后的收益是 hosted frontend 读取统一 Browser fact trace;standalone high-level policy 复用同一 +exact Document、typed navigation handoff 与 replacement contract。两者都不再依赖 frontend noop 或 +无类型 pending-navigation 扫描,但不要求共享同一个 journal instance。 + +## Crate 与模块边界 + +### `moli-core` + +新增或逐步形成以下工作模块: + +```text +moli-core/src/browser_host/ + mod.rs + identity.rs + page_residence.rs + target_handle.rs + owner_input.rs + actor.rs + handle.rs + turn.rs + command.rs + outcome.rs + fact.rs + journal.rs + commit_participant.rs + navigation_owner.rs + navigation_owner/ + types.rs + context_registry.rs + target_registry.rs + target_transaction.rs + engine_registry.rs + page_runtime.rs + document_build.rs + request.rs + request_registry.rs + history.rs + history_registry.rs + page_registry.rs + page_replacement.rs + target_termination.rs +``` + +职责: + +- Browser Host single-owner state; +- typed command/intent/outcome/fact; +- context/target/page/navigation lifetime; +- application-facing handle; +- protocol-neutral snapshot; +- exact commit participant registry。 + +现有 `runtime/navigation_engine.rs`、Page orchestration 和 storage/profile service 可以先被新 owner +封装,再逐项移动;不能一次机械搬完整目录。 + +### `moli-page-types` + +只有 renderer、core 和 protocol 都必须共享的最小 neutral identity/carrier 才放这里。禁止加入: + +- CDP session/command id; +- protocol event/error shape; +- Browser Host mutable registry; +- V8 handle 或 executor/channel。 + +### `moli-renderer-v8` + +继续拥有: + +- V8/DOM/parser/task/microtask/timer; +- exact Document lifecycle; +- renderer DevTools agent endpoint; +- immutable renderer browser intent producer。 + +不新增对 `moli-core` 的 production dependency,不直接获取 Browser Host mutable state。 + +### `moli-protocol` + +Phase 2 迁移期的 replacement/termination 代码也必须按边界拆开,不能重新堆回 domain handler 或 +`CdpConnection` 主文件: + +```text +moli-protocol/src/conn/ + browser_owner_input.rs # Browser Host publication adapter + browser_host_turn_executor.rs # short start + exact participant completion adapter + browser_page_replacement.rs # core transaction adapter + browser_target_termination.rs # core transaction adapter + browser_context/ + loaded_page_projection.rs # physical replacement projection + target_termination_projection.rs # physical termination projection + +moli-protocol/src/domains/page/ + navigation.rs # navigation admission/start and legacy materialized drain adapter + navigation_completion.rs # exact load/configure/commit/replay participant state machine + navigation_commit.rs # physical loaded Page commit projection + navigation_tail.rs # renderer finish/replay start-wait-apply boundary +``` + +application 侧的 mailbox 与其他 owner inputs 独立放在: + +```text +moli/src/cdp_scheduler/ + owner_inputs.rs # Host execution lane + input/completion residences +``` + +adapter 只负责 core permit/commit、participant 顺序和迁移期 async cleanup;projection 只修改 +active/background physical Target/Page 与 DevTools attachment 状态;domain handler 只保留 command 参数、 +pending response correlation 和 CDP event shape。任一层都不能复制 core request/history/generation/Target +lifecycle registry。 + +迁移后保留: + +- DevToolsSession / renderer channel / V8 state cookie; +- target/session route projection; +- domain enable/subscription; +- DevTools command/result facade 的协议语义; +- browser fact -> DevTools/CDP-neutral event projection; +- protocol-specific interception/debug participant adapter。 + +逐步移出: + +- strong `NavigationEngine` owner; +- authoritative BrowserContext/Target/Page mutable state; +- browser navigation/history/termination execution; +- protocol scheduler 中的 browser owner action。 + +`CdpConnection` 可以在迁移中先变成: + +```text +CdpConnection { + browser_host: BrowserHostHandle, + frontend_state: ..., + devtools_agent_hosts: ..., + pending_commands: ..., + projectors: ..., +} +``` + +最终是否重命名为 `DevToolsFrontendState` 或拆成多个结构,在 ownership 真正移动后决定;不能只重命名 +结构而保留全部 browser state。 + +### `moli` + +应用层负责: + +- 启动和关闭 Browser Host runtime; +- 为 raw CDP/BiDi/Classic 提供 owner endpoint 或 typed service handle; +- 为 CLI/MCP 创建一次调用级 standalone `Browser` Core facade; +- 选择 host lifetime/profile; +- 启动各 protocol frontend actor; +- 不重新实现 Page owner loop。 + +raw CDP `CdpScheduler` 长期只负责 frontend command/pending response/output ordering 和 fact projection +drain,不再负责 Browser Owner action selection。 + +### 暂不新增 crate + +现有 dependency direction 已允许 `moli-protocol -> moli-core`。第一轮不要新增 +`moli-browser-core` crate。只有满足以下条件才重新评估: + +- `moli-core` 因 Browser Host API 产生明确、持续的依赖膨胀; +- neutral runtime 可以在不依赖 renderer/protocol 的情况下独立编译; +- 新 crate 能删除依赖环或显著缩短 build,而不是只移动文件。 + +## 分阶段实施 + +每个 phase 可以拆成多个小 PR,但一个 PR 只能有一个 execution authority。迁移期允许 adapter, +不允许 old/new owner 同时可能执行同一 action。 + +### Phase 0:冻结基线和名词 + +状态:已完成架构 exit gate;扩展性能基线继续作为 benchmark backlog,不阻塞 owner 分离。 + +已完成: + +- exact Document lifecycle identity 和 typed replacement terminal; +- renderer navigation intent 在 producing turn 冻结/移动; +- passive CDP navigation progress 回归; +- pending load global guard 的短期 containment; +- Chromium browser/renderer/DevTools ownership 调研; +- `CdpConnection` 第一轮字段组 owner inventory; +- production navigation trace 第一段:renderer/frontend action、Core request、response commit-ready、Page + replacement、renderer lifecycle observation 和 frontend projection 已用同一 correlation key 串通。 +- schema v1 JSONL transport、CLI/CDP/BiDi/Classic 同源 exact-Document fixture,以及固定 release binary 的 + 10 轮 short-navigation latency/PSS/RSS/sampled-CPU 第一版基线。 +- Browser fact journal 已加入 monotonic fact sequence,Target/navigation/lifecycle producer inventory 已完成。 + +非阻塞后续: + +- 增加 Chromium machine differential; +- 补 event-heavy、多 Target、idle footprint 基线,并为 sub-100ms CLI workload 提供比 1--2 个 sample 更稳定的 + CPU/peak-memory 证据。 + +Exit gate:文档和 trace 能回答一个 navigation 的 source、owner、request、Page generation、Document、 +执行 turn 和 frontend projection sequence。 + +2026-08-06 final audit:production trace、Core fact sequence 和 exact frontend projection 已能回答上述 +问题,因此 exit gate 通过。Chromium machine differential、event-heavy、多 Target、idle footprint 与短进程 +CPU/peak-memory 增强仍有产品价值,但它们验证性能和兼容面,不再作为 Browser/CDP authority 拆分的前置条件。 + +### Phase 1:neutral identity 与 renderer intent 去 session 化 + +状态:已完成。 + +目标:先清掉 action 对 frontend route 的依赖,不改 scheduler/lifetime。 + +工作: + +1. 把 `TargetPageResidenceIdentity` 中浏览器语义部分迁到 neutral owner identity; +2. renderer-produced top-level navigation action 不再保存 `CommandOwnerScope/sessionId`; +3. 使用 `{context, target, page residence}` 直接定位 browser Page owner; +4. protocol route 只在输出 projector/DevTools attachment 侧使用; +5. 增加 park/promote、auxiliary session、detached session 下的 stale/action regression。 + +迁移适配期仍可由 `CdpConnection` 调用执行方法,但方法签名必须不再需要 frontend session 才能找到 +Page。 + +Exit gate:同一个 renderer intent 在无 session、primary session、auxiliary session 和 session +reattach 下指向同一个 BrowserTarget/Page;detach 不能改变是否执行。 + +2026-08-02 实现记录: + +- `PageResidenceIdentity` 已迁到 `moli-core::browser_host`,内部使用 typed + `BrowserContextId` / `BrowserTargetId`;protocol 中的 `TargetPageResidenceIdentity` 只保留迁移别名; +- `TopLevelLocationNavigationOwnerAction` 已删除 `CommandOwnerScope` 和 `sessionId`,payload 只保留 + exact Page residence、source Document 和 navigation intent; +- owner lookup 只根据 `{browser context, target, slot instance, loaded_page_generation}` 选择 active/background + Page,frontend detach/reattach 不参与 authorization; +- legacy session-owner navigation API 暂时通过无 session 的 exact owner route adapter 调用;该 adapter + 随 Phase 2 owner seam 一起删除,不能重新进入 action payload; +- primary reattach、background auxiliary detach 和 stale Page generation 均有直接回归,完整 CDP 与 + WebDriver Classic/BiDi/Selenium release smoke 通过。 + +### Phase 2:提取 Browser navigation owner state + +状态:已完成。BrowserContext authoritative identity/selection registry、Target authoritative +identity/context membership/active-background topology registry、stable Target instance handle/lifecycle +capability、strong exact Page-slot capability registry、active/background strong engine registry、typed operation +facade、target joint history registry、cross-document request lifecycle registry、loaded Page replacement、Target +termination 的 authoritative commit transaction、initial-empty-Document/Target creation metadata,以及 +selected/retained engine 的完整 target-keyed identity/handoff 已进入 core owner。第十六切片已关闭最后一条 +Page generation mutation authority:initial Page materialization 与 failed-navigation Page discard 也改为 +Core prepare/commit、Protocol 同 turn 同步投影。release build 中 Protocol 不再能推进或任意设置 Page +generation,本阶段 Exit gate 已通过。 + +物理 BrowserContext/Target/Page payload 在本阶段可以继续按“Core commit 后、同一 actor turn 同步投影”的 +bridge 暂存 Protocol;是否搬走这些 frontend/renderer payload 属于后续 lifetime 提取。自主 Browser Owner +queue 与 Browser fact journal 分别是 Phase 3 和 Phase 5 的工作,也不应倒过来成为 Phase 2 的退出条件。 + +目标:先形成可以被独立 actor 拥有的 state seam。 + +工作: + +1. 在 `moli-core` 形成 `BrowserPageOwner` / `BrowserNavigationOwner` 工作结构; +2. 移入 active/background `NavigationEngine` 的 ownership 和 exact Page lookup; +3. 移入 navigation request、replacement、history 和 termination 的 owner-level API; +4. `CdpConnection` 只通过 facade 调用,不直接访问 engine; +5. 现有 protocol event 先通过 adapter 返回,行为不变。 + +这一阶段不急于改 WebSocket lifetime,也不创建第二条 actor。目的是先让 state 边界可编译、可测试。 + +Exit gate:Browser Core 拥有 keyed engine 以及 authoritative BrowserContext/Target/Page mutable registry; +`CdpConnection` 不再直接持有或替换 engine,也不再决定 Target/Page lifetime。owner-level API 不接受 +protocol event buffer、session id 或 CDP command shape。 + +2026-08-02 第一切片实现记录: + +- `moli-core::browser_host::BrowserNavigationOwner` 成为 active engine 与 keyed retained + background engine 的唯一 strong registry,key 使用 protocol-neutral `BrowserPageOwnerKey`; +- active replacement、background retain/take/forget 和 renderer publication sender 向新旧 engine 的 + 传播由该 owner 执行;`CdpConnection` 不再保存 engine/map 字段,也不再把 renderer publication sender + 保存为 protocol scheduler hook; +- context/target park、promote 和 diagnostics 已改为调用 owner registry; +- 为保持本切片行为不变,旧 navigation/build/resource-runtime 方法仍通过明确命名的 + `active_engine_for_migration[_mut]` bridge 借用 engine。该 bridge 只允许减少调用方;删除 bridge、把 + navigation/replacement/history/termination 以及 exact Page lookup 变成无 session owner operation,才 + 能把 Phase 2 标成完成。 + +2026-08-02 第二切片实现记录: + +- `BrowserPageOwner` 已封装 active/background `NavigationEngine`,协议侧的 + `active_engine_for_migration[_mut]` bridge 已删除;`CdpConnection` 的 active document build、fetch、 + resource-runtime、lifecycle 和 diagnostics 路径只调用 `BrowserNavigationOwner` 的 typed operation; +- core owner 按责任拆为 `navigation_owner.rs` registry、`navigation_owner/types.rs` neutral input/key、 + `navigation_owner/page_runtime.rs` 页面运行操作和 `navigation_owner/document_build.rs` 文档构建操作, + 避免把迁移期 facade 继续堆进一个大模块; +- protocol-only 的 `TargetNavigationLoadInputs` 展开与 active/detached builder 选择留在独立 + `conn/browser_document_page_builder.rs` adapter,Browser Core 不依赖 protocol command/session/event + 类型;detached background job 仍临时持有自己的 engine,active owner 不再向 protocol 暴露 engine + 引用; +- engine install/take、detached background job、exact Page lookup,以及 + navigation/replacement/history/termination 的 execution authority 仍在 `CdpConnection` 路径中。下一 + 切片必须继续把这些整体 owner operation 移入 core;本记录不表示 Phase 2 已完成。 + +2026-08-02 第三切片实现记录: + +- browser navigation request identity 已移入独立的 + `moli-core::browser_host::navigation_owner::request` 模块,形成 protocol-neutral + `BrowserNavigationRequestId` 和 `BrowserDocumentNavigation`;它只携带 typed target、loader 和 + browser request identity,不再携带 `sessionId` 或 CDP command identity; +- protocol 的 `DocumentNavigationToken` 暂时只是 core 类型的迁移别名。pending/committed navigation、 + renderer Page binding 和 late-completion authorization 因而比较同一个 browser-owned request,不能 + 因 frontend detach/reattach 改变; +- background protocol gate 需要的 frontend session correlation 继续从 + `NavigationDispatchState` 派生并留在 `conn/scheduler_state.rs`,没有回填到 browser request;直接回归 + 覆盖 event session 优先、command session fallback 以及二者共享同一 browser request identity; +- 本切片只迁移 request identity,没有宣称 protocol 已失去 navigation execution authority。exact Page + registry/lookup、history、replacement、termination 和 request install/cancel 仍需按各自模块继续移入 + Browser Core,不能把这些职责重新集中到 `request.rs` 或 `CdpConnection` adapter。 + +2026-08-02 第四切片实现记录: + +- joint session history 的 entries、cursor、entry/document sequence allocator、pending replace/traverse 和 + same-document update 算法已整体移入独立的 + `moli-core::browser_host::navigation_owner::history` 模块;原 protocol + `conn/state/navigation.rs` 只保留公开 API 的兼容别名,不再实现或复制一份 history 状态机; +- history owner API 只接收 browser history entry、entry id 和 renderer 的 + `SameDocumentHistoryUpdate`,不接收 session、CDP command 或 event buffer;原有同文档 traversal、reload + replace、forward pruning、document sequence 判定继续由 core 单元测试和 protocol 行为测试覆盖; +- 当前 core history 值仍作为迁移字段嵌在 protocol `TargetOwnerState` 中,读取也仍通过 session-route + adapter 定位 target。只有 exact Page/Target registry 进入 `BrowserNavigationOwner`、history command 和 + renderer update 都通过 typed owner operation 后,execution authority 才算完全移出 protocol; +- 下一切片优先提取 exact Page lookup/state residence,随后再把 history 值随 Page owner 一起迁移。不得为 + 提前宣称完成而在 core 新建一份 target/history mirror registry。 + +2026-08-02 第五切片实现记录: + +- exact Page identity 被拆成两个独立 core 模块: + `browser_host/page_residence.rs` 只拥有稳定 slot instance、generation、handle 与 identity; + `browser_host/navigation_owner/page_registry.rs` 只负责从 protocol-neutral + `BrowserPageOwnerKey` 弱索引到物理 slot,并完成 capture/resolve。主 `navigation_owner.rs` 只组合 + registry,不承载 Page 身份算法; +- registry 不复制 generation、alive 或 active/background 状态。authority 始终在 + `BrowserPageResidenceHandle` 背后的 core state:安装、替换或退休一个 renderer `Page` 会推进同一 + handle 的 generation;复用物理 active slot 给另一个 Target 时会分配新 handle instance;slot 被释放后 + weak registration 自动失效;Target termination 也通过 core `forget_target` 立即移除 lookup index, + 即使 protocol 暂时还持有关闭中的 slot,也不会延长 browser authority。两者都不需要 protocol + teardown flag 或第二份 generation map; +- `TargetPageSlot` 当前仍是迁移期的物理容器,但只持有 core handle,不再拥有裸 + `loaded_page_generation`。target park/promote 搬运整个 runtime slot,也就搬运同一个 capability; + `document.open()` 只替换同一 Page 内的 Document,不替换 slot,所以不会错误推进 Page residence; +- frontend session 只允许在 capture 起点解析一次 `{browser context, target}`。之后的 exact lookup 先由 + `BrowserNavigationOwner` 验证 instance + generation,再由 protocol adapter 把已经授权的 + `BrowserPageOwnerKey` 投影成临时 active/background route;detach/reattach 不参与 browser action + identity,也不能让旧 action 跟随 replacement Page; +- renderer Page 输出路由与 current Page action identity 明确分开:尚未 commit 的 Page stream 绑定 + `BrowserPageOwnerKey + RendererPageResidenceIdentity`,不能借用旧 current Page 的 generation;输出 + ingress 通过 exact renderer Page reservation/installation 路由,Browser action 才通过 exact current + `PageResidenceIdentity` 授权。这样 replacement 推进 generation 后,新 Page stream 不会被误判 stale, + 旧 stream 也不能因为 target key 相同而跟随 replacement; +- live browser work 必须从 handle capture identity。保留的 `PageResidenceIdentity::new` 与 + `set_generation_for_migration` 仅服务尚未迁完的测试 fixture;合成 identity 没有 slot instance + capability,不能通过 core owner lookup,后续应随 fixture 收口删除; +- core 单测覆盖 successor generation、同 key slot replacement 与 slot drop;protocol 回归覆盖 frontend + session churn、background auxiliary detach、隐式 active target replacement,以及真实 Page + install/replacement/retirement 自动推进 generation。exact residence authorization 已迁入 core,但 + `Page` 对象及 navigation/replacement/termination 的执行动作仍由 protocol target slot 路径触发,下一 + 切片不能把本记录误读为 Phase 2 已完成。 +- 本切片最终验证为 core/protocol 全量 nextest `5703 passed, 13 skipped`、workspace clippy、release + build、清代理默认 CDP smoke `210/210` 和 WebDriver Classic/BiDi/Selenium smoke `59/59`。旧 fixture + 暴露的 synthetic identity 均改为从真实 slot capture;相关失败集额外进行 10 轮 stress,未通过放宽 + stale authorization 或 retry 隐藏失败。 + +2026-08-02 第六切片实现记录: + +- joint session history 的**值 ownership** 已从 protocol `TargetOwnerState` 移出,成为 + `BrowserNavigationOwner` 内按 `BrowserPageOwnerKey { context, target }` 索引的唯一 authoritative + registry;protocol 不再保存 entries、cursor、entry/document allocator 或 pending replace/traverse, + active/background park/promote 也不再搬运或清理 history 值; +- 模块继续按责任拆分:`navigation_owner/history.rs` 只实现 history 值类型与 cursor/update 算法, + `navigation_owner/history_registry.rs` 只实现 Target lookup、lazy seed 和 lifetime,protocol 新增的 + `conn/browser_navigation_history.rs` 只把 frontend session route 一次解析成 neutral target key,并投影 + core snapshot;主 `navigation_owner.rs` 只组合子 registry,没有吸收 history command 细节; +- core 新增 `BrowserNavigationHistoryPageSnapshot` / `BrowserNavigationHistorySeed` typed input。迁移期 + protocol 可以提供 initial empty Document 或当前 Page 的 URL/title,但 entry id、transition 处理和 + document sequence 只由 core 分配;`Page.getNavigationHistory`、reset、reload/replace、traversal、 + loaded commit 与 renderer same-document update 全部调用同一 core owner operation; +- Target lifetime 明确区分三种动作:park/promote 不碰 history;navigation failure 只 + `discard_target_page_runtime`,保留 joint history;crash 退役 Page runtime 并在 core 留下显式 empty + history tombstone,防止尚存的 protocol initial-document metadata 把 `about:blank` 重新 seed;真正 Target + close/rollback 才 `forget_target` 并删除 history registry entry; +- 本条记录的是第六切片当时的迁移形状:initial empty Document 曾由 protocol Target staging 保存 immutable + seed metadata,history 第一次被查询、same-document 更新或 loaded commit 时由 core lazy materialize。 + 第十五切片已经 supersede 这条 bridge:creation seed 与 lifecycle 现由 Core registry 唯一持有,Protocol + 不能再反向提供 initial-Document history seed; +- core registry/算法与 protocol adapter 的 history 聚焦集共 `94/94` 通过,覆盖 initial seed、pending + replace、same-document traversal、reset、background/inactive target、park/promote、crash 不重 seed、 + failed-navigation 保留 history;core/protocol 全量 nextest `5710 passed, 13 skipped`、workspace + all-target clippy、release build 均通过;明确使用 `target/release/moli` 且清空大小写 proxy/no-proxy + 环境变量的外部 smoke 为 CDP `210/210`、WebDriver Classic/BiDi/Selenium `148/148`。 + +2026-08-02 第七切片实现记录: + +- cross-document navigation 的 pending/committed request lifecycle 已成为 + `BrowserNavigationOwner` 内按 `BrowserPageOwnerKey` 索引的唯一 authoritative registry。新的 + `navigation_owner/request_registry.rs` 只实现 start、exact pending/body/committed acceptance、commit、 + failed-pending clear 和 Target retirement;`navigation_owner/request.rs` 继续只保存不可变 request id、 + target 与 loader identity,主 `navigation_owner.rs` 只组合 registry,不吸收状态机细节; +- protocol 新增独立 `conn/browser_document_navigation.rs` adapter。它只在入口把 frontend + session/target route 解析成 neutral owner key,随后调用 core typed operation,并把结果投影到 renderer + attachment、initial-empty-document metadata、resource publication 与 protocol event;它不保存第二份 + pending/committed request,也不让 session detach/reattach 改变 request authority; +- `TargetPageSlot` / `TargetRuntimeSlot` / `BrowserContext` 已删除 pending/committed navigation token、 + current/committed loader 与 late-completion acceptance 状态。Page slot 只保留 pending renderer Page + reservation 和 exact-Document lifecycle projection;`DevToolsRendererChannel` 只保留 Inspector + attachment lease、输出 suspend/resume 和 projection diagnostics,不能决定 browser request 是否仍为 + current; +- DCL/load 直接受益于权威状态收口,但语义没有被改成跨 Document:renderer 仍为每个真实 Document + 产生 exact lifecycle fact,protocol 只有在 lifecycle binding 的 optional request token 仍等于 Browser + Core committed request 时才允许观察、注册 waiter 或结算 root load/network-idle。403 Document 的 DCL + 仍是真的;如果页面随后触发 replacement,是否继续跟随由高层 wait/done 和 Browser Owner progress + 决定,不能靠把前一个 Document 的 DCL 判假; +- navigation background event、document body completion、Fetch response-head candidate commit 与 + lifecycle waiter 都通过同一 core request identity gate。交错 response head 仍能区分“被更新 request + supersede”和普通 non-pending,同时 superseded candidate 不能切换 renderer attachment; +- failed navigation、Target crash 和 Target close 分别通过 core discard/forget lifetime operation 清理 + request registry;park/promote 只改变 protocol residence,不改变 request authority。core 单测覆盖 + supersede、late committed body、failed-pending restore 与 Target discard;protocol 回归额外证明清除 + renderer projection 不能反向清除 Browser Core pending request。 +- 本切片聚焦验证为 core request registry `4/4`、跨 core/protocol request/lifecycle/Fetch 集合 + `15/15`;core/protocol 全量 nextest `5712/5712`,workspace 全量 nextest `15516/15516`、 + `17 skipped`,workspace all-target clippy、fmt check 和 release build 均通过;显式清除大小写 + proxy/no-proxy 环境变量并固定 `target/release/moli` 的外部 smoke 为 CDP `210/210`、 + WebDriver Classic/BiDi/Selenium `148/148`。验证中发现一个既有 direct-command 测试会丢弃可选 + renderer fence:dispatch 组基线 5 轮失败 2 轮;改为完整消费 command output 后,121-test dispatch + 组连续 10 轮全部通过,该测试同步修复独立提交,没有混入 Browser Core 状态机。 + +本切片完成了 Phase 2 的 request lifecycle authority 迁移,但不表示完整 navigation action、Page +replacement 或 Target termination action 已离开 `CdpConnection`。下一切片应迁移一个完整 owner +operation,不得建立第二份 protocol request/history cache,也不得把 replacement 或 termination 逻辑堆入 +`request_registry.rs`。 + +2026-08-02 第八切片实现记录: + +- loaded cross-document Page replacement 的 owner transaction 已进入独立的 + `moli-core::browser_host::navigation_owner::page_replacement` 模块。prepare 阶段同时验证 exact + pending `BrowserDocumentNavigation` 和 exact physical Page residence,但不修改 request、history 或 + generation;commit 阶段在一次 `&mut BrowserNavigationOwner` operation 中推进共享 Page generation、 + 提交同一个 request,并记录 joint history。permit/result 不携带 frontend session、CDP command 或 event + buffer;request、history 和 Page registry 继续留在各自模块,主 owner 只组合它们; +- protocol 也按两层拆分:`conn/browser_page_replacement.rs` 只编排 core prepare/commit 与 participant; + `conn/browser_context/loaded_page_projection.rs` 只处理 active/background Target 的 renderer attachment、 + target metadata 和物理 Page slot 投影。`page_state/loaded.rs` 保留 DevTools renderer participant 细节, + 没有把这些 protocol 类型反向放入 core; +- 当前迁移期顺序被冻结为:core authorization → renderer/DevTools participant prepare → core owner commit + → 同一 actor turn 内同步投影 successor Page → 异步关闭旧 Page。从 core commit 到物理 slot swap 之间 + 没有 `await`、外部 callback 或 frontend flush;旧 Page 的 teardown 只有在 target URL、initial-empty + state、session/document projection、network cursor 和 current slot 都切到 successor 后才开始; +- `TargetPageSlot` 新增的投影入口必须用 core 返回的 successor `PageResidenceIdentity` 证明共享 handle + 已经是 current,并且不会再次推进 generation。普通 install/clear/termination 路径仍使用原入口自行 + 推进 generation,避免在 termination 尚未迁移前削弱其 stale-work 防护;直接回归证明一次 replacement + 只推进一次 generation,并覆盖错误 physical slot、superseded request、background route 和迟到 initial + Page build; +- renderer/DevTools participant 可能已经为 response-ready candidate 提交 attachment,随后 request 又被 + 更新 navigation supersede。此时 core prepare 会拒绝旧 replacement,protocol adapter 只允许回滚与该 + candidate transaction 完全相同、且仍为 current 的 attachment,再关闭 candidate Page;它不能回滚更新 + request 的 attachment,也不能反向修改 core request/history/generation。该补偿逻辑归 + `conn/browser_page_replacement.rs`,精确 attachment 状态机仍归 `DevToolsRendererChannel`,没有塞进 + Target route projection 或 core transaction; +- DCL/load 因而在 lifecycle binding 前已经看到 committed browser request 和 successor Page + generation。旧 Document 的 DCL 仍是真实 exact-Document fact;replacement 只让旧 Page/Document 的后续 + completion 按 exact identity stale-drop,不把 403 DCL 改成“假事件”,也不把 high-level successor-follow + policy 塞进 protocol slot; +- production loaded-commit 路径不再分别执行“替换 Page、更新 target identity、提交 request、记录 + history”四个可分离动作。当前仍是迁移边界而非最终原子内存布局:物理 `Page`、Target metadata 和 + renderer attachment 暂留 protocol projection,且尚未发布 `PageReplaced` browser fact。Phase 2 下一 + 切片应迁移 Target termination owner operation;Phase 3/5 再建立自主 Browser Owner queue 与 fact + journal,不能把这次同步 adapter 当成最终 scheduler。 + +本切片最终聚焦验证为 Page replacement core/protocol 与 supersede rollback 集合 `12/12`; +core/protocol 全量 nextest `5718/5718`、`13 skipped`,workspace 全量 nextest `15522/15522`、 +`17 skipped`,workspace fmt check、all-target clippy 和 release workspace build 均通过。显式清除大小写 +proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定 `target/release/moli` 的外部 smoke 为 CDP +`210/210`、WebDriver Classic/BiDi/Selenium `148/148`。 + +2026-08-02 第九切片实现记录: + +- Target terminal lifecycle 与 authoritative commit 已进入独立的 + `moli-core::browser_host::navigation_owner::target_termination` 模块。neutral + request/permit/result 只携带 `{BrowserPageOwnerKey, exact PageResidenceIdentity, Crash|Close}`;prepare 与 + commit 都重新验证 exact slot instance + generation。commit 在一次 owner mutation 中推进共享 Page + generation、清除 pending/committed request、退役 keyed retained engine,并按 crash/close 分别清空或 + 删除 joint history;Crash/Close terminal state 都由 core exact-once gate,protocol projection 不能再次 + 推进 generation; +- 模块按三层保持拆分:`conn/browser_target_termination.rs` 是 core transaction adapter; + `conn/browser_context/target_termination_projection.rs` 只负责 active/background physical Target/Page、 + renderer channel、network artifact 与 session state 投影;`domains/page/termination.rs` 和 Target domain + 只负责命令前置清理、response/event/detach shape。原 `target_session_owner.rs` 中重复的 crash、Page.close + 和 Target.close mutation 已删除,没有把新逻辑转移进另一个总管文件; +- `PageTargetTerminationOwnerAction` 已删除 `CommandOwnerScope/sessionId`,只保存 neutral core request 与 + projection kind。primary session 被 detach/reattach 后 action 仍作用于 capture 时的 Browser Target;如果 + Page 已 replacement,旧 generation 的 delayed action 会被 core 拒绝,不能关闭 successor; +- `Page.crash`、`Page.close`、active/background `Target.closeTarget` 与 BrowserContext disposal 都通过 + 同一个 transaction adapter。迁移期固定顺序为 projection inputs prepare → core commit → 同一 actor turn + 内同步 physical absence projection → 异步关闭 retired Page/必要时 promote;core commit 与同步投影之间 + 没有 `await`、frontend flush 或 callback; +- crash 是可恢复的 Target 状态,不是永久 close。core 保存 `Crashed` / + `Recovering(exact BrowserDocumentNavigation)` gate:重复 crash 被拒绝,crashed Target 仍能 + `Target.closeTarget`;启动恢复 navigation 后只有该 request 能 replacement,失败会恢复 crash gate,成功 + commit 后 Target 可再次 crash。扩展回归最初稳定暴露 crash 后恢复 navigation 丢 response(20/20 + 失败),修正 owner state 后原用例与 crashed-target close 连续 20 轮 `40/40` 通过,没有增加 sleep、 + timeout 或 retry; +- popup 创建尚未成为 live Target 前的失败回滚仍允许直接 `forget_target`,因为它是在撤销未提交的 + staging,而不是执行一个已存在 Target 的 terminal transition。这个例外不得扩展给 Page/Target command + 或 BrowserContext disposal; +- 当前仍是 Phase 2 bridge:physical Target/Page registry、active engine 的完整 target-keyed identity 与 + promotion handoff 仍在 protocol,termination action 也仍由 protocol scheduler 调用;尚未发布 + `TargetCrashed/TargetClosed` Browser fact。下一步不能把 adapter 当作最终 Browser queue,也不能把 + frontend event 状态反向塞入 core。 + +本切片最终聚焦验证为 core/adapter termination 集合 `15/15`、修复后 exact recovery/close stress +`40/40`、protocol close/crash 扩展集 `62/62`;core/protocol 全量 nextest `5730/5730`、`13 skipped`, +workspace 全量 nextest `15534/15534`、`17 skipped`,workspace fmt check、all-target clippy 与 release +workspace build 均通过。显式清除大小写 proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定 +`target/release/moli` 的外部 smoke 为 CDP `210/210`、WebDriver Classic/BiDi/Selenium +`148/148`。 + +最终 workspace 验证还暴露了一个与 termination 无关的既有测试同步竞争:blocked defer 测试把 +“defer source 请求已经到 fixture server”误当成“parser 已到 EOF、`readyState` 已进入 +`interactive`”,因此在全量负载下出现 `15533 passed, 1 failed`,独立基线却是 `30/30`。测试改为让 +`Runtime.evaluate` 在 defer 响应仍未释放时通过 `readystatechange` 精确等待 `interactive`,继续断言 +目标 Document 已提交、defer 尚未执行且 load 尚未发生;没有增加 sleep、poll 或 retry。修正后精确 +用例 `50/50`、四个相邻 parser/defer 生命周期用例 15 轮共 `60/60`,随后 workspace 全量通过;该测试 +同步修复使用独立 commit,未混入 Browser Core 状态机提交。 + +2026-08-02 第十切片实现记录: + +- selected/retained `NavigationEngine` 的**身份与交接 authority** 已完整收进独立的 + `moli-core::browser_host::navigation_owner::engine_registry` 模块。该 registry 是 + `selected engine + Option + keyed retained engines` 的唯一 strong owner;未绑定状态 + 只允许用于启动期或当前 BrowserContext 没有 Target。主 `navigation_owner.rs` 只组合 engine、Page + residence、history、request 和 termination registry,不再实现 active/background map 或 Target engine + 切换算法;页面执行与 document build sibling 模块也只能通过 core-private selected-engine accessor + 工作,protocol 不能借回 `&mut NavigationEngine`; +- core 暴露 protocol-neutral 的 typed transaction:同 BrowserContext 使用 + `BrowserTargetEngineHandoff`,跨 BrowserContext 使用 `BrowserContextEngineHandoff`,旧 selected engine + 的处理必须显式声明为 `Unbound`、`Discard(exact owner)` 或 `Retain(exact owner)`。registry 在 mutation + 前验证当前 exact owner;selected A 时声称 current B 的 stale handoff 会直接失败,不能依赖 protocol + 当前指针把错误 runtime 搬给新 Target。`Unbound` 也不是 wildcard:它只能匹配 Core 确实尚未绑定的 + selected engine,不能替 frontend 为一个漏登记的 current Target 补造历史;同 context handoff 的 + 可失败构造器还会拒绝 current/next BrowserContext 不同的 key,跨 profile 复用不能靠 frontend 自律; +- 交接语义按物理 Page lifetime 固化:同 context 的当前 Target 没有 resident Page 时可以把同一 selected + engine 重新绑定给下一个 Target;有 resident Page 时必须按 exact key park,优先恢复下一个 Target 的 + retained engine,否则创建 replacement;跨 context 即使旧 Target 没有 Page,也不能复用旧 context 的 + engine,必须恢复 next context 的 exact retained engine 或用 next context runtime 新建。这样 storage、 + cookie、renderer context runtime 不会因为“恰好没加载 Page”跨 profile 泄漏; +- protocol 侧相应拆出唯一的 + `moli-protocol::conn::browser_target_engine_handoff` 投影 adapter。它可以读取迁移期 physical + active/background Target/Page slot 来决定 `Retain` 或 `Discard`,但只向 Core 提交 + `{browser_context_id, target_id}`,不读取 CDP attachment/session 来授权交接。Target 创建、同 context + promote/park、BrowserContext 激活和 idle reset 都经过该 adapter;`CdpConnection` 不再拥有名为 + `replace_navigation_engine` 的无身份入口; +- loaded Page commit 现在把 Core 已授权并提交的 exact `BrowserPageOwnerKey` 作为结果返回,detached + engine 据此直接收养到 selected/retained residence,不再在 commit 后通过 frontend session 重新猜 + owner。session-based adoption 只保留 `#[cfg(test)]` 的迁移 fixture adapter;background residence 也按 + Target key 而不是 session key。Target crash 保留 selected owner 以便 exact recovery,真正 close 才解绑 + dead Target;A → B → A 的 loaded Target promote 回归证明两个 renderer owner 会被精确 park/restore; +- 本切片完成的是 engine registry 与 handoff,不是最终 Browser Owner queue。physical Target/Page registry、 + Target metadata 和 engine replacement 所需的 fetch/runtime construction inputs 仍暂留 protocol + projection;下一切片应先迁移 physical Target/Page registry,再让 navigation action scheduling 和 + Browser fact producer 建立在同一个 Core owner 上。不得把 physical projection 搬进 + `engine_registry.rs`,也不得让新 frontend 直接调用 engine adoption 绕过 typed Target/context handoff。 + +严格 `Unbound` gate 在 workspace 验证中集中暴露了两个 Target 测试 fixture:它们过去直接赋值 physical +`browser_context/active_target_id`,没有经过 Core context handoff;其中 loaded fixture 甚至先用 unbound +engine 构建 Page、最后才补 Target。两个共享入口现统一先通过生产 `insert_browser_context` 建立 exact +owner,再加载/收养 Page engine;没有逐测试补登记,也没有给测试新增绕过 Core 的后门。修正后整个 +Target domain `553/553` 通过。 + +本切片聚焦证据为 Core engine registry 与 protocol projection `11/11`;完整 `scripts` 测试二进制中 +218 个已运行用例连续 10 轮全部通过,DCL snapshot 的两条合法竞态用例连续 20 轮 `40/40` 通过。 +最终 core/protocol nextest `5739/5739`、`13 skipped`(run ID +`7252715f-5a9c-4e83-afcf-24bcf5a56b56`),workspace 全量 nextest `15543/15543`、`17 skipped` +(run ID `9e380b24-7d78-42f9-94d9-566faa9031f1`);workspace fmt check、all-target clippy 和 release +workspace build 均通过。显式清除大小写 proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定 +`target/release/moli` 的外部 smoke 为 CDP `210/210`、WebDriver Classic/BiDi/Selenium +`148/148`,两者失败列表均为空。 + +最终 workspace 的前一轮(run ID `dede38d3-2b89-4c4c-88d0-e4b66b107a5c`)曾在最后一项观察到 +与本切片无依赖路径的 renderer +`worker_messageport_close_preserves_same_task_queued_messages` 触发 30 秒外层 test timeout;当时页面侧 +初始化变量都尚未出现,其余 `15542` 项通过。该精确用例随后以 `--stress-count 20 --flaky-result fail` +连续 `20/20` 通过(run ID `28e532c8-a353-47ab-b544-205c7e718221`),下一轮 workspace 全量也通过。 +证据支持全量 CPU/V8 压力下未复现的执行饥饿,不支持归因到 Browser engine handoff;本切片没有修改 +renderer 生产代码、timeout、retry 或测试调度。 + +2026-08-02 第十一切片实现记录: + +- BrowserContext 的**存在、当前选中项、inactive 顺序和拓扑 revision** 已成为 + `BrowserNavigationOwner` 内唯一 authoritative registry。Core 按责任新增独立 + `navigation_owner/context_registry.rs`。第68切片又为可由 frontend 显式指定、删除后可复用的 public id 增加 + Core-owned exact `BrowserContextHandle`;registry 保存 typed `BrowserContextId`、exact handle 与 revision,engine strong + ownership 继续留在 sibling `engine_registry.rs`,registry 回归单独放在 + `navigation_owner/context_registry/tests.rs`。主 `navigation_owner.rs` 仍只组合子 registry,没有吸收 + BrowserContext 算法; +- Core 暴露 `register_browser_context`、`activate_browser_context`、 + `prepare_browser_context_removal` 和两种 exact commit。duplicate/unknown context、selected physical + projection 不一致、selected engine owner 不一致,以及 revision 过期的 removal permit 都在任何 topology + mutation 前被拒绝。selected context 的 removal successor 由 Core 在 permit 中冻结,Protocol 只能为该 + exact successor 提供尚未迁移的物理 runtime 输入,不能临时改选另一个 context; +- BrowserContext topology 与跨 context engine handoff 现在由同一个 `&mut BrowserNavigationOwner` + transaction 提交。切换时 Core 先验证 `{selected context, selected target engine}` 的 exact projection, + 再按 context/target key restore retained engine 或建立 replacement,最后提交 selected/inactive topology; + raw `BrowserContextEngineHandoff` 已降为 `engine_registry` 的 Core-private contract,Protocol 不再能够绕开 + context registry 单独搬 engine; +- Protocol 侧也明确拆成两层:`conn/browser_context/registry_projection.rs` 只执行 Core transaction,并在 + 同一 actor turn、无 `await`/callback/frontend flush 的区间内同步 `Option + Vec` 物理 + payload 投影;`conn/browser_context/lifecycle.rs` 只保留 command routing、fetch override、resource + invalidation 和 preferred-context restore。`has_browser_context_id` 已查询 Core authority,不再用物理 + payload 的偶然存在授权 Browser/Target/Page 命令;default-context bootstrap 和 create-target 也先查询 + Core count。每次注册、切换、删除后,projection adapter 都断言 Core 与 physical payload 的 count、 + selected id、identity 唯一性和 membership 一致,不允许错位状态静默流入下个命令; +- 初始化顺序因而成为明确 owner invariant:必须先通过正式 registry 路径注册 BrowserContext,再由该 + context 的 selected engine 加载并安装 Page。严格 gate 暴露了过去“先用 startup engine 加载 Page,最后 + 直接赋值 physical BrowserContext”的旧测试夹具;这些夹具统一改为生产入口,没有增加 test-only registry + 同步、fallback、sleep 或 retry。否则注册 context 时旧 engine 被正确 replacement,先前 Page 的 renderer + runtime 也会随错误 owner 关闭; +- 本切片没有把 `BrowserContext` 的 DevTools session/domain state、storage/cookie payload、Target/Page + 物理容器或 frontend event projection 搬进 Core。它们仍是 transition projection,而不是第二份 topology + authority。当前 public physical fields 仍允许尚未迁完的内部 fixture 直接构造 payload;任何需要 + BrowserContext existence/selection 的 production path 都必须经过 Core registry,最终应随 Target/Page + registry 迁移删除这条可绕过的表示; +- 下一切片应把 Target topology(context membership、active/background identity、lifetime)迁到独立 Core + registry,再让 physical Target/Page payload 通过 exact handle 投影。BrowserContext disposal 目前先经 + Target termination transaction 清理各 Target,再提交 context registry removal;在 Target registry + 完成前,不能把这组 protocol cleanup 编排误称为最终 Browser Owner 原子操作,也不能把 Target/Page + metadata 塞进 `context_registry.rs`。 + +本切片聚焦验证包括 Core context registry `6/6`(run +`7bd79ddf-c592-4836-b91b-0c33d5a75668`)、严格 projection/bootstrap/create-target 扩展集 +`252/252`(run `3deabc5c-03b2-49fb-8bb6-f4c2c56e0677`),以及夹具迁移后的 Protocol 全量 +`3225/3225`。最终 workspace nextest `15549/15549`、`17 skipped`(run +`c7ef7251-2912-4198-a6fd-68496c7516e2`),workspace fmt check、all-target clippy `-D warnings` 与 +release workspace build 均通过;release binary SHA-256 为 +`38a647ee45eb1f4a2d431deccf4af3609999233f364d798b58a04193fc98274a`。显式清除大小写全部 +proxy/no-proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 release binary 的外部 smoke 为 CDP +默认全组 `210/210`、WebDriver Classic/BiDi/Selenium `148/148`,两者失败列表均为空。本记录仍不表示 +Phase 2 已完成。 + +2026-08-02 第十二切片实现记录: + +- top-level Target 的**存在、全局唯一 identity、BrowserContext membership 和 active/background + topology** 已成为 `BrowserNavigationOwner` 内唯一 authoritative registry。Core 继续按责任拆分: + `navigation_owner/target_registry.rs` 只保存拓扑和值校验, + `navigation_owner/target_transaction.rs` 只组合 Target 注册、激活和 staging rollback 与 engine handoff; + transaction 回归放在独立 `target_transaction/tests.rs`。主 `navigation_owner.rs` 只组合 sibling + registry,并把通用 runtime-state cleanup 保持为 Core 内部入口,不重新吸收 Target 算法; +- BrowserContext 注册会在同一个 Core operation 中提交它的初始 Target topology;context 切换和 removal + 直接读取 Core active Target,而不再让 Protocol 传入一个可能过期的 `target_id`。context removal 同时删除 + 该 context 的全部 Target membership,并清理 keyed engine、Page residence、request、history 与 + termination state;Target id 在所有 BrowserContext 间全局唯一; +- Core 暴露 protocol-neutral 的 typed Target transaction:background/active registration、exact activation + 和尚未成为 live browsing context 的 background staging rollback。live Target close 仍只允许走已有的 + `target_termination` transaction:Close 在同一次 owner commit 中删除 Target topology,Crash 保留 Target + membership 以便 exact recovery;active close 后是否选择 successor 是后续独立 activation transaction, + 不能由 physical `Vec` 自动决定。Protocol 的 close projection 也直接消费 Core 返回的 authoritative + `Active|Background` residence,不再重新读取物理容器做第二次决策; +- Protocol 侧保持三个窄模块:`conn/browser_context/target_registry_projection.rs` 只执行 Core transaction + 和同 turn 的物理 Target payload 投影;`conn/browser_context/engine_factory.rs` 只捕获创建 replacement + engine 所需的迁移输入;Target domain/lifecycle 模块只做 command、session、event 和异步 Page 同步编排。 + `conn.rs` 只保留模块接线和 worker id collision 查询,不承载 Target topology mutation; +- 迁移期顺序被冻结为:校验 exact physical projection → Core topology/engine commit → 同一 actor turn 内 + 同步 active/background payload projection → 再允许 DevTools session、loaded Page 同步或旧 Page teardown + await。Core commit 与物理 projection 之间没有 `await`、callback、frontend flush 或 renderer call;每次 + context/target 注册、激活、关闭和 rollback 后都断言 Core 与全部 physical payload 的 count、membership、 + active identity、background 顺序和全局唯一性完全一致; +- `Target.createTarget`、renderer popup、`Target.activateTarget`、Target/Page close、BrowserContext disposal、 + popup failure rollback 和 Target identity lookup 已接入同一 Core authority。popup rollback 只接受 + background staging,并且在清理 tab/session 之前先验证该不变量;旧的“active popup staging rollback” + 测试被改为明确断言非法状态,而不是继续保留一条绕过 typed transaction 的兼容路径; +- 严格 projection gate 在首轮 Protocol 全量中暴露了 754 个旧测试 fixture:它们曾直接赋值 + `browser_context`、`inactive_browser_contexts` 或 `background_targets`,有些还先用错误 engine 加载 Page + 再补 Target。共享 fixture 和精确用例全部改为正式 context/Target registry 与 navigation installation + 入口;没有增加 Core 自动收养、test-only topology sync、fallback、sleep、retry 或 `yield_now`。迁移后 + 整个 Target domain `615/615`、Protocol 全量 `3229/3229` 通过; +- 首轮 workspace 集成还暴露了一个真正缺失的 owner operation:production default BrowserContext 先注册 + unclaimed active placeholder,第一次 `Target.createTarget` 会在物理 payload 中替换它;如果 Core 走普通 + active registration,就会把 placeholder 降为 background,形成 Core 两个 Target、physical 一个 Target。 + 修复没有在应用层加特判写 registry,而是在 `target_transaction.rs` 新增 exact bootstrap active-target + replacement:先校验 selected context、完整 physical topology、当前 engine owner 和 expected active + placeholder,再原子绑定 replacement engine、替换 active identity、退休旧 Target runtime state,且绝不产生 + background Target;Protocol 的 `replace_active_target_projection` 只负责同 turn 投影。Core 与 Protocol 都有 + “替换后仍恰好一个 Target”的边界回归; +- 本切片仍没有把 `BackgroundTarget` / active Target 的 DevTools session、opener/window metadata、storage + snapshot、renderer `Page` 和 domain state 搬入 Core,也没有建立 Browser fact journal 或自主 Browser + Owner queue。下一切片应继续拆 physical Target/Page payload 与 browser-owned handle/fact producer;不能把 + protocol session/event state 塞进 `target_registry.rs`,也不能把这次同 turn projection 当成最终 actor。 + +本切片聚焦验证包括 Core navigation owner `40/40`(run +`2bd909ad-a490-48eb-9f56-d1630fbd7cdd`)、Target domain `615/615`(run +`f38b1903-538c-402f-90a7-2ea15a333c20`)和 Protocol 全量 `3229/3229`(run +`b97faf6f-f872-4f12-ab0a-bdf909ead802`)。Protocol 前一轮唯一失败是既有 +`isolated_context_hash_navigation_emits_navigated_within_document` 偶发得到 `Null`;没有增加 retry、sleep、 +放宽断言或改产品路径,而是把该 exact case 连续跑到 `20/20`(run +`376a57f0-416e-4663-af8b-5013a424211b`),再把所属 runtime navigation 模块 10 轮跑到 `110/110`(run +`b02e5ece-8ad3-4849-a592-3bc8f93a0688`),随后 Protocol 全量通过。证据只支持保留为未复现的既有并发风险, +不支持把它归因于本切片 Target topology 改造。 + +最终 workspace nextest `15557/15557`、`17 skipped`(run +`cedbedb5-0d7b-400e-8636-d61b52ce2e9d`),workspace fmt check、all-target clippy `-D warnings` 与 +release workspace build 均通过;release binary SHA-256 为 +`12212bd23d4cf756d4ea7b11d0d3da39409fc0756c9451e9984f11aed2c17f21`。显式清除大小写全部 +proxy/no-proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 release binary 的外部 smoke 为 CDP +默认全组 `210/210`、WebDriver Classic/BiDi/Selenium `148/148`,两者 `ok=true`、失败列表均为空。本记录 +仍不表示 Phase 2 已完成。 + +2026-08-02 第十三切片实现记录: + +- Core 新增独立 `browser_host/target_handle.rs`,只定义一个 top-level Target 实例的 stable + capability 与 `staged -> live -> retired` exact-once lifecycle。clone 共享同一个 instance state;相同 + public `targetId` 新建出的另一个 handle 不是同一实例,不能授权旧实例或 replacement。handle 不保存 + URL/title、opener/window、DevTools session、storage、renderer `Page` 或 domain state,避免把物理 payload + 重新聚合成一个 Core 大对象; +- 模块职责继续拆开:`navigation_owner/target_registry.rs` 只保存 + `{targetId -> BrowserContext + exact handle}` 与 active/background topology,并校验 physical projection 携带 + 的 handle 是同一 live instance;`navigation_owner/target_transaction.rs` 只组合注册、激活、placeholder + replacement 和 engine handoff;`target_termination.rs` 继续拥有 crash/close transaction。主 owner 只组合 + sibling registry,不吸收 Target lifecycle 算法; +- 新 Target 的 handle 由 Core typed transaction 分配并随 `BrowserTargetRegistration` 返回,Protocol 必须把 + 这个 exact capability 同 turn 投影到物理槽位。唯一不同的是 BrowserContext 初次注册:physical context + 先显式创建 staged initial handle,Core 在 context commit 时校验每个 handle 尚未使用,再统一 activate。 + production default-target bootstrap 因此只保留一次性的 + `stage_active_target_for_browser_context_registration` 入口;任意写 raw `targetId` 的便利 setter 已限制到 + test fixture; +- Protocol 的 `ActiveTargetState` 与 `BackgroundTarget` 现在都直接携带 `BrowserTargetHandle`;旧的 + `BrowserContext.target_id: Option` 和 `BackgroundTarget.target_id: String` 已删除,所有 public ID + 读取都从 capability 派生。park/promote、active demotion、popup staging 和 loaded/unloaded slot snapshot 都 + 搬运同一个 handle,不再根据字符串重建 Target identity; +- lifecycle 由 Core 单方推进:background staging rollback、close、BrowserContext removal 和 bootstrap + placeholder replacement 都 retire 精确 handle;Target crash 保留 live handle 以支持 exact recovery;新 + registration/initial context commit 只 activate staged handle。Protocol 只能查询 `is_live/is_retired` 或用 + `target_handle_is_current` 验证 projection,不能 activate/retire; +- projection gate 不只比较 count、membership、active ID 和 background 顺序,还比较 exact handle instance 与 + live state。新增回归证明“同 public ID、错误 instance handle”不能 activate Target,placeholder 的旧 handle + 会 retired、replacement handle 会 live,rollback/close/context removal 都只退休所属实例,而 crash 不会; +- 严格 handle projection 在首轮 Protocol integration 中暴露两个旧 fixture:它们在 context 已注册后又写入 + 同一个 `targetId`,过去只是字符串幂等赋值,现在会错误地制造 staged instance。test-only 同 ID setter + 被定义为幂等并保留已有 Core handle;这不是自动收养、retry 或 fallback。`cargo check -p + moli-protocol` 证明非测试生产构建不再依赖该 raw-ID setter; +- 本切片仍没有把 active/background Target 的 URL/title、opener/window metadata、session/storage/domain + state 或 renderer `Page` 移入 Core,也没有建立 Browser fact journal 或自主 Browser Owner queue。 + `BrowserTargetHandle` 只是 physical payload 与 Core authority 之间的 exact capability,不代表 physical + Target/Page registry 已迁完。下一切片应继续拆出 browser-owned immutable Target creation/initial-Document + metadata 与 physical Page handle 边界;不能把 DevTools session/event projection 塞进 handle 或 registry。 + +本切片聚焦验证包括 Core Target handle `2/2`(run +`c46602ca-16be-416a-8f11-bb370df1745a`)、Core navigation owner `40/40`(run +`ecf51968-8a4f-423a-8e50-d8a0786e6cc8`)、Protocol exact Target projection `6/6`(run +`7f0fc3e6-1142-45f3-ae16-4e5f6d182d55`)、Target domain `615/615`(run +`2b88659c-b05b-41ca-b3c1-8061649b5d9f`)和 Protocol 全量 `3231/3231`(run +`951e8574-a559-42e6-846c-fb0b90812434`)。 + +首轮 workspace 全量 `15560/15561`(run `ae6516fd-805b-44f8-9df4-42dd99860e1f`)唯一失败是与本切片 +不相交的 ServiceWorker 测试时间假设:JS 在固定 20 个 zero-delay timer 后永久快照,当时 lifecycle 已到 +`activating`,后续 `activated` 无法更新结果。测试改为等待真实 `statechange:activated` completion,没有 +增加 sleep、timeout、retry 或修改 production 行为;原失败独立复现后,精确 stress `20/20`(run +`261e0c78-b3bc-4e90-b290-8e31faa7f0c5`)和 ServiceWorker 组 `448/448`(run +`7fe38175-5d17-4055-9187-a37583cab80a`)通过,该测试同步修复保持为独立 commit。 + +最终 workspace nextest `15561/15561`、`17 skipped`(run +`720ec5ee-a153-46f7-90d4-59e703984f0e`),workspace fmt check、all-target clippy `-D warnings` 与 release +workspace build 均通过;release binary SHA-256 为 +`07d31b4215707af9878250c2b4cc7ed31bd16e5b2fbde396924e431b38e064e4`。显式清除大小写全部 +proxy/no-proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 release binary 的外部 smoke 为 CDP +默认全组 `210/210`、WebDriver Classic/BiDi/Selenium `148/148`,两者 `ok=true`、失败列表均为空。本记录 +仍不表示 Phase 2 已完成。 + +2026-08-02 第十四切片实现记录: + +- top-level Target 的 **exact Page-slot capability** 已从“Protocol 持有 strong handle、Core 只建 weak + lookup”迁成 `BrowserNavigationOwner` 内唯一 authoritative strong registry。BrowserContext 初次注册时, + Core 原子收养 staged `{Target handle, Page residence handle}`;后续 Target registration 则由 Core 同时分配 + 两个 exact capability,并通过 `BrowserTargetRegistration` 返回给同 turn 物理投影。丢弃 Protocol clone + 不再撤销 Browser authority,同 `targetId` 的另一个 Page handle 也不能伪造 authority;这项新语义明确 + supersede 第五切片记录的迁移期 weak-index 方案; +- `BrowserTargetTopologyProjection` 已从 Target-handle 列表升级为 + `BrowserTargetSlotProjection { target, page_residence }`。Core 分别由 `target_registry.rs` 校验 + identity/context/active-background topology、由 `page_registry.rs` 校验 Page capability instance 和一对一 + slot 关系;Protocol 的 `target_registry_projection.rs` 只组装 exact physical projection、执行 typed + transaction,并在无 `await`、callback 或 frontend flush 的同一 actor turn 内把 Core 返回的 capability + clone 绑定到 active/background 物理 slot; +- replacement 与 termination 不再接收 Protocol 反向传入的 `BrowserPageResidenceHandle` 作为授权材料。 + `page_replacement.rs` 和 `target_termination.rs` 只以 Core-owned `{BrowserContext, Target}` 找到注册 + capability,再 capture exact generation、prepare 和 commit。Protocol adapter 仍负责 renderer/DevTools + participant 与物理 Page swap/teardown,但不能通过选择一个 handle 改变哪个 Page 是 current; +- lifetime 规则已经收口:loaded replacement 在 Core commit 中推进共享 generation;Crash 推进 generation + 但保留 Target 与 Page-slot registration,供 exact recovery/后续 Close 使用;Close、staging rollback、 + bootstrap placeholder replacement 和 BrowserContext removal 删除 registration;navigation failure 的 Page + runtime discard 只清 engine/request 等 page-runtime work,保留 live Target 的 Page-slot capability,因为 + 物理 slot 已通过同一个共享 handle 暴露 successor generation; +- 模块边界保持拆分:`page_residence.rs` 只实现 stable instance/generation primitive, + `page_registry.rs` 只实现 Target-to-Page authority 与 lifetime,并使用普通 owner-owned map,只有 + `&mut BrowserNavigationOwner` transaction 能注册、删除或推进 authority;`target_registry.rs` 只实现 + Target topology 及 paired projection guard,`target_transaction.rs` 只组合新 Target capability allocation + 与 engine/topology commit;Protocol 的 `state/page_slot.rs`、`page_state/parked.rs` 继续只保存和搬运物理 + payload,replacement、termination、Target projection 仍是三个独立 adapter,没有把 session、CDP event + 或 renderer payload 塞进 Core handle/registry; +- strict projection gate 暴露了一批旧 Protocol test fixture:它们直接赋值 physical `browser_context`、 + Target 或 Page,绕过 Core registration,能执行 renderer JS,却没有资格让后续 publication 进入当前 Page。 + 受影响 fixture 改为生产 `insert_browser_context` / exact Target staging 入口,没有增加自动收养、test-only + authority fallback、sleep、retry 或 timeout。最后两条 IndexedDB fixture 在修正前精确压力复跑连续 + `3/3` 超时;修正后分别连续 `20/20` 通过(run + `115e95ba-f906-4deb-a931-77bcd12771c9`、`3f420da2-49b7-4230-9cc0-ca54e8f4be33`),证明根因是 + split physical/Core state,而不是 IndexedDB 事务速度或调度饥饿; +- 本切片的 Core 全量为 `2529/2529`、`13 skipped`,Protocol 全量为 `3232/3232`(run + `e677e7b3-de0d-473e-a8b8-7cd8830ad638`),workspace 全量为 `15565/15565`、`17 skipped`(run + `eb7a4049-36ed-4f3c-af2e-34de8de074cc`);workspace fmt check、all-target clippy `-D warnings` 与 release + build 均通过,release binary SHA-256 为 + `92f4a1d426beccf129978f853fdd792af81b2611730a1b24b3d6e9eb78b1a316`。显式清除大小写全部 + proxy/no-proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 release binary 的外部 smoke 为 CDP 默认 + 全组 `210/210`、WebDriver Classic/BiDi/Selenium `148/148`,两者 `ok=true`、失败列表均为空。 + +本切片仍不表示 Core 已拥有 renderer `Page`、initial-empty-Document/Target creation metadata、Page +generation 的物理 mutation 或自主 Browser Owner queue。下一切片先迁移 immutable creation/initial-Document +metadata,并做一次严格 Phase 2 exit audit;在 physical Page payload、navigation scheduling 或 fact producer +仍依赖 `CdpConnection` 时,不得提前进入 Phase 3,也不得把这些 payload 聚合进 `page_registry.rs`。 + +2026-08-02 第十五切片实现记录: + +- initial-empty-Document 的 creation seed、creator security context、storage key、stable loader id、materialized、 + exited 与 pending cross-document navigation 已从 Protocol `TargetOwnerState` 移出,成为 + `BrowserNavigationOwner` 内按 `BrowserPageOwnerKey { context, target }` 索引的唯一 authoritative registry。 + Protocol parking state 已删除对应 metadata、lifecycle flag、history seed helper 和 diagnostics,Target + park/promote、frontend detach/reattach 不再搬运或重建这份 browser state; +- 模块继续按责任拆分:`navigation_owner/initial_document.rs` 只定义 immutable creation value 与只读 + snapshot,`initial_document_registry.rs` 只实现 initial Document lifecycle/lifetime,`target_creation.rs` + 只定义可扩展的 protocol-neutral Target creation envelope;`target_transaction.rs` 在 Target handle、Page + residence 与 topology 注册的同一 Core transaction 内安装 creation metadata。Protocol 新增的 + `conn/browser_initial_document.rs` 只把一次 frontend route 解析成 owner key、查询 Core snapshot,并校验 + 同 turn 物理 Page projection,不保存第二份状态; +- ordinary active/background Target creation、bootstrap active replacement 和 renderer popup 都通过 + `BrowserTargetCreationMetadata` 把 initial-Document seed 一次性交给 Core。default BrowserContext bootstrap + 因 context topology 必须先整体注册,保留一个显式 bootstrap registration adapter;它只在同一同步调用路径 + 中补注册已经存在的 exact Target,不经过 frontend event、callback 或 `await`。这是第十五切片当时的迁移形状; + 第28切片已把这条补注册 adapter 删除,并把 metadata 并入 BrowserContext registration transaction; +- cross-document request start 在 Core 内标记 initial Document pending,failed request clear 同时清 pending, + request commit 与 loaded Page replacement 原子标记 exited。history snapshot/same-document/loaded commit + 优先从 Core registry 派生 initial seed,Protocol 只能提供已加载 Page 的 fallback snapshot,不能反向声明 + initial Document。`document.open()` 的 exact renderer lifecycle ingress、Target crash、failed Page-runtime + discard 与 Target close 分别执行 exit、exit、exit 与 forget;creation metadata 在普通 navigation/crash 后 + 保留供 history/diagnostics 使用,只有 Target lifetime 结束才删除; +- initial Page materialization 仍是 Phase 2 same-turn physical projection:先向 Core 查询 current creation + record,再同步安装 active/background `Page` 与 exact Document lifecycle binding,立即在 Core 标记 + materialized,并在任何旧 Page teardown `await` 之前断言“materialized current initial Document 必有物理 + Page”。如果 replacement/exit 已发生,迟到的 initial Page build 被 stale-drop,不能覆盖 successor Page; +- connection diagnostics 新增从 Core registry 直接生成并按 target id 排序的 + `browserOwnerInitialEmptyDocuments`;旧 physical Target owner diagnostics 不再输出一份看似 authoritative 的 + initial Document。Core transaction、request/replacement/history/termination 与 Protocol initial Page、 + popup creator/origin、frame tree、`document.open()`、stale build 回归的聚焦 nextest 为 `76/76`(run + `96b2f73c-66aa-4316-a208-1e7e4b490a97`)。 +- 首轮 workspace nextest 为 `15563/15569`、`17 skipped`(run + `ada7392c-1be1-4afb-9911-4a6c45392514`),没有用重跑掩盖失败:五条 navigation history 用例独立复现 + `5/5` 失败,统一少了 initial `about:blank` entry。根因是共享 test fixture 仍把已被删除的 Protocol + staging 参数误当 creation authority;修复让 fixture 在 topology commit 后走与 production default-context + 相同的显式 Core bootstrap registration。修复后五条精确用例 `5/5` 通过(run + `55ba3005-88c9-4761-b47b-81133221a3c6`),并连续 `20/20` 轮、共 `100/100` 次通过(run + `10db3f2b-85d6-4aa5-aa3e-705ec4bb3f37`);没有恢复 Protocol metadata、增加 fallback、sleep 或 retry; +- 首轮剩余一条 `runtime_insert_then_remove_iframe_preserves_attach_before_detach` 失败与本切片没有已证明的 + 数据依赖,单跑随后通过。按 flaky 证据流程,精确用例连续 `30/30`(run + `ec2b2cba-6d08-4acb-ab91-88d7e5c823a0`)、所在六条 iframe navigation 邻域连续 `10/10` 轮、共 + `60/60` 次(run `eb4a7301-4224-406c-ab32-650a230bc039`)均通过,未修改产品行为或放宽断言;证据只支持 + 记录为首轮未复现的并发风险,不支持宣称已经找到独立 iframe 根因; +- 修复后 Protocol 全量 `3231/3231`(run `ab1f565a-4dc6-4d01-838d-3a282a415cc5`),最终 workspace + nextest `15569/15569`、`17 skipped`(run `562b6dcc-6761-4d43-a6ba-2fa8c72f4636`)。workspace fmt + check、diff check、all-target clippy `-D warnings` 与 release workspace build 均通过;被测 binary 为 + `target/release/moli`,SHA-256 为 + `c6b514d1b520f8bb0965804f839488144ea571f94768076e2988a17accea5c55`。显式清除大小写全部 + proxy/no-proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 release binary 的外部 smoke 为 CDP 默认 + 全组 `210/210`、WebDriver Classic/BiDi/Selenium `148/148`,两者 `ok=true`、失败列表均为空。clippy + 删除 test-only navigation commit adapter 的空尾分支后,initial/document-navigation 聚焦集再跑 + `66/66`(run `1b543f56-615c-4c58-beb7-dbed6149a73c`)通过。 + +本切片后的严格 Phase 2 exit audit 仍**不允许**进入 Phase 3: + +- `BrowserPageResidenceHandle::advance_generation` 仍是 public production API; +- Protocol `TargetPageSlot::replace_loaded_page_with_reason` 在 initial Page install、generic Page install/clear 和 + failed-navigation discard 路径直接调用它;replacement/termination 虽已由 Core transaction 推进 exact + generation,这些剩余路径仍允许 physical projector 决定 Page lifetime; +- 下一内聚切片必须新增 Core-owned Page residence transition operation,由 Core 校验 exact Target/Page + capability、推进 generation 并返回 successor identity;Protocol 只能同步投影 `Some(Page)` / absence,且 + 不得再次推进。完成所有 production call-site 迁移后,把 `advance_generation` 收窄到 Core module,删除 + production migration setter,再重新执行 Exit gate audit; +- renderer `Page` 物理对象暂留 Protocol 不构成该 audit 的失败,拥有 generation mutation authority 才构成。 + 自主 scheduling 和 fact journal 继续分别留给 Phase 3/5,不能为了提前开始 actor 而把 Page authority 缺口 + 带入下一阶段。 + +2026-08-02 第十六切片实现记录: + +- 新增独立 Core 模块 `navigation_owner/page_transition.rs`,定义 protocol-neutral + `BrowserPageResidenceTransitionKind`、exact prepare permit 与 committed transition。initial Document + materialization 和 failed-navigation discard 都先冻结 `{BrowserPageOwnerKey, previous Page residence, + kind}`;Core commit 重新校验 exact Target 与 generation,只推进一次共享 capability,并返回 exact + successor identity。permit/result 不含 `sessionId`、CDP command、event buffer 或物理 `Page`; +- initial materialization commit 现在把 Page generation 与 initial-empty-Document `materialized` one-shot flag + 一次性提交。没有 creation record、已经 materialized、已经 exited 或 generation 已被 successor 替换时, + Core 都不再签发 permit;Protocol 不能通过“slot 当前为空”重新制造 initial Document authority; +- failed-navigation discard commit 把 Page generation、target-keyed engine discard、pending/committed request + 清理、crash-recovery request 取消和 initial Document exit 放在同一个 Core mutation 中;Target topology、Page + slot capability 与 joint history 保留。旧的 `CdpConnection -> discard_target_page_runtime(targetId)` 二段式 + bridge 已删除,failed-navigation history 继续保持不变; +- Protocol 新增独立 `conn/browser_context/page_residence_projection.rs`。它在 Core commit 前只校验 active 或 + background physical slot 携带 permit 的 exact capability;Core commit 后无 `await` 地投影 `Some(Page)` 或 + absence,并断言 successor generation/kind。renderer lifecycle binding、session-local projection 清理和 + background cursor/websocket 清理仍属于物理 participant;退役旧 `Page` 的 `close_async()` 只允许在 Core + commit 与同步投影之后执行; +- `TargetPageSlot` / `TargetRuntimeSlot` / `BrowserContext` / `BackgroundTarget` 的 generic + `replace_loaded_page`、`clear_loaded_page_with_reason` 已收窄为 test-only fixture API。普通 loaded replacement + 和 Target termination 继续消费各自已有的 Core transaction,initial/failure 消费本切片的新 transaction; + BrowserContext/Target 已被 Core forget 后的残余 payload cleanup 使用明确的 field-only projection,不推进 + 已失去 owner 的 capability; +- `BrowserPageResidenceHandle::advance_generation` 已收窄为 Core crate-private;旧 production migration setter + 已删除。为仍需合成 stale generation 的下游单元测试新增默认关闭的 Core `test-support` feature,Protocol + 只在 dev-dependency 打开 `advance_generation_for_test_fixture` / `set_generation_for_test_fixture`。普通 + release dependency graph 不编译这两个入口;测试 fixture API 不能重新成为 production authority; +- active initial Page、background initial Page、late initial build、failed-navigation history 与 stale generation + 的首轮聚焦回归为 `24/24`(run `4249c54a-5717-47b6-801f-2a89b4020454`);Core 新 transition 模块 + `3/3`(run `fb3ac350-561f-4902-a77b-58d5e43d6906`),增加“恰好推进一次”和旧 generation 立即失效 + 断言后的跨 Core/Protocol 聚焦集 `7/7`(run `55c85f70-4d86-473b-9953-642578f60022`); +- 首轮 Core + Protocol 全量没有被重跑掩盖:`5762 passed / 6 failed / 13 skipped`(run + `b9b2c996-7f8c-4ae5-a897-82b065e403e0`)。六条失败单独运行稳定 `0/6`(run + `4f2b85fb-ec1e-4b1c-a27f-843a5d2f2016`),共同根因是 Target/attachment 测试仍用旧 fixture 只注册 + targetId 或空 creation metadata,却要求 materialize initial Page;另一个 isolated-world fixture 直接清掉 + 物理 Page 后试图第二次消费同一 one-shot initial Document。修复让这些 fixture 显式注册 + `BrowserTargetCreationMetadata` 或新的 bootstrap initial creation epoch,没有放宽 Core acceptance、恢复 + Protocol fallback、增加 sleep/retry 或弱化断言;修复后精确六条 `6/6`(run + `f549692c-a174-4ce0-9b9f-c4e9492544ca`),连续 `20/20` 轮、共 `120/120` 次通过(run + `9666ed02-cd62-4433-b42d-0430deb9f64b`),Protocol 全量 `3231/3231`(run + `e8665472-121d-4667-b2f1-12cb326832d1`)。 +- 最终 workspace nextest `15572/15572`、`17 skipped`(run + `d0fafa90-4746-43cf-85a6-1e9eea0d10d3`)。workspace fmt check、diff check、all-target check、all-target + clippy `-D warnings` 与 release workspace build 均通过;被测 binary 为 `target/release/moli`, + SHA-256 为 `5a807e20b074946063d099af4306f39c2b79e2a7624a3288bd184829c92cb0c3`。显式清除大小写全部 + proxy/no-proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 release binary 的外部 smoke 为 CDP 默认 + 全组 `210/210`、WebDriver Classic/BiDi/Selenium `148/148`,两者 `ok=true`、失败列表均为空。 + +第十六切片后的严格 Phase 2 exit audit:**通过**。 + +- production Protocol 已没有 Page generation mutator;所有 production Page lifetime change 都由 Core-owned + replacement、termination 或 residence-transition transaction 决定 successor,再由 Protocol 同 turn 投影; +- Core Page/Target/engine/request/history/initial-Document API 不接受 frontend session、wire command 或 event + storage,physical Page payload 暂留 Protocol 只是一项 Phase 6 lifetime migration,不再反向授权 Core; +- Context removal/Target rollback 后的物理 payload disposal 不再伪造 successor generation,因为 Core 已先 + 删除 exact owner,旧 work 无法通过 owner lookup; +- 因而 Phase 3 可以从一个稳定的 state seam 建立 Browser Owner lane。Phase 0 的统一 production trace、同源 + differential fixture 与 release latency/RSS/CPU baseline 仍未完成;开始 PR D 的 queue cutover 前应先补齐 + 这项可观测性 gate,不能把 frontend noop/pump 当作新 actor 的诊断方法。 + +2026-08-02 第十七切片实现记录(Phase 0 production trace 第一段): + +- 新增 neutral `BrowserActionId`(`moli-page-types/src/browser_identity.rs`)。renderer top-level + location intent 在 producing turn 分配一次,clone/move 不改 identity;frontend/browser action 使用同一 + process-local identity space。它不复用 CDP command id、`sessionId`、loaderId 或 renderer output sequence; +- 新增独立 Core 模块 `navigation_owner/navigation_trace.rs`,集中定义 `BrowserInstanceId`、trace source、 + request correlation context 和 typed event builder。Core context 只含 Browser instance、action origin、exact + source Page residence 和可选 source Document;不含 frontend route、wire payload、event buffer 或 socket; +- request registry 把可选 trace sidecar 与 exact pending/committed `BrowserDocumentNavigation` 放在同一 + target entry。new request 只替换 pending sidecar,commit 把它原子迁到 committed sidecar,failed pending + cleanup、Target forget 和 runtime discard 与 request authority 一起清理;因此 trace 开启时每 Target 仍只有 + bounded pending + committed correlation,不新增 unbounded cache/journal,trace 关闭时不创建 request + context; +- 当前 production 路径已记录 `renderer_intent_published` / `browser_action_published` / + `browser_owner_accepted|rejected` / `navigation_request_started` / `network_request_admitted` / + `response_commit_ready` / `page_replacement_committed` / exact renderer lifecycle reached/observed / + frontend DCL/load projected。Page replacement event 使用 successor Page generation;action/request event 的 + Document 是 causal source Document,DCL/load event 显式覆盖成 committed successor Document; +- `frontend_projection_sequence` 是单 `CdpConnection` projection-local 单调序列,只在 trace 开启且 exact + request correlation 存在时分配。它描述 output-buffer projection,不授权 Browser action,也不与 renderer + lifecycle sequence、protocol work publish sequence 或未来 Browser fact sequence 比较; +- trace 通过 `MOLI_BROWSER_OWNER_TRACE=1` 默认关闭,统一 target 为 + `moli_browser_owner`。当前字段显式保留 `browser_fact_sequence=None`,因为 Browser fact journal 尚未 + 建立;不能用 renderer lifecycle sequence 冒充全局 fact order。trace 不记录 URL、cookie value、 + authorization、request/response body; +- 这一步**没有**迁移 execution authority:trace 中 renderer action 的当前 residence 仍明确经过 + `protocol-scheduler`,`TopLevelLocationNavigationOwnerAction` 也仍是 `ProtocolSchedulerWork`。因此它只是 + Phase 3 cutover 的观测前置,不得宣称 Browser Owner queue 已独立; +- 开启 trace 的真实 raw CDP probe 从 command navigation 加载 Document A,A 的 timer 再执行 + `location.href` 到 Document B,全程不发送额外 progress/noop command。结果为 `loads=2`、最终 + `document.title=B`;command action `1/request 1` 从 Page generation `1 -> 2` 并完成 DCL/load,随后 + renderer action `2/request 2` 从 generation `2 -> 3`,其 source Document 为 Page `2`,successor DCL/load + 为 Page `3`。这证明同一 correlation key 能区分连续真实 Document,未把前一个 DCL 判假或把 frontend + command 当 progress pump; +- identity/request sidecar、stale Page action、scheduler residence、session reattach、`document.open()`、 + replacement 和 exact DCL/load 聚焦集 `14/14` 通过(nextest run + `4a22eaab-6cd1-435b-91ac-20b3f6432d97`);新增 projection sequence order-domain 用例 `1/1` 通过 + (run `032743e7-827c-4271-bd6f-4b0976935b31`),renderer action clone/distinct identity 用例 `1/1` + 通过(run `5cab76c4-5ace-479b-965a-1ab1df9f4b80`);相关五个 crate all-target clippy + `-D warnings` 通过; +- workspace clippy 首轮准确暴露 trace context 使 `ProtocolSchedulerWork` 变大后,durable + `ProtocolSchedulerResidence` 的 inline variant 达到约 `377B`。修复只在该 residence storage seam 把 + concrete work 改成 `Box`;publication order、client-turn predecessor、exact load + predecessor 与 completion API 均不变。对应 `cdp_scheduler` 邻域 nextest `41/41` 通过(run + `60a4ccc5-13eb-41c1-95fc-759877086085`),最终 workspace clippy `--all-targets -- -D warnings` 通过; +- 最终 workspace nextest `15618/15618`、`17 skipped`(run + `12703160-1ce1-4bf3-a3c6-d0240875cedd`)。workspace fmt check、diff check 与 release workspace build + 均通过;被测 `target/release/moli` SHA-256 为 + `24010b0323b713dd570ef98a7905ef795591310e4e2591f94759b6ea46e8377a`。显式清除大小写全部代理变量、 + 设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 release binary 后,CDP 默认全组和 WebDriver + Classic/BiDi/Selenium 均返回 `ok=true`,没有失败记录。 + +本切片后 Phase 0 仍是“部分完成”:下一步要建立可机器比较的 CLI/CDP/BiDi/Classic 同源 trace fixture, +把 Browser fact journal 的 sequence 接入,细化剩余 field inventory,并冻结 release latency/RSS/CPU baseline。 +在这些证据完成前,不开始 Phase 3 execution-authority cutover。 + +2026-08-02 第十八切片实现记录(machine trace transport): + +- `moli-trace` 新增 schema version `1` 的 `BrowserOwnerTraceRecord` 和 + `BrowserOwnerTraceDocument`。字段与 human trace 同源,Document 拆为 renderer Page id、Document + generation 和 lifecycle epoch;renderer lifecycle kind/reason/last-reached 使用稳定 kebab-case label, + 不要求 benchmark 解析 Rust `Debug` 文本; +- 显式设置 `MOLI_BROWSER_OWNER_TRACE_JSONL=` 时,每条完整记录在 process-local mutex 下追加为 + 单行 JSON 并立即 flush。sink 仍默认关闭;打开 JSONL 不再隐式打开 human `tracing` 输出,原 + `MOLI_BROWSER_OWNER_TRACE=1` 继续单独控制人类日志。文件 open/serialize/write 失败不 panic、不卡住 + Browser Owner,consumer 把缺失或截断记录当自己的 gate failure; +- schema 明确不包含 URL、cookie、authorization、body、frontend session 或 wire command payload;所有 + 尚未建立的 authority 字段(尤其 `browser_fact_sequence`)保留显式 `null`,不能把不完整 correlation + 误当完整 Browser fact; +- Core typed action/request trace、renderer intent 和 renderer exact-Document lifecycle 都写入同一个 JSONL + sink。schema/lifecycle-label/identity/request 聚焦 nextest `6/6` 通过(run + `aa2b9df4-69c0-4517-a2d1-e15d36f4a5b3`),相关四个 crate all-target clippy `-D warnings` 通过。真实 + CLI probe 只设置 JSONL path、未设置 human flag,stderr 没有 Browser Owner human record;JSONL 为合法 + schema `1`,按 `started -> dom-content-loaded -> load -> terminated` 输出同一个 exact Document。 + +本切片只稳定 trace transport,不新增 journal、subscriber 或 owner queue。下一切片由 benchmark 模块消费 +JSONL,建立 CLI/CDP/BiDi/Classic 同源 fixture;在 fixture 和 baseline 完成前 Phase 0 仍是“部分完成”。 + +2026-08-02 第十九切片实现记录(cross-frontend trace fixture 与第一版 release baseline): + +- `moli-benchmark navigation-trace` 新增同一个本地 HTTP source 驱动的 standalone CLI、raw CDP、 + standalone WebDriver BiDi 和 WebDriver Classic 四路径 differential。Document A 在自己的 `load` handler + 中同步设置 `location.href` 到 Document B,B 的 `load` handler 请求 `/complete`;fixture server 按 run token + 记录 `a -> b -> complete` 的事实顺序,并通过 condition 等具体请求,不用固定 sleep、retry 或浏览器轮询; +- benchmark 代码按责任拆为 `navigation_trace_fixture`(HTTP source/factual wait)、 + `navigation_trace_records`(schema/identity normalization)、`navigation_trace_frontends`(四类 client)和 + `navigation_trace`(gate/report orchestration);CLI/serve 只提供命令注册和 diagnostics env/resource sampler + 参数,不把 protocol client 或 trace parser 重新揉进总入口; +- 这里刻意使用 load-boundary 内已经产生的 navigation,而不是未知 future timer。standalone 当前 + `Done == Load`,但 `FollowBeforeReply` 必须先消费同一个 load owner turn 已经产生的 replacement;本 fixture + 不偷改 `Done` 产品合同。future-timer/challenge 是否进入 B 仍由明确的高层 wait policy 负责; +- CDP/BiDi/Classic 各自先加载 bootstrap Document,使用一次**测量前**的读取命令切开 bootstrap JSONL;正式 + A navigation 发出后,在 B 的真实 load 和 `/complete` 到达前不再发送 frontend command。因而这个 barrier + 只隔离 trace,不是推动 A -> B 的 heartbeat。CDP/BiDi 还分别要求自己的 wire lifecycle 精确为 + `A:DCL -> A:load -> B:DCL -> B:load`;Classic 在 `/complete` 前不读取 URL/title/source; +- consumer 校验 schema version、完整 JSONL record boundary、HTTP request 顺序、两个 exact renderer + Document 的 DCL/load、最终 URL/title/body phase,以及 post-navigation frontend command 数为零。它把 + process-local action/request/Page/Document 数字首次出现顺序归一化为 `A1/R1/P1/D1...`,排除 + `frontend_*` projection record 后比较 CDP/BiDi/Classic 的 Browser Owner transition shape;projection 仍由 + 各自 wire gate 单独检查,不能反向成为 Browser execution authority; +- suite 输出 `runs.csv`、完整 `runs.json`、`cross-frontend.json`、`summary.json` 和每次原始 JSONL。process + resource sampler 对这个短 workload 使用 10ms interval,记录 process-tree PSS/RSS 和 sampled lifetime CPU; + latency scope 明确区分 CLI 的 cold process-to-successor-load 与三种协议的 warm host + command-to-successor-load,不能把四个延迟数字直接当同口径排名; +- Python fixture/schema/normalization/CLI/env 聚焦 unittest `24/24` 通过,benchmark 全量 unittest + `362/362` 通过。debug binary 的 bounded stress 为 10 轮、40 个 frontend run 全部成功,10/10 + visible-shape 与三协议 owner-shape 都一致,没有重试失败轮次; +- release binary SHA-256 为 + `30a8a37fbe527d55d5df98f1ecc1fc13d40425e6f2a7474b85c00861728d903b`。命令 + `moli-benchmark navigation-trace --runs 10 --timeout 10` 的 artifact 位于 + `/tmp/moli-navigation-trace-rebased.YbmygW`;40/40 frontend run、10/10 cross-frontend gate + 通过。第一版数值如下(median / p95): + +| frontend | navigation ms | peak PSS MiB | peak RSS MiB | sampled CPU % | samples/run | +| --- | ---: | ---: | ---: | ---: | ---: | +| CLI | 29.71 / 31.32 | 28.89 / 63.62 | 31.20 / 65.82 | 0.0 / 100.0 | 2 / 2 | +| CDP | 15.10 / 16.56 | 88.66 / 93.94 | 91.24 / 96.47 | 50.0 / 63.63 | 5 / 5 | +| BiDi | 16.16 / 16.75 | 82.91 / 86.20 | 85.38 / 88.59 | 53.55 / 62.32 | 4 / 5 | +| Classic | 19.78 / 34.86 | 82.01 / 84.05 | 84.44 / 86.35 | 42.7 / 50.0 | 6 / 6 | + +同一工作树已 rebase 到 `origin/master` 的 `2d4f2baf47356903bfbef0c5f30d282420b5042f`。rebase 后最终 gate +为 `cargo nextest run --no-fail-fast` 15630/15630 通过、17 skipped(run ID +`4d3c3d0c-683b-4a0b-9681-9ff58761ba2f`),benchmark unittest 362/362、fmt、diff check、workspace +clippy 和 release workspace build 均通过。显式清除大小写全部 proxy 变量、设置 `NO_PROXY=*` / +`no_proxy=*` 并固定上述 release binary 后,CDP 默认全组 210/210、WebDriver +Classic/BiDi/Selenium `--continue-on-failure` 148/148;两者 `ok=true`、失败列表为空。 + +CLI 只有 1--2 个短进程 sample,CPU 的 `0/100` 量化尤其弱,PSS median 也可能漏过短峰值;当前 raw +artifact 保留 sample count,后续 cutover 比较必须使用相同 sampler 和 latency scope,并优先看 p95/重复轮次, +不能把这组 CPU 数字包装成精确性能结论。event-heavy、多 Target 和 idle footprint 仍由 startup/其它 Phase 0 +workload 补齐。 + +本切片仍不新增 Browser fact journal 或独立 owner queue。它冻结了四种 Moli frontend 的第一条同源 +machine gate 和 release 基线;Phase 0 仍为“部分完成”,因为 `browser_fact_sequence`、更细字段 inventory、 +Chromium machine differential 以及 event-heavy/多 Target/idle baseline 尚未全部收口。 + +2026-08-02 第二十切片实现记录(renderer Browser Owner lane 第一刀): + +- Core 按责任新增 `browser_host/owner_input.rs` 与 `owner_queue.rs`。前者只定义 protocol-neutral + `BrowserOwnerInput` / renderer intent,payload 只含 exact Page residence、source Document navigation 和 + 可选 trace;后者只实现 single-owner FIFO,不含 session、command id、domain subscription、socket 或 + protocol predecessor; +- renderer prepared output 现在发布 `BrowserOwnerInputPublished`。该 migration envelope 只把 neutral input + 交给 application composition root,不分配 `ProtocolWorkPublishSequence`,也不进入 + `ProtocolSchedulerResidence`;旧 `TopLevelLocationNavigationOwnerAction` 文件、payload kind、ready branch 和 + protocol execution path 已删除,因而同一 renderer action 不存在 old/new 双 execution authority; +- raw CDP、standalone BiDi 和 Classic session actor 都在各自 loop 顶部执行同一 selection rule:先完成已经成为 + authoritative fact 的 exact old-Document load projection,再检查 Browser Owner queue;两者都没有时才接受 + socket command、renderer publication 或普通 adapter work。这里的 load 优先级只覆盖 terminal 已 ready 的 + front residence(包括跨过其既有 client-turn boundary、但尚未 attachment 的状态)或已 attachment 的 terminal, + pending observer 不会阻塞 replacement。external navigation wait 仍先 drain Browser Owner queue,再检查其 + 同步 protocol lifecycle snapshot; +- 这里的“下一 turn”不是递归调用 renderer-output producer。producer turn 只 capture/publish input 并返回; + queue turn 才执行 navigation。Browser Owner selection 不读取 Page-domain enable 或 client-turn predecessor。 + 边界回归把一个未满足 client-turn predecessor 的 protocol residence 与 ready owner input 同时放入 + scheduler,证明 owner input 能完成且不会满足、消费或进入 frontend queue; +- `CdpConnection::complete_browser_owner_input_turn` 是迁移期 physical Page/event projection adapter:它只能执行 + application queue 已选择的 neutral input,不能建 residence 或决定运行时机。该 adapter 与 + `CdpScheduler` composition 持有 queue 的形状必须在后续切片收敛为 `BrowserHostActor/Handle`;本切片不把 + 它冒充最终 Browser Host lifetime,也没有提前建立 Phase 5 fact journal; +- stale exact Page residence 仍在 owner lookup 处拒绝;same-Page `document.open()`、frontend session reattach + 和 runtime output barrier 保持原语义。`TestContext` 也新增独立 Browser Owner pending queue,并只在下一 + scheduler turn 消费;依赖旧同步 residence 的 4 个 fixture 改为等待真实 scheduler state,而不是恢复同步 + 执行或发送 noop command; +- actor 回归新增 Page domain 从未 enable 的 parser-blocking `location.href`:`Page.navigate` response 后不再 + 发送任何 frontend command,replacement HTTP request 仍自主开始。原 Page-enabled passive-progress 用例继续 + 验证 replacement Document 的 exact loader/DCL;Core FIFO/exact Page、protocol stale/reattach/ + `document.open()`、Runtime barrier 和 application queue/client-predecessor 边界均有聚焦覆盖; +- 第一次 post-pull release trace 暴露出一条跨 queue 因果缺口:renderer 已按 exact Document 发布 A 的 load + fact 和 `A -> B` intent,但 raw CDP 的 adapter 尚未来得及把 ready load residence 跨过 client-turn/attachment + 边界,loop 顶部便先选择 B。结果 HTTP `a -> b -> complete` 全部成功,而 CDP wire 稳定缺失 `A:load`(0/10); + Classic 同一顺序出现 1/10 波动。问题不是 403/DCL 虚假,也不是 pending load 应全局阻塞 owner,而是两个独立 + queue 之间遗漏了“已成为事实的旧 Document terminal 必须先投影”这条局部 edge; +- `ProtocolAdapterScheduler` 现在保留 exact load observer 的只读 readiness probe。若 front load residence 已 + terminal、但尚未 attachment,它复用既有 coalesced adapter self-turn 依次跨过 producer boundary、建立 + attachment、消费 terminal;若 attachment 已存在则从专用 completion channel 消费,普通 self-turn 不能插队。 + 三个 frontend actor 只在这一窄条件下让 load projection 先于 Browser Owner;observer 仍 pending 时 owner + 立即运行,因而 replacement/termination 仍能成为产生 `Superseded`/`Unavailable` terminal 的必要前驱。实现 + 没有增加 sleep、`yield_now`、retry、heartbeat,也没有把 navigation 重新放回 protocol residence; +- 这条 `load_projection_precedes_browser_owner` 是 physical Page/lifecycle 仍住在 `CdpConnection` 时的迁移期 + safety edge,不是最终 Browser Host 可以等待 CDP frontend flush 的许可。独立 actor 完成后,应由 Browser + lifecycle ingress/fact journal 先记录 A load fact、再接受 B;frontend 只按 fact sequence 异步投影,即使 writer + 慢或已断开也不能反向延迟 B。该 edge 必须随 physical lifecycle authority 一起迁入 Browser Core,而不能把 + `ProtocolAdapterScheduler` 搬进 `BrowserHostActor`; +- WebSocket 回归新增 load handler navigation,锁住 source loader 的 `Page.lifecycleEvent(load)` 必须先于 + replacement loader 的 `Page.frameNavigated`,同时保留 parser-blocking/Page-disabled 两条自主进度覆盖;该 + exact 回归 nextest stress 20/20 通过。修复后的 release navigation trace 为 CLI/CDP/BiDi/Classic 各 10/10、 + cross-frontend 10/10、`gate_failures=0`,没有放宽 benchmark 的 owner-stage 或 wire lifecycle 断言; +- renderer action 的 trace `browser_action_published.owner_state_after` 从历史 + `protocol-scheduler` 改为 `browser-owner-queue`。frontend command action 仍是 Phase 4 前的既有路径,不能把 + 这项 trace 变化误写成 command navigation 已经汇合。 + +本切片的 queue 无外部直连 producer;每个 application actor 在接收下一 external input 前持续优先 drain, +而每个 Page prepared-output slot 最多保留一个 top-level navigation,因此不会跨 external turn 无界累积。Phase 4 +扩大 command/popup/termination source 前仍需冻结正式 channel capacity/coalescing/backpressure policy;不能只靠 +当前结构性 drain 假设宣称最终资源 gate 已完成。 + +当前 raw/BiDi/Classic loop 仍要等产生该 input 的 renderer-publication turn 完成现有 protocol output routing 后, +才回到 loop 顶部选择 queue。因此“slow writer 不延迟 replacement start”和“frontend disconnect 后 accepted +action 继续”这两条 actor regression 尚未通过结构性证明;它们要求下一切片把 Browser Host turn/lifetime 从 +frontend flush 返回值中再拆开。这里不能把“queue 不读取 socket 状态”偷换成“当前控制流已不等待 socket”。 + +本切片早期证据:Core FIFO/exact Page `1/1`(nextest run +`fc60960a-5f42-4f19-9f59-3829f3a09121`);application queue + Page-enabled/Page-disabled passive actor +回归 `3/3`(run `cc71edc4-0aee-492a-ae49-ea765eceb542`);`moli-protocol` 全包 +`3233/3233`(run `d26334c5-8c61-4dc2-9c0e-9cb393884877`);navigation-trace Python 单元 +`6/6`;`moli-core` / `moli-protocol` / `moli` all-target clippy `-D warnings`、workspace +fmt check 与 diff check 通过。最终 post-pull release binary SHA-256 为 +`086086d68c9da8a17b42d157df9d1f39068e837d90ca7fd2d44303f086e20569`;四 frontend 10 轮 artifact 位于 +`/tmp/moli-owner-queue-trace-final.l8ZpOZ`,40/40 frontend run、10/10 cross-frontend gate、零失败。 +新增 exact actor 回归 stress 20/20(run `03032ed4-59fb-4e85-851c-c6956c45aa1b`);最终 +`moli` + `moli-protocol` 包级 nextest 3789/3789(run +`746f82a3-e114-4faf-abf9-ab08c836fb75`),navigation-trace unittest 6/6,三个受影响 crate all-target +clippy `-D warnings` 通过。显式清除大小写 proxy、设置 `NO_PROXY=*` / `no_proxy=*` 并固定上述最终 binary 后, +CDP 默认全组 210/210、WebDriver Classic/BiDi/Selenium 148/148,二者 `ok=true` 且失败列表为空。按本轮 +约定没有重复 workspace 全量 nextest。 + +### Phase 3:建立独立 Browser Owner lane + +状态:已完成(第48切片 exit audit)。第20切片已完成 renderer top-level navigation 的 queue/residence cutover,并让 CDP、BiDi、 +Classic application actor 在接收下一 frontend/renderer input 前先结算 causally-ready exact lifecycle +projection、再执行 ready Browser Owner input;第21切片已把 Page replacement/residence transition 的 stale +permit 改为 typed outcome,并关闭 Page/Target handle 的 Acquire/Release 审计;第22切片已把 Target +termination 的 stale/Crash-Recovering divergence 改为可回滚 typed commit;第23切片已把 Target engine +adoption 的 Core/physical owner divergence 改为 typed rejection,并由 Core 决定 selected/retained residence; +第24切片已把 BrowserContext registration/activation/removal projection 的 miss/divergence 改为可回滚 typed +transaction;第25切片已把 Target registration/activation/rollback projection 改为完整 identity/capability +校验与 same-turn staged transaction;第26切片已把 Core Target registry 的 context/Target registration、active +replacement、activation、removal 与 handle lifecycle 发布改为 registry-owned exact transaction;第27切片已把 +Core Page registry 的 context/Target registration 改为 staged/live exact transaction,并与 Target/engine participant +形成可回滚的联合提交;第28切片已把 default BrowserContext bootstrap 的 initial-Document creation metadata +并入同一个 Core registration transaction,删除 Protocol 的 production 补注册与 `expect`;第29切片已把 +physical Page residence participant 改为 Core commit 前完成 exact staging/typed validation、Core rejection 时恢复 +原 context/target slot 的联合事务;第30切片已把 renderer same-document history 更新改为 exact Page + typed Core +commit,并让 physical Target metadata 与 frontend event 都服从该 commit 结果;第31切片已建立 Core +`BrowserHostActor` mailbox 与 cloneable `BrowserHostHandle`,删除 renderer input 的 +`CdpSchedulerEvent::BrowserOwnerInputPublished` envelope;第32切片再以 Core-issued、non-cloneable +`BrowserHostTurn` capability 收回 FIFO selection/execution permission,删除 application/fixture 的 raw input +pop 与 `CdpConnection` 的 raw-input completion API;截至第32切片,actor 仍由 `CdpScheduler` composition root +驱动。第33切片已把 actor residence 移出 `CdpScheduler`,并让三种 frontend loop 和 direct command wait 直接 +监听 mailbox wake;physical Page executor 仍借用 `CdpConnection`,actor teardown 也仍跟随 application owner +loop,typed outcome/fact channel 尚未建立,因而本阶段 Exit gate 尚未宣告通过。 +第34切片进一步把 Core executor contract 收紧为同步启动,并在 application composition 建立 exact participant +completion mailbox;等待 renderer/network participant 时不再持有 Browser Host actor 或 `CdpConnection`。但 +第35切片又把 response-ready completion 的 configuration/renderer commit wait 从 direct 与 background apply +turn 中移出,并让 background gate 延迟到 terminal phase 才结算。第36切片继续把 response-ready tail 的每个 +renderer Inspector replay dispatch 拆成独立 participant;commit completion 在 replay batch 结束前保持同一 gate +为 non-terminal。第37切片进一步让 generic materialized outcome 在 body apply 后复用同一 tail participant seam, +不再由 Browser Host completion inline drain Inspector replay。第38切片将 generic loaded Page restore wait 移入 +move-owned participant;第39切片将旧 Page close 移入 disposal participant;第40切片删除 disposal completion 中 +DedicatedWorker registry retirement 的伪异步 wrapper,使它在 exact owner apply turn 同步提交。第41切片又把 loaded +lifecycle/activity prefix 改为同步投影,并在返回前发布真正异步的 load owner action。第42切片进一步把 +loaded-navigation 的 BiDi preload proxy/listener/cleanup command 拆成 exact Page participant;listener 的 deferred +message reply 仍是独立 response-ready input。第43切片又把该路径的 Runtime output context-id/DOM-node normalization +拆成 exact renderer-attachment participant,使 loaded-navigation completion apply 不再跨 Page lookup wait。第44切片 +又把 `Page.createIsolatedWorld` 的 realm lookup 和 preload listener batch 变成该 Page command task 的显式 participant; +replacement 后的旧 world 响应不会把 listener 工作带入 successor Page。第45切片再把 realm inventory 与 listener +batch 抽成可复用 exact-Page setup,并让 `Page.addScriptToEvaluateOnNewDocument` 的 renderer install 与 +run-immediately listener startup 成为命令自身的 participant;旧 Page completion 不能进入 replacement Page。 +第46切片又把普通 Runtime 与 Runtime-binding Inspector completion 的 context-id/DOM-node normalization 变成 +Runtime command 自身的 participant;stale completion 会结束旧 command,而不是等待已经失去 owner 的 response +receiver。 +第47切片再让 scheduler-facing protocol-neutral `ReleaseObjects` 的每个已知 handle 成为外层 Runtime command task +可见的 participant;`start_devtools_runtime_command_dispatch` 不再串行等完整个 handle list。 +Fetch continue/BrowserContext disposal compatibility drain、其他 BiDi listener/direct compatibility 入口、 +`CdpTurnOutcome` projection 和 Host lifetime 仍在 frontend owner loop,protocol-neutral Browser fact/outcome channel +尚未建立;第48切片 audit 将它们分别归入 Phase 4、Phase 5 和 Phase 6,不再把后续 phase 的完成条件误作 +renderer-navigation lane cutover 的 blocker。 + +#### 当前 production compatibility 清单 + +截至第64切片,不能把“局部 legacy 已清”描述成整个 Browser/CDP 拆分完成。Production 仍明确保留: + +1. `domains/page/navigation_tail.rs::finish_materialized_navigation_tail_async`:尚未暴露 participant boundary 的 + 调用方仍 inline drain renderer replay; +2. `domains/page/navigation_commit.rs::commit_loaded_navigation_async`:尚未迁移调用方仍通过 compatibility wrapper + 等待 loaded commit; +3. `domains/runtime/dispatcher.rs::start_bidi_preload_channel_listeners_for_execution_context_*`:loaded-navigation、 + `Page.createIsolatedWorld` 与 Page command 形式的当前 Page preload install 已不再调用该 inline helper;runtime + realm-created 和 create-target initial `about:blank` 入口仍会 inline drain;targeted protocol-neutral preload 则由 + `preload/add_command.rs::execute_direct_async` compatibility adapter 本地 drain 同一 move-owned task; +4. Fetch continue 与 BrowserContext disposal termination 的 Fetch cancellation compatibility drain:两者尚未成为独立 + Browser Host participant。`Page.stopLoading` 的 admission、当前 Document 选择和 renderer lifecycle stop 已在第63切片进入 + Host,第64切片再把其 paused main-navigation/subresource Fetch cancellation 拆成逐项 Host participant;旧 + `fail_pending_fetch_state_background_events_async` 仍供尚未迁移的 termination/context-disposal 调用方使用,但只兼容地本地 + drain 同一个 exact-Page cancellation 状态机,不再维护另一份 cancellation 实现; + direct `wait:none` 的 admission/start 已在第62切片进入 Browser Host,但 detached load completion、lifecycle 与 Network + output 仍通过既有 background navigation completion/output transport 回到 application owner loop; + Page.crash/Page.close 已在第58切片迁移,显式 `Target.closeTarget` 已在第59切片迁移为 exact Host input + + disposal/promotion participant,第61切片又删除 paused-fetch cancellation 的 Page/Target termination + `ProtocolSchedulerWork` admission。command renderer predecessor 仍由 command completion fence 先投影,但不再拥有或延迟 + Browser input; +5. `complete_runtime_protocol_message_for_session_owner_async` 已成为 `cfg(test)` fixture helper,production caller + 清零;scheduler-facing `ReleaseObjects` 已使用逐 handle participant chain。无 scheduler participant loop 的 direct + protocol-neutral adapter 仍会本地 drain release/normalization,runtime realm-created、initial `about:blank` 与部分 + direct/replay 路径也仍可能在 frontend owner loop 内等待 renderer participant,且尚无 protocol-neutral Browser + outcome/fact channel; +6. `BrowserHostActor` teardown 仍跟随 frontend/application owner loop,Browser Host lifetime 与 frontend lifetime + 尚未分离。 + +第42--47切片关闭的是 loaded-navigation owner gate、`Page.createIsolatedWorld`、Page command add-preload、普通 +Runtime/Runtime-binding command 与 scheduler-facing `ReleaseObjects` 内会隐藏 owner participant 的真实 wait;它不宣称 +所有 BiDi listener/direct adapter 或所有 Runtime completion 调用方已完成迁移。第58切片关闭 Page-originated +termination execution authority,第59切片继续关闭显式 top-level `Target.closeTarget` 的 execution authority;两者都不改变 +上述 Runtime compatibility 边界。第60切片又删除 popup/auxiliary navigation 的 Protocol scheduler execution +payload;这同样不表示 Runtime compatibility wrapper、paused-fetch admission 或 Host lifetime 已经迁完。 +第61切片删除了该 paused-fetch admission,但不表示 Fetch continue、BrowserContext disposal、fact channel 或 Host lifetime +已经迁完。第62切片关闭 direct `wait:none` 的旧 Page admission path,但 detached navigation completion 仍通过既有 +background completion/output transport 回到 owner loop;这条 transport 要由 Phase 5/6 的 neutral fact/outcome 与独立 +Host lifetime 收口。第63切片又关闭 `Page.stopLoading` 的 frontend execution authority,第64切片进一步关闭它在 renderer +stop 之后隐藏的 paused-Fetch inline wait;但 cancellation output 仍是 Protocol event/projection,其他 +termination/context-disposal caller 也仍通过 compatibility drain 执行,所以不能把“stopLoading participant 已完整”写成 +“neutral fact channel 或 Host lifetime 已完成”。 + +#### Phase 3 防雕花判据与 exit audit + +Phase 3 的目标不是消灭 Protocol 中每一个 `await`。frontend 可以等待自己的 response;只有满足至少一项的等待才必须 +迁成 Browser/Renderer owner participant: + +1. frontend 不继续 poll/dispatch 时会阻止 Browser Owner 导航或其他浏览器事实推进; +2. 跨等待持有 Browser Host、`CdpConnection` 中的共享 physical owner 或不可冻结的 authority; +3. completion 可能按 target/session 重新发现 Page,从而进入 replacement Document; +4. slow/disconnected frontend 会改变 browser-owned navigation trace、commit/fact 顺序或 terminal disposition。 + +第48切片已逐项完成该 audit:无 frontend input 和 Page domain disabled 时 Browser Owner 都会自主推进;bounded socket +writer enqueue 不等待 peer,overflow 只关闭该 frontend;page/browser frontend detach 后 owner 与 Target 仍存活;剩余 +Runtime/preload direct adapter wait 只延迟自己的 command/adapter,不拥有 renderer top-level navigation queue。它们只有在 +后续实际命中上述四条判据时才迁移,不能再以“Phase 3 还有 await”为由扩大本阶段。 + +目标:让 renderer navigation 完全离开 protocol residence。 + +工作: + +1. application/local runtime 启动 `BrowserHostActor` 和 `BrowserHostHandle`; +2. renderer intent 直接进入 Browser Owner input; +3. `TopLevelLocationNavigationOwnerAction` 从 `ProtocolSchedulerWork` 删除; +4. Browser Owner 自主 schedule next turn; +5. renderer/network participant completion 通过 typed `CompletedBrowserHostTurn` input 返回 owner loop;完整 + navigation/lifecycle outcome/fact journal 按 Phase 5 建立,不作为本阶段的隐藏追加 gate; +6. 保持同进程、同 current-thread runtime,不先引入跨线程 V8 handle。 + +2026-08-03 第四十八切片实现记录(Phase 3 exit audit): + +- production type audit 确认 `ProtocolSchedulerWorkKind` 只剩 protocol observation、main-document load、BiDi channel、 + popup navigation 与 Target termination;renderer `TopLevelLocationNavigation` prepared output 直接调用 + `publish_prepared_top_level_location_navigation_input`,失败时返回 typed Host publication error,既不会重建 + scheduler fallback,也不会执行 navigation; +- Core `BrowserHostActor` 独占 FIFO selection 并签发 non-cloneable `BrowserHostTurn`。application 的 + `BrowserHostExecutionLane::recv_wake` 直接监听 actor mailbox 和 exact participant completion;CDP、BiDi、Classic + loop 与 direct wait 都把该 wake 作为独立输入,不需要下一 frontend command 或 loop-top polling; +- slow frontend 的 wire boundary 是 bounded `CdpSocketSink::enqueue_message`:enqueue 同步返回,queue/byte overflow + 只请求关闭该 sink。`CdpFrontendRouter` 对每个 frontend 独立路由,失败不会 await writer,也不会停止 Browser Host。 + frontend detach 只移除 route/session projection;已有 dynamic Target 与 renderer state 继续存活; +- compatibility 清单重新分类:popup/Target termination 是 Phase 4 尚未汇流的真实 browser-owner action; + `CdpTurnOutcome`/BackgroundProtocolEvent 到 neutral fact 的迁移是 Phase 5;actor 与 frontend/application teardown + 分离是 Phase 6。navigation tail、preload direct adapter 和 Runtime local drain 当前不持有 renderer top-level + navigation execution authority,不能为了清零 wrapper 留在 Phase 3; +- 聚焦证据:parser script navigation 在无后续命令和 Page domain disabled 两种 production WebSocket 路径均通过 + `2/2`(run `b410b739-c869-4037-a155-51668d8bd48a`);browser frontend detach 后动态 Page/Target 保持存活 + `1/1`(run `b66755b2-c624-4fb0-bd11-3cfc7a83c340`);Browser Host mailbox 独立于 protocol client-turn + predecessor `1/1`(run `6c38cd00-bcf2-4347-bd60-321fd5407fd8`);renderer intent 无 protocol scheduler + fallback `1/1`(run `6b71fab6-b434-43fc-a896-1030d7bbf07c`); +- Phase 3 Exit gate 因此通过。下一 production slice 进入 Phase 4,把 frontend `Page.navigate` command 与 renderer + intent 汇入同一 Browser Owner queue;不得先回头继续清 Runtime/preload wrapper,也不得把 Phase 5/6 的 gate + 偷塞回 Phase 3。 + +#### 独立 BrowserHostActor 前的 panic / 并发安全审计 + +2026-08-02 评审确认下面 12 组既有问题;第18--20切片没有引入它们。第21切片已关闭第 1、9、12 组, +第22切片已关闭第 2 组,第23切片已关闭第 3 组,第24切片已关闭第 4 组,第25切片已关闭第 5 组,第26切片 +已关闭第 6 组,第27切片已关闭第 7 组,第28切片已关闭第 8 组,第29切片已关闭第 10 组,第30切片已关闭 +第 11 组。12 组既有问题至此均已有独立内聚切片和回归证据;这只完成 actor 前的安全审计,不表示 Browser Host +lifetime/queue 已脱离 frontend stack,也不授权提前把 physical Page/V8 payload 移到跨线程执行: + +1. **第21切片已关闭**:Core `page_replacement.rs` 的 commit 重新验证 exact request、Target recovery 和 Page + generation,返回 `BrowserPageReplacementCommitError`;request 预提交在 Page CAS 失败时同 turn 回滚; +2. **第22切片已关闭**:Core `target_termination.rs` 的 commit 返回 + `BrowserTargetTerminationCommitError`;stale Page、重复 Crash 及 Crash/Recovering 冲突不再触发 production + panic,Close 的 termination state、Target topology 与 Page generation 通过同轮 rollback transaction 保持原子; +3. **第23切片已关闭**:Protocol `browser_target_engine_handoff.rs` 不再用五处 production `expect` 处理 + Core/physical engine divergence;exact adoption 由 Core 校验 Target topology、selected engine owner 与 + physical selected-Target projection,并返回 `BrowserTargetEngineAdoptionError`; +4. **第24切片已关闭**:Protocol `browser_context/registry_projection.rs` 的 registration/activation/removal + 先验证完整 physical identity set;会移动 payload 的路径以 exact `Option`/`Vec` slot transaction 包围 Core + commit,Core typed rejection 时恢复 selection 与原 vector index。`lifecycle.rs` 不再把 physical/Core miss + 视为 process panic;能返回 command error 的入口传播 `BrowserContextProjectionError`; +5. **第25切片已关闭**:Protocol Target topology mirror 在任何 Core mutation 前校验完整 physical Target set、 + context/active residence、exact Target handle 与 Page residence;registration、activation、rollback 传播 + `TargetProjectionError`。会移动 payload 的路径先暂存 exact BrowserContext/Target slot,Core typed rejection 时 + 恢复原 selected/inactive index 与 background index;一致性断言只保留为 `debug_assert!` 诊断; +6. **第26切片已关闭**:Core Target registry 在 engine handoff 前以 exact transaction 暂存 BrowserContext/Target + topology 与 reverse owner,并预留 handle activation/retirement;Core typed rejection 同 turn 恢复原 context、 + active/background slot 与 vector index,成功后才发布 lifecycle。missing reverse owner、context mismatch、 + non-staged/non-live handle 都返回 typed error;production registry 路径不再以 `assert!`/`expect` 处理 divergence; +7. **第27切片已关闭**:Core Page registry 以 staged/live record 和不可伪造 transaction token 管理 + BrowserContext/Target registration;staged residence 不参与 live lookup/projection,Page participant 或 engine + 拒绝时先恢复 exact Page entry、再恢复 Target transaction,成功时 Target lifecycle 最后发布。重复 owner、 + non-live residence 与跨 Context capability alias 返回 typed error,production registration 不再使用 `assert!`; +8. **第28切片已关闭**:default BrowserContext 的 active Target creation metadata 与 Context/Target/Page topology + 由同一个 Core registration transaction 接收并发布;Protocol 不再在 Core commit 后另行补注册 + initial-Document,也不再以 production `expect` 维持两步时序; +9. **第21切片已关闭**:Core `page_transition.rs` 返回 + `BrowserPageResidenceTransitionCommitError`;initial-Document materialization 预提交在 Page CAS 失败时同 turn + 回滚,不再以 production `assert!`/`expect` 处理 stale permit; +10. **第29切片已关闭**:Protocol Page residence bridge 在 Core commit 前校验完整 Target topology、exact permit + generation/instance 和 initial slot absence,并暂存 exact BrowserContext/active-or-background Target participant; + Core typed rejection 恢复原 selected/inactive context slot 与 background vector index,成功后不再按 id 做 fallible + lookup;physical divergence 返回 `PageResidenceProjectionError`,一致性断言只保留为 `debug_assert!`; +11. **第30切片已关闭**:Core same-document history 以 exact Page residence + typed result 原子提交;Protocol 在 + Core 接受前不再修改 physical Target URL/origin,拒绝时也不再返回 frame id 或发布 frontend navigation event; +12. **第21切片已关闭**:Core `page_residence.rs` 的 generation read 使用 Acquire,exact generation advance 使用 + AcqRel CAS、失败使用 Acquire;`target_handle.rs` lifecycle read/CAS 使用同样配对。instance-id allocator 只负责 + 唯一数字分配,继续使用 Relaxed,不承担 payload 发布同步。 + +处理原则不是机械把所有断言改成日志:prepare permit 应携带不可伪造的 exact generation/instance,commit 应返回 +typed stale/divergence outcome;projection mirror 的诊断断言与真正可恢复错误必须区分;history 必须传播 Core +结果;跨线程可见 handle 必须建立并用测试证明 Acquire/Release 配对。上述审计现已收口,但 Phase 3 仍只允许保持 +同 current-thread runtime 的逻辑 actor/queue 拆分;独立 actor lifetime、typed fact channel 和 payload residence +仍须通过各自 Exit gate,不能把当前单线程时序假设包装成线程安全。 + +2026-08-02 第二十一切片实现记录(Browser Host actor 前的 exact commit safety): + +- `BrowserPageResidenceRegistry::commit_replacement/commit_transition` 不再返回无原因的 `Option`,而是用 exact + instance + generation compare/exchange 返回 `UnknownTarget`、`ProjectionMismatch`、`StaleTransition` 或 + `GenerationExhausted`;stale permit 是可观察 owner rejection,不是 process invariant; +- loaded replacement 先把 exact pending navigation 预提交为 committed,再执行 Page generation CAS;CAS + 失败会用私有 rollback token 恢复 previous committed request/trace 与 pending request/trace,history、recovery、 + initial-Document exit 都不会提前变更。成功后其余 owner mutation 保持同 turn、无 fallible participant; +- initial empty Document materialization 同样在 Page CAS 前预提交 lifecycle bit,CAS 失败精确恢复;failed + navigation discard 只在 generation 成功后清理 runtime/request/termination state; +- protocol migration adapter 把 commit stale 当作 candidate 失效:loaded candidate Page best-effort close,initial + materialization 返回既有 `Stale`,failed-navigation discard 保持原 Page;没有 retry、sleep 或吞掉 successor; +- `BrowserPageResidenceHandle` 和 `BrowserTargetHandle` 的共享状态已建立 Acquire/Release 配对。这里关闭的是 + generation/lifecycle capability 的跨线程可见性,不代表 renderer `Page`/V8 payload 可以跨线程移动;Phase 3 + 仍保持 current-thread Browser Host actor; +- 新增 regression 覆盖 request supersede、Page generation stale 与 initial materialization stale 三条 + prepare/commit 漂移路径,并验证 request/history/lifecycle rollback;聚焦 3/3(run + `3d839c5e-f514-466e-a38d-27536f805e1a`)。最终 `moli-core` + `moli-protocol` + `moli` + 包级 nextest 6332/6332、13 个既有 skip(post-rebase run + `64c3ad4c-59a2-4461-be32-26ef8a7832a4`),三个受影响 + crate all-target clippy `-D warnings`、workspace fmt/diff check 通过;release binary SHA-256 为 + `01feef3f36b4139b823b5662cfdad29df7031354393f550c9fa926dde77d555f`。显式清除大小写 proxy、设置 + `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认全组 210/210、WebDriver + Classic/BiDi/Selenium 148/148,二者 `ok=true` 且失败列表为空。分支已通过 `git pull -r origin master` + rebase 到 `92294b5ca4`,上述三包、clippy、release 和 smoke 均为 post-rebase 证据。本切片未重复 workspace + 全量 nextest。 + +2026-08-02 第二十二切片实现记录(Target termination exact commit safety): + +- `commit_target_termination` 不再假设 prepare permit 永远新鲜。commit 重新验证 exact Target instance,先用 + 私有 rollback token 预提交 `Crashed/Closed`,并将重复 Crash、Crash permit 撞上 `Recovering`、已关闭 Target + 映射为 `BrowserTargetTerminationCommitError::TargetNoLongerAcceptsTermination`;这些是 owner rejection,不是 + process invariant; +- Close 在推进 Page generation 前执行私有 `BrowserTargetRemovalTransaction`:从 active/background topology 与 + reverse owner map 暂存 exact record,但保持 Target handle live。Page CAS 失败时按原 residence/index 恢复 + topology 和此前的 `Live/Crashed/Recovering` termination state;成功后才 retire handle 并清理 engine、request、 + history 与 initial-Document state。因而 stale commit 不会退休 Target、丢 pending recovery、清 history/runtime, + 也不会留下 Page-generation/Target-topology 半提交; +- `BrowserPageResidenceRegistry::commit_termination` 复用 typed exact generation CAS,返回 + `BrowserPageResidenceRegistryError`,不再把 stale/unknown residence 压成 `Option`。Target owner lookup 对 retired + handle、缺失 context topology 和 reverse/topology divergence 也返回 typed `BrowserTargetRegistryError`; +- Protocol 仍在同一 actor turn 同步投影成功结果,但现在显式处理 Core commit rejection。per-session Inspector + crash-delivery 位移到 Core 成功之后写入,避免 owner rejection 后产生“Browser 未 crash、frontend 已记录 + crash”的假状态;本切片没有新增 await、retry、sleep 或 frontend authority; +- 新增 Core regression 覆盖 prepare 后 Page generation stale、第二个 Crash permit 撞上 Recovering、Recovering + Close 在 Page CAS 失败后恢复 active topology 与 exact recovery authorization。Core 聚焦 12/12(run + `f069f0b9-66e1-41ce-8f84-f634c02cf866`),Protocol 聚焦 5/5(run + `9b6c6741-a1a9-41e2-b0a1-e8c83291824b`);最终 `moli-core` + `moli-protocol` + + `moli` 包级 nextest 6335/6335、13 个既有 skip(run + `6201f7cf-a5e8-48d6-9c30-7195734d9465`),三个受影响 crate all-target clippy `-D warnings` 与 workspace + fmt/diff check 通过;release binary SHA-256 为 + `3f545e8aca9985f743430be5fa5611847e2c844f9fdbd493cb4a6c31ba4c53ed`。显式清除大小写 proxy、设置 + `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认全组 210/210、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` 148/148,两者 `ok=true` 且失败列表为空。本切片按约定没有重复 + workspace 全量 nextest。 + +2026-08-02 第二十三切片实现记录(Target engine adoption divergence safety): + +- Core `engine_registry.rs` 新增 `BrowserTargetEngineAdoptionError`,区分 unknown/mismatched Target、selected + engine owner mismatch 与 physical selected-Target projection mismatch。`adopt_registered_target_engine` + 在同一个 `&mut BrowserNavigationOwner` 调用中验证 exact Target membership/residence,并由 Core 自己决定 + `Selected` 或 `Retained`;Protocol 不再先读取 Core topology、在稍后的 registry mutation 中重新提交 residence; +- 对仍未携带 neutral Target key 的迁移入口,Protocol 只提交当前物理投影看到的 + `Option`。Core 从 selected BrowserContext + active Target registry 重新求出权威 owner, + 两者不一致时在任何 engine-registry mutation 前拒绝;没有 selected Target 时也必须同时满足 Core 与 physical + projection 都是 unbound,不能用 idle reset 把一个已绑定 engine 静默改成 startup engine; +- navigation commit、materialized completion、initial Document build、runtime direct load 与 idle-memory reset + 已显式消费 typed result。尚未发送 `Page.navigate` response 的路径返回 CDP `-32000`,已经提前响应的异步路径 + 记录带 error 的 warning,直接 runtime API 返回 `Err`,idle reset 返回 + `reason=engine-owner-diverged`;background promotion 的 physical BrowserContext 丢失也返回 typed string error。 + loaded Page 已经 commit 时,错误只替换尚未发送的 success response,不得提前返回并吞掉该 exact Document + 已产生的 DCL/load lifecycle tail;这些路径没有 retry、sleep、fallback owner 或 production panic; +- rejection 在候选 engine 写入前完成,回归验证旧 selected engine payload、owner key 与 retained registry 均不变。 + 这里保证的是 engine registry 的失败原子性,不宣称 physical `Page` 与 engine adoption 已成为跨 actor 的联合 + transaction;物理 Page strong lifetime 仍在 Protocol,随 Phase 6 owner payload migration 一并消除; +- Core/Protocol、lifecycle tail 与 close/recreate fixture 聚焦回归 `11/11`(nextest run + `53659f2e-f90a-450c-b6c4-72bc97b5c48e`)通过,覆盖 registry owner 分歧、physical/Core selected Target + 分歧、unknown exact Target、idle reset divergence、正常 selected/retained park/restore,以及同 targetId 的 + 新 Target instance 必须先重新注册再加载 engine;另有回归证明 error response 会压掉后续 success response, + 但不会压掉已 commit Document 的 DCL tail; +- strict validation 后首轮 Protocol 全包没有靠重跑掩盖:`3217 passed / 20 failed`(run + `966f08da-45df-4fd7-8b23-f51bb747f769`)。20 条失败的共同根因是旧测试先直接写 + `conn.browser_context = Some(...)` 制造 physical active Target,再调用 direct runtime load,让旧无身份 adoption + 为 Core 不存在的 Target 补绑 engine owner。fixture 已统一改走生产 `insert_browser_context` 或 exact + `register_active_target_projection`,没有给 product path 加 fallback、跳过 Core validation 或恢复 raw residence + 选择;修复后最终 Protocol 全包 `3237/3237`(run `d53c4e1f-84a5-41fc-9824-5bf568f7e838`)通过; +- rebase 前 `moli-core` + `moli-protocol` + `moli` 包级 nextest `6339/6339`、13 个既有 + skip(run `b6632901-1217-44ed-a7b8-65752de9f92c`)通过。切片完成后按约定执行 + `git pull -r origin master`,29 个分支提交无冲突重放到 `b1970bd4d4`;因最终树吸收了 DOM、样式、Network + 测试与锁文件变化,又在 post-rebase 树上执行 workspace 全量 nextest:`15645/15645`、17 个既有 skip(run + `984319f2-3ee0-4693-bd7e-56667f56b4f6`)。workspace all-target clippy `-D warnings`、workspace fmt/diff + check 与 release workspace build 均通过;最终 `target/release/moli` SHA-256 为 + `b99b2674f110af22c296a2b87f427a5e6ccd5007dc5cc7e70584585fcd112f40`。显式清除大小写全部 + proxy/no-proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认全组 `210/210`、 + WebDriver Classic/BiDi/Selenium `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空。 + +2026-08-02 第二十四切片实现记录(BrowserContext projection failure atomicity): + +- Protocol 新增 `BrowserContextProjectionError`,保留 Core `BrowserContextRegistryError`,并区分 physical context + count、selected identity、duplicate identity、Core membership 与 exact payload miss。registration、activation、 + selected/inactive removal 不再以 `Option`、`expect` 或 production assertion 压平这些结果;完整 physical + BrowserContext identity set 在任何 Core mutation 前验证; +- activation 和 removal 先从 `browser_context: Option<_>` / `inactive_browser_contexts: Vec<_>` 暂存 exact payload + 与原 index,再同步调用 Core transaction。Core 因 engine owner、selection、revision 或 topology projection + 拒绝时,Protocol 在返回 typed error 前恢复原 selected payload 与 vector index;Core 成功后才发布 matching + physical selection。这里没有 await、retry、sleep、fallback owner,也没有把 physical projection 提升为 + authority; +- `lifecycle.rs` 的 worker/target physical match 不再因 Core 未注册 context 而 panic。legacy bool activation API + 对 unknown context 保持 `false`,对 divergence 记录结构化 warning;create/dispose context、create target、BiDi + emulation/preload 与 Network lazy-default-context 等能返回 command error 的入口走 `try_*` typed API,并通过统一 + `DevToolsErrorKind::Internal` 映射传播。默认 Target bootstrap 的 context registration 若被拒绝会在 initial- + Document metadata 之前停止,不能把本切片 error 转移成后继 bootstrap panic; +- 回归覆盖 duplicate registration、Core-known/physical-missing activation、activation engine-owner rejection、 + selected removal with successor、最后一个 selected removal、inactive 中间 vector slot rollback,以及 physical + context match/Core miss。首个 corrupt fixture 仍试图通过第23切片已经禁止的 public engine adoption 注入错误 + owner,首轮 `3 passed / 2 failed`(run `356b719f-c676-4a11-bcb5-fc860f001151`);fixture 随后改为合法构造 + “physical Page owner 已出现、Core engine owner 尚未发布”的迁移窗口,没有放宽 product validation。最终专项 + rollback 回归 `7/7`(run `246849ad-91c7-4bba-863a-7f6830706c34`),typed create/dispose 调用链聚焦 `6/6` + (run `88a12609-91dd-4624-aac5-626a21bab41a`)通过; +- 最终 workspace 全量 nextest `15652/15652`、17 个既有 skip(run + `2746090c-b070-4fb1-9126-08df723d8a03`),workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过。固定 `target/release/moli` SHA-256 + `4de87f893753d47eb373fe04c70edf0de9480981f4271261a8b7e9c99c9a1544`,显式清除大小写全部 proxy/no-proxy + 变量、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver Classic/BiDi/Selenium + `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空。 + +2026-08-03 第二十五切片实现记录(Target projection failure atomicity): + +- Protocol 新增 `TargetProjectionError`,保留 Core `BrowserTargetRegistryError`,并区分全局/per-context Target + count、active residence、duplicate identity、Core context membership、exact Target handle、exact Page residence、 + physical Context/Target miss。完整 physical Target set 在任何 Core mutation 前 + 验证;release 路径不再以 `expect`/`assert!` 处理 registration、activation、rollback 或 topology divergence, + 完成后的镜像检查只保留 `debug_assert!` 诊断; +- registration/replacement 先从 selected `Option` 或 inactive `Vec` 暂存承载 Target 的 exact BrowserContext payload, + Core typed rejection 时恢复原 slot/index,Core 成功后才把新 handle 与 Page residence 投影进物理 payload。 + activation 还会在 Core call 前摘下 exact background Target slot 与 parked auxiliary state;拒绝时恢复原 index, + 成功后直接消费 staged payload 完成 active/background swap,不再在 authoritative commit 后进行第二次可失败查找。 + incomplete-popup rollback 同样先摘下 exact background payload,Core 拒绝时原 index 恢复;整个 transaction 无 + await、sleep、retry 或 fallback authority; +- 模块按责任拆成 `target_projection_error.rs`(错误词汇)、`target_topology_projection.rs`(只读 exact mirror + 校验)、`target_context_projection.rs`(physical BrowserContext slot staging)和 + `target_registry_projection.rs`(Core transaction coordinator);background Target slot 的可逆 staging 留在 + `page_state/parked.rs`,没有把 session/event orchestration 混入 owner transaction; +- `Target.createTarget` 可返回的入口把 projection rejection 映射为 Internal command error;renderer popup 及 + incomplete-popup cleanup 无同步 command response 可承载错误,因而记录包含 context/target/error 的结构化 warning + 并停止后继 projection。cleanup 在 owner rejection 前不再先拆 tab/page session;误传 active Target 时无损拒绝, + 不再触发 production panic。Target promotion 不再把 Core rejection 压成 `None`/`String`,而是传播 + `BrowserTargetPromotionError`;`Target.activateTarget` 将 owner/projection rejection 映射为 Internal,只有真正 + unknown Target 仍返回 NoSuchTarget,`Page.bringToFront` 的既有 string 边界只在最外层格式化 typed error; +- 回归覆盖正常 registration/activation、bootstrap active replacement、staged rollback、同 public id 错 Target + handle、错 Page residence、physical-only rogue Target,以及 duplicate Core registration rejection 后 exact physical + Context slot 恢复,并增加 inactive Context 中间 slot rollback。当前最终树的 projection + activation + popup + rollback 调用面聚焦 `27/27`(其中 projection 专项 `9/9`,run + `88d45b9e-1834-4f7c-853c-3fb38e7c1851`),最终 Protocol 全包 `3246/3246`、protocol all-target clippy + `-D warnings` 通过; +- 最终 workspace 全量 nextest `15654/15654`、17 个既有 skip,workspace all-target clippy `-D warnings`、fmt/diff + check 与 release workspace build 均通过。固定 `target/release/moli` SHA-256 + `140f8c246052ea501febabadeda5ea2bb4357574ef1de21a704b24c14b2555b3`,显式清除大小写全部 proxy/no-proxy + 变量、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver Classic/BiDi/Selenium + `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空。本切片提交后执行 + `git pull -r origin master`,`origin/master` 为 `b1970bd4d4` 且分支已是最新;上述 workspace、clippy、release + 和 smoke 均为 pull 后最终树证据。 + +2026-08-03 第二十六切片实现记录(Core Target registry lifecycle transaction): + +- `BrowserTargetRegistry` 不再在 validation 与不可回滚 commit 之间依赖“调用方不会插入可失败步骤”。 + BrowserContext registration/removal、background/active Target registration、bootstrap active replacement、 + background activation 与 Target removal 都先创建 registry-owned exact transaction;engine handoff 拒绝时恢复 + reverse owner、active/background slot 和原 vector index,成功后才消费 transaction。activation 不再使用会改变 + 其余 background 顺序的 `swap_remove`;三 Target 回归证明 rollback 与 commit 都保持原相对顺序; +- `BrowserTargetHandle` 增加只对 Core owner 可见的 activation/retirement reservation。reservation 期间 public + observer 仍分别看到 staged/live,commit 以 AcqRel CAS 发布 live/retired,rollback 恢复原状态;同一 public + target id 的其他 handle 仍不能取得该 reservation。missing reverse owner、reverse-owner context mismatch、 + non-staged/non-live handle 分别返回 `BrowserContextRegistryError` / `BrowserTargetRegistryError`,不再触发 + production panic; +- 模块按责任拆为 `target_registry.rs`(authority、projection validation 与 typed error)、 + `target_registry/transaction.rs`(可逆 topology/lifecycle transaction)和 `target_registry/tests.rs`(边界回归); + sibling `target_transaction.rs` 只编排 engine participant 与 registry transaction,没有把 session、event 或 + physical Protocol payload 放回 Core registry; +- 回归覆盖 commit 前 handle 可观察状态、non-staged/non-live rejection 无部分 mutation、missing/mismatched + reverse owner、context removal exact rollback、active registration/replacement rollback、三 background Target + activation 顺序,以及 engine participant 前 rejection 不污染 successor/engine。聚焦回归 `25/25`(run + `1ce626b1-1ebb-400e-bae6-287a1b5d02b9`)、Core 全包 `2558/2558`、13 个既有 skip(run + `67f6030d-e730-445f-9777-1f7a586df8c1`)和 Protocol 全包 `3246/3246`(run + `70a359e8-2934-45a8-9db5-b4c88762243f`)通过; +- 最终 workspace 全量 nextest `15665/15665`、17 个既有 skip(run + `574a57ca-007e-4f62-ace9-dd594769c5cb`),workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过。固定 `target/release/moli` SHA-256 + `c9167a1138795c85db73e44172ad770f7f12bd0f0779e027e4299f642fe72232`,显式清除大小写全部 + proxy/no-proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空; +- 本切片只关闭审计第 6 组。`page_registry.rs` 的 context/Target registration 原子性仍属于第 7 组,不能因 + Target topology 已可回滚而宣称完成;bootstrap initial-Document、physical Page residence projection 与 + same-document history rejection 也仍分别留在第 8、10、11 组。 + +2026-08-03 第二十七切片实现记录(Core Page registry registration transaction): + +- `BrowserPageResidenceRegistry` 的 entry 现在显式区分 `Staged` / `Live`。context/Target registration 先以 + exact owner + `BrowserPageResidenceHandle` 创建私有 transaction;staged entry 占用 identity、阻止重复注册, + 但 `capture_page_residence`、generation transition、termination、handle lookup 和 physical projection validation + 都不会把它当作 live authority。commit 发布 exact staged record,rollback 只删除 transaction 自己的实例; +- BrowserContext registration、background/active Target registration 与 bootstrap active replacement 都先暂存 + Target transaction,再暂存 Page transaction。Page staging 或 engine handoff 拒绝时按相反顺序恢复 Page 与 + Target;成功路径先安装 initial-Document/runtime sidecar、发布 Page record,最后以 Target handle AcqRel commit + 作为 capability lifecycle 发布点。这里没有 await、sleep、retry、fallback owner,也没有在 caller 重做 + registry insert; +- context bootstrap 还会把 projected Page capability 与全部已注册/暂存 entry 比较;同一 physical Page capability + 不能被两个 Context/Target 同时注册。错误继续通过 `BrowserPageResidenceRegistryError` 嵌入 + `BrowserContextRegistryError` / `BrowserTargetRegistryError`,并新增 staged-not-live 的精确诊断; +- 模块按责任拆为 `page_registry.rs`(record authority、live lookup、projection 与 generation transition)、 + `page_registry/transaction.rs`(registration staging/commit/rollback)和 `page_registry/tests.rs`(边界回归); + context/Target orchestrator 只组合 registry participant,没有把 Protocol payload 或 frontend session 引入 Core; +- 回归覆盖 staged entry 对 live lookup 不可见、exact rollback 后可重新注册、multi-slot context rollback、跨 Context + Page capability alias、outstanding Page transaction 与新 Target/active replacement 冲突,以及 joint Context + + Target rollback 不泄漏 handle/topology/engine authority。聚焦回归 `26/26`(run + `355b0be6-e5ef-4ba8-ac66-9574d781c104`)、Core 全包 `2565/2565`、13 个既有 skip(run + `e6fc2756-0724-49ff-81bb-5f7b90543161`)和 Protocol 全包 `3246/3246`(run + `ec7d6a6a-f8bf-49ba-a87d-e08f0c332ed2`)通过; +- 最终 workspace 全量 nextest `15672/15672`、17 个既有 skip(run + `9d87b807-14a6-4a17-9e19-e1e2f6375a52`),workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过。固定 `target/release/moli` SHA-256 + `26931867ab3b309a9a9afb255304e69574c241ac59b96f3823e6c306e37356f6`,显式清除大小写全部 + proxy/no-proxy 变量、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空; +- 本切片只关闭审计第 7 组。在第27切片结束时,Protocol bootstrap initial-Document 时序、physical Page + residence projection 和 same-document history rejection 仍分别属于第 8、10、11 组;其中第 8 组随后由 + 第28切片关闭,Phase 3 Exit gate 仍未通过。 + +2026-08-03 第二十八切片实现记录(bootstrap initial-Document registration transaction): + +- 新增独立的 `context_creation.rs`,用 protocol-neutral `BrowserContextRegistrationMetadata` 承载 exact active + Target 的 `BrowserTargetCreationMetadata`。它与 physical `BrowserTargetTopologyProjection` 分离:前者是 Browser + creation input,后者仍只是迁移期 topology/capability guard,没有把 Page payload、session 或 CDP event 塞进 + Core metadata; +- `BrowserNavigationOwner::register_browser_context_with_metadata` 在任何 registry mutation 前验证 metadata 必须有 + exact staged active Target;缺失 active Target 返回 + `ActiveTargetCreationMetadataWithoutActiveTarget`,不会创建 replacement engine、Context、Target、Page residence + 或 initial-Document sidecar。全部 fallible Target/Page/engine participant 接受后,Core 在发布 live Page/Target + capability 前安装 creation metadata;Target lifecycle 仍是最后发布点; +- default BrowserContext bootstrap 现在只提交一次带 metadata 的 Core registration。原来 + `try_insert_browser_context(...) -> register_target_initial_empty_document(...).expect(...)` 的两步 production 路径 + 已删除;`browser_initial_document.rs` 只保留 snapshot/lifecycle projection,以及显式 `#[cfg(test)]` 的 typed + fixture helper,不再含 production `expect`。普通无 initial metadata 的 BrowserContext registration 继续通过空 + envelope 走同一实现; +- `target_creation.rs` 统一拥有 creation metadata 的安装操作,ordinary background/active registration、bootstrap + active replacement 与 BrowserContext bootstrap 共用这一逻辑;context registry、target transaction 和 Protocol + projection 只做各自 participant 编排,没有复制 initial-Document registry mutation; +- 回归覆盖 metadata 与 default Context/Target topology 同时提交,以及 metadata 没有 active Target 时在任何 mutation + 前 typed rejection。最终 Core context-registry 聚焦 `9/9`(run + `f001de2b-d231-4504-9a7b-2b0c9da275ff`)、Protocol default-bootstrap 聚焦 `1/1`(run + `2bc7f5f3-122c-48ac-bb03-203d0b744ac7`)通过;workspace 全量 nextest `15675/15675`、17 个既有 skip(run + `1de7442a-9608-44c3-a8b5-1b8479af711c`),workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过。固定 `target/release/moli` SHA-256 + `a4e5237eb031de9ae15cb496ff223492696a988ba6c59a9f0c3332ad7adf2a7d`,显式删除大小写全部 proxy/no-proxy + 变量并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空; +- 本切片只关闭审计第 8 组。第 10 组 physical Page residence projection divergence 随后由第29切片关闭;剩余 + 第 11 组 same-document history rejection 仍未关闭,独立 `BrowserHostActor/BrowserHostHandle` 和 Phase 3 + Exit gate 也仍未完成。 + +2026-08-03 第二十九切片实现记录(physical Page residence projection transaction): + +- `page_residence_projection` 按责任拆为 orchestration、`transaction.rs`、typed error 和 tests。事务先验证完整 + Core/physical Target topology,再用 Core permit 冻结 exact Page instance/generation;随后从 selected/inactive + Protocol slot 暂存整个 BrowserContext。background 路径还会取出 exact Target 并保存原 vector index,active + 路径直接持有 staged context 内的 active participant; +- initial-Document materialization 在 Core mutation 前额外验证物理 slot 没有 loaded Page。wrong residence、missing + physical Target/Context 和 occupied initial slot 分别返回 `PageResidenceProjectionError` / wrapped + `TargetProjectionError`;candidate Page 只会在 physical context 已恢复后异步关闭; +- Core commit typed rejection 会在同 turn 恢复 exact BrowserContext slot 和 background index。Core 成功后不再按 + context/target id 重新 lookup,也不再存在 post-commit `expect`;transition kind、owner 和共享 residence 的检查只 + 保留为 `debug_assert!` 诊断。Page replacement 的同步字段投影结束、physical context 恢复后,才允许 await 旧 + Page disposal; +- failed-navigation production caller 不再丢弃 physical projection error,而是记录包含 session/request URL 的 + warning;initial build caller把 typed error 映射到既有 build failure surface。normal stale permit 仍是 + `Stale`/`None`,不会被错误升级为 process failure; +- 新增三条边界回归:wrong physical residence 在 Core generation 变化前 typed reject;occupied initial slot 在 + materialization flag/generation 变化前 reject 并恢复 context;background discard 保持 exact Target vector index、 + 只推进一次 generation 且 topology 继续一致。聚焦 transaction 回归 `3/3`(nextest run + `7db789c0-56af-4803-a891-13e35b5ca36c`),initial-build/failed-discard 既有回归 `7/7`(run + `6d130d8f-bad2-4abe-8843-e51e32fdeb1b`)通过; +- 最终 workspace 全量 nextest `15678/15678`、17 个既有 skip(run + `7e7b0be6-ac59-4023-ad70-375daf176ec7`),workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过。固定 `target/release/moli` SHA-256 + `8141ed7353376e5ced83fe1c14ab301063e2452b5f151f207184de2fc7c5f430`,显式删除大小写全部 + proxy/no-proxy 变量并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空; +- 切片完成后执行 `git pull -r origin master`,35 个分支提交重放到 `29a5e3228cdf2403201244efe944d1d66e5f3cbd`。 + rebase 同时带入 renderer form POST、dedicated worker replacement、Critical-CH 与 XHR network 回归。集成层保留 + master 的 request method/body/headers 与 worker retirement,并保留本分支的 Browser Owner queue、exact Page + residence 和 lifecycle observer;form POST 回归显式推进下一次独立 Browser Owner turn,不再假设 + `Runtime.evaluate` 的 frontend turn 同步执行 renderer navigation; +- 首次 clean workspace nextest 为 `15750/15753`、17 skipped(run + `94a84221-a243-43d5-b14d-74960ec76a9d`)。三个失败均由 rebase 带入的测试 fixture 直接写 + `CdpConnection.browser_context`、绕过 Core topology registration 导致;精确复跑 `0/3`(run + `8841c55f-9fb0-4d60-88fc-3b9e82d6ff10`)证明它们是确定性边界错误,不是 flaky。fixture 改用 + `insert_browser_context` 后连续 5 轮 `15/15`(run `3a41f154-0f95-4758-a0cc-4593847eaea8`);dedicated + worker fixture 同样从 Core 读取 authoritative residence。最终 workspace nextest `15753/15753`、17 skipped + (run `21aa1af6-c3a8-4b3f-b9de-e04233334e03`)通过; +- post-rebase workspace fmt/diff check、all-target clippy `-D warnings` 与 release workspace build 均通过。最终 + `target/release/moli` SHA-256 为 + `2ff43b6cd590cb4652e214b04a367e5c266243dcee6733f9223647c190a73590`;显式删除大小写全部 + proxy 变量并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空; +- 本切片只关闭审计第 10 组。第 11 组 same-document history rejection 是独立的 Core outcome 传播问题,留给下一 + 内聚切片;physical `Page` payload 仍暂存在 `CdpConnection`,因此这也不表示独立 + `BrowserHostActor/BrowserHostHandle` 或 Phase 3 Exit gate 已完成。 + +2026-08-03 第三十切片实现记录(same-document history exact commit): + +- `BrowserNavigationHistory::record_same_document_update` 不再用无原因的 bool 表示 traversal 失败,而是返回 + `BrowserSameDocumentHistoryUpdateError`。无 current entry、index overflow、越界 target 和 renderer/browser URL + drift 都在 cursor mutation 前形成 typed rejection;push/replace 继续由 Core 分配或保留 entry/document id; +- `BrowserNavigationOwner::commit_same_document_navigation_history` 接收 exact `PageResidenceIdentity`,先验证该 + generation/instance 仍是当前 Page,再提交 joint history。lazy seed 也成为事务 participant:若后续 traversal + 被拒绝,会恢复 seed 前完整 registry value,包括既有 pending replace/traverse;新建的空 registry entry 也不会 + 因失败泄漏; +- 模块边界保持分离:`history.rs` 只拥有值算法与 algorithm error,`history_registry.rs` 只拥有 exact Page gate、 + seed/lifetime transaction 和 Core commit error;Protocol `browser_navigation_history.rs` 只把 session route 解析为 + exact Page,并在 Core commit 前暂存可直接修改的 active/background physical Target participant。Core 成功后才 + 原地更新 URL/security origin;Core 或 topology 拒绝后没有 post-commit lookup、部分 physical mutation 或 + `Page.navigatedWithinDocument` event; +- Page producer 继续先丢弃 stale Page 和 pending cross-document navigation;通过这两个 gate 后若 history commit + 仍被拒绝,会记录含 exact context/target/generation 的诊断并停止 projection。旧的 mutation-first Target helper + 已删除,没有加入 sleep、retry、fallback owner 或 production panic; +- Core/Protocol 聚焦回归分别通过 `10/10`(nextest run + `00fa2d11-2896-44e7-b738-4a1868ab72be`)和 `13/13`(run + `f5b5b146-1f0e-44e7-b738-4a1868ab72be`);三个受影响 package 全量 `6404/6404`、13 个既有 skip(run + `29dcbb82-7612-4c37-b598-a003eb886c41`),workspace 全量 nextest `15759/15759`、17 个既有 skip(run + `af448d4f-9f89-4ab7-8d37-5fcb38661547`)通过; +- workspace all-target clippy `-D warnings`、fmt/diff check 与 release workspace build 均通过。固定 + `target/release/moli` SHA-256 + `689119a12c634c71a9d3d229102d29ffeb13e04a1cc2ca7d5f328a421cc53f24`,显式删除大小写全部 + proxy/no-proxy 变量并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空; +- 切片提交后执行 `git pull -r origin master`,37 个分支提交重放到 `b6759c82ff`。master 增量只有一笔 + Cloudflare 调研文档整理;旧/new 切片 tree 对 `moli-core`、`moli-protocol` 和本规划文件的逐路径 + diff 为空,没有改变源码、构建配置或上述 release binary 输入。因此没有机械重复全量测试和 smoke,post-pull + 只重新执行 workspace fmt/diff check;前述 nextest、clippy、release 与 smoke 仍对应 exact same build inputs; +- 本切片关闭审计第 11 组,因此 actor 前列出的 12 组既有 panic/divergence/ordering 问题均已收口。physical + `Page` payload 仍暂存在 `CdpConnection`,独立 `BrowserHostActor/BrowserHostHandle`、自主 host lifetime 与 + typed outcome/fact channel 尚未实现,所以 Phase 3 Exit gate 仍未通过;下一切片回到 actor extraction,不能继续 + 把 Protocol adapter 当作最终 Browser Owner residence。 + +2026-08-03 第三十一切片实现记录(Browser Host input actor/handle seam): + +- Core 新增职责分离的 `browser_host/actor.rs` 与 `browser_host/handle.rs`:`BrowserHostActor` 是唯一 mailbox + receiver 和 FIFO selection authority,`BrowserHostHandle` 是 cloneable producer endpoint。publish 只完成同步 + admission,不递归执行 navigation;actor 停止后返回携带 exact input kind 的 typed rejection; +- 删除临时 `BrowserOwnerQueue` 类型和 `CdpSchedulerEvent::BrowserOwnerInputPublished`。renderer prepared output + 直接经 `CdpConnection` 保存的 handle 进入 Core mailbox;未安装或已停止的 Host 分别返回 typed publication + error 并记录 rejection trace,不允许 Protocol 重建 fallback queue/event;成功 admission 后才记录既有 + `browser_action_published` trace。`browser_owner_input_published` runtime trace 同样移到 successful admission + 边界,但 producer 不再读取 actor queue length; +- application `CdpScheduler`、Protocol 测试 actor 都在 composition 时创建 actor/handle pair。CDP、BiDi 与 Classic + 的既有 selection rule 现在直接查询并 pop Core actor mailbox;producer turn 仍只发布,下一独立 owner turn 才 + 调用迁移期 physical adapter,因此没有把 renderer output callback 变成递归 navigation executor; +- actor 边界回归覆盖 FIFO/exact Page identity、一个 producer handle clone detach 不终止 Host、Host shutdown 的 + typed rejection、无 Host 时不回退 Protocol scheduler,以及 prepared/barrier navigation 只在下一 owner turn + 执行。handle clone 测试只冻结 endpoint ownership,不冒充“frontend stack 已可整体 drop”的 lifetime 证明; +- mailbox 暂时保持与旧 `VecDeque` 相同的无界 admission 语义,且 production producer 仍只有 owner thread 内的 + renderer-output ingestion。扩展 command/external producer 前必须在 Phase 4 冻结 capacity、coalescing、wake 和 + overload outcome;不能把当前同 turn producer wake 条件当作最终自主 actor loop; +- actor/owner 聚焦回归 `106/106`(nextest run + `4e02d8ee-863a-4bcd-8523-bdc15db4b88b`)通过。首次 workspace 全量为 `15760/15762`(run + `675d93e4-5d37-4133-aac8-98ff6573a5ed`),两个失败都位于未修改的 renderer-v8 Blob/OPFS 与 Worker + MessagePort 用例;两者随后共同精确复跑 `2/2`(run `044f7cca-772f-482e-8639-bbd7411fce72`)、20 轮 + stress `20/20`(每轮两条,run `fe45e11e-9600-4adb-b03a-397c729209c4`)和 renderer-v8 全包 + `6970/6970`(run `be926a5b-3f5d-4d7e-b9cf-25b3cb4c055a`)均通过;没有为此加 retry、sleep、timeout 或 + 修改 renderer 测试/产品代码。最终 workspace 原命令重跑 `15762/15762`、17 个既有 skip(run + `44404f08-b815-44d8-93cc-3a2b023d7f34`)通过; +- workspace all-target clippy `-D warnings`、fmt/diff check 和 release workspace build 通过。固定 + `target/release/moli` SHA-256 为 + `2e4f0ec8d1446facc615cc589d5094546d9f4ac79eb8d45dd298fc665d215e9d`;显式删除大小写全部 proxy/no-proxy + 变量并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`,两者 `ok=true` 且失败列表为空; +- 本切片只建立 input ownership/type seam。`BrowserHostActor` 仍作为字段位于 `CdpScheduler` application object, + physical BrowserContext/Target/Page 与 input execution adapter 仍在 `CdpConnection`;BiDi/Classic socket flush、 + frontend disconnect、独立 Host lifetime 和 typed outcome/fact journal 均未结构性解决,Phase 3 Exit gate 仍未 + 通过。 + +2026-08-03 第三十二切片实现记录(Browser Host turn capability seam): + +- Core 新增独立 `browser_host/turn.rs`。`BrowserHostTurn` 是 non-cloneable、`#[must_use]` 的 exact turn + capability;其构造权限限制在 Core Browser Host 模块,production 只由 actor 在 FIFO selection 后签发。 + executor 能看到 input kind 和 selection 后 ready-count snapshot,并只能消费已签发 turn,不能从 raw renderer + intent 伪造 selection; +- `BrowserHostActor::complete_next_turn` 同时拥有 mailbox selection 与 executor invocation;唯一 mutable actor borrow + 跨 executor future 保持,因而 application 不能在第一轮尚未完成时选出第二个 input。空 mailbox 不调用 executor, + 原公开 `pop_next_input` 已删除; +- Protocol 按责任拆成 `browser_owner_input.rs` publication adapter 与 `browser_host_turn_executor.rs` physical Page + executor。`CdpConnection` 实现不含 frontend/session/socket contract 的 Core executor trait,但不再暴露接收 raw + `BrowserOwnerInput` 的 inherent completion API;既有 `browser_owner_input_start` trace 保留在 exact executor + entry,记录 Core turn 的 kind 与 remaining snapshot; +- application `CdpScheduler`、Protocol `TestContext`、Page producer 与 runtime barrier fixture 都只请求 actor 完成 + 下一 turn,不再取得、缓存或转交 raw owner input。fixture 的 marker 只表达“这里轮到 Browser Host”,实际 exact + input 仍在执行时由 actor 从 mailbox 选择;只有 Core/Protocol capability 边界单元测试使用 recording executor + 消费 turn 后检查 payload identity,不参与 production scheduling; +- actor/owner 聚焦回归 `107/107`(nextest run + `821c0dd4-5ee9-4056-acaf-8bd2ce12020e`)通过;最终 workspace 全量 nextest `15763/15763`、17 个既有 + skip(run `a60b49d2-4f57-46df-bbfe-b3aad0822d73`),workspace all-target clippy `-D warnings`、fmt/diff + check 与 release workspace build 均通过。固定 `target/release/moli` SHA-256 + `091fab9ecf8dd56426033c51133d30668a6484d47a6a66c4c1ec942dcb9707f3`,显式清除大小写全部 + proxy/no-proxy 变量并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`;两套 JSON 汇总均为 `ok=true` 且失败列表为空; +- 切片提交后执行 `git pull -r origin master`,远端 `master` 与当前 merge-base 都是 + `b6759c82ff847aa5153e53d4189062ecaa645856`,Git 报告当前分支已是最新,没有 rebase、冲突或源码/build-input + 变化;因此前述 nextest、clippy、release 与 smoke 仍对应最终代码输入,没有机械重复执行; +- 这一步只关闭 turn authority 的类型边界。production loop 仍通过 `has_ready_input` polling 驱动 actor,没有把 + mailbox receiver/wake 纳入独立 application `select!`;actor 仍是 `CdpScheduler` 字段,physical executor 仍可能 + await 既有 Page command,typed outcome/fact projection、frontend disconnect/slow writer 隔离、独立 Host lifetime + 和 bounded admission 均未完成。因此不能把本切片称为自主 Browser Owner lane,也不能宣告 Phase 3 Exit gate + 通过。 + +2026-08-03 第三十三切片实现记录(application-owned Host receive/wake seam): + +- `BrowserHostActor` 新增 cancellation-safe `select_next_turn_when_ready`。pending Tokio mpsc receive 被 sibling + select branch 取消时不消费 input;receive 成功后在同一次 poll 内把 exact `BrowserHostTurn` 存回 actor,只向 + application 返回 typed `Selected/Closed`,不暴露 raw input 或 turn payload。selected slot 使重复 selection + 只能观察同一轮,不能在前一轮执行前 dequeue 第二轮; +- application 新增独立 `cdp_scheduler/owner_inputs.rs`,集中放置 Host actor、background completion/event 与 + renderer publication residences。`CdpScheduler` 不再含 `BrowserHostActor` 字段,只保留 physical/protocol + projection state;constructor 同时创建 actor/producer pair,再把 receiver residence交给 application inputs、 + 把 cloneable producer handle 安装到 `CdpConnection`; +- CDP owner、standalone BiDi、Classic-with-BiDi 和 bare Classic 四条 production blocking loop 都把 Host mailbox + receive 放进 biased `tokio::select!`。Host ready 后先退出 selection future,再调用 actor/executor 并完成输出 + routing,避免 sibling frontend readiness 取消已 dequeue 的 navigation。ready Host 也先于 renderer transport + terminal 结算;不再靠 loop 顶部 `has_ready_input` polling 才获得 wake; +- direct CDP/WebDriver command waits 的统一 `recv_interleaved_input` 新增 payload-free `BrowserHostTurn` marker, + 因而 lifecycle/navigation wait 阻塞在内层时也能推进 Host turn,不必等控制返回最外层 frontend loop。specialized + exact renderer cursor fence 仍只消费其因果所需的 concrete renderer stream,没有让无关 Host input 越过 fence; +- Core 回归覆盖 mailbox 从空闲状态被 publication 唤醒、selection 与 execution 分离、pending selection cancellation + 不吞下一 input,以及 sender shutdown 的 typed terminal;application 回归覆盖没有 protocol/background/renderer + 输入时 Host mailbox 独立唤醒 direct wait,并保持 protocol client-turn predecessor 不变; +- actor/owner 聚焦回归 `98/98`(nextest run `8e2e86df-1e41-4a88-b65e-686dffdc595d`)、受影响包级 + `3132/3132`(run `3ed66bfa-417b-4f09-b999-b5ace05ab99a`)和最终 workspace 全量 + `15766/15766`、17 个既有 skip(run `75447eef-81ab-49b3-ab00-ed47eb0147c7`)通过。4 条新增 + mailbox selection/cancellation 回归以 `--stress-count 20 --flaky-result fail` 重复 20 轮,共 80 次断言无失败 + (run `4aaae340-b8bb-40e1-ae5d-2518e3d2fab9`);workspace all-target clippy `-D warnings`、fmt/diff + check 和 release workspace build 均通过; +- 固定 `target/release/moli` SHA-256 为 + `2422de207654cd68ff46a5924ec370385c36fe4f401be50d3cbc16f96cce418e`。显式清除大小写全部 + proxy/no-proxy 变量并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`;两套 JSON 汇总均为 `ok=true` 且失败列表为空; +- 切片提交后执行 `git pull -r origin master`,远端 `master` 与当前 merge-base 都是 + `b6759c82ff847aa5153e53d4189062ecaa645856`,Git 报告当前分支已是最新,没有 rebase、冲突或源码/build-input + 变化;因此前述 nextest、clippy、release 与 smoke 仍对应最终代码输入,没有机械重复执行; +- 这一步完成的是 receive/wake residence,而不是独立执行 task。actor 仍与各 frontend application owner loop 一起 + 创建和销毁,selected turn 仍借用 `CdpConnection` 的长 physical Page executor,output flush 也仍在同一 loop; + slow writer/disconnect 隔离、短 Browser commit、typed outcome/fact channel 和 bounded admission 尚未完成,不能 + 宣告 Phase 3 Exit gate 通过。 + +2026-08-03 第三十四切片实现记录(short Host turn 与 participant completion residence): + +- Core `BrowserHostTurnExecutor` 不再返回一个由 actor `await` 的 future,而是同步消费 exact + `BrowserHostTurn` 并立即返回 dispatch。`BrowserHostActor::complete_next_turn` 因而只在一个无 suspension 的 + run-to-completion turn 内持有 actor 与 executor mutable authority;网络、renderer 或 protocol participant wait + 不能再藏在这个 trait boundary 后面; +- Protocol 把 renderer top-level navigation 拆成同步 start 与后续 completion:同步 start 完成 stale Page + validation、navigation admission 和可立即完成的 background registration;需要等待时返回不可 clone、 + `#[must_use]` 的 `PendingBrowserHostTurn`。该 capability 保留原 `CommandOwnerScope`、exact Page/renderer command + token 和 command context;`wait(self)` 消耗它并产生 move-owned `CompletedBrowserHostTurn`,late completion + 继续由原有 exact generation/token 校验拒绝,不能扫描 current Target 来修改 replacement Page; +- application `cdp_scheduler/owner_inputs.rs` 新增独立 `BrowserHostExecutionLane`,集中拥有 Core actor 和 + participant completion channel。start dispatch 若含 pending,lane 在 local executor 上登记 wait 并立即把 start + outcome 交给 scheduler;完成值以后作为 `BrowserHostExecutionWake::ParticipantCompleted` 回到同一输入选择边界。 + completion wake 优先于新的 Host mailbox selection,且两者由 lane 内一次 `select!` 完成,避免对同一 lane 的 + 双重 mutable borrow 或应用层取得 raw input; +- CDP actor、standalone BiDi、Classic-with-BiDi、bare Classic 与 direct lifecycle/navigation wait 全部消费同一 + lane wake contract。ready mailbox input 只同步 start;pending wait 期间 actor 已释放,后续 owner input 可继续 + dequeue/start。Protocol output 仍由各 frontend 现有 projector 发送,没有新增 sleep、retry、`yield_now` 或把 + 403/DCL 当成伪 lifecycle 的规则; +- 回归在 direct physical adapter 上先启动一个真实 pending top-level load,在尚未等待其 completion 时发布并选择 + 第二个 stale Page input,证明第一个 participant wait 不再持有 Browser Host actor;随后完成原 exact load,并 + 继续验证 `Page.frameStartedNavigating` 的 target/loader/url 和 automation event。Core 既有 FIFO、selection + cancellation 与 shutdown regressions 继续覆盖 turn capability; +- Core actor 聚焦回归 `7/7`(nextest run `3bb5c1e5-6e5d-4c75-8503-aad07bb39351`)、跨 + Protocol/application/WebSocket 聚焦回归 `6/6`(run `1265dec3-330d-4e95-811e-4c45418df1aa`)和受影响三包 + `6411/6411`、13 个既有 skip(run `341d9299-f169-4b92-bc72-f160429161ba`)通过。新增真实 pending/第二输入 + 回归以 `--stress-count 20 --flaky-result fail` 重复 20 轮全部通过(run + `6c06286d-cb99-4dcc-873b-f09a7fc04394`);最终 boxing/layout 修正后的 workspace 全量为 + `15766/15766`、17 个既有 skip(run `3df25789-b242-42e0-ac88-6a86562a2805`);workspace all-target clippy + `-D warnings`、fmt/diff check 和 release workspace build 均通过; +- 固定 `target/release/moli` SHA-256 为 + `6ae535aec7f0b57ee3d7344bbccf2c5851ead4ea06dad6257e618169ac688c7e`。显式清除大小写全部 + proxy/no-proxy 变量并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium `--continue-on-failure` `148/148`;两套 JSON 汇总均为 `ok=true` 且失败列表为空; +- 本切片只建立 **typed participant completion residence**,不是最终 Browser Fact channel。同步 start 已经是短 + turn,但 `CdpConnection::complete_browser_host_turn` 对 Fetch continue、materialized Page commit 等迁移期路径 + 仍可能异步执行;`BrowserHostTurnDispatch` 的即时 projection 也仍携带 `CdpTurnOutcome`。下一切片应把 completion + application 继续拆成短 Core commit / 再登记 participant 的状态机,并把 navigation result/fact 从 CDP event + shape 中分离;随后才能把 Host task/lifetime 与 frontend writer/teardown 分开。Phase 3 Exit gate 仍未通过。 + +2026-08-03 第三十五切片实现记录(response-ready navigation completion 状态机): + +- 新增独立 `domains/page/navigation_completion.rs`,不再把 continuation phase 堆进 4k 行的 navigation start + handler。一个 direct navigation 现在显式经历 `Load` → `ConfigurePreparedDocument` → + `CommitPreparedDocument`;每个 pending phase move-own exact `BrowserDocumentNavigation`、冻结的 + `NavigationDispatchState`、prepared Page/commit permit、renderer attachment transaction 与 engine,不跨 wait + 借用 `CdpConnection`,也不从 current Target 重新推断 owner; +- network completion 的 apply turn 只做 current-token check、materialization 和 commit configuration snapshot, + 随即登记 configuration participant;configuration completion 只做 exact renderer candidate transaction,再登记 + renderer Document commit participant。commit failure 仍以原 transaction rollback,stale completion 仍由 exact + token/attachment/Page generation 拒绝,没有新增 sleep、retry、timeout 放宽或 DCL 特判; +- direct `PendingBrowserHostTurn` 与 production background navigation 共用同一状态机。background 路径新增 opaque + `BackgroundNavigationParticipantCompletion`,application 只能经既有 completion channel 路由,不能取得 Page、 + renderer 或 navigation mutable capability;原 command/session route、`CommandDispatchContext`、requested URL 和 + `BackgroundNavigationGateKey` 跨 phase 原样保留; +- background drain 返回 typed `BackgroundNavigationTurnDisposition`。`ParticipantPending` 不结算 navigation gate; + 只有最终 success/error 的 `Terminal` 才让 scheduler 调用 `note_navigation_completion_drained`。因此 configuration + 与 renderer commit 两个独立 wake 不会被误当成导航结束,也不会提前开放依赖该 exact loader/request 的后续 + protocol residence;main-document body completion 仍是独立非 gate input; +- 独立 transport 暴露了一个必须显式维持的顺序:prepared renderer 可以先发布 + `MainDocumentCommit`,但 Protocol 不能在 Browser commit 前拿旧 frame/loader 状态投影这条事实,否则会把 + `frameNavigated`/execution-context init 当成 stale 丢掉。application 新增 + `NavigationRendererPublicationBuffer`,只从首次包含 `MainDocumentCommit` 的 exact renderer stream 开始保留 raw + publication;stream control 与其他 stream(包括 popup)仍可通过,普通 command fence 也可跳过被保留的导航 + stream,只有该 navigation terminal cursor 才释放自己的 stream。这里保留的是 commit publication,不是延后或 + 合并 DCL;403 Document 与 successor Document 的 DCL 仍各自属于 exact Document; +- `pending_top_level_location_navigation_releases_the_browser_host_actor` 现在进一步断言 load completion 会登记 + configuration wait、configuration completion 会再登记 renderer commit wait;新增 production-shaped + `background_navigation_commit_participants_resume_as_separate_inputs` 直接验证 lifecycle → participant → participant + 三次 input 和同一 gate key,并验证前两次 disposition 非 terminal、最后一次才 terminal。最初把 navigation gate + 解释成“冻结全部 renderer raw input”后,Protocol 全包第一次运行通过 `3279/3280`,但 + `window_open_named_target_reuses_existing_popup_target` 持续不结束(run + `268c3f3c-d8b9-470f-8895-83489406f17e`,手动终止);原因是旧 stream command fence 不断取到并重新排队新 + popup 的 `Opened` control。改为上述 per-stream invariant 后,该用例单独在 `0.123s` 通过(run + `9988120d-16c1-409e-aa54-ddc4770737cd`),没有增加 sleep/retry 或放宽 timeout; +- 最终 8 条聚焦边界/回归用例 `8/8`(nextest run + `ef5c9b62-f038-44d3-a1ce-d3de2a504902`);三条 protocol ordering/race 回归以 + `--stress-count 20 --flaky-result fail` 共执行 60 次无失败(run + `9ed0d514-ee79-400d-b933-1e5f6a6a168b`);`moli-protocol` 全包 `3280/3280`(run + `a15d0d69-ddab-4014-a1cb-d70715e3f8a4`);最终 workspace 全量为 `15770/15770`、17 个既有 skip(run + `c4dcc0c0-9995-4125-a0f3-db0a1be30515`),workspace all-target clippy `-D warnings` 与 fmt/diff check + 通过; +- release workspace build 通过,固定 `target/release/moli` SHA-256 为 + `02422d5d4e0cbf67251c1c9d7e2ced7e431dda061725167d1ecd77bd110e557f`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与既有 no-proxy 变量,再设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 + `210/210`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`;两套 JSON 均为 + `ok=true` 且失败列表为空; +- 本切片仍不是完整 short completion apply。Fetch request/auth/response continue 仍可能在持有 `CdpConnection` 时 + 进行 network/prepare 工作;renderer commit 完成后的 loaded Page runtime restore、replacement/worker teardown、 + lifecycle activity 和 Inspector replay 也仍是 async physical apply。`CdpTurnOutcome` 仍是即时输出类型,Host 与 + background participant channel 也仍依附 frontend owner lifetime。下一切片应优先把这些 remaining suspension + points 逐个变成 exact participant 或短 commit,再建立 protocol-neutral outcome/fact channel;Phase 3 Exit gate + 仍未通过。 + +2026-08-03 第三十六切片实现记录(navigation-tail Inspector replay participant): + +- `conn/runtime_eval.rs` 新增 `PendingRendererCallReplayBatch` / `CompletedRendererCallReplayBatch`。一次 pending + capability move-own 当前 `PendingRuntimeProtocolMessageDispatch`、exact renderer correlation/response lease、目标 + attachment 和尚未启动的 FIFO replay;`wait(self)` 不接收、保存或借用 `CdpConnection`。completion apply 消费 + exact Page completion、恢复 frontend command id、投影本轮 output,然后才同步尝试启动下一 replay。无效 JSON、 + stale route/attachment 和 Page miss 仍沿用原 terminal error 语义,不新增 retry、sleep 或 current-Target fallback; +- 新增独立 `domains/page/navigation_tail.rs`,集中拥有 old-Document renderer finish、released output routing、 + non-replayable call termination、replay start/apply 与 exact loader clear。`navigation_completion.rs` 增加 + `ReplayRendererCalls` phase:response-ready commit completion 若启动 replay 就再次返回 `Pending`,direct Host 与 + background path 仍走同一 opaque participant channel;每个 replay completion 若还有 successor replay 继续返回 + `ParticipantPending`,只有整个 batch 终止才清理 matching loader 并返回 `Terminal`; +- `navigation.rs` 只保留 generic materialized path 的 compatibility drain。Fetch continuation、termination 以及尚未 + 进入 response-ready phase state machine 的 materialized outcome 仍会经这个 wrapper inline 等完 replay;它们是 + 后续切片的明确 suspension point,不能用本切片宣称所有 navigation tail 已脱离 `CdpConnection`; +- 允许 replay wait 期间启动 successor Browser input 后,旧的“tail 结束再 adoption engine”会暴露新的跨代写入: + 当前 engine registry 只用 `{browserContext,target}` key,迟到的 A replay tail 可能覆盖已经提交的 B engine。 + 因此 `NavigationEngine` 改为在 A 的 exact Document commit apply turn 内、发布 replay participant 前完成 adoption; + replay completion 不再携带或写 engine。loader clear 继续用 exact loader compare,B 已开始时 A tail 不能清掉 B; +- runtime 边界回归 `renderer_replay_batch_waits_one_move_owned_page_dispatch_at_a_time` 用两个真实 + `Console.clearMessages` Inspector dispatch 验证 FIFO start/apply、旧 response lease retirement、原 frontend + receiver completion,以及 participant 存活期间 connection 可独立访问。production-shaped + `background_navigation_commit_participants_resume_as_separate_inputs` 现在人为保留一个 replayable old-attachment + call,验证 lifecycle → configuration → renderer commit → Inspector replay 四段 wake 使用同一 gate;commit apply + 为 non-terminal,只有 replay apply 才 terminal。两项用例聚焦 `2/2`(run + `2f8ba619-81ef-42f6-96ae-d43a652e65d8`),`--stress-count 20 --flaky-result fail` 20/20 轮通过(run + `e90177cc-a1cd-4a16-9e58-4db8341714ab`),`moli-protocol` 全包 `3281/3281`(run + `3cd750d5-11d3-46b4-adcb-2328f514c7ca`); +- 最终 workspace 全量为 `15771/15771`、17 个既有 skip(run + `4c944313-19ee-4f74-82c7-0dda4ceee28f`);workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过。固定 `target/release/moli` SHA-256 为 + `008c10c89bd7e703a8a8ce8a8efcc4a1a8bb41d83c7f1177bc3d2d6c59ad9d9d`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 + `210/210`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套 JSON 均为 + `ok=true` 且失败列表为空; +- replay completion apply 内的 runtime context/node remote-object normalization 仍可能 `await`;loaded Page runtime + restore、physical replacement/old Page close、worker teardown、Fetch continue、generic materialized drain、即时 + `CdpTurnOutcome` projection 与 Host/frontend lifetime 也尚未拆出。DCL/load 仍保持 exact-Document 事实,本切片 + 只改变高层 navigation completion 的 participant ownership 与 terminal gate 条件;Phase 3 Exit gate 仍未通过。 + +2026-08-03 第三十七切片实现记录(generic materialized navigation-tail participant): + +- `navigation.rs` 把 materialized outcome 的 body apply 与 renderer tail 明确拆开: + `apply_materialized_navigation_into_buffer_async` 只处理 `ResponseCommitReady` / `Loaded` / `Download` / `Failed` + 的物理 outcome 和协议事实,不再隐式等待 Inspector replay;原 + `complete_materialized_navigation_into_buffer_async` 仅作为尚未迁移调用方的 compatibility drain; +- `navigation_completion.rs` 的非 `ResponseCommitReady` 分支不再回到 `CdpConnection` legacy drain,而是在 body apply + 后直接调用同一个 `finish_or_suspend_navigation_tail`。因此 direct command 和 production background lifecycle + completion 都会把每个 replay 返回为 move-owned `PendingNavigateCommand::ReplayRendererCalls`;background gate 在 + generic `Loaded` commit 后仍为 non-terminal,只有 exact replay batch 结束才清理 matching loader 并 terminal; +- engine adoption 与第36切片保持同一不变量:materialized body 得到 exact committed + `BrowserPageOwnerKey` 后,在释放第一个 replay participant 前完成 target-keyed engine adoption。迟到的旧 replay + completion 既不携带 engine,也只能按 exact loader 清理,因此不能覆盖 successor Target engine 或清除 successor + navigation gate; +- production-shaped 回归 `background_generic_loaded_navigation_tail_resumes_as_participant_input` 先让真实 data URL + response-ready Page 完成 renderer commit,随后用同一个 production payload 进入 legacy `Loaded` outcome,保留一个 + 真实 `Console.clearMessages` old-attachment replay。用例验证 lifecycle apply 为 non-terminal、旧 response lease 已 + retirement、successor participant 保留同一 gate,且只有 replay apply terminal;没有构造假的 `Page` payload, + 也没有加入 sleep/retry; +- 新旧两条 background participant 回归聚焦 `2/2`(run + `10156b71-e062-48a9-b4f9-4e26b0f14756`),以 `--stress-count 20 --flaky-result fail` 共执行 40 次无失败(run + `190e8633-cdfd-4375-b691-43f59ccc1528`);`moli-protocol` 全包 `3282/3282`(run + `80dce44d-e616-49ae-bb66-7bb1a66fa54e`); +- 最终 workspace 全量为 `15772/15772`、17 个既有 skip(run + `2539124d-b9f1-4e33-9652-089111ea64bd`);workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过。固定 `target/release/moli` SHA-256 为 + `b282bda12944e441e654f37c67193b78c9943715e5289eb2a2ba158f90f673f0`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 + `210/210`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套 JSON 均为 + `ok=true` 且失败列表为空; +- 本切片只关闭 Browser Host direct/background generic outcome 的 tail wait。Fetch continue 和 Target termination + 仍通过 compatibility wrapper inline drain;generic `Loaded` body 内的 runtime restore、physical Page replacement、 + old Page/worker teardown 仍可能跨 `await` 借用 `CdpConnection`,replay completion apply 的 output normalization 也 + 尚未拆开。`CdpTurnOutcome`、typed Browser fact/outcome channel 与 Host/frontend lifetime 均未改变,Phase 3 Exit + gate 仍未通过。 + +2026-08-03 第三十八切片实现记录(generic Loaded Page restore participant): + +- 新增独立 `domains/page/loaded_page_restore.rs` 模块,集中拥有 loaded Page 的 Runtime/V8 session restore、Fetch + subresource interception restore、permission override restore,以及随后安装 Page 所需的 exact renderer attachment + capability。`TargetLoadedNavigationCommitState` 只在 start apply turn 从 session owner 冻结为值快照; + `PendingLoadedPageRestore` move-own 新 `Page`、该快照、permission 列表、candidate/committed attachment 和 trace + identity。其 `wait(self)` 只向自己拥有的 Page 发 renderer command,不接收、保存或借用 `CdpConnection`、 + `CommandOutputBuffer` 或 `CommandDispatchContext`; +- `navigation_commit.rs` 将 loaded commit 明确建模为 `start → pending/ready → complete`。外层 + `PendingLoadedNavigationCommit` 同时保留 resource、creation lifecycle、progress gate、inner engine 和 activity + continuation,但等待期间不执行这些 projection。generic/legacy `Loaded` 返回 pending;正常 + `ResponseCommitReady` 的 renderer configuration 已由 prepared-Document participant 提交,因此返回 ready 并在同一 + apply turn 继续,不人为增加一个 scheduler wake。缺失 commit-state、restore error 和 stale attachment 仍保持原 + success/error/drop 语义;Runtime output predecessor 只在 completion apply 时并入 command context,且即使后续 + Fetch/permission restore 失败也随 failure completion 保留,不丢失原错误响应前的 renderer ordering fence; +- `navigation_completion.rs` 新增 `RestoreLoadedPage` pending/completed phase。generic `Loaded` 的 lifecycle apply 只 + 注册 opaque Page restore participant,此时旧 renderer attachment 和旧 Page 仍是 current;restore completion + 重新进入 Browser Host owner turn 后才允许 physical replacement、旧 response lease retirement、worker cleanup、 + lifecycle commit 与后续 Inspector replay。restore 和 replay 各自保持同一个 exact + `BackgroundNavigationGateKey`,前者 completion 若发布 replay 仍为 `ParticipantPending`,只有最终 tail 才 + `Terminal`; +- stale ordering 继续由 exact navigation token/attachment capability 决定,不靠 current Target 猜测。新增 + `superseded_generic_loaded_restore_cannot_install_stale_page`:A 的 Page 被 restore participant 独占期间启动 B,A + completion apply 必须被 replacement transaction 拒绝,不能替换旧 attachment、不能写 B 的 engine,也不能要求 + frontend 用下一命令 pump。`background_generic_loaded_navigation_tail_resumes_as_participant_input` 进一步验证 + lifecycle apply 后 attachment 仍为旧实例,restore apply 后才替换并发布真实 old-attachment + `Console.clearMessages` replay。旧实现的红测 run `b28e99dd-a407-463c-9da8-c21deb29ca24` 精确失败为 lifecycle + apply 已把 attachment 从 `1` 提前换成 `2`;没有通过 sleep、retry、放宽 timeout 或把 DCL 判假来改变结果; +- compatibility 边界保持显式:`navigation.rs::commit_loaded_navigation_async` 对 Fetch continue、Target termination + 和其他尚未进入 Browser Host phase state machine 的调用方仍会 inline 等待同一个 move-owned restore;这避免双 + execution authority,但还不是这些调用方的最终 queue cutover。response-ready fast path 没有新增 participant, + 既有 `background_navigation_commit_participants_resume_as_separate_inputs` 继续保持 + lifecycle → configuration → renderer commit → replay 的原 wake 数; +- 最终三条 participant/ordering 回归以 `--stress-count 20 --flaky-result fail` 共执行 60 次无失败(run + `3e676216-6460-492c-9fd4-06152c45c73d`);`moli-protocol` 全包 `3283/3283`(run + `212587d0-1ca9-445c-90ff-1ed968d4ca8c`);workspace 全量 `15773/15773`、17 个既有 skip(run + `a9d54cbb-ef41-4355-a150-6cbede507140`)。workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过;固定 `target/release/moli` SHA-256 为 + `b8fdc7eff8f059a58a9e8f5430c3264e9612d6c117e0a76fe446ef4263487e83`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、 + WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套 JSON 均为 `ok=true` 且失败 + 列表为空; +- 本切片只拆出 author-JS 后 generic Page configuration restore 的等待 ownership。restore completion apply 内的 + physical replacement/旧 Page close、dedicated-worker retirement、BiDi preload listener startup,以及外层 loaded + lifecycle activity 仍可能跨 `await` 借用 `CdpConnection`;Fetch/termination compatibility drain、replay output + normalization、protocol-neutral `CdpTurnOutcome`/fact channel 和 Host/frontend lifetime 也尚未拆开。Phase 3 Exit + gate 仍未通过,下一切片应从 physical replacement disposal 或 Fetch/termination compatibility drain 中选择一个 + 单一 authority 边界继续切,不把两类 mutation 混进同一个 future。 + +2026-08-03 第三十九切片实现记录(physical Page replacement disposal participant): + +- `conn/browser_page_replacement.rs` 将 loaded Page replacement 拆成同步 + `start_loaded_page_replacement_for_session_owner` 与 move-owned `PendingLoadedPageReplacement`。start turn 仍连续完成 + exact Browser owner/导航许可检查、renderer attachment preparation、Core request/Page generation/history commit 和 + physical Page-slot projection;这段保持无 `await`,因此不会出现 Core 已指向 successor、physical slot 仍指向旧 + Page 的可观察窗口。只有已经退出 physical residence 的旧 Page,或被 stale transaction 拒绝的新候选 Page,才与 + terminal `Committed/Rejected/Failed` outcome 一起移入 disposal capability;`wait(self)` 只执行其自有 + `Page::close_async()`,不接收、保存或借用 `CdpConnection`,也没有 session/Target current fallback; +- 新增独立 `domains/page/loaded_page_install.rs`,没有继续扩张第38切片的 restore 模块。`loaded_page_restore.rs` 只拥有 + Runtime/Fetch/permission configuration wait;install 模块拥有同步 Browser/physical replacement、Page disposal 以及 + 尚未迁出的 dedicated-worker/BiDi compatibility continuation。`navigation_commit.rs` 再把 resource/lifecycle/activity + continuation 与 `PendingLoadedPageInstall` 组合成 `PendingLoadedNavigationPageDisposal`,等待值仍不持有 frontend + output/context 或 connection mutable capability; +- `navigation_completion.rs` 增加 `DisposeReplacedPage` pending/completed phase。正常 response-ready 路径现在是 + lifecycle → configuration → renderer commit/install → retired Page disposal → Inspector replay;generic `Loaded` 是 + lifecycle → Page restore → install/disposal → Inspector replay。每一段沿用同一个 exact + `BackgroundNavigationGateKey`,install/disposal completion 仍为 `ParticipantPending`,只有 replay 或 rejected disposal + 后没有其他 tail 时才 `Terminal`。这不是 sleep/drain/retry:Page close acknowledgement 成为 Browser Host 可登记、 + frontend 不必用下一命令泵动的普通 participant input; +- engine residence 必须与 exact replacement 同 turn 提交。inner loaded engine 与 materialized outer engine 都在 + replacement 已得到 `BrowserPageOwnerKey` 后、发布 disposal participant 前 adoption;成功路径从 continuation 中取走 + engine,late disposal/replay completion 不再携带或回写它。测试给 lifecycle engine 注入共享同一 renderer owner、 + 但带唯一 user-agent marker 的 payload,并在 disposal 尚未完成时直接验证 marker 已成为 active engine;因此慢旧 + Page close 不能让迟到 A engine 覆盖已经提交的 successor engine; +- lower-boundary 回归 `adapter_commits_request_history_and_exactly_one_page_generation` 先安装真实 predecessor Page,验证 + start 返回 disposal participant 时 Core generation、request commit 和 physical slot 已同步完成,再独立等待旧 Page + 关闭。production-shaped response-ready/generic 回归分别验证新增 disposal wake、旧 attachment response lease 在 + disposal 前已 retirement、disposal 后才启动真实 `Console.clearMessages` replay; + `superseded_generic_loaded_restore_cannot_install_stale_page` 现在验证 A restore 被 B supersede 后先发布 rejected Page + disposal,late disposal completion 不能更换 renderer attachment,且无需 replay 即 terminal。旧 wake 断言在新实现 + 上稳定 `0/3`,失败均精确落在“下一个 participant 已不是 replay/terminal”的旧假设(nextest run + `b8bb0f79-2ba8-4ae5-bfed-9ed150e681ae`);最终四条聚焦回归 `4/4`(run + `d3e90415-4084-4805-90dc-812491cf635b`),以 `--stress-count 20 --flaky-result fail` 共执行 80 次无失败(run + `0bfb4edb-1102-47e2-ae3f-b2cfe0e215f5`),`moli-protocol` 全包 `3283/3283`(run + `00c3215c-b1a2-400f-a072-b7730e23be34`); +- clippy 要求将体积较大的 `BrowserPageReplacement` outcome 装箱后,最终四条聚焦回归再次 `4/4`(run + `83afca1e-9d3d-46d1-b8bf-89b611795475`);最终源码的 workspace 全量为 `15773/15773`、17 个既有 skip(run + `fca92778-5c67-43bd-8ea7-bf9846110f17`)。workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过;固定 `target/release/moli` SHA-256 为 + `04b843f649e2b90132a19e1355664cea3370a5c709873720e9cd2fbb7e80be64`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、 + WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`;两套 JSON 均为 `ok=true` 且失败 + 列表为空; +- 本切片仍没有把 disposal 后的 dedicated-worker retirement、BiDi preload listener startup 或 loaded + lifecycle/activity projection 变成独立 participant;这些 compatibility continuation 仍可能跨 `await` 借用 + `CdpConnection`,必须按 exact current Page/fact publication 语义另切,不能塞回 Page close wait。Fetch/termination + compatibility drain、replay output normalization、protocol-neutral `CdpTurnOutcome`/fact channel 与 Host/frontend + lifetime 也未改变。Phase 3 Exit gate 仍未通过;下一切片优先从 worker/BiDi post-install continuation 或 + Fetch/termination drain 中选一个单一边界。 + +2026-08-03 第三十九切片后 master 集成与 owner-progress 响应屏障修复记录: + +- 本轮第一次 rebase 到 master `a3c9f06e2c` 后保留了上游新增的 network observation journal:Browser Core 的 + navigation fetch 继续返回 `NetworkFetchResult`,protocol 在解包 response 时同时取得 + journal,不退化成只返回 body/head 的旧接口。direct `Target.createTarget(url)` 的首条 history 也不再从 + protocol 临时构造 initial-empty seed;调用方只通过 exact `BrowserPageOwnerKey` 标记 + `ReplaceInitialEmptyDocument`,authoritative initial-Document metadata 仍由 Core target registry 提供。受 master + 新增 Core topology 约束影响的测试 fixture 统一通过 production `insert_browser_context` / navigation adapter 注册 + context、Target、Page residence,没有重新开放 physical-only registry 作为 authority; +- proxy-cleared CDP tracing 复现出一个独立于 navigation participant 的循环等待: + + ```text + Runtime.evaluate(awaitPromise) + -> 页面同步创建 blob: DedicatedWorker + -> auto-attach 要求 Worker pause-on-start + -> Worker 等 Runtime.runIfWaitingForDebugger + -> frontend 必须先收到 attachedToTarget 才能发 resume + -> 旧 output slot 又把 DedicatedWorker lifecycle 扣到 evaluate response 之后 + ``` + + LLDB 和临时 trace 共同确认 Worker 已创建 isolate、进入 pre-bootstrap debugger pause,Tokio owner loop 在正常 + park;不是 V8 自旋或 CPU profiler deadlock。诊断探针在修复前已全部删除。根因是 + `DedicatedWorkerTargetLifecycle` 虽然已经分类为 `OwnerAction`,却仍被错误地放在 `AfterResponse`;而当前 command + 正在等待只有该 owner action 才能解锁的 Worker,因此 response 本身永远不会产生; +- 冻结新的迁移期不变量:**response ordering fence 可以排序 observation,但不能扣住 pending command 完成所必需的 + owner progress / permit publication**。`DedicatedWorkerTargetLifecycle` 因此改为 `BeforeResponse`:frontend 可先收到 + `Target.attachedToTarget`,发送 `Runtime.runIfWaitingForDebugger`,Worker 执行并回传首条 message,最后原 + `Runtime.evaluate` response 才完成。这与 Chromium 的可观察顺序一致,也没有加入 sleep、retry、drain、额外 timeout + 或把任意 DCL/event 判成假的。新增 + `dedicated_worker_lifecycle_can_unlock_awaiting_runtime_response` 锁定 delivery 与 response order;通用 slot table 同时 + 锁定该映射; +- 该例外不表示所有 CDP event 都是 owner progress:SharedWorker/ServiceWorker target lifecycle 和纯 observation 仍按 + 自己的 occurrence/response 语义排序。判断依据不是事件名称,而是 exact output 是否携带当前 command 继续执行所需的 + browser/renderer permit。长期改造应把这种 permit-bearing progress 从 frontend response slot 中提取到 Browser Owner + queue;届时 CDP 只投影已经发生的 Target fact。当前修改只消除迁移期 response barrier 的循环依赖,没有建立 typed + Browser fact journal,也没有完成 Browser Owner queue / CDP frontend queue 分离,Phase 3 Exit gate 仍未通过; +- 首次修复时两条 slot 边界回归分别 `1/1`(nextest runs `b019e8f8-f410-472e-9710-90e6bf801d9f`、 + `41e5a58a-db32-4bb0-a09d-30ded8a977d1`);proxy-cleared tracing group 完整通过后又连续复跑 10 次无失败。按本轮 + 要求提交后再次执行 `git pull -r origin master`,master 从 `a3c9f06e2c` 前进到 `532fc0bb93`,48 个分支提交无 + conflict 重放。新 master 的 `d96aa3eaf6` 已包含相同的 production `BeforeResponse` 映射和强化 tracing smoke;rebase + 后本分支只额外保留 named invariant regression 与本设计记录,没有第二条 worker resume authority; +- 最终 master 基线上的 slot table/named regression 为 `2/2`(run + `8baaeef0-f7a6-44e2-b778-a933b0511dbf`),`moli-protocol` 全包 `3327/3327`(run + `518b9b4f-22b4-4867-9d7a-f47773dce266`),workspace 全量 `15960/15960`、17 个既有 skip(run + `2ab4a775-354f-4f68-9f2e-052df9c1fc8a`);workspace all-target clippy `-D warnings`、fmt/diff check 和 release + workspace build 均通过。固定 `target/release/moli` SHA-256 为 + `7e463a829a4aa4846188e9fe76c60dfa80d637c9aa699c2a70d9387059e3f3b3`;显式清除大小写 HTTP/HTTPS/ALL/FTP proxy + 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`(其中 tracing profiler 为 107 个 + trace event、7 个 profile、5042 个 sample),WebDriver Classic/BiDi/Selenium/Semantics + `--continue-on-failure` `157/157`;两套 JSON 均为 `ok=true` 且失败列表为空。 + +2026-08-03 第四十切片实现记录(Page replacement DedicatedWorker retirement 同步 apply): + +- 审计 `loaded_page_install.rs -> worker_target.rs -> session_owner.rs` 后确认,旧 Page 的 `close_async()` 已在 + disposal participant 中先结算 renderer lifetime;随后 exact owner Page 的 DedicatedWorker retirement 只剩 + Protocol worker target/session registry、Target observer delta 和 frontend session bookkeeping。原实现之所以跨 + `.await` 借用 `CdpConnection`,只是复用了同时覆盖 active Page binding cleanup 的通用 async helper; + DedicatedWorker 分支本身没有 I/O 或会返回 `Pending` 的 operation; +- `retire_dedicated_worker_targets_for_replaced_page` 因此改为普通同步 owner apply。它先按 exact + `TargetPageResidenceIdentity` 冻结属于 predecessor Page 的 renderer instance 列表,再逐个 prepare terminal outputs; + `commit_dedicated_worker_retirement_sync` 在不可插入其他 actor turn 的同一调用中先移除该 worker registry/host,随后 + 只从 prepared snapshot 投影 `targetInfoChanged -> detachedFromTarget -> targetDestroyed`。并行 Page residence 的 + worker 仍按 owner identity 保留,session route 和 Target host 均在函数返回前结算; +- 这刀没有创建一个永远 ready 的 `PendingDedicatedWorkerRetirement`,也没有增加 wake、sleep、retry 或 drain。generic + worker lifecycle projector 仍保留 async terminal commit,供不是 Page-disposal exact batch 的其他 Target 路径使用; + 下一条真实异步边界仍是 BiDi preload listener startup,不能把它和本轮同步 registry transaction 合并; +- 边界回归改名为 + `page_replacement_retires_only_its_owned_dedicated_workers_synchronously`,调用点不再 `.await`,同时继续锁定 exact owner + isolation、三类 CDP event 顺序、session route 清理和 Target host retirement。该回归单次 `1/1`(nextest run + `af397305-e4af-4ade-b3a9-6f11ca821179`),stress `20/20`(run + `c5d457f1-6c3c-4e11-a797-4b0c51dec18b`);与 debugger-resume retirement、production-shaped disposal/replay actor + 回归组合为 `3/3`(run `13a6adba-af32-444c-b01f-4d45e4a5bb4f`); +- 首次 `moli-protocol` 全包在 `3326/3327` 后暴露既有 named background popup fixture 的确定性过早条件(run + `9c068854-ecc1-4d90-9be3-fa6e1c0cf632`):fixture 看到 successor Page 已安装便发送下一条 Runtime command,却没有 + 等同一 navigation 的 disposal/replay terminal,导致 command predecessor publication 已到达、仍被 exact navigation + stream gate 合法阻挡。干净 `HEAD` detached worktree 同样 `1/1` 失败(run + `a7ec1c99-5b77-48a9-a888-077ad0b39e0c`),证明不是本轮 worker 修改;测试 helper 现捕获唯一已 admission 的 + `BackgroundNavigationGateKey`,只消费真实 scheduler input 直到该 exact key 被 terminal completion 移除,不延长 + timeout。修复后原例 stress `20/20`(run `29baba4d-6188-465e-bfd3-afbc63501beb`),整个 47 项 + background-staging 模块 stress 3 轮 `141/141`(run `a83652a6-4f12-4b6d-a702-9c8d3fa1b526`),Protocol 全包最终 + `3327/3327`(run `27b83242-baae-4e75-a4cf-02e76224a0f6`); +- workspace 全量最终为 `15960/15960`、17 个既有 skip(run + `05894e27-cb0a-4333-a157-8585d0c9760f`)。workspace all-target clippy `-D warnings`、fmt/diff check 和 release + workspace build 均通过;固定 `target/release/moli` SHA-256 为 + `93cc3887f5819ad5f5f0c4c6b0e2eccf18bda51ed53302fff4dc57a2704aee15`。显式清除大小写 + HTTP/HTTPS/ALL proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `210/210`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`;两套 JSON 均为 `ok=true`,进程退出码为 0; +- Chromium machine differential 使用 `/home/donoughliu/chromium/src/out/Default/chrome` + (`Chrome/147.0.7709.0`) 和 Xvfb、清代理环境。默认配置会把 predecessor Document 放入 BFCache,因而其 + DedicatedWorker 在 successor load 后仍可存在;这不是 hard Page replacement 的可比样本。以 fresh profile 并禁用 + `BackForwardCache` 后,raw CDP 观察到 worker 依次发生 + `Target.targetCreated(attached=false) -> Target.targetInfoChanged(attached=true) -> Target.attachedToTarget -> + Target.targetInfoChanged(attached=false) -> Target.detachedFromTarget -> Target.targetDestroyed`,随后 + `Target.getTargets` 已无该 worker。对同一 fixture 的 release Moli 探针得到相同六事件顺序;替换后的第一条真实 + command 同时读取 DOM 并执行 `fetch('/api')`,两边均返回 `plain ok` / `fixture api body`,且均不需要额外 pump command。 + 这项 differential 证明本轮同步的是 hard-replacement 后已发生的 exact worker retirement;它不主张 Moli 已实现 + Chromium BFCache lifetime; +- 本切片只关闭 disposal completion 中 DedicatedWorker registry cleanup 的伪异步 borrow。navigation tail inline + drain、`commit_loaded_navigation_async` compatibility wrapper、BiDi listener 真实 wait、Fetch continue/Target + termination drain、loaded lifecycle/activity projection、replay `CdpTurnOutcome` normalization、typed Browser + fact/outcome channel 和 Host/frontend lifetime 均仍在 production;Phase 3 Exit gate 继续保持未通过。 + +2026-08-03 第四十一切片实现记录(loaded lifecycle/activity prefix 同步 owner apply): + +- 对 `MainDocumentNavigationActivity::emit_loaded_navigation_commit_async` 做逐调用审计后确认,其函数体没有任何 + renderer/network wait:它只投影已冻结的 navigation result、Target info、main-resource metadata、pre-DCL network + backlog 与 initial renderer lifecycle journal,并将后续 load observation 发布为 + `MainDocumentLoadOwnerAction`。原 `async fn` 和调用方的 `Box::pin(async move { ... }).await` 只是历史 future 形状, + 不是 participant; +- 该入口现改为同步 `emit_loaded_navigation_commit`。response metadata、renderer output fence、DCL/termination prefix 和 + deferred-load admission 在不可插入其他 actor turn 的同一 apply 中完成;若 Document 在 DCL 前已终止,同 turn 取消 + exact load visibility barrier。真正可能等待 renderer load 的 observer 仍由既有 load owner action 拥有,不被内联 + drain,也没有添加 sleep、retry、yield 或新 compatibility wrapper; +- 回归改为普通 `#[test]`,除继续锁定 lifecycle 只消费 frozen journal、不从 current Page 重新发现 child activity 外, + 还断言 `emit_loaded_navigation_commit` 返回前已经发布 exact `MainDocumentLoadOwnerAction`。单次 `1/1`(nextest run + `a8b32a17-d00a-4021-9fbd-d8e48c8992e4`),stress `20/20`(run + `7cb30f33-2d86-4f00-b63d-6d4f54decf6c`);`moli-protocol` 全包 `3327/3327`(run + `d09202b5-37f5-4fc5-8803-f85a1235f0fc`),workspace 全量 `15960/15960`、17 个既有 skip(run + `515061ba-85fd-4ce1-9203-86a299e13269`);workspace all-target clippy `-D warnings`、fmt/diff check 和 release + workspace build 均通过。固定 `target/release/moli` SHA-256 为 + `ac209ed58617f9cd463be83b890dce25f361a7c530c911576e29adcb1cbe9653`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,当前 CDP 默认全组实际为 + `242/242`,WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`;两套 JSON 均为 + `ok=true`、失败列表为空; +- 本切片不改变可观察 event/response 顺序,也没有把 BiDi preload channel listener 的真实 renderer command wait + 同步化。`complete_loaded_navigation_page_install_async` 仍会跨该真实 wait 借用 `CdpConnection`;下一切片应把 BiDi + listener startup 建模成 exact Page-owned participant,而不是把它伪装成同步 registry transaction。navigation tail + inline drain、`commit_loaded_navigation_async` wrapper、Fetch/termination drain、protocol-neutral fact/outcome channel 和 + Host/frontend lifetime 也仍未迁移,Phase 3 Exit gate 继续保持未通过。 + +2026-08-03 第四十二切片实现记录(loaded-navigation BiDi preload listener participant): + +- 模块边界分成三层。`conn/runtime_eval.rs` 只拥有可复用的单次 `StartListener` / `ReleaseObjectGroup` renderer owner + action 状态机;新模块 `domains/runtime/preload_listener.rs` 拥有 loaded-navigation 专用的 `realm × handoff` FIFO batch; + `loaded_page_install.rs`、`navigation_commit.rs` 和 `navigation_completion.rs` 只负责把该 batch 表示成 + `StartPreloadListeners` navigation phase。没有把 realm 枚举、proxy materialization、listener restart 和 navigation + terminal policy 重新集中到一个总管函数; +- loaded Page restore 不再只保留 execution-context id,现冻结并去重完整 `RendererRuntimeRealmInfo`。post-install apply + 因而不需要跨 wait 后重新查询“当前 realm”;它从 committed Document 的 frozen realm id、frame id 和 context id 构造 + exact listener job,保留 realm 顺序和每个 realm 内 handoff 顺序; +- 每个 job 依次启动 proxy handle materialization、listener dispatch,失败时再启动 exact object-group cleanup。所有真实 + Page dispatch/deferred-response wait 都由 `PendingBidiPreloadListenerBatch::wait(self)` 或 + `PendingBidiChannelOwnerAction::wait(self)` move-own;等待函数不接收、保存或借用 `CdpConnection`。每次 completion + re-enter owner apply 后先验证 `BidiChannelPageOwner` 的 BrowserContext/Target/Page generation,再解析 handle、注册 + realm/object group 或启动下一 command。replacement generation 的旧 completion 直接 terminal,不进入 successor Page; +- listener startup 的完成条件是 listener command 已被 exact Page 接受并接入 response-ready lane,不是“收到第一条 + channel message”。若首条 reply 已在初始 renderer turn 中 ready,则其 `script.message` 暂存在同一 navigation output + 的 after-response 区;若仍 deferred,则 navigation gate 结束后由既有 response-ready input 独立发布并重启 listener。 + 因而页面从不调用 channel 时也不会把 navigation 永久挂起; +- `preload_listener_batch_completion_rejects_replacement_generation_before_apply` 使用真实 Page 启动 proxy participant, + 在 wait 与 apply 之间推进 loaded Page generation,证明迟到 completion 不发 event、listener 或 cleanup 到 replacement + Page。该回归 `1/1`(run `0d8e3013-0bbf-450a-ae2a-a6bc701f7706`);lower listener/cancellation/stale 组合 `5/5` + (run `646e185e-b354-4670-bd2a-a02a8432d9d6`),BiDi channel owner action 组合 `5/5`(run + `632bf1ff-85fe-4ca7-9530-a3313dcda041`),direct/background navigation participant 组合 `2/2`(run + `7cadc91b-c39e-4497-a047-3cd061ff5b65`); +- WebSocket 集成覆盖 mutation observer、sandbox realm、token gate、existing Classic session、两 channel 等场景,组合 + `9/9`(run `4da34927-8c64-4dcf-a5ab-d25a0f7e1b85`)。`wait=none` loaded-navigation 回归进一步改为两个 channel,锁定 + navigate response 先于 `script.message` 且 first/second FIFO,单次 `1/1`(run + `1d76535c-ef2f-473a-a58b-30aa23346df4`); +- Chromium machine differential 使用 `/home/donoughliu/chromium/src/out/Default/chrome` 与 `chromedriver` + (`147.0.7709.0`)、Xvfb、fresh WebDriver session 和清代理环境。raw BiDi 的 `wait=none` timeline 为 + `navigate response -> DOMContentLoaded -> load -> first script.message -> second script.message`;`wait=complete` 为 + `DOMContentLoaded -> load -> navigate response -> first script.message -> second script.message`。两种 wait policy 都证明 + navigation response 不等待首条 listener reply,且多个 channel 按 preload argument 顺序投影;本轮只把稳定的 + response-before-message 与 FIFO 纳入 Moli 回归,不额外绑定 DCL/load 的 frontend flush 位置; +- 首次 Protocol 全包在 `3327/3328` 暴露本轮引入的确定性 test-thread stack overflow(run + `1260837a-d504-44f9-9a5b-996698ab708b`)。失败用例 + `local_storage_mutations_fan_out_across_targets_without_leaking_session_storage` 聚焦复跑为 `0/20`(run + `5babd70f-adab-4dc6-8893-065a27bfbb62`),而 detached `a634a1a5af` 基线为 `1/1`(run + `8e06d248-01ff-4371-8474-b052b5c8084d`):原因不是 DOMStorage race,而是新增具体 preload async future 把通用 + `complete_pending_navigate_command` 从 `22392` 膨胀到 `28272` bytes,并继续嵌入所有 Page command wait; +- 修复没有增大线程栈或改变测试 timeout,而是在 `PendingNavigateCommand::wait` 与 + `complete_pending_navigate_command` 模块 API 上返回 type-erased boxed future。上层 + `PendingPageCommandDispatch::wait` 由修前 `28320` 降至 `7832` bytes,原失败用例修后 `1/1`(run + `0119ec94-7547-4ba1-8f0c-78c892553b19`)、stress `20/20`(run + `ab864324-65ef-49b5-94d0-d5ca699ca1ff`),Protocol 全包最终 `3328/3328`(run + `838545b7-596b-4029-857f-b0bcbe0187df`)。这条边界同时防止未来新增 navigation phase 把具体 future 布局泄漏到 + 所有无关协议 command; +- workspace 全量为 `15961/15961`、17 个既有 skip(run + `56a7207b-0158-45c2-8f3d-7e54248ecbd6`);workspace all-target clippy `-D warnings`、fmt/diff check 和 release + workspace build 均通过。固定 `target/release/moli` SHA-256 为 + `e88e8c201a262c16edc7d29d3607d42e6006e7d316497969d4324f9b031e9e23`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组为 + `242/242`,WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`;两套 JSON 均为 + `ok=true`、失败列表为空; +- 本切片仍没有拆除非 loaded-navigation 的 BiDi listener compatibility helper;proxy/listener completion apply 也仍 + 复用通用 async renderer output normalization,后者可能为 context id/DOM node 启动 Page lookup。navigation tail + inline drain、`commit_loaded_navigation_async` wrapper、Fetch/termination drain、protocol-neutral fact/outcome channel 和 + Host/frontend lifetime 同样未迁移,Phase 3 Exit gate 继续保持未通过。 + +2026-08-03 第四十三切片实现记录(Runtime output normalization participant): + +- 新增 `conn/runtime_eval/normalization.rs`,把“消费冻结的 Runtime Page dispatch 输出”和“投影 CDP + response/event”之间可能发生的 Page lookup 建模成独立状态机。状态机冻结 session、BrowserContext、Target、 + renderer-agent attachment 和 renderer Inspector session;先枚举 execution-context compatibility job,再枚举缺少 + subtype 的 object/function RemoteObject,按稳定 FIFO 每次只启动一个 `EnsureContextWorlds`、`ResolveContextId` 或 + `ResolveNode` Page command; +- `Page` 的 isolated-world attach 与 inspector-context-id lookup 新增对称的 `start_*` / `finish_*` API,原 async API + 只保留为兼容组合。`PendingRuntimeProtocolMessageNormalization::wait(self)` move-own 唯一 + `PendingPageCommand`,不保存或借用 `CdpConnection`;completion apply 先按冻结 route 重验 exact renderer attachment, + 失效时保留 immutable old-Page output 但停止剩余 lookup,随后 normalized routing 再按旧 attachment terminal drop, + 不从 successor Page 重新发现 node/context; +- loaded-navigation 的 `NormalizeProxy` batch operation 和 lower `Normalize` BiDi owner action 现在显式承接上述 + participant。proxy materialization、listener startup 以及失败 cleanup 的 completion apply 均为同步函数; + `loaded_page_install.rs`、`navigation_commit.rs` 与 `navigation_completion.rs` 不再为了 Runtime normalization 跨 + `await` 借用整个 connection。listener 的首条 deferred reply 仍是独立 response-ready input;内部 + `Runtime.releaseObjectGroup` response 不携带 context id 或 RemoteObject,因而可直接进入 already-normalized route; +- `node_normalization_completion_does_not_enter_replacement_renderer_attachment` 使用真实旧 Page 执行 + `Runtime.evaluate(document.body)` 并等待 DOM-node lookup 完成,然后在 apply 前切换到新的 renderer attachment;回归 + 证明旧响应不会借用 replacement Page 补 subtype、stale attachment 输出不会投影 frontend,且 successor attachment + 保持不变(nextest `1/1`,run `03fccd48-e3ea-4e4f-89d1-58f43ee4103d`)。既有 node subtype、isolated-world + console context-id、preload replacement-generation、BiDi listener/channel 回归继续锁定非 stale 路径的可观察语义; +- node/context/stale/DOMStorage 聚焦组为 `6/6`(run `41291738-df30-45fc-804e-36bc509de92c`),BiDi channel/listener + owner 组合为 `10/10`(run `aecaeea8-fb21-4172-adf3-977281379112`),WebSocket preload 集成为 `12/12` + (run `41d9b6eb-39ae-4487-adc7-77459a7d6f0d`)。首次 workspace clippy 精确发现 normalization continuation + 内嵌后造成三个 large-enum variant;修复没有添加 lint allowance,而是让 Runtime step、BiDi owner action 与 preload + batch 全程保持 boxed participant,避免把大 continuation 再复制进外层 command/navigation future; +- 最终 `moli-protocol` 全包为 `3329/3329`(run `6237eb3e-9a61-418d-866b-13adad34658b`),workspace + 为 `15962/15962`、17 个既有 skip(run `1880aeda-ec7b-4e50-9d94-6a50313f8025`)。workspace all-target + clippy `-D warnings`、fmt/diff check 与 release workspace build 均通过;固定 `target/release/moli` SHA-256 为 + `51917858c7026a27fdaab54f3dd2660ac0a0abe6048931d5ed5e30b9415854e9`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组为 `242/242`, + WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`;两套 JSON 均为 `ok=true`、失败 + 列表为空; +- 本切片没有删除 `complete_runtime_protocol_message_for_session_owner_async`:它现在是显式 drain 新 participant 的 + compatibility wrapper,仍服务 runtime realm-created、`Page.createIsolatedWorld`、run-immediately preload、initial + `about:blank` 和部分 replay 调用方。Fetch continue/Target termination drain、navigation tail/loaded commit wrapper、 + protocol-neutral Browser fact/outcome channel 与 Host/frontend lifetime 也仍未迁移,Phase 3 Exit gate 继续保持未通过。 + +2026-08-03 第四十四切片实现记录(`Page.createIsolatedWorld` preload participant): + +- `Page::runtime_realm_inventory_async` 被拆成对称的 `start_runtime_realm_inventory` / + `finish_runtime_realm_inventory`,async API 只保留为兼容组合;Core `Page` 继续拥有 renderer command 的启动与结算, + Protocol 不直接读取 renderer realm payload; +- `CreateIsolatedWorldCommandTask` 新增 `RuntimeRealmInventory` 和 `PreloadListeners` 两个显式 phase。world renderer + command 完成后先冻结 `executionContextId`,随后每个 pending value 独占自己的 participant wait;apply turn 才重新取得 + `CdpConnection` 和 exact Page。listener/proxy/cleanup 复用 `domains/runtime/preload_listener.rs` 的 FIFO batch,未在 + Page domain 复制第二套 BiDi channel 状态机; +- realm-inventory completion 复用 world 创建时冻结的 `RendererAgentAttachmentId` 做 exact revalidation。若旧 Page 在 + wait 期间被 replacement,命令保持已经冻结的 world 成功响应,但停止 listener startup,不能 + 从新 Page 重新发现同 id realm,也不能修改 successor attachment;listener batch 自身仍在每次 operation apply 前 + 重验 frozen Page owner; +- 新增 `create_isolated_world_channel_listener_advances_through_owned_participants`,证明实际 phase 顺序为 renderer world + command → realm inventory → preload listener batch;新增 + `stale_realm_inventory_does_not_enter_replacement_page`,证明 replacement 后保留冻结响应且不产生 successor + Inspector await。全部 `create_isolated_world` 聚焦用例为 `20/20`(run + `83c9d277-5d90-4584-8f4f-c41d537bbfc1`),BiDi preload WebSocket 集成为 `12/12`(run + `9a7a3570-ad72-4afa-915a-32a298148f4c`); +- 首次 workspace 全量在 `15962` 项通过后有两个与本切片无关的 worker DNS-failure 用例触发 5 秒外层 timeout; + 两项以 nextest `--stress-count 10 --flaky-result fail` 复核为 `10/10`(run + `c5516fef-3839-43ef-b765-e3b5a28ebc1f`),未改测试、timeout 或产品行为;随后相同 workspace 全量为 + `15964/15964`、17 个既有 skip(run `e412d57a-c440-4a8a-b0d7-4c5ac9d1f18e`); +- 按约定提交后执行 `git pull -r origin master`,实际引入 `f16860e4fb` 的 process-signal crate 并无冲突地重放 + 本分支 54 个提交。最终 rebased tree 的 `moli-protocol` 为 `3331/3331`(run + `72c479e6-4804-4306-be85-d428f3a411a5`),workspace 为 `15966/15966`、17 个既有 skip(run + `33841610-833a-4d5c-ae59-50b457e9f9ea`)。workspace all-target clippy `-D warnings`、fmt/diff check 与 release + workspace build 均通过;固定 `target/release/moli` SHA-256 为 + `a84c1c64950f1756fdf0e3c8badf8d129ca0c1de6595fd58838f1eb5cdcbfec9`;显式清除大小写 HTTP/HTTPS/ALL/FTP + proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组为 `242/242`,WebDriver + Classic/BiDi/Selenium/Semantics 为 `157/157`,两套结果均为 `ok=true`、失败列表为空; +- 本切片只迁出 `Page.createIsolatedWorld` 的 post-create compatibility wait。runtime realm-created、run-immediately + preload、initial `about:blank` 与部分 replay 仍通过 generic Runtime completion compatibility wrapper;Fetch + continue/Target termination drain、navigation tail/loaded commit wrapper、protocol-neutral Browser fact/outcome + channel 与 Host/frontend lifetime 也仍未迁移,Phase 3 Exit gate 继续保持未通过。 + +2026-08-03 第四十五切片实现记录(Page add-preload command participant): + +- `domains/runtime/preload_listener.rs` 新增可复用的 `BidiPreloadListenerSetup`。它把 execution-context id 到 exact + realm inventory、再到既有 `realm × handoff` listener batch 串成同一 move-owned participant chain;realm phase 冻结 + `BidiChannelPageOwner`,wait 只拥有 `PendingPageCommand`,apply 先重验 exact BrowserContext/Target/Page attachment, + replacement 后直接终止旧 setup。`Page.createIsolatedWorld` 因而删除自己复制的 `RuntimeRealmInventory` phase, + 只保留一个委托给该 setup 的 `PreloadListeners` phase; +- 新模块 `domains/page/preload/add_command.rs` 内聚 target-scoped + `Page.addScriptToEvaluateOnNewDocument` 的状态机。Target 脚本注册表是先提交的 durable state;当前 Page renderer + install/run-immediately 是冻结 `RendererAgentAttachmentId` 的独立 phase,BiDi channel 的 realm/listener 工作再委托给 + Runtime setup。每个 pending value move-own 唯一 renderer participant,`PendingPageCommandDispatch::wait` 不借用 + `CdpConnection`,completion apply 才重新进入 command owner route; +- replacement 不回滚已提交的 Target 注册,也不把旧 renderer completion 重放到 successor Page:若 attachment 在 + install wait 期间改变,命令返回已经分配的 identifier,旧 completion 被丢弃,新 Page 后续只通过其正常 + document-start registry 输入取得脚本。`CompletedAddScriptToEvaluateOnNewDocumentCommand` 同时透传 renderer output + predecessor,保持 command/event 的 causal ordering; +- Page command 路径不再用“立即 ready 的 pending command”包住一个随后 inline-await 的实现。BiDi/其他 + protocol-neutral target route 暂时通过 `execute_direct_async` compatibility adapter 本地 drain 同一状态机;这条 + adapter 没有复制语义,但仍属于待迁出的 frontend-owned async 组合,不能据此宣称所有 preload caller 已解耦; +- 新增 `channel_run_immediately_advances_through_owned_participants`,用真实 V8 isolated world 与 channel handoff 证明 + renderer install → realm inventory → listener batch 的 phase 顺序;新增 + `stale_renderer_completion_keeps_registry_without_entering_replacement_page`,证明 replacement 后 identifier/Target + registry 保留、successor attachment 不变且没有遗留 Inspector await。连同 create-world setup replacement 回归为 + `4/4`(run `e90074a5-802c-4b5b-b2c4-305249e59ddb`);既有 add-script/create-world/DOMStorage 聚焦组为 `41/41` + (run `3c4babaf-9aae-47ca-8999-9b0f23d97be7`),BiDi command mapping 为 `4/4`(run + `6fb36a22-26a1-41ef-a386-7c3846881a2e`),BiDi preload WebSocket 集成为 `12/12`(runs + `4fd2e288-f44c-4d77-9a53-ffa15f4d50b5`、`66f3e8ee-ee17-426e-9406-d56e491db25d`); +- 提交前 `moli-protocol` 全包为 `3333/3333`(run + `9d6a16c1-c36f-469a-bbc5-61452c693830`),workspace 为 `15968/15968`、17 个既有 skip(run + `b5ab6cdf-7b2a-4983-9050-5466ff0d4345`);随后按约定执行 `git pull -r origin master`,实际吸收 + `c597ac97dc` 的 WebGL shader pipeline compatibility 修复,55 个分支提交无冲突重放。最终 rebased tree 的 + workspace 全量为 `15969/15969`、17 个既有 skip(run + `1bd03d94-d392-4f13-8c74-f0a2cd7e14d3`),workspace all-target clippy `-D warnings`、fmt/diff check 与 + release workspace build 均通过;固定 `target/release/moli` SHA-256 为 + `ee240e810bef9c50a36e7f19de89c71aea9b7e08ef15f2a981a3d67a1577d9e7`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组为 `242/242`, + WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`;两套 JSON 均为 `ok=true`、失败 + 列表为空; +- 本切片没有拆除 runtime realm-created/create-target initial `about:blank` 的 listener compatibility helper,也没有迁出 + `execute_direct_async`、Fetch continue/Target termination drain、navigation tail/loaded commit wrapper、 + protocol-neutral Browser fact/outcome channel 或 Host/frontend lifetime,Phase 3 Exit gate 继续保持未通过。 + +2026-08-03 第四十六切片实现记录(Runtime command normalization participant): + +- 新模块 `domains/runtime/dispatcher/command_normalization.rs` 内聚 Inspector dispatch completion 到 exact-Page + normalization participant 的 transition。普通 Runtime command 与 `Runtime.add/removeBinding` 共用 + `RuntimeCommandNormalizationContinuation`;它 move-own deferred-response receiver、page-owner access permit、binding + task 与 timing state,不把这些 continuation 字段散回总 dispatcher; +- `PendingRuntimeCommandKind::ProtocolMessageNormalization` 现在是 Runtime command 的显式 phase。Inspector dispatch + apply 只调用 `start_runtime_protocol_message_completion`;遇到 context-id compatibility 或 DOM-node subtype lookup 时 + 立即返回 move-owned pending command,wait 不借用 `CdpConnection`,completion input 回来后才调用 + `complete_runtime_protocol_message_normalization`。多 job normalization 每次只启动一个 exact Page command; +- normalization 已完成的 output 只走同步 + `route_normalized_renderer_command_turn_output_into`,删除无人使用的 async + `route_renderer_command_turn_output_into` wrapper,不能在 final routing 中再次隐式执行第二轮 Page lookup;renderer + output predecessor 仍随同一个 Runtime command plan 投影; +- replacement 回归第一版稳定悬挂超过 60 秒,暴露出新 participant boundary 上原先不可能插入的真实 interleaving:旧 + Page normalization completion 被 stale-drop 后,command 仍等待旧 deferred-response receiver。修复在 final apply 前 + 比较 frozen `RendererAgentAttachmentId`;attachment 已 stale 时同步返回 + `Execution context was destroyed by navigation`、清理 pending Inspector await 并丢弃 receiver,不能进入 successor + Page 或形成 orphan command;没有增加 timeout、retry、sleep 或 drain; +- 新增 `node_result_normalization_is_an_owned_runtime_command_phase`,证明 `Runtime.evaluate(document.body)` 依次经过 + Inspector dispatch 与 command-owned normalization 两个 participant,并保留 `subtype=node`;新增 + `stale_owned_runtime_normalization_does_not_enter_replacement_page`,证明 replacement 后旧 command 以 error 终止、 + successor attachment 不变且没有 pending await。两条聚焦回归 `2/2`(run + `ca8d4a16-300e-409b-b46d-69bfae18ffd3`),Runtime evaluate/binding 组 `141/141`(run + `cfe1e6da-c278-46db-a584-687e2d7c26cb`),Protocol 全包 `3335/3335`(run + `cba829d3-ff5a-4c91-b973-ce43f4af91e1`),Protocol all-target clippy `-D warnings` 与 fmt/diff check 通过; +- 本切片完成后已执行 `git pull -r origin master`,重放到 `origin/master@03ef15db11`。rebase 在前序 Runtime + normalization extraction 与 master 新增的 DOM whitespace projection 参数上发生一次内容冲突;合并保留 extracted + exact-Page participant,并在启动 node snapshot 时冻结当前 session 的 `include_whitespace`,没有恢复已删除的 async + route wrapper。rebase 后 `cargo nextest run --no-fail-fast` 为 `15979/15979`、17 skipped(run + `f385df49-e08c-485d-8441-70d1adc38bc3`),workspace all-target clippy `-D warnings`、fmt 与 diff check 通过; +- rebase 后 workspace release build 通过,smoke 固定使用 + `target/release/moli`(SHA-256 + `b9dc6bb8c3e6b74b0c9c436e7dde82f28d088d3bc6b5c8ebca99ce2e177d8e06`)。清空大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组为 `244/244`, + WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`;两套 JSON 均为 `ok=true`、失败 + 列表为空; +- `complete_runtime_protocol_message_for_session_owner_async` 的 production caller 已从普通 Runtime/Runtime-binding + completion 删除,只剩 protocol-neutral `ReleaseObjects` helper compatibility drain。本切片没有迁出 runtime + realm-created/create-target initial listener、targeted preload direct adapter、Fetch continue/Target termination、 + navigation tail/loaded commit wrapper、protocol-neutral Browser fact/outcome channel 或 Host/frontend lifetime,Phase 3 + Exit gate 继续保持未通过。 + +2026-08-03 第四十七切片实现记录(protocol-neutral `ReleaseObjects` participant chain): + +- 新模块 `domains/runtime/dispatcher/release_objects.rs` 内聚一个外层 `ReleaseObjects` transaction。状态机 move-own + `remaining_handles`、当前 object correlation、已产生的 protocol events 与最新 exact renderer predecessor;没有把 + 多 handle continuation 塞回通用 dispatcher 或 Browser Core; +- `start_devtools_runtime_command_dispatch(ReleaseObjects)` 不再调用会串行完成整个 handle list 的 async helper。它先按 + target/realm 过滤未知或错误 realm 的 handle,再把每个已知 handle 作为标准 `Runtime.releaseObject` command task + 启动;外层 scheduler 每次只收到一个 `PendingDevToolsRuntimeCommandDispatch`,完成后才在同一个 apply turn 启动下一 + handle。重复 handle 在首次成功注销后自然跳过,不产生额外 renderer command; +- 每个内部 release 复用现有 Runtime dispatcher 的 attachment freeze、normalization 和 deferred-response correlation, + 不另造第二套 Inspector completion 语义。当前 handle 的 plan 先吸收该等待期间的 interleaved protocol events;多 + handle 产生的 events 按顺序累积,renderer predecessor 取顺序链中最新的 exact fence,最终与外层 Empty/error result + 一起返回; +- replacement 发生在旧 Page command wait 与 completion apply 之间时,既有 exact attachment 检查把首个 response + 转成 `Execution context was destroyed by navigation`。外层 release state 立即 terminal,不会重新按 target 找 Page + 或启动剩余 handles。未知 handle 与真实 V8 `NoSuchHandle` 仍保持 WebDriver/BiDi disown 的幂等语义;没有增加 + sleep、retry、timeout 或 owner drain; +- `complete_runtime_protocol_message_for_session_owner_async` 已降为 `cfg(test)` fixture helper,production caller 为 + 零。无 scheduler participant loop 的 direct protocol-neutral adapter 仍明确保留本地 release/normalization drain; + 本切片不以消灭所有 Protocol `await` 为目标,也没有把 frontend-local response wait 误称为 Browser Owner blocker; +- 新增 `devtools_release_objects_exposes_each_known_handle_as_an_owned_participant`,覆盖 unknown + 两个已知 + duplicate + handle,证明恰好产生两个不同 internal command participant 且最终 registry 清理;新增 + `stale_release_objects_participant_does_not_enter_replacement_page`,在首个 renderer completion 后替换 Page,并给 + successor registry 注入同 ID handle,证明旧 outer command 以 stale error 终止且不会删除 successor handle。两条 + 聚焦回归 `2/2`(run `fc175008-5d62-44cc-a357-2e0e0e808023`);既有 direct script evaluate/callFunction 语义回归 + `1/1`(run `54bef051-af94-4cd2-8654-a17819b143a7`); +- 本切片 rebase 前验证:Protocol 全量 `3344/3344`(run `50f0a851-18a9-4d7f-8b3e-383e4f16cc35`);workspace 全量 + `15981/15981`、17 skipped(run `f4d1a7fe-349c-4152-84cd-0dd941cb5203`);Protocol 与 workspace + `cargo clippy --all-targets -- -D warnings` 均通过;release workspace build 通过,`target/release/moli` + SHA-256 为 `3d213c2c12801bdda24657d30aa225a4376ca5458f6491b4d4a8d30051e5ff62`;完全清空代理环境并锁定该 + 二进制后,CDP smoke `244/244`、WebDriver Classic/BiDi/Selenium/semantics smoke `157/157`; +- 本切片完成后先执行 Phase 3 exit audit,不继续按 wrapper 数量推进。navigation tail/loaded commit、runtime + realm-created/initial `about:blank`、targeted preload direct adapter、Fetch continue/Target termination、Browser + outcome/fact channel 与 Host/frontend lifetime 仍需按上述四条防雕花判据重新分类,Phase 3 Exit gate 继续保持未通过。 + +2026-08-03 第四十七切片 rebase 集成记录: + +- 已执行 `git pull -r origin master`,57 个分支提交重放到 `origin/master@37a4e248a0`。冲突合并保留 master 新增的 + 完整 `BrowserIdentityProfile` 与 reload request kind,同时保留本分支 Browser Owner typed operation、navigation + trace 和 exact participant 边界;没有恢复已经删除的 Protocol execution authority; +- rebase 后补齐两处 production seam:renderer reload 的 trace wrapper 继续把 + `BrowserNavigationRequestKind` 传入 owner navigation;runtime load configuration 比较从 Core-owned active fetch + configuration 读取 BrowserIdentity,不再回到已经删除的 physical engine fetch-config authority; +- master 新增的 reload header 回归改为并发推进 test-only Browser Host owner turn 与等待服务器请求,验证无需额外 + frontend command,renderer reload 仍自主到达网络层;BrowserIdentity 回归改为通过 Core registry 安装 + BrowserContext,并等待 exact background navigation gate 后再读取新 Document,避免测试 fixture 绕过 owner 或跨 + Document 竞争; +- workspace 首次验证 `16010/16011` 时,唯一失败 + `devtools_command_executes_input_key_command_without_cdp_sidecar` 在相同用例 20 次压力复跑中为 `17/20`。根因是测试 + 把 DCL 后独立发布的 post-parse autofocus rendering task 当成 load completion 的组成部分;按键命令可能在 autofocus + 前合法完成,因此 input value 偶发仍为空。这是测试同步错误,不是 key dispatch completion 或 scheduler sidecar + 丢失;fixture 改为 parser script 同步 `focus()`,并新增 key dispatch scheduler sidecar 必须为空的断言。没有增加 + sleep、retry、timeout,也没有放宽结果断言;修复后同一用例压力复跑 `50/50`(run + `a5ba8c5e-1666-4b55-aa15-c404502fedfe`),Input 边界 `71/71`(run + `6486380f-e19f-40ed-9816-5e411a0c3310`); +- rebase 集成最终验证:Protocol 全量 `3348/3348`(run `6109c928-7081-4f8c-9287-bbb8cecfa842`),workspace + 全量 `16011/16011`、17 skipped(run `01185ece-f856-41a6-864c-d8be46cfe2f5`),workspace all-target clippy + `-D warnings`、fmt 和 diff check 通过;workspace release build 通过,`target/release/moli` SHA-256 为 + `6d7f513b3bf6fa84184ae984395af9f847478d4d2e4fcaa6dfc2beb6eb1e0172`。完全清空大小写 HTTP/HTTPS/ALL/FTP + proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP smoke `244/244`、WebDriver + Classic/BiDi/Selenium/semantics smoke `157/157`; +- 本次 rebase 只做 master 语义与既有 owner 边界的窄集成,没有扩大 Phase 3 范围。下一步仍先执行严格 exit audit, + 再优先推进 protocol-neutral outcome/fact channel 或 Host/frontend lifetime;不能因为这些集成修复宣称 Phase 3 + 已完成。 + +必须新增的 actor regressions: + +- `Page.navigate` response 后 parser script 导航,不发下一命令也进入 B; +- frontend 完全不 enable Page domain,renderer navigation 仍执行; +- 插入/删除 noop command,browser trace 完全相同; +- slow protocol output queue 不延迟 replacement HTTP request start; +- old Page intent 在 B commit 后被 stale-drop。 + +Exit gate:protocol scheduler 中不存在 renderer-sourced top-level navigation execution authority。 + +### Phase 4:command navigation 与 renderer navigation 汇合 + +状态:已完成(第76切片 exit audit)。第49切片已迁移 raw CDP 顶层跨 Document `Page.navigate` 的 admission/start authority,第50切片再把 +selection 后的 participant lifetime 迁给 Browser Host,第51切片拆开 Host terminal 中已有的 neutral outcome 与 Protocol +projection sidecar,第52切片又把 response-head early response 改成 neutral outcome + frontend projection,第53切片把 +raw CDP 顶层 same-Document `Page.navigate` 的分类/start authority 也迁入 exact Browser Host turn,第54切片再迁移 raw CDP +顶层 `Page.reload` 的 admission、current-URL selection 与 participant lifetime,第55切片把 raw CDP +`Page.navigateToHistoryEntry` 的 destination resolution、Document 分类与 participant lifetime 汇入同一 lane,第56切片再把 +带 DCL/load wait 的 BiDi/Classic 顶层 navigate/reload 接入该 mailbox 与 Host participant loop,第57切片继续迁移 direct +history traversal,并删除 Classic 的 frontend history snapshot/entry 选择,第58切片再把 Page.crash/Page.close 的 exact +terminal action 接入同一 mailbox,第59切片又把显式 top-level `Target.closeTarget` 的 ordinary admission、Core commit、 +retired-Page disposal 与 retained-Target promotion 接入同一 Host participant lane,第60切片再把 popup/auxiliary Target +navigation 从 Protocol scheduler 搬到 protocol-neutral Browser Owner input + Host participant lane,第61切片最终删除 +Page/Target termination 的 paused-fetch Protocol admission,第62切片继续把 direct BiDi/Classic `wait:none` 顶层 +navigate/reload/history 的 admission/start authority 汇入同一 mailbox,第63切片再迁移 raw CDP `Page.stopLoading` 的 +admission、current-Document selection 与 renderer stop participant,第64切片继续把 stop-loading paused-Fetch cancellation 的 +每个真实 renderer wait 暴露为同一 Host participant lane,第65切片再迁移 raw CDP request-stage 主文档 +`Fetch.failRequest` 的 admission/decision participant,第66切片继续迁移 raw/nested CDP request-stage 主文档 +`Fetch.continueRequest` 的 exact request mutation、network fetch、auth transition 与 Document build participant,第67切片再把 +raw/nested CDP 主文档 `Fetch.continueWithAuth` 的 terminal/retry decision 与后续 network/Document participant 收入同一 lane; +第68切片在 Phase 4 exit audit 后先补齐 BrowserContext exact instance capability,作为 context disposal owner admission 的必要 +前置条件;第69切片再把 production raw CDP 与 typed BiDi/Classic 的 `Target.disposeBrowserContext` admission、logical reservation、 +paused-Fetch/Page cleanup participant lifetime 和 terminal Context removal 接入同一 Browser Host lane;第70切片继续把 raw/nested CDP +response-stage 主文档 `Fetch.continueResponse` 的 admission、response release 与 Document build 接入同一个 +`ResolvePausedNavigation` participant lane;第71切片再把 raw/nested CDP 主文档 request/response-stage +`Fetch.fulfillRequest` 与 response-stage `Fetch.failRequest` 接入该 lane;第72切片继续把 production typed BiDi/Classic +terminal Fetch decision 暴露成 application scheduler task:main Document 进入相同 Owner lane,subresource 保留 exact Page +participant,二者等待期间都不借用 `CdpConnection`;第73切片再删除 download network/artifact action 对 frontend response flush 的 +progress dependency,并以有界 projection gate 保持 response/event 顺序;第74切片继续把 `Target.createTarget`、 +`Runtime.runIfWaitingForDebugger` 与 `Page.enable` 的 initial target URL replacement 汇入同一个 protocol-neutral Browser Owner input; +第75切片又把 renderer-produced top-level history delta 从 output projector 的 session-routed direct completion 改为 exact Page +`RendererTopLevelHistoryTraversalInput`,由 Browser Host selection 解析 Core history 并持有后续 Page participant;第76切片最终把 +`Page.createIsolatedWorld` 的 initial-target prerequisite 迁成 opaque Browser command + frontend continuation sidecar,并删除旧 nested +navigate participant 与 direct helper。child-frame 仍是 Page/renderer action;无 Host loop 的 direct-call compatibility wrapper 不再是 +production blocker,detached `wait:none` completion transport 归 Phase 5 outcome audit。background-target audit 已确认普通 parked Page +navigate/reload/history completion 不会 promotion 或绕过 exact owner。Phase 4 Exit gate 已通过:production +`ProtocolSchedulerWork` 不含 navigation、replacement、popup 或 termination browser-owner payload。loaded-Document tail 仍可能进入既有 +`MainDocumentLoadOwnerAction` compatibility residence,但它是 Phase 5 fact/outcome subscriber 迁移项,不再误算为 Phase 4 navigation +action。 + +目标:所有 top-level owner action 进入同一个 Browser Owner queue。 + +按风险依次迁移: + +1. `Page.navigate`; +2. reload、history traversal、same-document navigation; +3. popup/auxiliary target navigation; +4. Page/Target termination; +5. download navigation 和 `Page.stopLoading`; +6. background target park/promote 中的 navigation completion。 + +每迁一类必须删除对应 protocol execution path,不能长期双写。 + +command response 使用 Browser Core accepted/completed outcome;CDP/BiDi/Classic 各自附加 wait policy。 + +2026-08-03 第四十九切片实现记录(raw CDP top-level cross-Document `Page.navigate` start cutover): + +- Core 新增 protocol-neutral `BrowserFrontendCommand::Navigate` 与 `BrowserCommandId`。mailbox input 不包含 CDP + request id、session、domain subscription、wire payload 或 socket;只包含 opaque correlation、exact + `PageResidenceIdentity`、URL 和 referrer; +- raw CDP frontend 仍负责参数解析以及 child-frame/same-document 分类。确认是顶层跨 Document 后,它只把 command + publish 到 `BrowserHostHandle` 并等待一次 exact start reply;不再直接调用 network/navigation start。Host 未安装或 + 已停止会返回 typed publication error,禁止回退到旧 direct path; +- CDP id/session/result payload 保存在 `CdpConnection` 的临时 prepared projection,以 opaque `BrowserCommandId` + 与 Core turn 关联。Core actor selection 后,physical executor 先重验 exact Page residence,再取出 projection 并启动 + 现有 navigation state machine,因此 stale Page 返回 `NoSuchTarget`,不会在 successor Page 上执行; +- actor 只把已经启动的 `PageCommandTaskStep` 交还 frontend command 的 participant chain。等待期间 production actor + 仍独立 poll Browser Host;若 frontend 在 start reply 前消失,send 失败会把该 step 留给 Browser Host participant + chain,而不是取消已选择的 Browser action。完整 protocol-neutral accepted/completed outcome 留到本阶段后续切片, + 本切片不冒充 Phase 5 fact channel。actor 已成功交出 reply 后,后续 participant 当前仍属于该 frontend command; + 因而“selection 后 frontend 消失也不影响 completion”尚不是本切片保证; +- 保留既有 raw CDP `Page.navigate` 的 background/early-response policy;迁移只改变 start authority,不把导航改成 + foreground drain。全量初跑曾把该 policy 错设为 foreground,稳定暴露 lifecycle、child-frame、download 与 response + ordering 回归;恢复 background policy 后代表性五项 WebSocket ordering 回归通过,禁止以后为了 owner cutover 再改变 + frontend wait policy; +- non-flattened `Target.sendMessageToTarget` 原先在 Target completion 内调用 direct helper 并 inline await 完整 nested + command。nested `Page.navigate` 进入 Browser Host 后,这会持有应用 turn、无法回到 actor `select!`,在全量矩阵最后 + 两项稳定悬挂。现在 outer Target command、nested command context、scheduler sidecar 与 renderer boundary 都由 + `PendingSendMessageToTargetCommand` move-own;每一段 nested pending 都返回 application actor,Browser Owner selection + 后再恢复原 Target wrapper。shared direct-turn settle 只负责最终 output projection,不获得 Browser Host execution + authority; +- 新增 exact regression:frontend dispatch 后 URL 保持 `about:blank` 且 actor mailbox 恰有一个 command;只有 + Browser Host selection 后才产生 navigation participant,最终 response 保留原 CDP id/session 并提交目标 URL。 + 另一个回归在 selection 前丢弃 frontend wait,验证 Browser Host 接管 participant 后目标 URL 仍提交;两项 + owner-selection 回归包含在 Page navigation `77/77`(run `54234356-b6aa-4acf-9e5c-34fb12097064`)。nested Target + owner-selection/full-response 回归 `2/2`(run `52977f74-364b-4340-abdb-d3934a10d0ae`),所有 + `sendMessageToTarget` 回归 `7/7`(run `60dc0d29-4ae2-4665-a6f8-62f30b73ff32`),Target domain 全组 + `557/557`(run `67c3797a-8578-4ae0-8921-82ab42da9e67`); +- workspace 首轮在 HTTPS proxy CONNECT auth 用例暴露唯一 legacy fixture:该用例直接赋值 physical + `browser_context`,没有调用 `insert_browser_context` 注册 Core Target/Page authority,因此 owner-selected navigation + 正确返回 `NoSuchTarget`。修复只把该 fixture 改为统一注册入口,未放宽 407/ExtraInfo/loadingFailed/errorText 断言; + exact stress `10/10`(run `d7a1a428-f6a5-43bc-a9c8-79bf38b967cc`)、Fetch navigation-subresource + `28/28`(run `17e2a1cb-f72b-4ab8-b6fb-04bc860bd1d6`),最终 workspace nextest + `16013/16013`(run `729c58a1-d21a-4b92-b3b3-66cc3d20f3d4`); +- 最终 gate 通过 `cargo fmt --all --check`、`cargo clippy --workspace --all-targets -- -D warnings` 和 + `cargo build --workspace --release`。release binary SHA-256 为 + `55863f8d8b1d2ae3b9802f4e9c4a1f59e3b9d41cc405699eba1490c882f7e1a6`;清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认全组 + `244/244`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套 JSON 均为 + `ok=true`、失败列表为空; +- 本切片刻意不迁移 same-document/child-frame 分类、reload/history 或 BiDi/Classic direct adapter,也不为了“零 await” + 清理 Runtime/preload wrapper。下一刀先决定 command participant completion 是否应继续回到 frontend,还是由 + Browser Host 保持到 accepted/completed neutral outcome,再按同一边界迁移 reload/history。 + +2026-08-03 第五十切片实现记录(Browser Host-owned command navigation participant lifetime): + +- actor selection 后不再把 `PageCommandTaskStep` 交给 frontend。`BrowserHostPageStepCompletion` 随 + `PendingBrowserHostTurn` / `CompletedBrowserHostTurn` move-own terminal disposition;application-side + `BrowserHostExecutionLane` 注册每一段 exact wait,并把 completion 作为独立 owner wake 重新 apply。frontend 是否 poll + 自己的 command reply 不再决定 network、renderer commit、replacement 或 navigation tail 是否继续; +- prepared projection 只给 Browser Host 一份共享原 command response-flush lifetime 的空 detached + `CommandDispatchContext`,不复制 frontend 已产生的 event/boundary。terminal 时 live frontend 收到 + `CompletedBrowserOwnerNavigateCommand { plan, context }`,再把 renderer predecessor、insertion boundary 和 + post-response ordering 原样接回原 command;它只会收到 terminal plan,不会再次获得 pending participant; +- frontend 在 selection 前或 selection 后丢弃 receiver 时,oneshot send failure 不取消 Browser action。Browser Host + 删除 abandoned command response,但通过 `into_composite_command_prefix` 保留 owner/background events、renderer + boundary、predecessor 与 scheduler sidecar,继续由应用 owner loop 投影;因此不能以“没人收 response”为由丢掉 + 已发生的 browser side effect; +- non-flattened `Target.sendMessageToTarget` 仍必须在原 outer Target wrapper 内封装 nested response/event。为此 live + receiver 接收 terminal Protocol projection,而不是让 Browser Host 旁路写入 CDP socket;这是 Phase 5 fact journal + 建立前的显式迁移形状。下一切片应把 terminal plan 拆为 protocol-neutral accepted/completed outcome 与独立 projection + sidecar,而不是复制这份 Protocol completion 到 reload/history; +- 新增三条 exact lifetime 回归:frontend 在 selection 前丢弃、selection 后丢弃、以及 frontend 保持 live 但完全不 poll + terminal reply,Browser Host 都能提交目标 URL;live frontend 最终仍只投影原 id 的一次 response。owner-selection + `3/3`(run `0ff81b30-f724-45fb-b294-bd5d7d975a4d`),slow frontend exact `1/1`(run + `2df7f868-e8df-49f4-a554-0d1240c7f039`),Page navigation 全组 `79/79`(run + `f63d18c3-d6cb-48e5-8d7d-a949664b603b`),`sendMessageToTarget` `7/7`(run + `cb9f0968-08cd-4dc7-ac4c-47bc178663d8`),application Browser Host wake 回归 `1/1`(run + `6062324a-f456-4edb-a6b5-4f0e3b5cc2ce`); +- protocol package 首轮 `3352` 项仅有一个无关 run-immediately binding 用例在全并发下失败;exact stress `20/20` + (run `399b70bc-3587-48e6-bd58-6ab5c0460a21`)和 lifecycle 模块 `57/57`(run + `3d45e8b2-3dca-4b49-9647-dd017787b1f0`)均通过;protocol package 原命令重跑 `3352/3352`(run + `26f07707-2604-4d6b-9dac-02c822754f80`),最终 workspace `16015/16015`、17 个既有 skip(run + `6316b0d9-4163-4ade-ac25-08340ac936f4`)。没有用 retry/sleep 或放宽断言掩盖,也没有在缺少因果证据时修改无关 + production 路径; +- 最终 gate 通过 `cargo fmt --all --check`、`git diff --check`、 + `cargo clippy --workspace --all-targets -- -D warnings` 和 `cargo build --workspace --release`。release binary + SHA-256 为 `40b0350045b3f2e1bd926b0877a0325dab376f9067eaf8ebf1c0edb4a320b1d1`;清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认全组 + `244/244`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套 JSON 均为 + `ok=true`、失败列表为空。 + +2026-08-03 第五十一切片实现记录(neutral navigate outcome / exact Protocol projection sidecar): + +- Core 新增独立模块 `browser_host/command_outcome.rs`,定义 + `BrowserNavigateCommandOutcome::{Completed, Rejected}`、protocol-neutral result/error/kind。`Completed` 只表示 command + response policy 已到 response boundary,不表示 Document 已达到 DCL/load;类型不保存 frontend command id、session、 + CDP code、JSON 或 output route; +- Protocol 新增聚焦模块 `domains/command_output/browser_navigate.rs`。Browser Host terminal 先从 + `CommandOutputPlan` 拆出 typed outcome,再把剩余 owner/background events、post-response events、renderer predecessor 与 + insertion boundary 留在 `BrowserNavigateCommandProjection`。sidecar 只保留 CDP response 的 exact insertion index、是否在 + renderer boundary 之前、session inheritance、wire error code/data 和未知扩展字段;frontend 收到 terminal reply 后才在 + 原 command 或 non-flattened `Target.sendMessageToTarget` wrapper 内复原一次 wire response; +- response 与 renderer boundary 相邻时,删除 response 后两者索引可能相等,不能根据新索引猜原侧。sidecar 因而显式冻结 + `before_renderer_boundary`,投影时只按该事实恢复 boundary;单元回归同时锁住 response 前后 Browser event、未知 result + 字段、Target/loader、error code/data 与 session shape。若 frontend 已丢弃,Browser Host 直接消费 sidecar 的 effects-only + plan,既不制造 `id: null` response,也不丢 Browser event/fence; +- 一个正确 terminal plan 最多有一个 response。异常的多 response 或 outcome/projection shape divergence 不使用 production + panic;adapter 记录 error 并收敛成一个 internal command error。普通 successful background navigation 的 Host terminal + plan 当前没有 response,因为 `BackgroundNavigationEarlyResult` 会在 response headers ready 后直接向 Protocol background + channel 发送 typed CDP response;本切片将其表示为 `outcome: None`,不把“Host participant 已结束”误写成“command response + 已 ready”。下一切片应迁移这个 early-response producer,而不是把 `None` 长期扩散到 reload/history; +- outcome/projection 单元与现有 Page navigation、frontend selection 前/后 drop、slow frontend、Browser Host wake、nested + `sendMessageToTarget` 聚焦合计 `91/91`(run `3882b100-8596-4e9f-a7b6-fbd7b839def5`);Protocol package + `3355/3355`(run `ab5b8b9e-f121-4dd1-96aa-733c085f2c77`);workspace `16019/16019`、17 个既有 skip + (run `68466a36-0601-48e1-b08f-22ee9dcdbb99`); +- `cargo fmt --all --check`、`git diff --check`、`cargo clippy --workspace --all-targets -- -D warnings` 和 + `cargo build --workspace --release` 均通过。固定 `target/release/moli` SHA-256 为 + `2753ab1d01803880d4317c2b6a99aed78be23c9c021568f6f4c747ac43fe9a41`;清除大小写 HTTP/HTTPS/ALL/FTP proxy + 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `244/244`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`;两套 JSON 均为 `ok=true`、失败列表为空。 + +2026-08-03 第五十二切片实现记录(early navigate outcome / shared Browser-frontend output FIFO): + +- 删除 network load job 中保存 raw `navigate_id + session_id + result_payload`、并直接构造 + `BackgroundProtocolEvent::command_success` 的 `BackgroundNavigationEarlyResult`。新 + `BackgroundNavigationEarlyOutcome` 只持有 shared output sender 与 `BrowserNavigateCommandOutcomeDelivery`;到达 inline/data + Document commit 或普通 HTTP response headers boundary 时,它发布 Core `BrowserNavigateCommandOutcome`,不在 producer + task 内构造 wire response; +- Protocol sidecar 继续独立保存 `FrontendCommandId`、`DevToolsSessionKey` 和第51切片的 + `BrowserNavigateCommandProjection`。Core outcome 仍只保存 requested URL、Target/loader、error/download 分类,不新增 + CDP id、session、JSON、wire code 或 event route。frontend 消费 delivery 时才按 sidecar 恢复 exact command response; + projection 异常记录 error 并收敛为一个 internal command error,不增加 production panic; +- 不能为 neutral outcome 另开 mpsc。HTTP 路径当前依赖同一 sender 上的 `requestWillBeSent/responseReceived` progress、early + command response 和后续 Network progress 的 exact FIFO;两个 channel 即使都先 drain ready prefix,也无法判断“另一个 + channel 中的 outcome”发生在某个 Network event 之前还是之后。为此新增聚焦模块 + `conn/background_output.rs`:private `BrowserBackgroundOutput::{ProtocolEvent, NavigateCommandOutcome}` 共用一个物理 + channel,`BackgroundEventSender` 兼容包装既有 fact producer,`BrowserBackgroundOutputReceiver` 是唯一 projection + ingress。application、TestContext、CDP/BiDi/Classic loops 只消费投影后的 event,不获得 Browser execution authority; +- FIFO 单元回归直接读取物理 carrier,锁住 `Protocol event -> neutral navigate outcome -> Protocol event`,并证明中间元素在 + receiver projection 前不是 `BackgroundProtocolEvent`。early producer 回归再锁住原 id/session/result wire shape;Page + navigation/participant `81/81`(run `c553c3d6-6f6e-43cd-b94e-fa3217c0eed3`)、nested + `Target.sendMessageToTarget` `7/7`(run `42866be3-3c75-428e-bf6d-52924e4987f3`)、application scheduler ordering + `4/4`(run `e85ba5c2-efea-46a0-bd18-17314ebed40a`),outcome/FIFO 聚焦 `5/5`(run + `c53a2120-a5bf-4899-8d7e-3df6c2915975`); +- Protocol 全包 `3356/3356`(run `a37af90a-250d-4fec-80e7-1cd61dc20cb4`);workspace + `16020/16020`、17 个既有 skip(run `27349be2-5050-494a-bc14-3ac6f640edc6`)。 + `cargo fmt --all --check`、`git diff --check`、`cargo clippy --workspace --all-targets -- -D warnings` 和 + `cargo build --workspace --release` 均通过;固定 release binary SHA-256 为 + `bce3714d3f1cf13873297b5e352d970b0684a31715f8599c13a15c1f9a534785`;清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `244/244`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套 JSON 均为 `ok=true`、失败列表为空; +- 这是 Phase 4 的 command outcome transport 切片,不是 Phase 5 fact journal。Network/lifecycle/Target producer 仍可直接 + 生成 `BackgroundProtocolEvent`,channel 仍是 frontend-owned unbounded migration queue,Browser Host lifetime 也没有 + 因此独立。下一刀应审计 reload/history/same-document 中哪一条已真正取得 owner authority,并让它复用同一 neutral + command outcome seam;不得把 mixed FIFO 包装描述成最终 journal,也不得为了消除 `outcome: None` 延迟普通 + `Page.navigate` 的 response-head ack。 + +2026-08-03 第五十三切片实现记录(raw CDP top-level same-Document `Page.navigate` owner classification): + +- raw CDP `Page.navigate` frontend 只保留参数解析与 child-frame 判断;所有顶层命令都冻结 exact + `PageResidenceIdentity` 并 publish 同一个 protocol-neutral `BrowserFrontendCommand::Navigate`。frontend 不再在 publish 前 + 读取 current URL、决定 same/cross Document,也没有 Host unavailable 时的 direct fallback; +- Browser Host turn 先以 exact residence 解析 owner route,再以 none-session owner override 读取该 Page 当前 URL并执行 + fragment classification。same-Document 分支也以 none-session owner route start/complete Page participant,原 frontend + session 只留在外层 pending response projection;它不能成为 renderer operation 的 execution route。cross-Document + 分支保持第49—52切片既有 start、background response-head ack 与 neutral outcome policy; +- frontend 预建的 cross-Document result payload 在 same-Document 分支不再成为事实。Browser Host 依据 frozen owner Target + 重建 `{ frameId }` result,明确不生成 `loaderId`,最终仍通过第51切片的 neutral outcome + Protocol sidecar 恢复原 CDP + id/session。`Page.navigatedWithinDocument` concrete publication 走独立 renderer scheduler ingress;测试在完全不 poll + command receiver 时先观察到该 publication 与 URL/history projection,之后才取 frontend response; +- 新增 owner-selection 回归锁住:dispatch 后 mailbox 为 1 且 URL 未变;Browser Host selection 后 same-Document + participant 独立完成;renderer ingress 不等 frontend;response 保留原 session/frame 且无 `loaderId`。exact `1/1` + (run `7ef321b1-2793-4da9-8798-68bd6c8130d6`)、same-document 聚焦 `14/14` + (run `2e8bd848-7055-4ab4-aadd-645171b03b3e`)、Page navigation `80/80` + (run `e65400c2-0832-4ed6-a2c1-31ff8661768b`)、nested `sendMessageToTarget` `7/7` + (run `ed829be1-c791-4c93-a46e-15107dc5527f`)、Browser Host independent wake `1/1` + (run `19d6cdfa-2415-4a59-a24c-dcedeb29519d`),Protocol package `3357/3357` + (run `1db41986-ad5c-4973-8b1b-adbfa9cae329`); +- 首次 workspace 全量为 `16020 passed / 1 failed / 17 skipped`(run + `a78b0610-62ef-481d-a34c-a27393f7125b`)。唯一失败是既有 renderer lifecycle FIFO witness 在 2 秒后 timeout, + 不是顺序反转:fixture 在 Page 创建返回后无条件 `try_recv` 清空 publication queue,高负载下会删掉已经到达的 exact + DCL/action 证据。测试修复删除该无条件清空,仍按 exact Page 与 record kind 过滤,且保留严格 + `lifecycle -> action` 断言和原 timeout;修复前 exact stress `20/20` 未复现(run + `b581a220-ed63-4a47-a1a1-b6135ee0f764`),修复后 exact `50/50`(run + `6092cd5f-0dc1-4bc4-9e2c-98af43aa9e99`)、相邻 handler-navigation 6 项 × 20 轮 `120/120` + (run `63c96a54-edb9-4c75-b883-d64a759f2a90`)、renderer package `7013/7013`、3 个既有 skip + (run `47d05f08-2271-477c-b5d3-afe9e9cd663b`)通过。该 test-only 修复独立提交,不改变 renderer production + ordering、断言、timeout 或重试 policy; +- 最终 workspace 全量 `16021/16021`、17 个既有 skip(run + `f9aeb89a-3ada-402b-b5b4-9dc0e9e58638`),workspace all-target clippy `-D warnings`、fmt 和 diff check + 均通过;workspace release build 通过,`target/release/moli` SHA-256 为 + `0786599c8871eff90f50c07062fab2fbefa45919dbd59d9e83bc9ace5aa0fff4`。显式清空大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认全组 + `244/244`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`;两套 JSON 均为 + `ok=true`、失败列表为空; +- 本切片只关闭 raw CDP 顶层 same-Document command 的 owner classification。renderer-sourced same-Document intent 已走 + Browser Owner action,但 history traversal、child-frame 与 protocol-neutral direct adapters 仍有各自 authority;下一刀 + 优先把 raw CDP reload 的 admission/start 变成新的 protocol-neutral Browser frontend input,不回到逐 wrapper 清零。 + +2026-08-04 第五十四切片实现记录(raw CDP top-level `Page.reload` owner cutover): + +- Core 新增 `BrowserFrontendCommand::Reload` / `BrowserReloadCommandInput` 与独立 + `BrowserOwnerInputKind::FrontendReload`。input 只保存 opaque `BrowserCommandId`、exact `PageResidenceIdentity`、 + `ignoreCache` 和 `scriptToEvaluateOnLoad`,没有 CDP id/session/JSON/result route,也刻意没有 frontend 快照的 current URL; + reload options 穿过 neutral boundary,但本切片不改变 physical engine 对这两个既有参数的支持程度; +- raw CDP frontend 仍负责 `ReloadParams` 解析和既有 `BrowserContextNotLoaded` / `TargetNotLoaded` 前置错误;确认存在 exact + Page 后只 publish Browser Owner input,并以 detached command context 等待 terminal projection。Host 未安装或停止仍返回 + typed publication error,禁止恢复 direct fallback; +- navigate/reload 没有各建一份 prepared registry 或 completion channel。原 navigate-only correlation map、pending/completed + envelope 与 Page completion variant 已泛化为 frontend navigation 共用结构;Browser Host executor 先把 Core command + 归一化为物理 navigation action,再统一取一次 prepared Protocol sidecar。现有 + `BrowserNavigateCommandOutcome` / projection seam 继续承载空 reload response,frontend session 只在最终 `{}` response + projection 中出现,不参与 execution route; +- Browser Host 只有在选择 exact turn、通过 `target_page_owner_route_if_current` 后,才以 none-session owner route 读取当前 + URL、收集 crash recovery observers、标记当前 history entry 为 reload replacement 并启动 Page participant。stale residence + 直接返回 `NoSuchTarget`,不能 reload successor Page;raw CDP owner path 与尚未迁移的 direct adapter 共用 + `start_reload_current_page_command`,没有复制 history/request/commit 语义; +- Core neutral-input exact 回归 `1/1`(run `d49ca64e-5111-4e96-bcef-5d3211ce9603`),Browser Host 边界 + `98/98`(run `b2514cde-fa91-4592-8a77-1b834a9712d5`)。reload owner 回归证明 frontend dispatch 后 mailbox 为 1 + 且 HTTP request count 不变;Browser Host 在完全不 poll frontend receiver 时完成第二次 request/replacement,之后 frontend + 才收到原 session 的空 result。该 exact 回归最终 `1/1`(run `6b83d1e8-7e4f-426f-bdfa-b11f718db7a9`),reload + 相关过滤 `66/66`(run `f2d97da8-9e87-4c9c-89d8-d17898dfc1f8`),Page navigation `80/80` + (run `65b2b782-eb2f-4b3f-bacd-7ede30be4ebe`); +- Protocol 首次全量的唯一失败是既有 preload run-immediately 用例:同名 child world 偶发已收到 top-level script。单独 + `50/50` 通过,但 lifecycle 模块 `20` 轮复跑在第 15、19 轮复现,证明它依赖 suite load/order,而非 reload 路径;仅把 + fixture 改成 production-shaped exact owner 安装后,模块复跑仍失败 `2/20`,因此没有把该修改误当成修复。最终定位为测试 + 在 child Document 尚未发布 exact `Page.frameStoppedLoading` 时注册 preload,合法地与 child world materialization 竞争; + fixture 现在先等待该 child frame 的 exact stopped-loading fact,再清空事件并注册脚本,没有增加 sleep/retry/timeout 或 + 放宽断言。相邻同类用例 `50/50`(run `06071cbb-04b8-4ef0-9241-a90c6d569d05`)、lifecycle 模块 + `1140/1140`(57 tests × 20,run `b3a72282-51dd-4f27-b03a-c78ca6712b6b`); +- 修正测试同步后 Protocol 全量 `3356/3356`(run `0fbbe029-35f7-4743-b994-4f239cf793ca`),workspace 全量 + `16021/16021`、17 skipped(run `deedd704-ef4f-462b-b0da-e3d79aab1464`); +- `cargo fmt --all --check`、`git diff --check`、`cargo clippy --workspace --all-targets -- -D warnings` 与 + `cargo build --workspace --release` 均通过;固定 `target/release/moli` SHA-256 为 + `7e7da1d0202776f3258992138cf62773349363bbb7b097bf391d04b509c02b48`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `244/244`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套 JSON 均为 `ok=true`、失败列表为空; +- 本切片不宣称 Phase 4 完成。raw CDP history traversal 仍在 frontend 解析 destination/current history 并直接启动 + participant,child-frame navigate 和 BiDi/Classic protocol-neutral adapters 也仍有 direct authority;下一刀优先审计并迁移 + raw CDP `Page.navigateToHistoryEntry`,同时保持 same-Document/cross-Document destination classification 在 exact owner turn。 + +2026-08-04 第五十五切片实现记录(raw CDP history traversal owner resolution): + +- Core history 模块新增 protocol-neutral `BrowserHistoryTraversalDestination`、 + `BrowserHistoryTraversalResolution` 与 typed error。entry、delta、current-entry no-op、目标 URL 和 + `document_sequence_number` 的 same/cross-Document 分类现在只在 `BrowserNavigationHistory::resolve_traversal` 实现;原 + Protocol snapshot 分类算法已删除,旧的 frontend `entry_id -> URL` lookup API 也从 Core/Protocol 整条删除; +- `BrowserNavigationOwner::resolve_exact_navigation_history_traversal` 先按 `PageResidenceIdentity` 重验 generation,再允许 lazy + seed 与 destination resolution。stale Page 返回 exact typed error,既不能读取 successor history,也不改变 cursor/seed; + direct DevTools entry/delta adapter 暂未迁入 mailbox,但已只做 destination shape/error 投影并调用同一个 Core resolver; +- raw CDP frontend 只解析 `entryId`、冻结 exact Page 并 publish + `BrowserFrontendCommand::TraverseHistory(BrowserHistoryTraversalCommandInput)`。input 不含 destination URL、current index、 + same-Document flag、CDP session/id/JSON;navigate/reload/history 继续共用既有 prepared correlation map、Host participant 与 + neutral outcome/projection seam,没有新增 history queue 或 completion channel; +- Browser Host 选中 input 后进入 exact none-session owner route,在同一 turn 调用 Core resolver。no-op 直接完成; + same-Document 先启动 renderer history delta participant,renderer 拒绝时才按既有语义 fallback 到 URL navigation; + cross-Document 先标记 Core pending traversal,再启动同一 Browser navigation request/commit pipeline。missing entry 保留 raw CDP + `-32000 / Navigation history entry not found`,stale residence 使用 `-31998 / NoSuchTarget`; +- exact 聚焦回归 `5/5`(run `50deab10-1fc2-4769-a043-274a91ee76d6`):Core 锁住 Document-sequence 分类且 resolution 不移动 + cursor,stale Page 不能解析;cross-Document background Target 在 frontend receiver 完全未 poll 时完成 traversal 且不 promotion; + missing entry 只在 owner turn 解析;same-Document traversal 在 response receiver 未 poll 时仍发布 renderer fact、保持原 Page + residence 与 JS realm。Core history 过滤 `70/70`(run `83cbb689-f60b-412b-933d-0f19ccf6e4b8`),Protocol history + `37/37`(run `1e70bdec-3593-4c85-9a11-782946041a5b`),Page navigation `81/81` + (run `2e118e4c-33c3-4ecb-bb48-0420d62014de`); +- Protocol 全量 `3356/3356`(run `9cf1f7ee-dee9-4b09-a19f-19a4e9d9d937`),workspace 全量 + `16024/16024`、17 skipped(run `7f84665c-1a0f-4c81-a80d-c958e0140f30`); +- `cargo fmt --all --check`、`git diff --check`、`cargo clippy --workspace --all-targets -- -D warnings` 与 + `cargo build --workspace --release` 均通过;固定 `target/release/moli` SHA-256 为 + `be39221cf2b09691dc3940efc2cf1c657d671988c094c4cd0e56f56c9231ebdb`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `244/244`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套 JSON 均为 `ok=true`、失败列表为空; +- 本切片只关闭 raw CDP entry-based history command 的 owner authority。BiDi/Classic/direct DevTools traversal 仍在调用方本地 + admission/drain;child-frame `Page.navigate` 属于 Page/renderer owner,不应仅为“所有 Page 命令都入 Browser queue”而错误 + 提升为 top-level Browser action。下一刀先审计 direct frontend navigation adapter 的 Host composition 与 wait policy,再决定 + 迁移该入口,或转向 popup/termination 这类明确的 Browser owner action。 + +2026-08-04 第五十六切片实现记录(direct BiDi/Classic navigate/reload owner admission): + +- production BiDi/Classic 的顶层 `Navigate` / `Reload` 在 wait policy 为 DCL 或 load 时,先通过新的 + `navigation_owner_adapter` 冻结 exact Page residence,再 publish 既有 protocol-neutral + `BrowserFrontendCommand::{Navigate, Reload}`;current URL、same/cross-Document 分类、HTTP start 与 replacement 仍只在 + Browser Host 选中 turn 后发生。adapter pending envelope 只 move-own Browser completion、DevTools context 与 result + projection,不保存 `&mut CdpConnection`,也没有 Host unavailable 时的 direct execution fallback; +- application 新增 navigation dispatch participant loop。它等待 direct frontend reply 时,在同一个 scheduler owner turn 中 + interleave Browser Host selection、exact participant completion 与 background output;每个已经 dequeue 的 concrete input 都完整 + apply 后才重新竞争 reply/timeout。foreground navigation 持有显式 renderer ingress gate,新 Page stream 只有在 terminal Host + projection 提供的 exact renderer boundary 上才能定向进入;不能在 replacement 的 root-Document binding 安装前由普通 renderer + receiver 抢跑。frontend timeout 把 move-owned completion 交给 `BrowserHostExecutionLane`,该 lane 继续持有同一 gate、完成 projection + 并释放 exact stream;frontend receiver drop 不取消已经 publish 的 Browser command。child-frame navigate 明确保留 Page/renderer + route,不能为了形式统一提升为 top-level Browser action; +- `allow_background_navigation` 是 Protocol prepared sidecar 的 response-boundary policy,不进入 Core neutral input。raw CDP + 继续使用 response-head early outcome;direct DCL/load 命令使用 foreground Host participant。Classic reload 没有 wire command + id,因此 prepared sidecar 以 `BrowserCommandId` 生成只在 physical Page pipeline 内使用的 opaque correlation id,使 terminal + neutral outcome 能返回原 waiter;该 id 不进入 Core execution semantics,也不暴露到 CDP/BiDi/Classic result; +- `wait:none` 本切片刻意保留既有 direct background path:它的 early completion 目前仍进入第52切片的 mixed physical FIFO, + 在 Phase 5 neutral outcome receiver 建立前再增加一条 direct correlation queue 会重新引入跨 channel 猜序。direct history + traversal 也尚未迁入 mailbox;这两项是显式 migration boundary,不是静默 fallback; +- 调度顺序回归暴露了一个既有错误假设:Browser Host terminal completion 与 renderer DCL/load publication 是两个独立输入, + Host completion 先到时,drain deferred load work 并不能证明 exact lifecycle fact 已被 scheduler dequeue。external navigation + wait 现在对 DCL 和 load 都按 frozen Document token + lifecycle epoch + milestone 观察;renderer 内部状态已经 `Reached` 仍不能 + 代替 frontend 实际观察到对应 fact。DCL delivery 因此也携带 exact renderer Document/epoch,而不再只有 frame/loader 文本; +- 最初实现允许 pending direct navigation 的普通 renderer receiver 与 Host completion 竞争,稳定暴露出一个真实违反路径:新 Page + 的 child-frame attach 先按旧 root-Document binding 进入 Protocol 并被 stale-drop,随后 load fact 却能进入,最终 BiDi `getTree` + 看不到 renderer 已存在的 iframe。修复点是上述 commit-before-renderer ingress gate,不是增加 tail drain;iframe/DCL/timeout 组合 + 聚焦 stress `10/10`、共 `40/40` execution 通过(run `20c13508-21f4-4ddc-8142-7a5c40366399`); +- Fetch auth pause 是另一种边界:该 Browser turn 可以只发布 auth-required/browser effects,而原 navigation command result 要由 + `continueWithAuth` 以同一 correlation 稍后完成。direct adapter 保留既有 `MissingDevToolsCommandResult` 非终态,不把它改写成 + `BrowserNavigateOutcomeMissing` 真错误,也不伪造 terminal outcome;该精确 BiDi 回归 stress `20/20` 通过(run + `7e9ff6ff-3730-435c-8b57-7cb3d99dca1c`)。Fetch continue 仍是 Phase 3/4 的 compatibility participant,后续 neutral fact/outcome + channel 应接管这段 correlation; +- exact Protocol 回归证明 direct BiDi command 的 frontend reply 完全不 poll 时,Browser Host 仍先完成 same-Document action并 + 发布 renderer fact,之后才投影 typed reply。Page navigation 模块 `82/82`(run + `f3848157-d387-4d8c-aabc-2cc92bddecfc`);最终 Protocol 全包 `3357/3357`(run + `35496ad9-08c1-4e85-8e07-fc940f1a9ef3`);最终 application 全包 `562/562`(run + `0a85f966-5bf7-4ef3-b419-42938e4fa5bd`);workspace 全量 `16026/16026`、仓库既有 skip `17`(run + `64f8de94-6a79-40e8-890f-705d9bb0d588`)。workspace clippy、fmt check、release build 均通过;清空代理后 release + 默认 CDP smoke 返回 `ok:true`,WebDriver `classic,bidi,selenium,semantics` 四组也返回 `ok:true`。没有 sleep、retry、timeout + 放宽或把旧 Document 的 DCL 判成假事件; +- 本切片不宣称 Phase 4 完成。下一刀应优先迁移 direct history traversal,或进入 popup/Target termination 这类明确的 + Browser-owner action;`wait:none` 应等待 neutral outcome ingress 设计,不为追求“零 direct wrapper”而建立临时第二队列。 + +2026-08-04 第五十七切片实现记录(direct BiDi/Classic history traversal owner admission): + +- `BrowserHistoryTraversalCommandInput` 从 entry-only 改为携带 protocol-neutral + `BrowserHistoryTraversalDestination::{Entry, Delta}`。它仍只包含 opaque command id、exact Page residence 和 destination; + current cursor、destination URL、Document sequence、frontend session/id/JSON 都不跨 Core input boundary。raw CDP 继续提交 + entry,BiDi 和 Classic 提交 delta,三者由同一个 Browser Host turn 在重验 exact Page 后调用 Core resolver; +- `navigation_owner_adapter` 现在接收带 DCL/load wait 的 direct `TraverseHistory`,复用第56切片的 Browser completion waiter、 + application Host participant loop、timeout detach 与 renderer ingress gate,没有建立 history 专用 queue 或 inline drain。 + `wait:none` 仍显式返回既有 background path,原因与 navigate/reload 相同:Phase 5 前 neutral early-outcome correlation 尚未 + 建立,不能为了形式统一再增加第二条跨 channel 结果队列; +- Core terminal `BrowserNavigateCommandResult` 新增可选 `BrowserHistoryTraversalResult::{Noop, SameDocument, + CrossDocument}`。Browser Host 初始 resolution 冻结分类;若 renderer 的 live same-Document history operation 返回 false 并 + 进入 URL fallback,exact participant completion 把分类改成 `CrossDocument` 后再生成 neutral outcome。BiDi/Classic projector + 因此只把 neutral metadata 变成 `Empty` 或 `TraverseHistory { same_document }`,不读取 mutable Page/history;raw CDP wire response + 仍保持 `{}`; +- Classic back/forward 删除 production `GetNavigationHistory` 前置命令、frontend cursor arithmetic、entry URL snapshot 和对应 + helper,直接提交 `Delta(-1/+1)`。Browser Owner 对越界返回 typed `NoSuchHistoryEntry`;Classic frontend 按 WebDriver + 语义把它映射为成功 no-op,BiDi 仍投影 `no such history entry`,没有把协议差异写进 Browser authority; +- 聚焦 Core/Classic command/三组 BiDi/Classic integration 共 `7/7`(run + `4c706769-aaa6-45b3-a83d-5b14ebd43baa`);exact history integration 首轮 `4/4`(run + `4f04ff34-2175-4bf1-9408-20892f63d281`)后按 race-prone async 验证纪律执行 `20` 轮 stress,共 `80/80` + execution,无 flaky(run `e88c24d4-0705-4492-ba8f-ec5605df58e9`)。同一组还覆盖 delta + cross-Document、delta zero no-op、hash/pushState same-Document、iframe rejection、restored same-Document entry 的 URL fallback + 与 Classic 初始越界 no-op; +- workspace 首轮全量 `16028/16028`、仓库既有 skip `17`(run + `8d3fded3-5b65-4dca-ba7e-b8201f04d2ec`)。本切片没有 sleep、retry、timeout 放宽或 assertion 弱化;也没有为了清理一个 + wrapper 新建状态机,改动只关闭 direct history 的 frontend authority; +- `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过; + `target/release/moli` SHA-256 为 + `1e80df5dc9ed48e773ff258d88b9044774d9af876d49f2ca30cfcad0a1b7fb40`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与原 no-proxy、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 release binary 后,CDP 默认 smoke 与 + WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 两套 JSON 均为 `ok=true`、失败列表为空; +- 本切片不宣称 Phase 4 完成。下一步应在 popup/auxiliary Target 与 Target termination 中选择一个真正的 Browser-owner + action,或者先为 `wait:none` 设计单一 neutral outcome ingress;不能继续以“Protocol 中还有 await”为理由清理与 Browser + progress 无关的局部 frontend wait。 + +2026-08-04 第五十八切片实现记录(Page termination Browser Owner admission): + +- Core `BrowserOwnerInput` 新增 `PageTermination(BrowserPageTerminationInput)`;payload 只 move-own Phase 2 已建立的 exact + `BrowserTargetTerminationRequest`,没有 command id、session id、frontend lifetime、subscription 或 response route。queued + close/crash 因此在 actor selection 后仍按 captured Page generation 重验,replacement 后只能 stale-drop,不能通过当前 session + lookup 关闭 successor Page; +- `ProtocolSchedulerWork` 把旧的混合 termination variant 拆成 `PageTargetTerminationAdmission` 与 + `TargetCloseOwnerAction`。前者是 renderer-output predecessor 的一次性准入 gate:ready turn 只 publish Core input,Target 在该 + protocol turn 后仍保持 live;真正的 Core commit、physical Page absence 与 crash/close projection 只能由下一次 actor-selected + Host turn 执行。后者刻意保留显式 `Target.closeTarget` 的现状,因为跨 BrowserContext close 仍可能先 await engine handoff; +- Browser Host participant envelope 从仅支持 `PageCommand` 扩为 typed `PageCommand | PageTermination`。termination start 在一个 + 无 await 的短 turn 内完成 Core commit 与 matching physical absence;若存在 retired renderer Page,才 move-own Page 并在 + application completion mailbox 中异步 dispose,完成后投影 Inspector/Target events。没有新增 sleep、retry、poll loop 或 + direct fallback,也没有让 Core 接触 `BackgroundProtocolEvent`; +- stateful `TestContext` 补齐 production mailbox 的独立 wake:protocol admission 新发布的 Browser input 被排成后续 concrete + `BrowserOwnerTurn`,而不是递归执行。手工 boundary 回归同时证明 admission 后 Target 仍 live、actor turn 才 retirement;另一个 + exact-generation 回归证明 queued old-Page close 在 replacement 后无事件 stale-drop。聚焦 `4/4`(run + `ca74fe93-774a-471f-ba7c-570224b0c2c8`),同组 `20/20` stress、共 `80/80` execution(run + `da413dcd-d6ed-4ff0-a756-6932992075c8`),全体 crash/close 相关 Protocol 回归 `116/116`(run + `c420ee18-f725-4eff-8b7c-e62a2769e317`); +- workspace 全量 `16029/16029`、仓库既有 skip `17`(run + `72bbc89f-984d-469f-ae0f-75f37f340758`);`cargo fmt --all --check`、`git diff --check`、workspace + all-target clippy `-D warnings` 与 workspace release build 均通过。`target/release/moli` SHA-256 为 + `7e8f6f97698f6082f74e45192937345c94d741cd854fdec76f70f0da89e15c57`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 release binary 后,CDP 默认 smoke + 连续两次均为 `ok=true`,计数复核 `244/244`;WebDriver Classic/BiDi/Selenium/Semantics + `--continue-on-failure` 连续两次均为 `ok=true`,计数复核 `157/157`; +- 本切片不宣称 termination 全部迁移,更不宣称 Phase 4 完成。下一刀应单独设计显式 `Target.closeTarget` 的 context-selection/ + engine-handoff participant,或迁移 popup/auxiliary Target;不能把 Page termination 已汇流外推为 BrowserContext disposal、worker + Target close 或所有 termination 都已离开 Protocol。 + +2026-08-04 第五十九切片实现记录(top-level `Target.closeTarget` Browser Owner admission): + +- Core `BrowserOwnerInput` 新增独立的 `TargetTermination(BrowserTargetTerminationInput)`,继续只 move-own exact + `BrowserTargetTerminationRequest`。显式 close 的 command/session/result 与 Target/Inspector event projection 留在 frontend; + mailbox input 不包含 CDP、BiDi、Classic identity,也不允许 Protocol 构造 raw Host turn; +- ordinary `Target.closeTarget` 在 pending fetch/inspector cleanup 后只 publish Host input。frontend response 可以先投影,真正的 + Core termination、physical Target/Page absence 与 retained background Target promotion 只能由 actor-selected Host turn 执行; + 未安装或已停止 Browser Host 时返回 typed Internal error,不再记录错误后仍谎报 `success: true`。若 cleanup 先产生 exact + renderer predecessor,则 `TargetCloseAdmission` 只负责在该 cursor 之后 publish 同一个 Core input,不再拥有 execution path; +- Browser Host participant envelope 增加与 Page termination 分离的 `TargetClose` variant。start turn 同步完成目标 + BrowserContext selection、Core termination commit 与 matching physical projection;retired renderer Page disposal、promoted + Page 的 header/network/script/fetch/surface state synchronization 分别由 move-owned participant 承担,完成值带 exact Page + residence,不能按后来 frontend session 或“当前 Page”重新发现 owner。旧的 async helper只作为尚未迁移的 context-disposal/test + adapter drain 同一状态机,不再包含第二套 termination 实现; +- 临时 BrowserContext selection 不跨 participant wait:start turn 在返回 retired-Page participant 前先恢复原 selection;后续 + completion 若要 promotion,只在一个同步 owner apply turn 内临时选择目标 context、启动下一 participant,并立即恢复该 turn + 开始时观察到的 selection。若等待期间真实 owner turn 已从 A 切到 C,旧 close completion 最终仍保持 C;promoted Page 的 exact + completion 也只有在其 context 仍 selected 时才刷新全局 active loader,不能让 inactive-context completion 重置新 owner; +- 模块继续按责任拆分:Core input/admission 位于 `browser_host/owner_input.rs`,Host turn/participant composition 位于 + `conn/browser_host_turn_executor.rs`,Target close transaction 位于 `conn/browser_target_termination.rs`,retained Target engine + handoff 位于 `conn/browser_target_engine_handoff.rs`,CDP/automation event completion 留在 + `domains/page/termination.rs`。没有把这些职责重新堆进 Target command dispatcher; +- exact 回归覆盖 mailbox selection 前 Target 保持 live、replacement generation stale-drop、participant 不持有临时 context + selection、Host 缺失 typed rejection,以及 response 在前、Inspector/Target detach event 在后且 typed sidecar 不丢失。 + Target close 最终源码聚焦 `41/41`(run `6e137243-2ce8-4727-af31-730691bda9e0`);其中 mailbox selection、context + selection 与无 legacy fallback 三项按 race-prone async 纪律执行 `20` 轮,共 `60/60` execution,无 flaky(run + `50b8af91-1c33-41f6-843a-619d419f765f`)。Protocol 全包 `3362/3362`(run + `49f98e44-bbfe-4297-856a-fe169c58aa02`); +- workspace 最终全量 `16033/16033`、17 个仓库既有 skip(run + `657cd14f-1d06-419c-b863-eca2a605de41`);`cargo fmt --all --check`、`git diff --check`、workspace all-target + clippy `-D warnings` 与 workspace release build 均通过。固定 release + `target/release/moli` SHA-256 为 `3855f219fb259aea4e1cfe95be783c6d5baf92472f1c7b0581ae9698c1427854`; + 显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、清除 inherited smoke group、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP + 默认全组 `244/244`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套 JSON 均为 + `ok=true`、失败列表为空; +- 本切片不宣称 Phase 4 完成。`ProtocolSchedulerWork::TargetCloseAdmission` 的 renderer-predecessor gate、popup/auxiliary + navigation、direct `wait:none`、Fetch continue 与 BrowserContext disposal 仍是明确迁移边界;下一刀应在这些真正的 + Browser-owner action 中选择,不回头按 Protocol `await` 数量雕花。 + +2026-08-04 第六十切片实现记录(popup/auxiliary Target navigation Browser Owner cutover): + +- Core 新增独立模块 `browser_host/auxiliary_navigation.rs`,定义 protocol-neutral + `BrowserAuxiliaryNavigationInput` 与 `BrowserAuxiliaryNavigationKind`;`RendererBrowserIntent::AuxiliaryNavigation` + 只携带 captured Page-slot authority、URL 与 `InitialDocument | NamedTargetReuse` browser kind,不包含 + `CommandOwnerScope`、session、CDP event、opener projection 或 socket lifetime; +- 两类 authority 被明确分开。`InitialDocument` 只为替换 exact bootstrap Document,actor selection 时必须仍匹配原 + Page generation,并再次确认 initial navigation 仍需要;`NamedTargetReuse` 是已接受给同一 browsing context/Target + 的新 navigation request,允许该 Target 自己的上一轮导航推进 generation,但必须匹配稳定 Page-slot instance。 + Core 新增 same-slot resolution:它可跨 generation,却会拒绝删除后以同一 public targetId 重建的新 Target/Page slot; +- Protocol publication adapter 在 popup target 创建或 named-target resolution 已完成后直接 publish Browser Host + mailbox。Host turn 才选择 exact input、建立既有 `PageCommandTaskStep`,network/renderer/configure/commit 等等待继续 + 作为 move-owned `PendingBrowserHostTurn` participant 返回 application owner loop。initial navigation terminal 后的 + `Target.targetInfoChanged` 仍由 Target projector 生成,并用同一稳定 Page-slot instance 防止事件进入同 id 新 Target; +- 删除 `conn/popup_navigation_work.rs`、`PopupTargetNavigationOwnerAction`、对应 `ProtocolSchedulerWorkKind/payload/ready` + variant、publish method、protocol-output async completion 和仅供该旧路径使用的测试 scheduler 停驻分支。Popup + navigation 不再持有 Protocol `CommandOwnerScope`,也不存在 Host 缺失时的 direct/scheduler execution fallback; +- 扩大 popup/window-open/auxiliary 相关矩阵首轮在 `51/52` 暴露确定性语义回归:同一 JS command 对同一 named Target + 连续接受 first/second 两次 navigation 时,若把 named action 错绑 Page generation,first commit 后 second 会被 stale-drop。 + 这证明 named action 的正确 authority 是 exact Target/Page slot 而不是 exact Document generation;修正后 Core payload、 + stable-slot、initial stale-drop 与全部相关矩阵 `54/54` 通过(run + `1e0593f7-77d7-4f92-852e-a1a404c03146`)。同-command named reuse、initial generation stale-drop、background named + reuse 三项执行 `20` 轮、共 `60/60` execution,无 flaky(run + `bf46feae-1cb1-4f50-a7de-894f4e2d6936`); +- Protocol 全包 `3363/3363`(run `f25bd6ec-86c6-4ecb-8fac-cae74e154894`);workspace 全量 + `16036/16036`、17 个仓库既有 skip(run `831088e7-2df1-49d3-94c0-e777c51e94d4`)。`cargo fmt --all --check`、 + `git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过;固定 release + `target/release/moli` SHA-256 为 `25c122cb344024e35a4a49b236e478c8e0ccb7f1cf626412201bbe11d7783092`; +- 显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,设置 `NO_PROXY=*` / + `no_proxy=*` 后,CDP 默认全组连续两次均为 `ok=true`,计数复核 `244/244`;WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` 连续两次均为 `ok=true`,计数复核 `157/157`; +- 本切片不宣称 Phase 4 完成。`TargetCloseAdmission`/Page termination 的 paused-fetch renderer predecessor 仍暂住 + Protocol,direct `wait:none`、Fetch continue 和 BrowserContext disposal 也尚未全部汇入 owner lane;下一刀应围绕这些 + Exit gate payload/owner action,而不是继续清理只影响 frontend 自身延迟的局部 `await`。 + +2026-08-04 第六十一切片实现记录(termination predecessor admission cutover): + +- Page.crash/Page.close 与显式 `Target.closeTarget` 在完成 pending-fetch cancellation 后都直接 publish 已捕获的 exact + `BrowserOwnerInput::{PageTermination, TargetTermination}`。删除 `PageTargetTerminationOwnerAction`、 + `publish_page_target_termination_owner_action`、`publish_target_close_admission` 以及 + `ProtocolSchedulerWorkKind/payload/ready` 中两种 termination admission;Protocol residence 不再 move-own、延迟或二次发布 + Browser termination input; +- ordering 不靠新的 queue 或 sleep。若 cancellation 产生 `RendererOutputFence`,command context 继续持有该 exact fence;同一 + application owner turn 返回 command completion 后,production `flush_completed_command_output` 必须先把 fence 对应的 renderer + publication 送过 ordered ingress,才能刷新 response 并回到外层选择已独立唤醒的 Browser Host turn。Host input publication + 本身不执行 termination,actor selection 前 Target 保持 live;没有让 Browser Host 读取 client-turn predecessor; +- Host 未安装或已停止时不再先回成功后日志丢弃。Page.close/Page.crash 投影 + `-32000 BrowserHostPageTerminationAdmissionFailed`,Target.closeTarget 投影 typed Internal + `BrowserHostTargetCloseAdmissionFailed`;已经生成的 pending-fetch terminal events 与 renderer predecessor 仍保留,且 rejected + admission 不修改 Target authority; +- exact Page direct-admission、Page Host rejection、Target 带 predecessor direct-admission/Host rejection、既有 Target ordinary + Host rejection 共 `5/5`(run `4cf74845-92ed-4702-81ce-90a7ec251066`);Page direct boundary、Target predecessor + direct boundary、Page/Target paused request-stage cancellation 四项执行 `20` 轮,共 `80/80` execution,无 flaky(run + `f2e3f298-21d6-44ab-a0f4-354f5e808909`);扩大 crash/close/termination 矩阵 `128/128`(run + `5d63b463-e506-4747-95ab-ec8d989a8d0a`); +- `moli-protocol` 全量 nextest `3366/3366`(run `84ef4a69-74bb-467a-bba3-fd09c94cbae1`),workspace + 全量 nextest `16039/16039`、既有 skip `17`(run `97e32aba-bc28-4c47-b42c-2b0aa12270ae`); + `cargo check --workspace --all-targets`、`cargo fmt --all --check`、`git diff --check`、 + `cargo clippy --workspace --all-targets -- -D warnings` 与 `cargo build --workspace --release` 均通过。release + `moli` SHA-256 为 `e1533c29cce224596abdb0b25c82c98ac04c0abdc0a4557eb630e994105d5e1a`;清理代理后连续两轮 + CDP smoke 均为 `244/244`,连续两轮 WebDriver Classic/BiDi/Selenium/semantics smoke 均为 `157/157`; +- 本切片通过了 Phase 4 的“Protocol scheduler 不再包含 termination browser-owner payload”子 gate,但不宣称 Phase 4 完成。 + direct `wait:none`、Fetch continue、BrowserContext disposal 以及 download/stopLoading/background-target 边界仍需单独 audit, + 不能把 termination admission 清零外推为所有 top-level owner action 已汇流。 + +2026-08-04 第六十二切片实现记录(direct `wait:none` navigation Owner admission cutover): + +- `navigation_owner_adapter` 删除按 wait policy 把顶层 `Navigate`、`Reload`、`TraverseHistory` 退回 Page direct execution + 的分支。三类命令现在都先冻结 exact Page residence 并 publish 既有 + `BrowserFrontendCommand::{Navigate, Reload, TraverseHistory}`;current URL、same/cross-Document 分类、history cursor 与 + network start 仍只在 actor-selected Host turn 内解析。child-frame navigation 继续属于 Page/renderer owner,没有为了 + “所有 navigation 都进顶层 queue”而改变责任边界; +- `wait:none` 仍保留自己的 response policy,而不是伪装成 DCL/load wait。对成功进入 detached cross-Document load 的 + `CompleteImmediate` start,Host 用 exact Page Target 与 action kind 构造 protocol-neutral accepted outcome;typed frontend + 在这个 start boundary 返回,commit、DCL、load 与 replacement 作为后续 Browser progress 独立发生。Noop、same-Document、 + Fetch pause 与 start error 仍使用各自 exact plan/outcome;实现没有解析 Protocol JSON 来反推 Browser identity; +- direct admission 会清除迁移期 `background_command_id`,因为 typed result 已由 exact Host reply correlation 返回。detached + load 仍发布 Browser effects,但其 terminal completion 不再携带旧 command id,不能在 background output FIFO 中制造第二个 + response。response-free projection 显式保留 renderer fence、Network/Page event 与 post-response segment,不把“无第二个 + wire response”误写成“无 Browser effects”; +- 聚焦回归覆盖三类命令在 actor selection 前均不执行、Navigate/Reload/History 的 typed result、detached Navigate 在 commit + 前即可返回,以及 background completion 不保留第二个 command correlation。最终聚焦 `3/3`(run + `cf87ed2f-acb8-46fb-bc0c-396627e052cf`);两条 race-prone async 边界各执行 `20` 轮,共 `40/40` execution,无 flaky + (run `80dc0d78-5238-4461-8577-26590eee3217`)。最终 Page navigation 扩大矩阵 `84/84`(run + `8a65450a-636f-4ee8-a429-5f9903302510`);raw CDP background handoff、BiDi `wait:none` 返回/后续 command/preload + 与 Classic page-load strategy none 的 application transport 矩阵 `5/5`(run + `d0ec73f8-038f-4f5b-9c57-932ce7c4f6c2`),同五项各执行 `20` 轮、共 `100/100` execution,无 flaky + (run `60f86583-574e-44e6-bb03-8cdc9fa5c098`); +- `moli-protocol` 全量 nextest `3369/3369`(run `3b02a333-f8e5-4435-abfd-f60f73af15eb`),workspace + 全量 nextest `16042/16042`、既有 skip `17`(run `dd67f431-3c71-49c6-99f9-6dd0d7b44541`)。 + `cargo check --workspace --all-targets`、`cargo fmt --all --check`、`git diff --check`、 + `cargo clippy --workspace --all-targets -- -D warnings` 与 `cargo build --workspace --release` 均通过;release + `moli` SHA-256 为 `8fc67a51e1230e774ec0534632a359ffd919bd425ae336c5fa18ce5027bf0ab9`。显式清除 + 大小写 HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,设置 `NO_PROXY=*` / `no_proxy=*` 后,默认 + CDP smoke 连续两轮均为 `244/244`、`ok=true`,WebDriver Classic/BiDi/Selenium/Semantics + `--continue-on-failure` 连续两轮均为 `157/157`、`ok=true`; +- 本切片不宣称 Phase 4 完成,也不把 accepted outcome 当成 DCL/load fact。detached load 的 completion、lifecycle 与 Network + output 仍通过既有 background navigation completion/output transport 回到 application owner loop;Fetch continue、 + BrowserContext disposal、download/stopLoading/background-target audit 也仍未完成。下一刀应优先关闭这些 owner action 或 + 建立 Phase 5 neutral fact/outcome channel,不能继续按 Protocol 中局部 `await` 的数量雕花。 + +2026-08-04 第六十三切片实现记录(`Page.stopLoading` Browser Owner admission cutover): + +- Core 新增 protocol-neutral `BrowserFrontendCommand::StopLoading`。input 只携带 opaque `BrowserCommandId` 与 exact + `PageResidenceIdentity`,不包含 CDP request/session、wire payload、domain subscription 或 output queue。其 hidden slot + instance capability 防止已删除 Target 的同 id 重建被误命中;admission 时的 generation 只是基线,Host FIFO 真正选中 turn + 后才以 same-slot authority 解析当时的当前 Document。这使排在 replacement 后面的 stop 命令作用于 successor,而不是冻结 + frontend 解析时偶然可见的旧 Document; +- Browser Host 选中后以 target-owner route 启动真实 `StopDocumentLifecycle` Page command,并把 pending renderer wait + move-own 到 `PendingBrowserHostTurn::StopLoading`。`Page` 的 stop API 拆成 start/finish 两段,finish 会保留 exact + `RendererOutputFence`;等待期间不借用 actor 或 `CdpConnection`。completion apply 前重新验证 exact generation,replacement + 后的旧 renderer completion 直接成功 stale-drop,不能刷新 successor Page state,也不能消费 successor 的 Fetch bucket; +- frontend 原有 `PendingPageCommandKind::StopLoading` fake pending、`complete_stop_loading_command_dispatch` 和临时切换/恢复 + active BrowserContext 的 direct path 已删除。Host 未安装或停止会返回 typed + `BrowserHostStopLoadingAdmissionFailed`,不会重建 Protocol scheduler/direct fallback。live frontend 只投影 response;若它在 + selection 前后丢弃 wait,Host 仍完成 action,并只结算 Browser-visible events/fence,不产生迟到 wire response; +- Chromium ownership 证据与这个边界一致。在本地 Chromium source commit + `a03603fe9af6230a12f1b2fb2c18a7d003a0d937` 中,browser-side + `content/browser/devtools/protocol/page_handler.cc::PageHandler::StopLoading` 调用 + `WebContentsImpl::Stop`;后者遍历 `FrameTree::StopLoading`,`FrameTreeNode::StopLoading` 以 `ERR_ABORTED` 取消已启动的 + `NavigationRequest`,并由 `RenderFrameHostManager::Stop` 向 current/speculative renderer 发 stop。用本地 Chromium + `147.0.7709.0` 的 browser-session CDP probe 验证:request-stage `Fetch.requestPaused` 的主文档导航收到 + `Page.stopLoading -> {}` 后产生 `Network.loadingFailed(net::ERR_ABORTED)`,紧接着的 successor navigation 正常提交; + 另一个已进入 provisional inactive RFH 的 slow-response probe 返回 `-32000 Not attached to an active page`,说明 Chromium + 还存在 RFH-active 的精确 error 语义。Moli 本切片只迁移既有 stop/cancel 行为的 owner,不在缺少同构 RFH 状态时伪造 + 该错误;它作为后续兼容性差异保留; +- 最终 stop-loading 聚焦矩阵 `12/12`(run `18268442-d894-45cb-94d3-efb38b49c256`);current-generation、stale completion + 与 frontend-drop 三条跨等待边界各执行 `20` 轮,共 `60/60` execution,无 flaky(run + `f78d8e23-6627-48b0-932d-2bc124b6c21e`)。Page navigation/stop-loading/browser-owner-input 扩大矩阵 `93/93` + (run `ed30532c-0150-4c81-940a-8a84be71bd5b`); +- `moli-protocol` 全量 nextest `3373/3373`(run `87874237-2ed3-4aa5-8d9e-b356e47e731b`),workspace + 全量 nextest `16047/16047`、既有 skip `17`(run `1294c3d1-3b0c-42c7-80a6-92cf4ab6d7ad`)。 + `cargo check -p moli-core -p moli-protocol --all-targets`、`cargo fmt --all --check`、`git diff --check`、 + workspace all-target clippy `-D warnings` 与 workspace release build 均通过;release `moli` SHA-256 为 + `62a9d2146d5b86b3bb064b0651fb8ca1f6cb550d65ee2b83f9f8496a2adc53b8`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,默认 CDP smoke + 连续两轮均为 `244/244`、`ok=true`,WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 连续两轮均为 + `157/157`、`ok=true`; +- 本切片不宣称 Phase 4 完成。stop action 的 admission/selection/renderer stop authority 已唯一,但 + `fail_pending_fetch_state_background_events_async` 仍在 Host completion apply 中 inline 等 paused-navigation/subresource + cancellation materialization;Fetch continue、BrowserContext disposal、download/background-target 与 detached navigation + completion 也仍在清单中。下一刀应从这些真实 owner/progress 边界或 Phase 5 neutral fact/outcome channel 中选择,不继续按 + frontend 局部 `await` 数量雕花。 + +2026-08-04 第六十四切片实现记录(`Page.stopLoading` paused-Fetch participant chain): + +- 新增独立 `domains/page/fetch_cancellation.rs`,把 pending main navigation、auth navigation、response-stage navigation 以及 + subresource request/auth/response 的取消收敛为一个 move-owned 状态机。它保持既有分组 FIFO 顺序,每次最多启动一个真实 + renderer/navigation participant;`wait()` 消费 exact capability,completion 回到 Browser Host mailbox 后才同步 apply 并启动 + 下一项,Host completion 不再一次 inline drain 整个 cancellation batch; +- stop-loading task 现在显式区分 `RendererStop` 与 `FetchCancellation` 两个 phase。renderer stop 完成后先按第63切片冻结的 + `TargetPageResidenceIdentity` 重验当前 Document,再从该 owner route 取走 paused Fetch state;每个后续 subresource + participant 又冻结其自己的 installed Page identity,并在 finish 前再次重验。若中途 replacement,旧 completion 被 stale-drop, + successor Page 新注册的 Fetch work 保持 pending; +- main-document cancellation 复用既有 `CompletedNavigateCommand::materialized` 与 + `complete_pending_navigate_command`,因此 navigation failure 后可能产生的 replay/tail 也继续作为 Host 可见 participant,而不是 + 被新 helper 隐藏。原 `navigate_session_id` 在 action start 前从 navigation state 取出,只作为 command response/event 的 + projection destination;执行 route 由 exact Page owner 决定,不能通过同 id 的 frontend session 重新选择 Page; +- renderer stop 与全部 cancellation 产生的 `RendererOutputFence` 按同一 stream tail 合并,只有 participant chain terminal 后才 + 生成一次成功 response plan。frontend 在 selection 前后断开只丢弃 response projection,不取消 chain。该状态机目前仍携带 + `BackgroundProtocolEvent` sidecar,因此这不是 Phase 5 的 protocol-neutral fact channel; +- termination、target detach 与 BrowserContext disposal 等尚未迁入 Host participant lane 的调用方,继续调用 + `fail_pending_fetch_state_background_events_async`,但该 compatibility helper 已删除旧的六段独立循环,改为本地 drain 同一个 + cancellation 状态机。后续迁移这些 owner 时只需暴露现成 participant boundary,不再复制行为; +- 回归把 current-Document selection 测试扩展为两个 paused subresource,显式观察 renderer-stop 后连续两个 Host + participant,并新增 replacement 恰好发生在 subresource cancellation wait 中的 stale-apply 用例。stop-loading 聚焦矩阵 `13/13`(run + `e2e605e9-18f5-42e8-b7af-5c20969e9f22`);current generation、replacement stale-drop 与 frontend-drop 三条边界各执行 + `20` 轮,共 `60/60` execution,无 flaky(run `f7aa6845-9dd0-465c-bc34-4123170d40e8`);termination/context-disposal/ + Fetch-disable compatibility 扩大矩阵 `35/35`(run `7347db5a-67e8-4288-b7ea-1dbbbb1694ad`); +- `moli-protocol` 全量 nextest `3374/3374`(run `6e690de4-f08a-4d3f-bb17-b77ab407ae36`),最终源码树的 workspace + 全量 nextest `16048/16048`、既有 skip `17`(run `0e79bcf4-4a9d-4b11-afde-632b680e4016`)。`cargo fmt --all --check`、 + `git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过;release `moli` SHA-256 为 + `b3a474c42644f2ae1bbfe49315bcef1c11c9b03735c10f86cb354537ff6030c6`。显式清除大小写 HTTP/HTTPS/ALL/FTP + proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组连续两轮均为 + `244/244`、`ok=true`,WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 连续两轮均为 + `157/157`、`ok=true`、失败列表为空;实现未加入 sleep、retry、`yield_now` 或第二条 owner queue; +- 本切片只关闭 stop-loading 的隐藏 renderer wait,不宣称 Fetch continue、BrowserContext disposal、detached navigation + completion、download/background-target、neutral fact channel 或独立 Host lifetime 已完成。下一刀应优先选择这些仍会改变 + Browser Owner progress/lifetime 的真实边界,不能因为共享状态机已经存在就把所有 compatibility caller 一次性塞进同一提交。 + +2026-08-04 第六十五切片实现记录(raw CDP request-stage `Fetch.failRequest` owner decision cutover): + +- Core 新增 `BrowserFrontendCommand::ResolvePausedNavigation` 和 + `BrowserPausedNavigationDecision::Fail`。input 只携带 opaque `BrowserCommandId`、exact `PageResidenceIdentity` 与 failure text, + 不携带 Fetch request、CDP id/session、wire response、domain subscription 或 output queue;这为后续 continue/auth/fulfill + 共用同一 paused-navigation decision 边界,但本切片只实现无网络 I/O 的 terminal fail; +- Protocol prepared sidecar move-own `PendingFetchNavigation`、detached `CommandDispatchContext` 与 reply sender。raw CDP + `Fetch.failRequest` 取走 request 后先按 pending navigation 的 Target route 捕获 exact Page,再发布 owner input。Host 未安装或 + mailbox 已停止时 sidecar 会把 request 交还原 exact owner bucket并返回 typed admission error;禁止内联执行旧 completion; +- 新增独立 `domains/fetch/navigation_decision.rs`。由于 `BrowserHostTurnExecutor` 是同步 selection seam,首个 participant 只做 + selection 到 async apply lane 的 move-owned handoff;apply turn 再次验证 exact generation、切到无 session 的 owner route, + 使用既有 `MaterializedNavigationCompletion` / `complete_pending_navigate_command` 执行失败与后续 renderer replay/tail。每个真实 + tail wait 继续作为独立 Host participant 返回,等待时不借用 `CdpConnection`; +- 原 `navigate_id` / `navigate_session_id` 在执行前从 navigation state 取出,只在 terminal 时把 navigation plan 投影为原 + `Page.navigate` 的 background response/event;outer `Fetch.failRequest -> {}` 仍保持既有先后顺序。若 selection 后 Page 被 + replacement,old generation 只生成 `Navigation aborted` 给原 navigation,不发 `Network.loadingFailed`、不 discard successor + physical Page。若 frontend completion receiver 已丢失,Host 仍结算原 navigation 的 failure/event,只删除迟到的 Fetch response; +- typed BiDi/Classic `DevToolsCommand::FailInterceptedRequest` 仍在单个借用 `CdpConnection` 的 async frontend future 内。若在该 + 形状中 publish 后原地等待 Host reply,反而会阻止 Host 取得 physical executor,因此它明确保留 compatibility completion; + response-stage `PausedDocumentTransfer::fail` 也未纳入本切片。迁移它们必须先暴露 scheduler-visible frontend task,不能把 + “所有 Fetch await 清零”当作目标; +- 回归覆盖 owner admission/participant 顺序、replacement generation stale-drop、frontend wait loss,以及 Host stopped 时 request + 恢复且无 direct fallback。Core neutral input 聚焦 `1/1`(run `9a8f15f9-7ea2-4853-a9e8-ea3076730b2e`),最终 + Fetch fail 矩阵 `15/15`(run `c72f3b45-ae95-401e-8ebf-e5cbb8298db6`);current owner、replacement stale-drop + 与 frontend-loss 三条边界各执行 `20` 轮,共 `60/60` execution,无 flaky(run + `1a7fb8a8-30b5-4e67-bda5-e5003479010e`)。Fetch fail/disable、stop-loading、termination 与 context-disposal 扩大兼容 + 矩阵 `57/57`(run `559d66fa-1810-4479-8d7f-5faeb15abe61`);publication-failure 大载荷装箱后的 request 恢复聚焦 + `1/1`(run `17f3e3c0-6ca0-4d1e-bc77-5472bf6b8946`); +- `moli-protocol` 全量 nextest `3378/3378`(run `8cab79af-f471-46fc-967c-ab42a321297a`),workspace + 最终源码树全量 nextest `16053/16053`、既有 skip `17`(run `b0b294c2-69f0-4d89-99df-16a47ec66c41`)。 + `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过; + release `moli` SHA-256 为 `5b45b68decdf49405229ee8ec1cae6ac361f8c547e08088fbb349152149161f8`。显式清除 + 大小写 HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP + 默认全组连续两轮均为 `244/244`、`ok=true`,WebDriver Classic/BiDi/Selenium/Semantics + `--continue-on-failure` 连续两轮均为 `157/157`、`ok=true`、失败列表为空;实现未加入 sleep、retry、`yield_now` + 或第二条 owner queue; +- 本切片仍携带 Protocol `CommandOutputPlan` / `BackgroundProtocolEvent` projection sidecar,不是 Phase 5 fact channel,也不宣称 + Fetch continue、auth/fulfill、BrowserContext disposal、detached navigation completion、download/background-target 或独立 Host + lifetime 已完成。下一刀应优先设计 request/auth/response continue 的 move-owned network job,或进入 neutral fact/outcome + channel;不能把 response-stage streaming 与 context disposal 强塞进本提交。 + +2026-08-04 第六十六切片实现记录(raw/nested CDP request-stage `Fetch.continueRequest` owner cutover): + +- Core 的 protocol-neutral `BrowserPausedNavigationDecision` 新增 `Continue`,只保存解析后的 URL、method/body/header override 与 + response-stage intent;它不携带 Fetch request id、CDP session、wire response 或 output queue。raw/nested CDP frontend 取走 + paused main-Document request 后只发布 exact Page-scoped decision,真正 mutation 发生在 actor-selected Host apply turn。Host 未安装/ + 已停止时 sidecar 把完全未修改的 request 恢复到原 owner bucket,不允许退回 inline frontend execution; +- 新增独立 `domains/fetch/navigation_resume.rs`。普通无 interception load 直接复用 `BackgroundNavigationLoadJob` 与既有 + `PendingNavigateCommand`;intercepted request 的 streaming fetch、auth challenge stream collection、buffered/streaming response + Document build、response-stage prepared Document 各自是 move-owned participant。每次 completion apply 前都由外层 + `navigation_decision` 重新验证 frozen `PageResidenceIdentity`,等待期间不借用 `CdpConnection`,也没有新增第二条 owner queue; +- `runtime_load` 把上述网络/renderer wait 拆成只持有 frozen load inputs、resource client、NavigationEngine 与 exact future-Page + reservation 的 job。buffered/streaming build 在 `DocumentCommit` boundary 返回,再进入共享 navigation configure/commit/ + replacement/tail 状态机;401/407 先作为独立 body collection participant 完成,再同步注册原 authRequired continuation; + response-stage pause先完成 exact prepared-Document participant,再登记可继续的 streaming body source。旧 inline helper 复用同一 + preparation job,未维护第二套 renderer build 语义; +- 原 `navigate_session_id` 在 Continue 路径继续保留在 paused navigation state,因为 auth/response-stage 后续 command 仍需沿原 + frontend projection route 完成;Browser execution authority 由 exact Page owner 决定,session 只影响最终 response/event + destination。frontend wait 被丢弃后 Host 仍完成 network/commit,并只丢迟到的 `Fetch.continueRequest` response;replacement + generation 变旧时在发起下一 participant 前返回原 navigation 的 `Navigation aborted`,不能请求网络或安装 successor Page; +- 真实 Playwright network smoke 暴露了 Host participant completion 与 frontend pending-command completion 之间的 handoff + 窗口:如果 application scheduler 在这个窗口读取 raw renderer stream,`MainDocumentCommit` 会在 physical Page 尚未安装时按旧 + loader stale-drop,随后 DCL/load 可以先于 `Page.navigate` response 到达,最终 `Page.goto(waitUntil=load)` 因缺少 + `frameNavigated` 悬挂。修复由 exact `PendingCdpCommandDispatch` 持有既有 + `NavigationRendererPublicationBuffer` gate,从 owner decision publish 一直覆盖到该 command 的 renderer insertion boundary;Host + 自身继续推进,gate 不依赖 frontend socket flush,也不依赖 Host reply oneshot 与 actor `select!` 的先后。command requeue 时根据下一 + phase 重新计算 permit,terminal completion 在同一 actor turn 先发布 Continue/Page.navigate response prefix,再释放 + `MainDocumentCommit`、`frameNavigated` 与 lifecycle; +- 这里没有把 DCL 判成假的,也没有在 Protocol 增加 pending lifecycle buffer。调试中验证过“先缓存 DCL/load、Page install 后补发”会 + 让 load observer 在 command response/commit boundary 前过早完成,因此已删除;正确边界是暂存 raw renderer publication,让既有 + commit/output state machine 按原顺序消费。新增 production-shaped WebSocket 回归同时要求 `Fetch.continueRequest` ACK 和原 + `Page.navigate` loader metadata 先于目标 `Page.frameNavigated`,从而覆盖上述极短 handoff race; +- typed BiDi/Classic `DevToolsCommand::ContinueInterceptedRequest` 仍在借用 `CdpConnection` 的 direct future 内,因此保留原 + compatibility completion;raw `continueWithAuth`、response-stage continue/fulfill/fail 与 BrowserContext disposal 也未被本切片 + 顺带迁移。它们需要各自 scheduler-visible task seam 或 permit policy,不能因 request-stage Continue 已迁移就直接 publish 后原地 + 等 Host reply; +- owner 专项回归覆盖 exact Page、frontend receiver loss、replacement stale-drop、Host stopped 原样恢复与 renderer publication + handoff ordering。Core neutral decision、request override/auth/response-stage 与四条 owner/handoff 边界聚焦 `8/8`(run + `397d6e87-58d7-40e2-b4c7-9afe76668faf`);WebSocket handoff、frontend-loss、replacement stale-drop 与 response-stage 四条 + race-prone async 边界各执行 `20` 轮,共 `80/80` execution,无 flaky(run + `896d9d72-1541-4843-a0a8-08ee2bd1351e`);Fetch navigation/auth/response-stage/subresource 扩大矩阵 `86/86` + (run `708f8242-d1f2-4932-92d3-ebb63628c162`); +- `moli-protocol` 全量 nextest `3381/3381`(run `9928e819-a487-4c83-9eb4-4fd46901e4b4`),最终源码树的 + workspace 全量 nextest `16058/16058`、既有 skip `17`(run `da5d277e-a61b-4d94-b192-1a519011611a`)。 + `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过;release + `moli` SHA-256 为 `2895aee900ee14364577a230da47ef914a1e232a8b705c52f348857282b93c95`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,真实 Playwright CDP + network 聚焦组通过,CDP 默认全组连续两轮均为 `244/244`、`ok=true`;WebDriver Classic/BiDi/Selenium/Semantics + `--continue-on-failure` 连续两轮均为 `157/157`、`ok=true`、失败列表为空。实现未加入 sleep、retry、`yield_now`、lifecycle + 补发或 fallback drain; + +本切片不宣称 Phase 4 完成,也不把 Fetch/Network/Page event projection 误称为 Browser fact channel。下一步应在 +typed/auth/response-stage Fetch decision、BrowserContext disposal、detached completion transport 与 Phase 5 neutral fact/outcome +channel之间做 exit audit 后选择主干,不继续以“Protocol 中每个 await 都要删除”为目标。 + +2026-08-04 第六十七切片实现记录(raw/nested CDP main-Document `Fetch.continueWithAuth` owner cutover): + +- Core 的 protocol-neutral `BrowserPausedNavigationDecision` 新增 `Auth`,只表达 abort navigation、expose challenged response + 或用已经解析的 browser credentials retry。它不携带 Fetch request id、CDP `Default/CancelAuth/ProvideCredentials` wire 名、 + session、401/407 response 或 output queue;Basic/Digest、server/proxy 等 credential 语义复用 browser page 层已有的 typed auth + capability; +- Protocol paused-navigation sidecar 从单一 `PendingFetchNavigation` 扩成显式 `Request/Auth` variant。raw/nested CDP + `Fetch.continueWithAuth` 取走 main-Document auth pause 后,先按该 navigation 的 Target route 捕获 exact Page,再把 auth pause、原 + response body/journal 与 projection identity move-own 到 sidecar,只把 exact Page 和 neutral decision 发布给 Core actor。Host + 未安装或 mailbox 已停止时,sidecar 按 current exact route 把同一个 pause 放回 registry;回归以原 auth response `Arc` identity + 证明不是重建近似状态,也没有 direct fallback; +- chained multi-session auth 的 `Default` 若只需把同一个 challenge 投影给下一个 session,仍在 frontend 同步生成下一次 + `authRequired`,因为这一步不推进 browser。unsupported challenge 也继续在 admission 前返回 `NotImplemented` 并恢复 pause。 + typed BiDi/Classic `DevToolsCommand::ContinueWithAuth` 与 response-stage credentials 暂留既有 compatibility future,避免在其 + direct adapter 尚未暴露 scheduler-visible task 时 publish 后原地等待 Host、反而阻住 physical executor; +- `navigation_decision` 新增 exact `AuthApply` phase,并复用第66切片的 navigation resume participant chain。每次 auth apply、 + network completion、Document build、commit/replacement 与 renderer tail 前都重新验证 frozen Page generation;frontend reply + receiver 被丢弃后 Host 仍继续 retry/commit,只丢 outer auth ACK。replacement 恰好发生在首个 participant wait 中时,旧 action + 在发起 credential HTTP request 前返回原 `Page.navigate` 的 `Navigation aborted`,不发 response/frame facts,也不能进入 + successor Page; +- credential retry 为 `BackgroundInterceptedNavigationFetchJob` 新增 move-owned buffered mode。Digest 继续由 libcurl buffered + path 完成内部 challenge round,Basic + response-stage interception 继续走 streaming head/body path;先前 401/407 的 + `NetworkObservationJournal` 与 retry journal 按物理顺序拼接。Default failure 仍 materialize navigation error,Cancel 仍暴露原 + challenged response,并保留 HTTPS proxy CONNECT 407 无 ExtraInfo、最终 network failure 的既有特殊语义;没有为了 owner cutover + 维护第二套 auth/load 规则; +- Core neutral input 聚焦 `1/1`(run `a17d5acc-cedc-48c2-a799-28f82041e8f3`);exact Page、frontend-loss、stale-generation 与 + Host-stopped rollback 三条 owner 回归聚焦 `3/3`(run `e9558ffc-b90c-4d0b-96ba-8597b20295a7`),并用 nextest + `--stress-count 20` 完成 `60/60` execution、无 flaky(run `06353b0d-aaa5-400d-8ef4-60a9b858c81b`)。ContinueWithAuth/ + navigation-auth 兼容矩阵 `36/36`(run `641b4262-443d-4c0d-8175-3abf1a0c7856`),Fetch navigation 全矩阵 + `89/89`(run `1a1e3082-8713-477f-98da-b0fc13f50e04`),`moli-protocol` 全量 `3384/3384`(run + `6a6eb314-49af-4ca1-9f08-7f73998e4446`); +- 最终源码树 workspace nextest `16062/16062`、既有 skip `17`(run + `9fed3e95-a1ff-41a8-af00-19e1b20c9ff8`)。`cargo fmt --all --check`、`git diff --check`、workspace all-target + clippy `-D warnings` 与 workspace release build 均通过;release `moli` SHA-256 为 + `325af3a6f09aed544dd0ea20e02877830c103a4e0a5a360f487d52fddc000034`。显式清除大小写 HTTP/HTTPS/ALL/FTP + proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,真实 Playwright CDP network 聚焦组 + `ok=true`,其中 main-Document auth continue/cancel/response-stage 均通过;CDP 默认全组连续两轮均为 `244/244`、 + `ok=true`,WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 连续两轮均为 `157/157`、`ok=true`、失败 + 列表为空。实现未加入 sleep、retry、`yield_now`、lifecycle 补发或 fallback drain。 + +本切片关闭的是“request-stage owner navigation 到 401/407 后又把 execution authority 交回 raw CDP frontend”的真实断点, +不是继续追求 Protocol 零 `await`。Phase 4 仍未完成;下一步应重新做 exit audit,在 typed/response-stage Fetch decision、 +BrowserContext disposal、detached completion transport 与 Phase 5 neutral fact/outcome channel 中选择会实际影响 Browser progress/ +lifetime 的主干,不再逐个迁移只影响单个 frontend response latency 的局部 wrapper。 + +2026-08-04 第六十八切片实现记录(BrowserContext exact instance capability): + +- Phase 4 exit audit 对比了三个候选:detached `wait:none` navigation 的 start/progress 已由 Browser Host 拥有,剩余主要是 + Phase 5/6 completion transport;response-stage Fetch 的局部 wait 只影响该命令时延,不能仅按“Protocol 里还有 await”判定; + `Target.disposeBrowserContext` 则仍由一个 frontend completion 跨 paused-Fetch cancellation、worker teardown、每个 Page + termination、Context removal 和 renderer close 借用整个 `CdpConnection`,确实会把 BrowserContext lifetime/progress 绑回 + frontend loop。因此下一条 Phase 4 production cut 明确选择 BrowserContext disposal; +- audit 同时发现不能直接把 `browser_context_id: String` 放入 mailbox。typed + `DevToolsCreateBrowserContextCommand` 允许 frontend 显式提供 id,Context 删除后可以用同一 public id 重建;排队的旧 disposal + 若在 Host selection 时按字符串 lookup,会删除新实例。Core 因而新增独立模块 `browser_host/context_handle.rs`,以单调 + instance identity + `Arc` capability 区分同名 Context;它只包含 staged/live/retired 生命周期,不携带 session、profile payload、 + Target/Page 或 protocol output; +- physical `BrowserContext` 在构造时创建 staged handle,正式 registration 将 exact handle 与 Context/Target/Page/engine transaction + 一起激活。任何 target/page/engine 验证失败都会回滚 handle activation;removal 则在同一无 await transaction 中先 reserve exact + Context retirement,与所有 Target retirement/engine handoff 一起 commit,失败时恢复 live。状态原子使用 Acquire/Release 配对, + cloneable/send capability 不依赖单线程 relaxed ordering; +- Core registry 现按 `{public id -> exact handle}` 保存 instance authority,并提供 + `prepare_browser_context_removal_for_handle`。removal permit 同时冻结 revision、Core 选择的 successor 和 exact handle;因此同名 + Context 的旧 capability 即使 public id 再次存在也返回 typed `BrowserContextHandleProjectionMismatch`,不能重定向。Protocol + registration/removal 已使用 physical context 携带的 handle,完整 topology projection 也逐 Context 校验 exact capability,不再只 + 对比 count/selected/public-id; +- Core 回归覆盖 handle lifecycle/rollback、错误 public id、removal 失败回滚和“删除 A、同 id 重建 B、旧 A capability 不能删除 B”; + Protocol projection 回归覆盖相同 public id 的错误 physical handle 在 Core mutation 前被拒绝。四条 exact-instance/rollback/ + projection 边界各执行 `20` 轮,共 `80/80` execution,无 flaky(run + `c46c69a6-5695-4012-acc0-68ea0e608812`);`moli-core` + `moli-protocol` 全量 nextest + `5982/5982`、既有 skip `13`(run `eacd9445-c4d8-47aa-bc93-213a4021deec`),最终源码树的 workspace 全量 nextest + `16068/16068`、既有 skip `17`(run `3d5b3963-58e1-49e2-877b-61d8bb6a3fb2`); +- `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过;release + `moli` SHA-256 为 `721eda8bc3e122d23b65fc862945ebbab83d81198097ba7f09480586183c5e9a`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组两轮均为 + `244/244`、`ok=true`,WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`、`ok=true`、失败 + 列表为空; +- 该切片没有把 disposal async chain 伪装成已迁移,也没有新增 queue、sleep、retry、yield 或 compatibility drain;下一切片才能以 + 这个 exact handle 作为 `BrowserOwnerInput` authority,把 Context logical removal 与每个真实 cleanup participant 接入 Browser Host。 + +2026-08-04 第六十九切片实现记录(BrowserContext disposal Browser Owner cutover): + +- Core 新增 move-only `BrowserContextDisposalReservation`。Host 选中携带 exact `BrowserContextHandle` 的 + `BrowserFrontendCommand::DisposeBrowserContext` 后才建立 reservation;仅发布命令不会提前改变 Context/Target topology。reservation + 期间 ordinary Context activation、Target registration、Document navigation、Page transition/replacement 都拒绝新 work,已经归 + disposal owner 的 exact Target/Page capability 仍可完成 cleanup。同 public id 重建的 Context 不能满足旧 input 或 reservation; +- disposal 不再为了复用 frontend helper 临时激活 inactive Context,也不再在多次 participant wait 后恢复 admission 时捕获的旧 + selection。每个 Page 使用 exact owner route 和预先捕获的 termination capability;terminal turn 才由 Core 根据当时 authoritative + topology 选择 successor,并在同一个 transaction 中 retire reservation/Context/remaining Target。关闭 disposal Context 中的 active + Target 即使临时 promote retained Target,也不执行普通 frontend settings synchronization,因为该 retained Target 紧接着由同一 + disposal chain 关闭; +- 原来隐藏在一个 `&mut CdpConnection` future 内的工作拆成显式 Host participant chain:每个 paused main-Document Fetch cancellation + 的真实 renderer replay、每个 retired Page close,以及 registry 外残留 physical Page close。Fetch cancellation 的无等待前半段改成 + 同步 start,只在确有 renderer replay 时返回 participant;SharedWorker/ServiceWorker 已经在 prepare 阶段停止,剩余 exact + attachment/version retirement 只是 connection-local apply,因此放入独立同步 projector,没有制造 ready future 或伪 async 边界; +- raw CDP `Target.disposeBrowserContext` 现在只解析参数、捕获 exact Context capability、保存 protocol projection sidecar 并 publish 到 + 既有 Browser Host mailbox;Host 未安装/停止时返回 admission error,禁止回退到旧 direct execution。typed BiDi/Classic 新增独立 + `browser_context_disposal_owner_adapter`,application scheduler 在等待 command reply 时继续服务 Host/protocol/renderer input;BiDi + `TargetDestroyed` prefix 仍按既有顺序投影; +- frontend timeout、receiver drop 或 raw pending command drop 只放弃该 frontend reply。已经 accepted 的 Host state machine 持有 + reservation 并继续完成 Context cleanup;terminal protocol effects 通过 detached/background projection 结算。Core input 不携带 + CDP id/session、BiDi context、socket 或 event buffer,frontend 也不能 drive 已接受的 cleanup; +- 新回归覆盖 reservation rollback/新 work rejection、terminal retirement 与 same-id ABA、raw publish-before-selection、queued old + capability 不删除同名 replacement、frontend reply drop 后 Host 仍删除 Context,以及 typed BiDi prefix/Host selection。首轮 workspace + gate 还实际抓出两条边界缺口:inactive Context 的 physical Target close 曾错误借用当前 selected Context;迁移期未注册 Context fixture + 又曾被与 `disposing` 相同的布尔判定误拒绝。前者改为按 termination capability 的 exact Context lookup,后者由 Core typed + `try_start_document_navigation_with_trace` 只拒绝已知 disposing Context,不让 Protocol 猜测 authority;对应 BiDi、navigation fixture、 + inactive disposal 与 Core reservation 聚焦回归均通过; +- 最终源码树 workspace 全量 nextest `16076/16076`、既有 skip `17`(run + `f17fb928-2291-4130-a098-f85e32108fa1`);`moli-protocol` BrowserContext 全组 `242/242`、application scheduler + owner-dispatch `1/1` 也通过。`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 + `cargo build --workspace --release` 均通过;固定 `target/release/moli` SHA-256 为 + `f5fc8c491e5dd19e4e8e228cdb6d537fb0c6b3bab1721897c3231d022f96dd80`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、设置 + `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认全组 `244/244`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`;两套 JSON 均为 `ok=true`、失败列表为空; +- direct `CdpConnection` 测试/尚未迁移的嵌入调用仍保留一个 inline compatibility wrapper,但它只 drain 同一套 owner task,不复制 + execution semantics;production raw CDP 与 typed application scheduler 均不经过该 wrapper。本切片迁移的是会影响 Browser lifetime/ + progress 的真实等待,不以删除所有 Protocol `await` 为目标,也不宣称 Phase 4 或 Phase 5 fact channel 已完成。 + +2026-08-04 第七十切片实现记录(raw/nested CDP response-stage `Fetch.continueResponse` owner cutover): + +- 这一刀迁移的不是“Protocol 里又一个 `await`”,而是 response-stage 主文档 pause 的 release/start authority。此前 raw CDP frontend + 取走 `PausedDocumentTransfer` 后可以直接启动 prepared Document、streaming replay 或 buffered build;现在 Core 的 + `BrowserPausedNavigationDecision::ContinueResponse` 只保存 browser-level status/header override,Protocol sidecar move-own Fetch + request id、response body、原 navigation projection 与 reply sender。只有 actor-selected exact Page turn 能消费 sidecar 并释放 pause; +- raw/nested CDP 通过 scheduler-visible pending command 发布 decision;Host 未安装/停止或 Page owner 捕获失败时,同一个 response + transfer 会按 exact current route(否则原 session route)放回 registry,禁止 direct fallback。typed BiDi/Classic + `DevToolsCommand::ContinueInterceptedResponse` 仍是借用整个 `CdpConnection` 的 direct adapter,暂留既有 compatibility path;在它尚未 + 暴露 scheduler task seam 前 publish 后原地等待会阻止 Host 取得 physical executor,因此本切片没有为了“入口一致”制造自我死锁; +- `domains/fetch/navigation_resume.rs` 现在同时拥有 request/auth/response 三类 paused-navigation resume。prepared streaming response + 继续复用原 background body-completion transport,Fetch ACK 不等待 body EOF;non-streaming buffered/captured/streaming response 则变成 + move-owned build participant,job 只持有 frozen load inputs、`NavigationEngine`、future-Page reservation、response head/body 和 + network journal,等待 parser/renderer 时不借用 `CdpConnection`。显式 response override 保留既有 synthetic-head 语义;无 override + 的 download detection、cookie/network metadata 与 prepared renderer-agent identity 继续保留; +- owner outcome 显式区分“decision 被拒绝”和“decision 已接受但 navigation 失败”。`takeResponseBodyAsStream` 已占用 transfer 时,Host + 原样恢复 active stream 并把 `ResponseBodyStreamActive` 返回给当前 `Fetch.continueResponse`;它不能先 ACK Fetch,再把错误错投给原 + `Page.navigate`。相反,已经接受后的 token/replacement failure 仍结算原 navigation。交错 response head 的旧请求现在由 + browser-owned navigation token 在 prepared renderer candidate commit 前统一 stale-drop 为 `Navigation aborted`,不会深入旧 + renderer channel 或安装 successor Page; +- exact Page + frontend receiver loss 回归连续 `20/20` 轮通过(run + `db14defd-ae4c-4020-aab4-f15c5fe1ed59`);continue-response 兼容矩阵 `20/20`(run + `ecb1a4c8-bec0-4f9f-97b6-3c05dd77f062`),完整 response-stage 矩阵 `29/29`(run + `eed6f73b-8fb5-434f-9fe4-4a0c64538acb`),`moli-protocol` 全量 `3390/3390`(run + `8afb9970-0521-4fe0-8452-5ecb558e38c2`);首轮 workspace 全量还准确暴露 + `continue_response_header_override_keeps_streaming_parser_body` 在高并发下会于 renderer load 前读取 partial DOM(run + `c8d7800a-0dbb-464e-9912-a9f19ea9f12a`),模块压力在第 `6/20` 轮复现为 `1` 次失败(run + `f5902b7b-9124-422d-a06b-be6e2c310ae0`)。修复没有增加产品 sleep/timeout:测试 server 的 one-shot signal 改成可保留 + permit 的 `notify_one`,最终 DOM 读取改为等待 exact `Target + loaderId` 的 renderer-owned load state;修复后完整 + response-stage 模块连续 `20/20` 轮、共 `580/580` 次通过(run `fc7123f3-e373-418d-825e-2e93151a0291`); +- 第二次 workspace 全量唯一失败是未改动的 renderer-v8 OPFS structured-clone 用例在全仓并发下仍为 `pending`(run + `c364f48d-06fc-4227-94ea-acdc635424e6`),该用例独立压力 `50/50` 通过(run + `229524b9-b702-4115-b7de-12894555671e`),因此没有把无关 renderer 改动混入本切片。随后 pre-rebase 标准 workspace + nextest `16078/16078`、既有 skip `17` 全部通过(run `31e78cde-f1cb-4dd2-958c-e51e347e2d38`);本切片提交后执行 + `git pull --rebase origin master`,上游带入 CSS/computed-style、CDP smoke 与 V8 build 等真实 tree 变化,因此对最终 tree + 重新运行 workspace nextest,`16085/16085`、既有 skip `17` 全部通过(run + `2d4e7b82-da8c-4ee5-85c4-ab37042c98b2`)。最终 tree 的 `cargo fmt --all --check`、`git diff --check`、workspace + all-target clippy `-D warnings` 与 `cargo build --workspace --release` 均通过;固定 `target/release/moli` SHA-256 为 + `f9c18b23b294c1459f7aac6971c8046bbb047a91ee0633fc634ea6f2e7bd4ceb`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 + no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,最终 CDP 默认全组为 `245/245`、 + `ok=true`;WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`、`ok=true`,两套失败列表均为空; +- 本切片没有建立第二条 queue,也没有把 Fetch ACK、Network/Page event 或 DCL/load 冒充 Browser fact。response-stage + `failRequest`/`fulfillRequest`、typed direct Fetch adapter、detached completion transport、protocol-neutral fact/outcome channel 和独立 + Host lifetime 仍分别属于后续 Phase 4/5/6;下一刀应重新做 exit audit,优先选择仍拥有 browser action/progress 的边界,不以清除 + 每个局部 frontend `await` 为目标。 + +2026-08-04 第七十一切片实现记录(raw/nested CDP main-Document Fetch terminal decision owner cutover): + +- Phase 4 exit audit 确认这一刀仍是 browser action 所有权,不是为了消除局部 `await`:此前 raw CDP + request/response-stage `Fetch.fulfillRequest` 会在 frontend command future 内直接启动 synthetic Document build,response-stage + `Fetch.failRequest` 也会直接结算原 navigation。现在三者都发布 exact Page `ResolvePausedNavigation` input,只有 + actor-selected Host turn 能消费 pause 并选择 commit/failure; +- Core 新增 protocol-neutral `BrowserPausedNavigationFulfillDecision`,只保存 HTTP status、header pairs 和可选 bytes。 + Fetch request id、CDP session/command id、paused request/response transfer 与 output route 仍 move-own 在 Protocol sidecar。Host 未安装、 + 已停止或 exact Page 捕获失败时恢复同一 sidecar,禁止 direct fallback;selection 后每个 apply/participant turn 继续重验 + `Target + Page generation`,replacement 后的旧 decision/completion 不能进入 successor Page; +- synthetic response 不再跨等待借用 `CdpConnection`。request-stage pause 保留 requested URL/cookie report,response-stage + pending transfer 保留原 body progress source,active body stream 还保留 response final URL 与 request-cookie report;随后统一构造 + move-owned captured-response load job。job 显式使用 `LifecycleTarget` reply boundary,与迁移前 synthetic fulfill 的完成语义一致; + 第70切片的 ordinary response continue 仍使用 `DocumentCommit` boundary,两者没有被错误合并。synthetic response 不进入 + download detection,response `Set-Cookie` 仍由 frozen load inputs 写入; +- response-stage failure 复用既有 exact-page failure apply participant;fulfill 复用同一 navigation build/materialize/apply chain。 + Fetch command response 仍只是 frontend projection:receiver 被 drop 后不再投递该 Fetch ACK,但 Host 必须继续完成原 + `Page.navigate` 的 commit/error。新增两个 production-shaped 回归分别覆盖 request-stage fulfill 与 response-stage fail, + 同时断言 exact Page identity、frontend drop 不取消 Browser action,以及 synthetic DOM/原 navigation error 的最终结果; +- Core neutral-decision 聚焦用例 `1/1`(run `18806ac9-0084-44cb-bb6a-0a56e265a803`);既有 request fulfill + 回归 `1/1`(run `7867bd09-6b38-441e-a5e5-8b60f618d41c`),response fail/fulfill 与 active-stream 矩阵 `4/4` + (run `19989ac9-b533-4c51-825e-8148155fba7b`);两个新 exact-owner/frontend-drop 用例 `2/2`(run + `d22e7514-9bd1-4e0c-8c2c-08da91c88f1d`),单用例 stress `20/20`、共 `40/40` 次通过(run + `5d1d6f27-984c-4cea-879f-c693154f41bd`);navigation control/response-stage 整个 `53/53` 矩阵连续 + `10/10` 轮、共 `530/530` 次通过(run `a9a6ec9e-5cce-4dc1-87e9-43cef65605ea`)。typed BiDi direct + compatibility 回归 `1/1`(run `5abcae67-cfef-4503-9a6d-823042a07a60`),`moli-protocol` 全量 + `3392/3392`(run `5a8ce3d3-bbdc-4ac8-83f0-89701fc9aff6`)与 protocol all-target clippy `-D warnings` 均通过; +- workspace 全量 nextest `16088/16088`、既有 skip `17` 全部通过(run + `a22f6e8b-7ec1-43c8-ae08-8905960fc384`);`cargo fmt --all --check`、`git diff --check`、workspace all-target + clippy `-D warnings` 与 `cargo build --workspace --release` 均通过。固定 `target/release/moli` SHA-256 为 + `af5403b3cf7e0ba6722f05a990ba49b8e4bc9ca0d1ad01304fdd0dc89703b6ed`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP + 默认全组 `245/245`、`ok=true`,WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` + `157/157`、`ok=true`,两套失败列表均为空; +- typed BiDi/Classic `DevToolsCommand` 目前仍在一个借用 `CdpConnection` 的 future 内执行;在该 frontend 暴露 + scheduler-visible task seam 前,publish 后原地等待会让 Host 无法取得 physical executor,所以本切片明确保留 + typed direct compatibility path。这不是第二套 Browser semantics,但仍是 Phase 4 未通过的边界;detached navigation + completion transport、download/background-target、Phase 5 fact channel 与 Phase 6 Host lifetime 也未在本切片扩大处理。 + +2026-08-04 第七十二切片实现记录(typed BiDi/Classic terminal Fetch scheduler task cutover): + +- 本切片关闭的是 production typed frontend 的 progress dependency,不以清除所有 Protocol `await` 为目标。此前 BiDi/Classic + 虽已复用第65至71切片的 paused-navigation state machine,但 `execute_devtools_fetch_command_async_with_protocol_events` + 会在借用整个 `CdpConnection` 的 future 内等待;若让主文档 decision 进入 Host mailbox,该 future 又会阻止 application + scheduler 取得同一个 physical executor。现在 `PendingDevToolsFetchCommand` move-own command route、success result、projection + context 与 exact pending participant,wait 不再借用 connection; +- application 新增独立 `cdp_scheduler/fetch_dispatch.rs`,只接管五类 terminal decision:`ContinueRequest`、 + `ContinueResponse`、`ContinueWithAuth`、`FailRequest` 和 `FulfillRequest`。scheduler 在等待 typed completion 时继续选择 + Browser Host turn、Host participant completion、background navigation/output 与 renderer publication;terminal Host apply + 同时发布原 navigation 与当前 Fetch completion 时,先观察 exact command completion,再处理无关 post-command work,保持 + 原 response/event fence 顺序; +- main-Document decision 以 `admit_main_document_to_browser_owner=true` 进入既有 `ResolvePausedNavigation` mailbox,并显式持有 + navigation renderer-publication gate,直到 exact Host projection 安装完成;subresource decision 不伪装成 Browser action,仍走 + exact Page participant,但同样由 application scheduler 等待而不是藏在 borrowed frontend future 里。两条路径共享原 Fetch + completion/result normalization,没有复制 request/auth/response semantics; +- production 调用点 audit 确认所有 BiDi DevTools dispatch 都进入带 `CdpSchedulerEventReceivers` 的 external-load-wait 入口;五类 + `network.*` terminal command 由 BiDi adapter 映射到上述五个 typed variant。Classic command executor 也统一进入同一入口, + 但当前不暴露 Fetch interception API。不带 receivers 的 BiDi helper 只执行 realm、BrowserContext、layout/FrameTree 与 Target + 等只读查询,不能承载 terminal Fetch decision; +- direct `CdpConnection::execute_devtools_command` 没有 application Host participant loop,因此继续以 + `admit_main_document_to_browser_owner=false` drain 同一套 state machine,明确只作为测试/嵌入 compatibility adapter。它不是第二套 + production action authority,也不再作为 Phase 4 production blocker;本切片没有建立新 queue、sleep、retry、`yield_now` 或 + Browser fact channel; +- production-shaped typed fulfill 回归证明 main Document task 暴露 exact Host wait、renderer gate 与 Page owner,且 Host action + 完成后原 navigation response、typed empty result 和 synthetic DOM 都在既有 fence 上投影;该用例连续 `50/50` 轮通过(run + `0addfce3-cc24-4edb-af10-80f88f948e66`)。production WebSocket BiDi Network 的 request-stage、response-stage、auth、 + main-Document 与 subresource 共 `8` 个场景连续 `20/20` 轮、共 `160/160` 次通过(run + `a522747c-efe7-45bb-a18b-b48501d0a20e`);`moli-protocol + moli` 全量 nextest `3959/3959`(run + `2d322586-2deb-41f4-9d8f-eb1d2e241548`),workspace 全量 nextest `16090/16090`、既有 skip `17`(run + `f16b501e-1666-4ac6-96e7-62c61348027c`); +- pre-rebase tree 的 `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release + build 均通过;固定 `target/release/moli` SHA-256 为 + `e6bcc792dc029add44059c9b3a5324f3e9366727aa314b9f98235ad690ce5f9a`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、 + 原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `245/245`、WebDriver + Classic/BiDi/Selenium/Semantics `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后执行 `git pull -r origin master`,上游从 `efeed5c372` 前进到 `29e5ec97a7`,带入 Streams owner cleanup 与 direct + input focus 稳定化等真实 tree 变化。84 个分支提交在旧整合切片重放时只产生一处测试注释冲突;resolution 保留上游以 + programmatic focus 取代 `autofocus` race 的意图,没有 skip commit。rebased conflict/typed-owner 聚焦回归 `2/2`(run + `7cc47665-0858-44e3-aa9d-f2dc5391e325`),最终 workspace nextest `16146/16146`、既有 skip `17`(run + `75c0630b-8413-4e05-9e6b-2182cb7b82cc`);最终 tree 的 fmt、diff-check、workspace all-target clippy 与 workspace release + build 再次通过,release SHA-256 为 `e053f65ac00e1f35762df0a9580478b3728f577ff294045eadff1696308134fa`。按相同无代理 + 环境复跑,CDP 新总数 `246/246`、WebDriver 四组 `157/157`,两套均为 `ok=true`、失败列表为空; +- 本切片仍不宣称 Phase 4 完成。detached `wait:none` completion 已不拥有 start authority,但其 transport 应归 Phase 5 outcome + audit;download/background-target 是否还存在 frontend-owned action 必须以 production trace/call graph 单独确认。Phase 6 Host + lifetime 与 direct fixture compatibility wrapper 也不能因为 typed production path 已迁移就顺带删除。 + +2026-08-04 第七十三切片实现记录(download action progress / frontend projection separation): + +- Phase 4 exit audit 找到一个真实 owner-progress dependency:navigation download 与 renderer pending download 虽然已在独立 task + 中完成,但 task 会先等待当前 CDP command 的 `response_flush`;frontend 不 flush 或 permit 被取消时,网络请求、流式写盘与 + download registry terminal 都不会发生。403/DCL 修复所要求的同一个不变量同样适用这里:response fence 只能排序 observation, + 不能授权或取消已经接受的 Browser action; +- 新增独立 `conn/download_event_projection.rs`,把 action 和 frontend event gate 拆成两个并行 participant。下载 task 立即执行; + projection waiter 只观察 response permit,flush 后按序释放,permit cancellation 则丢弃 frontend events,但两者都不影响 action。 + gate 在 flush 前只保留有限的 will-begin/initial-progress prefix 和**最新一批** progress;body 有多少 chunk 都不会令事件缓存线性 + 增长。实现上每个被 response gate 挡住的 download 增加一个只等 frontend flush 的 Tokio task,但没有新增 Browser Owner drain/pump; + flush 后继续直接投递,不新增 sleep、retry、轮询或另一套 download semantics。这个 per-download waiter 是迁移 plumbing,Phase 5 的统一 + fact journal/projector 建立后应由 subscriber gate 取代,不能复制成各功能各自一套 watcher; +- navigation/prefetched download 的 body 写盘现在先完成,再把固定大小的完整事件序列交给 gate;pending streaming download 则在每个 + chunk 后更新 coalesced progress,registry `Completed/Canceled` 与 artifact rename 都发生在 frontend projection 之外。无 active + response permit 的 direct adapter 由 gate 立即 release,保持既有同步/测试语义;早期 start 仍留在 command 的 post-response + segment,后续 progress 不可能越过它; +- 三条因果回归分别锁定 gate 的 `start + latest progress` 顺序、navigation artifact 在 held flush 下已经完成,以及 pending download + 在 held flush 下已经向真实本地 HTTP server 发出请求。聚焦 `3/3` 通过(run + `b8180639-eab0-4e68-b6c9-b5eb8fed72b3`);三条共同 `--stress-count 20 --flaky-result fail`,共 `60/60` 次通过(run + `af58814a-2ee5-4ba9-b544-cf68fce8fcef`)。本轮修改前 workspace 基线为 `16145/16146`,唯一失败是无关的 + `sandboxed_blob_iframe_keeps_opaque_storage_context_for_opfs_messages`;该 exact case 随后 `10/10` 全过(run + `504af16d-761d-4fba-b4be-caa31c1c30c6`),没有借本切片修改 structured-clone/OPFS 或放宽测试; +- 修改后 `moli-protocol` 全量 nextest `3397/3397`(run `22f06b7d-68ce-4443-9d8b-45d2cb42b4bc`),workspace + 全量 nextest `16149/16149`、既有 skip `17`(run `d2b0f54f-c1b1-4600-895d-c61f661ec107`)。workspace + all-target clippy `-D warnings`、workspace fmt/diff check 与 workspace release build 均通过;固定 + `target/release/moli` SHA-256 为 + `18ea61a4cbd9d43ae2a1b5aeb76bca2cf9f26525879e81560c9536bad1011498`。显式清除大小写 HTTP/HTTPS/ALL/FTP + proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `246/246`、 + WebDriver Classic/BiDi/Selenium/Semantics `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后按约定执行 `git pull -r origin master`,上游从 `29e5ec97a7` 前进到 `ef7439ba17`,新增 Spider benchmark + resource timeline 报告实现与测试;85 个分支提交无冲突重放,没有 skip commit。rebased tree 的上述三条专项回归 + `3/3`(run `0c2ad64e-7606-4972-93fc-03d581091a44`),workspace 全量 nextest `16149/16149`、既有 skip `17` + (run `9aeb9353-351a-49a1-b4af-da07a73a7f52`)。workspace fmt/diff check、all-target clippy `-D warnings` 与 + workspace release build 再次通过;release SHA-256 保持 + `18ea61a4cbd9d43ae2a1b5aeb76bca2cf9f26525879e81560c9536bad1011498`。按相同无代理环境复跑,CDP 默认全组 + `246/246`、WebDriver 四组 `157/157`,两套均为 `ok=true`、失败列表为空; +- background-target call graph audit 同时排除了普通 parked navigation completion:background `Page.navigate`/reload/history 都按 exact + `PageResidenceIdentity` 进入既有 Owner request/replacement state machine,loaded/failed projection 直接写回原 background slot, + 不会先 promotion,也没有 `ProtocolSchedulerWork` fallback。target activation 的 Core topology commit 也发生在 renderer state + synchronization `await` 之前,不是 navigation completion authority; +- audit 仍发现三个同源的 initial target URL 触发器尚未汇流:`Target.createTarget` 的 post-create continuation、 + `Runtime.runIfWaitingForDebugger` 和 `Page.enable` 都可能调用 Protocol 的 + `start_initial_document_target_url_navigation_if_needed_background_events_async` / `start_initial_document_navigation_for_session_owner`, + 再直接启动 background load,而不是发布 exact Browser Owner input。因此本切片关闭 download progress blocker,但不宣称 Phase 4 + 完成;下一 production cut 应把这三个触发器归一成一个 protocol-neutral initial-target navigation input,再删除 direct start path。 + +2026-08-04 第七十四切片实现记录(initial target URL Browser Owner admission cutover): + +- Core 新增独立模块 `browser_host/initial_target_navigation.rs` 与 + `BrowserInitialTargetNavigationInput`。input 只携带 exact `PageResidenceIdentity` 和 immutable destination URL,不包含 CDP command id、 + session/target route、domain subscription、response gate 或 output queue;`Target.createTarget`、debugger resume 与 `Page.enable` 只是三个 + advisory trigger,不再各自拥有 navigation start authority; +- `BrowserNavigationOwner::accepts_initial_target_navigation` 统一检查 exact Target instance/Page generation、仍处于 initial empty + Document、没有已接受的 cross-Document navigation,并拒绝 initial URL 本身。frontend publication 前可用它跳过明显 no-op,但 + Browser Host 真正选中 mailbox turn 后必须再次检查;因此两个 trigger 在 selection 前同时入队也只会有第一个启动 request,后一个会在 + Core 已标记 pending 或 generation 已变化后 no-op,旧 Page input 不能追随 successor; +- `Target.createTarget` 继续遵守 `waitForDebuggerOnStart`:无需等待时只按新 Target 的 exact route 发布 input; + `Runtime.runIfWaitingForDebugger` 在 inspector resume 成功后发布同一 input;`Page.enable` 在既有 initial-document policy 判定需要 replacement + 时也只发布。Host 未安装/已关闭时三者返回 typed publication error,不会回落到 Protocol direct start。旧的 + `start_initial_document_target_url_navigation_if_needed_background_events_async` 与 + `start_target_url_navigation_if_allowed_background_events_async` production path 已删除; +- Browser Host executor 在选中 input 后重新解析 exact owner route,并复用既有 Page navigation participant 完成 network、commit、Page + replacement 与 target-info projection;等待期间 input、actor 和 frontend response 不互相借用。command turn 本身不发布 + `ProtocolSchedulerWork`,也不会在 response completion 内执行导航。Document commit 后仍可能发布既有 + `MainDocumentLoadOwnerAction` 来等待 exact load terminal;这是下一阶段应迁入 neutral fact/outcome channel 的 compatibility tail, + 本切片没有把它伪装成已消失,也没有新增 drain、pump、watcher 或另一套 navigation state machine; +- 修改前 clean HEAD workspace 基线为 `16149/16149`、既有 skip `17`(run + `43ffde0c-2c5b-407a-80a2-9c20bdd69876`)。Core input/acceptance、Host selection、`Page.enable`、 + `Target.createTarget` 与 debugger-resume/Fetch pause 共六条因果回归 `6/6`(run + `f4628d19-a1e8-4a9f-abd4-a6156a48029d`);同六条各执行 `20` 轮、共 `120/120` execution,无 flaky(run + `f77b2cd8-388e-471d-bd4b-2d4261da3eb7`)。`moli-core + moli-protocol` 扩大矩阵 + `6002/6002`、既有 skip `13`(run `4ed0eb72-524f-411b-8705-31b68604ba64`); +- pre-rebase workspace 全量 nextest `16152/16152`、既有 skip `17`(run + `0705266b-5b6f-47cf-a9e7-f33407e6b9dd`)。`cargo fmt --all --check`、`git diff --check`、workspace + all-target clippy `-D warnings` 与 workspace release build 均通过;固定 `target/release/moli` SHA-256 为 + `876d452fc74abea3d706f47348af7b71a12f8513c50fbbd440b9673e7064c0a8`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 + `246/246`、WebDriver Classic/BiDi/Selenium/Semantics `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后执行 `git pull -r origin master`;`origin/master` 仍为 `ef7439ba17`,Git 明确返回 current branch up to date,因而没有 commit + replay、skip 或冲突。post-pull 六条因果回归 `6/6`(run `51a9e9a3-c27b-4d6c-83b5-730d803ba3e0`),workspace + 全量 nextest 再次 `16152/16152`、既有 skip `17`(run `8b0c461e-8d28-4738-8a03-7eb166599c08`);fmt、diff-check、workspace + all-target clippy 与 workspace release build 再次通过,release SHA-256 保持 + `876d452fc74abea3d706f47348af7b71a12f8513c50fbbd440b9673e7064c0a8`。按相同无代理环境复跑,CDP 默认全组 + `246/246`、WebDriver 四组 `157/157`,两套仍为 `ok=true`、失败列表为空; +- production call graph 复核后,三个已命名 trigger 都只剩 typed publication,test-only renderer direct helper 已显式 + `#[cfg(test)]`。但 `Page.createIsolatedWorld` 仍因“先确保 requested initial URL committed,再创建 world”的 command dependency 调用 + `start_initial_document_navigation_for_session_owner`;renderer top-level history output 仍会进入 + `traverse_session_owner_history_from_renderer_background_events_async`。这两条必须作为后续独立 owner cut 迁移,不能借本切片宣称 Phase 4 + 完成;Phase 5 的 loaded-tail/fact projection 与 Phase 6 Host lifetime 也保持未完成。 + +2026-08-04 第七十五切片实现记录(renderer top-level history Browser Owner cutover): + +- 旧路径把 `RendererOwnerAction::TopLevelHistoryTraversal { delta }` 留到 Page output projector,再依据 mutable session route 调用 + `traverse_session_owner_history_from_renderer_background_events_async`;该函数会解析 history destination、直接启动 Page navigation,并在 + output completion 内等待整个 renderer/navigation step。renderer intent 因而虽已产生,execution authority 仍藏在 Protocol projection + turn。现在 output ingress 先把 raw delta 绑定为 `PagePreparedTopLevelHistoryTraversal { exact Page, traversal }`,projector 只把它 + move 进 Core `RendererTopLevelHistoryTraversalInput`;input 不含 session、frontend correlation、resolved entry/URL、event queue 或 wait + policy; +- Browser Host actor 选中 input 后才重验 `PageResidenceIdentity`,在 exact none-session route scope 内调用 Core authoritative history + resolver,并复用第55/57切片已有的 no-op、same-Document 与 cross-Document classifier/participant。Page replacement 或 generation advance + 会在 selection 前 stale-drop;越界 delta、history 已清除或 destination 已消失都按 renderer `history.go()` 语义静默 no-op,不能追随 + successor Page,也不能回退到 frontend session; +- history source 继续显式携带 `HistoryTraversalStartSource::Renderer`,包括 same-Document renderer 拒绝后 fallback 到 URL load 的分支,因而 + CDP scheduled/requested/cleared-scheduled lifecycle shape 不会被误投影成 browser-command navigation。`Runtime.evaluate` response 只证明 + JavaScript 已排入 traversal intent,不是 traversal terminal;Browser Host turn 可在 response 后独立推进。旧的 + `start_session_owner_history_traversal_from_renderer`、`traverse_session_owner_history_from_renderer_background_events_async` 与 async output + completion 调用已删除,Host 未安装时返回 typed publication error,不存在 `ProtocolSchedulerWork` 或 direct-start fallback; +- 两条新增因果回归分别冻结 stale generation 和 parked background Page route:旧 Page input 在 Host selection 前 advance generation 后不会 + 改动 URL/history/request;background Target 的 `history.back()` 在 Runtime response 后仍停在第二个 URL,唯一 ready Host turn 完成后才回到 + 第一个 URL,active Target identity/URL 始终不变。既有前后退/越界用例也改成显式消费具体 Browser Host input,不再把 Runtime response + completion 当作 traversal completion;没有加入 sleep、retry、轮询、`yield_now` 或宽松成功条件; +- 修改前 clean tree 的 workspace 基线为 `16152/16152`、既有 skip `17`(run + `8b0c461e-8d28-4738-8a03-7eb166599c08`),专项基线 `4/4`(run + `d50fd7e2-91b4-4f4b-8108-fff99634cf91`)。首轮实现后专项为 `6/7`(run + `59dc6249-6ab9-4aeb-bbf3-4fe10b4c1201`):唯一失败精确暴露既有测试仍假定第二次 `Runtime.evaluate` response 会 inline 完成 traversal;修正 + 测试因果边界后同组 `7/7`(run `b66dd962-2d9d-4599-b1c2-8d6251cac39e`),最终 renderer-history 名称组 `6/6`(run + `3b94db73-1017-4d07-970d-bf245cae2469`,包含 renderer scheduled/requested/cleared probe 顺序)。同六条各执行 20 轮、共 + `120/120` 次通过(run + `5de4b63b-a390-45dd-adfc-044d8ccc13af`); +- `moli-core + moli-protocol` 扩大矩阵 `6006/6006`、既有 skip `13`(run + `6aafe1c4-fd65-44c4-8624-c0cc27244f95`),workspace 全量 nextest `16156/16156`、既有 skip `17`(run + `f42d06fa-375f-4487-9846-ad8132e4b8df`)。`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过;固定 `target/release/moli` SHA-256 为 + `0da80892f3dedfab2e7d442a0327fe701285049cf292f4e32a21f63460188706`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 + inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `246/246`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后按约定执行 `git pull -r origin master`,`origin/master` 从 `67fb30bbe2` 前进到 `fa3dd36527`,87 个分支提交全部重放, + 没有 skip。第 43 个旧切片 `detach navigation inspector replays` 与 master 新增的 target-qualified `uniqueContextId` rewrite 在 + `runtime_eval.rs` 测试尾部发生一次 content conflict;resolution 同时保留 master 的 current/foreign Target realm-id 两条回归与分支的 + move-owned replay participant 回归,没有选择整侧覆盖。history + conflict boundary 专项 `11/11`(run + `06929d08-9c61-4cb2-82b6-148f005817cf`),同组连续 20 轮、共 `220/220` 次通过(run + `f8cceda2-a61e-4a8c-93aa-77a8f7a58285`); +- rebased tree 的 workspace 全量 nextest `16181/16181`、既有 skip `17`(run + `66a38ef6-e692-485c-b38f-cbd6ba138aa2`);`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 再次通过。最终 `target/release/moli` SHA-256 为 + `e055042fa90cdb993e4706918dba4fd63784057e093046c26f1bbfba83ebd25f`。按相同无代理环境复跑,上游新增两条 smoke 后 CDP + 默认全组为 `248/248`,WebDriver Classic/BiDi/Selenium/Semantics 仍为 `157/157`;两套均为 `ok=true`、失败列表为空; +- production call graph 复核确认 renderer history direct function 已无引用,`TopLevelHistoryTraversal` output slot 现在只负责 typed + publication。Phase 4 仍不宣称完成:`Page.createIsolatedWorld` 的 requested initial URL prerequisite 还在 command-owned task 内直接调用 + `start_initial_document_navigation_for_session_owner`。既有 `MainDocumentLoadOwnerAction` 是 Phase 5 要替换的 exact load subscriber/ + outcome transport,不能借本切片改名为 Browser action,也不能为了“零 Protocol work”提前再造 history/load 专用 queue;Phase 6 Host + lifetime 同样保持未完成。 + +2026-08-04 第七十六切片实现记录(`Page.createIsolatedWorld` initial-target prerequisite Browser Owner cutover 与 Phase 4 exit audit): + +- 旧 `createIsolatedWorld` task 在判断 initial empty Document 有 replacement URL 后,会直接调用 + `start_initial_document_navigation_for_session_owner`,再把 `PendingNavigateCommand` 或 paused-Fetch continuation 嵌进自己的 Page command + phase;CDP task 因而同时拥有“等待这个前置条件”和“启动/推进顶层导航”两种责任。现在 Core 新增 + `BrowserFrontendCommand::EnsureInitialTargetNavigation`:input 只含 opaque `BrowserCommandId`、exact + `PageResidenceIdentity` 与 immutable URL,不含 CDP id/session、world 参数、response route、event subscription 或 socket state; +- Protocol 以 opaque id 保存一次性 continuation sidecar,`createIsolatedWorld` 的 initial phase 只 move-own receiver。Browser Host actor + 选中 input 后才重验 Core initial-Document/pending-request/generation authority,并复用同一 page-owned navigation participant。terminal + navigation plan 回到 dependent command,保留 nested navigation events、renderer predecessor 与 exact insertion boundary,再继续解析当前 + renderer attachment 并创建 world;Host 未安装/停止会 typed reject,禁止回退到 Protocol direct start; +- dependent frontend wait 若在 terminal 前消失,oneshot send failure 会把 navigation plan 交给 Host 的 detached projection,Browser action、 + protocol events 和 renderer fence 都继续结算,不能因 CDP command lifetime 取消。actor selection 时 action 已被另一 exact transition 满足或 + 变 stale,则只完成空 prerequisite;后续 world phase 重新解析当前 attachment,不能追随旧 generation 的物理 Page; +- 旧 `InitialDocumentNavigation` / `InitialDocumentNavigationContinue` command phase 与 + `start_initial_document_navigation_for_session_owner`、`start_navigate_to_url_command` 两个零引用 direct helper 已删除。production call graph + 中 `createIsolatedWorld` 只剩 typed publication;没有新增 sleep、retry、`yield_now`、poll loop、drain/pump 或逐功能 watcher; +- 修改前专项基线 `21/21`(run `fb9cf58f-184b-487c-9372-09814f16d861`)。最终 Core/input、Host absence、selection-before-start、 + terminal-before-reply、detached frontend、initial URL commit 与既有 create-world 行为名称组 `28/28`(run + `1e35ee68-3ce4-4f2e-b9ca-124cb523ff3e`)。期间新增 detached regression 首次为 `27/28`(run + `c23e1a68-1660-4840-846d-1e13d2cb9478`):失败来自测试误用只允许无 fence 输出的 flatten helper,保护断言正确拒绝压平 renderer + boundary;回归改为检查 explicit command-turn boundary 后单条通过(run `5d395389-2599-4180-8cd5-9709c35a2ee3`),没有修改 product + guard。五条核心因果回归连续 `20` 轮、共 `100/100` 次通过(首尾 run + `8be869d7-121a-4363-9e52-95050bf750d4` / `afe9aa18-e75c-4770-bfee-abd719dc254d`); +- `moli-core + moli-protocol` 扩大矩阵 `6026/6026`、既有 skip `13`(run + `da8531e3-76dd-458b-9247-a15423053a75`),workspace 全量 nextest `16185/16185`、既有 skip `17`(run + `6706b93a-e216-4d89-8f90-b217bb5f9214`)。`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过;固定 `target/release/moli` SHA-256 为 + `998c6e830bfa2dc83972f1601f15aa1d9cff1fc837706f01510a9726b0fc50e8`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 + no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组与 WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` 连续两轮均为 `ok=true`、失败列表为空;计数复核分别为 + `248/248` 与 `157/157`; +- 提交后按约定执行 `git pull -r origin master`,`origin/master` 从 `fa3dd36527` 前进到 `ef44056fe9`,88 个分支提交全部重放, + 没有 content conflict 或 skip。rebased tree 的本切片专项 `28/28`(run + `d6e3e3c9-8d31-4549-8070-a095cc47c0a5`)。首轮 workspace 全量为 `16187/16188`(run + `f9efa71f-fba2-4004-aae7-7c4450cf7ba6`):唯一失败是上游新增的 child-frame Page event fan-out fixture 直接赋值 + `conn.browser_context`,绕过 Phase 2 后 Core-owned topology 注册,因而 exact root Document attachment 单独稳定失败(run + `f579f697-0410-472e-a3b9-6fd8f555c9ec`),不是调度 flaky。fixture 改走 production/test 共用的 + `insert_browser_context` 注册边界后单条通过(run `67403dc1-51b5-45ea-b772-11297be2`),20 个独立 nextest 进程共 + `20/20`(首尾 run `99a8ea40-4e43-496a-a668-3b1830b5507e` / `c00b6ebc-c344-416c-862c-5bff74ec48ca`),相关 + child-frame producer 组 `10/10`(run `dd572fbe-f665-41a7-b701-569b0dcaad7f`)。最终 workspace 全量 + `16188/16188`、既有 skip `17`(run `e8871796-848d-4205-b8fc-732067865e86`)。`cargo fmt --all --check`、 + `git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 再次通过;最终 + `target/release/moli` SHA-256 为 + `8ed077c4ebc18cf3c5455a5611196297b450218eeaf878543bc97cf9de7d1f93`。按相同无代理环境复跑,CDP 默认全组 + `248/248`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 + `ok=true`、失败列表为空; +- Phase 4 exit audit 确认 `ProtocolSchedulerWorkKind` 仅余 `ProtocolObservation`、Phase 5 的 + `MainDocumentLoadOwnerAction` 与 BiDi channel continuation,不含 navigation、replacement、popup 或 termination browser action;所有 production + 顶层 command/renderer/auxiliary/Fetch/termination/initial-target action 都经 Browser Host mailbox 与同一 Core request authority。因此 Phase 4 + 正式完成。下一刀进入 Phase 5 fact/outcome journal;不能把 `MainDocumentLoadOwnerAction` 改名后继续算 Phase 4,也不能为“零 Protocol await” + 清理无 application Host loop 的测试/embedding compatibility adapter。 + +Exit gate:`ProtocolSchedulerWork` 不再包含 navigation、replacement、popup 或 termination browser-owner +payload;所有来源对同一 target 的 conflict/cancel 使用同一 request state machine。 + +### Phase 5:navigation/lifecycle/target fact journal + +目标:把 browser state transition 与 protocol event shape 分开。 + +工作: + +1. 发布 `NavigationAccepted/Committed/Failed`、`PageReplaced`、exact lifecycle、Target lifetime facts; +2. 为 CDP 实现 projector,保持现有 event shape/session filtering/loader identity; +3. 为 BiDi/Classic/high-level wait 增加同源 consumer; +4. pending DCL/load attachment 改成 subscriber/wait ticket,不再是 owner action; +5. 建立 per-subscriber cursor 和 bounded lag policy; +6. 将 command response ordering 与 fact occurrence time 分开记录。 + +状态:已完成(2026-08-05 exit audit)。唯一 Core transition 已发布 top-level Target creation、navigation +admission/terminal、loaded commit + Page replacement、Target crash/close 与 exact Document lifecycle facts。CDP 使用单一 +`CdpBrowserFactProjector` 和 bounded cursor;BiDi、Classic 与 high-level wait 使用同源 exact fact ticket/outcome,不再向 +Protocol Page slot 注册 waiter,也不从 CDP result/error JSON 反推 Browser outcome。application scheduler 只接收 payload-free +coalesced wake;subscription、response visibility 和 wire ordering 只影响 frontend 投影,不参与 Browser progress。 + +exit audit 逐项确认了本阶段六项工作:journal 有显式 lag/cursor policy;旧 Page fact 会按 replacement/failure/termination +topology 退休;root lifecycle sidecar 只能由 exact fact sequence 与冻结 Document binding 授权;Target bootstrap 来自 Core +current-state snapshot。Core transition 不构造 `BackgroundProtocolEvent`,关闭 frontend subscription 不改变 Browser trace。 +当前 fact projector、response correlation 和部分 transport 仍物理驻留 `CdpConnection`,这是 Phase 6 的 Host lifetime/transport +迁移问题,不是第二份 Browser authority,因此不再阻塞 Phase 5 exit。 + +2026-08-04 第七十七切片实现记录(bounded Browser fact journal 与 exact lifecycle 首个 producer): + +- `moli-core::browser_host` 新增独立 `fact_journal` 模块,工作契约为 + `BrowserFactSequence`、immutable `BrowserFactEnvelope`、`BrowserFact`、typed publish/receive/lag error 与 + `BrowserFactSubscriber`。首个 fact 只有 + `DocumentLifecycleReached { document, milestone, stamp }`:envelope 携带同一 Browser Owner 的 + `BrowserInstanceId`、typed BrowserContext/Target、exact `PageResidenceIdentity`;fact 携带 exact + `RendererDocumentLifecycleIdentity` 与 renderer sequence/timestamp stamp,不含 session id、Page domain enable、loader wire shape、 + `BackgroundProtocolEvent` 或 WebSocket state; +- journal 使用固定 `1024` 条 retained ring 和同容量 non-blocking fanout。每个 subscriber 先获得创建 cursor 时仍保留的有界 bootstrap, + 再接收 future facts;slow consumer 得到显式 `Lagged { skipped }`,publish 不 `await`、不等待 frontend。没有 subscriber 时 retained + ring 仍保存 transition,Browser fact sequence 仍单调推进;大型 DOM/network/download payload 不进入 journal; +- journal 当前作为 `BrowserNavigationOwner` 的字段保存,以复用 Phase 2 已建立的唯一 `BrowserInstanceId` 与 exact Page authority;它的类型和 + API 位于 Browser Host 模块,Protocol 只能请求 Core 记录已经被权威入口接受的 lifecycle record。这个物理 residence 是 Phase 6 前的迁移形状: + `CdpConnection` drop 仍会 drop owner/journal,不能宣称 Browser Host lifetime 已独立;Phase 6 应整体移动 owner state,而不是在 actor 中再分配 + 第二个 browser instance 或复制 journal; +- `TargetPageSlot` 的 renderer lifecycle ingress 现在显式返回 `authoritative` 与 `visible` 两个集合。exact binding、Document/epoch、严格递增 + renderer sequence 通过后,record 立即进入 authoritative cursor 和 Browser fact producer;command-response load visibility barrier 只决定 + record 是否进入当轮 CDP-visible 集合。释放 barrier 只投影原 deferred tail,不再写第二条 fact。因此语义固定为: + + ```text + renderer exact lifecycle record accepted + -> authoritative Page lifecycle state + -> BrowserFactJournal append(sequence, exact Page, exact Document) + -> optional CDP visibility barrier + -> existing CDP event projector + ``` + +- 普通 committed Document bind/ingest 与 initial-empty-Document materialization 的 active/background 直接 Page 投影都接入同一 Core fact + 入口;只对 Page registry 中 exact current generation 发布。stale/targetless Page 返回 typed rejection并保留诊断,不允许旧 Page milestone 进入 + successor。Started/terminated record 仍更新 renderer lifecycle state,但本切片只将实际 reached 的 DCL/load 作为 Browser fact; +- 现有 CDP event shape、session fan-out、loader id、Page subscription replay 与 response visibility barrier 没有改由 Core 构造。 + `MainDocumentLoadOwnerAction` 也仍是 Protocol exact-load observer/terminal projection transport;把它改成 journal subscriber、让 CDP projector + 消费同源 fact、再发布 navigation/target facts,属于后续 Phase 5 切片。本切片不新增 sleep、retry、poll/drain/pump、per-feature watcher 或 + Browser progress gate; +- 修改前 renderer lifecycle 基线 `16/16`(run `e2fa94f0-02da-4551-b521-0b906f16fc59`)。Core journal retention/bootstrap、 + subscriber lag 与 stale Page generation 三条契约回归 `3/3`(run + `12e47f04-fae7-482c-8c78-138f42990e64`);authoritative/visible 分离、既有 lifecycle cursor 与关键 Page integration + 因果组 `14/14`(run `831eb927-eee0-47a1-92ac-6ee59d14ddd7`)。关键用例证明在 Page lifecycle subscription 尚未 enable、load + 仍被 visibility barrier 隐藏时,journal 已按 exact Document 顺序持有 DCL/load;随后 enable/disable frontend subscription 与释放 barrier + 不新增、删除或重排 fact。background initial-Document direct projection exact-owner 单条通过(run + `42502aa2-8f42-4adc-97e2-2142c39a8f1f`);四条核心 causal/identity regression 用 20 个独立 nextest run 共 + `80/80` 次通过(首尾 run `e7c6506e-8e14-4413-8ce6-985c94af7437` / + `30edb908-a16d-429b-8237-f53cf6a948b9`); +- `moli-core + moli-protocol` 扩大矩阵 `6031/6031`、既有 skip `13`(run + `32164bfd-d548-4db1-a896-bfd7a63de3e2`),workspace 全量 nextest `16191/16191`、既有 skip `17`(run + `f9939ab2-b9da-452a-b7dd-18c9d6007c7d`)。`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过;固定 `target/release/moli` SHA-256 为 + `7c55d866c6aa446ec85f21a7f61a65dcc54715939fab7d75d6ded728d4149fac`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 + no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组连续两轮均为 `ok=true`,计数复核 + `248/248`;WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`、`ok=true`;两套失败列表均为空; +- 提交后按约定执行 `git pull -r origin master`,`origin/master` 从 `ef44056fe9` 前进到 `cac2e67294`,89 个分支提交全部重放, + 没有 content conflict 或 skip。rebased tree 的 lifecycle 聚焦组 `16/16`(run + `04f30ec2-1d48-429f-9819-8f1ce64fcb93`),workspace 全量 nextest `16191/16191`、既有 skip `17`(run + `f42ec092-2c4c-43c0-b89b-5d9cd7350b90`);`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 再次通过,release SHA-256 保持 + `7c55d866c6aa446ec85f21a7f61a65dcc54715939fab7d75d6ded728d4149fac`。按相同无代理环境复跑,CDP 默认全组因上游新增 + case 为 `249/249`,WebDriver 四组为 `157/157`,两套均为 `ok=true`、失败列表为空。 + +2026-08-04 第七十八切片实现记录(loaded Page replacement 首个 navigation fact producer): + +- `BrowserFact` 新增 protocol-neutral + `PageReplaced { previous_page: A, navigation: N }`。fact envelope 统一携带已提交 successor `B`、Browser instance、typed + BrowserContext/Target 与全局单调 sequence;payload 保留 retired predecessor `A` 和 exact + `BrowserDocumentNavigation N`(request id、Target、loader identity)。它不携带 CDP session、command id、event shape、response gate 或 + renderer physical `Page`;因此 frontend attach/detach 不能改变 replacement truth; +- 唯一 producer 位于 `BrowserNavigationOwner::commit_loaded_page_replacement`。只有 request identity、Target recovery eligibility、Page + generation、joint history 与 initial-empty-Document exit 已在同一 Core turn 成功提交后才 append fact;`prepare` 不发布,stale/superseded + permit、renderer attachment rollback 与 Protocol physical projection 均无发布权限。固定顺序为: + + ```text + validate exact pending request N and predecessor A + -> atomically commit request + Page generation B + history/recovery/initial-document state + -> append PageReplaced(A, B, N) + -> return replacement to the physical Page projector + -> asynchronously dispose predecessor Page A when needed + -> later exact successor lifecycle ingress appends DCL_B / load_B + ``` + +- fact append 不 `await`、不等待 subscriber,也不依赖 predecessor disposal;sequence exhaustion 只产生显式 production diagnostic,不能在 + browser state 已提交后 panic 或伪造 rollback。successor DCL/load 复用同一 journal,因此必须取得更大的 Browser fact sequence;旧 Page lifecycle + 仍由 exact generation 拒绝; +- Core 回归同时断言 exact context/Target、`A/B/N` identity、唯一 sequence、replacement-before-DCL 与 stale permit 零 fact。Protocol adapter + 回归进一步证明 fact 在旧 Page `close_async()` participant 开始等待前已经可观察,且 superseded request 即使完成 renderer candidate rollback 也不会 + 发布 replacement fact。本切片没有建立 CDP projector,也没有更改既有 CDP/BiDi/Classic event shape 或 loaded-tail waiter;后续 consumer 必须从 + journal 投影,不能从 physical Page storage 反推第二份 replacement truth; +- 开始本切片前按用户要求立即执行 `git pull -r origin master`:`origin/master` 从 `cac2e67294` 前进到 `b016375769`,分支 89 个提交全部重放, + 无 conflict/skip;新上游 parser DOM refresh/DCL barrier 与既有 lifecycle fact 基线 `15/15`(run + `e8c34c5b-3f8a-4b26-a962-0c7863650e1d`)。本切片 Core 聚焦 `5/5`(run + `41ecb03c-f2cf-4872-be30-8095ffea5e5d`),Protocol replacement adapter `2/2`(run + `6698ec6d-f2fe-42a7-a04a-931ee415d965`);四条 exact/stale Core + Protocol 回归使用 nextest 原生 + `--stress-count 20 --flaky-result fail` 共执行 `80/80`,零 flaky(run + `b25ed6ad-3661-40f2-ace7-9f309407c9eb`); +- 首次 Core + Protocol 扩大矩阵在机器同时为另一工作树 `/home/donoughliu/code/moli0` 编译两份 V8-heavy Rust test binary、24 核 load + average 达到 `33.25` 时为 `6030/6033`(run `ca0442be-3f95-4715-84e8-52676370addc`),三个失败均位于未修改的 DOM test。 + 三条精确复跑 `3/3`(run `a07002d1-05f1-418f-ae37-8a45518d09b2`),原生 stress `60/60`(run + `d883a369-92d1-4e78-a185-6159b512a9a2`),Protocol 全包 `3424/3424`(run + `d6a9c1e0-bb90-4cd8-89a0-610f96f9ba1e`)。相同扩大矩阵在外部链接仍运行时为 `6031/6033`(run + `d3c5c880-1845-4344-9a8a-6f94ded34d1f`):一个相同 DOM identity failure,另一个 test-only detached commit wait 在 CDP 已投影 + DCL/load 后超时;后者精确 stress `20/20`(run `f8213799-a463-46a3-949c-4c6cdc1f706c`)。未修改 production 或这些无关测试,未降 + nextest 并发、增加 timeout 或使用 retry。外部编译结束、机器负载回落后,原命令最终为 `6033/6033`、既有 skip `13`(run + `67fa3de9-92d1-4ada-8efe-b4339281459a`); +- 最终 workspace 全量 nextest `16195/16195`、既有 skip `17`(run + `4ca7f313-e4f0-49c8-a46e-ca2997bfe37d`)。`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过;固定 `target/release/moli` SHA-256 为 + `adc4dfe339400de4c67ad2ec0120ffc0338a817bc150d5e7816acf5ec3825327`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 + no-proxy 与 inherited CDP smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组因上游新增 parser DOM case 为 + `250/250`、WebDriver Classic/BiDi/Selenium/Semantics 为 `157/157`;两套均 `ok=true`、失败列表为空。提交后的收尾 + `git pull -r origin master` 再次确认 `origin/master` 仍为 `b016375769`,Git 报告当前分支 up to date;无 conflict、skip 或 commit + rewrite,代码树和上述固定 release 二进制均未变化。 + +2026-08-04 第七十九切片实现记录(exact Target crash/close fact producer): + +- 把迁移 `MainDocumentLoadOwnerAction` 所需 terminal 反向推导了一遍:只有 DCL/load reached fact 不足以替换 renderer lifecycle observer;等待 + 期间 exact Page 还可能被 replacement、Target crash 或 Target close 终止。第七十八切片已有 `PageReplaced`,因此本切片先补唯一 Target + termination truth;Document lifecycle interruption 仍须由后续切片补齐,不能在 journal consumer 中回头读取 Protocol Page slot 猜 terminal; +- `BrowserFact` 新增 `TargetCrashed { previous_page: A }` 与 `TargetClosed { previous_page: A }`。两者的 envelope 均携带 + Target terminal Page generation `T`、exact Browser instance/Context/Target 和单调 sequence。crash 表示 `A -> T` 后 Target 仍 live、可由一个 + exact recovery navigation 恢复;close 表示 `A -> T` 与 Target topology removal 同一 Core commit,`T` 即使已不能从 live registry lookup,仍是 + immutable terminal identity。fact 不携带 session、inspector listeners、close reason、physical `Page` 或 Target promotion projection; +- 唯一 producer 位于 `BrowserNavigationOwner::commit_target_termination`,且只在 termination state、Page generation、Target topology、request + registry、engine ownership、history 与 initial-empty-Document state 全部成功提交后 append。capture/prepare 不发布;stale Page generation、delayed + crash、rollback 和 Protocol projection 均无发布权限。固定顺序为: + + ```text + validate exact Target + Page A + crash|close permit + -> atomically commit Target state + terminal Page T + request/runtime/history cleanup + -> append TargetCrashed(A,T) or TargetClosed(A,T) + -> return immutable termination result + -> synchronously project physical absence + -> asynchronously dispose retired Page / promote successor Target when needed + ``` + +- crash 后 close 会得到严格连续的两条 fact:第一条 envelope `T1` 是第二条 payload `previous_page`,第二条 envelope 为 `T2`;stale close 零 + Target terminal fact。Protocol production-shaped 回归覆盖 Page.close、Page.crash、Target.closeTarget,并证明 loaded background Target 的 + `TargetClosed` 在 retired Page `close_async()` participant 开始等待前已进入 journal。本切片不改既有 CDP/BiDi/Classic crash/close event shape, + 不创建 watcher、drain/pump 或 frontend cache;现有 projection 继续消费 transaction result,下一步再让 neutral wait ticket 消费 facts; +- 修改前 Core + Protocol Target termination 基线 `21/21`(run `883ae3d8-ffa3-444e-8fce-583d96587e4d`)。本切片 exact + identity/sequence/stale/cleanup-before-wait 聚焦 `7/7`(run `8bb4b0a5-3d93-4c83-adb3-1241721de1c6`),完整 Target + termination 因果组 `21/21`(run `633288ad-f736-400e-b672-e8770d66cc9b`);七条关键回归用 nextest 原生 + `--stress-count 20 --flaky-result fail` 共执行 `140/140`,零 flaky(run + `0fe63ff7-a512-4667-9833-cbe92b18375c`); +- 扩大验证没有用 retry 取最后一次绿色。最初两次 Core + Protocol 全矩阵都为 `6031/6033`、既有 skip `13`(run + `59ff97cf-ea6d-49e8-b782-0debde4d3e34`、`777036bc-bd14-45c1-8adf-54516635c9fa`),随机落在四条 DOM 测试;精确与 + 原生 stress 均不复现。保持默认并发继续 stress 后又抓到 patchright DOM、search 与 child-frame Runtime 用例。共同违反路径是测试把 + `Page.navigate` 的 early command response 或 root Document load 当成新 DOM/child realm 已完成;这恰好违反本文已规定的 + `navigate reply != DCL/load terminal`,不是 Target fact producer 的生产竞态; +- 独立测试提交 `685ae3b7df` 没改生产等待语义:DOM fixture 按 exact `frameId + loaderId` 等 renderer-owned load,child-frame Runtime + fixture 在 navigation 前订阅 Page facts 并等 exact child `frameStoppedLoading`。所有等待仅消费真实 scheduler input,无 sleep、retry、timeout + 放宽或降并发。54 条相关路径原生 `--stress-count 20 --flaky-result fail` 共 `1080/1080`(run + `18abb7db-7af6-485f-8782-79d8bde49988`);随后 Core + Protocol 完整矩阵连续两轮共 `12066/12066`、每轮既有 skip `13`(run + `d1b582e5-d60a-4bee-ab21-a42018f2b2db`)。此前单次未复现的 file-chooser 症状也包含在该聚焦矩阵和完整矩阵中,但证据仍是有界 stress,不能声称 + 证明未来任意调度均不再出现; +- 随后的首次 workspace 全量为 `16194/16195`、既有 skip `17`(run `e16fc438-fca1-4036-af3a-e03f6f575de3`),唯一失败位于 + renderer-v8 SharedWorker stale-event 测试:测试要求一个 HTTP streaming navigation owner turn 必须越过 phase one,但生产状态机合法返回 + `PendingPhaseOne`。精确原生 stress `100/100`(run `95695f2a-5ae9-45d5-9522-44e600575d08`)证明它只在扩大调度窗口出现;源码审计则确认 + 断言本身不成立。测试改用已物化的 `data:` replacement Document,保留“真实 PageVm replacement 后 stale event 不得进入新 realm”的契约,移除与 + 该契约无关的 HTTP phase-one timing。修改后精确 stress `100/100`(run `9caa8750-71a2-4310-ba59-11b0ad88abc8`),整个 + SharedWorker client-event 邻域 `7 * 20 = 140/140`(run `cd0a4775-d585-4e87-a2fd-71202c43131a`);未增加 sleep、retry、timeout、skip + 或生产 drain; +- 按用户要求在收尾前立即执行 `git pull -r --autostash origin master`:`origin/master` 从 `065e3145d0` 前进到 `744e161dad`,分支 91 个 + commit 全部重放,autostash 完整恢复,无 content conflict 或 skip。rebased tree 的完整 Target termination 因果组 `21/21`(run + `1e31ebb0-c6e7-47ba-ae01-e74a6dfeb498`);workspace 全量 nextest 一次通过 `16204/16204`、既有 skip `17`(run + `9c7ada40-870c-49c5-b4a9-06d3afcd2331`); +- `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过;固定 + `target/release/moli` SHA-256 为 `0194aa36d3d861cc06f2c54f9c385731c72e04b16519bd3761bad54d683ad884`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组连续两轮均为 + `ok=true`,第二轮计数复核 `251/251`;WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`、 + `ok=true`;两套失败列表均为空。 + +2026-08-04 第八十切片实现记录(main-Document load neutral fact wait ticket): + +- 从 deferred main-Document load 的全部 terminal 反向审计 owner:只补 renderer `Terminated` 不够。等待旧 Document load 时,新 cross-Document + request 一旦成为 Target 的 pending request,旧等待就应立即 `Superseded`;不能等到新请求成功 replacement,因为请求若在 commit 前失败,旧 + Page-slot observer 已被 navigation admission 终止,而只消费 `PageReplaced` 的 journal waiter 会永久 pending。因此本切片同时加入 + `NavigationAccepted { navigation }` 和 `DocumentLifecycleTerminated { document, last_reached, termination }`; +- `NavigationAccepted` 只由 `BrowserNavigationOwner::try_start_document_navigation_with_trace` 在 request registry、Target recovery state 与 + initial-empty-Document pending state 已共同接受 exact request 后发布。fact envelope 是请求所替代的 exact Page `A`,payload 是 Core request + identity `N`;disposing Context rejection、same-Document navigation 和未进入 request registry 的 intent 零 fact。固定因果为: + + ```text + accept cross-Document request N2 against Page A + -> request registry pending = N2 + -> BrowserFact::NavigationAccepted(A, N2) + -> pending load wait(A, Document D) = Superseded + ``` + +- renderer lifecycle 的唯一 authoritative ingress 现在把 exact `Terminated` 与 reached DCL/load 一样投影进 Browser journal。termination fact 保留 + exact renderer Document/epoch、`last_reached`、renderer sequence/timestamp/reason;load waiter 若 `last_reached >= Load` 则仍为 `Reached`,否则为 + typed `Interrupted`。`PageReplaced(A, B, N)`、`TargetCrashed(A, T)`、`TargetClosed(A, T)` 继续作为 replacement/Target terminal 的 fallback; +- Core 新增 move-only `BrowserDocumentLifecycleWaitTicket` 与 cloneable read-only readiness。ticket 只消费 immutable Browser facts,并按 exact + Browser instance journal、Context/Target/Page generation、renderer Document/epoch 和 milestone 过滤;结果只有 `Reached`、带 termination stamp 的 + `Interrupted`、`Superseded` 或 typed `Unavailable`。journal close 与 subscriber lag 都显式 terminal,不能回读 Protocol Page、猜当前状态或静默 + 悬挂; +- async wait cursor 和 scheduler readiness cursor 由 Core 一次 `subscribe_browser_fact_pair()` 在同一个 retained/future cut 创建。两个 cursor + 独立消费但共享只写一次的 terminal:readiness 可在下一个 Browser Owner turn 被选择前同步看见已发布 fact,不依赖 spawned wait task 是否获得 + 调度;async cursor 又不会被 readiness 偷走。两者都没有 publish/mutate 能力,slow frontend 不会 backpressure Browser Owner; +- 删除 Protocol `document_lifecycle_observer` 模块、Page-slot observer publisher 字段,以及 navigation/replacement/clear 时反向完成 observer 的 callback + authority。保留的 `RendererDocumentLifecycleWaiter` 仅服务现有显式 DevTools polling wait key,并继续由自己的 release protocol 管理。回归明确证明: + 仅把 physical `CdpConnection.browser_context` 投影移除不会制造 Browser terminal,必须先有 Core fact; +- `ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction` 同步改名为 `MainDocumentLoadFactProjection`。它仍是保证既有 CDP output causal ordering 的 + durable Protocol continuation,但不再拥有或推进 renderer/Browser lifecycle;CDP response/event shape、load visibility barrier 与 session routing + 未改。当时尚待迁移的通用 CDP projector、navigation outcome facts 和其他 high-level wait consumer 仍属于后续 Phase 5,不能据此宣称 Host + lifetime 已独立; +- 修改后的 Core ticket 与跨层 deferred-load 精确回归 `14/14`(run `c1df4ef8-e261-44ff-831b-a3aadaa226e9`);其中覆盖 retained load、future + termination、navigation acceptance、retained replacement、unrelated Target fact、显式 lag、DCL 不满足 load、terminal-before-adapter-wait、 + physical projection loss 与 Target crash。final lock implementation 改用仓库规定的 `parking_lot::Mutex` 后,同组再次 `14/14`(run + `71368f8e-da64-4faa-b335-a47652f2e4d3`),并以 nextest 原生 `--stress-count 20 --flaky-result fail` 共执行 `280/280`、零 flaky + (run `7bdcb4a5-d194-4948-a77f-831cd25a69de`);fact producer、Page replacement、Target termination 与 deferred-load scheduler + 扩大因果组 `55/55`(run `8808ace4-d944-4bcd-9ebc-22629618f918`); +- 首次 Core + Protocol + application 完整矩阵为 `6604/6605`、既有 skip `13`(run + `a49257c4-d24a-403f-9b26-13e0dde948a0`),唯一失败是未修改的 file-chooser `document.open()` 测试在 replacement 后用“当前 Document” + registry 反查旧 Document backend id。原用例精确 stress `100/100`(run `b40a71dc-ced6-427d-a294-c588f8b0c470`),全部 file-chooser + 邻域 `12 * 20 = 240/240`(run `6ed46812-ed79-4f3a-81b3-d287c0673a6b`),证明不是本切片的确定性生产回归,但原断言混合了两个 + Document scope。独立测试提交 `edaf87d292` 把 current-Document shared-id registration 与同一 JS turn 的 click -> `document.open()` stale + backend isolation 分成两条真实 Page 契约;没有改生产代码、sleep、retry、timeout 或并发。两条精确回归 `2 * 100 = 200/200`(run + `478fae86-3781-4f54-b15a-e33aa338f16d`),扩大后的全部 file-chooser 邻域 `13 * 20 = 260/260`(run + `05dbef9d-92de-4e1b-ae8d-33eb86b5f4e7`);随后三 crate 完整矩阵 `6606/6606`、既有 skip `13`(run + `ada6e9f2-fbcf-43d3-8e7c-04ecf38a3685`); +- 按用户要求在本切片收尾开始时立即执行原样 `git pull -r origin master`;Git 因工作树有本切片未提交修改拒绝 rebase。随后把完整工作树 + (含新增文件)临时 `stash -u`,再次执行同一 pull,再立即 `stash pop`;`origin/master` 仍为 `744e161dad`,分支 up to date,恢复无 + content conflict,临时 stash 已删除。本次同步未改写提交或代码; +- 最终 workspace 全量 nextest `16210/16210`、既有 skip `17`(run + `7c4fa601-2e50-476e-a0a1-7b2a348904f5`)。`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过;固定 `target/release/moli` SHA-256 为 + `17bbff39c4e2fab2b5f809fe518b30ecd1adeb9757a3338edfcd01d9571e133a`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 + no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `251/251`;WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` 为 `157/157`;两套均 `ok=true`、失败列表为空。 + +2026-08-05 第八十一切片实现记录(loaded navigation commit 与 Page replacement 同源 fact batch): + +- producer 审计确认,当前 production loaded-Document 成功路径只有 + `BrowserNavigationOwner::commit_loaded_page_replacement` 能同时把 exact request 从 pending 移到 committed、推进 Page generation、写入 joint + history、提交 crash recovery navigation 并退出 initial-empty-Document 状态。Protocol 的 renderer attachment、旧 Page disposal 和 preload + listener 都只是这次 commit 前后的 participant/projection,不能单独声称 navigation 已 committed; +- `BrowserFact` 新增 `NavigationCommitted { navigation }`。唯一 Core transaction 在全部权威状态提交后,以一个 `publish_batch` 固定发布: + + ```text + pending request N against Page A + -> commit request + Page B + history + recovery state + -> sequence K: NavigationCommitted(N), envelope Page B + -> sequence K + 1: PageReplaced(A, N), envelope Page B + -> renderer lifecycle facts for Page B + ``` + + 两条 fact 共享 exact Browser/Context/Target/Page B source,并保持相邻 sequence;没有第二个 commit 点、Protocol callback、frontend session id 或 + subscription 判断。`NavigationCommitted` 表达 request outcome,`PageReplaced` 表达 topology change,即使目前二者总在同一 loaded commit + 出现,也不能让 consumer 通过其中一条反推并伪造另一条; +- stale/superseded permit、stale Page generation 和 renderer attachment rollback 都在 Core commit 前失败,因此不得发布任一 commit fact。Core + 回归检查 acceptance -> committed -> replacement -> DCL 的严格顺序与同一 successor Page;Protocol 回归检查两条 fact 在 predecessor Page + disposal participant 等待前已经可见,并检查 stale request 两条都不泄漏;现有 CDP response/event shape、session filtering 和 command-response + barrier 本切片不变,尚未把 frontend event 改为 journal projector; +- 同轮 failure producer 审计没有把 navigation tail 的 loader-only `clear_pending...` 升格成 `NavigationFailed`:network/load failure 可能先提交 + `FailedNavigationDiscard` Page transition,Fetch cancellation 保留当前 Document,download 是无 Document commit 的成功分流,而新 request + admission 会 supersede 旧 pending request。下一切片必须为这些路径设计 exact request token + protocol-neutral typed terminal transaction,并明确 + download 与 failure 的不同语义;不能在 renderer replay tail 完成后按 loader 猜失败,也不能把 errorText/CDP result shape 存进 Browser Core; +- 修改前 Core 成功 commit 基线 `1/1`(run `bcd8372e-4af3-48f1-9bf5-651833f5aca4`)。修改后 Core exact commit `1/1`(run + `c188899c-a4d6-42a4-a4d7-a28d0b3c56e3`);Core Page replacement 因果组 `8/8`(run + `579456ba-3146-4734-ad0f-f856c70d31bc`)、lifecycle wait consumer 组 `6/6`(run + `17b78a89-1c31-4a0c-bdf9-b6cbc849c6b7`)、Protocol replacement/rollback 组 `2/2`(run + `115161ae-8b5e-4ea0-8deb-81a8cc6358a4`)。Core commit 用例原生 stress `20/20`(run + `da863ef1-8c59-4ef1-bb5d-f00b458ef622`),Protocol commit + stale rollback 两条用例原生 stress 共 `40/40`(run + `2c28f31f-d0ff-4162-93b7-140c49142907`),均使用 `--flaky-result fail`; +- workspace 全量 nextest `16210/16210`、既有 skip `17`(run `135460df-d20d-4e19-8d39-3071470cf2d8`)。 + `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过;固定 + `target/release/moli` SHA-256 为 `df3adc786cc10568f9312edef4e7fa94524884531c09b02fefa6e10133f938e3`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 和 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组连续两轮均 + `ok=true`,第二轮计数 `251/251`、失败列表为空;WebDriver Classic/BiDi/Selenium/Semantics + `--continue-on-failure` 为 `157/157`、`ok=true`、失败列表为空; +- 本切片开始前按用户要求立即执行 `git pull -r origin master`,`origin/master` 没有当前分支尚待重放的新提交,Git 报告 up to date;工作树当时 + 干净,未使用 autostash,也没有 content conflict 或 skip。 + +2026-08-05 第八十二切片实现记录(accepted navigation 的 exact non-commit terminal fact): + +- production terminal-path 审计把跨 Document request 的 Browser 不变量收敛为: + + ```text + NavigationAccepted(N1, Page A) + -> exactly one of: + NavigationCommitted(N1, Page B) + NavigationFailed(N1, reason, Page A or successor/terminal Page B) + NavigationConvertedToDownload(N1, Page A) + ``` + + `BrowserNavigationFailure` 是 Core-owned、protocol-neutral 的 typed reason:`Network`、`Commit`、`Canceled`、 + `Superseded { replacement }`、`TargetCrashed`、`TargetClosed`;错误文本只表达浏览器失败事实,不保存 CDP command/session/result shape。 + download 是 request 成功终止但没有创建 Document,不能伪装为失败或 Page replacement; +- request registry 把 pending/committed navigation 与 trace sidecar 合并为一个 `BrowserDocumentNavigationRecord`,所有 retire/rollback 都携带 exact + request token。新 request admission 若替换旧 pending request,会在同一个 Browser Owner batch 中先发布 + `NavigationFailed(N1, Superseded { N2 })`,再发布 `NavigationAccepted(N2)`;两条 envelope 都携带 admission 时的 current Page。旧 request 的 + delayed completion 因 request id + loader id 不再匹配,只能 stale-drop,不能终止 N2,也不能重复发布 N1 terminal; +- 保留 current Document 的 network/commit/cancel failure 通过 `fail_document_navigation_if_matches` 原子 retire exact pending request,envelope 仍是 + current Page,`previous_page = None`;response 转 download 通过独立 exact transaction retire request,Page generation 不变。需要丢弃失效 + Document 的 network failure 走 `FailedNavigationDiscard` Page transaction:先 exact-take pending authority,再 CAS 推进 Page generation;若 Page + permit 已 stale,则同 turn 恢复原 request record 且不发布 fact;成功后清理 runtime/request residence,并以 successor Page envelope 发布 + `NavigationFailed`,payload 保存被淘汰的 `previous_page`。Page transition permit 的私有 payload 使用互斥 enum,不能表示“failure kind 但缺少 + navigation/failure”的内部无效组合; +- Target crash/close 在唯一 Core termination transaction 清理 request、runtime、history 与 Page/Target topology 后,如存在 exact pending request, + 会在同一个 batch 先发布 `NavigationFailed(TargetCrashed|TargetClosed)`,再发布 `TargetCrashed|TargetClosed`;两条使用同一 terminal Page + envelope,failure payload 保存旧 Page。没有 pending request 时仍只发布 Target terminal fact; +- Protocol 新增的 adapter 只把 session 一次解析为 exact Browser owner,并分别提交 failure/download transaction。materialized network/load error + 分类为 `Network`,renderer candidate/config/install rejection 分类为 `Commit`,`Page.stopLoading` 等保留当前 Page 的显式中止分类为 + `Canceled`;Fetch main-Document failure 若使 Document 失效则走 Page discard。download 必须先成功 retire exact Core request,才允许启动物理 + download projection。旧的 loader-only navigation tail 已改名并限定为 renderer/resource 的物理投影清理,不再修改 Core request authority;因此 + delayed N1 tail 即使在 N2 admission 后运行,也不能清除 N2; +- 聚焦验证中,Core navigation-owner 组 `99/99`(run `a515ff6d-309f-42bc-8c55-f58840f6e91d`),Protocol exact navigation adapter + `2/2`(run `f3344072-b39b-44aa-9c6e-ad1749af7faa`),materialized failure/download、Fetch failure 与 stop-loading 组 `6/6` + (run `f1bf8a82-1670-493e-87cd-19b7f81af605`)。第一次 Protocol 聚焦运行唯一失败是测试把 Fetch main-Document network failure 错写成 + `Canceled`;按 production 的 Document-invalidating 行为修正期望后通过,没有为测试改变实现; +- 收尾扩大验证中,Core + Protocol 全量 `6045/6045`、既有 skip `13`(run + `afb3ce8e-9e29-43fa-bead-f885cf648989`);9 条 exact terminal / supersede / stale rollback / Target close / Fetch / stop-loading / download / + delayed-tail 关键用例原生 stress `20/20`,共 `180/180` 次通过(run `d594d07f-a80f-455d-b311-0d2c9db507b2`, + `--flaky-result fail`)。第一次 workspace 全量为 `16215/16216`(run `f11e9f39-fb87-4ecd-9dce-c8eaa994c265`),唯一失败是本轮未修改的 + renderer sandboxed-blob-iframe/OPFS 测试;该精确用例随后单次通过(run `3254aea9-0852-489a-aff3-cfd1479f0d10`)、原生 stress + `50/50`(run `a7796e39-4e06-4f57-a539-5316147d6ed0`),相邻 structured-clone 8 条用例 stress `10/10`、共 `80/80` + (run `c0afc462-07f6-41af-88e1-e45d3b1bae1c`);未加 sleep/timeout/retry,也未修改该测试或 renderer。最终 workspace 全量 + 复核先以 `16216/16216`、既有 skip `17` 通过(run `3fc18d78-4e3b-426b-9238-024cbbf530bc`);补齐 pending navigation 的 crash + batch 顺序断言后,该分支原生 stress `20/20`(run `e14b32f1-71ba-4f28-9632-40a011ac2ed5`),最后一次 workspace 全量仍为 + `16216/16216`、既有 skip `17`(run `828d7053-2188-4ac4-aaf6-0fa6094b08cf`); +- `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过;固定 + `target/release/moli` SHA-256 为 `53a7e01801a18020c41a3a9c22743e2fbc27a5064a134e87d5a922bc1c1552bc`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组连续两轮均 + `ok=true`,第二轮计数 `251/251`、失败列表为空;WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 连续两轮均 + `ok=true`,第二轮计数 `157/157`、失败列表为空; +- 本切片开始前按用户要求立即执行 `git pull -r origin master`;工作树当时干净,Git 报告当前分支 up to date,没有 autostash、content + conflict 或 skip。同步完成后才开始本切片修改; +- 本切片没有新增 sleep、retry、poll、drain、pump 或 per-request watcher,也没有改变现有 CDP event shape/response barrier。fact 的通用 frontend + projector、BiDi/Classic/high-level consumer、journal lag recovery,以及 Browser Host 与 frontend connection 的 lifetime 分离仍属于后续 Phase + 5/6,不能据此宣称 Phase 5 已完成。 + +2026-08-05 第八十三切片实现记录(首个 root lifecycle frontend fact projector): + +- lifecycle producer 与 projector 的边界收敛为: + + ```text + exact renderer lifecycle record R accepted for Page A / Document D + -> Browser Core appends DocumentLifecycleReached(K, A, D, milestone, stamp) + -> connection-local frontend subscriber observes K and freezes the matching protocol binding + -> existing command-response visibility barrier releases the same stamp + -> frontend projector checks exact K/A/D/binding/stamp + -> emit existing CDP + automation DCL/load shape + ``` + + renderer record 仍负责有序 ingress 和 response visibility release,但不再单独授权 root `Page.domContentEventFired` / `Page.loadEventFired` 或对应 + `Page.lifecycleEvent`。没有 exact Browser fact、同一 raw record 第二次 replay、旧 attachment 或已经投影到较晚 sequence 后再倒序补发的旧 milestone + 都 fail-closed,不允许回退到旧 direct emitter;`Started`(包括 `document.open()` 的 `init`)尚无 Browser fact,仍走既有 renderer-local + projection,不在本切片伪装成已迁移; +- `CdpConnection` 新增 `CdpBrowserFactProjector` 作为迁移期 frontend-owned cursor。它订阅 Core 的 immutable bounded journal,跨所有 fact 保持严格递增 + sequence;只有等待当前 frontend exact visibility 选择或退休的 `DocumentLifecycleReached` 进入本地 pending queue,通常是被 load visibility barrier + 挡住的 load,也可能是尚未被该 frontend 选择的较早 milestone;navigation/Target/termination facts 当前只推进 cursor、不构造 wire shape。pending + lifecycle 上限固定为 `1024`,publish 和 Browser Owner 都不等待 projector;subscriber lag/close、非单调 + sequence、pending overflow、缺失 exact fact 均成为 typed terminal error,当前 adapter 记录错误并停止继续猜测 lifecycle event。application 统一 + disconnect/resnapshot policy 仍是后续工作; +- frontend cursor 不要求每个订阅者消费同一 Page/binding 的所有较早 lifecycle fact。若较晚的 exact load 已经由当前 frontend 的 visible record 选中, + projector 会退休这个 binding 下未被该 frontend 选择的更早 DCL fact,再投影 load;随后旧 DCL raw replay 因 exact fact 已退休而 fail-closed。这个选择不是 + 放宽 Document 内 renderer 顺序:`RendererDocumentLifecycleProtocolCursor` 仍负责拒绝倒序 ingress;它只承认 frontend 可以在 DCL 与 load 之间开始观察。 + 本地 Chromium 对照也明确区分了两种行为:`third_party/blink/renderer/core/inspector/inspector_page_agent.cc` 中 `Page.enable` 只注册 agent、不会回放 + `domContentEventFired`,而 `setLifecycleEventsEnabled(true)` 才按当前 Document timing 回放已经达到的 lifecycle marker。因此“任何 frontend 看到 load 前 + 都必须先消费 journal 中旧 DCL”不是 Chromium/CDP 不变量; +- projector 在 authoritative lifecycle ingress 后立即以非阻塞 `try_recv` 捕获 fact,并冻结 + `{PageResidenceIdentity, Renderer Document/frame, TargetPageAttachmentId, frameId, loaderId, navigation}` binding。这样 load fact 即使先发生、后因 response + barrier 才可见,也不需要重读 mutable Page;同 renderer Document 的已完成旧 epoch 可以复用同一冻结 wire binding,而 attachment replacement 不能 + 领取旧 fact。可见 record 只作为 exact release token,最终 timestamp、Document/epoch 和 navigation correlation 均取自 fact + frozen binding; +- lifecycle projection trace 现在同时写入 renderer lifecycle sequence、frontend projection sequence 和实际 `browser_fact_sequence`,并把输入 residence + 标成 `browser-fact-journal`。fact append 与 frontend output 的完整独立 wall-clock trace 仍需随通用 scheduler-side fact ingress 补齐,不能把当前相邻 + 同线程 capture 误称为 application/Host lifetime 已拆开; +- production 搜索确认 root renderer DCL/load 的两个 `BackgroundProtocolEvent` constructor 只剩这个 fact-gated emitter 调用;其余命中均为 constructor + 本身或 serialization tests。child-frame lifecycle、`Page.setLifecycleEventsEnabled` 的 current visible snapshot replay、navigation/Target events 与 + `MainDocumentLoadFactProjection` completion transport 没有在本切片扩大迁移;后续应把 subscriber wake 提到 application scheduler,并逐 fact family + 增加 projector,而不是在每个 domain 新建 watcher; +- 修改前 lifecycle 邻域 `135/135`(run `438df3ef-77a3-478f-ba3c-daee9c11ee1d`)。新增 projector 的 exact visibility/once、attachment stale、completed + epoch、late-observer skip/no-regression 与 bounded lag 五条回归最终 `5/5`(run `76df095f-f1b8-4f76-b722-5a5003751b1a`);既有 lifecycle 邻域扩大到 + `136/136`(run `0dae1654-50e3-454c-956c-e0930b033dd0`),Protocol 全包 `3430/3430`(run + `5bce6dd8-2294-4c6d-81ea-61805b2adeb1`),13 条 exact/stale/barrier/replacement/Chromium-order 邻域做 `--stress-count 20 + --flaky-result fail` 共 `260/260`(run `ce4e45ad-06a1-4aa8-9b01-b85e4b7ee896`)。开发中的第一处 fixture 回归是一个 test-only + loaded-commit fixture 直接赋值 physical + `browser_context` 并手工传 DCL、没有注册 Core Page 或发布 Browser fact;fixture 改走 `insert_browser_context`,显式记录 exact fact 后仍证明“不回读 live + Page”,并新增 raw record duplicate 零 DCL/load 与 consumed fact sequence 断言,没有 production fallback。随后一版过严的 projector order guard 又让 4 条 + `document.open/write/close` 回归 `4/4` 必现超时:初始 Document 的 DCL fact 未被该 frontend 选择,初始 load 因此关闭整个 subscriber,饿死后续 replacement + lifecycle;按上述 Chromium subscription 语义改为退休未选择的旧 fact 后,原 4 条 `4/4` 通过(run + `3980c05c-044f-456e-bd86-7f44aa851ba4`),没有加 timeout、sleep 或 retry; +- 提交/rebase 前的 workspace 全量 nextest 为 `16221/16221`、既有 skip `17`(run `1dbd4f77-a780-4531-9ce1-2ca26847db35`)。 + `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过;固定 + `target/release/moli` SHA-256 为 `5f335925d3b2438a79458a9c3bf92d4a354daf28d2cc835f36291fa90362a198`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组连续两轮均为 + `251/251`、`ok=true`、失败列表为空;WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 连续两轮均为 + `157/157`、`ok=true`、失败列表为空; +- 本切片提交后按用户要求执行原样 `git pull -r origin master`:`origin/master` 从 `744e161dad` 前进到 `dae23ca510`,97 个分支提交全部 + 重放,没有 skip。三处 conflict 均按现有责任边界合并:`moli-protocol/src/lib.rs` 同时保留 master 的 renderer command exports 与分支的 + Browser Host turn export;`conn/dispatch.rs` 保留 master 的显式 parse-error 语义与分支的 owner-lane settle;Patchright shadow fixtures 使用 master + 已提供的 `navigate_to_data_html_async`,该 helper 自身已经等待 exact renderer load,因此没有叠加第二次等待。rebase 后 workspace all-target + `cargo check` 发现 `Target.sendMessageToTarget` 的 owner-lane adapter 仍调用 master 已删除的 infallible `ParsedCdpCommand::parse`;修复抽出统一 raw + dispatch 入口,让顶层和 nested command 共享 `parse_str` / `-32700`,并新增“outer command 成功、nested parse error 经 + `Target.receivedMessageFromTarget` 返回”的回归,没有引入 `expect` 处理不可信 payload; +- rebase conflict、nested parse 与本轮 projector 邻域 `14/14`(run `66612363-3c50-470e-85e7-09a5fc3e5ce0`),rebased Protocol 全包 + `3431/3431`(run `778deb7d-7c16-4e09-bffd-556d80b79e61`),workspace all-target `cargo check`、`cargo fmt --all --check` 与 + `git diff --check` 通过,Protocol all-target clippy `-D warnings` 通过。rebase 前已完成 16k workspace、release 与两套 smoke;按用户要求降低重复验收 + 密度,rebase 后没有再次运行 workspace 全量、release 或 smoke,不能把上面的 release SHA 当成 rebased tree 的二进制哈希; +- 第一次 rebased branch 推送后的 remote 校验窗口中,`origin/master` 又新增一个仅修改 parser 调研文档的 `d4070fec16`。因此再次执行原样 + `git pull -r origin master`,98 个分支提交全部重放,无 conflict 或 skip;该上游增量不改代码,未因此重复 Rust/protocol 验证; +- 本切片没有新增 sleep、retry、poll、drain、pump、spawn 或 per-feature watcher,也没有改变现有 event JSON/session fan-out/response visibility barrier。 + cursor 当前仍和 authoritative `BrowserNavigationOwner` 一起物理驻留在 `CdpConnection`,只是责任上只读;通用 application-owned fact wake、Target/ + navigation projector、frontend attach bootstrap/lag recovery,以及 Browser Host 独立 lifetime 仍分别属于后续 Phase 5/6,不能据此宣称 Phase 5 完成。 + +2026-08-05 Phase 5 frontend fact ingress/projector 模块闭环记录: + +- `BrowserFactJournal` 在 retained cursor 之外新增 payload-free `BrowserFactWakeSubscriber`。每个原子 publish batch 在 fact 已进入 retained ring 与 + broadcast cursor 后,只发布一次最新 `BrowserFactSequence`;wake 使用 `watch` 合并慢 consumer,不复制 envelope、不保留第二份 fact queue,且 + `send_replace` 不等待 frontend。新订阅者能看到当前 committed tail;journal drop 前已经提交的 tail 先交付,随后才报告 typed closed terminal; +- `CdpSchedulerEventReceivers` 现在物理拥有 wake subscription。WebSocket actor 的 `SchedulerInputReceivers` 与 direct CDP/WebDriver 的 + `recv_interleaved_input` 都把 `BrowserFactWake(sequence)` 当作独立 application input;renderer navigation gate 不屏蔽它,ready Runtime response + snapshot 也先冻结当时已经 ready 的 fact wake。没有新增 background task、per-domain watcher、sleep、poll loop 或 Browser Owner drain; +- frontend 收到 wake 后让唯一 bounded fact cursor 前进,并严格验证 cursor 至少到达 wake sequence。lag、journal close、non-monotonic、pending overflow + 或 wake/cursor 不一致均沿 typed scheduler progress failure 终止该 frontend;Browser Owner 和其它 subscriber 不受影响。原先只表达 renderer transport + 的 failure wrapper 因此改名为通用 `CdpSchedulerProgressFailure`,避免把 fact ingress terminal 伪装成 renderer 错误; +- `CdpBrowserFactProjector` 现在同时拥有 navigation、Target terminal 与 root lifecycle 的 pending projection。Page navigation admission、普通/失效 Page + failure、download、loaded commit、Page crash/close 和 BrowserContext disposal 的 Page close 都必须 claim exact envelope;loaded commit 要求 + `NavigationCommitted -> PageReplaced` 相邻,Target terminal 会一起消费同 batch 的 pending-navigation failure。URL、initiator、security origin、session fan-out、 + discovery subscription 和 response barrier 仍是在 producing turn 冻结的 frontend binding,不反向进入 Browser fact; +- projector 观察到 `PageReplaced`、带 predecessor 的 `NavigationFailed`、`TargetCrashed` 或 `TargetClosed` 时,会退休该 predecessor Page 尚未释放的 + lifecycle projection,防止 canceled response barrier 或 slow frontend 让旧 Document 长期占用 bounded pending window。production producer audit 确认 + Core navigation/replacement/termination producer 都通过上述 claim boundary;剩余直接调用位于 `#[cfg(test)]` fixture; +- root lifecycle producer 与 CDP projection 当前仍在同一 `CdpConnection` renderer turn。该 turn 内的 exact non-blocking capture 暂时保留,用于保证同一 + publication 中 DCL/load 与其它 renderer output 的既有顺序;直接把唯一 cursor 搬出 connection 会让 fact 到下一 application turn 才到达,从而需要新的 + output residence。后续应随 Browser producer 移入 Host 或引入明确 fact/output causal join 后删除这条 same-turn 快路径,不能把本模块描述成 projector + lifetime 已独立; +- 本模块不改变 CDP/BiDi/Classic event JSON、session fan-out 或 response visibility barrier。Core coalescing/bootstrap/closed、application idle wake、 + same-cursor fact-family、old-Page retirement、failed-Page transaction 和 Target terminal adjacency 均有聚焦回归。最终当前树 workspace 全量 nextest + `16233/16233`、既有 skip `17`(run `2cd09b14-eff0-4fb4-9734-ae1b5522a7d9`);`cargo fmt --all --check`、workspace all-target + clippy `-D warnings` 与 workspace release build 均通过。固定 `target/release/moli` SHA-256 为 + `e2555ac0f7a52c3f10544efacb5fec45deb85f9c5759f32ee3e5ce3eac2f0735`; +- 显式清除大小写 HTTP/HTTPS/ALL/FTP proxy 和 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组两轮均为 + `251/251`、`ok=true`、失败列表为空;WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` 两轮均为 `157/157`、 + `ok=true`、失败列表为空。Target creation/metadata facts、attach bootstrap/resnapshot 和 Browser Host 独立 lifetime 仍属于后续 Phase 5/6, + 因此本模块闭环不等于 Phase 5 exit。 + +2026-08-05 Phase 5 top-level Target creation fact 模块闭环记录: + +- Core `BrowserContext` topology commit、background/active Target registration 与 bootstrap placeholder replacement 现在都在完整 + Target/Target-handle/Page-slot commit 后发布唯一 `BrowserFact::TargetCreated`。envelope 冻结 Browser instance、typed + BrowserContext/Target 与初始 exact `PageResidenceIdentity`;不携带 tab facade、session、attachment、discovery filter 或 wire event; +- `BrowserTargetRegistration` 返回同一 commit 的 Page identity。Protocol 的统一 target-registry projector 在同 turn 完成 physical payload + 投影后,从唯一 `CdpBrowserFactProjector` claim 该 occurrence;即使没有 discovery subscriber 也会 claim 并丢弃 frontend 输出,避免关闭 + subscription 改变 Browser trace 或让 bounded pending window 累积。fact 缺失会使该 frontend projector 进入 typed terminal error,不能回退为 + 根据 physical Target 猜测事件; +- `Target.createTarget` 与 renderer popup 路径把 claim 后的 opaque creation projection 随既有 completion move-own。输出前再次验证 exact + Page-slot instance:initial Document materialization 可以推进 generation,但 Target close 后复用相同 public targetId 会获得不同 slot instance, + predecessor token 不能投影到 successor。一个 occurrence 可以授权该 frontend 对同一 browser Target 生成 page/tab CDP fan-out 与 automation + sidecar,但只能被 cursor claim 一次;auto-attach completion 本身不被 discovery subscription 反向阻塞; +- BrowserContext/default Target bootstrap 会立即 claim 并结束 creation occurrence。随后 `Target.setDiscoverTargets` 独立枚举当前 live topology, + 因而“创建时 discovery 关闭,稍后开启”仍报告现存 tab/page;重复开启依靠 frontend reported-target state 去重,不会再次 claim 或重放旧 + occurrence。这冻结了 occurrence 与 resnapshot 的模块边界,但 resnapshot 的 authoritative snapshot API 尚待从 physical Protocol topology + 提取;worker Target 仍是 renderer lifecycle,不伪装成 top-level Browser topology fact; +- Core 与 projector 回归覆盖 Context active/background 发布顺序、拒绝时零 fact、无 discovery claim、单次 claim、Document generation 前进、 + same targetId 新实例 stale-drop、bootstrap 消费、后开启 discovery resnapshot,以及既有 createTarget/popup/auto-attach 行为。聚焦 + `cargo nextest` 为 `84/84`(run `a5121ac3-6775-4c32-bd27-adf4a47cc311`)。本模块没有新增 task、watcher、sleep、retry、poll、 + drain 或 pump;最终当前树 workspace 全量 nextest `16239/16239`、既有 skip `17`(run + `611e1045-e5d3-4396-bafb-d82389b5c0e4`),`cargo fmt --all --check`、`git diff --check`、workspace all-target + clippy `-D warnings` 与 workspace release build 均通过。固定 `target/release/moli` SHA-256 为 + `e1ac8225192bb5c1c95e35f702f31108f500de0393e185847ffdfcab32f476cc`;显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、 + 原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `251/251`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后执行 `git pull -r origin master`,上游从 `d4070fec16` 前进到 `780d9fe8ed`,只新增一份 benchmark 审计文档;100 个 + 分支提交无 conflict 或 skip 重放。排除 `docs/**` 后 pre/post-rebase tree diff 为空,post-rebase `cargo fmt --all --check` 与 + `git diff --check` 通过;按降低重复验收密度的约定,没有为纯文档上游增量再次运行 workspace nextest、release 或 smoke。 + +2026-08-05 Phase 5 Browser-owned top-level Target snapshot 与 attach bootstrap 模块闭环记录: + +- Core 新增 protocol-neutral `BrowserTopLevelTargetSnapshot`、`BrowserContextTargetSnapshot` 与 + `BrowserTargetStateSnapshot`。snapshot 在一个 owner turn 内按 selected Context、inactive Context,以及各 Context 的 active Target、 + background Target 顺序冻结当前 topology;每项保存 exact Browser instance、Context handle、Target handle 和 Page-slot identity,不保存 + title、URL、opener、session、attachment、filter、tab facade 或 worker metadata; +- current-state snapshot 与 `TargetCreated` occurrence journal 明确分工:journal 回答“发生过什么”,snapshot 回答“现在有哪些 exact + top-level Target”。Document generation、active/background residence 可以前进而不使稳定 Page slot 失效;Context/Target/Page-slot 被替换, + 或 public id 在另一个 Browser instance 中复用时,旧 snapshot 必须 stale-drop。即使 snapshot 为空也保留 Browser provenance,不能跨 + Browser Host 投影; +- Protocol 新增单一 `target_snapshot_projection` 边界:先验证 physical/Core topology,再从 Core 捕获 snapshot;投影时逐项验证 exact + Context、Target 和物理 Page-slot handle,最后才 join 当前 title/URL/opener/attached metadata。renderer-owned shared/dedicated/service + worker 仍由各 Context 的 frontend payload 追加;Core 不接触 CDP type、session 或 wire event; +- `Target.getTargets`、`Target.getTargetInfo`、`Target.getClientWindows`、`Target.setDiscoverTargets` 的现存 Target bootstrap,以及 + `Target.attachToTarget`、`Target.setAutoAttach` 的 top-level page/tab bootstrap 已改为消费同一 Core snapshot。direct attach 与 + auto-attach 在 initial Document/runtime binding 等 await 后重新验证 exact snapshot,再投影并提交 session/event;旧 Target completion + 不能绑定或宣布复用相同 targetId 的后继实例。auto-attach 在 Context activation await 后也重新验证 exact Context,恢复先前 selected + Context 时不按裸 public id 选择后继实例; +- 本模块没有增加 task、watcher、sleep、retry、poll、drain 或 pump。Core/projection、discovery/get、direct attach、auto-attach 与 + same-id stale completion 的聚焦回归最终为 `88/88`(run `51ca9e6f-13dc-416d-903b-054a6fbcf1ad`),focused Core/Protocol lib + clippy `-D warnings` 通过。最终当前树 workspace 全量 nextest `16248/16248`、既有 skip `17`(run + `df479120-aab2-49cd-87b9-d3af075204f1`);`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过。固定 `target/release/moli` SHA-256 为 + `32f7ebcaeb48ff7d428b1d52eb988a8fb0400d0381d952c223d9b27e13b5fa4b`;显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 + no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `251/251`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 这个 snapshot 模块落地时仍是 Phase 5 迁移形状:authoritative owner/snapshot 物理上由 `CdpConnection` 持有的 + `BrowserNavigationOwner` 提供,metadata transition 与 attachment/subscription 当时尚未拆开;该缺口由下一个模块记录闭环,owner 的 + 物理 residence 则仍留给 Phase 6。 + +2026-08-05 Phase 5 top-level Target metadata fact 模块闭环记录: + +- Core 新增 protocol-neutral `BrowserTargetMetadataTransition` 与 `BrowserFact::TargetMetadataChanged`。一个 loaded Document commit + 在 request、Page generation 和 history 都提交后,以同一原子 batch 发布 + `NavigationCommitted -> PageReplaced -> TargetMetadataChanged`;metadata 冻结 exact navigation、successor + `PageResidenceIdentity`、URL 与 title,不含 CDP session、attached、discovery filter、tab facade 或 wire payload; +- named target reuse 只向 Browser Host 提交 auxiliary navigation intent,不再提前改 physical Target URL,也不在导航接受时发布 metadata + fact。后继 Document 真正 commit 后才产生唯一 metadata occurrence。初始 Target navigation 与普通/renderer navigation 使用同一 producer, + 删除了 Browser Host completion 内根据物理 Target 猜测 `targetInfoChanged` 的 special case; +- 本地 Chromium `/home/donoughliu/chromium/src/out/Default/chrome`(source revision + `a03603fe9af6230a12f1b2fb2c18a7d003a0d937`)在显式清理代理的本地 HTTP named-popup probe 中,等待第一次导航完全 settled 后,第二次 + `window.open('/two', 'named')` 的可观察顺序是 `Runtime.evaluate` response,随后恰好一条 popup + `Target.targetInfoChanged(url=/two)`。因此 named-target selection 不是 metadata occurrence;未等待第一次导航的 probe 中 response 前出现的 + `/one` change 是前一次 Document 的延迟 commit,不能误归因给第二次 named reuse; +- 唯一 `CdpBrowserFactProjector` 按 exact navigation/Page claim metadata fact。独立 Target projector 先验证 Core snapshot 与 exact Page, + 再用 fact 中冻结的 URL/title 覆盖物理投影,并只在 frontend 边界 join attached、discovery/reporting policy 和 page/tab fan-out;旧 fact 在 + replacement 后 stale-drop,slow frontend 尚未 claim 的 predecessor metadata 会由 successor replacement/termination 退休,不占用 bounded + pending window; +- attach/detach 只产生 frontend-owned attached-state delta,不写 Browser fact;shared/dedicated/service worker 的 metadata 继续由各自 + renderer/frontend lifecycle 投影,没有伪装成 top-level Browser transition。本模块没有新增 task、watcher、sleep、retry、poll、drain 或 + pump; +- Core/fact/projector/Chromium-order 聚焦回归 `17/17`(run `e2d44b55-2ac0-4415-8d96-e5c38e50d150`),Target metadata、attachment、 + auto-attach 与 named-popup 扩大矩阵 `183/183`(run `5af8b929-32c6-460b-b69b-fa3468261d49`),same-command/independent named + navigation `3/3`(run `83926ae9-e660-4d0f-b7ec-c578936c21cd`)。最终树 workspace 全量 nextest `16254/16254`、既有 skip + `17`(run `489d2680-51c1-4945-a439-cbd72cbb4b27`);`cargo fmt --all --check`、`git diff --check`、workspace + all-target clippy `-D warnings` 与 workspace release build 均通过。固定 `target/release/moli` SHA-256 为 + `4ace6a0fe991dce27b038b5775f573b146cc0a82b33e091093e58783d258da7e`;显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 + no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `251/251`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后按约定执行 `git pull -r origin master`:`origin/master` 从 `93ceb0ed1f` 前进到 `08d0340f0c`,102 个分支提交全部 + 重放,无 skip;七处 content conflict 按当前边界合并。上游把 CDP ingress 收紧为 validated `ParsedCdpCommand`、私有 + renderer policy 和 typed navigation token,因此内部合成的 Runtime payload 改用 `RendererCommandDescriptor::from_synthesized_payload` + 或先 parse 再建立 `Cmd` view,test-only command 改用 `Cmd::for_test`;Debugger cleanup 使用 exact canceled navigation terminal, + Performance fixture 则先注册 Core Context/Target topology 再安装 Page。没有恢复 raw struct literal、旧 navigation clear 或绕过 Core + authority 的 compatibility path; +- rebased tree 的跨边界聚焦组 `14/14`(run `0ff4f788-f884-42d9-939d-642e85c3ae4c`)。首次 workspace 全量为 + `16268/16269`(run `5aa98c33-484c-40bc-8907-9c758271ea9d`),唯一失败由上述 Performance fixture 确定性违反注册顺序造成; + 修正后 exact stress `20/20`(run `96cbb916-b125-43a9-bc50-a45c061e45d0`)、Performance 模块 `55/55`(run + `33678d29-3856-416d-a26d-c25a45d1d50b`),最终 workspace 全量 `16269/16269`、既有 skip `17`(run + `3f1987de-fbbd-432f-95b8-bbec44c00a95`)。`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过;release SHA-256 为 + `aa677bdc412e498fca340d73cd5cd059f08561f873f418712c1890a90abe5788`。显式清理大小写 HTTP/HTTPS/ALL proxy 并设置 + `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `251/251`、WebDriver 四组 `157/157`,均为 `ok=true`; +- rebase 后曾在一次 CDP 全组的 routed popup `wait_for_load_state("load")` 抓到无 DCL/load 的挂起;初始独立 core 复跑为 + `8/10`,但增加 production trace 或失败后诊断会改变调度,后续明确清代理的 fresh-process core `50/50` 与 CDP 全组均通过。 + Chromium/renderer 路径审计排除了“403 DCL 为假”、projector pending 阻塞 renderer ingress 和重复 initial-navigation trigger;成功/失败 + wire 都已完成真实 Document commit,差异发生在 post-commit lifecycle tail。当前没有足够证据归因到 response-flush continuation、engine + handoff 或 renderer owner,因此没有提交 sleep、retry、timeout 放宽或猜测性 production 修复;临时 Python 诊断已删除。这是仍需以 + exact Document/generation trace 捕获的残余风险,单次最终 smoke 绿色不能证明其消失; +- 这仍不是 Phase 5 exit:CDP projector 与 authoritative owner/journal 仍共同物理驻留 `CdpConnection`,root lifecycle causal join 和 + BiDi/Classic 的同源消费还需审计。下一步先做严格 Phase 5 exit audit,按未满足的不变量选择一个端到端模块;不再默认逐 command/wrapper + 扩展 fact plumbing。 + +2026-08-05 Phase 5 Browser-owned Document lifecycle wait 模块闭环记录: + +- Phase 5 audit 确认 CDP navigation completion 与 BiDi/Classic/high-level page-load wait 仍通过 + `CdpConnection -> TargetPageSlot -> RendererDocumentLifecycleWaiter` 注册、轮询和显式释放 frontend-local waiter。它虽然不再拥有 + renderer progress,却让同一 DCL/load truth 分成“Browser fact occurrence”和“Protocol Page callback”两套消费路径;也无法在 bounded + journal 已淘汰旧 occurrence 后可靠 bootstrap 新 waiter; +- Core 新增每个 live top-level Target 至多一条的 `BrowserDocumentLifecycleRegistry` current-state index。它只保存 exact + `PageResidenceIdentity`、renderer Document/epoch、furthest milestone 和 termination,不保存协议事件、subscriber、physical Page 或 callback。 + occurrence journal 回答“发生过什么”,current-state index 回答“当前 exact Document 已经到哪一步”;`Started` 只选择 current snapshot, + reached/terminated 仍由唯一 bounded journal 发布。replacement、带 predecessor 的 failure、Target crash/close 会退休旧 Page snapshot; +- `BrowserNavigationOwner::capture_document_lifecycle_wait` 在一个 Core turn 内验证 exact current Page generation、pending replacement 和 + current snapshot,再从同一个 journal cut 建立双 cursor 的 move-only ticket。已达到或已终止的 Document 立即得到 typed terminal;旧 + Page 或已有 queued cross-Document navigation 得到 `Superseded`;否则后续 DCL/load/termination/replacement/Target terminal 从公共 fact + stream 决定结果。ticket 构造器收为 Core-private,Protocol 不能自行拼 cursor 或伪造 Browser authority; +- 删除 `TargetPageSlot` 的 waiter id、registry、event callback loop、outcome polling 与 release API。`DevToolsDocumentLifecycleWaitKey` + 现在只冻结 frontend route 解析得到的 frame/loader/Document identity,并 move-own Core ticket;scheduler、CDP、BiDi 与 Classic 只读取 ticket + state,drop 即释放 subscriber。exact CDP lifecycle event matching、response visibility barrier,以及 + `Page.setLifecycleEventsEnabled` 对 frontend-visible current snapshot 的 replay 都保留在 Protocol projection,因为它们决定 wire visibility, + 不拥有 Browser lifecycle truth; +- 本模块没有新增 task、watcher、sleep、retry、轮询、drain 或 pump。current snapshot/journal eviction、future termination、successor + replacement、CDP same-Document wait、BiDi interactive/complete wait 与 Classic page-load strategy 的跨层矩阵 `217/217`(run + `6415ad9c-c40b-4220-bb2a-126b1708f0a0`);其中 7 条关键路径按 nextest 原生 stress 连续 20 轮、共 `140/140` 通过(run + `adfb5f19-be4b-4be8-bd83-b001c6a36c70`)。最终当前树 workspace 全量 nextest `16273/16273`、既有 skip `17`(run + `b353b357-4afc-44d7-91b8-1188b105c924`);`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过,release SHA-256 为 + `8892c61dcbe3a0fc605c0c55093fe0b98567f453637af87c7e137f977eedc867`。显式清理大小写 HTTP/HTTPS/ALL/FTP proxy、原 smoke + group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `251/251`、WebDriver Classic/BiDi/Selenium/Semantics + `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 这仍不是 Phase 5 exit:root lifecycle producer/projector 的 same-turn causal join、其余 BiDi/Classic navigation/target consumer audit, + 以及 owner/journal 从 `CdpConnection` 的物理迁出仍未完成;最后一项属于 Phase 6。 + +2026-08-05 Phase 5 root lifecycle fact/output causal link 模块闭环记录: + +- exit audit 确认上一模块仍有一处不必要耦合:renderer ingress 向 Core 发布 exact lifecycle fact 后,会在同一个 + `CdpConnection` turn 立即调用 `CdpBrowserFactProjector::capture_available(...)`。这个调用不阻塞 Browser Owner,却让 producer + 负责推进 frontend 的唯一 journal cursor;application fact wake 与 renderer visible output 因而不是两个真正独立的 consumer 时机; +- producer 现在只登记 `BrowserFactSequence -> CommittedRendererDocumentBinding` causal link。link 冻结 exact + `PageResidenceIdentity` 对应的 attachment、renderer frame/Document、navigation token 与 loader attribution;登记时验证 fact 的 exact + Page/Document,重复同一 link 幂等,冲突或错配使该 frontend projector 进入 typed terminal error。fact 仍是生命周期 occurrence 的唯一 + authority,link 只回答“这个 occurrence 应投影到哪个已冻结 frontend binding”,不反向修改 Browser state; +- link 登记不读取 subscriber、不推进 cursor。payload-free application wake 可以先把 fact 捕获为 pending,随后 renderer visible record + 再按同一个 sequence/binding/stamp claim;visible record 也可以先触发独立 capture。attachment replacement 后的新 binding 不能领取旧 + Document fact,`document.open()` 已完成 epoch 仍沿用当前 root-Document binding 的既有可观察语义; +- causal-link 表最多保留与 bounded journal 相同的 `1024` sequence window;超出可达窗口的 link 会被裁掉,subscriber lag 仍是 typed + terminal error。它不复制 fact payload、不唤醒或推进 Browser,也没有新增 task、watcher、sleep、retry、poll、drain 或 pump,因此不是第二条 + fact/event queue; +- projector 聚焦回归 `12/12`(run `dfb52549-3175-4630-8846-6136662f2de4`);覆盖 Core lifecycle、Page slot、main-Document + commit、CDP lifecycle、BiDi interactive/complete 与 Classic page-load strategy 的跨层矩阵 `236/236`(run + `d3b24a1a-b0ff-4fdb-a59e-cb574a746135`);6 条 wake/replacement/epoch/commit/BiDi 关键路径按 nextest 原生 stress 连续 20 轮、共 + `120/120` 通过(run `d8743f85-5cfc-4fef-ac06-60f3b367b9ae`); +- 最终当前树 workspace 全量 nextest `16273/16273`、既有 skip `17`(run + `3658d588-6464-4022-aeee-92942b9f595b`);`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy + `-D warnings` 与 workspace release build 均通过,release SHA-256 为 + `06e57c0845b4b993952c25eafde4a1bfe5fcee86c6acfee1c4e916b0d84ed6f8`。显式清理大小写 HTTP/HTTPS/ALL/FTP proxy、原 + no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `251/251`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 这仍不是 Phase 5 exit。`moli/src/protocol_server/webdriver_bidi.rs` 的 + `push_initial_load_events_for_script_created_targets` / `initial_load_events_for_target_created_message` 仍把 + `Target.targetCreated` 直接扩写成 `AutomationEvent::NavigationFrame` 和 `AutomationEvent::Load`;该 load 没有 exact + Document/milestone/stamp fact,关闭或改变 Target frontend projection 仍可能改变 BiDi lifecycle trace。下一模块应先为 script-created + target 建立同源 lifecycle bootstrap/consumer,再继续审计剩余 BiDi/Classic outcome;authoritative owner/journal 从 + `CdpConnection` 的物理迁出明确留给 Phase 6。 + +2026-08-05 Phase 5 BiDi script-created Target lifecycle consumer 模块闭环记录: + +- 本地 Chromium/ChromeDriver `147.0.7709.0` 在显式清代理的 HTTP popup probe 中输出一条 + `contextCreated(url=about:blank)`,随后按同一非空 navigation UUID 输出一条 DCL 和一条 load,二者 timestamp 非零;没有从 Target creation + 推断 load。当前 Moli 对同一场景则先输出 `load(navigation=null)`,再输出共享真实 loader navigation 的 DCL/load。首条 load 由 + `Target.targetCreated` 的 URL 合成,不对应任何 Document/milestone/stamp,造成同一 Document 两次 load,并把假 load 排在真实 DCL 前; +- 删除 `push_initial_load_events_for_script_created_targets` 与 + `initial_load_events_for_target_created_message`,不再把 Target frontend message 扩写成 `NavigationFrame + Load`。script-created popup 的 + DCL/load 只来自 `domains/page/lifecycle.rs`:renderer ingress 发布 exact `DocumentLifecycleReached`,统一 projector claim sequence 与冻结 + Document binding 后生成 `AutomationEvent::DomContentLoaded/Load`,BiDi frontend 再做 subscription/context mapping; +- 临时 Target discovery 现在只服务 `browsingContext.contextCreated` subscription。仅订阅 DCL/load 的 BiDi connection 不再改变 Target + discovery state,也不需要 Target event 才能观察 popup lifecycle。既有 command-response 后 frontend navigation delivery wait 仍用于把已完成 + 的 fact-authorized sidecar 送上该 socket;它没有新增 Browser owner action、task、watcher、sleep、retry、poll、drain 或 pump,也不把 + frontend 订阅变回 Browser progress 条件; +- 新回归先在旧实现上确定性失败:收到 synthetic load、真实 DCL、真实 load 共三条(run + `a73761dd-c019-435f-9f67-0dfd6265cebe`);删除推断后 exact case 通过(run + `6c85e1e1-a882-4750-8806-713788b6d0c2`)。BiDi lifecycle/subscription/WPT viewport 与 CDP popup 扩大矩阵 `78/78`(run + `7a20593c-5914-421b-9d1b-b8c8a6597abe`);exact BiDi、viewport 和 CDP popup 三条路径连续 20 轮、共 `60/60` 通过(run + `23c3c912-a8e7-4e72-9f07-3b125a2cc9c0`); +- workspace 首轮 nextest 为 `16273/16274`、既有 skip `17`(run + `3c867449-d670-4214-88a4-f68cf9f3e7b4`),唯一失败是本模块未修改的 renderer/OPFS 用例 + `sandboxed_blob_iframe_keeps_opaque_storage_context_for_opfs_messages`,结果停在 `"pending"`。该用例在历史全量中已有同型单次失败记录;本轮 exact + `--stress-count 20 --flaky-result fail` 为 `20/20` 通过(run `47493d76-7bab-48c3-8440-9e36995f60ae`)。首轮执行时另一个 checkout + 也在跑 workspace nextest,存在资源争用但不足以证明根因;未修改 OPFS/structured-clone、未增加 timeout、retry 或放宽断言。无该外部争用后的 + workspace 最终 nextest 为 `16274/16274`、既有 skip `17`(run `b1f7a9e4-e13e-4ca7-ad17-284fe66d3f81`); +- `cargo fmt --all`、`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build + 均通过。rebase 前固定 `target/release/moli` SHA-256 为 + `f47b60918fb24964935ad237c9729222e1430d055873bac264cf9fa7763a346a`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、原 + no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `251/251`、WebDriver + Classic/BiDi/Selenium/Semantics `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后执行 `git pull -r origin master`,上游从 `08d0340f0c` 前进到 `815b44cbf0`,新增 protocol TCP keepalive、CDP DOM + snapshot smoke 同步和 WebDriver smoke 失败诊断;105 个分支提交无冲突重放,本模块 rebase 前后 stable patch-id 均为 + `f80cbbbf460ff98a45fe37d4155bda4020a6262e`。当前树 exact popup lifecycle 回归 `1/1` 通过(run + `6e4940b7-f23f-45e6-bf1f-6122750528e9`),fmt/diff check 与 workspace release build 通过,release SHA-256 更新为 + `a429aabfdf8913c90fc7bf5204ad8b037b92f0a4e8653b5329e08fd3565e2a05`;同样清代理后 CDP `251/251`、WebDriver + `157/157` 再次通过。未在等价模块 patch 上重复 workspace 全量与 clippy;上面的全量/clippy 是 rebase 前同一模块 patch 的门禁,rebase 后以 exact + 交叉点、重建 release 和两套完整 smoke 覆盖新增 master integration; +- 这仍不是 Phase 5 exit:显式 `browsingContext.create` 成功后仍重新查询 physical TargetInfo 并构造 + `AutomationEvent::TargetCreated`,显式 close 仍用 command 前保存的 TargetInfo 与成功结果构造 `TargetDestroyed`;二者没有把 Protocol 已 claim + 的 exact Target creation/terminal fact 带到 BiDi projection。下一模块应把显式 create/close 的 Target lifecycle sidecar 收敛到同一 fact + projection,同时检查 Chromium live `contextCreated(url=about:blank)` 与当前 Moli requested-URL payload 的独立兼容性差异;owner/journal + 从 `CdpConnection` 的物理迁出仍属于 Phase 6。 + +2026-08-05 Phase 5 显式 BiDi Target lifecycle consumer 模块闭环记录: + +- 显式 `Target.createTarget` 的 physical Target/Page-slot commit 已由既有 `BrowserFact::TargetCreated` 唯一授权。本模块让 fact claim 后的 + projection 在 BiDi lifecycle projector 已注册时,无论 CDP discovery 是否开启都发布恰好一个 protocol-neutral + `AutomationEvent::TargetCreated`,并携带 committed + `DevToolsTargetInfo` 快照。`Target.targetCreated` 的 page/tab、filter 和 owner-session fan-out 仍由 CDP frontend 决定,但这些 wire + notification 在 BiDi projector 存在时显式关闭 automation sidecar,不能再按 discovery owner 数量重复生成 BiDi occurrence;没有 + BiDi frontend 时保持既有 CDP typed event,不额外构造 neutral projection;popup 使用同一形状; +- top-level Target close 在 prepare 阶段冻结 exact page TargetInfo,Browser Host 提交 `TargetClosed` 后才允许 terminal projector 生成恰好一个 + neutral `AutomationEvent::TargetDestroyed`。CDP `Target.targetDestroyed` fan-out 同样变为 wire-only;Target attachment/detachment 和 worker + lifecycle 仍保留各自原有 typed sidecar。BrowserContext disposal 的既有 fact-gated frozen lifecycle prefix 保留,但其 top-level CDP + destroyed fan-out 也不再复制 automation occurrence; +- 删除 BiDi command adapter 在 create 成功后执行 `GetTargetInfo`、按 command result/fallback 合成 `TargetCreated`,以及 close 前查询并保存 + `TargetInfo`、按成功结果合成 `TargetDestroyed` 的路径。现在 Browser transition 是 occurrence authority;command result 只决定 frontend + response,不再证明 browsing-context lifecycle 已发生; +- create fact 与 command completion 在同一 turn 可用,因此 exact `contextCreated` 先投影、随后发送 create response。close 只负责向 + Browser Owner mailbox 提交 action,真正 terminal fact 在后续 owner turn 到达;BiDi socket actor 因而只暂存带 channel 的 close response 和 + exact target id,立即返回 application loop。Browser Host 自主推进后,输出路由先按 TargetDestroyed sidecar 投影/发送 + `contextDestroyed`,再释放匹配 target 的 response。未订阅该事件时仍按 sidecar 解锁 response;不相关 Target 的 terminal fact 不能误释放, + 多条 pending close 按 exact target id 分别结算; +- 这条 frontend response residence 不拥有 Browser progress,也没有新增 task、watcher、sleep、retry、poll、drain 或 pump。socket 断开只会 + 丢弃尚未发送的 frontend response;Browser Host 与 frontend/application lifetime 的物理解耦仍是 Phase 6,不能用本模块宣称 disconnect 后的 + Host lifetime 已完成。当前 projector registration 是 `CdpConnection` 上的 connection-local 迁移状态,由 BiDi actor attach/release 显式 + 开关;Phase 6 应随通用 frontend handle/subscription 移出 connection,而不是把该布尔量扩散成 domain policy; +- 红灯回归先证明 discovery 关闭时显式 create/close 的 typed lifecycle 均为 `0`;修正后 Protocol exact create/close、CDP-discovery 去重、 + popup discovery/no-discovery、Context disposal 与 BiDi create/close ordering 聚焦矩阵通过。红灯 run 为 + `bbd39c49-89ce-41e8-a811-5d16bdec323b`;扩大后的 Protocol Target 矩阵 `688/688`(run + `55a8479f-9b9a-4e3e-ac3e-9628cd58d551`),BiDi 模块与 exact close-response helper `129/129`(run + `b2d3b0c4-2c6f-4907-b142-50e278a5b82f`); +- 最终 workspace nextest 一轮 `16277/16277`、既有 skip `17`(run + `42fcfcff-0de4-4dfc-bb48-01f837858f1f`)。`cargo fmt --all --check`、`git diff --check`、workspace all-target + clippy `-D warnings` 与 workspace release build 均通过;固定 `target/release/moli` SHA-256 为 + `0aa46477cd776f7e5e6c59b9adca2f7354b10a6277d9d5ff228e82e5b49f19f3`。显式清除大小写 HTTP/HTTPS/ALL/FTP + proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `251/251`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后执行 `git pull -r origin master`,上游从 `815b44cbf0` 前进到 `abdb5e8cc4`。106 个分支提交只有旧 Target-topology + 提交与上游新增 Page/Network listener 测试装配发生一处冲突;合并结果同时保留 Core Target registry 注册和主/辅助 session-owned + Network listener。排除本条事后记录的 production/test patch 在 rebase 前后 stable patch-id 均为 + `81a07b599c17806de02c69600be87747388b8cfc`;交叉点两例与 exact + create/close 三例合计 `5/5` 通过(run `1f63d7df-4c3e-48fa-9098-30bf2eb07e14`),fmt/diff check 和 workspace release + build 通过,release SHA-256 更新为 `38ef645268149d02a45b7fbbcf95dc517119c354220755bbdecf1d0390a5f75a`。同样清代理后,包含上游 + 新增 case 的 CDP 默认全组 `252/252`、WebDriver 四组 `157/157`,均为 `ok=true`、失败列表为空。未在等价模块 patch 上重复 + workspace 全量与 clippy;rebase 前同一 stable patch 已通过两项门禁,rebase 后以 exact 交叉点、重建 release 和两套完整 smoke 覆盖 + master integration。 + +2026-08-05 Phase 5 BiDi deferred navigation outcome 模块闭环记录: + +- 此前 `browsingContext.navigate(wait=interactive|complete)` 遇到 Fetch request/response/auth pause 时,会暂存 BiDi response;后续 + `network.continue*` 结束暂停后,adapter 先读 `BackgroundCommandResponsePayload`,读不到再按相同 command id 扫原始 CDP + `{"id","result"|"error"}`。这让 CDP wire JSON 事实上成为第二个 navigation completion authority;任意同 id 原始消息都可能提前 + 结算 BiDi pending response,且 early success 与 paused terminal 使用两套推断路径; +- `BackgroundCommandResponseEvent` 现在可携带不参与 JSON serialization 的 exact + `BrowserNavigateCommandOutcome` sidecar。background early-success FIFO 在投影 `Page.navigate` response 时保留同一个 neutral outcome; + Fetch decision 的 terminal plan 则冻结原始 requested URL,先分离 neutral outcome 与 frontend projection,再把 outcome 绑定到原 + navigation command id 的 response envelope。`network.continue*` 自己的 response 仍独立使用外层 command id,不能误领该 sidecar; +- `BrowserNavigateResponseProjectionShape` 记录原 response 是否实际包含 `frameId`、`loaderId`、`errorText`、`isDownload`。BiDi + `navigation-{loaderId}` 在分离边界恢复 neutral loader identity,但 wire projection 保留原来的 `navigation`/`url` shape,不额外泄漏 + CDP 字段。BiDi pending consumer 只按 exact command id 读取 `BrowserNavigateCommandOutcome::{Completed,Rejected}`;旧的 typed CDP + payload parser、raw protocol-message fallback 和 result-shape猜测函数均已删除; +- 这条 sidecar 只随既有有界 output FIFO 前进,不新增 task、watcher、sleep、retry、poll、drain 或 pump,也不参与 Browser Owner + 调度。frontend 慢或断开只影响自身 pending response 是否还能发送,不能改变已经接受的 navigation; +- 本地 Chromium checkout 的 WPT + `third_party/blink/web_tests/external/wpt/webdriver/tests/bidi/network/continue_with_auth/action.py::test_cancel` + 明确要求 auth cancel 后完成唯一 401 response。因此端到端回归保持“401 Document 正常完成 navigation”的语义,而不是把 cancel + 制造成 navigation error;provideCredentials 则继续要求 200 Document 与非空 navigation id; +- 红灯先证明旧实现会用 raw CDP error 结算 pending BiDi response(run + `417f480c-2929-4828-bb08-89181ba2a38e`)。修正后 early success、BiDi wire-shape round-trip、paused rejection sidecar、typed-only + consumer、raw-ignore,以及 auth cancel/provideCredentials 两条 WebSocket 路径共 `7/7` 通过(run + `28e5a08a-7ee1-442b-bd53-fec63ba6f291`); +- 最终 workspace nextest 一轮 `16292/16292`、既有 skip `17`(run + `42fb1e85-967b-4d74-a468-9b558302c3c0`)。`cargo fmt --all --check`、`git diff --check`、workspace all-target + clippy `-D warnings` 与 workspace release build 均通过;固定 `target/release/moli` SHA-256 为 + `86531502df70c3871a279c3f27e514857e8f948d00a6d8da5dc3bbb47d67709c`。显式清除大小写 HTTP/HTTPS/ALL/FTP + proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `252/252`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后执行 `git pull -r origin master`,上游从 `abdb5e8cc4` 前进到 `c7baec54b8`,新增 layoutless page-side point query + 的 empty-result 语义及对应 CDP smoke;107 个分支提交无冲突重放。排除本条事后证据的 module patch stable patch-id 前后均为 + `78715685b9ca7747670af13f6ac5c477956e75f9`。上述 7 条 outcome 路径与上游 3 条 point-query/CDP hit-test 交叉点合计 + `10/10` 通过(run `9e66a3fe-e422-47ee-bbde-12b8cbcd079c`),fmt/diff check 与 workspace release build 通过,release + SHA-256 更新为 `8bf3107f836cad88718287d83de08e9b7542e95a19c800e6698287d2e7deff8e`;同样清代理后,CDP 默认全组 + `252/252`、WebDriver 四组 `157/157` 再次通过。未在等价 module patch 上重复 workspace 全量与 clippy;rebase 前同一 stable + patch 已通过两项门禁,rebase 后以 exact 交叉点、重建 release 和两套完整 smoke 覆盖新增 master integration; +- 最终同步时再次执行 `git pull -r origin master`,上游从 `c7baec54b8` 前进到 `031fa2c39f`,新增 base URL/小型 + parsing allocation 与 shadow-slot index 的 DOM CPU 优化及 profile 文档;107 个分支提交仍无冲突重放。排除本条事后证据的 + module patch stable patch-id 前后均为 `18f690a5461ff6cce4d12a91acbfefb49d5aaeec`。7 条 outcome 路径与 5 条上游 + DOM 交叉点合计 `12/12` 通过(run `dc757f65-9881-4576-9684-2bf1fb94f141`);workspace release build 通过,release + SHA-256 更新为 `3b832d3a5a438192cded7e463901bfc5ebb9bea3f77f00e64d33de98205b080c`。同样清代理后,CDP 默认全组 + `252/252`、WebDriver 四组 `157/157` 再次为 `ok=true`、失败列表为空; +- 本模块落地时尚未单独完成 Phase 5 exit audit:neutral outcome 的类型和 navigation authority 已属于 Core,但 response + correlation/sidecar 仍物理驻留 Protocol output envelope。下述 exit audit 已确认没有其他 frontend 从 wire payload 反推 + Browser outcome;该物理 transport residence 转入 Phase 6,不把 sidecar 扩散成各 domain 的自定义 channel。 + +2026-08-05 Phase 5 exit audit: + +- producer inventory 确认 Target、navigation、Page replacement、exact lifecycle 与 terminal facts 只从 Core authoritative + transition 发布;Core 不依赖 `BackgroundProtocolEvent`、session subscription 或 socket flush; +- consumer inventory 确认 CDP 由单 cursor projector claim fact,BiDi/Classic/high-level wait 使用 exact ticket 或 neutral + outcome;剩余 JSON 解析属于 renderer Runtime 或 frontend command shape,不承担 Browser 完成判断; +- journal 的 retained bound、subscriber cursor、显式 `Lagged`、stale Page retirement 与 response-visible sequence 已覆盖慢订阅、 + replacement 和 DCL/load causal join;frontend 断开只丢失自身投影; +- 因此本阶段 exit gate 已满足。journal/projector 的共同物理 residence 和 Browser Host 的应用级 lifetime 明确转入 Phase 6, + 不再用“清除 Protocol 内所有 await”扩大 Phase 5。 + +Exit gate:Browser Core transition 不构造 `BackgroundProtocolEvent`;CDP event 是否发出由 frontend +subscription 决定,但关闭 subscription 不改变 browser trace。 + +### Phase 6:Browser Host lifetime 与 browser state 完整提取 + +目标:Browser Host 不再从属于单个 frontend connection。 + +状态:已完成。profile/storage/cookie owner、Browser Host registry/engine residence、browser-global behavior policy、physical +renderer Page lifetime owner、Page command/cache payload、per-context renderer/network runtime root、Host executor authority、download +policy/registry、Host identity namespace、network request/body owner 与 Target `sessionStorage` association 已按端到端模块进入 +application-owned Browser Host。actual socket frontend 已由 `CdpFrontendEndpoint/Router/Receivers` 独立表示;owner-task-resident +`DevToolsHostAdapter` 负责 renderer/DevTools projection,并不随任一 socket attach/detach 创建或销毁。 + +Phase 6 最终 audit 修正了一个过期前提:legacy 名为 `CdpConnection` 的值不是一个 socket frontend connection,而是共享 owner task +内的 DevToolsAgentHost 类 adapter。`BrowserContext`/Target 中剩余的 renderer channel、Inspector session、Network/Log/Runtime cursor +与 output projection 不应仅因位于 Protocol 就搬进 Browser Core。Phase 6 的 exit gate 以 authority 和 lifetime 为准,而不是要求 +Protocol projection 容器归零;多 frontend 共用同一 Browser Host、以及每种 frontend 的 projection 收敛属于 Phase 7。 + +按 owner inventory 分批迁移: + +1. BrowserContext/Target/Page authoritative registry; +2. profile/storage partition/cookie owner; +3. network condition、cache、headers、request/body stores 的 browser portion(global policy 与 request/body store 已完成); +4. download registry/global IO 的 browser portion; +5. permission/emulation 中真正影响浏览器行为的 state(browser-global policy 已完成,context/Target runtime projection 随第 1 项迁移)。 + +2026-08-05 profile/storage/cookie owner 模块: + +- application 在接受协议 frontend 前创建唯一 `StoragePartitionState`;default profile BrowserContext 通过 + `StoragePartitionSharedStorageHandles` 共享同一个 live cookie store。raw CDP、standalone BiDi 与 Classic 的读、写、删除立即 + 观察同一真值;ephemeral BrowserContext 仍使用独立 memory partition; +- 删除 `SharedCookieProfile`、`CookieProfileCommit`、`CdpCookieSnapshot`、connection-local profile snapshot、 + `commit_cookie_delta` 及 teardown merge。frontend/owner 结束只发送 payload-free flush request,不能把旧视图写回 Browser state; +- `StoragePartitionState::flush` 在 application-owned partition 内串行化“取当前快照 + 写文件”,避免多个生命周期 checkpoint + 并发时较旧文件写入最后完成。flush 是持久化边界,不持有 Browser Owner、不推进 navigation,也没有新增 drain/pump; +- typed command 回归覆盖两个 `CdpConnection` 的 live Set/Get/Delete;服务级回归覆盖两个并发 BiDi frontend 立即互见,以及旧 + frontend 先关闭时仍只落盘较新的 Browser-owned 值;既有 profile restart、delete persistence 和 ephemeral isolation 用例保留; +- 聚焦 owner/live/persistence 回归 `6/6` 通过(runs `1d7dce32-7236-4db5-af6b-b5fcef765f6c`、 + `5982026d-1c7b-429f-bf44-50146091de12`)。workspace nextest `16289/16289`、既有 skip `17`(run + `701a8887-d69e-4b14-8d6c-208ecab6524d`),fmt、diff check、workspace all-target clippy `-D warnings` 和 workspace + release build 均通过;`target/release/moli` SHA-256 为 + `95fb65b3a3db319b466d03645dc323ee8fc8a4aa9360da36bfc4fd5454bc1cfc`。固定该 binary 并显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、继承 smoke group,设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 + `252/252`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 + `ok=true`、失败列表为空; +- 提交后按约定执行 `git pull -r origin master`,上游从 `031fa2c39f` 前进到 `d7fb86b60e`,新增 + awaitPromise realm-replacement 回归与 Inspector scoped-microtask 修复;108 个分支提交无冲突重放,本模块 stable + patch-id 前后均为 `42903c7ea4240981738a5fa5649ac213cfeb3318`。本模块 6 条 owner/storage 用例与上游 3 条 + Inspector/realm 用例交叉验证 `9/9` 通过(run `ba67dcf6-58d4-449a-a2b5-cdb380b13c9a`);fmt/diff check 与 + workspace release build 通过,最终 binary SHA-256 为 + `102b97bb2020a343afd9f2e94eef84d49ef700cb199d3db6bd4ecf9c0a61cd67`。同样清代理后,CDP 默认全组 + `252/252`、WebDriver 四组 `157/157` 再次为 `ok=true`、失败列表为空。没有在 stable module patch 上重复 + workspace 全量与 clippy;rebase 前同一 patch 已通过两项门禁,rebase 后以 exact 交叉点、release 重建和两套完整 + 外部 smoke 覆盖新增 master integration; +- 这完成了 Phase 6 inventory 的第 2 项,但不是 Phase 6 exit。后续模块必须按完整 Browser Host + lifetime/registry 闭环提取,不能退回逐字段 wrapper 迁移。 + +2026-08-05 Browser Host registry/engine residence 模块: + +- Core 新增 `BrowserHostState`,把 `BrowserNavigationOwner`、authoritative BrowserContext/Target/Page-generation + registry、selected/retained `NavigationEngine` 和 Browser fact journal 放进同一个 current-thread Host allocation。 + 这里有意使用 `Rc>`:V8/renderer owner 当前只允许在 dedicated local runtime 上运行,使用 + `Arc>` 会虚构并不存在的跨线程能力; +- application composition root 先按实际 fetch/resource 配置创建 `BrowserHostState`,再创建 + `CdpConnection` adapter。`BrowserHostActor` 必须接收并持有这一个 state,owner queue 与 authoritative state + 因而属于同一个 Host 组件;没有 state 的第二种 actor 构造路径已经删除; +- `CdpConnection` 删除内嵌的 `BrowserNavigationOwner` 值,只保存访问同一 residence 的迁移期 capability。 + fact projector 也从该 residence 建立自己的 subscriber cursor;所有 registry/history/replacement/termination/engine + transaction 均访问同一个 owner,没有复制 shadow registry、第二条 queue 或新的 drain/pump; +- data/inline/buffered/streaming/network-response Page 构建和 resource-runtime rebuild 不把 + `RefMut` 带过 `await`。target/session-owned detached load 在同步 owner turn 内捕获 + move-owned `NavigationEngine`,等待完成后随 typed navigation outcome 进入既有 exact adoption boundary;active/unbound + setup load 则克隆 exact selected engine 的 renderer capability,由 authoritative selected engine 继续保持 runtime lifetime, + 不把每次新建的 storage-input `RendererBrowserContextRuntime` 误作 renderer owner,也不返回调用者不可见的隐藏 handoff。 + 无 navigation outcome 的 direct fetch/rebuild wrapper 在等待后显式归还 engine。targeted all-target clippy 以 + `clippy::await-holding-refcell-ref` 作为结构门禁通过; +- Core 回归证明 actor 持有 application 创建的 exact Host residence;Protocol 回归在注册 default + BrowserContext/Target 后销毁整个 `CdpConnection`,随后仍可从 application-owned residence 读取同一个 + `BrowserInstanceId` 和完整 registry。聚焦用例通过(runs `63ffcb43-af28-41c9-9343-0dee4ee31402`、 + `e7470f58-7a37-4bf1-a09d-1fed28db12cc`); +- 首轮 workspace 全量暴露 7 条 owner-route/storage Protocol 回归:临时 engine 被当作 authoritative owner,或 + buffered setup 返回隐藏 engine 后随部分 move 被 drop,导致旧 Page 的 render-runtime 关闭。修复后 7 条 exact + 回归连续 `10/10` 轮全部通过(run `d3527df6-b57c-41e5-9ba2-b0aa504f9d9a`)。同轮唯一 Renderer + structured-clone 失败单独及 `20/20` stress 均通过(runs `201f6d3b-518e-4437-89b5-b0d79f73e072`、 + `c43c36ee-1265-46e4-9a3b-4cd71d2dc88d`),未用 retry/sleep 或削弱断言处理;最终 workspace nextest + `16295/16295`、既有 skip `17`(run `e63f8434-26d3-4dbc-ac65-705b66029930`); +- `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build + 均通过;固定 `target/release/moli` SHA-256 为 + `657278b75a92cc37df38b1171f7cc97c8fc4f46f3807fc72a7191c29a4962402`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认全组 + `252/252`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 + `ok=true`、失败列表为空; +- 随后按约定执行 `git pull -r origin master`,109 个分支提交重放到 `f22f23462b89`。上游 structured resource-runtime + shutdown 要求先停止 renderer producers、再释放 Page/engine lease,最后关闭 fetch admission 并 join network owner。初版 rebase + integration 错把“取走 navigation owner”放在 `CdpConnection::drop`,workspace run + `10c50dcd-c853-452c-9318-37419dfe3f56` 的 exact Host-lifetime 回归证明 frontend drop 会提前终止 Host。最终边界改为: + retiring adapter 释放 physical frontend Context projection,并把 renderer runtime roots 转交 `BrowserHostState`;它不再停止 + Browser-owned Page 的 renderer producers,最后一个 Host residence drop 时才终止 producers、释放 authoritative navigation + owner/engines 并 join roots。WebSocket owner-registry shutdown `2/2`、frontend drop + 后 registry 可读回归均通过;`moli-protocol/test-support` 同步传递 Core test support,避免依赖构建绕过同一装配路径; +- rebase 后全量 run `028ea487-3049-4046-a6a3-2990f9f56d1e` 为 `16317/16318`,唯一失败不是 Browser/CDP 路径: + `StreamingRawResponse` 析构时 completion receiver 可先唤醒 reaper,而 exact runtime lifetime lease 尚未按字段顺序释放。修复由 response + 自己在 `Drop` 中显式先释放 lease,并新增不依赖调度的析构顺序回归;新回归 `20/20`(run + `3697830d-686e-408a-bdf7-ff903ed2f77c`)、原失败用例 `100/100`(run + `75a492c2-4155-4031-b3fb-0dc5ef62261c`)。`f22f23462b89` 组合树 workspace nextest `16319/16319`、既有 skip `17`(run + `34220ede-79d3-48a9-b0b3-3100a84bcca2`),fmt、workspace all-target clippy `-D warnings` 与 release build 通过;该树 binary + SHA-256 为 `09559e50e21e63003a596cfe7079672684acbf429e09b93474aa4c92740f4e03`; +- 首轮 release CDP 全组暴露 `dom-shadow-outer-html` 偶发在主 Document DCL 后读取不到已提交 child Document;带相邻 DOM + 组复跑为 `1/10` 失败。`~/chromium/src/out/Default/chrome` 147 的 gated-iframe probe 证明:child request 已发出时主 DCL 可以已经发生, + iframe 此时仍投影 `about:blank`,目标 child node 尚不存在。因此该 outerHTML 用例改为等待它实际需要的 `load`,不是放宽 Browser + lifecycle;相邻组修正后 `20/20`。清代理后的 CDP 默认全组最终 `252/252`、WebDriver 四组 `157/157`,均为 `ok=true`、失败列表为空; +- 同一 release、无代理的知乎 answer live 复核仍显示书面边界:`done` 约 0.18 秒返回 720-byte `zh-zse-ck` challenge Document, + `domstable` 约 10.2 秒后得到约 393 KiB successor DOM,同时记录 challenge fetch 的 CORS preflight rejection。该 navigation 由未来 + timer/network work 产生,不属于 exact DCL/load turn 的 `FollowBeforeReply`;它仍是高层 wait policy 与站点 WebAPI 兼容问题,不能据此 + 把 403 DCL 判假,也不能把 Phase 6 Host residence 改造成隐式 sleep/drain; +- 最终同步时 `origin/master` 再前进到 `d74a05535409`,新增 failed-navigation error Document 的 CDP 合同,以及 runtime-created + script 的 Document referer/initiator 修复;110 个分支提交无冲突重放。首次交叉验证 `4/5`,唯一失败是新 Protocol fixture 仍直接写 + physical `browser_context`,绕过本分支 authoritative Core registry,因而导航没有进入 owner;改用同文件既有 + `insert_browser_context` 装配后,该 runtime-script 用例连续 `20/20`(run + `1425e321-150f-49c0-a7e2-1a20e8ed985d`)。最终 workspace nextest `16320/16320`、既有 skip `17`(run + `74c6cf4f-d753-4bce-b5b0-bfa72adb9d35`),fmt、workspace all-target clippy `-D warnings` 和 release build 通过;binary + SHA-256 为 `aceb60eb0a61bd96ee16ba39cee7d8cfe5d8fce196bc7db8b618b557b6a91a44`。无代理的新增 error-Document 组 + `8/8`、扩展后 CDP 默认全组 `258/258`、WebDriver 四组 `157/157` 均为 `ok=true`、失败列表为空。这继续确认 error + Document 的 DCL/load 是事实;是否跟随未来 replacement 仍由上层 completion policy 决定; +- 验收完成后的最后一次 `git pull -r origin master` 又把 110 个分支提交无冲突重放到 `7b6328faac0a`;该 master 增量只刷新 + 4 份 WPT cross-engine passed/failed/timeout/crash 基线列表,不修改 Rust、协议、smoke 或构建配置。因可执行 tree 与上述 + `d74a05535409` 验收相同,没有为纯基线数据重复 workspace/release/外部 smoke;fmt 与 diff check 仍通过; +- 这是一项物理 residence 提取,不是 Phase 6 exit。`CdpConnection` 当前仍持有 strong access capability,且 + physical `BrowserContext`/Target/Page payload、Protocol session projection 和 `BrowserHostTurnExecutor` 仍混在 + Protocol;physical Page 自身仍可能跨 renderer wait 借给 compatibility wrapper,但 Browser Host registry/engine + residence 已不随该 wait 被借用。进入 physical payload 拆分前只允许把仍驻留 Connection 的整组 browser-global policy + 一次迁出;随后必须整体拆分 physical Browser runtime payload 与 frontend projection,并把 adapter capability 收紧为 + 不会决定 Host lifetime 的 handle,而不是继续为每个调用点增加 forwarding wrapper。 + +2026-08-06 browser-global behavior policy 模块: + +- Core 新增 `BrowserHostPolicyState`,由 `BrowserHostState` 与 navigation owner/engine 共居。基础 browser identity、HTTP + proxy/no-proxy、TLS host verification、全局 UA/headers/cache/network/geolocation、window bounds 和 permission overrides + 现在只有这一份 application-owned 真值;这些类型不携带 session id、command id、subscription 或 CDP event; +- `CdpConnection` 删除对应 11 个 mutable 字段。Host-attached adapter 构造器也删除第二份 `FetchConfig` 参数:配置只在 + `BrowserHostState::new` 时从 authoritative `NavigationEngine` 初始化,frontend 不能再用自己的配置建立 shadow global + state。两个 adapter 共享同一 Host 时会立即读到同一 policy,销毁其中一个 adapter 不会 reset policy; +- policy mutation 使用 closed、move-owned `BrowserHostPolicyUpdate`,在同步短临界区应用;不向 adapter 暴露任意 + re-entrant mutation closure,typed update 后续可原样进入 Host mailbox。renderer/network wait 只携带 move-owned policy + snapshot,不跨 `await` 持有 `RefCell` guard。高频 resource-runtime/navigation 路径使用不包含 permission/window 的 + `BrowserHostNetworkPolicySnapshot`,避免每次导航克隆可增长的 permission descriptor 列表; +- Protocol 仍负责把 global policy 应用到现有 physical Page/worker,因为对应 payload 尚未移出 Protocol; + `BrowserContext.global_*` 明确降级为 applied projection,新 Context 从 Host snapshot 初始化,frontend teardown 不会把 + projection 写回 Host。全局 command 更新 Host truth 后再启动既有 exact Page participant,因此没有新增 drain、pump、sleep + 或第二条 execution authority; +- Core policy/default/shared-residence 与 Protocol permission/window/UA/geolocation/replacement 回归 `38/38` 通过(run + `02225502-9532-483d-bfe2-f5eeeaefa25e`);headers/cache/proxy/TLS/network condition、background promotion 和 + existing/future Page application 回归 `26/26` 通过(run `29ce5265-33f9-4172-a18b-3f63c5732796`); +- workspace nextest `16323/16323`、既有 skip `17`(run `b2eb0e75-429b-4e03-aa21-948f5f7f389c`), + `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build + 均通过;固定 `target/release/moli` SHA-256 为 + `148c31c7d79ae29175d45e0fb3063670683dcdb37991aac33686cbd802bd467b`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 和继承的 smoke group、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP + 默认全组 `258/258`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 + `ok=true`、失败列表为空; +- 随后按约定执行 `git pull -r origin master`,111 个分支提交无冲突重放到 `590ee6c64e`。上游增量只修改 + `moli-http-cache`,删除 disposable HTTP cache response path 的 `fsync`,与 policy residence 没有代码重叠; + rebase 后 cache crate 全部用例与三条 Host policy 共享回归合计 `47/47` 通过(run + `8e2cf1f4-57a4-4d44-b57b-31a7f582bb45`),workspace release build 通过,最终 binary SHA-256 为 + `a84c09f04279d3e984e0f70c2704860fcf9f8743c85d1382e7c1c0fe5d32cfd8`。同样清代理并固定最终 binary 后, + CDP 默认全组 `258/258`、WebDriver 四组 `157/157` 再次为 `ok=true`、失败列表为空;未在无冲突、无代码交叉的 + stable module patch 上重复 workspace 全量与 clippy,前一条记录是本模块 exact patch 的完整门禁; +- 该模块完成 Phase 6 inventory 中第 3、5 项的 browser-global policy 部分,但不是 physical runtime extraction,也不是 + Phase 6 exit。下一模块必须以 `BrowserContext/Target/Page runtime payload + Host executor` 为闭环拆分 frontend projection, + 不能继续逐个 global field 或 wrapper 迁移。现有多个 frontend 对已经各自复制的 physical Context 不做跨 adapter + 反向同步;该限制只能通过移出单份 physical payload 解决,不能在 projection 上增加 watcher。 + +2026-08-06 physical renderer Page lifetime owner 模块: + +- renderer 的单体 `RendererPageHandle` 拆成两种明确能力:唯一的 lifetime owner 负责 cancel/remove 与最终析构,cloneable + `RendererPageClientHandle` 只提交命令、读取稳定 identity 和访问 DevTools/dialog bridge,drop client 不会移除 renderer Page。 + `Page` 对新建/standalone 调用仍暂存唯一 owner;一旦 Browser Page residence commit,它只保留 command/cache projection,因而 + frontend Page wrapper 的 `close`/drop 不再拥有 Browser Page 的生死权; +- Core `BrowserPageResidenceRegistry` 现在把 renderer Page lifetime owner 与 exact + `{BrowserContext, Target, Page-slot instance, generation}` 放在同一 record。initial Document materialization 和 loaded replacement + 只有在 request/Target/generation 全部复验成功后才从 move-owned candidate 取走 owner;stale/reject 之前不会 `take`,候选仍可由 + caller 确定性关闭。replacement、failed-navigation discard、crash/close termination 则随 typed commit result 返回 predecessor + owner,由 commit 之后的独立 cleanup participant 等待 remove acknowledgement;不携带 renderer owner 的 authority helper 只在 + `cfg(test)` / `test-support` 构建存在,production API 不能绕开 transfer invariant; +- Protocol 的 active/background Page slot 仍保存 `Page` command/cache projection 和 attachment/lifecycle/session state,但 projection + 不再保存已 commit renderer Page 的唯一 owner。replacement/termination cleanup 同时兼容迁移期 test fixture 中尚未被 Core + 采用的 Page owner,却以 Core 返回的 retired owner 为 production authority;没有在 Core commit 与 physical projection 之间加入 + await、callback、drain、pump、sleep、retry 或 watcher; +- owner-transfer audit 又覆盖了 legacy forget/removal 路径:Core registry 的 Target forget、staged rollback 与 BrowserContext removal + 都按 exact `BrowserPageOwnerKey` move-return renderer Page owner,调用方必须显式完成 cleanup。尤其是已经 materialize initial + Document、随后失败的 popup rollback,不再依赖 frontend `Page::close` 的旧副作用;BrowserContext terminal removal 也会返回 typed + termination 之后残留的 owner,作为不静默泄漏的安全边界。同 targetId 的另一 Context/实例不能授权取走 owner; +- bootstrap placeholder replacement 只允许尚未 commit renderer Page 的占位 Target;若目标已有 Core-owned Page,返回 + `TargetHasCommittedRendererPage`,要求调用方先走 typed termination,而不是覆盖 registry record 并静默 drop 唯一 owner; +- `CdpConnection::drop` 现在先从 physical Context 取出 renderer runtime roots,再只销毁 frontend Context/Target/Page projection, + 不再调用 producer termination;roots 转交 `BrowserHostState`。最后一个 Host residence 才按“终止 producers -> drop + navigation owner/renderer Page owners -> shutdown/join network roots”的既有顺序收尾; +- 新的真实 renderer 回归会 build 并安装 initial Page,确认 Core registry 持有 exact renderer page id,drop 整个 + `CdpConnection` 后仍可从 application-owned Host capture/commit Target termination,并等待同一个 renderer Page owner 成功关闭。 + 这比只检查 BrowserContext/Target registry 存活更强:它同时证明 frontend teardown 没有提前 drop Page owner 或关闭 producer + admission。Page-residence 邻域 `16/16`(run `a2b84a19-ddc5-48db-a848-fd2f0f8eccfe`),replacement/termination/lifetime + 邻域 `40/40`(run `76864d7b-464a-4c6d-95c5-39296dcce1c8`),新增真实 teardown 回归 `1/1`(run + `1d87edbc-104f-4bfe-9adb-8a88bc6a0c46`)通过;最终扩展邻域 `57/57`(run + `7a3a4bbb-8748-4ef2-b43b-7dfc74b29134`)通过。加入真实 initial-Document popup rollback 后,最终 owner 边界矩阵 + `22/22`(run `4eee66fa-0036-4f94-af74-2c0e661bedef`);四条 replacement/teardown/popup async 路径用 native + nextest `--stress-count 20 --flaky-result fail` 做了 20 轮、共 80 次有界复跑,全部通过(run + `e008f539-722c-4af9-ad62-2435683141b4`),没有用 retry、sleep 或放宽断言隐藏失败; +- 首次最终 workspace run 为 `16323/16324`:唯一失败是 renderer BroadcastChannel 的真实 PageVm replacement identity-collision + 回归。该用例不改代码独立复跑 `1/1`,随后 `--stress-count 50` 为 `50/50`(run + `2331c3c8-bac5-4128-992e-70f7f6c62caf`);再次以只报告失败的全 workspace run 验证为 `16324/16324`、既有 + skip `17`(run `cc3f7e04-8933-4ff0-8110-4f8007d609bc`)。因此保留为一次未复现的并发瞬态证据,不用重跑结果 + 反向证明其根因已经修复; +- `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build + 均通过。固定 `target/release/moli` SHA-256 为 + `8e566274d16a23bda31916034ec745d9f226e5d36423e66d1dcee9764eaecefb`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 和继承的 smoke group、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 + `258/258`、`ok=true`;WebDriver Classic/BiDi/Selenium/Semantics + `--continue-on-failure` 为 `157/157`、`ok=true`,两套失败列表均为空; +- 提交后按约定执行 `git pull -r origin master`,112 个分支提交无冲突重放到 `25326b1c12`。master 增量只新增 + Fetch/runtime callback teardown 的 CDP smoke 与 fixture(4 个 Python/README 文件),不修改 Rust、构建配置或本模块代码;排除 + 本条事后证据的 module patch stable patch-id 前后均为 `6cf756750e2f16259f8205448278a2c4fcd83df4`。rebase 后 + workspace release build 通过,binary SHA-256 仍为 + `8e566274d16a23bda31916034ec745d9f226e5d36423e66d1dcee9764eaecefb`;新增 Fetch teardown 组 `3/3`、扩展后的 + CDP 默认全组 `259/259`、WebDriver 四组 `157/157` 均为 `ok=true`、失败列表为空。没有在无 Rust 交叉且 stable module + patch 不变的组合树上重复 workspace 全量与 clippy;rebase 前同一 patch 已通过两项门禁,rebase 后以新增 exact smoke、release + 重建和两套完整外部 smoke 覆盖集成; +- 这完成的是 physical Page 的 lifetime authority,不是完整 Page payload 或 Host executor 提取。`CdpConnection` 仍持有 physical + `BrowserContext`/Target、`TargetPageSlot` 中的 Page command/cache projection、attachment/session state 和 production + `BrowserHostTurnExecutor`;per-context renderer/network root 也仍在 Context 创建时驻留 Protocol,只在 adapter teardown 时转交 + Host。下一模块必须整体提取 BrowserContext/Target runtime payload 与 Host executor,并让 CDP 只持 handle/projection,不能重新 + 退回逐字段 forwarding wrapper。 + +2026-08-06 BrowserContext renderer/network runtime root residence 模块: + +- inventory 先确认现有 `BrowserContext` 同时包含 Page/worker runtime payload、storage/network applied state 与 CDP + session/subscription/cache;因此本模块没有把整个混合类型塞进 `Rc>` 冒充拆分,而只提取其中唯一、可独立定义生命周期的 + `RendererBrowserContextRuntimeOwner`; +- `BrowserHostState` 新增按 exact `BrowserContextHandle` 索引的 runtime-root registry。Context topology 注册与 root 注册是同一个同步 + Host transaction;Context removal 先暂存 exact root,再提交 Core topology,typed rejection 会把 root 放回原实例,成功才 move-return + 给 terminal cleanup。同一个公开 context id 的旧、新实例不能互相取走 root; +- 未注册的 physical `BrowserContext` 只暂存 candidate owner;注册成功后只留下 cloneable、non-owning runtime access。 + `CdpConnection::drop` 的“临终收集并转交 roots”整段删除:frontend teardown 不再参与 Browser runtime ownership,最后一个 Host + residence 仍按“终止 producers -> drop navigation/Page owners -> shutdown/join network roots”收尾; +- `Target.disposeBrowserContext` 的既有 owner task 新增正常的 Context runtime cleanup participant。terminal owner turn 立即关闭 producer + admission,随后在不借用 `CdpConnection` 的 participant 中关闭残留 exact Page owners/projections,最后 join network root;这不是 + frontend waiter、drain 或 pump,丢弃 command reply 也不能取消已接受的 cleanup; +- Core exact-root 回归覆盖 topology rejection rollback、成功 move-return 与 same-id ABA 重建;Protocol 回归覆盖 committed projection + 不再携带 owner、drop frontend 后 Host root 仍可访问,以及丢弃 disposal reply 后 network root 仍被 terminal participant 关闭。开发期 + 聚焦 nextest 为 Core `1/1`(run `19580cf4-af8d-4bf0-b812-9ae9b9655a4e`)、Context disposal/Host lifetime `23/23` + (run `63cdc1ea-40fb-490f-8d36-ee85de74a460`),加强后的 detached cleanup exact 回归 `1/1` + (run `abf2afcb-8039-4080-a5d4-4706708b04bb`),Core state 与既有 Context projection rollback 矩阵 `11/11` + (run `f20741c6-b36d-4bf7-a9d5-562d9323ea1f`); +- 最终 workspace nextest `16325/16325`、既有 skip `17`(run `c38f6747-df7f-45f8-af81-c0562458cc54`), + `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过。 + 固定 `target/release/moli` SHA-256 为 + `5449a8dfa744417b1f8b7fec4064d5d16d54e1895c027ca2e8fa5072beeadfcf`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与继承 smoke group、设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 `259/259`、 + WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 这完成了 physical BrowserContext runtime root 的 lifetime authority,但不是 Phase 6 exit。`CdpConnection` 仍保存 physical + BrowserContext/Target、Page command/cache、session/attachment projection 与 production `BrowserHostTurnExecutor`。下一模块按 + `Context/Target/Page runtime payload + Host executor` 的完整闭环继续,不再以单字段或单 wrapper 作为架构里程碑。 + +2026-08-06 physical Page command/cache payload residence 模块: + +- Core `BrowserPageResidenceRegistry` 的 exact Target/Page-generation record 现在同时持有唯一 renderer lifetime owner 与唯一 + mutable `Page` command/cache payload owner。initial Document materialization 和 loaded replacement 在同一个 Browser owner + transaction 中校验两者指向同一 renderer Page,再一起发布 successor;failed-navigation discard、Target termination 和 + Target forget 会在 owner turn 内撤销 payload liveness,不再等待 Protocol slot drop; +- Protocol `TargetPageSlot` 删除 `Option`,只保存 cloneable、non-owning `BrowserPageRuntimeAccess`。一次 Page 操作先 + checkout move-owned `BrowserPageRuntimeLease`:短 `RefCell` borrow 在返回前已经结束,renderer/V8 等待期间不借用 + `BrowserHostState`。lease drop 只有在同一个 Core owner 仍 live 时才归还 payload;replacement/termination 已发生时直接丢弃旧 + Page,不能把 late completion 的旧 cache 放回 successor generation; +- runtime access 单独保存 immutable physical Page id、renderer Page id 与 renderer owner id。renderer output route 和 Core owner + 配对验证不需要 checkout mutable payload,因此某条 frontend 命令持有 lease 时不会让 Browser navigation/replacement 丢失 exact + identity;`has_loaded_page` 也表示 Core liveness,而不是 cell 此刻是否被一条命令临时 checkout; +- replacement rejection 会把 renderer lifetime owner 恢复到尚未 commit 的 Page candidate,再确定性关闭;commit 后 frontend + projection 只安装 Core 返回的 access。renderer attachment projection 恢复既有幂等语义:prepare 已提交 attachment 时只绑定其 + id,channel closed/missing 时记录诊断并返回,不因重复 attach 或 production `expect` panic; +- exact 回归让 predecessor Page lease 跨过第二次 replacement commit,证明旧 access 在 commit turn 立即失效、late lease drop + 不能复活旧 Page、successor access 仍解析到新 payload;增强回归还明确证明 live access 在 payload 被 checkout 时保持 live, + 同期第二次 checkout 只表示 occupancy,不表示 Document 消失(run `d682b087-0f74-46d6-9ddd-51babcf375f3`)。相邻 stale + replacement、background owner、initial build、termination 与 active/background swap `5/5`(run + `3688ebd1-e459-4b0f-b8a4-79b56e44bcc0`),Core replacement/transition/termination 邻域 `24/24`(run + `e480bf90-9e15-4474-911b-fac78d3a7eb7`)通过;Core/Protocol all-target check 与 all-target clippy `-D warnings` + 通过; +- 首次 workspace 全量把一个不能模糊处理的 lease 约束暴露成 `55` 条失败:旧的两阶段 Page API 在 + `start -> await -> finish` 之间保留第一次 lease,或测试把只读 lease 跨到下一条命令时,第二次 checkout 会暂时失败;这不是 + `NoDocumentLoaded`,因为 Core Page residence 仍 live。共享 preload、child-frame DOM、frame-tree、resource search、runtime + normalization 与 fetch cleanup 入口现在只在同步 start/finish turn 内持有 lease,测试读访问也缩到实际断言作用域;需要 renderer + owner cleanup 的 termination 回归改走真实 Core replacement,不再用裸 slot fixture 假装 production ownership。这个约束写入 + access API:`is_live` 判断 residence,checkout 失败还可能表示同一 payload 正被另一 lease 占用;同一两阶段操作再次 checkout 前 + 必须先释放,不能把 occupancy 投影成 Document absence,也没有增加 retry、sleep、drain 或等待轮询; +- 修正后 Protocol 全量 `3469/3469`(run `804efa76-9c9f-4fae-9a8f-bf5f93e8f06f`),WebSocket + CDP/BiDi/Classic 集成层 `350/350`(run `8425e5b7-e42b-46c3-8836-4e7e26fce71a`),最终 workspace + `16325/16325`、既有 skip `17`(run `81bfc369-0fd8-427a-8954-4c1fe5574eb6`)。`cargo fmt --all --check`、 + `git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过;release binary SHA-256 为 + `5889fa1758aa748959fb0936629f2f5e53c158ee4f705bdf288c9ebee32a33ea`。显式清除大小写 HTTP/HTTPS/ALL/FTP proxy、 + 继承的 smoke group 和固定端口环境后,CDP 默认全组 `259/259`、WebDriver Classic/BiDi/Selenium/Semantics + `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 这是完整 Page payload 的物理 residence cutover,但不是 Phase 6 exit。`CdpConnection` 仍持有 access capability、physical + BrowserContext/Target、attachment/session/fact projection,production `BrowserHostTurnExecutor` 仍以整个 connection 为执行体。 + 下一模块应把 Host runtime executor 与 Context/Target runtime payload 组成独立 owner-facing API,让 CDP 只提交命令并消费事实; + 不再按 Page command 调用点继续加 forwarding wrapper。 + +2026-08-06 Browser Host executor authority 与 application module 模块: + +- inventory 先验证了一个错误方向:现有 `BrowserContext` 混合 physical Target/worker runtime、storage/network applied state 与 + CDP session/subscription projection;若把整个类型机械塞进共享 `RefCell`,`browser_context_by_id()` 等裸引用 API 会迫使数十个 + domain 理解 residence guard,且跨 projection 调用立即产生大面积临时借用与重入冲突。这满足本文“同一修复扩散到无关调用点” + 的停止条件,因此该未提交实验被完整撤回;后续必须先在 `BrowserContext` 内建立 physical payload/frontend projection 的类型边界, + 不能用一个共享容器冒充模块拆分; +- Protocol 新增独立 `browser_host_executor_residence` 模块,只定义 non-cloneable、current-thread + `BrowserHostTurnExecutorOwner` 与 crate-private short-turn adapter。production `CdpConnection` 不再实现 + `BrowserHostTurnExecutor`,也不再公开 participant completion executor;只有持有 owner 的 application composition 才能让 + Core actor 启动 selected turn 或应用 move-owned completion。Protocol 单元测试保留 `cfg(test)` direct-actor compatibility impl, + 不进入 production library; +- application 的纯 `BrowserHostOwnerLane` 物理驻留独立 `browser_host/execution_lane.rs`,同时持有 + `BrowserHostActor`、唯一 executor owner 和 owner participant completion receiver;该模块不再引用 detached DevTools command + completion。`cdp_scheduler/owner_inputs.rs` 只保留 frontend input mux,把 CDP navigation timeout 后的 reply future 与 Host wake + 作为两种独立来源合并给现有 adapter loop。start turn 使用一次短 adapter,renderer/network wait 被 move 到 + `PendingBrowserHostTurn` 后 adapter 立即释放;completion 到达时再绑定新 adapter,因此没有 owner/connection borrow 跨 wait; +- executor start trace 增加 application-owner turn sequence,便于区分 Core mailbox selection、短 physical projection turn 与后续 + participant completion;没有增加 queue、drain、pump、sleep、retry 或 frontend flush 依赖,也不改变 CDP/BiDi wire shape; +- Host mailbox/owner lifetime、stopped Host、frontend mux gate 与 executor residence 邻域 `21/21` 通过(run + `29ab4958-321f-4dc8-90c2-0b4725376c29`);slow frontend、wait:none、separate participant completion 与 + temporary Context selection 的跨等待不变量 `4/4` 通过(run `47861c78-1baf-4ef7-ba4d-06daa3823b6b`); +- workspace nextest `16326/16326`、既有 skip `17`(首次记录 run + `77878ca4-1ab6-4291-b8df-c7f93bb0cefb`,constructor 收口后的精确工作树复跑同样通过), + `cargo fmt --all --check`、`git diff --check` 与 workspace all-target clippy `-D warnings` 通过;本模块不改变 + wire shape 或浏览器可观察行为,因此不机械重复 release 与外部 CDP/WebDriver smoke; +- 这完成的是 production execution authority 和 application module residence,不是 Phase 6 exit。executor 内部为完成迁移期 + Page/Target/session projection 仍短暂借用 `CdpConnection`,且 physical `BrowserContext`/Target container 仍驻留 Protocol。 + 下一模块应先把该混合 container 拆成 Host-owned physical Context/Target payload 与 frontend-owned projection,再把前者交给这里已经 + 独立的 executor owner;不得把 `RefCell` guard 或 forwarding wrapper 扩散到所有 domain。 + +2026-08-06 Browser download owner 模块: + +- 模块开始前先验证并撤回了一条错误路径:把 SharedWorker/DedicatedWorker/ServiceWorker 的四组 map 从 + `BrowserContext` 搬到同一个 `CdpConnection` sidecar,会迫使 `71` 个调用点理解 `RefCell` guard,产生近 `900` 行迁移 + plumbing;这些 map 本来就是 renderer worker fact 的 CDP frontend projection,搬家既不改变 Browser Host lifetime,也不让 + 多 frontend 共享真值。该实验未提交、已完整撤回。后续 inventory 必须先区分“仍在 Protocol 的 Browser owner”与“本来就应留在 + frontend 的 projection”,不能用字段 residence 代替 authority 证据; +- Core 新增 protocol-neutral `BrowserDownloadBehavior`、`BrowserDownloadPolicyState` 和 + `BrowserDownloadRegistry`。behavior/path 与 per-BrowserContext override 现在和 navigation/policy 一样由 application-owned + `BrowserHostState` 保存;active cancel handle、terminal state 和 artifact path 则由 Host 持有的 thread-safe registry 保存,后台 + download participant 只克隆同一个 registry handle; +- `CdpConnection` 删除 `BrowserDownloadBehavior` 的 browser policy 部分和 connection-local `SharedDownloadRegistry`。两个 + frontend 连接到同一个 Host 时,后写入的 download policy 立即成为共同真值;第一个 frontend drop 后,第二个 frontend 仍可取消 + active download、读取 completed artifact。下载网络、文件写入、rename 和 terminal registry commit 不依赖 frontend response flush, + 没有增加 drain、pump、sleep 或 retry; +- CDP `Browser.setDownloadBehavior.eventsEnabled` 的 session generation、typed automation event enablement 和 WebDriver BiDi + subscription 明确保留在 connection-local `BrowserDownloadEventSubscriptions`。raw CDP session detach 只清自身 observer;同 Host 的 + 第二个 frontend 不继承第一个 frontend 的 wire/automation subscription。Browser policy 与 frontend projection 因而不再混在一个 + `BrowserDownloadBehavior` struct; +- download、response-flush、context disposal、CDP/Page alias、event route generation 与跨 frontend owner 聚焦 nextest + `58/58` 通过(run `651afce4-1186-411f-9c08-4cca829b7ef5`),相邻 ordered typed-event drain 与 context disposal + `2/2` 通过(run `6283652a-1095-49a8-8e3b-868b54713317`);最终 workspace 全量 `16329/16329`、既有 + skip `17`,`cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace + release build 均通过。固定 release binary SHA-256 为 + `9c3de12998bae928fc85bc290d060e3023a430abea028021aff4699a352dc826`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、继承的 smoke group 和固定端口环境后,CDP 默认全组 `259/259`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 这完成了 Phase 6 inventory 第 4 项中的 download policy/registry owner,不是 Phase 6 exit。`global_io_streams` 是返回给请求 + frontend 的 IO handle projection,不因名字含 global 就提升为 Browser owner。下一步应做严格 owner inventory/exit audit:若 + `BrowserContext`/Target 中剩余值只是 session、attachment、subscription、applied snapshot 或 non-owning runtime access,应留在 + frontend 并重命名/分型;只有能改变无 frontend 时 browser trace 或决定 lifetime 的 payload 才迁入 Host。 + +2026-08-06 Browser Host identity namespace 模块: + +- 严格 owner inventory 先排除了继续搬 worker/session/subscription map:这些值只改变 frontend attach/event projection,不决定无 + frontend 时的 browser trace。真正仍由每个 `CdpConnection` 各自从 `1` 开始的 owner cluster 是 BrowserContext sequence、Target + sequence 和 Browser Owner command correlation;多个 adapter 一旦共享 Host,这三组 local counter 会产生相同 public/correlation id; +- Core 新增 `BrowserHostIdentityState`。BrowserContext 与 Browser command sequence 由 exact `BrowserHostState` 单调分配; + `CdpConnection` 删除 `next_bc_id`、`next_target_id`、`shared_target_id_allocator` 与 + `next_browser_owner_command_id`。CDP `BID-*`、BiDi `user-context-*` 共用一个 Host context sequence,所有进入 Browser mailbox 的 + command 也共用一个 Host correlation namespace;session id、Page subscription generation 和 internal Runtime command id 仍明确留在 + frontend; +- Target id 还必须满足进程级 DevTools discovery 唯一性,因此 application 创建 cloneable `BrowserTargetIdAllocator`,注入每个 + participating Host;Host 再负责实际 allocation。原来从 application 反向安装到 connection 的 allocator setter 已删除。该 atomic + 只传递 uniqueness,使用 relaxed compare-exchange;Target payload 的发布/可见性仍由 Host mailbox 与 commit 边界同步,不能把 + identity atomic 当成 state publication; +- 两个 adapter 共享同一 Host 的 exact 回归证明 Context、Target 与 Browser command 分别得到 `1/2`,两个 Host 共享 application + Target allocator 的回归也得到不重复 sequence。Core/Protocol owner 回归 `4/4`(run + `4b2a5e4e-b79b-4a67-8812-8bddbeff34ca`),真实 Browser command、Context、page/worker Target 创建路径 `8/8`(run + `0b173060-6875-409b-a021-3730374b946c`),HTTP/WebSocket live AgentHost route/teardown `4/4`(run + `74a5222c-6323-409e-af46-f9e5bba6bc9f`)通过;最终 workspace 全量 `16333/16333`、既有 skip `17`, + `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build + 均通过。固定 release binary SHA-256 为 + `fa98735b39dce491edfbc3862aa6d065609ebf10ce619ce54307e4534023e8aa`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、继承的 smoke group 和固定端口环境后,CDP 默认全组 `259/259`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 这关闭 inventory 中 BrowserContext/Target identity owner,不是 physical Context/Target payload,也不是 Phase 6 exit。 + `ConnectionNetworkRequestIdAllocator` 暂不机械并入本模块:当前 request/body artifact 同时含 Browser producer data、CDP session + visibility、collector scope 和 IO cursor,必须在下一次完整 request/body owner 模块中先分型,不能只移动 counter 制造半套真值。 + +2026-08-06 Browser network request/body owner 模块: + +- owner inventory 把原 `TargetNetworkArtifacts` 拆成两类状态。request id namespace、原始 request/response body、pending/failed/ + evicted response 状态会在 frontend 不存在时继续决定浏览器事实,归 Browser Host;session visibility、collector membership、 + subresource/WebSocket handle mapping、event cursor 与 IO read offset 只决定某个 frontend 如何观察,留在 Protocol; +- Core 新增 `BrowserNetworkArtifactStore` 和 protocol-neutral body/spool 类型,并由 application-owned `BrowserHostState` 保存唯一 + store。`CdpConnection` 删除 connection-local allocator;所有 document、subresource 与 WebSocket request id 都从 exact Host 的 + 短生命周期 handle 分配。Protocol 中仅供测试的 target-local `REQ-*` counter 也已删除,避免结构继续暗示第二个 namespace; +- response body 保持既有 inspector budget:总计 `20,000,000` bytes、单项 `2,000,000` bytes,并保留明确 `Evicted` 状态;过去 + unbounded 的 request body 现在使用 Host lifetime budget:总计 `200,000,000` bytes、单项 `64 MiB`。body 可在 memory、secure + tempfile spool 或 renderer subresource source 中驻留,不进入 fact journal,也不按 frontend 复制; +- 未注册 `BrowserContext` 可暂存 candidate artifact store。Context/Target projection commit 时,exact Host adoption 只复制该 + projection 已知的 request ids,同时把 candidate sequence 单调合并进 Host,防止注册后重用 `REQ-n`。active、background、parked + Target 以及 restore projection 都走同一个 adoption 边界; +- `Network.disable`、session detach 或 frontend drop 现在只删除自身 visibility/collector/IO projection,不删除 Host raw body。 + 另一个 frontend 只有在自己的 event/fact projection 建立 visibility 后才能读取同一 raw artifact;共享 Host 本身不会绕过 CDP + session/target 授权。没有新增 watcher、drain、pump、sleep、retry 或 Network observer backpressure; +- Core network body/artifact 聚焦回归 `13/13`(run `b3b69c68-9fd8-4d68-9346-eefbeefaa8c3`),Protocol + Network/Fetch/Target parking 与跨 frontend 聚焦矩阵 `347/347`(run + `2a55c422-a481-4b93-9611-3952568fa9a9`)通过。第一次 workspace 全量为 `16335/16337`,稳定暴露 + `ParkedNetworkArtifacts` 的 derived equality 错把两个空 projection 中不同 Host capability residence 判成 frontend state + 不同;exact 两用例复跑 `0/2` 后,改为只比较 frontend-local artifacts/counters,并在 restore/drain 边界显式 adopt Host + handle。修复后 exact 两用例连续 20 轮、共 `40/40`(run `f7f17139-53e2-4e7f-bf13-02752dfe6d93`),最终 workspace + 全量 `16337/16337`、既有 skip `17`(run `0ca9ef8f-a4dc-46ee-acbe-0d04b0d7a3be`); +- `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过。 + 固定 release binary SHA-256 为 `1dfb9b1d5c28717544f61f5a281caea4f36357778c6b11198b2e905d557170bb`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与继承的 smoke group 后,CDP 默认全组 `259/259`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 这完成 Phase 6 inventory 第 3 项的 request/body browser portion,不是 Phase 6 exit。Protocol 仍保存 physical + BrowserContext/Target container 和 frontend-local projection;下一模块必须继续按完整 physical payload/handle 边界收口,不能把 + raw body 再塞进 fact journal,也不能为了复用 body store 让普通 Network listener 获得 request permit。 + +2026-08-06 Browser Target `sessionStorage` namespace owner 模块: + +- owner inventory 把 top-level Target 的 `sessionStorage` namespace 判为 Browser state:它跨 Document navigation、active/background + residence 与 frontend detach 保持,但不同 top-level Target 必须隔离;带 opener 的 popup 在 renderer 接受 `window.open` 时冻结一份 + deep-cloned seed,后续 mutation 不与 opener 共享。CDP session、DOMStorage subscription 和 event fan-out 仍是 frontend projection; +- Core 新增独立 `target_session_storage` 模块。authoritative `BrowserTargetRecord` 现在把 exact `BrowserTargetHandle` 与 + `SharedWebStorageStore` association 放在同一 registry record;BrowserContext bootstrap 会在一个 registration transaction 中转交 + active 和所有 background Target 的 candidate namespace,普通 background/active registration、bootstrap replacement 和 renderer popup + 则通过 `BrowserTargetCreationMetadata` 原子安装 seed。Core commit 返回只读 `BrowserTargetSessionStorageAccess`,不把 mutable registry + 或 frontend route 暴露给 Protocol; +- Target registration rollback 不发布 live access;activation/demotion 只移动同一个 exact Target record;Target removal、Context disposal + 和 active replacement 与 handle retirement 同一 transaction 提交。复用相同 public targetId 会得到新的 Target instance 与新的 namespace, + predecessor access 立即不再 live,不能授权 successor。已经被 in-flight renderer work 捕获的 store clone 可以完成旧操作,但 access 本身 + 不绕过 exact Target route/liveness 授权; +- Protocol 的 `TargetSessionStorageNamespace` 降级为 registration 前的 candidate 或 Core access projection。Context/Target projector 在 + same-turn commit 后绑定 exact handle;Page storage handles 从 access 取得 store。createTarget、popup、active replacement、parking、promotion + 与 test fixture 都经过同一绑定边界;frontend teardown 不把 namespace 写回 Core,也没有新增 watcher、task、drain、pump、sleep 或 retry; +- Core session-storage owner/persistence 邻域 `6/6`(其中 exact registration/rollback/public-id reuse/Context removal 与 metadata + rejection `4/4`,run `1410ea18-e7e5-4a7f-9b34-5a0f0e878560`),包含 active/background Context bootstrap 的全 Target handoff 回归 `1/1` + (run `55164a08-5cf9-475a-aa83-8ebf39588a97`),跨 navigation/parking/popup/DOMStorage 与 same-id replacement 的 + `session_storage` 矩阵 `10/10`(run `8e6f5880-f6f6-42bb-98b7-514de43b7eeb`)通过; +- 最终 workspace 全量 `16343/16343`、既有 skip `17`(run `27222a88-8eb1-4990-9383-765eb7b4ab93`); + `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过。 + 固定 release binary SHA-256 为 `87e1b977cea39c27f06f0d9a8a7395e146413a914deb839c94ec37154fc2ec75`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与继承的 smoke group 后,CDP 默认全组 `259/259`、WebDriver + Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 这关闭了 mixed Target container 中唯一明确的 browsing-context storage owner,不是 Phase 6 exit。physical `BrowserContext`/Target + 仍保存 DevTools session/attachment、worker fact projection、applied runtime snapshot 与 non-owning Host access;下一模块应按完整 + Context/Target physical payload 与 frontend projection 边界拆分,不能继续把纯 frontend map 搬进 Host,也不能为每个 domain 增加 wrapper。 + +2026-08-06 top-level Target frontend attachment projection 模块: + +- owner inventory 明确区分两件事:Target identity/residence/lifetime 属于 Browser Core;CDP primary/auxiliary `sessionId`、detach 与 + session-to-Target route 只决定某个 frontend 如何控制和观察该 Target,属于 frontend projection。后者不能为了“共享 Host”被搬进 + Core,也不应继续寄生在 physical `BackgroundTarget` 或 active Page slot 上; +- Protocol 新增 `TopLevelTargetAttachmentProjectionRegistry`,以 exact `BrowserTargetHandle` 为 key,并维护唯一 session reverse route。 + `BrowserContext.session_id`、`auxiliary_target_sessions` 与 `BackgroundTarget.session_id` 已删除。active/background promotion、demotion + 只移动 physical runtime payload,不移动或重建 attachment;primary 与 auxiliary session 都继续绑定同一个 exact Target instance; +- public `targetId` 复用不能授权旧 attachment。primary/auxiliary route 在返回 public id 前都会复验 registry handle 与当前 physical + Target handle 完全相同;predecessor 的 late command、detach 或 held session 因而不能进入同名 successor。session id 重新绑定时, + registry 同步清除旧 primary/auxiliary projection,避免 reverse map 与 per-Target set 形成双重 truth; +- Target registration、attach/auto-attach、session route、parking/promotion、rollback、close/crash/Context disposal 都改为通过同一个 + attachment registry。Target terminal 先冻结 primary/auxiliary cleanup,再移除 exact registry record 与 physical payload;没有新增 + task、watcher、drain、pump、sleep、retry,也没有让 frontend attachment 参与 Browser progress; +- 旧 Protocol 单元测试曾把 session 直接传给 `BackgroundTarget` 构造器,等价于绕过新 owner boundary。测试构造 metadata 现在只在 + `cfg(test)` 下短暂存在,并在 Core exact Target registration 或 direct test harness command boundary 立即消费进同一 registry;生产 + runtime 不读取该 metadata。另有测试原先构造“存在 page session、但不存在 Target”的不可能状态,现已改为显式 Target fixture,或 + 按真实语义断言 `InvalidSessionId`; +- exact registry、same-public-id stale route、session-id reuse、active/background swap 与 Target attach 邻域聚焦 nextest `58/58` + 通过(run `f7d20653-cb5b-44df-a904-8de856388527`)。首次 workspace 全量确定性暴露一批 legacy test fixture 在创建 exact + Target 前挂 session,或直接插入带 session 的 background fixture 后未消费 projection metadata;这些路径被新 registry 正确拒绝,未通过 + compatibility fallback 放宽。修正夹具装配顺序后,代表性 Worker Fetch `1/1`(run + `d787ca12-05f8-46a5-bbed-c3529d48c614`)、剩余六条 direct fixture 路径 `6/6`(run + `e688c17f-14ad-4c69-9e4d-eccb5d0a3a43`)与完整 Protocol 包 `3469/3469`(run + `00f7ff3e-fed7-4339-a894-338b01587bd5`)通过; +- 最终 workspace 全量 `16347/16347`、既有 skip `17`(run `388a7470-87c5-4e93-9c3f-a84fa66ad566`)。 + `cargo fmt --all --check`、`git diff --check`、workspace all-target clippy `-D warnings` 与 workspace release build 均通过;固定 + `target/release/moli` SHA-256 为 `8e02a0a7a2d62b126fd0136510e0e8dd78a91fe54562c8cd407ff056a38a3589`。显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group,并设置 `NO_PROXY=*` / `no_proxy=*` 后,CDP 默认全组 + `259/259`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 `ok=true`、失败列表为空; +- 提交后按约定执行 `git pull -r origin master`,120 个分支提交无冲突重放到 `8f1bbd0de3`。master 增量压缩 native DOM element + identity 并刷新 WPT baseline,不修改 Protocol attachment/session 路径;本模块 `moli-protocol` code patch 的 stable patch-id + 前后均为 `72e5935686bf08ba4d09179f54c2f2de7f1b0c9a`。rebase 后 attachment 全组与新增 DOM string boundary 交叉验证 + `59/59`(run `b2fb901c-17d2-42f3-8a07-ff2d39d4504b`),fmt/diff check 和 workspace release build 通过,最终 binary + SHA-256 为 `8c5f85bb98cd975814719ccfeecb732ac35ef8d25515f47e532dd5d381bd7d9e`;同样清代理并固定该 binary 后,CDP + `259/259`、WebDriver 四组 `157/157` 再次为 `ok=true`、失败列表为空。未在 stable module patch 上重复 workspace 全量与 clippy; + rebase 前同一 code patch 已通过两项门禁,rebase 后以 exact 交叉点、release 重建和两套完整 smoke 覆盖 master integration; +- 这完成的是 mixed Target container 的 frontend attachment 分型,不是 Phase 6 exit。physical `BrowserContext`/Target 仍保存 worker + projection、applied runtime snapshot、Page command/cache projection 与 non-owning Host access,且 executor completion 仍短暂借用 + `CdpConnection`。下一模块应以完整 physical Context/Target runtime payload 为单位进入独立 Host owner API;不能继续逐 session 或 + domain 增加 forwarding wrapper,也不能把这个 frontend registry 误搬进 Browser Core。 + +2026-08-06 exact Target frontend session state registry 模块: + +- 前一模块只抽出了 attachment route,本模块把同一 exact `BrowserTargetHandle` 下的 primary/root + `DevToolsSessionState`、auxiliary session state 与 session reverse route 合并为一个 + `TopLevelTargetFrontendSessionRegistry`。`BrowserContext` 不再另存 active primary/auxiliary state, + `ParkedPageSessionState` 也不再保存 background 副本;physical active/background residence、parking、promotion 和 demotion + 因而只移动 renderer/policy payload,不再搬运或克隆 frontend session state; +- primary state 属于 exact Target,显式 primary `sessionId` 只是可 detach/rebind 的 route;auxiliary state 则与对应 attachment + route 同生共死。session id 复用会在建立新 route 前原子删除旧 primary/auxiliary route 与旧 auxiliary state。session-scoped + reset 只把 still-attached auxiliary value 归零,不能删除 map membership;只有 detach/Target terminal 才能做结构删除,避免 + reverse route 仍存在但 state 已消失的双重 truth; +- `unbound` 只表示尚无 active Target 时的 root candidate。新注册的 active exact Target 原子接管它;active Target terminal + 会先删除所有显式 route,再只把跨 successor 明确定义保留的 aggregate runtime bindings 写入新的 candidate。Runtime/Page + subscription、pending inspector await、remote-object ownership、dialog 与 document-local state 都不会进入 successor;复用相同 + public `targetId` 也不会继承 predecessor state; +- Target owner command mutation、navigation renderer inputs、loaded-Document cleanup、pending inspector await drain/diagnostics、 + Runtime remote-object owner 检查与 output subscription 都改为查询同一 registry。任意 session/target route 使用 exact lookup; + stale 或缺失的 auxiliary route 退化为 `NoLoaded`,且在确认 frontend projection 前不会取走 parked policy payload。background + navigation 缺失 frontend projection 时使用空 frontend-derived renderer config 继续 Browser-owned action,不能让 projection + 完整性变成 Browser progress permit; +- physical `targetParking` diagnostics 不再伪装拥有 frontend pending-await state;对应计数现在从 exact registry 汇总。没有新增 + task、watcher、drain、pump、sleep、retry,也没有新增 production `expect!`/`panic!`。registry exact identity、residence + invariance、same-public-id isolation、session-id reuse、primary detach/rebind、unbound adoption、terminal sanitized inheritance、 + background navigation 与 stale auxiliary policy preservation 均有直接回归。聚焦 terminal sanitized inheritance `1/1` + 通过(run `0b9cb2a9-ba09-4b16-9c57-892ef54a4dd8`);完整 Protocol 包 `3475/3475` 通过。首次大范围迁移验证暴露的 + auxiliary reset/route membership、无 active Target 的 root command、无 primary session 的 background fixture registration、 + terminal root binding retention 与 diagnostics residence 都在本模块 owner boundary 收口,没有以 compatibility fallback 放宽; +- 最终 workspace nextest `16353/16353`、既有 skip `17`(run + `f68bf84f-917e-40bd-9e45-d5556a54a1a0`);`cargo fmt --all --check`、`git diff --check`、workspace + all-target clippy `-D warnings` 与 workspace release build 均通过。固定 `target/release/moli` SHA-256 为 + `ca0a67549906e379ff3a52f748ac4b53ba5d33c6a413a6d493400507d51dc2ad`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与 smoke group 环境覆盖、设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认全组 + `259/259`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 + `ok=true`、失败列表为空; +- 提交后执行 `git pull -r origin master`,121 个分支提交无冲突重放到 `45c532c5eb`;本模块 stable patch-id 前后均为 + `b4cfeeb201eb5281adb48f4f4daac5682980ba94`。上游唯一增量修复 Debugger step response/event ordering,并与本模块在 + `domains/runtime/activity.rs` 的测试 fixture accessor 有代码交叉;其 wire barrier、Protocol output slot、renderer pause bridge + 和真实 WebSocket 新回归合计 `7/7` 通过(run `3696dd3e-e9f8-4163-b07b-694f51e5d597`)。rebase 后 workspace + release build 通过,最终 binary SHA-256 为 + `889b46e530adeef89d60988d7e899544751d15c84be5237b2771de0b7ca70040`;同样清代理并固定该 binary 后,CDP + `259/259`、WebDriver 四组 `157/157` 再次为 `ok=true`、失败列表为空。没有在 stable module patch 上重复 workspace + 全量与 clippy;rebase 前同一 patch 已通过两项门禁,rebase 后以 exact 交叉点、release 重建和两套完整 smoke 覆盖 master + integration; +- 这关闭的是 top-level Target frontend session state 的单一 residence;当时仍把 `CdpConnection` 视为 frontend connection,因而把 + 完整 physical Context/Target payload 提取列为 Phase 6 blocker。后续 final audit 以 production composition root 纠正了这个前提: + actual socket frontend 与 owner-task-resident adapter 已是两个 residence,详见下一模块。 + +2026-08-06 DevTools Host adapter lifetime final audit 与 Phase 6 exit: + +- audit 从 production `SharedCdpOwnerRegistry -> owner task -> CdpScheduler` 装配路径重新追踪 lifetime,而不是继续按 legacy 类型名 + 推断 ownership。actual socket frontend 是 `CdpFrontendEndpoint/Receivers`、`CdpFrontendRouter` 和 per-socket sink; + `CdpConnection` 则始终驻留共享 owner task,跨 browser/page WebSocket attach、detach 和 reconnect 存活。已有真实 WebSocket 回归 + 明确证明 browser frontend 断开后 owner count、dynamic Target、renderer runtime 与独立 page frontend 均继续存活;因此把后者继续 + 当作“单个 frontend connection”拆分,是过期模型; +- audit 中验证并撤回了 generic owned/borrowed `CdpConnection` residence 方案。该方案机械泛型化接近 `300` 个 Protocol 文件,all-target + 编译仍需要大量 fixture 改写,更关键的是它只是把仍含 Inspector session、worker projection 与 output cursor 的混合 + `BrowserContext` 整体搬家,没有改变任何 authority。未提交实验被完整撤回,工作树恢复到精确基线;这再次满足本文的停止条件: + 大面积 forwarding/generic plumbing 且不删除 owner,不能作为架构进展; +- Protocol 新增 non-cloneable `DevToolsHostAdapter`,显式包住 legacy `CdpConnection`。application `CdpScheduler` 字段从模糊的 + `conn` 改为 `host_adapter`;Browser Host execution lane 的 application API 只接受这个 adapter 类型。Core actor 仍独立拥有输入选择 + 与 authoritative `BrowserHostState`,adapter 只在 actor 选定 turn 后提供短 renderer/DevTools projection。socket frontend + endpoint/router 从未获得 adapter 或 executor capability; +- 该 cutover 不增加 queue、task、watcher、drain、pump、sleep、retry,也不改变 command/event wire shape。它冻结 Chromium 风格的 + 三层责任:Browser Core 拥有 browser truth/progress,application-owned DevTools adapter 横跨 Browser/Renderer 做命令与事实投影, + socket frontend 只负责输入、route 和输出 sink。剩余 worker/Inspector/Network/Log projection 可以合法留在 adapter;只有经 owner + inventory 证明会在无 frontend 时继续决定 browser fact/runtime lifetime 的状态,才需要继续迁入 Core; +- exact adapter residence `1/1`(run `ce59391a-3623-413f-aa89-e35ecb053278`)、无 frontend 输入时的 Browser fact wake + `1/1`(run `802fb26d-4d29-4825-9411-fcfe5b8669ce`)与真实 browser WebSocket detach 后 dynamic Target/runtime 存活 + `1/1`(run `6a640b13-be4b-40e2-aac0-bc7d335b3d60`)通过;最终 workspace nextest `16360/16360`、既有 skip + `17`(run `ec82af25-a5e1-45d7-b976-075aed6c3f1b`);`cargo fmt --all --check`、`git diff --check`、workspace + all-target clippy `-D warnings` 与 workspace release build 均通过。固定 release binary SHA-256 为 + `862d5b85506c14afc0e24b55ce4e7b087c49b1a1e49e720f3a31071c4d83f355`;显式清除大小写 + HTTP/HTTPS/ALL/FTP proxy 与 inherited smoke group/port,设置 `NO_PROXY=*` / `no_proxy=*` 并固定该 binary 后,CDP 默认 + 全组 `259/259`、WebDriver Classic/BiDi/Selenium/Semantics `--continue-on-failure` `157/157`,两套均为 + `ok=true`、失败列表为空; +- Phase 6 exit 以 production lifetime 和 authority 通过,不再以“Protocol projection 容器必须为空”作为伪 gate。下一阶段只收敛 + raw CDP、BiDi 与 Classic 的 actual frontend/service lifetime;standalone `Browser` 不因复用 renderer/core 语义而被强制改造成 + DevTools frontend。 + +`moli serve` 先创建 owner task,其中 Browser Host owner lane 与 DevTools Host adapter 是独立成员;随后接受 +CDP/BiDi frontend endpoint。frontend endpoint 保存 command queue、route 与 output sink,不持有 Browser Host 或 adapter。 +`moli fetch` / MCP 则创建一次调用级 standalone `Browser` Core;它们不持有 served Host receiver,也不参与 CDP +frontend queue。两种 deployment 共享 exact lifecycle/navigation contract,但 high-level wait 与 CDP observation 的返回 policy +可以不同。 + +Exit gate(已通过):drop actual frontend endpoint 不会 drop Browser Host 或 DevTools Host adapter;BrowserContext/Target/Page +identity、topology、navigation、Page/engine/profile lifetime 与 browser-global policy 的 authoritative mutable state 位于 Core/ +application-owned services。legacy `CdpConnection` 只作为 `DevToolsHostAdapter` 内部实现保存 renderer/DevTools projection 和 +non-owning/共享 Core access,不再被解释为 frontend lifetime owner。 + +### Phase 7:served protocol frontend 收敛 + +目标:删除“actual frontend endpoint 可以持有或驱动 Browser/DevTools Host receiver”的过渡模型。 + +工作: + +- 同一 logical browser/session 的多个 frontend clone exact 同一 owner endpoint 或 typed service handle;独立 WebDriver + session 与 raw-CDP browser 可以拥有不同 Host instance,具体 actor 类型可以服从各自 composition; +- raw CDP、BiDi 与 Classic 都不能直接持有或驱动 Host receiver/scheduler; +- 各 frontend 只保留协议 wait/error/result/event mapping; +- WebDriver default Context 必须属于该 session 的 private Host;只有 wire API 显式创建/删除 user context 时才发对应 + create/delete command,不能为架构整齐改变 BiDi default userContext 语义; +- 删除各 adapter 的独立 page pump、pending-navigation 扫描和 browser teardown fallback。 + +Exit gate:给同一 Browser Host 输入相同 command/renderer intent,raw CDP、BiDi 与 Classic 观察到相同 +browser trace;差异仅来自书面的等待与 wire 投影语义。关闭或放慢任一 actual frontend 不改变 Host trace/lifetime。 +standalone CLI/MCP 的跨 deployment differential 属于产品兼容与 benchmark gate,不是 CDP/Browser owner 分离 gate。 + +状态:已完成(2026-08-06 exit audit)。第一项端到端模块已把 standalone BiDi 与 Classic 的 production Host residence 收敛到 +`protocol_server/devtools_host_service.rs` 与 `protocol_server/devtools_host_service/actor.rs`:service actor +唯一拥有 `CdpScheduler`、`BrowserHostExecutionLane`、renderer/background receivers 和 +`ProtocolAdapterScheduler`;cloneable service handle 只提交 typed request/attach/shutdown。Classic 保留独立串行 +frontend queue 和 WebDriver frame/result 映射,attached BiDi detach 后 exact Classic Host 继续运行并允许重新 attach; +standalone BiDi socket 结束后由 session owner 显式 shutdown/join service。原 standalone BiDi loop 与 +Classic-with/without-BiDi Host loop 已删除,没有新增 sleep、poll、drain、retry 或 frontend progress trigger。 + +最终 audit 没有继续把 raw CDP 强塞进同一个 service 类型。production `SharedCdpOwnerRegistry` application-own +一个 shared owner task;`CdpFrontendEndpoint` 只有 control/command sender 和 shutdown watch,不包含 +`CdpScheduler`、`BrowserHostActor`、renderer/background receiver 或 adapter。owner actor 独立选择 Browser Host +wake、participant completion、fact wake 与 frontend input;browser/page WebSocket detach 只删除 route/sink,不能关闭 +owner。raw CDP 的多 socket/Target topology 与 WebDriver 的 session-owned Host 不同,保留两个 composition actor +是有意设计,不是第二份 browser authority。 + +同一 audit 还确认:standalone `Browser` 的 `FollowInStandaloneAdapter + FollowBeforeReply` 是 +`document-milestone-navigation-completion-design-current.md` 已冻结的 high-level completion policy;它不是 frontend +pump。把完整 `Request`、raw non-HTML、response/selector/script wait、dump/trace 与 mutable automation `Page` +改经 DevTools command,会重写 public Browser API,却不删除 served Host 中任何 owner,因此移出本阶段。 + +本端到端模块的验证边界是 Host service lifetime,不是“删掉所有 frontend await”:Protocol server +BiDi/Classic 聚焦组 `258/258`(run `af25a630-f095-4e7b-87b1-d602c960da22`),`moli` +crate 全量 `573/573`(run `8f63490c-6264-4139-be1b-7a1e78be15ef`)。workspace 首轮 +`16361/16362` 时出现一项未被本模块修改的 renderer sandboxed Blob iframe/OPFS 调度失败(run +`20afe1dd-3a7f-48e4-97c4-f46f0844abbc`);原测试聚焦压力 `20/20`(run +`4ec3c12a-33ab-4805-a5a7-f4cfc66d51ad`)、所属 structured-clone 模块五轮 `40/40`(run +`a8a8fa2b-0ec5-4017-b69f-1b7514725e10`),最终 workspace 全量 `16362/16362`、既有 skip `17` +(run `9aaa6193-538d-4890-bd37-ec40ee869a53`)。workspace fmt、all-target clippy `-D warnings`、 +release build 与 diff check 均通过;固定 `target/release/moli` SHA-256 为 +`89425f5cb6f804fcccdc48ade3d521c530ff0e6a7beaf17960bf95a2712ca795`。显式清除大小写 +HTTP/HTTPS/ALL/FTP proxy、原 no-proxy 与 inherited smoke group/port,并设置 `NO_PROXY=*` / +`no_proxy=*` 后,CDP 默认全组 `259/259`,WebDriver Classic/BiDi/Selenium/Semantics +`--continue-on-failure` `157/157`;两套均为 `ok=true`、失败列表为空。 + +2026-08-06 exit audit 的静态 inventory 与聚焦证据:`ProtocolSchedulerWork` 只剩 protocol observation、 +main-Document Browser-fact projection 和 BiDi channel continuation;renderer navigation、replacement、popup、 +termination 均已从该 residence 消失。`CommandOwnerScope` 只冻结 session/output route,不授权 Browser action。 +Host mailbox/fact wake、raw-CDP frontend detach/reconnect、BiDi detach 后 Classic Host 存活与 clone handle lifetime +共 `6/6` 通过(run `44580b3e-dbfb-457b-9d63-00955ec7da60`);standalone post-load reload 与两个 +Fetch exact owner 在 frontend wait 丢失后继续完成共 `3/3` 通过(run +`68648067-a21c-4fad-880d-96687f1c1455`)。本次只修正文档 gate,没有修改 Rust,因此没有机械重复 +workspace/release/smoke;前一端到端模块的完整门禁和外部 smoke 即为当前代码树证据。 + +### Phase 8:删除迁移层并评估物理隔离 + +状态:已完成 exit audit;物理隔离保持可选。 + +删除或确认不再承担 Browser authority: + +- protocol scheduler 中遗留 browser-owner work kind; +- `CommandOwnerScope` 对 browser action 的使用; +- duplicated BrowserContext/Target/Page current-state cache; +- command-followup 充当 browser progress pump 的 helper; +- 已失去用途的 generation、route override 和 adapter bridge。 + +2026-08-06 audit 结果:前两项已经满足;`ProtocolSchedulerWorkKind` 的三个剩余 variant 都是协议投影/ +continuation,`CommandOwnerScope` 是 frontend correlation。physical `BrowserContext`/Target/Page view 仍可合法驻留 +`DevToolsHostAdapter`,但 authoritative identity/topology/generation/lifetime 在 Core;因此它不是 duplicated current-state +authority。现存 command-followup snapshot 只调度 BiDi channel action,不扫描或推进 navigation。generation、exact route +和 non-cloneable `DevToolsHostAdapter` 仍分别用于 stale rejection、frontend routing 和 application residence,不能因带有 +迁移期名字而删除。 + +这也冻结 Phase 8 的停止条件:重命名 300 个 Protocol 文件、把 `CdpConnection` 机械改名,或让 raw CDP/WebDriver/ +standalone Browser 共用一个 concrete actor,都不会删除 authority 或 progress dependency,不能算本计划收益。后续只有 +production trace 证明某个 adapter 字段会在没有 frontend 时决定 Browser progress/lifetime,才重开 owner migration。 + +随后基于证据决定是否: + +- Browser Host 独立 OS thread; +- 多 Browser Host worker pool; +- 进程/IPC 隔离。 + +当前没有 correctness 或资源证据要求物理拆分;同进程 current-thread 模型长期保留。物理拆分不是完成条件。 + +## 第一批 PR 建议 + +不要从“移动整个 `CdpConnection`”开始。建议按以下可审查切片推进: + +### PR A:identity inventory 与 trace + +- 维护本文和 field ownership inventory; +- 给 renderer intent、owner action、request、commit、fact projection 增加统一 trace key; +- 不改行为。 + +状态:架构范围已完成。本文、initial/producer inventory、production navigation trace/JSONL、Core fact +sequence、四种 Moli surface 同源 fixture 和第一版 release short-navigation 基线均已建立;Chromium +machine differential 以及 event-heavy/多 Target/idle baseline 作为后续 benchmark 增强,不阻塞本计划 exit。 + +### PR B:renderer navigation 去 session 化 + +- action 只携带 neutral target/page/document identity; +- owner lookup 不再进入 `CommandOwnerScope`; +- 保留现有 scheduler,先证明路由不回退。 + +状态:已完成。`TopLevelLocationNavigationOwnerAction` 已去 session 化;下一切片进入 PR C,不再给 +该 action 增加 frontend route compatibility field。 + +### PR C:Browser navigation owner seam + +- `NavigationEngine` 和 page lookup 进入 core-owned substructure; +- protocol 通过无 session 的 owner API 调用; +- event adapter 暂时保留。 + +状态:已完成。BrowserContext/Target/Page generation、initial Document、request、history、replacement、 +termination 与 selected/retained engine authority 均已进入 Core;physical payload adapter 只剩同 turn 投影。 + +### PR D:renderer navigation lane cutover + +- 启动 Browser Owner queue; +- renderer intent 直接发布到该 queue; +- 删除 `TopLevelLocationNavigationOwnerAction` protocol residence; +- 复用本次 passive-progress regression 作为红绿证据。 + +PR C 已先迁移 `Page.navigate` 共用的 request/history/replacement/termination authority;PR D 只切换 renderer +与 command navigation 的执行 lane,不再重做这些 state owner。Browser fact journal 已在 Phase 5 完成;当前进入 Phase 6 +Host lifetime 与 browser state 的应用级提取。 + +状态:已完成(第48切片 exit audit)。renderer action 已直接进入 Core `BrowserHostActor` mailbox,Protocol scheduler residence 与 +`BrowserOwnerInputPublished` migration envelope 均已删除;`CdpConnection` 在 publication boundary 只保存 +cloneable producer handle。raw-input pop/complete seam 也已删除:Core actor 签发 exact `BrowserHostTurn` 并调用 +protocol-neutral executor,`CdpScheduler` 和行为 fixture 的调度路径不再拥有 selection 后的裸 input。actor +residence 也已移到 application input composition,所有 production blocking wait 直接监听 mailbox,不再用 +frontend/renderer input 间接唤醒或靠 loop-top polling 推进。 +actor executor 也已改成同步 short-start contract;renderer/network participant wait 现在由 application execution +lane 登记成 exact pending/completion input,不再跨等待借用 actor 或 `CdpConnection`。response-ready navigation +现在由独立 completion 模块把 load/configuration/renderer commit 分成三个 phase;production background path 也 +通过 opaque participant completion 回到原 input channel,且只由 terminal disposition 释放 navigation gate。 +renderer commit 后的 Inspector replay dispatch 也已拆成逐个 move-owned participant;全部 replay 共用原 gate, +target-keyed engine 在 commit apply turn 内完成 adoption。generic materialized outcome 也已在 body apply 后进入同一 +tail participant seam,不再由 Browser Host completion inline drain replay。loaded Page restore/replacement/disposal、 +worker retirement、lifecycle projection、BiDi preload listener 与 Runtime output normalization 的主要 navigation/Page +command 路径也已逐步迁成 exact participant;scheduler-facing `ReleaseObjects` 也已成为逐 handle participant chain。 +第48切片确认 renderer top-level navigation 已无 Protocol execution fallback,slow/disconnected frontend 也不构成其 +progress predecessor,因此 PR D 和 Phase 3 正式关闭。`CdpTurnOutcome`/fact journal 与 actor teardown/lifetime 分别进入 +Phase 5/6;第49--75切片已把 production command navigation、renderer top-level history、popup/termination、Fetch decision、 +BrowserContext disposal、download progress 与 initial-target URL 的 action authority 逐步汇入同一 Host lane;第76切片又迁移 +`Page.createIsolatedWorld` 的 initial-URL prerequisite 并完成 Phase 4 exit audit。neutral fact/completion transport 和独立 Host lifetime +仍分别由 Phase 5/6 负责。 + +## 回归矩阵 + +### Navigation source + +至少覆盖: + +1. CDP `Page.navigate`; +2. BiDi/Classic navigate; +3. CLI initial navigation; +4. parser-blocking script `location.href`; +5. DCL handler navigation; +6. load handler navigation; +7. post-load one-shot timer reload; +8. history traversal / same-document hash navigation; +9. popup/auxiliary target; +10. `Page.stopLoading` / target close competing with navigation。 + +### Frontend condition + +每个关键 source 至少组合: + +- 无 protocol subscriber; +- passive CDP event listener; +- 插入 noop command; +- 多 in-flight command; +- slow socket writer; +- frontend disconnect; +- primary + auxiliary session; +- Page domain disabled/enabled; +- BiDi/Classic attached; +- CLI high-level wait。 + +### Identity 与 race + +- A/B 使用不同 Page/document/loader identity; +- consecutive navigation 后旧 request completion 不能 commit; +- old renderer intent 不能修改 successor Page; +- old DCL/load wake 不能投影成新 Document; +- target park/promote 不重建错误 owner route; +- context delete/browser close 只结算一次; +- frontend detach 不影响 accepted browser action; +- interception permit disconnect 按 policy 结算; +- duplicate fact/projector retry 不重复执行 side effect。 + +### Protocol observable + +必须检查: + +- command response shape/error code; +- response 与 post-response event 顺序; +- `frameNavigated`、loader id、lifecycle event attribution; +- Target attach/detach/close sequence; +- Network request/response/body target ownership; +- pending command timeout/cancel; +- session-local domain subscription; +- slow/lagged subscriber 的显式处理。 + +### Differential + +本地 deterministic fixture 同时跑: + +- Moli standalone; +- Moli raw CDP; +- Moli BiDi/Classic; +- Xvfb Chromium raw CDP。 + +比较 browser-visible trace,不比较像素: + +- request/redirect/status/final URL; +- navigation/commit/replacement; +- Document/loader identity; +- DCL/load sequence; +- command response timing; +- frontend disconnect/second command 是否改变结果。 + +### Repository gate + +每一阶段使用所属边界附近的 focused `cargo nextest`。阶段合并前按仓库规则: + +```bash +cargo nextest run --no-fail-fast +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +``` + +禁止用 `cargo test` 代替。 + +## 性能与资源约束 + +拆分不能以复制状态和事件换 correctness。 + +必须保持: + +- 第一阶段不新增每 frontend OS process/thread; +- Browser Host mutable state 单份; +- subscriber 只保存 cursor/小型 projection state; +- DOM snapshot、network body 和下载数据不复制进 journal; +- owner/action/fact channel bounded 或有明确 backpressure policy; +- 无 subscriber 时不构造昂贵 CDP JSON; +- trace 默认关闭,开启时有界; +- 不因为 frontend 数量重复 NavigationEngine/renderer,除非明确创建独立 BrowserContext/Target。 + +Phase 0 先记录现有 release 的: + +- CLI/CDP navigation latency; +- cold/warm RSS/PSS; +- 1/多 target 并发; +- event-heavy Network/CDP output; +- browser idle footprint。 + +后续每个 owner/lifetime cutover 都做前后对比。任何明显 regression 先定位状态复制、channel queue、 +serialization 或多余 wake,不能通过少发协议事件提高分数。正式百分比 gate 以 Phase 0 baseline 和 +benchmark noise 冻结,不在设计阶段拍脑袋写阈值。 + +## Diagnostics + +统一 trace 至少包含: + +```text +browser_instance_id +browser_context_id +target_id +page_residence_generation +navigation_request_id +renderer_agent_attachment_id +document_lifecycle_identity +browser_action_id +browser_fact_sequence +source = frontend-command | renderer-intent | network | lifecycle +owner_state_before / owner_state_after +frontend_projection_sequence +``` + +时间点分开记录: + +- intent/command published; +- Browser Owner accepted; +- network started; +- response commit-ready; +- commit participant settled; +- Page replacement committed; +- renderer lifecycle reached; +- browser fact appended; +- frontend event projected/flushed。 + +这样可以区分“browser 没执行”“fact 没发布”“projector 没订阅”“socket 没 flush”,避免重新用下一条 +命令做探针。trace 不记录 cookie value、authorization 或 response body 等敏感内容。 + +## 迁移纪律 + +- 一个 action 任意时刻只能有一个 execution authority; +- 新 owner 路径落地后,同一 PR 或紧邻 PR 删除旧执行路径; +- adapter 可以转换类型,不能重新扫描 mutable Page 寻找 pending action; +- 不用 feature flag 长期维护两套 browser runtime;必要时只做 test-only shadow decision trace,shadow + 不能执行 side effect; +- 不为了减少改动让 Browser Core 接受 `sessionId`、CDP event buffer 或 wire error; +- 不为了复用 `CdpScheduler` 把 Browser Owner queue 命名成另一种 protocol residence; +- 不把 frontend disconnect 的 drop side effect 当作 browser lifecycle API; +- 不在 CDP、BiDi、Classic 调用点复制同一种 navigation/termination 修复; +- 不用 sleep、drain、retry、heartbeat 或无限 pump 修调度正确性。 + +## 风险 + +### V8/thread affinity + +`NavigationEngine` 和 renderer handle 仍是 current-thread owner。过早跨线程会引入 Send/Sync、local +task 和 teardown 风险。因此先在同一 dedicated runtime 内拆 actor/handle,不能为了“看起来分开” +移动 V8 到普通 Tokio worker。 + +### BrowserContext 中混有 protocol state + +当前 BrowserContext/Target 结构可能同时保存 browser runtime 和 DevTools projection。不能机械移动 +整个类型。先做 field ownership inventory,再拆 authoritative runtime state 与 frontend mirror。 + +### DevTools commit timing + +document-start script、binding、isolated world 和 V8 session restore 要在 author JS 前就绪。Browser +Core 分离不能把它们推迟到 DCL 后;应使用 explicit commit participant,与现有 +`PreparedRendererDocument/ReadyToCommit` 计划对齐。 + +### Network/Fetch 边界 + +Fetch interception 可以合法暂停 request,但 Network listener 只是 observer。迁移时最容易把两者 +都做成 protocol backpressure。必须分别建模 request permit 与 network fact subscription。 + +### 多 frontend 与 lifetime + +共享 Browser Host 后,frontend attach/detach、session-owned context 和 browser-close 权限要明确。 +第一版不必支持任意多客户端写同一 target,但内部 API 不能重新把 host ownership 绑到第一个 socket。 + +### Event journal 内存 + +Network/DOM 高频事实可能撑大 journal。第一阶段只迁 navigation/lifecycle/target small facts;大 body、 +DOM snapshot 和 stream 保持 owner store + handle。journal retention 和 lag policy 必须有指标。 + +### 双重 truth + +迁移期间 frontend mirror 可能和 Browser Core current state 分叉。每个字段必须标记 +authoritative/projection/cache;projection 不得反向决定 current target/page。 + +## 停止并重新设计的条件 + +出现以下任一情况,暂停扩大修改: + +- Browser Core action 需要 CDP/BiDi session id 才能执行; +- Browser Host 直接构造 CDP JSON 或 `BackgroundProtocolEvent`; +- protocol actor 仍需发 noop/drive/pump 才能让 browser action 前进; +- old/new queue 都可能执行同一 navigation; +- frontend output queue 满会停止 renderer/network owner; +- 为迁移新增 sleep、retry、反复 `yield_now` 或无限 drain; +- 同一修复要复制到 CDP、BiDi、Classic 三处; +- stale completion 只能靠“当前 target”猜 owner; +- 必须先析构旧 Page 才能 commit 新 Page; +- Browser Core 和 frontend 同时保存 authoritative BrowserContext/Target/Page; +- 新 crate 造成 core/protocol/renderer dependency cycle; +- benchmark 变快是因为漏发事件、跳过 DOM/network 输出或降低正确性。 + +停止时记录:应成立的不变量、违反路径、当前 authority、下一步最小 differential。 + +## 非目标 + +- 不复制 Chromium 的完整多进程、Mojo、RenderFrameHost、BFCache、prerender 或 OOPIF; +- 不实现 GUI、layout、paint/compositor; +- 不在本计划中重新定义 DCL/load/Done/domstable; +- 不解决知乎 challenge、WAF、CORS 或具体站点兼容; +- 不改变 CDP/BiDi wire shape; +- 不一次重写所有 domain dispatcher; +- 不同时重构全部 Network/Fetch/body store; +- 不为了架构形式新增 OS process、thread 或 crate; +- 不承诺无证据的多客户端并发写语义。 + +## 待冻结决策 + +| 问题 | 默认方向 | 冻结阶段 | +| --- | --- | --- | +| Browser Host lifetime | raw-CDP browser 为 `serve` owner 级;WebDriver 为 session 级;CLI 为一次调用级;只有显式 attach 的 frontend 共享 exact instance | Phase 6/7 | +| frontend disconnect 后页面是否继续 | 默认继续;session-owned context 显式删除 | Phase 3/6 | +| 第一版是否跨线程 | 否,同 dedicated current-thread runtime | Phase 2 | +| 是否新增 crate | 否,先放 `moli-core` | Phase 2 | +| journal retention | bounded ring + subscriber lag 明确失败/重快照 | Phase 5 | +| 多 frontend 写同一 target | 先保证读/attach 与 owner 隔离,再定义冲突 policy | Phase 6 | +| DevTools commit participant timeout | exact request policy;断开不得 orphan | Phase 4/5 | +| Network fact 迁移粒度 | navigation/lifecycle 完成后逐 producer 迁移 | Phase 5+ | +| Browser Host 是否未来独立 process | 只按性能/隔离证据决定 | Phase 8 | + +## 完成定义 + +长期改造完成必须同时满足: + +1. Browser Host 是 BrowserContext、Target、Page 和 NavigationEngine 的唯一 mutable owner; +2. renderer navigation intent 不含 frontend/session route; +3. command navigation 与 renderer navigation 进入同一 Browser Owner request state machine; +4. `ProtocolSchedulerWork` 不含 navigation/replacement/termination browser action; +5. `CdpConnection` 不持有 strong NavigationEngine 或 authoritative BrowserContext/Target/Page state; +6. Browser Core 不依赖 CDP/BiDi wire crate,不保存 frontend command/session id; +7. raw CDP/BiDi/Classic 的 actual frontend 只通过 owner endpoint 或 typed service handle 访问 served + Browser Host;standalone CLI/MCP 通过一次调用级 `Browser` Core facade,不能驱动 served Host receiver; +8. lifecycle/navigation/target facts 带 exact identity 和 sequence,并由 frontend 独立投影; +9. frontend command、读取、断开和 backpressure 不成为 browser progress trigger; +10. explicit interception/debug/commit hold 使用 request-scoped permit,普通 observer 没有执行权限; +11. 旧 Page/request/agent 的 late completion 不能污染 current Page/session; +12. 插入/删除任意 noop frontend command 不改变 browser trace; +13. focused、workspace nextest、fmt、clippy、raw client smoke 和 Chromium differential 通过; +14. WebFetch/CDP 成功率不因漏事件或降低正确性虚增,资源回归有前后证据; +15. 当前短期 adapter guard 只剩 attachment/投影职责,不再承担 browser progress containment。 + +## 相关文档 + +- [`cdp-current.md`](cdp-current.md):当前 CDP stack 和剩余架构债事实。 +- [`workspace-crate-map-current.md`](workspace-crate-map-current.md):当前 crate dependency 和 ownership 约束。 +- [`chromium-aligned-devtools-navigation-redesign-2026-07-27.md`](chromium-aligned-devtools-navigation-redesign-2026-07-27.md):DevTools target/session、renderer channel、V8 session restore 和 author-JS 前 commit 设计。 +- [`cdp-chromium-aligned-architecture-refactor-plan-2026-06-14.md`](cdp-chromium-aligned-architecture-refactor-plan-2026-06-14.md):CDP actor、renderer callback 和 output ordering 的既有总计划。 +- [`cdp-scheduler-owner-loop-cleanup-plan-2026-06-12.md`](cdp-scheduler-owner-loop-cleanup-plan-2026-06-12.md):owner output channel 与删除 generic pump 的历史计划。 +- [`document-milestone-navigation-completion-design-current.md`](document-milestone-navigation-completion-design-current.md):exact DCL/load、跨 Document completion gate 和 passive progress 不变量。 +- [`page-lifecycle-current.md`](page-lifecycle-current.md):当前 wait mode 和 lifecycle 事实。 +- [`webfetch-horizontal-zhihu-fix2-2026-08-01.md`](webfetch-horizontal-zhihu-fix2-2026-08-01.md):CLI/CDP 差异、Chromium 对照、短期修复和 live evidence。 diff --git a/moli-benchmark/README.md b/moli-benchmark/README.md index dc4e87b4b8..b6cf114ed9 100644 --- a/moli-benchmark/README.md +++ b/moli-benchmark/README.md @@ -12,6 +12,10 @@ The runner records the environment and browser versions, keeps raw measurements, and produces a self-contained HTML report. It supports quick local checks, cross-engine investigations, and formal release-readiness runs. +The `navigation-trace` suite additionally records exact-Document +CLI/CDP/BiDi/Classic traces under `navigation-trace/`, including per-run JSONL +and a cross-frontend comparison summary. + ## Quick start You need Python 3.11 or newer, [`uv`](https://docs.astral.sh/uv/), and a release @@ -35,6 +39,7 @@ Run a compact fetch/CDP comparison across the configured engines: ```bash uv run moli-benchmark run --profile horizontal --timeout 10 +uv run moli-benchmark navigation-trace --runs 10 --timeout 10 ``` Run one deterministic case while working on Moli: diff --git a/moli-benchmark/moli_benchmark/cli.py b/moli-benchmark/moli_benchmark/cli.py index b520e01f11..bc2053da4f 100644 --- a/moli-benchmark/moli_benchmark/cli.py +++ b/moli-benchmark/moli_benchmark/cli.py @@ -27,6 +27,7 @@ from .crawler import run_crawler_suite from .environment import collect_environment from .html_report import write_benchmark_html +from .navigation_trace import run_navigation_trace_suite from .publish_readiness import build_publish_readiness from .report_diff import build_report_diff, load_baseline_summary from .startup import ( @@ -897,6 +898,39 @@ def cmd_cdp_smoke(args: argparse.Namespace) -> int: return 1 if summary.get("gate_failures") else 0 +def cmd_navigation_trace(args: argparse.Namespace) -> int: + output_dir = ensure_dir(_report_output_dir(args)) + moli_bin = moli_binary(args.moli_bin) + target_matrix = _target_matrix( + moli_bin, + lightpanda_override=args.lightpanda_bin, + chrome_override=args.chrome_bin, + obscura_override=args.obscura_bin, + ) + _write_metadata( + output_dir, + moli_bin, + lightpanda_override=args.lightpanda_bin, + chrome_override=args.chrome_bin, + obscura_override=args.obscura_bin, + ) + summary = run_navigation_trace_suite( + moli_bin=moli_bin, + output_dir=output_dir, + runs=_runs(args), + timeout_seconds=args.timeout, + ) + _finish_report( + output_dir=output_dir, + moli_bin=moli_bin, + target_matrix=target_matrix, + summaries=[summary], + baseline_report=args.baseline_report, + ) + print(output_dir) + return 1 if summary.get("gate_failures") else 0 + + def cmd_wpt(args: argparse.Namespace) -> int: output_dir = ensure_dir(_report_output_dir(args)) moli_bin = None @@ -1101,6 +1135,13 @@ def build_parser() -> argparse.ArgumentParser: cdp_smoke.add_argument("--command", nargs=argparse.REMAINDER) cdp_smoke.set_defaults(func=cmd_cdp_smoke) + navigation_trace = subparsers.add_parser( + "navigation-trace", + help="compare one exact navigation trace across CLI, CDP, BiDi, and Classic", + ) + _add_common_run_args(navigation_trace) + navigation_trace.set_defaults(func=cmd_navigation_trace) + wpt = subparsers.add_parser("wpt", help="run or collect WPT compat reports") _add_output_args(wpt) wpt.add_argument("--baseline-report", type=Path, default=None, help="compare this report with a previous summary.json or report directory") diff --git a/moli-benchmark/moli_benchmark/navigation_trace.py b/moli-benchmark/moli_benchmark/navigation_trace.py new file mode 100644 index 0000000000..935df288a0 --- /dev/null +++ b/moli-benchmark/moli_benchmark/navigation_trace.py @@ -0,0 +1,266 @@ +"""Exact-navigation differential fixture for Moli frontends. + +Document A requests Document B synchronously from its load handler. That is a +FollowBeforeReply boundary covered by standalone fetch; it deliberately does +not ask wait-until done to wait for an unknown future timer. CDP and BiDi send +no command after the A navigation until B has independently reached load. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from .artifacts import write_csv, write_json +from .navigation_trace_fixture import NavigationTraceFixture, _navigation_a_document +from .navigation_trace_frontends import _prepare_trace_path, _run_frontend, _url_stage +from .navigation_trace_records import ( + load_browser_owner_trace, + normalize_lifecycle_milestones, + normalize_owner_shape, +) +from .stats import summarize + +NAVIGATION_TRACE_FRONTENDS = ("cli", "cdp", "bidi", "classic") +EXPECTED_REQUEST_STAGES = ("a", "b", "complete") +EXPECTED_LIFECYCLE_MILESTONES = ( + "D1:dom-content-loaded", + "D1:load", + "D2:dom-content-loaded", + "D2:load", +) +EXPECTED_PROTOCOL_LIFECYCLE = ( + "a:dom-content-loaded", + "a:load", + "b:dom-content-loaded", + "b:load", +) +EXPECTED_OWNER_STAGE_SEQUENCE = ( + "browser_action_published", + "browser_owner_accepted", + "navigation_request_started", + "network_request_admitted", + "response_commit_ready", + "page_replacement_committed", + "browser_domcontentloaded_observed", + "renderer_intent_published", + "browser_action_published", + "browser_load_observed", + "browser_owner_accepted", + "navigation_request_started", + "network_request_admitted", + "response_commit_ready", + "page_replacement_committed", + "browser_domcontentloaded_observed", + "browser_load_observed", +) + + +def _finalize_frontend_result( + frontend: str, + fixture: NavigationTraceFixture, + run_token: str, + trace_path: Path, + raw: dict[str, Any], +) -> dict[str, Any]: + records = load_browser_owner_trace(trace_path, int(raw.get("trace_offset") or 0)) + request_stages = fixture.navigation_request_stages(run_token) + lifecycle_milestones = normalize_lifecycle_milestones(records) + owner_shape = normalize_owner_shape(records) + final_observation = raw.get("final_observation") or {} + errors: list[str] = [] + if request_stages != list(EXPECTED_REQUEST_STAGES): + errors.append(f"request stages were {request_stages!r}") + if lifecycle_milestones != list(EXPECTED_LIFECYCLE_MILESTONES): + errors.append(f"lifecycle milestones were {lifecycle_milestones!r}") + if frontend in {"cdp", "bidi"} and raw.get("protocol_lifecycle") != list( + EXPECTED_PROTOCOL_LIFECYCLE + ): + errors.append(f"protocol lifecycle was {raw.get('protocol_lifecycle')!r}") + if _url_stage(str(final_observation.get("url") or "")) != "b": + errors.append(f"final URL was {final_observation.get('url')!r}") + if final_observation.get("title") != "Trace B" or final_observation.get("phase") != "b": + errors.append(f"final document was {final_observation!r}") + if raw.get("followup_commands_before_terminal") != 0: + errors.append("a frontend follow-up command was sent before successor load") + if frontend != "cli": + if not any(record.get("browser_instance_id") is not None for record in records): + errors.append("protocol frontend trace lacked Browser Owner correlation") + owner_stages = [record["stage"] for record in owner_shape] + if owner_stages != list(EXPECTED_OWNER_STAGE_SEQUENCE): + errors.append(f"Browser Owner stages were {owner_stages!r}") + return { + **raw, + "frontend": frontend, + "run_token": run_token, + "ok": not errors, + "errors": errors, + "trace_path": str(trace_path), + "trace_record_count": len(records), + "trace_schema_versions": sorted({record.get("schema_version") for record in records}), + "request_records": fixture.request_records(run_token), + "request_stages": request_stages, + "lifecycle_milestones": lifecycle_milestones, + "owner_shape": owner_shape, + "visible_shape": { + "request_stages": request_stages, + "lifecycle_milestones": lifecycle_milestones, + "final_stage": _url_stage(str(final_observation.get("url") or "")), + "final_title": final_observation.get("title"), + "final_phase": final_observation.get("phase"), + }, + } + + +def _shape_digest(value: Any) -> str: + payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def run_navigation_trace_suite( + *, + moli_bin: Path, + output_dir: Path, + runs: int, + timeout_seconds: float, +) -> dict[str, Any]: + if runs <= 0: + raise RuntimeError("navigation trace runs must be positive") + suite_dir = output_dir / "navigation-trace" + details: list[dict[str, Any]] = [] + cross_frontend_rows: list[dict[str, Any]] = [] + with NavigationTraceFixture() as fixture: + for run_id in range(1, runs + 1): + run_details: dict[str, dict[str, Any]] = {} + for frontend in NAVIGATION_TRACE_FRONTENDS: + run_token = f"{frontend}-{run_id}" + trace_path = suite_dir / "traces" / f"{run_token}.jsonl" + _prepare_trace_path(trace_path) + try: + raw = _run_frontend( + frontend, + moli_bin, + fixture, + run_token, + trace_path, + timeout_seconds, + ) + detail = _finalize_frontend_result( + frontend, fixture, run_token, trace_path, raw + ) + except Exception as error: + detail = { + "frontend": frontend, + "run_token": run_token, + "ok": False, + "errors": [f"{type(error).__name__}: {error}"], + "trace_path": str(trace_path), + "request_records": fixture.request_records(run_token), + } + details.append(detail) + run_details[frontend] = detail + + visible_shapes = { + frontend: detail.get("visible_shape") + for frontend, detail in run_details.items() + if detail.get("ok") + } + owner_shapes = { + frontend: run_details[frontend].get("owner_shape") + for frontend in ("cdp", "bidi", "classic") + if run_details[frontend].get("ok") + } + visible_match = len(visible_shapes) == len(NAVIGATION_TRACE_FRONTENDS) and len( + {_shape_digest(shape) for shape in visible_shapes.values()} + ) == 1 + owner_match = len(owner_shapes) == 3 and len( + {_shape_digest(shape) for shape in owner_shapes.values()} + ) == 1 + cross_frontend_rows.append( + { + "run": run_id, + "ok": visible_match and owner_match, + "visible_match": visible_match, + "owner_match": owner_match, + "visible_digests": { + frontend: _shape_digest(shape) for frontend, shape in visible_shapes.items() + }, + "owner_digests": { + frontend: _shape_digest(shape) for frontend, shape in owner_shapes.items() + }, + } + ) + + rows = [ + { + "frontend": detail["frontend"], + "run_token": detail["run_token"], + "ok": detail.get("ok", False), + "navigation_elapsed_ms": detail.get("navigation_elapsed_ms"), + "latency_scope": detail.get("latency_scope"), + "peak_pss_bytes": (detail.get("resources") or {}).get("peak_pss_bytes"), + "peak_rss_bytes": (detail.get("resources") or {}).get("peak_rss_bytes"), + "peak_cpu_percent": (detail.get("resources") or {}).get("peak_cpu_percent"), + "resource_sample_count": (detail.get("resources") or {}).get("sample_count"), + "trace_record_count": detail.get("trace_record_count"), + "visible_shape_sha256": ( + _shape_digest(detail["visible_shape"]) if detail.get("visible_shape") else None + ), + "owner_shape_sha256": ( + _shape_digest(detail["owner_shape"]) if detail.get("owner_shape") else None + ), + "errors": " | ".join(detail.get("errors") or []), + } + for detail in details + ] + individual_failures = sum(1 for detail in details if not detail.get("ok")) + cross_frontend_failures = sum(1 for row in cross_frontend_rows if not row["ok"]) + summary: dict[str, Any] = { + "suite": "navigation-trace", + "runs": runs, + "timeout_seconds": timeout_seconds, + "frontends": {}, + "cross_frontend": cross_frontend_rows, + "individual_failures": individual_failures, + "cross_frontend_failures": cross_frontend_failures, + "total_failures": individual_failures + cross_frontend_failures, + "gate_failures": individual_failures + cross_frontend_failures, + } + for frontend in NAVIGATION_TRACE_FRONTENDS: + frontend_rows = [row for row in rows if row["frontend"] == frontend] + summary["frontends"][frontend] = { + "navigation_elapsed_ms": summarize( + row["navigation_elapsed_ms"] + for row in frontend_rows + if row.get("ok") and row.get("navigation_elapsed_ms") is not None + ), + "peak_pss_bytes": summarize( + row["peak_pss_bytes"] + for row in frontend_rows + if row.get("ok") and row.get("peak_pss_bytes") is not None + ), + "peak_rss_bytes": summarize( + row["peak_rss_bytes"] + for row in frontend_rows + if row.get("ok") and row.get("peak_rss_bytes") is not None + ), + "peak_cpu_percent": summarize( + row["peak_cpu_percent"] + for row in frontend_rows + if row.get("ok") and row.get("peak_cpu_percent") is not None + ), + "resource_sample_count": summarize( + row["resource_sample_count"] + for row in frontend_rows + if row.get("ok") and row.get("resource_sample_count") is not None + ), + "failures": sum(1 for row in frontend_rows if not row.get("ok")), + } + + write_csv(suite_dir / "runs.csv", rows) + write_json(suite_dir / "runs.json", details) + write_json(suite_dir / "cross-frontend.json", cross_frontend_rows) + write_json(suite_dir / "summary.json", summary) + return summary diff --git a/moli-benchmark/moli_benchmark/navigation_trace_fixture.py b/moli-benchmark/moli_benchmark/navigation_trace_fixture.py new file mode 100644 index 0000000000..6a052a3ec3 --- /dev/null +++ b/moli-benchmark/moli_benchmark/navigation_trace_fixture.py @@ -0,0 +1,172 @@ +"""Deterministic HTTP source for exact-Document navigation traces.""" + +from __future__ import annotations + +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + + +class NavigationTraceFixture: + def __init__(self) -> None: + self._condition = threading.Condition() + self._requests: list[dict[str, Any]] = [] + self._sequence = 0 + self.httpd = _NavigationTraceHttpServer(("127.0.0.1", 0), _NavigationTraceHandler) + self.httpd.fixture = self + self.port = int(self.httpd.server_address[1]) + self.thread = threading.Thread( + target=self.httpd.serve_forever, + name="moli-navigation-trace-fixture", + daemon=True, + ) + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self.port}" + + def url(self, stage: str, run_token: str) -> str: + return f"{self.base_url}/navigation-trace/{stage}?run={run_token}" + + def __enter__(self) -> NavigationTraceFixture: + self.thread.start() + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + self.httpd.shutdown() + self.httpd.server_close() + self.thread.join(timeout=2) + + def record(self, run_token: str, stage: str, status: int) -> None: + with self._condition: + self._sequence += 1 + self._requests.append( + { + "sequence": self._sequence, + "run": run_token, + "stage": stage, + "status": status, + "completed_monotonic_ns": time.monotonic_ns(), + } + ) + self._condition.notify_all() + + def wait_for(self, run_token: str, stage: str, timeout_seconds: float) -> None: + deadline = time.monotonic() + timeout_seconds + with self._condition: + while not any( + request["run"] == run_token and request["stage"] == stage + for request in self._requests + ): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"timed out waiting for fixture stage {stage!r} in run {run_token!r}") + self._condition.wait(remaining) + + def request_records(self, run_token: str) -> list[dict[str, Any]]: + with self._condition: + return [dict(request) for request in self._requests if request["run"] == run_token] + + def navigation_request_stages(self, run_token: str) -> list[str]: + stages = [request["stage"] for request in self.request_records(run_token)] + try: + first_navigation = stages.index("a") + except ValueError: + return stages + return stages[first_navigation:] + + +class _NavigationTraceHttpServer(ThreadingHTTPServer): + fixture: NavigationTraceFixture + + +class _NavigationTraceHandler(BaseHTTPRequestHandler): + server: _NavigationTraceHttpServer + + def do_GET(self) -> None: + parsed = urlparse(self.path) + run_token = parse_qs(parsed.query).get("run", [""])[0] + stage = parsed.path.removeprefix("/navigation-trace/") + if not run_token or stage not in {"bootstrap", "ready", "a", "b", "complete"}: + self.send_error(404) + return + if stage == "bootstrap": + self._send_html(_bootstrap_document()) + self.server.fixture.record(run_token, stage, 200) + return + if stage == "a": + self._send_html(_navigation_a_document()) + self.server.fixture.record(run_token, stage, 200) + return + if stage == "b": + self._send_html(_navigation_b_document()) + self.server.fixture.record(run_token, stage, 200) + return + self.send_response(204) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", "0") + self.end_headers() + self.server.fixture.record(run_token, stage, 204) + + def _send_html(self, body: bytes) -> None: + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + try: + self.wfile.write(body) + except BrokenPipeError: + return + + def log_message(self, format: str, *args: object) -> None: + return + + +def _bootstrap_document() -> bytes: + return _trace_document( + "Trace Bootstrap", + "bootstrap", + """ + addEventListener('load', () => { + const run = new URL(location.href).searchParams.get('run') || ''; + void fetch('/navigation-trace/ready?run=' + encodeURIComponent(run), {cache: 'no-store'}); + }, {once: true}); + """, + ) + + +def _navigation_a_document() -> bytes: + return _trace_document( + "Trace A", + "a", + """ + addEventListener('load', () => { + const run = new URL(location.href).searchParams.get('run') || ''; + location.href = '/navigation-trace/b?run=' + encodeURIComponent(run); + }, {once: true}); + """, + ) + + +def _navigation_b_document() -> bytes: + return _trace_document( + "Trace B", + "b", + """ + addEventListener('load', () => { + const run = new URL(location.href).searchParams.get('run') || ''; + void fetch('/navigation-trace/complete?run=' + encodeURIComponent(run), {cache: 'no-store'}); + }, {once: true}); + """, + ) + + +def _trace_document(title: str, phase: str, script: str) -> bytes: + return ( + "" + f"{title}" + f'
{title}
' + ).encode("utf-8") diff --git a/moli-benchmark/moli_benchmark/navigation_trace_frontends.py b/moli-benchmark/moli_benchmark/navigation_trace_frontends.py new file mode 100644 index 0000000000..8ce78e2d4a --- /dev/null +++ b/moli-benchmark/moli_benchmark/navigation_trace_frontends.py @@ -0,0 +1,618 @@ +"""CLI, CDP, BiDi, and Classic consumers for the navigation trace fixture.""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse + +import websockets + +from .config import REPO_ROOT, clear_proxy_env +from .navigation_trace_fixture import NavigationTraceFixture +from .process import run_process +from .raw_cdp import RawCdpClient, connect_raw_cdp +from .serve import start_moli_serve, stop_moli_serve + +TRACE_ENV = "MOLI_BROWSER_OWNER_TRACE_JSONL" +TRACE_RESOURCE_SAMPLE_INTERVAL_SECONDS = 0.01 +FINAL_OBSERVATION_EXPRESSION = ( + "JSON.stringify({url: location.href, title: document.title, " + "phase: document.body && document.body.dataset.tracePhase})" +) + + +def _trace_offset(path: Path) -> int: + try: + return path.stat().st_size + except OSError: + return 0 + + +def _prepare_trace_path(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.unlink(missing_ok=True) + + +def _url_stage(url: str) -> str | None: + path = urlparse(url).path + prefix = "/navigation-trace/" + if not path.startswith(prefix): + return None + stage = path.removeprefix(prefix) + return stage if stage in {"bootstrap", "a", "b"} else None + + +def _final_observation_from_html(final_url: str, html: str) -> dict[str, str]: + title = "Trace B" if "Trace B" in html else "" + return {"url": final_url, "title": title, "phase": "b" if "data-trace-phase=\"b\"" in html else ""} + + +def _run_cli_frontend( + moli_bin: Path, + fixture: NavigationTraceFixture, + run_token: str, + trace_path: Path, + timeout_seconds: float, +) -> dict[str, Any]: + env = clear_proxy_env(os.environ) + env[TRACE_ENV] = str(trace_path) + result = run_process( + [ + str(moli_bin), + "fetch", + "--dump", + "json", + "--wait-until", + "done", + "--timeout", + str(int(timeout_seconds * 1000)), + fixture.url("a", run_token), + ], + cwd=REPO_ROOT, + timeout_seconds=timeout_seconds + 1, + env=env, + resource_sample_interval_seconds=TRACE_RESOURCE_SAMPLE_INTERVAL_SECONDS, + ) + if result.returncode != 0 or result.timed_out: + raise RuntimeError(f"CLI navigation failed: {result.json_summary(include_output=True)}") + payload = json.loads(result.stdout) + if not isinstance(payload, dict): + raise RuntimeError(f"CLI fetch returned unexpected JSON: {payload!r}") + fixture.wait_for(run_token, "complete", timeout_seconds) + observation = _final_observation_from_html(str(payload.get("final_url") or ""), str(payload.get("html") or "")) + return { + "navigation_elapsed_ms": result.elapsed_ms, + "latency_scope": "cold-process-to-successor-load", + "final_observation": observation, + "protocol_lifecycle": [], + "trace_offset": 0, + "resources": result.resources, + "process": result.json_summary(include_output=False), + "followup_commands_before_terminal": 0, + } + + +async def _run_cdp_frontend( + moli_bin: Path, + fixture: NavigationTraceFixture, + run_token: str, + trace_path: Path, + timeout_seconds: float, +) -> dict[str, Any]: + handle = start_moli_serve( + moli_bin, + timeout_seconds, + env_overrides={TRACE_ENV: str(trace_path)}, + resource_sample_interval_seconds=TRACE_RESOURCE_SAMPLE_INTERVAL_SECONDS, + ) + client: RawCdpClient | None = None + stop_summary: dict[str, Any] = {} + result_payload: dict[str, Any] | None = None + try: + client = await connect_raw_cdp(handle.endpoint) + target_id = await _cdp_create_target(client, timeout_seconds) + session_id = await _cdp_attach_target(client, target_id, timeout_seconds) + for method, params in ( + ("Page.enable", None), + ("Page.setLifecycleEventsEnabled", {"enabled": True}), + ("Runtime.enable", None), + ): + command_id = await client.send(method, params, session_id=session_id) + await client.recv_until_id(command_id, timeout=timeout_seconds) + + bootstrap_id = await client.send( + "Page.navigate", + {"url": fixture.url("bootstrap", run_token)}, + session_id=session_id, + ) + bootstrap_response, bootstrap_messages = await client.recv_until_id( + bootstrap_id, timeout=timeout_seconds + ) + bootstrap_loader = bootstrap_response.get("result", {}).get("loaderId") + if not isinstance(bootstrap_loader, str): + raise RuntimeError(f"CDP bootstrap navigation lacked loaderId: {bootstrap_response}") + await _recv_cdp_until( + client, + bootstrap_messages, + lambda messages: _cdp_has_stage_load(messages, {bootstrap_loader: "bootstrap"}, "bootstrap"), + timeout_seconds, + ) + await asyncio.to_thread(fixture.wait_for, run_token, "ready", timeout_seconds) + # This pre-navigation command is only a bootstrap trace barrier. No + # command is sent after the measured A navigation until B reaches load. + barrier_id = await client.send( + "Runtime.evaluate", + {"expression": "0", "returnByValue": True}, + session_id=session_id, + ) + await client.recv_until_id(barrier_id, timeout=timeout_seconds) + trace_offset = _trace_offset(trace_path) + + started = time.perf_counter() + navigate_id = await client.send( + "Page.navigate", + {"url": fixture.url("a", run_token)}, + session_id=session_id, + ) + navigate_response, navigation_messages = await client.recv_until_id( + navigate_id, timeout=timeout_seconds + ) + loader_a = navigate_response.get("result", {}).get("loaderId") + if not isinstance(loader_a, str): + raise RuntimeError(f"CDP navigation lacked loaderId: {navigate_response}") + await _recv_cdp_until( + client, + navigation_messages, + lambda messages: _cdp_has_stage_load(messages, {loader_a: "a"}, "b"), + timeout_seconds, + ) + await asyncio.to_thread(fixture.wait_for, run_token, "complete", timeout_seconds) + navigation_elapsed_ms = (time.perf_counter() - started) * 1000.0 + protocol_lifecycle = _cdp_lifecycle_shape(navigation_messages, {loader_a: "a"}) + + evaluate_id = await client.send( + "Runtime.evaluate", + { + "expression": FINAL_OBSERVATION_EXPRESSION, + "returnByValue": True, + }, + session_id=session_id, + ) + evaluate, _ = await client.recv_until_id(evaluate_id, timeout=timeout_seconds) + value = evaluate.get("result", {}).get("result", {}).get("value") + if not isinstance(value, str): + raise RuntimeError(f"CDP final observation was not a string: {evaluate}") + final_observation = json.loads(value) + close_id = await client.send("Target.closeTarget", {"targetId": target_id}) + await client.recv_until_id(close_id, timeout=timeout_seconds) + result_payload = { + "navigation_elapsed_ms": navigation_elapsed_ms, + "latency_scope": "warm-host-command-to-successor-load", + "final_observation": final_observation, + "protocol_lifecycle": protocol_lifecycle, + "trace_offset": trace_offset, + "ready_ms": handle.ready_ms, + "followup_commands_before_terminal": 0, + } + finally: + if client is not None: + await client.websocket.close() + stop_summary = stop_moli_serve(handle) + if result_payload is None: + raise RuntimeError("CDP navigation completed without a result") + result_payload["resources"] = stop_summary.get("resources", {}) + result_payload["serve"] = stop_summary + return result_payload + + +async def _cdp_create_target(client: RawCdpClient, timeout_seconds: float) -> str: + command_id = await client.send("Target.createTarget", {"url": "about:blank"}) + response, _ = await client.recv_until_id(command_id, timeout=timeout_seconds) + target_id = response.get("result", {}).get("targetId") + if not isinstance(target_id, str) or not target_id: + raise RuntimeError(f"Target.createTarget lacked targetId: {response}") + return target_id + + +async def _cdp_attach_target(client: RawCdpClient, target_id: str, timeout_seconds: float) -> str: + command_id = await client.send("Target.attachToTarget", {"targetId": target_id, "flatten": True}) + response, _ = await client.recv_until_id(command_id, timeout=timeout_seconds) + session_id = response.get("result", {}).get("sessionId") + if not isinstance(session_id, str) or not session_id: + raise RuntimeError(f"Target.attachToTarget lacked sessionId: {response}") + return session_id + + +async def _recv_cdp_until( + client: RawCdpClient, + messages: list[dict[str, Any]], + predicate: Callable[[list[dict[str, Any]]], bool], + timeout_seconds: float, +) -> None: + deadline = asyncio.get_running_loop().time() + timeout_seconds + while not predicate(messages): + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError(f"timed out waiting for CDP lifecycle; tail={messages[-20:]}") + messages.append(await asyncio.wait_for(client.recv(), timeout=remaining)) + + +def _cdp_loader_stages( + messages: list[dict[str, Any]], seed: dict[str, str] +) -> dict[str, str]: + loader_stages = dict(seed) + for message in messages: + if message.get("method") != "Page.frameNavigated": + continue + frame = message.get("params", {}).get("frame", {}) + loader_id = frame.get("loaderId") + stage = _url_stage(str(frame.get("url") or "")) + if isinstance(loader_id, str) and stage is not None: + loader_stages[loader_id] = stage + return loader_stages + + +def _cdp_has_stage_load( + messages: list[dict[str, Any]], seed: dict[str, str], expected_stage: str +) -> bool: + loader_stages = _cdp_loader_stages(messages, seed) + return any( + message.get("method") == "Page.lifecycleEvent" + and message.get("params", {}).get("name") == "load" + and loader_stages.get(message.get("params", {}).get("loaderId")) == expected_stage + for message in messages + ) + + +def _cdp_lifecycle_shape( + messages: list[dict[str, Any]], seed: dict[str, str] +) -> list[str]: + loader_stages = _cdp_loader_stages(messages, seed) + lifecycle: list[str] = [] + for message in messages: + if message.get("method") != "Page.lifecycleEvent": + continue + params = message.get("params", {}) + stage = loader_stages.get(params.get("loaderId")) + name = params.get("name") + if stage in {"a", "b"} and name in {"DOMContentLoaded", "load"}: + lifecycle.append(f"{stage}:{'dom-content-loaded' if name == 'DOMContentLoaded' else 'load'}") + return lifecycle + + +async def _run_bidi_frontend( + moli_bin: Path, + fixture: NavigationTraceFixture, + run_token: str, + trace_path: Path, + timeout_seconds: float, +) -> dict[str, Any]: + handle = start_moli_serve( + moli_bin, + timeout_seconds, + env_overrides={TRACE_ENV: str(trace_path)}, + resource_sample_interval_seconds=TRACE_RESOURCE_SAMPLE_INTERVAL_SECONDS, + ) + websocket_url = handle.endpoint.replace("http://", "ws://", 1).rstrip("/") + "/session" + stop_summary: dict[str, Any] = {} + result_payload: dict[str, Any] | None = None + try: + async with websockets.connect(websocket_url, proxy=None, max_size=2**24) as websocket: + await _bidi_command(websocket, 1, "session.status", {}, timeout_seconds) + await _bidi_command(websocket, 2, "session.new", {"capabilities": {}}, timeout_seconds) + create, _ = await _bidi_command( + websocket, 3, "browsingContext.create", {"type": "tab"}, timeout_seconds + ) + context = create.get("result", {}).get("context") + if not isinstance(context, str) or not context: + raise RuntimeError(f"BiDi browsingContext.create lacked context: {create}") + await _bidi_command( + websocket, + 4, + "session.subscribe", + { + "events": [ + "browsingContext.navigationStarted", + "browsingContext.domContentLoaded", + "browsingContext.load", + ], + "contexts": [context], + }, + timeout_seconds, + ) + _, bootstrap_messages = await _bidi_command( + websocket, + 5, + "browsingContext.navigate", + {"context": context, "url": fixture.url("bootstrap", run_token), "wait": "complete"}, + timeout_seconds, + ) + await _recv_bidi_until( + websocket, + bootstrap_messages, + lambda messages: _bidi_has_stage_load(messages, "bootstrap"), + timeout_seconds, + ) + await asyncio.to_thread(fixture.wait_for, run_token, "ready", timeout_seconds) + # Keep bootstrap records outside the measured trace without using + # a post-navigation heartbeat to advance Browser Owner work. + await _bidi_command( + websocket, + 6, + "script.evaluate", + { + "expression": "0", + "target": {"context": context}, + "awaitPromise": False, + }, + timeout_seconds, + ) + trace_offset = _trace_offset(trace_path) + + started = time.perf_counter() + _, navigation_messages = await _bidi_command( + websocket, + 7, + "browsingContext.navigate", + {"context": context, "url": fixture.url("a", run_token), "wait": "complete"}, + timeout_seconds, + ) + await _recv_bidi_until( + websocket, + navigation_messages, + lambda messages: _bidi_has_stage_load(messages, "b"), + timeout_seconds, + ) + await asyncio.to_thread(fixture.wait_for, run_token, "complete", timeout_seconds) + navigation_elapsed_ms = (time.perf_counter() - started) * 1000.0 + protocol_lifecycle = _bidi_lifecycle_shape(navigation_messages) + + evaluate, _ = await _bidi_command( + websocket, + 8, + "script.evaluate", + { + "expression": FINAL_OBSERVATION_EXPRESSION, + "target": {"context": context}, + "awaitPromise": False, + }, + timeout_seconds, + ) + value = evaluate.get("result", {}).get("result", {}).get("value") + if not isinstance(value, str): + raise RuntimeError(f"BiDi final observation was not a string: {evaluate}") + final_observation = json.loads(value) + await _bidi_command(websocket, 9, "session.end", {}, timeout_seconds) + result_payload = { + "navigation_elapsed_ms": navigation_elapsed_ms, + "latency_scope": "warm-host-command-to-successor-load", + "final_observation": final_observation, + "protocol_lifecycle": protocol_lifecycle, + "trace_offset": trace_offset, + "ready_ms": handle.ready_ms, + "followup_commands_before_terminal": 0, + } + finally: + stop_summary = stop_moli_serve(handle) + if result_payload is None: + raise RuntimeError("BiDi navigation completed without a result") + result_payload["resources"] = stop_summary.get("resources", {}) + result_payload["serve"] = stop_summary + return result_payload + + +async def _bidi_command( + websocket: Any, + command_id: int, + method: str, + params: dict[str, Any], + timeout_seconds: float, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + await websocket.send( + json.dumps({"id": command_id, "method": method, "params": params}, separators=(",", ":")) + ) + messages: list[dict[str, Any]] = [] + deadline = asyncio.get_running_loop().time() + timeout_seconds + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError(f"timed out waiting for BiDi command {method}; tail={messages[-20:]}") + raw = await asyncio.wait_for(websocket.recv(), timeout=remaining) + message = json.loads(raw) + if not isinstance(message, dict): + raise RuntimeError(f"unexpected BiDi message: {message!r}") + messages.append(message) + if message.get("id") == command_id: + if message.get("type") != "success": + raise RuntimeError(f"BiDi command {method} failed: {message}") + return message, messages + + +async def _recv_bidi_until( + websocket: Any, + messages: list[dict[str, Any]], + predicate: Callable[[list[dict[str, Any]]], bool], + timeout_seconds: float, +) -> None: + deadline = asyncio.get_running_loop().time() + timeout_seconds + while not predicate(messages): + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError(f"timed out waiting for BiDi lifecycle; tail={messages[-20:]}") + raw = await asyncio.wait_for(websocket.recv(), timeout=remaining) + message = json.loads(raw) + if isinstance(message, dict): + messages.append(message) + + +def _bidi_has_stage_load(messages: list[dict[str, Any]], expected_stage: str) -> bool: + return any( + message.get("method") == "browsingContext.load" + and _url_stage(str(message.get("params", {}).get("url") or "")) == expected_stage + for message in messages + ) + + +def _bidi_lifecycle_shape(messages: list[dict[str, Any]]) -> list[str]: + lifecycle: list[str] = [] + for message in messages: + method = message.get("method") + if method not in {"browsingContext.domContentLoaded", "browsingContext.load"}: + continue + stage = _url_stage(str(message.get("params", {}).get("url") or "")) + if stage in {"a", "b"}: + lifecycle.append( + f"{stage}:{'dom-content-loaded' if method.endswith('domContentLoaded') else 'load'}" + ) + return lifecycle + + +def _run_classic_frontend( + moli_bin: Path, + fixture: NavigationTraceFixture, + run_token: str, + trace_path: Path, + timeout_seconds: float, +) -> dict[str, Any]: + handle = start_moli_serve( + moli_bin, + timeout_seconds, + env_overrides={TRACE_ENV: str(trace_path)}, + resource_sample_interval_seconds=TRACE_RESOURCE_SAMPLE_INTERVAL_SECONDS, + ) + session_id: str | None = None + stop_summary: dict[str, Any] = {} + result_payload: dict[str, Any] | None = None + try: + session = _webdriver_request( + handle.endpoint, "POST", "/session", {"capabilities": {"alwaysMatch": {}}}, timeout_seconds + ) + value = session.get("value") + session_id = value.get("sessionId") if isinstance(value, dict) else None + if not isinstance(session_id, str) or not session_id: + raise RuntimeError(f"Classic session creation lacked sessionId: {session}") + _webdriver_request( + handle.endpoint, + "POST", + f"/session/{session_id}/url", + {"url": fixture.url("bootstrap", run_token)}, + timeout_seconds, + ) + fixture.wait_for(run_token, "ready", timeout_seconds) + # Classic has no lifecycle subscription, so a pre-navigation title + # read provides the same bootstrap/trace cut as the protocol runners. + _webdriver_request( + handle.endpoint, + "GET", + f"/session/{session_id}/title", + None, + timeout_seconds, + ) + trace_offset = _trace_offset(trace_path) + + started = time.perf_counter() + _webdriver_request( + handle.endpoint, + "POST", + f"/session/{session_id}/url", + {"url": fixture.url("a", run_token)}, + timeout_seconds, + ) + fixture.wait_for(run_token, "complete", timeout_seconds) + navigation_elapsed_ms = (time.perf_counter() - started) * 1000.0 + final_url = _webdriver_request( + handle.endpoint, "GET", f"/session/{session_id}/url", None, timeout_seconds + ).get("value") + final_title = _webdriver_request( + handle.endpoint, "GET", f"/session/{session_id}/title", None, timeout_seconds + ).get("value") + source = _webdriver_request( + handle.endpoint, "GET", f"/session/{session_id}/source", None, timeout_seconds + ).get("value") + result_payload = { + "navigation_elapsed_ms": navigation_elapsed_ms, + "latency_scope": "warm-host-command-to-successor-load", + "final_observation": { + "url": final_url, + "title": final_title, + "phase": "b" if isinstance(source, str) and "data-trace-phase=\"b\"" in source else "", + }, + "protocol_lifecycle": [], + "trace_offset": trace_offset, + "ready_ms": handle.ready_ms, + "followup_commands_before_terminal": 0, + } + finally: + if session_id is not None: + try: + _webdriver_request( + handle.endpoint, + "DELETE", + f"/session/{session_id}", + None, + timeout_seconds, + ) + except Exception: + pass + stop_summary = stop_moli_serve(handle) + if result_payload is None: + raise RuntimeError("Classic navigation completed without a result") + result_payload["resources"] = stop_summary.get("resources", {}) + result_payload["serve"] = stop_summary + return result_payload + + +def _webdriver_request( + endpoint: str, + method: str, + path: str, + body: dict[str, Any] | None, + timeout_seconds: float, +) -> dict[str, Any]: + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + request = urllib.request.Request(endpoint.rstrip("/") + path, data=data, headers=headers, method=method) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + with opener.open(request, timeout=timeout_seconds) as response: + payload = response.read() + status = response.status + except urllib.error.HTTPError as error: + payload = error.read() + status = error.code + parsed = json.loads(payload.decode("utf-8")) + if status != 200 or not isinstance(parsed, dict): + raise RuntimeError(f"WebDriver {method} {path} failed with {status}: {parsed!r}") + return parsed + + +def _run_frontend( + frontend: str, + moli_bin: Path, + fixture: NavigationTraceFixture, + run_token: str, + trace_path: Path, + timeout_seconds: float, +) -> dict[str, Any]: + if frontend == "cli": + return _run_cli_frontend(moli_bin, fixture, run_token, trace_path, timeout_seconds) + if frontend == "cdp": + return asyncio.run( + _run_cdp_frontend(moli_bin, fixture, run_token, trace_path, timeout_seconds) + ) + if frontend == "bidi": + return asyncio.run( + _run_bidi_frontend(moli_bin, fixture, run_token, trace_path, timeout_seconds) + ) + if frontend == "classic": + return _run_classic_frontend(moli_bin, fixture, run_token, trace_path, timeout_seconds) + raise RuntimeError(f"unknown navigation trace frontend: {frontend}") diff --git a/moli-benchmark/moli_benchmark/navigation_trace_records.py b/moli-benchmark/moli_benchmark/navigation_trace_records.py new file mode 100644 index 0000000000..893769febd --- /dev/null +++ b/moli-benchmark/moli_benchmark/navigation_trace_records.py @@ -0,0 +1,104 @@ +"""Schema validation and ephemeral-id normalization for Browser Owner JSONL.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def load_browser_owner_trace(path: Path, offset: int = 0) -> list[dict[str, Any]]: + if not path.exists(): + raise RuntimeError(f"Browser Owner trace was not created: {path}") + with path.open("rb") as trace_file: + trace_file.seek(offset) + payload = trace_file.read() + if payload and not payload.endswith(b"\n"): + raise RuntimeError(f"Browser Owner trace ended with a partial JSONL record: {path}") + records: list[dict[str, Any]] = [] + for line_number, raw_line in enumerate(payload.splitlines(), start=1): + try: + record = json.loads(raw_line) + except json.JSONDecodeError as error: + raise RuntimeError(f"invalid Browser Owner JSONL record {line_number}: {error}") from error + if not isinstance(record, dict) or record.get("schema_version") != 1: + raise RuntimeError(f"unsupported Browser Owner trace record: {record!r}") + records.append(record) + return records + + +def normalize_lifecycle_milestones(records: list[dict[str, Any]]) -> list[str]: + document_labels: dict[tuple[int, int, int], str] = {} + milestones: list[str] = [] + for record in records: + if record.get("stage") != "renderer_lifecycle_reached": + continue + kind = record.get("renderer_lifecycle_kind") + if kind not in {"dom-content-loaded", "load"}: + continue + document_key = _trace_document_key(record) + if document_key is None: + continue + label = document_labels.setdefault(document_key, f"D{len(document_labels) + 1}") + milestones.append(f"{label}:{kind}") + return milestones + + +def normalize_owner_shape(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + action_labels: dict[int, str] = {} + request_labels: dict[int, str] = {} + page_labels: dict[int, str] = {} + document_labels: dict[tuple[int, int, int], str] = {} + normalized: list[dict[str, Any]] = [] + for record in records: + action_id = record.get("browser_action_id") + stage = record.get("stage") + if not isinstance(action_id, int) or not isinstance(stage, str) or stage.startswith("frontend_"): + continue + action = action_labels.setdefault(action_id, f"A{len(action_labels) + 1}") + request_id = record.get("navigation_request_id") + request = ( + request_labels.setdefault(request_id, f"R{len(request_labels) + 1}") + if isinstance(request_id, int) + else None + ) + page_generation = record.get("page_residence_generation") + page = ( + page_labels.setdefault(page_generation, f"P{len(page_labels) + 1}") + if isinstance(page_generation, int) + else None + ) + document_key = _trace_document_key(record) + document = ( + document_labels.setdefault(document_key, f"D{len(document_labels) + 1}") + if document_key is not None + else None + ) + normalized.append( + { + "stage": stage, + "source": record.get("source"), + "origin": record.get("navigation_origin"), + "before": record.get("owner_state_before"), + "after": record.get("owner_state_after"), + "action": action, + "request": request, + "page": page, + "document": document, + } + ) + return normalized + + +def _trace_document_key(record: dict[str, Any]) -> tuple[int, int, int] | None: + document = record.get("document_lifecycle_identity") + if not isinstance(document, dict): + return None + values = ( + document.get("renderer_page_id"), + document.get("document_generation"), + document.get("lifecycle_epoch"), + ) + if not all(isinstance(value, int) for value in values): + return None + return int(values[0]), int(values[1]), int(values[2]) diff --git a/moli-benchmark/moli_benchmark/process.py b/moli-benchmark/moli_benchmark/process.py index b26156112a..dceb76c047 100644 --- a/moli-benchmark/moli_benchmark/process.py +++ b/moli-benchmark/moli_benchmark/process.py @@ -156,6 +156,7 @@ def run_process( timeout_seconds: float, env: dict[str, str] | None = None, sample_resources: bool = True, + resource_sample_interval_seconds: float = 0.1, time_verbose_path: Path | None = None, cgroup_artifact_dir: Path | None = None, ) -> ProcessResult: @@ -169,7 +170,11 @@ def run_process( stderr=subprocess.PIPE, start_new_session=True, ) - sampler = ResourceSampler(process.pid) if sample_resources else None + sampler = ( + ResourceSampler(process.pid, interval_seconds=resource_sample_interval_seconds) + if sample_resources + else None + ) cgroup = collect_cgroup_artifacts(process.pid, cgroup_artifact_dir) if cgroup_artifact_dir is not None else None if sampler is not None: sampler.start() diff --git a/moli-benchmark/moli_benchmark/serve.py b/moli-benchmark/moli_benchmark/serve.py index e5a71c168f..de195ecdb7 100644 --- a/moli-benchmark/moli_benchmark/serve.py +++ b/moli-benchmark/moli_benchmark/serve.py @@ -121,6 +121,8 @@ def start_moli_serve( *, time_verbose_path: Path | None = None, cgroup_artifact_dir: Path | None = None, + env_overrides: dict[str, str] | None = None, + resource_sample_interval_seconds: float = 0.1, ) -> ServeHandle: logs: list[str] = [] reserved_port = reserve_port() @@ -139,10 +141,13 @@ def start_moli_serve( time_verbose_path, ) reserved_port.release_socket() + env = clear_proxy_env(os.environ) + if env_overrides is not None: + env.update(env_overrides) process = subprocess.Popen( command, cwd=REPO_ROOT, - env=clear_proxy_env(os.environ), + env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, @@ -151,7 +156,10 @@ def start_moli_serve( except BaseException: reserved_port.close() raise - sampler = ResourceSampler(process.pid) + sampler = ResourceSampler( + process.pid, + interval_seconds=resource_sample_interval_seconds, + ) sampler.start() handle = ServeHandle( process=process, diff --git a/moli-benchmark/tests/test_cli.py b/moli-benchmark/tests/test_cli.py index da73439939..717406a5ca 100644 --- a/moli-benchmark/tests/test_cli.py +++ b/moli-benchmark/tests/test_cli.py @@ -159,6 +159,24 @@ def test_agent_episode_has_no_stress_or_live_profile(self) -> None: with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): parser.parse_args(["agent-episode", "--live"]) + def test_navigation_trace_command_uses_common_run_controls(self) -> None: + args = build_parser().parse_args( + [ + "navigation-trace", + "--moli-bin", + "/tmp/moli", + "--runs", + "3", + "--timeout", + "12", + ] + ) + + self.assertEqual(args.command, "navigation-trace") + self.assertEqual(args.moli_bin, "/tmp/moli") + self.assertEqual(args.runs, 3) + self.assertEqual(args.timeout, 12.0) + def test_finish_report_builds_readiness_after_top_level_artifacts_exist(self) -> None: summaries = [ { diff --git a/moli-benchmark/tests/test_navigation_trace.py b/moli-benchmark/tests/test_navigation_trace.py new file mode 100644 index 0000000000..ff48ee8e45 --- /dev/null +++ b/moli-benchmark/tests/test_navigation_trace.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +import urllib.request +from pathlib import Path + +from moli_benchmark.navigation_trace import ( + EXPECTED_LIFECYCLE_MILESTONES, + NavigationTraceFixture, + _navigation_a_document, + load_browser_owner_trace, + normalize_lifecycle_milestones, + normalize_owner_shape, +) + + +def _document(page_id: int, generation: int, epoch: int) -> dict[str, int]: + return { + "renderer_page_id": page_id, + "document_generation": generation, + "lifecycle_epoch": epoch, + } + + +class NavigationTraceTests(unittest.TestCase): + def test_fixture_records_exact_navigation_request_order(self) -> None: + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with NavigationTraceFixture() as fixture: + for stage in ("a", "b", "complete"): + with opener.open(fixture.url(stage, "unit-run"), timeout=2) as response: + response.read() + fixture.wait_for("unit-run", "complete", 2) + + self.assertEqual( + fixture.navigation_request_stages("unit-run"), + ["a", "b", "complete"], + ) + + def test_fixture_navigation_is_inside_load_boundary_not_future_timer(self) -> None: + source = _navigation_a_document().decode("utf-8") + + self.assertIn("addEventListener('load'", source) + self.assertIn("location.href = '/navigation-trace/b", source) + self.assertNotIn("setTimeout", source) + + def test_jsonl_loader_honors_record_boundary_offset(self) -> None: + first = {"schema_version": 1, "stage": "bootstrap"} + second = {"schema_version": 1, "stage": "measured"} + first_line = (json.dumps(first) + "\n").encode("utf-8") + with tempfile.TemporaryDirectory() as temp_dir: + trace_path = Path(temp_dir) / "trace.jsonl" + trace_path.write_bytes(first_line + (json.dumps(second) + "\n").encode("utf-8")) + + records = load_browser_owner_trace(trace_path, len(first_line)) + + self.assertEqual(records, [second]) + + def test_jsonl_loader_rejects_partial_record(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + trace_path = Path(temp_dir) / "trace.jsonl" + trace_path.write_text('{"schema_version":1}', encoding="utf-8") + + with self.assertRaisesRegex(RuntimeError, "partial JSONL record"): + load_browser_owner_trace(trace_path) + + def test_lifecycle_normalization_keeps_exact_document_order(self) -> None: + records = [ + { + "stage": "renderer_lifecycle_reached", + "renderer_lifecycle_kind": "started", + "document_lifecycle_identity": _document(9, 4, 1), + }, + { + "stage": "renderer_lifecycle_reached", + "renderer_lifecycle_kind": "dom-content-loaded", + "document_lifecycle_identity": _document(9, 4, 1), + }, + { + "stage": "renderer_lifecycle_reached", + "renderer_lifecycle_kind": "load", + "document_lifecycle_identity": _document(9, 4, 1), + }, + { + "stage": "renderer_lifecycle_reached", + "renderer_lifecycle_kind": "terminated", + "document_lifecycle_identity": _document(9, 4, 1), + }, + { + "stage": "renderer_lifecycle_reached", + "renderer_lifecycle_kind": "dom-content-loaded", + "document_lifecycle_identity": _document(9, 5, 1), + }, + { + "stage": "renderer_lifecycle_reached", + "renderer_lifecycle_kind": "load", + "document_lifecycle_identity": _document(9, 5, 1), + }, + ] + + self.assertEqual( + normalize_lifecycle_milestones(records), + list(EXPECTED_LIFECYCLE_MILESTONES), + ) + + def test_owner_shape_removes_ephemeral_ids_and_frontend_projection(self) -> None: + records = [ + { + "stage": "browser_action_published", + "source": "frontend-command", + "navigation_origin": "frontend-command", + "owner_state_before": "frontend", + "owner_state_after": "browser-owner-inbox", + "browser_action_id": 91, + "navigation_request_id": None, + "page_residence_generation": 7, + "document_lifecycle_identity": _document(11, 3, 1), + }, + { + "stage": "navigation_request_started", + "source": "frontend-command", + "navigation_origin": "frontend-command", + "owner_state_before": "browser-owner", + "owner_state_after": "request-pending", + "browser_action_id": 91, + "navigation_request_id": 42, + "page_residence_generation": 7, + "document_lifecycle_identity": _document(11, 3, 1), + }, + { + "stage": "frontend_load_projected", + "source": "lifecycle", + "browser_action_id": 91, + }, + { + "stage": "renderer_intent_published", + "source": "renderer-intent", + "navigation_origin": "renderer-intent", + "owner_state_before": "renderer-task", + "owner_state_after": "renderer-output", + "browser_action_id": 117, + "navigation_request_id": 99, + "page_residence_generation": 8, + "document_lifecycle_identity": _document(11, 4, 1), + }, + ] + + normalized = normalize_owner_shape(records) + + self.assertEqual( + [(record["action"], record["request"], record["page"], record["document"]) for record in normalized], + [ + ("A1", None, "P1", "D1"), + ("A1", "R1", "P1", "D1"), + ("A2", "R2", "P2", "D2"), + ], + ) + self.assertFalse(any(record["stage"].startswith("frontend_") for record in normalized)) + + +if __name__ == "__main__": + unittest.main() diff --git a/moli-benchmark/tests/test_serve.py b/moli-benchmark/tests/test_serve.py new file mode 100644 index 0000000000..ededa94ec3 --- /dev/null +++ b/moli-benchmark/tests/test_serve.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import io +import unittest +from pathlib import Path +from unittest.mock import patch + +from moli_benchmark.serve import start_moli_serve, stop_moli_serve + + +class _FakeProcess: + pid = 12345 + + def __init__(self) -> None: + self.returncode: int | None = None + self.stdout = io.BytesIO() + self.stderr = io.BytesIO() + + def poll(self) -> int | None: + return self.returncode + + def terminate(self) -> None: + self.returncode = -15 + + def wait(self, timeout: float | None = None) -> int | None: + del timeout + return self.returncode + + +class _FakeSampler: + def __init__(self, pid: int, *, interval_seconds: float = 0.1) -> None: + self.pid = pid + self.interval_seconds = interval_seconds + + def start(self) -> None: + return None + + def stop(self) -> dict[str, int]: + return {"pid": self.pid} + + +class ServeTests(unittest.TestCase): + def test_start_serve_applies_diagnostic_environment_override(self) -> None: + process = _FakeProcess() + with ( + patch("moli_benchmark.serve.subprocess.Popen", return_value=process) as popen, + patch("moli_benchmark.serve.ResourceSampler", _FakeSampler), + patch("moli_benchmark.serve.probe_url", return_value=True), + ): + handle = start_moli_serve( + Path("/bin/moli"), + 1.0, + env_overrides={"MOLI_BROWSER_OWNER_TRACE_JSONL": "/tmp/trace.jsonl"}, + resource_sample_interval_seconds=0.01, + ) + child_env = popen.call_args.kwargs["env"] + + self.assertEqual( + child_env["MOLI_BROWSER_OWNER_TRACE_JSONL"], + "/tmp/trace.jsonl", + ) + self.assertEqual(handle.sampler.interval_seconds, 0.01) + stop_moli_serve(handle) + + +if __name__ == "__main__": + unittest.main() diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_shadow_outer_html.py b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_shadow_outer_html.py index 79ca3d7fb2..3d2e51e7ad 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_shadow_outer_html.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_shadow_outer_html.py @@ -50,7 +50,10 @@ async def run_dom_shadow_outer_html_group(state: SmokeState) -> None: try: await page.goto( f"{state.fixture}/dom-shadow-outer-html", - wait_until="domcontentloaded", + # Chromium can publish the main Document's DCL while this fixture's + # iframe still exposes its initial about:blank Document. This group + # reads the committed child shadow tree, so synchronize with load. + wait_until="load", timeout=10_000, ) session = await state.context.new_cdp_session(page) diff --git a/moli-core/Cargo.toml b/moli-core/Cargo.toml index 18f0629fbd..4a0e07e7ab 100644 --- a/moli-core/Cargo.toml +++ b/moli-core/Cargo.toml @@ -7,7 +7,9 @@ edition = "2024" [features] default = [] # Compile renderer-only testing and diagnostic projections. This is not part of -# normal browser behavior and must stay disabled in production builds. +# normal browser behavior and must stay disabled in production builds. The +# feature also enables narrow fixture-only Page residence mutation helpers in +# this crate; production builds must not expose a second Page lifetime owner. test-support = ["moli-renderer-v8/test-support"] # Forward the renderer's WPT-only test harness globals. This must stay opt-in: # normal Moli pages should not expose `window.webdriver` / `window.WebDriver`. @@ -16,6 +18,7 @@ wpt-extensions = ["moli-renderer-v8/wpt-extensions"] [dependencies] anyhow = "1.0.100" moli-browser-profile = { path = "../moli-browser-profile" } +moli-bounded-buffer = { path = "../moli-bounded-buffer" } moli-dom = { path = "../moli-dom" } moli-parser = { path = "../moli-parser" } moli-page-types = { path = "../moli-page-types" } @@ -24,6 +27,7 @@ moli-selector = { path = "../moli-selector" } moli-storage-key = { path = "../moli-storage-key" } moli-storage-service = { path = "../moli-storage-service" } moli-url = { path = "../moli-url" } +moli-trace = { path = "../moli-trace" } moli-web-mime = { path = "../moli-web-mime" } moli-cookie-jar = { path = "../moli-cookie-jar" } moli-fetch = { path = "../moli-fetch" } diff --git a/moli-core/src/browser_host/actor.rs b/moli-core/src/browser_host/actor.rs new file mode 100644 index 0000000000..3ca67a1e9a --- /dev/null +++ b/moli-core/src/browser_host/actor.rs @@ -0,0 +1,311 @@ +use tokio::sync::mpsc; + +#[cfg(test)] +use crate::runtime::NavigationEngine; + +use super::{ + BrowserHostHandle, BrowserHostState, BrowserHostTurn, BrowserHostTurnExecutor, + BrowserOwnerInput, +}; + +/// Single-owner Browser Host input actor. +/// +/// It owns the authoritative Host-state residence, admission order and ready +/// queue while the selected input's physical Page execution adapter is still +/// being migrated out of Protocol. It intentionally contains no CDP session, +/// command id, domain subscription or socket state. +#[derive(Debug)] +pub struct BrowserHostActor { + state: BrowserHostState, + input_rx: mpsc::UnboundedReceiver, + selected_turn: Option, +} + +/// Result of waiting for the Browser Host to select its next exact turn. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use = "a selected Browser Host turn must be completed before selecting again"] +pub enum BrowserHostTurnSelection { + Selected, + Closed, +} + +impl BrowserHostActor { + /// Creates the owner queue over an application-created Browser Host + /// residence. The actor, rather than any protocol adapter, keeps the + /// authoritative state allocation alive. + pub fn new(state: BrowserHostState) -> (Self, BrowserHostHandle) { + let (input_tx, input_rx) = mpsc::unbounded_channel(); + ( + Self { + state, + input_rx, + selected_turn: None, + }, + BrowserHostHandle::new(input_tx), + ) + } + + pub fn state(&self) -> &BrowserHostState { + &self.state + } + + /// Returns whether at least one input is ready for a later owner turn. + pub fn has_ready_input(&self) -> bool { + self.selected_turn.is_some() || !self.input_rx.is_empty() + } + + /// Waits until this actor has selected one exact FIFO turn. + /// + /// Selection stores the turn inside the actor rather than returning its + /// payload to application scheduling code. The underlying Tokio receive + /// is cancellation-safe while pending; once it returns, the selected turn + /// is installed synchronously before this future becomes ready. + pub async fn select_next_turn_when_ready(&mut self) -> BrowserHostTurnSelection { + if self.selected_turn.is_some() { + return BrowserHostTurnSelection::Selected; + } + let Some(input) = self.input_rx.recv().await else { + return BrowserHostTurnSelection::Closed; + }; + self.selected_turn = Some(BrowserHostTurn::new(input)); + BrowserHostTurnSelection::Selected + } + + /// Selects and starts one FIFO input as a single short Browser Host turn. + /// + /// The executor must return synchronously. Any network, renderer or other + /// participant wait is represented in its output and completed as a later + /// owner-loop input. An empty mailbox does not invoke the physical adapter. + pub fn complete_next_turn( + &mut self, + executor: &mut Executor, + ) -> Option + where + Executor: BrowserHostTurnExecutor + ?Sized, + { + let turn = if let Some(turn) = self.selected_turn.take() { + turn + } else { + let input = self.input_rx.try_recv().ok()?; + BrowserHostTurn::new(input) + }; + Some(executor.execute_browser_host_turn(turn)) + } + + pub fn ready_len(&self) -> usize { + usize::from(self.selected_turn.is_some()) + self.input_rx.len() + } +} + +#[cfg(test)] +mod tests { + use std::future::ready; + + use crate::{ + PageId, + browser_host::{BrowserOwnerInputKind, PageResidenceIdentity, RendererBrowserIntent}, + page::{ + RendererDocumentLifecycleIdentity, RendererDocumentSourcedTopLevelLocationNavigation, + RendererDocumentToken, RendererFrameToken, RendererLifecycleEpoch, + }, + }; + + use super::*; + + #[derive(Default)] + struct RecordingExecutor { + calls: usize, + } + + impl BrowserHostTurnExecutor for RecordingExecutor { + type Output = BrowserHostTurn; + + fn execute_browser_host_turn(&mut self, turn: BrowserHostTurn) -> Self::Output { + self.calls += 1; + turn + } + } + + fn input(target: &str, generation: u64) -> BrowserOwnerInput { + let page_id = PageId::new_for_testing(generation + 1); + let document = RendererDocumentLifecycleIdentity { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, generation), + epoch: RendererLifecycleEpoch(generation), + }; + BrowserOwnerInput::renderer_top_level_location_navigation( + PageResidenceIdentity::new( + "context-browser-host-actor".to_owned(), + Some(target.to_owned()), + generation, + ), + RendererDocumentSourcedTopLevelLocationNavigation::new( + document, + format!("https://example.test/{generation}"), + ), + None, + ) + } + + #[tokio::test] + async fn actor_executes_fifo_turns_and_preserves_exact_page_identity() { + let (mut actor, handle) = + BrowserHostActor::new(BrowserHostState::new(NavigationEngine::new())); + let mut executor = RecordingExecutor::default(); + handle + .publish(input("target-a", 1)) + .expect("live Browser Host should accept first input"); + handle + .publish(input("target-b", 2)) + .expect("live Browser Host should accept second input"); + + assert_eq!(actor.ready_len(), 2); + let first = actor + .complete_next_turn(&mut executor) + .expect("first owner turn"); + let first = first.into_input(); + let BrowserOwnerInput::RendererIntent(RendererBrowserIntent::TopLevelLocationNavigation( + first, + )) = first + else { + panic!("expected renderer navigation input"); + }; + assert_eq!(first.page_owner().target_id(), Some("target-a")); + assert_eq!(first.page_owner().loaded_page_generation(), 1); + + let second = actor + .complete_next_turn(&mut executor) + .expect("second owner turn"); + let second = second.into_input(); + let BrowserOwnerInput::RendererIntent(RendererBrowserIntent::TopLevelLocationNavigation( + second, + )) = second + else { + panic!("expected renderer navigation input"); + }; + assert_eq!(second.page_owner().target_id(), Some("target-b")); + assert_eq!(second.page_owner().loaded_page_generation(), 2); + assert_eq!(executor.calls, 2); + assert!(!actor.has_ready_input()); + } + + #[tokio::test] + async fn dropping_one_producer_handle_does_not_stop_browser_host() { + let (mut actor, host_handle) = + BrowserHostActor::new(BrowserHostState::new(NavigationEngine::new())); + let mut executor = RecordingExecutor::default(); + let detached_producer = host_handle.clone(); + drop(detached_producer); + + host_handle + .publish(input("target-after-detach", 3)) + .expect("dropping one producer endpoint must not stop Browser Host"); + assert!(actor.has_ready_input()); + assert!(actor.complete_next_turn(&mut executor).is_some()); + } + + #[test] + fn actor_owns_browser_host_state_residence() { + let state = BrowserHostState::new(NavigationEngine::new()); + let browser_instance_id = state.navigation_owner().browser_instance_id(); + let (actor, _handle) = BrowserHostActor::new(state); + + assert_eq!( + actor.state().navigation_owner().browser_instance_id(), + browser_instance_id + ); + } + + #[test] + fn publication_after_actor_shutdown_is_typed_rejection() { + let (actor, handle) = BrowserHostActor::new(BrowserHostState::new(NavigationEngine::new())); + drop(actor); + + let error = handle + .publish(input("target-stopped", 4)) + .expect_err("stopped Browser Host must reject publication"); + assert_eq!( + error.kind(), + BrowserOwnerInputKind::RendererTopLevelLocationNavigation + ); + } + + #[tokio::test] + async fn empty_mailbox_does_not_invoke_physical_executor() { + let (mut actor, _handle) = + BrowserHostActor::new(BrowserHostState::new(NavigationEngine::new())); + let mut executor = RecordingExecutor::default(); + + assert!(actor.complete_next_turn(&mut executor).is_none()); + assert_eq!(executor.calls, 0); + } + + #[tokio::test] + async fn mailbox_wake_selects_without_executing_until_the_owner_turn() { + let (mut actor, handle) = + BrowserHostActor::new(BrowserHostState::new(NavigationEngine::new())); + let mut executor = RecordingExecutor::default(); + + let (selection, publication) = tokio::join!(actor.select_next_turn_when_ready(), async { + handle.publish(input("target-wake", 4)) + }); + publication.expect("live Browser Host should accept the waking input"); + assert_eq!(selection, BrowserHostTurnSelection::Selected); + assert_eq!(actor.ready_len(), 1); + assert_eq!(executor.calls, 0, "selection must not execute recursively"); + + let turn = actor + .complete_next_turn(&mut executor) + .expect("selected turn should remain actor-owned until completion"); + assert_eq!( + turn.into_input().kind(), + BrowserOwnerInputKind::RendererTopLevelLocationNavigation + ); + assert_eq!(executor.calls, 1); + assert!(!actor.has_ready_input()); + } + + #[tokio::test] + async fn canceled_pending_selection_does_not_consume_the_next_input() { + let (mut actor, handle) = + BrowserHostActor::new(BrowserHostState::new(NavigationEngine::new())); + let mut pending_selection = Box::pin(actor.select_next_turn_when_ready()); + + tokio::select! { + biased; + _ = &mut pending_selection => { + panic!("an empty Browser Host mailbox must remain pending"); + } + _ = ready(()) => {} + } + drop(pending_selection); + + handle + .publish(input("target-after-cancel", 5)) + .expect("canceled selection must leave the mailbox receiver live"); + assert_eq!( + actor.select_next_turn_when_ready().await, + BrowserHostTurnSelection::Selected + ); + let mut executor = RecordingExecutor::default(); + let selected = actor + .complete_next_turn(&mut executor) + .expect("input published after cancellation must remain selectable"); + assert_eq!( + selected.into_input().kind(), + BrowserOwnerInputKind::RendererTopLevelLocationNavigation + ); + } + + #[tokio::test] + async fn mailbox_shutdown_is_a_typed_selection_terminal() { + let (mut actor, handle) = + BrowserHostActor::new(BrowserHostState::new(NavigationEngine::new())); + drop(handle); + + assert_eq!( + actor.select_next_turn_when_ready().await, + BrowserHostTurnSelection::Closed + ); + } +} diff --git a/moli-core/src/browser_host/auxiliary_navigation.rs b/moli-core/src/browser_host/auxiliary_navigation.rs new file mode 100644 index 0000000000..d34a1c177f --- /dev/null +++ b/moli-core/src/browser_host/auxiliary_navigation.rs @@ -0,0 +1,60 @@ +use super::PageResidenceIdentity; + +/// Why an already-created auxiliary Target needs a top-level navigation. +/// +/// The distinction is browser state, not a frontend projection detail. A new +/// auxiliary Target starts with an initial empty Document, while a named +/// Target reuse navigates an already-loaded Page. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserAuxiliaryNavigationKind { + InitialDocument, + NamedTargetReuse, +} + +/// Exact auxiliary-Target navigation admitted to Browser Owner. +/// +/// Target creation/name resolution has already completed when this input is +/// published. The destination and exact Page residence are immutable, so a +/// delayed owner turn cannot follow a replacement Page or a frontend route. +#[derive(Debug)] +pub struct BrowserAuxiliaryNavigationInput { + page_owner: PageResidenceIdentity, + url: String, + kind: BrowserAuxiliaryNavigationKind, +} + +impl BrowserAuxiliaryNavigationInput { + pub(super) fn new( + page_owner: PageResidenceIdentity, + url: String, + kind: BrowserAuxiliaryNavigationKind, + ) -> Self { + Self { + page_owner, + url, + kind, + } + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn kind(&self) -> BrowserAuxiliaryNavigationKind { + self.kind + } + + pub fn into_parts( + self, + ) -> ( + PageResidenceIdentity, + String, + BrowserAuxiliaryNavigationKind, + ) { + (self.page_owner, self.url, self.kind) + } +} diff --git a/moli-core/src/browser_host/command_outcome.rs b/moli-core/src/browser_host/command_outcome.rs new file mode 100644 index 0000000000..2f26323877 --- /dev/null +++ b/moli-core/src/browser_host/command_outcome.rs @@ -0,0 +1,180 @@ +use super::BrowserTargetId; + +/// Protocol-neutral terminal result for one frontend-originated navigation +/// command. +/// +/// `Completed` means the command's response policy has reached its response +/// boundary. It does not imply that the resulting Document has reached DCL or +/// load; those remain independently observed browser lifecycle facts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserNavigateCommandOutcome { + Completed(BrowserNavigateCommandResult), + Rejected(BrowserNavigateCommandError), +} + +impl BrowserNavigateCommandOutcome { + pub fn with_history_traversal(self, result: BrowserHistoryTraversalResult) -> Self { + match self { + Self::Completed(completed) => Self::Completed(completed.with_history_traversal(result)), + Self::Rejected(error) => Self::Rejected(error), + } + } +} + +/// Browser-visible navigation metadata available when a command response is +/// ready. +/// +/// `is_download` is optional because a normal Document navigation does not +/// need to expose a download classification. A failed or download navigation +/// may additionally carry `error_text` while still completing the frontend +/// command successfully. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserNavigateCommandResult { + requested_url: String, + target_id: Option, + loader_id: Option, + error_text: Option, + is_download: Option, + history_traversal: Option, +} + +impl BrowserNavigateCommandResult { + pub fn new( + requested_url: impl Into, + target_id: Option, + loader_id: Option, + error_text: Option, + is_download: Option, + ) -> Self { + Self { + requested_url: requested_url.into(), + target_id, + loader_id, + error_text, + is_download, + history_traversal: None, + } + } + + pub fn with_history_traversal(mut self, result: BrowserHistoryTraversalResult) -> Self { + self.history_traversal = Some(result); + self + } + + pub fn requested_url(&self) -> &str { + &self.requested_url + } + + pub fn target_id(&self) -> Option<&BrowserTargetId> { + self.target_id.as_ref() + } + + pub fn loader_id(&self) -> Option<&str> { + self.loader_id.as_deref() + } + + pub fn error_text(&self) -> Option<&str> { + self.error_text.as_deref() + } + + pub fn is_download(&self) -> Option { + self.is_download + } + + pub fn history_traversal(&self) -> Option { + self.history_traversal + } +} + +/// Browser-owned classification of a completed joint session-history command. +/// +/// This is terminal command metadata, not a frontend inference from URL or a +/// pre-selection history snapshot. A same-Document attempt that falls back to +/// loading its entry URL is reported as [`CrossDocument`](Self::CrossDocument). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserHistoryTraversalResult { + Noop, + SameDocument, + CrossDocument, +} + +/// Protocol-neutral reason that a navigation command could not reach its +/// normal response boundary. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserNavigateCommandError { + kind: BrowserNavigateCommandErrorKind, + message: String, +} + +impl BrowserNavigateCommandError { + pub fn new(kind: BrowserNavigateCommandErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + pub fn kind(&self) -> BrowserNavigateCommandErrorKind { + self.kind + } + + pub fn message(&self) -> &str { + &self.message + } +} + +/// Stable browser-command rejection classes. Protocol frontends map these to +/// their own wire error codes and shapes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserNavigateCommandErrorKind { + InvalidInput, + RequesterUnavailable, + TargetUnavailable, + Failed, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn navigate_outcome_contains_no_frontend_correlation() { + let outcome = BrowserNavigateCommandOutcome::Completed(BrowserNavigateCommandResult::new( + "https://example.test/", + Some(BrowserTargetId::new("target-1")), + Some("loader-1".to_owned()), + None, + None, + )); + + let BrowserNavigateCommandOutcome::Completed(result) = outcome else { + panic!("expected completed navigation outcome"); + }; + assert_eq!(result.requested_url(), "https://example.test/"); + assert_eq!( + result.target_id().map(BrowserTargetId::as_str), + Some("target-1") + ); + assert_eq!(result.loader_id(), Some("loader-1")); + assert_eq!(result.error_text(), None); + assert_eq!(result.is_download(), None); + assert_eq!(result.history_traversal(), None); + } + + #[test] + fn navigate_outcome_can_carry_browser_owned_history_classification() { + let result = BrowserNavigateCommandResult::new( + "https://example.test/#before", + None, + None, + None, + None, + ) + .with_history_traversal(BrowserHistoryTraversalResult::SameDocument); + + assert_eq!( + result.history_traversal(), + Some(BrowserHistoryTraversalResult::SameDocument) + ); + } +} diff --git a/moli-core/src/browser_host/context_handle.rs b/moli-core/src/browser_host/context_handle.rs new file mode 100644 index 0000000000..318c50d818 --- /dev/null +++ b/moli-core/src/browser_host/context_handle.rs @@ -0,0 +1,261 @@ +use std::{ + fmt, + hash::{Hash, Hasher}, + num::NonZeroU64, + sync::{ + Arc, + atomic::{AtomicU8, AtomicU64, Ordering}, + }, +}; + +use super::BrowserContextId; + +static NEXT_BROWSER_CONTEXT_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); + +const CONTEXT_STAGED: u8 = 0; +const CONTEXT_LIVE: u8 = 1; +const CONTEXT_RETIRED: u8 = 2; +const CONTEXT_ACTIVATION_RESERVED: u8 = 3; +const CONTEXT_RETIREMENT_RESERVED: u8 = 4; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct BrowserContextInstanceId(NonZeroU64); + +impl BrowserContextInstanceId { + fn allocate() -> Self { + let raw = NEXT_BROWSER_CONTEXT_INSTANCE_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .unwrap_or_else(|_| panic!("browser context instance id exhausted")); + Self( + NonZeroU64::new(raw) + .unwrap_or_else(|| panic!("browser context instance allocator returned zero")), + ) + } +} + +struct BrowserContextHandleState { + instance_id: BrowserContextInstanceId, + browser_context_id: BrowserContextId, + lifecycle: AtomicU8, +} + +/// Stable capability for one BrowserContext instance. +/// +/// Public BrowserContext ids may be supplied by a frontend and reused after +/// disposal. The physical context projection carries this capability so a +/// queued owner action cannot be redirected to a later context with the same +/// public id. +#[derive(Clone)] +pub struct BrowserContextHandle { + state: Arc, +} + +impl BrowserContextHandle { + /// Allocates a staged BrowserContext capability. + /// + /// Browser Core must accept the matching context registration before the + /// handle becomes live. + pub fn staged(browser_context_id: impl Into) -> Self { + Self { + state: Arc::new(BrowserContextHandleState { + instance_id: BrowserContextInstanceId::allocate(), + browser_context_id: BrowserContextId::new(browser_context_id), + lifecycle: AtomicU8::new(CONTEXT_STAGED), + }), + } + } + + pub fn browser_context_id(&self) -> &str { + self.state.browser_context_id.as_str() + } + + pub fn is_live(&self) -> bool { + matches!( + self.state.lifecycle.load(Ordering::Acquire), + CONTEXT_LIVE | CONTEXT_RETIREMENT_RESERVED + ) + } + + pub fn is_retired(&self) -> bool { + self.state.lifecycle.load(Ordering::Acquire) == CONTEXT_RETIRED + } + + #[cfg(test)] + pub(super) fn is_staged(&self) -> bool { + matches!( + self.state.lifecycle.load(Ordering::Acquire), + CONTEXT_STAGED | CONTEXT_ACTIVATION_RESERVED + ) + } + + pub(super) fn reserve_activation(&self) -> bool { + self.state + .lifecycle + .compare_exchange( + CONTEXT_STAGED, + CONTEXT_ACTIVATION_RESERVED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + pub(super) fn commit_activation_reservation(&self) { + let committed = self + .state + .lifecycle + .compare_exchange( + CONTEXT_ACTIVATION_RESERVED, + CONTEXT_LIVE, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + debug_assert!( + committed, + "only the registry transaction that reserved BrowserContext activation may publish it" + ); + } + + pub(super) fn rollback_activation_reservation(&self) { + let rolled_back = self + .state + .lifecycle + .compare_exchange( + CONTEXT_ACTIVATION_RESERVED, + CONTEXT_STAGED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + debug_assert!( + rolled_back, + "only an uncommitted BrowserContext activation reservation may be rolled back" + ); + } + + pub(super) fn reserve_retirement(&self) -> bool { + self.state + .lifecycle + .compare_exchange( + CONTEXT_LIVE, + CONTEXT_RETIREMENT_RESERVED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + pub(super) fn commit_retirement_reservation(&self) { + let committed = self + .state + .lifecycle + .compare_exchange( + CONTEXT_RETIREMENT_RESERVED, + CONTEXT_RETIRED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + debug_assert!( + committed, + "only the registry transaction that reserved BrowserContext retirement may publish it" + ); + } + + pub(super) fn rollback_retirement_reservation(&self) { + let rolled_back = self + .state + .lifecycle + .compare_exchange( + CONTEXT_RETIREMENT_RESERVED, + CONTEXT_LIVE, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + debug_assert!( + rolled_back, + "only an uncommitted BrowserContext retirement reservation may be rolled back" + ); + } + + pub(super) fn same_instance(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.state, &other.state) + } +} + +impl fmt::Debug for BrowserContextHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BrowserContextHandle") + .field("instance_id", &self.state.instance_id) + .field("browser_context_id", &self.browser_context_id()) + .field("live", &self.is_live()) + .field("retired", &self.is_retired()) + .finish() + } +} + +impl PartialEq for BrowserContextHandle { + fn eq(&self, other: &Self) -> bool { + self.state.instance_id == other.state.instance_id + } +} + +impl Eq for BrowserContextHandle {} + +impl Hash for BrowserContextHandle { + fn hash(&self, state: &mut H) { + self.state.instance_id.hash(state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_public_id_does_not_alias_context_instance() { + let first = BrowserContextHandle::staged("context-1"); + let second = BrowserContextHandle::staged("context-1"); + + assert_ne!(first, second); + assert!(first.same_instance(&first.clone())); + assert!(!first.same_instance(&second)); + } + + #[test] + fn core_lifecycle_is_exact_once_and_reservations_remain_observably_live() { + let handle = BrowserContextHandle::staged("context-1"); + + assert!(handle.is_staged()); + assert!(handle.reserve_activation()); + assert!(handle.is_staged()); + handle.commit_activation_reservation(); + assert!(handle.is_live()); + assert!(!handle.reserve_activation()); + assert!(handle.reserve_retirement()); + assert!(handle.is_live()); + handle.commit_retirement_reservation(); + assert!(handle.is_retired()); + assert!(!handle.reserve_retirement()); + } + + #[test] + fn uncommitted_lifecycle_reservations_restore_the_observable_state() { + let handle = BrowserContextHandle::staged("context-1"); + + assert!(handle.reserve_activation()); + handle.rollback_activation_reservation(); + assert!(handle.is_staged()); + + assert!(handle.reserve_activation()); + handle.commit_activation_reservation(); + assert!(handle.reserve_retirement()); + handle.rollback_retirement_reservation(); + assert!(handle.is_live()); + } +} diff --git a/moli-core/src/browser_host/context_runtime_registry.rs b/moli-core/src/browser_host/context_runtime_registry.rs new file mode 100644 index 0000000000..178412457e --- /dev/null +++ b/moli-core/src/browser_host/context_runtime_registry.rs @@ -0,0 +1,129 @@ +use std::{collections::HashMap, fmt}; + +use crate::runtime::{ + RendererBrowserContextRuntimeOwner, RendererBrowserContextRuntimeOwnerAccess, +}; + +use super::{BrowserContextHandle, BrowserContextId, BrowserContextRegistryError}; + +/// Browser Host rejected a physical BrowserContext runtime-root transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserContextRuntimeRegistryError { + Context(BrowserContextRegistryError), + DuplicateRuntimeRoot(BrowserContextId), + MissingRuntimeRoot(BrowserContextId), +} + +impl fmt::Display for BrowserContextRuntimeRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Context(error) => error.fmt(formatter), + Self::DuplicateRuntimeRoot(browser_context_id) => write!( + formatter, + "BrowserContext {:?} already has an exact renderer runtime root", + browser_context_id.as_str() + ), + Self::MissingRuntimeRoot(browser_context_id) => write!( + formatter, + "BrowserContext {:?} has no exact renderer runtime root", + browser_context_id.as_str() + ), + } + } +} + +impl std::error::Error for BrowserContextRuntimeRegistryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Context(error) => Some(error), + Self::DuplicateRuntimeRoot(_) | Self::MissingRuntimeRoot(_) => None, + } + } +} + +impl From for BrowserContextRuntimeRegistryError { + fn from(error: BrowserContextRegistryError) -> Self { + Self::Context(error) + } +} + +/// Unique renderer/network roots keyed by exact BrowserContext instance. +/// +/// This registry deliberately owns no selection or Target topology. Its +/// transaction helpers compose with `BrowserNavigationOwner` synchronously so +/// the two registries either publish together or restore the exact root. +#[derive(Default)] +pub(super) struct BrowserContextRuntimeRegistry { + roots: HashMap, +} + +impl BrowserContextRuntimeRegistry { + pub(super) fn register( + &mut self, + browser_context_handle: BrowserContextHandle, + renderer_runtime_owner: RendererBrowserContextRuntimeOwner, + commit: impl FnOnce( + RendererBrowserContextRuntimeOwnerAccess, + ) -> Result, + ) -> Result { + if self.roots.contains_key(&browser_context_handle) { + return Err(BrowserContextRuntimeRegistryError::DuplicateRuntimeRoot( + BrowserContextId::new(browser_context_handle.browser_context_id()), + )); + } + let renderer_runtime_access = renderer_runtime_owner.owner_access(); + let committed = commit(renderer_runtime_access)?; + self.roots + .insert(browser_context_handle, renderer_runtime_owner); + Ok(committed) + } + + pub(super) fn remove( + &mut self, + browser_context_handle: BrowserContextHandle, + commit: impl FnOnce() -> Result, + ) -> Result<(T, RendererBrowserContextRuntimeOwner), BrowserContextRuntimeRegistryError> { + let Some(renderer_runtime_owner) = self.roots.remove(&browser_context_handle) else { + return Err(BrowserContextRuntimeRegistryError::MissingRuntimeRoot( + BrowserContextId::new(browser_context_handle.browser_context_id()), + )); + }; + match commit() { + Ok(committed) => Ok((committed, renderer_runtime_owner)), + Err(error) => { + self.roots + .insert(browser_context_handle, renderer_runtime_owner); + Err(error.into()) + } + } + } + + pub(super) fn owner_access( + &self, + browser_context_handle: &BrowserContextHandle, + ) -> Option { + if !browser_context_handle.is_live() { + return None; + } + self.roots + .get(browser_context_handle) + .map(RendererBrowserContextRuntimeOwner::owner_access) + } + + pub(super) fn terminate_renderer_producers(&mut self) { + for root in self.roots.values_mut() { + root.terminate_renderer_producers_for_owner_shutdown(); + } + } + + pub(super) fn shutdown_network_and_join(&mut self) { + for root in self.roots.values_mut() { + root.shutdown_network_and_join(); + } + } + + #[cfg(test)] + pub(super) fn len(&self) -> usize { + self.roots.len() + } +} diff --git a/moli-core/src/browser_host/document_lifecycle_wait.rs b/moli-core/src/browser_host/document_lifecycle_wait.rs new file mode 100644 index 0000000000..2a307e551e --- /dev/null +++ b/moli-core/src/browser_host/document_lifecycle_wait.rs @@ -0,0 +1,558 @@ +use std::sync::Arc; + +use parking_lot::Mutex; + +use crate::page::{ + RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, + RendererLifecycleTerminationStamp, +}; + +use super::{ + BrowserFact, BrowserFactEnvelope, BrowserFactReceiveError, BrowserFactSubscriber, + BrowserFactTryReceiveError, PageResidenceIdentity, +}; + +/// Why an exact Browser fact wait can no longer observe its Document. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserDocumentLifecycleWaitUnavailableReason { + NoCurrentPage, + TargetCrashed, + TargetClosed, + SubscriberLagged { skipped: u64 }, + FactJournalClosed, +} + +/// Protocol-neutral terminal for one exact Document milestone wait. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserDocumentLifecycleWaitOutcome { + Reached, + Interrupted { + last_reached: Option, + termination: RendererLifecycleTerminationStamp, + }, + Superseded, + Unavailable(BrowserDocumentLifecycleWaitUnavailableReason), +} + +struct BrowserDocumentLifecycleWaitShared { + expected_page: Option, + expected_document: Option, + milestone: RendererDocumentLifecycleMilestone, + outcome: Mutex>, + readiness_subscriber: Mutex>, +} + +impl std::fmt::Debug for BrowserDocumentLifecycleWaitShared { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BrowserDocumentLifecycleWaitShared") + .field("expected_page", &self.expected_page) + .field("expected_document", &self.expected_document) + .field("milestone", &self.milestone) + .field("outcome", &self.current_outcome()) + .finish() + } +} + +impl BrowserDocumentLifecycleWaitShared { + fn current_outcome(&self) -> Option { + self.outcome.lock().clone() + } + + fn poll_readiness(&self) -> Option { + loop { + if let Some(outcome) = self.current_outcome() { + return Some(outcome); + } + let received = { + let mut subscriber = self.readiness_subscriber.lock(); + let subscriber = subscriber.as_mut()?; + subscriber.try_recv() + }; + match received { + Ok(fact) => { + if let Some(outcome) = self.outcome_for_fact(&fact) { + return Some(self.finish(outcome)); + } + } + Err(BrowserFactTryReceiveError::Empty) => return None, + Err(BrowserFactTryReceiveError::Lagged { skipped }) => { + return Some( + self.finish(BrowserDocumentLifecycleWaitOutcome::Unavailable( + BrowserDocumentLifecycleWaitUnavailableReason::SubscriberLagged { + skipped, + }, + )), + ); + } + Err(BrowserFactTryReceiveError::Closed) => { + return Some( + self.finish(BrowserDocumentLifecycleWaitOutcome::Unavailable( + BrowserDocumentLifecycleWaitUnavailableReason::FactJournalClosed, + )), + ); + } + } + } + } + + fn outcome_for_fact( + &self, + envelope: &BrowserFactEnvelope, + ) -> Option { + let expected_page = self.expected_page.as_ref()?; + let expected_document = self.expected_document?; + match envelope.fact() { + BrowserFact::NavigationAccepted { .. } + if envelope.page_residence() == expected_page => + { + Some(BrowserDocumentLifecycleWaitOutcome::Superseded) + } + BrowserFact::NavigationCommitted { previous_page, .. } + if previous_page == expected_page => + { + Some(BrowserDocumentLifecycleWaitOutcome::Superseded) + } + BrowserFact::TargetCrashed { previous_page, .. } if previous_page == expected_page => { + Some(BrowserDocumentLifecycleWaitOutcome::Unavailable( + BrowserDocumentLifecycleWaitUnavailableReason::TargetCrashed, + )) + } + BrowserFact::TargetClosed { previous_page, .. } if previous_page == expected_page => { + Some(BrowserDocumentLifecycleWaitOutcome::Unavailable( + BrowserDocumentLifecycleWaitUnavailableReason::TargetClosed, + )) + } + BrowserFact::DocumentLifecycleReached { + document, + milestone, + .. + } if envelope.page_residence() == expected_page + && document == &expected_document + && milestone_satisfies(*milestone, self.milestone) => + { + Some(BrowserDocumentLifecycleWaitOutcome::Reached) + } + BrowserFact::DocumentLifecycleTerminated { + document, + last_reached, + termination, + } if envelope.page_residence() == expected_page && document == &expected_document => { + if last_reached.is_some_and(|reached| milestone_satisfies(reached, self.milestone)) + { + Some(BrowserDocumentLifecycleWaitOutcome::Reached) + } else { + Some(BrowserDocumentLifecycleWaitOutcome::Interrupted { + last_reached: *last_reached, + termination: *termination, + }) + } + } + _ => None, + } + } + + fn finish( + &self, + outcome: BrowserDocumentLifecycleWaitOutcome, + ) -> BrowserDocumentLifecycleWaitOutcome { + let mut current = self.outcome.lock(); + if let Some(current) = current.as_ref() { + return current.clone(); + } + *current = Some(outcome.clone()); + outcome + } +} + +/// Cloneable readiness over an independent cursor at the same journal cut. +/// +/// It can consume facts only to decide whether the exact wait is terminal. It +/// cannot publish facts, mutate Browser state, or consume the ticket's async +/// cursor. This makes terminal-before-next-owner ordering independent of when +/// the adapter's wait task happens to be scheduled. +#[derive(Clone, Debug)] +pub struct BrowserDocumentLifecycleWaitReadiness { + shared: Arc, +} + +impl BrowserDocumentLifecycleWaitReadiness { + pub fn is_terminal(&self) -> bool { + self.shared.poll_readiness().is_some() + } + + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn terminal_for_test_support() -> Self { + Self { + shared: Arc::new(BrowserDocumentLifecycleWaitShared { + expected_page: None, + expected_document: None, + milestone: RendererDocumentLifecycleMilestone::Load, + outcome: Mutex::new(Some(BrowserDocumentLifecycleWaitOutcome::Reached)), + readiness_subscriber: Mutex::new(None), + }), + } + } +} + +/// Move-only subscriber ticket for one exact Page/Document milestone. +/// +/// The ticket consumes the common Browser fact journal. It does not register +/// a Page-slot callback and cannot influence Browser Owner progress. Its two +/// cursors are created at one owner cut: one drives the async wait, while the +/// other lets the scheduler inspect readiness without stealing that wait. +pub struct BrowserDocumentLifecycleWaitTicket { + wait_subscriber: Option, + shared: Arc, +} + +impl std::fmt::Debug for BrowserDocumentLifecycleWaitTicket { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BrowserDocumentLifecycleWaitTicket") + .field("shared", &self.shared) + .finish() + } +} + +impl BrowserDocumentLifecycleWaitTicket { + pub(crate) fn new( + wait_subscriber: BrowserFactSubscriber, + readiness_subscriber: BrowserFactSubscriber, + expected_page: PageResidenceIdentity, + expected_document: RendererDocumentLifecycleIdentity, + milestone: RendererDocumentLifecycleMilestone, + ) -> Self { + let ticket = Self { + wait_subscriber: Some(wait_subscriber), + shared: Arc::new(BrowserDocumentLifecycleWaitShared { + expected_page: Some(expected_page), + expected_document: Some(expected_document), + milestone, + outcome: Mutex::new(None), + readiness_subscriber: Mutex::new(Some(readiness_subscriber)), + }), + }; + let _ = ticket.shared.poll_readiness(); + ticket + } + + pub fn resolved( + expected_page: Option, + expected_document: Option, + milestone: RendererDocumentLifecycleMilestone, + outcome: BrowserDocumentLifecycleWaitOutcome, + ) -> Self { + Self { + wait_subscriber: None, + shared: Arc::new(BrowserDocumentLifecycleWaitShared { + expected_page, + expected_document, + milestone, + outcome: Mutex::new(Some(outcome)), + readiness_subscriber: Mutex::new(None), + }), + } + } + + pub fn expected_page(&self) -> Option<&PageResidenceIdentity> { + self.shared.expected_page.as_ref() + } + + pub fn expected_document(&self) -> Option { + self.shared.expected_document + } + + pub fn milestone(&self) -> RendererDocumentLifecycleMilestone { + self.shared.milestone + } + + pub fn readiness(&self) -> BrowserDocumentLifecycleWaitReadiness { + BrowserDocumentLifecycleWaitReadiness { + shared: Arc::clone(&self.shared), + } + } + + pub fn is_terminal(&self) -> bool { + self.outcome().is_some() + } + + pub fn outcome(&self) -> Option { + self.shared.poll_readiness() + } + + pub async fn wait(mut self) -> BrowserDocumentLifecycleWaitOutcome { + loop { + if let Some(outcome) = self.shared.poll_readiness() { + return outcome; + } + let Some(subscriber) = self.wait_subscriber.as_mut() else { + return self + .shared + .finish(BrowserDocumentLifecycleWaitOutcome::Unavailable( + BrowserDocumentLifecycleWaitUnavailableReason::FactJournalClosed, + )); + }; + match subscriber.recv().await { + Ok(fact) => { + if let Some(outcome) = self.shared.outcome_for_fact(&fact) { + return self.shared.finish(outcome); + } + } + Err(BrowserFactReceiveError::Lagged { skipped }) => { + return self + .shared + .finish(BrowserDocumentLifecycleWaitOutcome::Unavailable( + BrowserDocumentLifecycleWaitUnavailableReason::SubscriberLagged { + skipped, + }, + )); + } + Err(BrowserFactReceiveError::Closed) => { + return self + .shared + .finish(BrowserDocumentLifecycleWaitOutcome::Unavailable( + BrowserDocumentLifecycleWaitUnavailableReason::FactJournalClosed, + )); + } + } + } + } +} + +fn milestone_satisfies( + reached: RendererDocumentLifecycleMilestone, + expected: RendererDocumentLifecycleMilestone, +) -> bool { + matches!( + (reached, expected), + ( + RendererDocumentLifecycleMilestone::DomContentLoaded, + RendererDocumentLifecycleMilestone::DomContentLoaded + ) | ( + RendererDocumentLifecycleMilestone::Load, + RendererDocumentLifecycleMilestone::DomContentLoaded + | RendererDocumentLifecycleMilestone::Load + ) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::browser_host::fact_journal::BrowserFactJournal; + use crate::{ + PageId, + browser_host::{BrowserDocumentNavigation, BrowserInstanceId}, + page::{ + RendererDocumentTerminationReason, RendererDocumentToken, RendererFrameToken, + RendererLifecycleEpoch, + }, + }; + + fn page(generation: u64) -> PageResidenceIdentity { + PageResidenceIdentity::new( + "context-1".to_owned(), + Some("target-1".to_owned()), + generation, + ) + } + + fn document(generation: u64) -> RendererDocumentLifecycleIdentity { + let page_id = PageId::new_for_testing(41); + RendererDocumentLifecycleIdentity { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, generation), + epoch: RendererLifecycleEpoch(generation), + } + } + + fn ticket( + journal: &BrowserFactJournal, + expected_page: PageResidenceIdentity, + expected_document: RendererDocumentLifecycleIdentity, + ) -> BrowserDocumentLifecycleWaitTicket { + let (wait_subscriber, readiness_subscriber) = journal.subscribe_pair(); + BrowserDocumentLifecycleWaitTicket::new( + wait_subscriber, + readiness_subscriber, + expected_page, + expected_document, + RendererDocumentLifecycleMilestone::Load, + ) + } + + fn publish(journal: &mut BrowserFactJournal, page: PageResidenceIdentity, fact: BrowserFact) { + journal + .publish_batch(page, vec![fact]) + .expect("test fact should publish"); + } + + #[tokio::test] + async fn retained_load_fact_resolves_exact_ticket_at_creation() { + let mut journal = BrowserFactJournal::new(BrowserInstanceId::allocate()); + let expected_page = page(2); + let expected_document = document(2); + publish( + &mut journal, + expected_page.clone(), + BrowserFact::DocumentLifecycleReached { + document: expected_document, + milestone: RendererDocumentLifecycleMilestone::Load, + stamp: crate::page::RendererLifecycleEventStamp { + sequence: 7, + timestamp_micros: 70, + }, + }, + ); + + let ticket = ticket(&journal, expected_page, expected_document); + assert!(ticket.is_terminal()); + assert_eq!( + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Reached + ); + } + + #[tokio::test] + async fn future_exact_termination_interrupts_load_ticket_and_readiness() { + let mut journal = BrowserFactJournal::new(BrowserInstanceId::allocate()); + let expected_page = page(2); + let expected_document = document(2); + let ticket = ticket(&journal, expected_page.clone(), expected_document); + let readiness = ticket.readiness(); + assert!(!readiness.is_terminal()); + let termination = RendererLifecycleTerminationStamp { + sequence: 8, + timestamp_micros: 80, + reason: RendererDocumentTerminationReason::Stopped, + }; + publish( + &mut journal, + expected_page, + BrowserFact::DocumentLifecycleTerminated { + document: expected_document, + last_reached: Some(RendererDocumentLifecycleMilestone::DomContentLoaded), + termination, + }, + ); + + assert!( + readiness.is_terminal(), + "readiness must consume the fact without waiting for the async ticket task" + ); + assert_eq!( + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Interrupted { + last_reached: Some(RendererDocumentLifecycleMilestone::DomContentLoaded), + termination, + } + ); + } + + #[tokio::test] + async fn newer_navigation_acceptance_supersedes_exact_page_ticket() { + let mut journal = BrowserFactJournal::new(BrowserInstanceId::allocate()); + let expected_page = page(2); + let expected_document = document(2); + let ticket = ticket(&journal, expected_page.clone(), expected_document); + publish( + &mut journal, + expected_page, + BrowserFact::NavigationAccepted { + navigation: BrowserDocumentNavigation::new("target-1", "loader-next"), + superseded_navigation: None, + }, + ); + + assert_eq!( + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Superseded + ); + } + + #[tokio::test] + async fn retained_page_replacement_supersedes_ticket_when_acceptance_is_outside_cut() { + let mut journal = BrowserFactJournal::new(BrowserInstanceId::allocate()); + let expected_page = page(2); + let expected_document = document(2); + let navigation = BrowserDocumentNavigation::new("target-1", "loader-next"); + publish( + &mut journal, + page(3), + BrowserFact::NavigationCommitted { + previous_page: expected_page.clone(), + navigation, + }, + ); + + let ticket = ticket(&journal, expected_page, expected_document); + assert_eq!( + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Superseded + ); + } + + #[tokio::test] + async fn unrelated_facts_are_ignored_before_exact_target_close() { + let mut journal = BrowserFactJournal::new(BrowserInstanceId::allocate()); + let expected_page = page(2); + let expected_document = document(2); + let ticket = ticket(&journal, expected_page.clone(), expected_document); + publish( + &mut journal, + page(7), + BrowserFact::TargetClosed { + previous_page: page(6), + pending_navigation: None, + }, + ); + assert!(!ticket.is_terminal()); + publish( + &mut journal, + page(3), + BrowserFact::TargetClosed { + previous_page: expected_page, + pending_navigation: None, + }, + ); + + assert_eq!( + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Unavailable( + BrowserDocumentLifecycleWaitUnavailableReason::TargetClosed + ) + ); + } + + #[tokio::test] + async fn readiness_reports_explicit_lag_instead_of_guessing_terminal_state() { + let mut journal = BrowserFactJournal::new(BrowserInstanceId::allocate()); + let expected_page = page(2); + let expected_document = document(2); + let ticket = ticket(&journal, expected_page, expected_document); + let readiness = ticket.readiness(); + for generation in 0..=1_024 { + publish( + &mut journal, + page(7), + BrowserFact::NavigationAccepted { + navigation: BrowserDocumentNavigation::new( + "target-1", + format!("unrelated-{generation}"), + ), + superseded_navigation: None, + }, + ); + } + + assert!(readiness.is_terminal()); + assert_eq!( + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Unavailable( + BrowserDocumentLifecycleWaitUnavailableReason::SubscriberLagged { skipped: 1 } + ) + ); + } +} diff --git a/moli-core/src/browser_host/download.rs b/moli-core/src/browser_host/download.rs new file mode 100644 index 0000000000..a37442ace5 --- /dev/null +++ b/moli-core/src/browser_host/download.rs @@ -0,0 +1,305 @@ +use std::{collections::HashMap, path::PathBuf, sync::Arc}; + +use moli_fetch::FetchCancelHandle; +use parking_lot::Mutex; + +/// Protocol-neutral browser policy for one download scope. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum BrowserDownloadBehavior { + #[default] + Default, + Deny, + Allow, + AllowAndName, +} + +impl BrowserDownloadBehavior { + pub fn parse(value: &str) -> Option { + match value { + "default" => Some(Self::Default), + "deny" => Some(Self::Deny), + "allow" => Some(Self::Allow), + "allowAndName" => Some(Self::AllowAndName), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Deny => "deny", + Self::Allow => "allow", + Self::AllowAndName => "allowAndName", + } + } + + pub fn allows_download(self) -> bool { + matches!(self, Self::Allow | Self::AllowAndName) + } + + pub fn names_artifact_by_guid(self) -> bool { + self == Self::AllowAndName + } + + pub fn is_canceled_without_download(self) -> bool { + matches!(self, Self::Default | Self::Deny) + } +} + +/// Browser-owned effective download policy. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BrowserDownloadPolicy { + behavior: BrowserDownloadBehavior, + download_path: Option, +} + +impl BrowserDownloadPolicy { + pub fn new(behavior: BrowserDownloadBehavior, download_path: Option) -> Self { + Self { + behavior, + download_path, + } + } + + pub fn behavior(&self) -> BrowserDownloadBehavior { + self.behavior + } + + pub fn download_path(&self) -> Option<&str> { + self.download_path.as_deref() + } +} + +/// One closed mutation of browser-owned download policy. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserDownloadPolicyUpdate { + SetGlobal { + behavior: BrowserDownloadBehavior, + download_path: Option, + }, + SetBrowserContext { + browser_context_id: String, + behavior: BrowserDownloadBehavior, + download_path: Option, + }, + ResetGlobal, + RemoveBrowserContext { + browser_context_id: String, + }, +} + +/// Application-owned policy shared by every frontend of one Browser Host. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BrowserDownloadPolicyState { + global: BrowserDownloadPolicy, + browser_context_overrides: HashMap, +} + +impl BrowserDownloadPolicyState { + pub fn effective_for_browser_context( + &self, + browser_context_id: Option<&str>, + ) -> BrowserDownloadPolicy { + browser_context_id + .and_then(|browser_context_id| self.browser_context_overrides.get(browser_context_id)) + .cloned() + .unwrap_or_else(|| self.global.clone()) + } + + pub fn global(&self) -> &BrowserDownloadPolicy { + &self.global + } + + pub fn browser_context_override( + &self, + browser_context_id: &str, + ) -> Option<&BrowserDownloadPolicy> { + self.browser_context_overrides.get(browser_context_id) + } + + pub(crate) fn apply(&mut self, update: BrowserDownloadPolicyUpdate) { + match update { + BrowserDownloadPolicyUpdate::SetGlobal { + behavior, + download_path, + } => { + self.global = BrowserDownloadPolicy::new(behavior, download_path); + } + BrowserDownloadPolicyUpdate::SetBrowserContext { + browser_context_id, + behavior, + download_path, + } => { + self.browser_context_overrides.insert( + browser_context_id, + BrowserDownloadPolicy::new(behavior, download_path), + ); + } + BrowserDownloadPolicyUpdate::ResetGlobal => { + self.global = BrowserDownloadPolicy::default(); + } + BrowserDownloadPolicyUpdate::RemoveBrowserContext { browser_context_id } => { + self.browser_context_overrides.remove(&browser_context_id); + } + } + } +} + +#[derive(Debug, Clone)] +struct BrowserDownloadRecord { + state: BrowserDownloadLifecycle, + artifact_path: Option, +} + +#[derive(Debug, Clone)] +enum BrowserDownloadLifecycle { + Active(FetchCancelHandle), + Completed, + Canceled, +} + +/// Cross-frontend registry for accepted downloads and their artifacts. +/// +/// Download participants may finish on background Tokio tasks, so this +/// protocol-neutral handle is thread-safe even though the surrounding Browser +/// Host actor remains current-thread owned. +#[derive(Clone, Default)] +pub struct BrowserDownloadRegistry { + inner: Arc>>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserDownloadCancelOutcome { + Handled, + AlreadyTerminal, + NotFound, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserDownloadArtifactOutcome { + Ready(PathBuf), + InProgress, + NotAvailable, + NotFound, +} + +impl BrowserDownloadRegistry { + pub fn register_active(&self, guid: String, cancel_handle: FetchCancelHandle) { + self.with_mut(|downloads| { + downloads.insert( + guid, + BrowserDownloadRecord { + state: BrowserDownloadLifecycle::Active(cancel_handle), + artifact_path: None, + }, + ); + }); + } + + pub fn record_completed(&self, guid: &str, artifact_path: PathBuf) { + self.with_mut(|downloads| match downloads.get_mut(guid) { + Some(record) => { + record.state = BrowserDownloadLifecycle::Completed; + record.artifact_path = Some(artifact_path); + } + None => { + downloads.insert( + guid.to_owned(), + BrowserDownloadRecord { + state: BrowserDownloadLifecycle::Completed, + artifact_path: Some(artifact_path), + }, + ); + } + }); + } + + pub fn record_canceled(&self, guid: &str) { + self.with_mut(|downloads| match downloads.get_mut(guid) { + Some(record) => record.state = BrowserDownloadLifecycle::Canceled, + None => { + downloads.insert( + guid.to_owned(), + BrowserDownloadRecord { + state: BrowserDownloadLifecycle::Canceled, + artifact_path: None, + }, + ); + } + }); + } + + pub fn cancel(&self, guid: &str) -> BrowserDownloadCancelOutcome { + self.with_mut(|downloads| match downloads.get_mut(guid) { + Some(BrowserDownloadRecord { + state: BrowserDownloadLifecycle::Active(cancel_handle), + .. + }) => { + cancel_handle.cancel(); + BrowserDownloadCancelOutcome::Handled + } + Some(_) => BrowserDownloadCancelOutcome::AlreadyTerminal, + None => BrowserDownloadCancelOutcome::NotFound, + }) + } + + pub fn artifact(&self, guid: &str) -> BrowserDownloadArtifactOutcome { + self.with_mut(|downloads| match downloads.get(guid) { + Some(BrowserDownloadRecord { + state: BrowserDownloadLifecycle::Completed, + artifact_path: Some(path), + }) => BrowserDownloadArtifactOutcome::Ready(path.clone()), + Some(BrowserDownloadRecord { + state: BrowserDownloadLifecycle::Active(_), + .. + }) => BrowserDownloadArtifactOutcome::InProgress, + Some(_) => BrowserDownloadArtifactOutcome::NotAvailable, + None => BrowserDownloadArtifactOutcome::NotFound, + }) + } + + fn with_mut( + &self, + operation: impl FnOnce(&mut HashMap) -> T, + ) -> T { + operation(&mut self.inner.lock()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_policy_overrides_global_behavior_and_path() { + let mut state = BrowserDownloadPolicyState::default(); + state.apply(BrowserDownloadPolicyUpdate::SetGlobal { + behavior: BrowserDownloadBehavior::Deny, + download_path: None, + }); + state.apply(BrowserDownloadPolicyUpdate::SetBrowserContext { + browser_context_id: "context-a".to_owned(), + behavior: BrowserDownloadBehavior::AllowAndName, + download_path: Some("/second".to_owned()), + }); + + let policy = state.effective_for_browser_context(Some("context-a")); + assert_eq!(policy.behavior(), BrowserDownloadBehavior::AllowAndName); + assert_eq!(policy.download_path(), Some("/second")); + } + + #[test] + fn registry_handle_keeps_terminal_artifact_visible() { + let registry = BrowserDownloadRegistry::default(); + let observer = registry.clone(); + let path = PathBuf::from("/tmp/download-artifact"); + + registry.record_completed("download-1", path.clone()); + drop(registry); + + assert_eq!( + observer.artifact("download-1"), + BrowserDownloadArtifactOutcome::Ready(path) + ); + } +} diff --git a/moli-core/src/browser_host/fact_journal.rs b/moli-core/src/browser_host/fact_journal.rs new file mode 100644 index 0000000000..33dd897701 --- /dev/null +++ b/moli-core/src/browser_host/fact_journal.rs @@ -0,0 +1,651 @@ +use std::{ + collections::VecDeque, + fmt, + num::{NonZeroU64, NonZeroUsize}, + sync::Arc, +}; + +use tokio::sync::{broadcast, watch}; + +use crate::page::{ + RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, + RendererLifecycleEventStamp, RendererLifecycleTerminationStamp, +}; + +use super::{ + BrowserContextId, BrowserDocumentNavigation, BrowserInstanceId, BrowserNavigationFailure, + BrowserTargetId, BrowserTargetMetadataTransition, PageResidenceIdentity, +}; + +const DEFAULT_BROWSER_FACT_JOURNAL_CAPACITY: usize = 1_024; + +/// Monotonic order allocated by one Browser Owner for protocol-neutral facts. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct BrowserFactSequence(NonZeroU64); + +impl BrowserFactSequence { + pub fn get(self) -> u64 { + self.0.get() + } +} + +/// Protocol-neutral browser state transition. +/// +/// This intentionally starts narrow. Renderer-local inspector output and +/// large payloads do not belong in the Browser fact stream. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserFact { + /// One top-level Target became live in Browser Core topology. The + /// envelope carries its initial exact Page-slot residence. DevTools tab + /// facades, attachment state and discovery subscription are frontend + /// projections and are intentionally absent. + TargetCreated, + /// Browser-visible metadata changed for one exact top-level Target/Page + /// occurrence. Attachment state and frontend discovery policy are not + /// metadata transitions and never enter this fact. + TargetMetadataChanged { + transition: BrowserTargetMetadataTransition, + }, + /// One exact cross-Document request became the Target's pending request. + /// The envelope carries the Page generation it targets, while + /// `superseded_navigation` identifies the predecessor retired by this + /// same admission occurrence, if any. + NavigationAccepted { + navigation: BrowserDocumentNavigation, + superseded_navigation: Option, + }, + /// One exact cross-Document request atomically committed its successor + /// Document and replaced the Target's previous Page generation. The + /// envelope carries the committed successor Page while the payload retains + /// the retired source Page. + NavigationCommitted { + navigation: BrowserDocumentNavigation, + previous_page: PageResidenceIdentity, + }, + /// One exact accepted request reached a non-commit terminal state. The + /// envelope carries the Page generation after the terminal transaction. + /// `previous_page` is present only when that same transaction retired the + /// prior Page generation. + NavigationFailed { + navigation: BrowserDocumentNavigation, + failure: BrowserNavigationFailure, + previous_page: Option, + }, + /// One exact accepted navigation became a download and therefore did not + /// create a successor Document. The current Page remains resident. + NavigationConvertedToDownload { + navigation: BrowserDocumentNavigation, + }, + /// One exact Target crash retired its current Page generation while + /// preserving the Target for a possible recovery navigation. The + /// envelope carries the terminal Page generation. + TargetCrashed { + previous_page: PageResidenceIdentity, + pending_navigation: Option, + }, + /// One exact Target close atomically retired both the Target and its + /// current Page generation. The envelope carries the terminal Page + /// identity even though that residence is no longer live in the registry. + TargetClosed { + previous_page: PageResidenceIdentity, + pending_navigation: Option, + }, + DocumentLifecycleReached { + document: RendererDocumentLifecycleIdentity, + milestone: RendererDocumentLifecycleMilestone, + stamp: RendererLifecycleEventStamp, + }, + /// One exact renderer Document terminated. A waiter can distinguish a + /// milestone that had already been reached from an interrupted wait by + /// inspecting `last_reached`. + DocumentLifecycleTerminated { + document: RendererDocumentLifecycleIdentity, + last_reached: Option, + termination: RendererLifecycleTerminationStamp, + }, +} + +/// Immutable fact together with the exact Browser source that produced it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserFactEnvelope { + sequence: BrowserFactSequence, + browser_instance_id: BrowserInstanceId, + page_residence: PageResidenceIdentity, + fact: BrowserFact, +} + +impl BrowserFactEnvelope { + pub fn sequence(&self) -> BrowserFactSequence { + self.sequence + } + + pub fn browser_instance_id(&self) -> BrowserInstanceId { + self.browser_instance_id + } + + pub fn browser_context_id(&self) -> &BrowserContextId { + self.page_residence.browser_context_identity() + } + + pub fn target_id(&self) -> &BrowserTargetId { + self.page_residence + .target_identity() + .expect("Browser fact envelopes are always target-backed") + } + + pub fn page_residence(&self) -> &PageResidenceIdentity { + &self.page_residence + } + + pub fn fact(&self) -> &BrowserFact { + &self.fact + } +} + +/// Rejection before a browser transition can enter the fact journal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserFactPublishError { + TargetlessPageResidence(PageResidenceIdentity), + StalePageResidence(PageResidenceIdentity), + SequenceExhausted, +} + +impl fmt::Display for BrowserFactPublishError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TargetlessPageResidence(page) => write!( + formatter, + "Page residence in BrowserContext {:?} has no Target identity", + page.browser_context_id() + ), + Self::StalePageResidence(page) => write!( + formatter, + "Page residence for Target {:?} in BrowserContext {:?} is stale", + page.target_id(), + page.browser_context_id() + ), + Self::SequenceExhausted => formatter.write_str("Browser fact sequence is exhausted"), + } + } +} + +impl std::error::Error for BrowserFactPublishError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserFactReceiveError { + Lagged { skipped: u64 }, + Closed, +} + +impl fmt::Display for BrowserFactReceiveError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Lagged { skipped } => { + write!( + formatter, + "Browser fact subscriber lagged by {skipped} facts" + ) + } + Self::Closed => formatter.write_str("Browser fact journal is closed"), + } + } +} + +impl std::error::Error for BrowserFactReceiveError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserFactTryReceiveError { + Empty, + Lagged { skipped: u64 }, + Closed, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserFactWakeReceiveError { + Closed, +} + +impl fmt::Display for BrowserFactWakeReceiveError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Closed => formatter.write_str("Browser fact wake subscription is closed"), + } + } +} + +impl std::error::Error for BrowserFactWakeReceiveError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserFactWakeTryReceiveError { + Empty, + Closed, +} + +/// Coalesced, payload-free wake subscription for an application scheduler. +/// +/// The retained journal remains the source of truth. This subscription only +/// reports the newest sequence that may be consumed, so an idle frontend can +/// wake without allocating a second per-fact queue or backpressuring Browser +/// Owner publication. +pub struct BrowserFactWakeSubscriber { + browser_instance_id: BrowserInstanceId, + receiver: watch::Receiver>, + last_delivered: Option, +} + +impl BrowserFactWakeSubscriber { + pub fn browser_instance_id(&self) -> BrowserInstanceId { + self.browser_instance_id + } + + pub async fn recv(&mut self) -> Result { + loop { + let newest = *self.receiver.borrow_and_update(); + if newest != self.last_delivered + && let Some(sequence) = newest + { + self.last_delivered = Some(sequence); + return Ok(sequence); + } + self.receiver + .changed() + .await + .map_err(|_| BrowserFactWakeReceiveError::Closed)?; + } + } + + pub fn try_recv(&mut self) -> Result { + loop { + let newest = *self.receiver.borrow_and_update(); + if newest != self.last_delivered + && let Some(sequence) = newest + { + self.last_delivered = Some(sequence); + return Ok(sequence); + } + match self.receiver.has_changed() { + Ok(true) => continue, + Ok(false) => return Err(BrowserFactWakeTryReceiveError::Empty), + Err(_) => return Err(BrowserFactWakeTryReceiveError::Closed), + } + } + } +} + +/// Independent bounded cursor over retained bootstrap and future facts. +/// +/// A slow consumer receives an explicit lag result. Publishing never awaits a +/// frontend and therefore cannot backpressure the Browser Owner. +pub struct BrowserFactSubscriber { + browser_instance_id: BrowserInstanceId, + replay: VecDeque>, + receiver: broadcast::Receiver>, +} + +impl BrowserFactSubscriber { + pub fn browser_instance_id(&self) -> BrowserInstanceId { + self.browser_instance_id + } + + pub async fn recv(&mut self) -> Result, BrowserFactReceiveError> { + if let Some(fact) = self.replay.pop_front() { + return Ok(fact); + } + self.receiver.recv().await.map_err(|error| match error { + broadcast::error::RecvError::Lagged(skipped) => { + BrowserFactReceiveError::Lagged { skipped } + } + broadcast::error::RecvError::Closed => BrowserFactReceiveError::Closed, + }) + } + + pub fn try_recv(&mut self) -> Result, BrowserFactTryReceiveError> { + if let Some(fact) = self.replay.pop_front() { + return Ok(fact); + } + self.receiver.try_recv().map_err(|error| match error { + broadcast::error::TryRecvError::Empty => BrowserFactTryReceiveError::Empty, + broadcast::error::TryRecvError::Lagged(skipped) => { + BrowserFactTryReceiveError::Lagged { skipped } + } + broadcast::error::TryRecvError::Closed => BrowserFactTryReceiveError::Closed, + }) + } +} + +/// Bounded Browser-owned record plus non-blocking subscriber fanout. +pub(super) struct BrowserFactJournal { + browser_instance_id: BrowserInstanceId, + capacity: NonZeroUsize, + next_sequence: Option, + retained: VecDeque>, + sender: broadcast::Sender>, + wake_sender: watch::Sender>, +} + +impl BrowserFactJournal { + pub(super) fn new(browser_instance_id: BrowserInstanceId) -> Self { + let capacity = + NonZeroUsize::new(DEFAULT_BROWSER_FACT_JOURNAL_CAPACITY).unwrap_or(NonZeroUsize::MIN); + Self::with_capacity(browser_instance_id, capacity) + } + + fn with_capacity(browser_instance_id: BrowserInstanceId, capacity: NonZeroUsize) -> Self { + let (sender, _) = broadcast::channel(capacity.get()); + let (wake_sender, _) = watch::channel(None); + Self { + browser_instance_id, + capacity, + next_sequence: Some(NonZeroU64::MIN), + retained: VecDeque::with_capacity(capacity.get()), + sender, + wake_sender, + } + } + + pub(super) fn subscribe(&self) -> BrowserFactSubscriber { + // Register for future facts before copying the retained window. The + // single Browser owner prevents concurrent mutation today, and this + // order also preserves a gap-free cut if the journal later moves + // behind an independently scheduled Host boundary. + let receiver = self.sender.subscribe(); + BrowserFactSubscriber { + browser_instance_id: self.browser_instance_id, + replay: self.retained.iter().cloned().collect(), + receiver, + } + } + + pub(super) fn subscribe_pair(&self) -> (BrowserFactSubscriber, BrowserFactSubscriber) { + // Both receivers are registered before taking one retained snapshot, + // so the pair represents one gap-free journal cut even after Browser + // Host subscription becomes an independently scheduled operation. + let first_receiver = self.sender.subscribe(); + let second_receiver = self.sender.subscribe(); + let replay = self.retained.iter().cloned().collect::>(); + ( + BrowserFactSubscriber { + browser_instance_id: self.browser_instance_id, + replay: replay.clone(), + receiver: first_receiver, + }, + BrowserFactSubscriber { + browser_instance_id: self.browser_instance_id, + replay, + receiver: second_receiver, + }, + ) + } + + pub(super) fn subscribe_wake(&self) -> BrowserFactWakeSubscriber { + BrowserFactWakeSubscriber { + browser_instance_id: self.browser_instance_id, + receiver: self.wake_sender.subscribe(), + last_delivered: None, + } + } + + pub(super) fn snapshot(&self) -> Vec> { + self.retained.iter().cloned().collect() + } + + pub(super) fn publish_batch( + &mut self, + page_residence: PageResidenceIdentity, + facts: Vec, + ) -> Result>, BrowserFactPublishError> { + if page_residence.target_identity().is_none() { + return Err(BrowserFactPublishError::TargetlessPageResidence( + page_residence, + )); + } + if facts.is_empty() { + return Ok(Vec::new()); + } + let first_sequence = self + .next_sequence + .ok_or(BrowserFactPublishError::SequenceExhausted)?; + let sequence_span = u64::try_from(facts.len() - 1) + .map_err(|_| BrowserFactPublishError::SequenceExhausted)?; + let last_sequence = first_sequence + .get() + .checked_add(sequence_span) + .ok_or(BrowserFactPublishError::SequenceExhausted)?; + self.next_sequence = last_sequence.checked_add(1).and_then(NonZeroU64::new); + + let mut published = Vec::with_capacity(facts.len()); + for (raw_sequence, fact) in (first_sequence.get()..=last_sequence).zip(facts) { + let sequence = + BrowserFactSequence(NonZeroU64::new(raw_sequence).unwrap_or(NonZeroU64::MIN)); + let envelope = Arc::new(BrowserFactEnvelope { + sequence, + browser_instance_id: self.browser_instance_id, + page_residence: page_residence.clone(), + fact, + }); + if self.retained.len() == self.capacity.get() { + self.retained.pop_front(); + } + self.retained.push_back(Arc::clone(&envelope)); + // No active subscriber is a valid state. The retained ring still + // records the transition, while future facts keep their sequence. + let _ = self.sender.send(Arc::clone(&envelope)); + published.push(envelope); + } + // One payload-free notification closes the whole committed batch. + // `send_replace` is non-blocking and coalesces any slow application + // scheduler to the latest sequence; the bounded journal cursor still + // detects whether the subscriber can consume the intervening facts. + self.wake_sender.send_replace(Some(BrowserFactSequence( + NonZeroU64::new(last_sequence).unwrap_or(NonZeroU64::MIN), + ))); + Ok(published) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + PageId, + page::{ + RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, + RendererDocumentToken, RendererFrameToken, RendererLifecycleEpoch, + RendererLifecycleEventStamp, + }, + }; + + fn lifecycle_fact(sequence: u64) -> BrowserFact { + let page_id = PageId::new_for_testing(7); + BrowserFact::DocumentLifecycleReached { + document: RendererDocumentLifecycleIdentity { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, 3), + epoch: RendererLifecycleEpoch(2), + }, + milestone: RendererDocumentLifecycleMilestone::Load, + stamp: RendererLifecycleEventStamp { + sequence, + timestamp_micros: sequence * 10, + }, + } + } + + fn publish(journal: &mut BrowserFactJournal, source_sequence: u64) { + journal + .publish_batch( + PageResidenceIdentity::new("context-1".to_owned(), Some("target-1".to_owned()), 0), + vec![lifecycle_fact(source_sequence)], + ) + .expect("test fact sequence should remain available"); + } + + #[test] + fn journal_retains_unsubscribed_facts_with_monotonic_browser_sequence() { + let instance = BrowserInstanceId::allocate(); + let mut journal = BrowserFactJournal::with_capacity( + instance, + NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN), + ); + + publish(&mut journal, 10); + publish(&mut journal, 20); + publish(&mut journal, 30); + + let retained = journal.snapshot(); + assert_eq!(retained.len(), 2); + assert_eq!(retained[0].sequence().get(), 2); + assert_eq!(retained[1].sequence().get(), 3); + assert!( + retained + .iter() + .all(|fact| fact.browser_instance_id() == instance) + ); + let mut subscriber = journal.subscribe(); + assert_eq!( + subscriber + .try_recv() + .expect("bounded bootstrap should replay first retained fact") + .sequence() + .get(), + 2 + ); + assert_eq!( + subscriber + .try_recv() + .expect("bounded bootstrap should replay second retained fact") + .sequence() + .get(), + 3 + ); + } + + #[test] + fn envelope_identity_is_derived_from_its_target_backed_page_source() { + let mut journal = BrowserFactJournal::new(BrowserInstanceId::allocate()); + publish(&mut journal, 10); + + let envelope = journal + .snapshot() + .pop() + .expect("published fact should be retained"); + assert_eq!(envelope.browser_context_id().as_str(), "context-1"); + assert_eq!(envelope.target_id().as_str(), "target-1"); + assert_eq!(envelope.page_residence().browser_context_id(), "context-1"); + assert_eq!(envelope.page_residence().target_id(), Some("target-1")); + } + + #[test] + fn targetless_page_source_is_rejected_before_allocating_a_fact_sequence() { + let mut journal = BrowserFactJournal::new(BrowserInstanceId::allocate()); + let targetless = PageResidenceIdentity::new("context-1".to_owned(), None, 0); + + assert_eq!( + journal.publish_batch(targetless.clone(), vec![lifecycle_fact(10)]), + Err(BrowserFactPublishError::TargetlessPageResidence(targetless)) + ); + publish(&mut journal, 20); + assert_eq!( + journal + .snapshot() + .pop() + .expect("valid fact should publish after rejection") + .sequence() + .get(), + 1 + ); + } + + #[test] + fn slow_subscriber_reports_bounded_lag_without_blocking_publication() { + let instance = BrowserInstanceId::allocate(); + let mut journal = BrowserFactJournal::with_capacity( + instance, + NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN), + ); + let mut subscriber = journal.subscribe(); + + publish(&mut journal, 10); + publish(&mut journal, 20); + publish(&mut journal, 30); + + assert_eq!( + subscriber.try_recv(), + Err(BrowserFactTryReceiveError::Lagged { skipped: 1 }) + ); + assert_eq!( + subscriber + .try_recv() + .expect("first retained subscriber fact") + .sequence() + .get(), + 2 + ); + assert_eq!( + subscriber + .try_recv() + .expect("second retained subscriber fact") + .sequence() + .get(), + 3 + ); + assert_eq!( + subscriber.try_recv(), + Err(BrowserFactTryReceiveError::Empty) + ); + } + + #[test] + fn application_wake_coalesces_to_latest_sequence_and_bootstraps_late_subscriber() { + let instance = BrowserInstanceId::allocate(); + let mut journal = BrowserFactJournal::with_capacity( + instance, + NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN), + ); + let mut wake = journal.subscribe_wake(); + + assert_eq!(wake.try_recv(), Err(BrowserFactWakeTryReceiveError::Empty)); + publish(&mut journal, 10); + publish(&mut journal, 20); + publish(&mut journal, 30); + + assert_eq!( + wake.try_recv() + .expect("one coalesced wake should expose the latest sequence") + .get(), + 3 + ); + assert_eq!(wake.try_recv(), Err(BrowserFactWakeTryReceiveError::Empty)); + + let mut late_wake = journal.subscribe_wake(); + assert_eq!(late_wake.browser_instance_id(), instance); + assert_eq!( + late_wake + .try_recv() + .expect("a late application subscriber should bootstrap the retained tail") + .get(), + 3 + ); + } + + #[tokio::test] + async fn application_wake_delivers_committed_tail_before_closed_terminal() { + let instance = BrowserInstanceId::allocate(); + let mut journal = BrowserFactJournal::with_capacity( + instance, + NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN), + ); + let mut wake = journal.subscribe_wake(); + publish(&mut journal, 10); + drop(journal); + + assert_eq!( + wake.recv() + .await + .expect("the committed tail must survive sender shutdown") + .get(), + 1 + ); + assert_eq!(wake.recv().await, Err(BrowserFactWakeReceiveError::Closed)); + } +} diff --git a/moli-core/src/browser_host/handle.rs b/moli-core/src/browser_host/handle.rs new file mode 100644 index 0000000000..09b28cc6b3 --- /dev/null +++ b/moli-core/src/browser_host/handle.rs @@ -0,0 +1,54 @@ +use tokio::sync::mpsc; + +use super::{BrowserOwnerInput, BrowserOwnerInputKind}; + +/// Cloneable producer endpoint for the protocol-neutral Browser Owner lane. +/// +/// Publishing is synchronous and never executes browser work recursively in +/// the renderer-output or frontend stack. The single Browser Host receiver +/// remains the only input-selection authority. +#[derive(Clone, Debug)] +pub struct BrowserHostHandle { + input_tx: mpsc::UnboundedSender, +} + +impl BrowserHostHandle { + pub(super) fn new(input_tx: mpsc::UnboundedSender) -> Self { + Self { input_tx } + } + + pub fn publish(&self, input: BrowserOwnerInput) -> Result<(), BrowserHostInputPublishError> { + let kind = input.kind(); + self.input_tx + .send(input) + .map_err(|_| BrowserHostInputPublishError { kind }) + } + + pub fn is_stopped(&self) -> bool { + self.input_tx.is_closed() + } +} + +/// Typed rejection returned when the Browser Host input owner has stopped. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BrowserHostInputPublishError { + kind: BrowserOwnerInputKind, +} + +impl BrowserHostInputPublishError { + pub fn kind(self) -> BrowserOwnerInputKind { + self.kind + } +} + +impl std::fmt::Display for BrowserHostInputPublishError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "Browser Host stopped before accepting {:?}", + self.kind + ) + } +} + +impl std::error::Error for BrowserHostInputPublishError {} diff --git a/moli-core/src/browser_host/identity.rs b/moli-core/src/browser_host/identity.rs new file mode 100644 index 0000000000..fa16adffce --- /dev/null +++ b/moli-core/src/browser_host/identity.rs @@ -0,0 +1,27 @@ +/// Protocol-neutral identity of one BrowserContext. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BrowserContextId(String); + +impl BrowserContextId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Protocol-neutral identity of one browser Target. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BrowserTargetId(String); + +impl BrowserTargetId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/moli-core/src/browser_host/identity_allocator.rs b/moli-core/src/browser_host/identity_allocator.rs new file mode 100644 index 0000000000..03e26d152d --- /dev/null +++ b/moli-core/src/browser_host/identity_allocator.rs @@ -0,0 +1,123 @@ +use std::{ + cell::Cell, + num::NonZeroU64, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use super::BrowserCommandId; + +/// Process-shareable source for public Browser Target identities. +/// +/// A server may expose more than one Browser Host while keeping all Target +/// ids in one DevTools discovery namespace. The application therefore owns +/// and clones this source into each participating Host; frontend connections +/// never own or reset it. +#[derive(Clone, Debug, Default)] +pub struct BrowserTargetIdAllocator { + next_sequence: Arc, +} + +impl BrowserTargetIdAllocator { + fn allocate_sequence(&self) -> u64 { + // Relaxed is intentional: this atomic carries uniqueness only. + // Browser Host mailbox/commit boundaries publish the payloads that + // later use the allocated identity. + self.next_sequence + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .expect("Browser Target id space exhausted") + + 1 + } +} + +/// Monotonic identity namespace owned by one Browser Host. +#[derive(Debug)] +pub(crate) struct BrowserHostIdentityState { + next_browser_context_sequence: Cell, + next_browser_command_sequence: Cell, + target_ids: BrowserTargetIdAllocator, +} + +impl BrowserHostIdentityState { + pub(crate) fn new(target_ids: BrowserTargetIdAllocator) -> Self { + Self { + next_browser_context_sequence: Cell::new(0), + next_browser_command_sequence: Cell::new(0), + target_ids, + } + } + + pub(crate) fn allocate_browser_context_sequence(&self) -> u64 { + next_nonzero_sequence(&self.next_browser_context_sequence) + } + + pub(crate) fn allocate_target_sequence(&self) -> u64 { + self.target_ids.allocate_sequence() + } + + pub(crate) fn allocate_browser_command_id(&self) -> BrowserCommandId { + let sequence = next_nonzero_sequence(&self.next_browser_command_sequence); + let nonzero = NonZeroU64::new(sequence).unwrap_or(NonZeroU64::MIN); + BrowserCommandId::new(nonzero) + } +} + +fn next_nonzero_sequence(sequence: &Cell) -> u64 { + let next = sequence + .get() + .checked_add(1) + .expect("Browser Host identity space exhausted"); + sequence.set(next); + next +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cloned_target_allocator_keeps_one_shared_sequence() { + let first = BrowserTargetIdAllocator::default(); + let second = first.clone(); + + assert_eq!(first.allocate_sequence(), 1); + assert_eq!(second.allocate_sequence(), 2); + assert_eq!(first.allocate_sequence(), 3); + } + + #[test] + fn host_context_and_command_sequences_are_independent() { + let identities = BrowserHostIdentityState::new(BrowserTargetIdAllocator::default()); + + assert_eq!(identities.allocate_browser_context_sequence(), 1); + assert_eq!(identities.allocate_browser_command_id().get(), 1); + assert_eq!(identities.allocate_browser_context_sequence(), 2); + assert_eq!(identities.allocate_browser_command_id().get(), 2); + } + + #[test] + #[should_panic(expected = "Browser Target id space exhausted")] + fn target_sequence_never_wraps() { + let identities = BrowserTargetIdAllocator { + next_sequence: Arc::new(AtomicU64::new(u64::MAX)), + }; + + let _ = identities.allocate_sequence(); + } + + #[test] + #[should_panic(expected = "Browser Host identity space exhausted")] + fn host_sequence_never_wraps() { + let identities = BrowserHostIdentityState { + next_browser_context_sequence: Cell::new(u64::MAX), + next_browser_command_sequence: Cell::new(0), + target_ids: BrowserTargetIdAllocator::default(), + }; + + let _ = identities.allocate_browser_context_sequence(); + } +} diff --git a/moli-core/src/browser_host/initial_target_navigation.rs b/moli-core/src/browser_host/initial_target_navigation.rs new file mode 100644 index 0000000000..939ce9614b --- /dev/null +++ b/moli-core/src/browser_host/initial_target_navigation.rs @@ -0,0 +1,76 @@ +use super::{BrowserCommandId, PageResidenceIdentity}; + +/// Exact initial Target URL navigation admitted to Browser Owner. +/// +/// A Target is first materialized with its initial empty Document and may have +/// a different creation URL waiting to replace it. DevTools commands can +/// trigger that transition, but neither their session nor their response +/// route owns it. The immutable destination and exact Page residence are all +/// the Browser Host mailbox needs. +#[derive(Debug)] +pub struct BrowserInitialTargetNavigationInput { + page_owner: PageResidenceIdentity, + url: String, +} + +impl BrowserInitialTargetNavigationInput { + pub(super) fn new(page_owner: PageResidenceIdentity, url: String) -> Self { + Self { page_owner, url } + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn into_parts(self) -> (PageResidenceIdentity, String) { + (self.page_owner, self.url) + } +} + +/// Frontend request to ensure one exact Target has left its initial empty +/// Document before the dependent command continues. +/// +/// `BrowserCommandId` is opaque Browser/Protocol correlation. CDP request ids, +/// sessions, response payloads and the dependent command state remain in the +/// frontend sidecar; Browser Owner receives only the browser action and exact +/// Page authority it must serialize. +#[derive(Debug)] +pub struct BrowserInitialTargetNavigationCommandInput { + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + url: String, +} + +impl BrowserInitialTargetNavigationCommandInput { + pub(super) fn new( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + url: String, + ) -> Self { + Self { + command_id, + page_owner, + url, + } + } + + pub fn command_id(&self) -> BrowserCommandId { + self.command_id + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn into_parts(self) -> (BrowserCommandId, PageResidenceIdentity, String) { + (self.command_id, self.page_owner, self.url) + } +} diff --git a/moli-core/src/browser_host/mod.rs b/moli-core/src/browser_host/mod.rs new file mode 100644 index 0000000000..9518807d75 --- /dev/null +++ b/moli-core/src/browser_host/mod.rs @@ -0,0 +1,118 @@ +//! Protocol-neutral browser owner identities and contracts. +//! +//! The first migration step keeps the existing protocol scheduler, but moves +//! identities used to authorize browser actions out of the CDP connection +//! model. Later Browser Host state and queues build on this boundary. + +mod actor; +mod auxiliary_navigation; +mod command_outcome; +mod context_handle; +mod context_runtime_registry; +mod document_lifecycle_wait; +mod download; +mod fact_journal; +mod handle; +mod identity; +mod identity_allocator; +mod initial_target_navigation; +mod navigation_access; +mod navigation_owner; +mod network_artifacts; +mod network_body; +mod owner_input; +mod page_payload; +mod page_residence; +mod policy; +mod state; +mod target_handle; +mod target_session_storage; +mod turn; + +pub use actor::{BrowserHostActor, BrowserHostTurnSelection}; +pub use auxiliary_navigation::{BrowserAuxiliaryNavigationInput, BrowserAuxiliaryNavigationKind}; +pub use command_outcome::{ + BrowserHistoryTraversalResult, BrowserNavigateCommandError, BrowserNavigateCommandErrorKind, + BrowserNavigateCommandOutcome, BrowserNavigateCommandResult, +}; +pub use context_handle::BrowserContextHandle; +pub use context_runtime_registry::BrowserContextRuntimeRegistryError; +pub use document_lifecycle_wait::{ + BrowserDocumentLifecycleWaitOutcome, BrowserDocumentLifecycleWaitReadiness, + BrowserDocumentLifecycleWaitTicket, BrowserDocumentLifecycleWaitUnavailableReason, +}; +pub use download::{ + BrowserDownloadArtifactOutcome, BrowserDownloadBehavior, BrowserDownloadCancelOutcome, + BrowserDownloadPolicy, BrowserDownloadPolicyState, BrowserDownloadPolicyUpdate, + BrowserDownloadRegistry, +}; +pub use fact_journal::{ + BrowserFact, BrowserFactEnvelope, BrowserFactPublishError, BrowserFactReceiveError, + BrowserFactSequence, BrowserFactSubscriber, BrowserFactTryReceiveError, + BrowserFactWakeReceiveError, BrowserFactWakeSubscriber, BrowserFactWakeTryReceiveError, +}; +pub use handle::{BrowserHostHandle, BrowserHostInputPublishError}; +pub use identity::{BrowserContextId, BrowserTargetId}; +pub use identity_allocator::BrowserTargetIdAllocator; +pub use initial_target_navigation::{ + BrowserInitialTargetNavigationCommandInput, BrowserInitialTargetNavigationInput, +}; +pub use navigation_owner::{ + BrowserContextActivation, BrowserContextDisposalReservation, BrowserContextRegistration, + BrowserContextRegistrationMetadata, BrowserContextRegistryError, BrowserContextRemoval, + BrowserContextRemovalPermit, BrowserContextSelectionProjection, BrowserContextTargetSnapshot, + BrowserDocumentNavigation, BrowserExactHistoryTraversalResolutionError, + BrowserHistoryTraversalDestination, BrowserHistoryTraversalResolution, + BrowserHistoryTraversalResolutionError, BrowserInitialEmptyDocumentCreator, + BrowserInitialEmptyDocumentSeed, BrowserInitialEmptyDocumentSnapshot, BrowserInstanceId, + BrowserNavigationFailure, BrowserNavigationHistory, BrowserNavigationHistoryEntry, + BrowserNavigationHistoryPageSnapshot, BrowserNavigationHistorySeed, + BrowserNavigationHistoryUpdate, BrowserNavigationOwner, BrowserNavigationRequestId, + BrowserNavigationTraceContext, BrowserNavigationTraceEvent, BrowserNavigationTraceSource, + BrowserPageFetchConfiguration, BrowserPageOwnerKey, BrowserPageReplacement, + BrowserPageReplacementCommitError, BrowserPageReplacementPermit, + BrowserPageResidenceRegistryError, BrowserPageResidenceTransition, + BrowserPageResidenceTransitionCommitError, BrowserPageResidenceTransitionKind, + BrowserPageResidenceTransitionPermit, BrowserSameDocumentHistoryUpdateError, + BrowserSameDocumentNavigationCommitError, BrowserSelectedTargetEngineDisposition, + BrowserTargetActivation, BrowserTargetCreationMetadata, BrowserTargetEngineAdoptionError, + BrowserTargetEngineContextMismatch, BrowserTargetEngineHandoff, + BrowserTargetEngineHandoffOutcome, BrowserTargetEngineOwnerMismatch, + BrowserTargetEngineResidence, BrowserTargetMetadataTransition, BrowserTargetRegistration, + BrowserTargetRegistryError, BrowserTargetResidence, BrowserTargetSlotProjection, + BrowserTargetStateSnapshot, BrowserTargetTermination, BrowserTargetTerminationCommitError, + BrowserTargetTerminationKind, BrowserTargetTerminationPermit, BrowserTargetTerminationRequest, + BrowserTargetTopologyProjection, BrowserTopLevelTargetSnapshot, +}; +pub use network_artifacts::{BrowserNetworkArtifactStore, BrowserNetworkResponseBody}; +pub use network_body::{ + CapturedBody as BrowserNetworkBody, CapturedBodyChunkReader as BrowserNetworkBodyChunkReader, + CapturedBodyWriter as BrowserNetworkBodyWriter, + DEFAULT_BODY_MATERIALIZE_LIMIT as DEFAULT_BROWSER_NETWORK_BODY_MATERIALIZE_LIMIT, + ensure_materialize_limit as ensure_browser_network_body_materialize_limit, +}; +pub use owner_input::{ + BrowserAuxiliaryTargetActivationInput, BrowserCommandId, BrowserContextDisposalCommandInput, + BrowserFrontendCommand, BrowserHistoryTraversalCommandInput, BrowserNavigateCommandInput, + BrowserOwnerInput, BrowserOwnerInputKind, BrowserPageTerminationInput, + BrowserPausedNavigationAuthDecision, BrowserPausedNavigationContinueDecision, + BrowserPausedNavigationDecision, BrowserPausedNavigationDecisionInput, + BrowserPausedNavigationFulfillDecision, BrowserPausedNavigationResponseDecision, + BrowserReloadCommandInput, BrowserStopLoadingCommandInput, BrowserTargetTerminationInput, + RendererBrowserIntent, RendererTopLevelHistoryTraversalInput, + RendererTopLevelLocationNavigationInput, +}; +pub use page_payload::{ + BrowserPageRuntimeAccess, BrowserPageRuntimeLease, BrowserPageRuntimeOwner, +}; +pub use page_residence::{BrowserPageResidenceHandle, PageResidenceIdentity}; +pub use policy::{ + BrowserHostNetworkPolicySnapshot, BrowserHostPolicyState, BrowserHostPolicyUpdate, + BrowserPermissionOverride, BrowserWindowBounds, EmulatedGeolocationOverride, + EmulatedGeolocationOverrideState, EmulatedNetworkConditions, +}; +pub use state::BrowserHostState; +pub use target_handle::BrowserTargetHandle; +pub use target_session_storage::BrowserTargetSessionStorageAccess; +pub(crate) use target_session_storage::BrowserTargetSessionStorageSeed; +pub use turn::{BrowserHostTurn, BrowserHostTurnExecutor}; diff --git a/moli-core/src/browser_host/navigation_access.rs b/moli-core/src/browser_host/navigation_access.rs new file mode 100644 index 0000000000..a56d065ca3 --- /dev/null +++ b/moli-core/src/browser_host/navigation_access.rs @@ -0,0 +1,492 @@ +use std::{sync::Arc, time::Duration}; + +use crate::{ + RendererOutputTransportSender, + network::ResourceRequestClient, + page::{ + CompletedPageCommand, Page, PendingPageCommand, RendererCommandTurnOutput, + RendererDocumentLifecycleEvent, RendererPageLifetimeOwner, SameDocumentHistoryUpdate, + }, + runtime::{NavigationEngine, NavigationResourceStorageHandles}, +}; + +use super::{ + BrowserContextActivation, BrowserContextDisposalReservation, BrowserContextHandle, + BrowserContextRegistryError, BrowserContextSelectionProjection, BrowserDocumentNavigation, + BrowserExactHistoryTraversalResolutionError, BrowserFactEnvelope, BrowserFactPublishError, + BrowserHistoryTraversalDestination, BrowserHistoryTraversalResolution, + BrowserHistoryTraversalResolutionError, BrowserHostState, BrowserInitialEmptyDocumentSeed, + BrowserNavigationFailure, BrowserNavigationHistoryEntry, BrowserNavigationHistoryPageSnapshot, + BrowserNavigationHistorySeed, BrowserNavigationTraceContext, BrowserPageFetchConfiguration, + BrowserPageOwnerKey, BrowserPageReplacement, BrowserPageReplacementCommitError, + BrowserPageReplacementPermit, BrowserPageResidenceTransition, + BrowserPageResidenceTransitionCommitError, BrowserPageResidenceTransitionPermit, + BrowserPageRuntimeOwner, BrowserSameDocumentNavigationCommitError, BrowserTargetActivation, + BrowserTargetCreationMetadata, BrowserTargetEngineAdoptionError, + BrowserTargetEngineOwnerMismatch, BrowserTargetEngineResidence, BrowserTargetRegistration, + BrowserTargetRegistryError, BrowserTargetTermination, BrowserTargetTerminationCommitError, + BrowserTargetTerminationPermit, BrowserTargetTopologyProjection, PageResidenceIdentity, +}; + +/// Narrow mutation entry points for authoritative Browser navigation state. +/// +/// Protocol callers can request one complete owner operation, but cannot hold +/// a mutable borrow of the underlying registry aggregate or combine arbitrary +/// mutations outside this boundary. +impl BrowserHostState { + pub fn activate_browser_context( + &self, + browser_context_id: &str, + projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + self.navigation_owner_mut().activate_browser_context( + browser_context_id, + projection, + create_replacement, + ) + } + + pub fn begin_browser_context_disposal( + &self, + browser_context_handle: &BrowserContextHandle, + ) -> Result { + self.navigation_owner_mut() + .begin_browser_context_disposal(browser_context_handle) + } + + pub fn rollback_browser_context_disposal( + &self, + reservation: BrowserContextDisposalReservation, + ) -> bool { + self.navigation_owner_mut() + .rollback_browser_context_disposal(reservation) + } + + pub fn register_background_target_with_creation_metadata( + &self, + browser_context_id: &str, + target_id: &str, + creation_metadata: BrowserTargetCreationMetadata, + topology_projection: BrowserTargetTopologyProjection, + ) -> Result { + self.navigation_owner_mut() + .register_background_target_with_creation_metadata( + browser_context_id, + target_id, + creation_metadata, + topology_projection, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn register_active_target_with_creation_metadata( + &self, + browser_context_id: &str, + target_id: &str, + creation_metadata: BrowserTargetCreationMetadata, + topology_projection: BrowserTargetTopologyProjection, + selection_projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + self.navigation_owner_mut() + .register_active_target_with_creation_metadata( + browser_context_id, + target_id, + creation_metadata, + topology_projection, + selection_projection, + create_replacement, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn replace_active_target_with_creation_metadata( + &self, + browser_context_id: &str, + expected_target_id: &str, + replacement_target_id: &str, + creation_metadata: BrowserTargetCreationMetadata, + topology_projection: BrowserTargetTopologyProjection, + selection_projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + self.navigation_owner_mut() + .replace_active_target_with_creation_metadata( + browser_context_id, + expected_target_id, + replacement_target_id, + creation_metadata, + topology_projection, + selection_projection, + create_replacement, + ) + } + + pub fn activate_target( + &self, + browser_context_id: &str, + target_id: &str, + topology_projection: BrowserTargetTopologyProjection, + selection_projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + self.navigation_owner_mut().activate_target( + browser_context_id, + target_id, + topology_projection, + selection_projection, + create_replacement, + ) + } + + pub fn rollback_staged_background_target( + &self, + browser_context_id: &str, + target_id: &str, + topology_projection: BrowserTargetTopologyProjection, + ) -> Result, BrowserTargetRegistryError> { + self.navigation_owner_mut() + .rollback_staged_background_target(browser_context_id, target_id, topology_projection) + } + + pub fn commit_initial_document_page_materialization( + &self, + permit: BrowserPageResidenceTransitionPermit, + renderer_page_owner: &mut Option, + page_runtime_owner: &mut Option, + ) -> Result { + self.navigation_owner_mut() + .commit_initial_document_page_materialization( + permit, + renderer_page_owner, + page_runtime_owner, + ) + } + + pub fn commit_failed_navigation_page_discard( + &self, + permit: BrowserPageResidenceTransitionPermit, + ) -> Result { + self.navigation_owner_mut() + .commit_failed_navigation_page_discard(permit) + } + + pub fn commit_loaded_page_replacement( + &self, + permit: BrowserPageReplacementPermit, + history_page: BrowserNavigationHistoryPageSnapshot, + renderer_page_owner: &mut Option, + page_runtime_owner: &mut Option, + ) -> Result { + self.navigation_owner_mut().commit_loaded_page_replacement( + permit, + history_page, + renderer_page_owner, + page_runtime_owner, + ) + } + + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn commit_page_residence_transition_without_renderer_owner_for_testing( + &self, + permit: BrowserPageResidenceTransitionPermit, + ) -> Result { + self.navigation_owner_mut() + .commit_page_residence_transition_without_renderer_owner_for_testing(permit) + } + + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn commit_loaded_page_replacement_without_renderer_owner_for_testing( + &self, + permit: BrowserPageReplacementPermit, + history_page: BrowserNavigationHistoryPageSnapshot, + ) -> Result { + self.navigation_owner_mut() + .commit_loaded_page_replacement_without_renderer_owner_for_testing(permit, history_page) + } + + pub fn try_start_document_navigation_with_trace( + &self, + key: &BrowserPageOwnerKey, + loader_id: String, + trace: Option, + ) -> Option { + self.navigation_owner_mut() + .try_start_document_navigation_with_trace(key, loader_id, trace) + } + + pub fn commit_document_navigation_if_matches( + &self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + self.navigation_owner_mut() + .commit_document_navigation_if_matches(key, navigation) + } + + pub fn fail_document_navigation_if_matches( + &self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + failure: BrowserNavigationFailure, + ) -> bool { + self.navigation_owner_mut() + .fail_document_navigation_if_matches(key, navigation, failure) + } + + pub fn convert_document_navigation_to_download_if_matches( + &self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + self.navigation_owner_mut() + .convert_document_navigation_to_download_if_matches(key, navigation) + } + + pub fn record_document_lifecycle_facts( + &self, + expected_page: &PageResidenceIdentity, + events: &[RendererDocumentLifecycleEvent], + ) -> Result>, BrowserFactPublishError> { + self.navigation_owner_mut() + .record_document_lifecycle_facts(expected_page, events) + } + + pub fn register_target_initial_empty_document( + &self, + owner: &BrowserPageOwnerKey, + seed: BrowserInitialEmptyDocumentSeed, + ) -> Result<(), BrowserTargetRegistryError> { + self.navigation_owner_mut() + .register_target_initial_empty_document(owner, seed) + } + + pub fn mark_target_initial_empty_document_exited(&self, owner: &BrowserPageOwnerKey) { + self.navigation_owner_mut() + .mark_target_initial_empty_document_exited(owner); + } + + pub fn commit_target_termination( + &self, + permit: BrowserTargetTerminationPermit, + ) -> Result { + self.navigation_owner_mut() + .commit_target_termination(permit) + } + + pub fn configure_active_fetch(&self, configuration: BrowserPageFetchConfiguration) { + self.navigation_owner_mut() + .configure_active_fetch(configuration); + } + + pub fn ensure_active_resource_runtime_ready( + &self, + storage: NavigationResourceStorageHandles, + ) -> anyhow::Result<()> { + self.navigation_owner_mut() + .ensure_active_resource_runtime_ready(storage) + } + + pub fn rebuild_active_resource_request_client( + &self, + storage: NavigationResourceStorageHandles, + ) -> anyhow::Result { + self.navigation_owner_mut() + .rebuild_active_resource_request_client(storage) + } + + pub fn ensure_active_cookie_store( + &self, + storage: NavigationResourceStorageHandles, + ) -> anyhow::Result { + self.navigation_owner_mut() + .ensure_active_cookie_store(storage) + } + + pub fn reset_active_resource_runtime_without_loaded_page(&self) { + self.navigation_owner_mut() + .reset_active_resource_runtime_without_loaded_page(); + } + + pub fn start_active_page_child_frame_lifecycle_work( + &self, + storage: NavigationResourceStorageHandles, + page: &Page, + timeout: Duration, + ) -> anyhow::Result { + self.navigation_owner_mut() + .start_active_page_child_frame_lifecycle_work(storage, page, timeout) + } + + pub fn complete_active_page_child_frame_lifecycle_work( + &self, + page: &mut Page, + completion: CompletedPageCommand, + ) -> anyhow::Result<(bool, RendererCommandTurnOutput)> { + self.navigation_owner_mut() + .complete_active_page_child_frame_lifecycle_work(page, completion) + } + + pub fn set_renderer_output_transport_sender(&self, sender: RendererOutputTransportSender) { + self.navigation_owner_mut() + .set_renderer_output_transport_sender(sender); + } + + pub fn adopt_target_engine( + &self, + owner: BrowserPageOwnerKey, + residence: BrowserTargetEngineResidence, + engine: NavigationEngine, + ) -> Result<(), BrowserTargetEngineOwnerMismatch> { + self.navigation_owner_mut() + .adopt_target_engine(owner, residence, engine) + } + + pub fn adopt_registered_target_engine( + &self, + owner: BrowserPageOwnerKey, + engine: NavigationEngine, + ) -> Result { + self.navigation_owner_mut() + .adopt_registered_target_engine(owner, engine) + } + + pub fn adopt_selected_target_engine_or_unbound( + &self, + projected_owner: Option, + engine: NavigationEngine, + ) -> Result, BrowserTargetEngineAdoptionError> { + self.navigation_owner_mut() + .adopt_selected_target_engine_or_unbound(projected_owner, engine) + } + + pub fn navigation_history_snapshot( + &self, + key: &BrowserPageOwnerKey, + fallback_page_seed: Option, + ) -> (usize, Vec) { + self.navigation_owner_mut() + .navigation_history_snapshot(key, fallback_page_seed) + } + + pub fn resolve_navigation_history_traversal( + &self, + key: &BrowserPageOwnerKey, + fallback_page_seed: Option, + destination: BrowserHistoryTraversalDestination, + ) -> Result { + self.navigation_owner_mut() + .resolve_navigation_history_traversal(key, fallback_page_seed, destination) + } + + pub fn resolve_exact_navigation_history_traversal( + &self, + expected_page: &PageResidenceIdentity, + fallback_page_seed: Option, + destination: BrowserHistoryTraversalDestination, + ) -> Result + { + self.navigation_owner_mut() + .resolve_exact_navigation_history_traversal( + expected_page, + fallback_page_seed, + destination, + ) + } + + pub fn reset_navigation_history( + &self, + key: &BrowserPageOwnerKey, + fallback_page_seed: Option, + ) -> bool { + self.navigation_owner_mut() + .reset_navigation_history(key, fallback_page_seed) + } + + pub fn can_reset_navigation_history( + &self, + key: &BrowserPageOwnerKey, + fallback_page_seed: Option, + ) -> bool { + self.navigation_owner_mut() + .can_reset_navigation_history(key, fallback_page_seed) + } + + pub fn mark_next_navigation_history_replace_current(&self, key: &BrowserPageOwnerKey) { + self.navigation_owner_mut() + .mark_next_navigation_history_replace_current(key); + } + + pub fn mark_next_navigation_history_replace_initial_empty_document( + &self, + key: &BrowserPageOwnerKey, + ) { + self.navigation_owner_mut() + .mark_next_navigation_history_replace_initial_empty_document(key); + } + + pub fn mark_next_navigation_history_traverse_to_entry( + &self, + key: &BrowserPageOwnerKey, + entry_id: i32, + ) { + self.navigation_owner_mut() + .mark_next_navigation_history_traverse_to_entry(key, entry_id); + } + + pub fn clear_pending_navigation_history_update(&self, key: &BrowserPageOwnerKey) { + self.navigation_owner_mut() + .clear_pending_navigation_history_update(key); + } + + pub fn record_loaded_page_navigation_history( + &self, + key: &BrowserPageOwnerKey, + page: BrowserNavigationHistoryPageSnapshot, + ) { + self.navigation_owner_mut() + .record_loaded_page_navigation_history(key, page); + } + + pub fn update_current_document_title( + &self, + expected_page: &PageResidenceIdentity, + title: String, + ) -> Result, BrowserFactPublishError> { + self.navigation_owner_mut() + .update_current_document_title(expected_page, title) + } + + pub fn commit_same_document_navigation_history( + &self, + expected_page: &PageResidenceIdentity, + fallback_page_seed: Option, + url: String, + title: String, + update: SameDocumentHistoryUpdate, + ) -> Result<(), BrowserSameDocumentNavigationCommitError> { + self.navigation_owner_mut() + .commit_same_document_navigation_history( + expected_page, + fallback_page_seed, + url, + title, + update, + ) + } +} diff --git a/moli-core/src/browser_host/navigation_owner.rs b/moli-core/src/browser_host/navigation_owner.rs new file mode 100644 index 0000000000..c2e2c51664 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner.rs @@ -0,0 +1,186 @@ +use crate::runtime::NavigationEngine; + +mod context_creation; +mod context_registry; +mod document_build; +mod document_lifecycle_registry; +mod engine_registry; +mod facts; +mod history; +mod history_registry; +mod initial_document; +mod initial_document_registry; +mod navigation_trace; +mod page_registry; +mod page_replacement; +mod page_runtime; +mod page_transition; +mod request; +mod request_registry; +mod target_creation; +mod target_metadata; +mod target_registry; +mod target_runtime_registry; +mod target_snapshot; +mod target_termination; +mod target_transaction; +mod types; + +use history_registry::BrowserNavigationHistoryRegistry; +use initial_document_registry::BrowserInitialEmptyDocumentRegistry; +use page_registry::BrowserPageResidenceRegistry; +use request_registry::BrowserDocumentNavigationRegistry; +use target_registry::BrowserTargetRegistry; +use target_runtime_registry::BrowserTargetRuntimeRegistry; + +use super::fact_journal::BrowserFactJournal; +use context_registry::BrowserContextRegistry; +use document_lifecycle_registry::BrowserDocumentLifecycleRegistry; +use engine_registry::{BrowserContextEngineHandoff, BrowserTargetEngineRegistry}; + +pub use context_creation::BrowserContextRegistrationMetadata; +pub use context_registry::{ + BrowserContextActivation, BrowserContextDisposalReservation, BrowserContextRegistration, + BrowserContextRegistryError, BrowserContextRemoval, BrowserContextRemovalPermit, + BrowserContextSelectionProjection, +}; +pub use engine_registry::{ + BrowserSelectedTargetEngineDisposition, BrowserTargetEngineAdoptionError, + BrowserTargetEngineContextMismatch, BrowserTargetEngineHandoff, + BrowserTargetEngineHandoffOutcome, BrowserTargetEngineOwnerMismatch, + BrowserTargetEngineResidence, +}; +pub use history::{ + BrowserHistoryTraversalDestination, BrowserHistoryTraversalResolution, + BrowserHistoryTraversalResolutionError, BrowserNavigationHistory, + BrowserNavigationHistoryEntry, BrowserNavigationHistoryPageSnapshot, + BrowserNavigationHistorySeed, BrowserNavigationHistoryUpdate, + BrowserSameDocumentHistoryUpdateError, +}; +pub use history_registry::{ + BrowserExactHistoryTraversalResolutionError, BrowserSameDocumentNavigationCommitError, +}; +pub use initial_document::{ + BrowserInitialEmptyDocumentCreator, BrowserInitialEmptyDocumentSeed, + BrowserInitialEmptyDocumentSnapshot, +}; +pub use navigation_trace::{ + BrowserInstanceId, BrowserNavigationTraceContext, BrowserNavigationTraceEvent, + BrowserNavigationTraceSource, +}; +pub use page_registry::BrowserPageResidenceRegistryError; +pub use page_replacement::{ + BrowserPageReplacement, BrowserPageReplacementCommitError, BrowserPageReplacementPermit, +}; +pub use page_transition::{ + BrowserPageResidenceTransition, BrowserPageResidenceTransitionCommitError, + BrowserPageResidenceTransitionKind, BrowserPageResidenceTransitionPermit, +}; +pub use request::{ + BrowserDocumentNavigation, BrowserNavigationFailure, BrowserNavigationRequestId, +}; +pub use target_creation::BrowserTargetCreationMetadata; +pub use target_metadata::BrowserTargetMetadataTransition; +pub use target_registry::{ + BrowserTargetRegistryError, BrowserTargetResidence, BrowserTargetSlotProjection, + BrowserTargetTopologyProjection, +}; +pub use target_snapshot::{ + BrowserContextTargetSnapshot, BrowserTargetStateSnapshot, BrowserTopLevelTargetSnapshot, +}; +pub use target_termination::{ + BrowserTargetTermination, BrowserTargetTerminationCommitError, BrowserTargetTerminationKind, + BrowserTargetTerminationPermit, BrowserTargetTerminationRequest, +}; +pub use target_transaction::{BrowserTargetActivation, BrowserTargetRegistration}; +pub use types::{BrowserPageFetchConfiguration, BrowserPageOwnerKey}; + +/// Single strong owner of active and parked browser navigation runtimes. +/// +/// Frontends submit protocol-neutral Target/context handoffs; the focused +/// engine registry owns selected/retained identity and replacement. Page +/// runtime, resource, history, termination, and document-build operations live +/// in sibling modules and do not expose the underlying engine to protocol +/// callers. +pub struct BrowserNavigationOwner { + browser_instance_id: BrowserInstanceId, + browser_facts: BrowserFactJournal, + browser_contexts: BrowserContextRegistry, + targets: BrowserTargetRegistry, + target_runtimes: BrowserTargetRuntimeRegistry, + target_engines: BrowserTargetEngineRegistry, + page_residences: BrowserPageResidenceRegistry, + document_lifecycles: BrowserDocumentLifecycleRegistry, + initial_empty_documents: BrowserInitialEmptyDocumentRegistry, + navigation_histories: BrowserNavigationHistoryRegistry, + document_navigations: BrowserDocumentNavigationRegistry, + target_terminations: target_termination::BrowserTargetTerminationRegistry, +} + +impl BrowserNavigationOwner { + pub fn new(active_engine: NavigationEngine) -> Self { + let browser_instance_id = BrowserInstanceId::allocate(); + Self { + browser_instance_id, + browser_facts: BrowserFactJournal::new(browser_instance_id), + browser_contexts: BrowserContextRegistry::default(), + targets: BrowserTargetRegistry::default(), + target_runtimes: BrowserTargetRuntimeRegistry::default(), + target_engines: BrowserTargetEngineRegistry::new(active_engine), + page_residences: BrowserPageResidenceRegistry, + document_lifecycles: BrowserDocumentLifecycleRegistry, + initial_empty_documents: BrowserInitialEmptyDocumentRegistry, + navigation_histories: BrowserNavigationHistoryRegistry, + document_navigations: BrowserDocumentNavigationRegistry, + target_terminations: target_termination::BrowserTargetTerminationRegistry, + } + } + + pub fn browser_instance_id(&self) -> BrowserInstanceId { + self.browser_instance_id + } + + /// Discards Page-runtime ownership for a Target that remains alive. + /// + /// A failed navigation can retire the current Page without terminating the + /// Target. The exact Page-slot capability and joint session history + /// therefore deliberately survive this operation; the shared capability + /// already exposes the physical slot's successor generation. + #[cfg(test)] + pub(super) fn discard_target_page_runtime(&mut self, target_id: &str) { + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + self.target_engines.discard_target_page_runtime( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + target_id, + ); + self.document_navigations + .forget_target(&mut self.target_runtimes, target_id); + self.initial_empty_documents + .mark_exited_for_target(&mut self.target_runtimes, target_id); + } + + /// Retires every navigation-owner state entry for internal fixture or + /// staging cleanup. Live Target closure must use the typed termination + /// transaction so topology and Page lifetime commit together. + #[cfg(test)] + pub(super) fn forget_target(&mut self, target_id: &str) { + if let Some(browser_context_id) = + self.target_browser_context_id(target_id).map(str::to_owned) + { + let owner = BrowserPageOwnerKey::new(browser_context_id, target_id); + let _ = self.targets.remove_target(&owner); + let _ = self.forget_target_runtime_state(&owner); + } + } + + pub(super) fn forget_target_runtime_state( + &mut self, + owner: &BrowserPageOwnerKey, + ) -> Option { + self.target_runtimes + .remove(owner) + .and_then(|runtime| runtime.page_residence) + .and_then(page_registry::BrowserPageResidenceRecord::into_renderer_page_owner) + } +} diff --git a/moli-core/src/browser_host/navigation_owner/context_creation.rs b/moli-core/src/browser_host/navigation_owner/context_creation.rs new file mode 100644 index 0000000000..88ab2298e7 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/context_creation.rs @@ -0,0 +1,66 @@ +use crate::{ + browser_host::{BrowserTargetId, BrowserTargetSessionStorageSeed}, + network::SharedWebStorageStore, +}; + +use super::BrowserTargetCreationMetadata; + +/// Protocol-neutral immutable metadata installed by one BrowserContext +/// registration transaction. +/// +/// The active Target is identified by the exact staged topology supplied to +/// that transaction. Keeping its creation metadata separate from the physical +/// topology prevents frontend Page payloads from becoming browser authority. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BrowserContextRegistrationMetadata { + active_target_creation: Option, + target_session_storage: Vec<(BrowserTargetId, BrowserTargetSessionStorageSeed)>, +} + +impl BrowserContextRegistrationMetadata { + pub fn with_active_target_creation(creation_metadata: BrowserTargetCreationMetadata) -> Self { + Self { + active_target_creation: Some(creation_metadata), + target_session_storage: Vec::new(), + } + } + + pub(super) fn active_target_creation(&self) -> Option<&BrowserTargetCreationMetadata> { + self.active_target_creation.as_ref() + } + + pub fn with_target_session_storage_store( + mut self, + target_id: impl Into, + store: SharedWebStorageStore, + ) -> Self { + let target_id = BrowserTargetId::new(target_id); + let seed = BrowserTargetSessionStorageSeed::from_store(store); + if let Some((_, current)) = self + .target_session_storage + .iter_mut() + .find(|(candidate, _)| candidate == &target_id) + { + *current = seed; + } else { + self.target_session_storage.push((target_id, seed)); + } + self + } + + pub(super) fn target_session_storage_store( + &self, + target_id: &str, + ) -> Option { + self.target_session_storage + .iter() + .find(|(candidate, _)| candidate.as_str() == target_id) + .map(|(_, seed)| seed.store()) + } + + pub(super) fn target_session_storage_target_ids(&self) -> impl Iterator { + self.target_session_storage + .iter() + .map(|(target_id, _)| target_id.as_str()) + } +} diff --git a/moli-core/src/browser_host/navigation_owner/context_registry.rs b/moli-core/src/browser_host/navigation_owner/context_registry.rs new file mode 100644 index 0000000000..fc50d4c231 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/context_registry.rs @@ -0,0 +1,1331 @@ +use std::collections::{HashMap, HashSet}; + +use crate::{ + browser_host::{ + BrowserContextHandle, BrowserContextId, BrowserTargetId, BrowserTargetSessionStorageAccess, + }, + page::RendererPageLifetimeOwner, + runtime::NavigationEngine, +}; + +use super::{ + BrowserContextEngineHandoff, BrowserContextRegistrationMetadata, BrowserNavigationOwner, + BrowserPageOwnerKey, BrowserPageResidenceRegistryError, BrowserSelectedTargetEngineDisposition, + BrowserTargetEngineHandoffOutcome, BrowserTargetEngineOwnerMismatch, + BrowserTargetEngineRegistry, BrowserTargetTopologyProjection, +}; + +/// Frontend projection of the currently selected BrowserContext and engine. +/// +/// The context identity is only a concurrency/invariant guard. Browser Core +/// remains authoritative for selection. The engine disposition temporarily +/// comes from the physical Page payload projection because engine residence +/// has not fully moved into Core yet. The exact Page-slot capability is +/// already registered by Core independently of that payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserContextSelectionProjection { + browser_context_id: Option, + target_engine: BrowserSelectedTargetEngineDisposition, +} + +impl BrowserContextSelectionProjection { + pub fn new( + browser_context_id: Option, + target_engine: BrowserSelectedTargetEngineDisposition, + ) -> Self { + Self { + browser_context_id: browser_context_id.map(BrowserContextId::new), + target_engine, + } + } + + pub fn browser_context_id(&self) -> Option<&str> { + self.browser_context_id + .as_ref() + .map(BrowserContextId::as_str) + } + + pub fn target_engine(&self) -> &BrowserSelectedTargetEngineDisposition { + &self.target_engine + } +} + +/// Result of registering one exact BrowserContext identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserContextRegistration { + browser_context_id: BrowserContextId, + selected: bool, + engine_outcome: Option, + target_session_storage_accesses: Vec<(BrowserTargetId, BrowserTargetSessionStorageAccess)>, +} + +impl BrowserContextRegistration { + pub fn browser_context_id(&self) -> &str { + self.browser_context_id.as_str() + } + + pub fn is_selected(&self) -> bool { + self.selected + } + + pub fn engine_outcome(&self) -> Option { + self.engine_outcome + } + + pub fn target_session_storage_access( + &self, + target_id: &str, + ) -> Option<&BrowserTargetSessionStorageAccess> { + self.target_session_storage_accesses + .iter() + .find(|(candidate, _)| candidate.as_str() == target_id) + .map(|(_, access)| access) + } + + pub fn target_session_storage_accesses( + &self, + ) -> impl Iterator { + self.target_session_storage_accesses + .iter() + .map(|(target_id, access)| (target_id.as_str(), access)) + } +} + +/// Result of selecting one already registered BrowserContext. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserContextActivation { + previous_browser_context_id: BrowserContextId, + browser_context_id: BrowserContextId, + changed: bool, + engine_outcome: Option, +} + +impl BrowserContextActivation { + pub fn previous_browser_context_id(&self) -> &str { + self.previous_browser_context_id.as_str() + } + + pub fn browser_context_id(&self) -> &str { + self.browser_context_id.as_str() + } + + pub fn changed(&self) -> bool { + self.changed + } + + pub fn engine_outcome(&self) -> Option { + self.engine_outcome + } +} + +/// Exact, revision-bound authorization to remove one BrowserContext. +/// +/// A selected context's successor is chosen by Core when the permit is +/// prepared. Protocol may inspect that exact physical projection to construct +/// a replacement engine, but cannot choose a different successor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserContextRemovalPermit { + revision: u64, + browser_context_id: BrowserContextId, + browser_context_handle: BrowserContextHandle, + was_selected: bool, + successor_browser_context_id: Option, +} + +/// Exact Core-owned claim that one BrowserContext is being disposed. +/// +/// The reservation does not remove Target/Page topology. It prevents new +/// work from entering the exact Context while its already-owned cleanup is +/// advanced as Browser Host participants. A public id reused by a later +/// Context cannot satisfy this capability. +#[derive(Debug)] +pub struct BrowserContextDisposalReservation { + browser_context_handle: BrowserContextHandle, +} + +impl BrowserContextDisposalReservation { + pub fn browser_context_handle(&self) -> &BrowserContextHandle { + &self.browser_context_handle + } + + pub fn browser_context_id(&self) -> &str { + self.browser_context_handle.browser_context_id() + } +} + +impl BrowserContextRemovalPermit { + pub fn browser_context_id(&self) -> &str { + self.browser_context_id.as_str() + } + + pub fn browser_context_handle(&self) -> &BrowserContextHandle { + &self.browser_context_handle + } + + pub fn was_selected(&self) -> bool { + self.was_selected + } + + pub fn successor_browser_context_id(&self) -> Option<&str> { + self.successor_browser_context_id + .as_ref() + .map(BrowserContextId::as_str) + } +} + +/// Result of committing one BrowserContext removal. +#[derive(Debug)] +pub struct BrowserContextRemoval { + browser_context_id: BrowserContextId, + selected_browser_context_id: Option, + engine_outcome: Option, + retired_renderer_page_owners: Vec, +} + +impl BrowserContextRemoval { + pub fn browser_context_id(&self) -> &str { + self.browser_context_id.as_str() + } + + pub fn selected_browser_context_id(&self) -> Option<&str> { + self.selected_browser_context_id + .as_ref() + .map(BrowserContextId::as_str) + } + + pub fn engine_outcome(&self) -> Option { + self.engine_outcome + } + + /// Takes any renderer Pages retired with residual Target runtime state. + /// A normal disposal has already terminated every Page Target, so this is + /// empty unless migration projection drift left a registered Page behind. + pub fn take_retired_renderer_page_owners(&mut self) -> Vec { + std::mem::take(&mut self.retired_renderer_page_owners) + } +} + +/// BrowserContext topology or engine projection failed exact identity checks. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserContextRegistryError { + PageResidence(BrowserPageResidenceRegistryError), + DuplicateBrowserContext(BrowserContextId), + UnknownBrowserContext(BrowserContextId), + BrowserContextHandleIdMismatch { + expected: BrowserContextId, + projected: BrowserContextId, + }, + BrowserContextHandleNotStaged(BrowserContextId), + BrowserContextHandleNotLive(BrowserContextId), + BrowserContextHandleProjectionMismatch(BrowserContextId), + BrowserContextDisposing(BrowserContextId), + BrowserContextDisposalReservationMismatch(BrowserContextId), + SelectionProjectionMismatch { + authoritative: Option, + projected: Option, + }, + EngineProjectionContextMismatch { + projected: Option, + engine_owner: BrowserPageOwnerKey, + }, + SelectedTargetProjectionMismatch { + projected: Option, + requested: Option, + }, + TargetTopologyProjectionContextMismatch { + expected: BrowserContextId, + projected: BrowserContextId, + }, + DuplicateTargetTopologyContext(BrowserContextId), + DuplicateProjectedTarget(BrowserTargetId), + DuplicateBrowserTarget(BrowserTargetId), + ActiveTargetCreationMetadataWithoutActiveTarget(BrowserContextId), + TargetSessionStorageMetadataWithoutTarget { + browser_context_id: BrowserContextId, + target_id: BrowserTargetId, + }, + TargetHandleNotStaged(BrowserTargetId), + TargetTopologyOwnerMissing { + browser_context_id: BrowserContextId, + target_id: BrowserTargetId, + }, + TargetTopologyOwnerContextMismatch { + browser_context_id: BrowserContextId, + target_id: BrowserTargetId, + actual_browser_context_id: BrowserContextId, + }, + TargetHandleNotLive(BrowserTargetId), + EngineOwnerMismatch(BrowserTargetEngineOwnerMismatch), + StaleRemovalPermit { + permit_revision: u64, + current_revision: u64, + }, + RemovalSuccessorRequired(BrowserContextId), + UnexpectedRemovalSuccessor(BrowserContextId), +} + +impl std::fmt::Display for BrowserContextRegistryError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PageResidence(error) => error.fmt(formatter), + Self::DuplicateBrowserContext(id) => { + write!( + formatter, + "BrowserContext {:?} is already registered", + id.as_str() + ) + } + Self::UnknownBrowserContext(id) => { + write!( + formatter, + "BrowserContext {:?} is not registered", + id.as_str() + ) + } + Self::BrowserContextHandleIdMismatch { + expected, + projected, + } => write!( + formatter, + "BrowserContext {:?} received an exact handle for {:?}", + expected.as_str(), + projected.as_str() + ), + Self::BrowserContextHandleNotStaged(id) => write!( + formatter, + "BrowserContext handle for {:?} was already activated or retired before registration", + id.as_str() + ), + Self::BrowserContextHandleNotLive(id) => write!( + formatter, + "BrowserContext handle for {:?} is no longer live for removal", + id.as_str() + ), + Self::BrowserContextHandleProjectionMismatch(id) => write!( + formatter, + "physical BrowserContext handle for {:?} is not the live Browser Core instance", + id.as_str() + ), + Self::BrowserContextDisposing(id) => write!( + formatter, + "BrowserContext {:?} is already being disposed", + id.as_str() + ), + Self::BrowserContextDisposalReservationMismatch(id) => write!( + formatter, + "BrowserContext {:?} disposal reservation is no longer the Core-owned claim", + id.as_str() + ), + Self::SelectionProjectionMismatch { + authoritative, + projected, + } => write!( + formatter, + "authoritative selected BrowserContext {:?} does not match projected selection {:?}", + authoritative.as_ref().map(BrowserContextId::as_str), + projected.as_ref().map(BrowserContextId::as_str) + ), + Self::EngineProjectionContextMismatch { + projected, + engine_owner, + } => write!( + formatter, + "projected BrowserContext {:?} does not own projected target engine {:?}", + projected.as_ref().map(BrowserContextId::as_str), + engine_owner + ), + Self::SelectedTargetProjectionMismatch { + projected, + requested, + } => write!( + formatter, + "selected BrowserContext target projection {:?} does not match requested selected target {:?}", + projected, requested + ), + Self::TargetTopologyProjectionContextMismatch { + expected, + projected, + } => write!( + formatter, + "BrowserContext {:?} received Target topology projection for {:?}", + expected.as_str(), + projected.as_str() + ), + Self::DuplicateTargetTopologyContext(id) => write!( + formatter, + "BrowserContext {:?} already has a Target topology", + id.as_str() + ), + Self::DuplicateProjectedTarget(id) => write!( + formatter, + "Target {:?} occurs more than once in one BrowserContext projection", + id.as_str() + ), + Self::DuplicateBrowserTarget(id) => write!( + formatter, + "Target {:?} is already registered in another BrowserContext topology", + id.as_str() + ), + Self::ActiveTargetCreationMetadataWithoutActiveTarget(id) => write!( + formatter, + "BrowserContext {:?} cannot install active Target creation metadata without an active Target", + id.as_str() + ), + Self::TargetSessionStorageMetadataWithoutTarget { + browser_context_id, + target_id, + } => write!( + formatter, + "BrowserContext {:?} cannot install sessionStorage metadata for absent Target {:?}", + browser_context_id.as_str(), + target_id.as_str() + ), + Self::TargetHandleNotStaged(id) => write!( + formatter, + "Target handle for {:?} was already activated or retired before BrowserContext registration", + id.as_str() + ), + Self::TargetTopologyOwnerMissing { + browser_context_id, + target_id, + } => write!( + formatter, + "BrowserContext {:?} Target topology has no reverse owner for {:?}", + browser_context_id.as_str(), + target_id.as_str() + ), + Self::TargetTopologyOwnerContextMismatch { + browser_context_id, + target_id, + actual_browser_context_id, + } => write!( + formatter, + "BrowserContext {:?} Target {:?} reverse owner belongs to {:?}", + browser_context_id.as_str(), + target_id.as_str(), + actual_browser_context_id.as_str() + ), + Self::TargetHandleNotLive(id) => write!( + formatter, + "Target handle for {:?} is no longer live for BrowserContext removal", + id.as_str() + ), + Self::EngineOwnerMismatch(error) => error.fmt(formatter), + Self::StaleRemovalPermit { + permit_revision, + current_revision, + } => write!( + formatter, + "BrowserContext removal permit revision {permit_revision} is stale at revision {current_revision}" + ), + Self::RemovalSuccessorRequired(id) => write!( + formatter, + "removing selected BrowserContext {:?} requires its Core-selected successor", + id.as_str() + ), + Self::UnexpectedRemovalSuccessor(id) => write!( + formatter, + "BrowserContext {:?} removal does not accept a successor", + id.as_str() + ), + } + } +} + +impl std::error::Error for BrowserContextRegistryError {} + +impl From for BrowserContextRegistryError { + fn from(error: BrowserPageResidenceRegistryError) -> Self { + Self::PageResidence(error) + } +} + +impl From for BrowserContextRegistryError { + fn from(error: BrowserTargetEngineOwnerMismatch) -> Self { + Self::EngineOwnerMismatch(error) + } +} + +/// Authoritative selected/inactive BrowserContext identity registry. +/// +/// This registry deliberately stores no DevTools session, domain policy, +/// physical Page, or storage payload. Those are separate migration units. +#[derive(Default)] +pub(super) struct BrowserContextRegistry { + selected: Option, + inactive: Vec, + handles: HashMap, + disposing: HashSet, + revision: u64, +} + +impl BrowserContextRegistry { + pub(super) fn contains(&self, browser_context_id: &BrowserContextId) -> bool { + self.handles.contains_key(browser_context_id) + } + + pub(super) fn handle( + &self, + browser_context_id: &BrowserContextId, + ) -> Option<&BrowserContextHandle> { + self.handles.get(browser_context_id) + } + + pub(super) fn handle_is_current(&self, handle: &BrowserContextHandle) -> bool { + self.handles + .get(&BrowserContextId::new(handle.browser_context_id())) + .is_some_and(|current| current.same_instance(handle) && current.is_live()) + } + + pub(super) fn accepts_owner_work(&self, browser_context_id: &BrowserContextId) -> bool { + self.handles + .get(browser_context_id) + .is_some_and(|handle| handle.is_live() && !self.disposing.contains(handle)) + } + + pub(super) fn is_disposing(&self, browser_context_id: &BrowserContextId) -> bool { + self.handles + .get(browser_context_id) + .is_some_and(|handle| self.disposing.contains(handle)) + } + + fn disposal_reservation_is_current( + &self, + reservation: &BrowserContextDisposalReservation, + ) -> bool { + self.handles + .get(&BrowserContextId::new(reservation.browser_context_id())) + .is_some_and(|current| { + current.same_instance(&reservation.browser_context_handle) + && self.disposing.contains(current) + }) + } + + pub(super) fn validate_projection( + &self, + selected_engine_owner: Option<&BrowserPageOwnerKey>, + projection: &BrowserContextSelectionProjection, + ) -> Result<(), BrowserContextRegistryError> { + if let Some(engine_owner) = projection.target_engine.expected_owner() + && projection + .browser_context_id + .as_ref() + .map(BrowserContextId::as_str) + != Some(engine_owner.browser_context_id()) + { + return Err( + BrowserContextRegistryError::EngineProjectionContextMismatch { + projected: projection.browser_context_id.clone(), + engine_owner: engine_owner.clone(), + }, + ); + } + if self.selected != projection.browser_context_id { + return Err(BrowserContextRegistryError::SelectionProjectionMismatch { + authoritative: self.selected.clone(), + projected: projection.browser_context_id.clone(), + }); + } + BrowserTargetEngineRegistry::validate_current( + selected_engine_owner, + &projection.target_engine, + )?; + Ok(()) + } + + pub(super) fn validate_selected_target( + &self, + projection: &BrowserContextSelectionProjection, + browser_context_id: &BrowserContextId, + target_id: Option<&str>, + ) -> Result<(), BrowserContextRegistryError> { + let requested = target_id + .map(|target_id| BrowserPageOwnerKey::new(browser_context_id.as_str(), target_id)); + let projected = projection.target_engine.expected_owner().cloned(); + if projected != requested { + return Err( + BrowserContextRegistryError::SelectedTargetProjectionMismatch { + projected, + requested, + }, + ); + } + Ok(()) + } + + fn validate_removal_permit( + &self, + permit: &BrowserContextRemovalPermit, + ) -> Result<(), BrowserContextRegistryError> { + if permit.revision != self.revision { + return Err(BrowserContextRegistryError::StaleRemovalPermit { + permit_revision: permit.revision, + current_revision: self.revision, + }); + } + if !self.contains(&permit.browser_context_id) { + return Err(BrowserContextRegistryError::UnknownBrowserContext( + permit.browser_context_id.clone(), + )); + } + let Some(current) = self.handles.get(&permit.browser_context_id) else { + return Err(BrowserContextRegistryError::UnknownBrowserContext( + permit.browser_context_id.clone(), + )); + }; + if !current.same_instance(&permit.browser_context_handle) { + return Err( + BrowserContextRegistryError::BrowserContextHandleProjectionMismatch( + permit.browser_context_id.clone(), + ), + ); + } + if !current.is_live() { + return Err(BrowserContextRegistryError::BrowserContextHandleNotLive( + permit.browser_context_id.clone(), + )); + } + Ok(()) + } + + pub(super) fn selected(&self) -> Option<&BrowserContextId> { + self.selected.as_ref() + } + + pub(super) fn ordered_context_ids(&self) -> impl Iterator { + self.selected.iter().chain(self.inactive.iter()) + } + + fn len(&self) -> usize { + self.handles.len() + } +} + +impl BrowserNavigationOwner { + pub fn register_browser_context( + &mut self, + browser_context_id: impl Into, + target_topology: BrowserTargetTopologyProjection, + projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + let browser_context_id = browser_context_id.into(); + let handle = BrowserContextHandle::staged(browser_context_id.clone()); + self.register_browser_context_with_handle_and_metadata( + browser_context_id, + handle, + BrowserContextRegistrationMetadata::default(), + target_topology, + projection, + create_replacement, + ) + } + + pub fn register_browser_context_with_metadata( + &mut self, + browser_context_id: impl Into, + registration_metadata: BrowserContextRegistrationMetadata, + target_topology: BrowserTargetTopologyProjection, + projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + let browser_context_id = browser_context_id.into(); + let handle = BrowserContextHandle::staged(browser_context_id.clone()); + self.register_browser_context_with_handle_and_metadata( + browser_context_id, + handle, + registration_metadata, + target_topology, + projection, + create_replacement, + ) + } + + /// Registers the exact BrowserContext capability carried by the physical + /// context projection. + /// + /// The public id alone is insufficient because typed frontends may reuse + /// it after disposal. Registration activates this staged handle only when + /// the Context, Target, Page and engine transaction commits together. + pub fn register_browser_context_with_handle_and_metadata( + &mut self, + browser_context_id: impl Into, + browser_context_handle: BrowserContextHandle, + registration_metadata: BrowserContextRegistrationMetadata, + target_topology: BrowserTargetTopologyProjection, + projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + let browser_context_id = BrowserContextId::new(browser_context_id); + let projected_handle_id = + BrowserContextId::new(browser_context_handle.browser_context_id()); + if projected_handle_id != browser_context_id { + return Err( + BrowserContextRegistryError::BrowserContextHandleIdMismatch { + expected: browser_context_id, + projected: projected_handle_id, + }, + ); + } + if self.browser_contexts.contains(&browser_context_id) { + return Err(BrowserContextRegistryError::DuplicateBrowserContext( + browser_context_id, + )); + } + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + self.browser_contexts + .validate_projection(selected_engine_owner.as_ref(), &projection)?; + self.targets + .validate_context_registration(&browser_context_id, &target_topology)?; + let next = target_topology + .active_target_id() + .map(|target_id| BrowserPageOwnerKey::new(browser_context_id.as_str(), target_id)); + let created_target_ids = target_topology + .active_target_id() + .into_iter() + .chain(target_topology.background_target_ids()) + .map(str::to_owned) + .collect::>(); + if registration_metadata.active_target_creation().is_some() && next.is_none() { + return Err( + BrowserContextRegistryError::ActiveTargetCreationMetadataWithoutActiveTarget( + browser_context_id, + ), + ); + } + if let Some(target_id) = registration_metadata + .target_session_storage_target_ids() + .find(|target_id| { + !created_target_ids + .iter() + .any(|candidate| candidate == target_id) + }) + { + return Err( + BrowserContextRegistryError::TargetSessionStorageMetadataWithoutTarget { + browser_context_id, + target_id: BrowserTargetId::new(target_id), + }, + ); + } + if !browser_context_handle.reserve_activation() { + return Err(BrowserContextRegistryError::BrowserContextHandleNotStaged( + browser_context_id, + )); + } + let mut target_session_storage_stores = target_topology + .active_target_id() + .into_iter() + .chain(target_topology.background_target_ids()) + .filter_map(|target_id| { + registration_metadata + .target_session_storage_store(target_id) + .map(|store| (BrowserTargetId::new(target_id), store)) + }) + .collect::>(); + if let (Some(active_target_id), Some(creation_metadata)) = ( + target_topology.active_target_id(), + registration_metadata.active_target_creation(), + ) { + target_session_storage_stores + .entry(BrowserTargetId::new(active_target_id)) + .or_insert_with(|| creation_metadata.session_storage_store()); + } + let target_registration = self + .targets + .begin_context_registration(target_topology.clone(), target_session_storage_stores); + let target_registration = match target_registration { + Ok(registration) => registration, + Err(error) => { + browser_context_handle.rollback_activation_reservation(); + return Err(error); + } + }; + let page_registration = match self.page_residences.begin_context_registration( + &mut self.target_runtimes, + &browser_context_id, + &target_topology, + ) { + Ok(registration) => registration, + Err(error) => { + let rolled_back = self + .targets + .rollback_context_registration(target_registration); + debug_assert!( + rolled_back, + "same-turn Page registration rejection must restore Target registration" + ); + browser_context_handle.rollback_activation_reservation(); + return Err(error.into()); + } + }; + + let (selected, engine_outcome) = if self.browser_contexts.selected.is_none() { + let outcome = match self.target_engines.handoff_browser_context_engine( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + BrowserContextEngineHandoff::new(projection.target_engine, next.clone()), + create_replacement, + ) { + Ok(outcome) => outcome, + Err(error) => { + let page_rolled_back = self.page_residences.rollback_context_registration( + &mut self.target_runtimes, + page_registration, + ); + let rolled_back = self + .targets + .rollback_context_registration(target_registration); + debug_assert!( + page_rolled_back, + "same-turn BrowserContext engine rejection must restore Page registration" + ); + debug_assert!( + rolled_back, + "same-turn BrowserContext engine rejection must restore Target registration" + ); + browser_context_handle.rollback_activation_reservation(); + return Err(error.into()); + } + }; + self.browser_contexts.selected = Some(browser_context_id.clone()); + (true, Some(outcome)) + } else { + self.browser_contexts + .inactive + .push(browser_context_id.clone()); + (false, None) + }; + if let (Some(owner), Some(creation_metadata)) = ( + next.as_ref(), + registration_metadata.active_target_creation(), + ) { + self.install_target_creation_metadata(owner, creation_metadata); + } + self.page_residences + .commit_context_registration(&mut self.target_runtimes, page_registration); + let target_session_storage_accesses = self + .targets + .commit_context_registration(target_registration); + let previous = self + .browser_contexts + .handles + .insert(browser_context_id.clone(), browser_context_handle.clone()); + debug_assert!(previous.is_none()); + browser_context_handle.commit_activation_reservation(); + self.browser_contexts.revision += 1; + for target_id in created_target_ids { + let owner = BrowserPageOwnerKey::new(browser_context_id.as_str(), target_id); + let Some(page) = + self.capture_page_residence(owner.browser_context_id(), owner.target_id()) + else { + tracing::error!( + browser_context_id = owner.browser_context_id(), + target_id = owner.target_id(), + "committed BrowserContext Target has no Page residence for creation fact" + ); + continue; + }; + if let Err(error) = self.record_target_created_fact(&owner, &page) { + tracing::error!( + %error, + browser_context_id = owner.browser_context_id(), + target_id = owner.target_id(), + "failed to publish BrowserContext Target creation fact" + ); + } + } + Ok(BrowserContextRegistration { + browser_context_id, + selected, + engine_outcome, + target_session_storage_accesses, + }) + } + + pub fn activate_browser_context( + &mut self, + browser_context_id: &str, + projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + let browser_context_id = BrowserContextId::new(browser_context_id); + if !self.browser_contexts.contains(&browser_context_id) { + return Err(BrowserContextRegistryError::UnknownBrowserContext( + browser_context_id, + )); + } + if !self + .browser_contexts + .accepts_owner_work(&browser_context_id) + { + return Err(BrowserContextRegistryError::BrowserContextDisposing( + browser_context_id, + )); + } + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + self.browser_contexts + .validate_projection(selected_engine_owner.as_ref(), &projection)?; + let previous = self + .browser_contexts + .selected + .clone() + .expect("a registered BrowserContext registry must have a selection"); + if previous == browser_context_id { + self.browser_contexts.validate_selected_target( + &projection, + &browser_context_id, + self.targets + .active_target(&browser_context_id) + .map(crate::browser_host::BrowserTargetId::as_str), + )?; + return Ok(BrowserContextActivation { + previous_browser_context_id: previous, + browser_context_id, + changed: false, + engine_outcome: None, + }); + } + + let index = self + .browser_contexts + .inactive + .iter() + .position(|candidate| candidate == &browser_context_id) + .expect("a known non-selected BrowserContext must be inactive"); + let next = self + .targets + .active_target(&browser_context_id) + .map(|target_id| { + BrowserPageOwnerKey::new(browser_context_id.as_str(), target_id.as_str()) + }); + let engine_outcome = self.target_engines.handoff_browser_context_engine( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + BrowserContextEngineHandoff::new(projection.target_engine, next), + create_replacement, + )?; + + let selected = self.browser_contexts.inactive.swap_remove(index); + self.browser_contexts.inactive.push(previous.clone()); + self.browser_contexts.selected = Some(selected); + self.browser_contexts.revision += 1; + Ok(BrowserContextActivation { + previous_browser_context_id: previous, + browser_context_id, + changed: true, + engine_outcome: Some(engine_outcome), + }) + } + + pub fn prepare_browser_context_removal( + &self, + browser_context_id: &str, + ) -> Result { + let browser_context_id = BrowserContextId::new(browser_context_id); + let browser_context_handle = self + .browser_contexts + .handle(&browser_context_id) + .cloned() + .ok_or_else(|| { + BrowserContextRegistryError::UnknownBrowserContext(browser_context_id.clone()) + })?; + self.prepare_browser_context_removal_for_handle(&browser_context_handle) + } + + /// Captures removal authority for one exact BrowserContext instance. + /// + /// This is the admission primitive for queued Browser Host disposal: an + /// old capability cannot authorize a newly registered context that reuses + /// the same frontend-visible id. + pub fn prepare_browser_context_removal_for_handle( + &self, + browser_context_handle: &BrowserContextHandle, + ) -> Result { + let browser_context_id = BrowserContextId::new(browser_context_handle.browser_context_id()); + let Some(current) = self.browser_contexts.handle(&browser_context_id) else { + return Err(BrowserContextRegistryError::UnknownBrowserContext( + browser_context_id, + )); + }; + if !current.same_instance(browser_context_handle) { + return Err( + BrowserContextRegistryError::BrowserContextHandleProjectionMismatch( + browser_context_id, + ), + ); + } + if !current.is_live() { + return Err(BrowserContextRegistryError::BrowserContextHandleNotLive( + browser_context_id, + )); + } + if self.browser_contexts.disposing.contains(current) { + return Err(BrowserContextRegistryError::BrowserContextDisposing( + browser_context_id, + )); + } + Ok(self.browser_context_removal_permit(browser_context_id, browser_context_handle)) + } + + /// Reserves one exact Context for a Browser Host disposal chain. + /// + /// This is a logical admission transition only. Existing Target/Page + /// capabilities remain available to the disposal owner, while ordinary + /// Context activation, Target registration and Page replacement reject + /// new work until the reservation commits or rolls back. + pub fn begin_browser_context_disposal( + &mut self, + browser_context_handle: &BrowserContextHandle, + ) -> Result { + let browser_context_id = BrowserContextId::new(browser_context_handle.browser_context_id()); + let Some(current) = self.browser_contexts.handle(&browser_context_id) else { + return Err(BrowserContextRegistryError::UnknownBrowserContext( + browser_context_id, + )); + }; + if !current.same_instance(browser_context_handle) { + return Err( + BrowserContextRegistryError::BrowserContextHandleProjectionMismatch( + browser_context_id, + ), + ); + } + if !current.is_live() { + return Err(BrowserContextRegistryError::BrowserContextHandleNotLive( + browser_context_id, + )); + } + if !self.browser_contexts.disposing.insert(current.clone()) { + return Err(BrowserContextRegistryError::BrowserContextDisposing( + browser_context_id, + )); + } + self.browser_contexts.revision += 1; + Ok(BrowserContextDisposalReservation { + browser_context_handle: browser_context_handle.clone(), + }) + } + + /// Releases a disposal claim that failed before logical Context removal. + pub fn rollback_browser_context_disposal( + &mut self, + reservation: BrowserContextDisposalReservation, + ) -> bool { + if !self + .browser_contexts + .disposal_reservation_is_current(&reservation) + { + return false; + } + let removed = self + .browser_contexts + .disposing + .remove(&reservation.browser_context_handle); + debug_assert!(removed); + self.browser_contexts.revision += 1; + true + } + + /// Captures a final removal permit for the same exact disposal claim. + /// + /// Selection and successor are resolved at the terminal owner turn, not + /// when cleanup first starts, so unrelated Context work may continue + /// without a stale restore overwriting its later selection. + pub fn prepare_browser_context_removal_for_disposal( + &self, + reservation: &BrowserContextDisposalReservation, + ) -> Result { + let browser_context_id = BrowserContextId::new(reservation.browser_context_id()); + if !self + .browser_contexts + .disposal_reservation_is_current(reservation) + { + return Err( + BrowserContextRegistryError::BrowserContextDisposalReservationMismatch( + browser_context_id, + ), + ); + } + Ok(self.browser_context_removal_permit( + browser_context_id, + &reservation.browser_context_handle, + )) + } + + fn browser_context_removal_permit( + &self, + browser_context_id: BrowserContextId, + browser_context_handle: &BrowserContextHandle, + ) -> BrowserContextRemovalPermit { + let was_selected = self.browser_contexts.selected.as_ref() == Some(&browser_context_id); + let successor_browser_context_id = if was_selected { + self.browser_contexts.inactive.first().cloned() + } else { + None + }; + BrowserContextRemovalPermit { + revision: self.browser_contexts.revision, + browser_context_id, + browser_context_handle: browser_context_handle.clone(), + was_selected, + successor_browser_context_id, + } + } + + pub fn commit_browser_context_removal( + &mut self, + permit: BrowserContextRemovalPermit, + projection: BrowserContextSelectionProjection, + create_unbound_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + self.browser_contexts.validate_removal_permit(&permit)?; + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + self.browser_contexts + .validate_projection(selected_engine_owner.as_ref(), &projection)?; + if permit.was_selected && permit.successor_browser_context_id.is_some() { + return Err(BrowserContextRegistryError::RemovalSuccessorRequired( + permit.browser_context_id, + )); + } + + let inactive_index = if permit.was_selected { + None + } else { + Some( + self.browser_contexts + .inactive + .iter() + .position(|candidate| candidate == &permit.browser_context_id) + .ok_or_else(|| { + BrowserContextRegistryError::UnknownBrowserContext( + permit.browser_context_id.clone(), + ) + })?, + ) + }; + if !permit.browser_context_handle.reserve_retirement() { + return Err(BrowserContextRegistryError::BrowserContextHandleNotLive( + permit.browser_context_id, + )); + } + let target_removal = match self + .targets + .begin_context_removal(&permit.browser_context_id) + { + Ok(removal) => removal, + Err(error) => { + permit + .browser_context_handle + .rollback_retirement_reservation(); + return Err(error); + } + }; + + let engine_outcome = if permit.was_selected { + let outcome = self.target_engines.handoff_browser_context_engine( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + BrowserContextEngineHandoff::new(projection.target_engine, None), + create_unbound_replacement, + )?; + self.browser_contexts.selected = None; + Some(outcome) + } else { + if let Some(index) = inactive_index { + self.browser_contexts.inactive.swap_remove(index); + } + None + }; + let removed_target_ids = self.targets.commit_context_removal(target_removal); + let mut retired_renderer_page_owners = Vec::new(); + for target_id in removed_target_ids { + let owner = + BrowserPageOwnerKey::new(permit.browser_context_id.as_str(), target_id.as_str()); + retired_renderer_page_owners.extend(self.forget_target_runtime_state(&owner)); + } + let removed_handle = self + .browser_contexts + .handles + .remove(&permit.browser_context_id); + debug_assert!( + removed_handle + .as_ref() + .is_some_and(|handle| handle.same_instance(&permit.browser_context_handle)), + "BrowserContext removal must retire the exact registered handle" + ); + self.browser_contexts + .disposing + .remove(&permit.browser_context_handle); + permit + .browser_context_handle + .commit_retirement_reservation(); + self.browser_contexts.revision += 1; + Ok(BrowserContextRemoval { + browser_context_id: permit.browser_context_id, + selected_browser_context_id: self.browser_contexts.selected.clone(), + engine_outcome, + retired_renderer_page_owners, + }) + } + + pub fn commit_browser_context_removal_with_successor( + &mut self, + permit: BrowserContextRemovalPermit, + projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + self.browser_contexts.validate_removal_permit(&permit)?; + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + self.browser_contexts + .validate_projection(selected_engine_owner.as_ref(), &projection)?; + let Some(successor_browser_context_id) = permit.successor_browser_context_id.clone() else { + return Err(BrowserContextRegistryError::UnexpectedRemovalSuccessor( + permit.browser_context_id, + )); + }; + if !permit.was_selected { + return Err(BrowserContextRegistryError::UnexpectedRemovalSuccessor( + permit.browser_context_id, + )); + } + let successor_index = self + .browser_contexts + .inactive + .iter() + .position(|candidate| candidate == &successor_browser_context_id) + .ok_or_else(|| { + BrowserContextRegistryError::UnknownBrowserContext( + successor_browser_context_id.clone(), + ) + })?; + let next = self + .targets + .active_target(&successor_browser_context_id) + .map(|target_id| { + BrowserPageOwnerKey::new(successor_browser_context_id.as_str(), target_id.as_str()) + }); + if !permit.browser_context_handle.reserve_retirement() { + return Err(BrowserContextRegistryError::BrowserContextHandleNotLive( + permit.browser_context_id, + )); + } + let target_removal = match self + .targets + .begin_context_removal(&permit.browser_context_id) + { + Ok(removal) => removal, + Err(error) => { + permit + .browser_context_handle + .rollback_retirement_reservation(); + return Err(error); + } + }; + let engine_outcome = match self.target_engines.handoff_browser_context_engine( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + BrowserContextEngineHandoff::new(projection.target_engine, next), + create_replacement, + ) { + Ok(outcome) => outcome, + Err(error) => { + let rolled_back = self.targets.rollback_context_removal(target_removal); + debug_assert!( + rolled_back, + "same-turn BrowserContext engine rejection must restore Target removal" + ); + permit + .browser_context_handle + .rollback_retirement_reservation(); + return Err(error.into()); + } + }; + let removed_target_ids = self.targets.commit_context_removal(target_removal); + let mut retired_renderer_page_owners = Vec::new(); + for target_id in removed_target_ids { + let owner = + BrowserPageOwnerKey::new(permit.browser_context_id.as_str(), target_id.as_str()); + retired_renderer_page_owners.extend(self.forget_target_runtime_state(&owner)); + } + let removed_handle = self + .browser_contexts + .handles + .remove(&permit.browser_context_id); + debug_assert!( + removed_handle + .as_ref() + .is_some_and(|handle| handle.same_instance(&permit.browser_context_handle)), + "BrowserContext removal must retire the exact registered handle" + ); + self.browser_contexts + .disposing + .remove(&permit.browser_context_handle); + permit + .browser_context_handle + .commit_retirement_reservation(); + + let selected = self.browser_contexts.inactive.swap_remove(successor_index); + self.browser_contexts.selected = Some(selected.clone()); + self.browser_contexts.revision += 1; + Ok(BrowserContextRemoval { + browser_context_id: permit.browser_context_id, + selected_browser_context_id: Some(selected), + engine_outcome: Some(engine_outcome), + retired_renderer_page_owners, + }) + } + + pub fn selected_browser_context_id(&self) -> Option<&str> { + self.browser_contexts + .selected() + .map(BrowserContextId::as_str) + } + + pub fn browser_context_count(&self) -> usize { + self.browser_contexts.len() + } + + pub fn has_browser_context(&self, browser_context_id: &str) -> bool { + self.browser_contexts + .contains(&BrowserContextId::new(browser_context_id)) + } + + pub fn browser_context_handle( + &self, + browser_context_id: &str, + ) -> Option<&BrowserContextHandle> { + self.browser_contexts + .handle(&BrowserContextId::new(browser_context_id)) + } + + pub fn browser_context_handle_is_current(&self, handle: &BrowserContextHandle) -> bool { + self.browser_contexts.handle_is_current(handle) + } + + pub fn browser_context_accepts_owner_work(&self, browser_context_id: &str) -> bool { + self.browser_contexts + .accepts_owner_work(&BrowserContextId::new(browser_context_id)) + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-core/src/browser_host/navigation_owner/context_registry/tests.rs b/moli-core/src/browser_host/navigation_owner/context_registry/tests.rs new file mode 100644 index 0000000000..d6b23c6fe1 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/context_registry/tests.rs @@ -0,0 +1,701 @@ +use super::*; +use crate::browser_host::{ + BrowserContextHandle, BrowserContextRegistrationMetadata, BrowserFact, + BrowserInitialEmptyDocumentSeed, BrowserPageResidenceHandle, BrowserTargetCreationMetadata, + BrowserTargetEngineResidence, BrowserTargetHandle, BrowserTargetSlotProjection, +}; + +fn engine() -> NavigationEngine { + NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics() +} + +fn projection( + context_id: Option<&str>, + disposition: BrowserSelectedTargetEngineDisposition, +) -> BrowserContextSelectionProjection { + BrowserContextSelectionProjection::new(context_id.map(str::to_owned), disposition) +} + +fn topology(context_id: &str, target_id: Option<&str>) -> BrowserTargetTopologyProjection { + BrowserTargetTopologyProjection::new( + context_id, + target_id.map(|target_id| { + BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged(target_id), + BrowserPageResidenceHandle::default(), + ) + }), + Vec::::new(), + ) +} + +fn register_selected( + owner: &mut BrowserNavigationOwner, + context_id: &str, + target_id: &str, +) -> BrowserPageOwnerKey { + let key = BrowserPageOwnerKey::new(context_id, target_id); + let registration = owner + .register_browser_context( + context_id, + topology(context_id, Some(target_id)), + projection(None, BrowserSelectedTargetEngineDisposition::Unbound), + engine, + ) + .expect("first context should register"); + assert!(registration.is_selected()); + key +} + +#[test] +fn registration_commits_active_target_creation_metadata_with_topology() { + let mut owner = BrowserNavigationOwner::new(engine()); + let target_owner = BrowserPageOwnerKey::new("context-a", "target-a"); + + owner + .register_browser_context_with_metadata( + "context-a", + BrowserContextRegistrationMetadata::with_active_target_creation( + BrowserTargetCreationMetadata::with_initial_empty_document( + BrowserInitialEmptyDocumentSeed::new("about:blank#bootstrap"), + ), + ), + topology("context-a", Some("target-a")), + projection(None, BrowserSelectedTargetEngineDisposition::Unbound), + engine, + ) + .expect("BrowserContext and active Target metadata should commit together"); + + let initial_document = owner + .target_initial_empty_document(&target_owner) + .expect("committed Target must expose its creation metadata"); + assert_eq!(initial_document.initial_url(), "about:blank#bootstrap"); + assert!( + owner + .target_handle(target_owner.target_id()) + .expect("registered Target handle") + .is_live() + ); + let facts = owner.browser_fact_snapshot(); + assert_eq!(facts.len(), 1); + assert!(matches!(facts[0].fact(), BrowserFact::TargetCreated)); + assert_eq!(facts[0].browser_context_id().as_str(), "context-a"); + assert_eq!(facts[0].target_id().as_str(), "target-a"); + assert_eq!( + facts[0].page_residence(), + &owner + .capture_page_residence("context-a", "target-a") + .expect("registered Target Page residence") + ); +} + +#[test] +fn active_target_creation_metadata_without_active_target_is_rejected_before_mutation() { + let mut owner = BrowserNavigationOwner::new(engine()); + let mut replacement_created = false; + let absent_target = BrowserPageOwnerKey::new("context-a", "target-a"); + + let error = owner + .register_browser_context_with_metadata( + "context-a", + BrowserContextRegistrationMetadata::with_active_target_creation( + BrowserTargetCreationMetadata::with_initial_empty_document( + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ), + ), + topology("context-a", None), + projection(None, BrowserSelectedTargetEngineDisposition::Unbound), + || { + replacement_created = true; + engine() + }, + ) + .expect_err("metadata without an exact active Target must be rejected"); + + assert_eq!( + error, + BrowserContextRegistryError::ActiveTargetCreationMetadataWithoutActiveTarget( + BrowserContextId::new("context-a") + ) + ); + assert!(!replacement_created); + assert_eq!(owner.browser_context_count(), 0); + assert_eq!(owner.selected_browser_context_id(), None); + assert!(owner.target_handle(absent_target.target_id()).is_none()); + assert!( + owner + .target_initial_empty_document(&absent_target) + .is_none() + ); + assert!(owner.browser_fact_snapshot().is_empty()); +} + +#[test] +fn session_storage_metadata_for_absent_target_is_rejected_before_mutation() { + let mut owner = BrowserNavigationOwner::new(engine()); + let mut replacement_created = false; + + let error = owner + .register_browser_context_with_metadata( + "context-a", + BrowserContextRegistrationMetadata::default().with_target_session_storage_store( + "target-absent", + crate::network::new_shared_web_storage_store(), + ), + topology("context-a", Some("target-a")), + projection(None, BrowserSelectedTargetEngineDisposition::Unbound), + || { + replacement_created = true; + engine() + }, + ) + .expect_err("namespace metadata must name an exact projected Target"); + + assert_eq!( + error, + BrowserContextRegistryError::TargetSessionStorageMetadataWithoutTarget { + browser_context_id: BrowserContextId::new("context-a"), + target_id: BrowserTargetId::new("target-absent"), + } + ); + assert!(!replacement_created); + assert_eq!(owner.browser_context_count(), 0); + assert_eq!(owner.target_count(), 0); +} + +#[test] +fn context_registration_publishes_active_then_background_target_occurrences() { + let mut owner = BrowserNavigationOwner::new(engine()); + let active_page = BrowserPageResidenceHandle::default(); + let background_page = BrowserPageResidenceHandle::default(); + + owner + .register_browser_context( + "context-a", + BrowserTargetTopologyProjection::new( + "context-a", + Some(BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged("target-active"), + active_page.clone(), + )), + vec![BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged("target-background"), + background_page.clone(), + )], + ), + projection(None, BrowserSelectedTargetEngineDisposition::Unbound), + engine, + ) + .expect("BrowserContext topology should register atomically"); + + let facts = owner.browser_fact_snapshot(); + assert_eq!(facts.len(), 2); + assert!( + facts + .iter() + .all(|fact| matches!(fact.fact(), BrowserFact::TargetCreated)) + ); + assert_eq!( + facts + .iter() + .map(|fact| fact.target_id().as_str()) + .collect::>(), + vec!["target-active", "target-background"] + ); + assert_eq!( + facts[0].page_residence(), + &active_page.identity("context-a".to_owned(), Some("target-active".to_owned())) + ); + assert_eq!( + facts[1].page_residence(), + &background_page.identity("context-a".to_owned(), Some("target-background".to_owned()),) + ); +} + +#[test] +fn first_registration_selects_and_later_registration_stays_inactive() { + let mut owner = BrowserNavigationOwner::new(engine()); + let first = register_selected(&mut owner, "context-a", "target-a"); + let mut replacement_created = false; + + let second = owner + .register_browser_context( + "context-b", + topology("context-b", Some("target-b")), + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Discard(first.clone()), + ), + || { + replacement_created = true; + engine() + }, + ) + .expect("second context should register inactive"); + + assert!(!second.is_selected()); + assert!(!replacement_created); + assert_eq!(owner.selected_browser_context_id(), Some("context-a")); + assert_eq!(owner.selected_target_engine_owner(), Some(&first)); + assert_eq!(owner.browser_context_count(), 2); +} + +#[test] +fn activation_atomically_swaps_context_and_exact_engine_owner() { + let mut owner = BrowserNavigationOwner::new(engine()); + let first = register_selected(&mut owner, "context-a", "target-a"); + owner + .register_browser_context( + "context-b", + topology("context-b", Some("target-b")), + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Discard(first.clone()), + ), + engine, + ) + .expect("second context should register"); + let second = BrowserPageOwnerKey::new("context-b", "target-b"); + + let activation = owner + .activate_browser_context( + "context-b", + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Discard(first), + ), + engine, + ) + .expect("second context should activate"); + + assert!(activation.changed()); + assert_eq!(activation.previous_browser_context_id(), "context-a"); + assert_eq!(activation.browser_context_id(), "context-b"); + assert_eq!(owner.selected_browser_context_id(), Some("context-b")); + assert_eq!(owner.selected_target_engine_owner(), Some(&second)); +} + +#[test] +fn duplicate_and_unknown_contexts_cannot_mutate_registry() { + let mut owner = BrowserNavigationOwner::new(engine()); + let first = register_selected(&mut owner, "context-a", "target-a"); + + let duplicate = owner.register_browser_context( + "context-a", + topology("context-a", None), + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Discard(first.clone()), + ), + engine, + ); + assert!(matches!( + duplicate, + Err(BrowserContextRegistryError::DuplicateBrowserContext(_)) + )); + let unknown = owner.activate_browser_context( + "missing", + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Discard(first.clone()), + ), + engine, + ); + assert!(matches!( + unknown, + Err(BrowserContextRegistryError::UnknownBrowserContext(_)) + )); + assert_eq!(owner.selected_browser_context_id(), Some("context-a")); + assert_eq!(owner.selected_target_engine_owner(), Some(&first)); + assert_eq!(owner.browser_context_count(), 1); +} + +#[test] +fn selected_removal_uses_core_successor_and_purges_removed_context_engines() { + let mut owner = BrowserNavigationOwner::new(engine()); + let first = register_selected(&mut owner, "context-a", "target-a"); + owner + .register_browser_context( + "context-b", + topology("context-b", Some("target-b")), + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Retain(first.clone()), + ), + engine, + ) + .expect("second context should register"); + let second = BrowserPageOwnerKey::new("context-b", "target-b"); + owner + .adopt_target_engine( + second.clone(), + BrowserTargetEngineResidence::Retained, + engine(), + ) + .expect("successor engine should be retained"); + let successor_renderer_owner = owner + .retained_renderer_owner_ids_for_diagnostics() + .next() + .expect("successor renderer owner"); + let removed_target_handle = owner + .target_handle("target-a") + .expect("selected context Target handle"); + let successor_target_handle = owner + .target_handle("target-b") + .expect("successor context Target handle"); + let permit = owner + .prepare_browser_context_removal("context-a") + .expect("selected removal should prepare"); + assert_eq!(permit.successor_browser_context_id(), Some("context-b")); + + let removal = owner + .commit_browser_context_removal_with_successor( + permit, + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Retain(first), + ), + engine, + ) + .expect("selected removal should commit"); + + assert_eq!(removal.selected_browser_context_id(), Some("context-b")); + assert_eq!(owner.selected_target_engine_owner(), Some(&second)); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + successor_renderer_owner + ); + assert_eq!(owner.retained_background_engine_count(), 0); + assert_eq!(owner.browser_context_count(), 1); + assert!(removed_target_handle.is_retired()); + assert!(successor_target_handle.is_live()); +} + +#[test] +fn stale_removal_permit_cannot_remove_new_selection() { + let mut owner = BrowserNavigationOwner::new(engine()); + let first = register_selected(&mut owner, "context-a", "target-a"); + owner + .register_browser_context( + "context-b", + topology("context-b", Some("target-b")), + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Discard(first.clone()), + ), + engine, + ) + .expect("second context should register"); + let permit = owner + .prepare_browser_context_removal("context-a") + .expect("removal should prepare"); + owner + .activate_browser_context( + "context-b", + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Discard(first), + ), + engine, + ) + .expect("second context should activate"); + let second = BrowserPageOwnerKey::new("context-b", "target-b"); + + let error = owner + .commit_browser_context_removal_with_successor( + permit, + projection( + Some("context-b"), + BrowserSelectedTargetEngineDisposition::Discard(second.clone()), + ), + engine, + ) + .expect_err("stale removal must be rejected"); + + assert!(matches!( + error, + BrowserContextRegistryError::StaleRemovalPermit { .. } + )); + assert_eq!(owner.selected_browser_context_id(), Some("context-b")); + assert_eq!(owner.selected_target_engine_owner(), Some(&second)); + assert_eq!(owner.browser_context_count(), 2); +} + +#[test] +fn mismatched_physical_projection_is_rejected_before_mutation() { + let mut owner = BrowserNavigationOwner::new(engine()); + let first = register_selected(&mut owner, "context-a", "target-a"); + let renderer_owner = owner.active_renderer_owner_id_for_diagnostics(); + + let error = owner + .register_browser_context( + "context-b", + topology("context-b", None), + projection( + Some("wrong-context"), + BrowserSelectedTargetEngineDisposition::Discard(first.clone()), + ), + engine, + ) + .expect_err("mismatched projection must be rejected"); + + assert!(matches!( + error, + BrowserContextRegistryError::EngineProjectionContextMismatch { .. } + | BrowserContextRegistryError::SelectionProjectionMismatch { .. } + )); + assert_eq!(owner.selected_browser_context_id(), Some("context-a")); + assert_eq!(owner.selected_target_engine_owner(), Some(&first)); + assert_eq!(owner.browser_context_count(), 1); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + renderer_owner + ); +} + +#[test] +fn retired_context_target_rejects_removal_before_context_or_engine_mutation() { + let mut owner = BrowserNavigationOwner::new(engine()); + let first = register_selected(&mut owner, "context-a", "target-a"); + let target_handle = owner + .target_handle("target-a") + .expect("registered Target handle"); + let renderer_owner = owner.active_renderer_owner_id_for_diagnostics(); + let permit = owner + .prepare_browser_context_removal("context-a") + .expect("single selected BrowserContext removal should prepare"); + assert!(target_handle.reserve_retirement()); + target_handle.commit_retirement_reservation(); + + let error = owner + .commit_browser_context_removal( + permit, + projection( + Some("context-a"), + BrowserSelectedTargetEngineDisposition::Discard(first.clone()), + ), + engine, + ) + .expect_err("retired Target must reject BrowserContext removal"); + + assert_eq!( + error, + BrowserContextRegistryError::TargetHandleNotLive(BrowserTargetId::new("target-a")) + ); + assert_eq!(owner.selected_browser_context_id(), Some("context-a")); + assert_eq!(owner.selected_target_engine_owner(), Some(&first)); + assert_eq!( + owner.active_target_id_for_browser_context("context-a"), + Some("target-a") + ); + assert_eq!(owner.browser_context_count(), 1); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + renderer_owner + ); + assert!( + owner + .browser_context_handle("context-a") + .is_some_and(BrowserContextHandle::is_live), + "failed removal must roll back the BrowserContext retirement reservation" + ); +} + +#[test] +fn exact_context_handle_rejects_same_id_aba_after_removal_and_recreation() { + let mut owner = BrowserNavigationOwner::new(engine()); + let first_handle = BrowserContextHandle::staged("context-reused"); + let first_owner = BrowserPageOwnerKey::new("context-reused", "target-first"); + owner + .register_browser_context_with_handle_and_metadata( + "context-reused", + first_handle.clone(), + BrowserContextRegistrationMetadata::default(), + topology("context-reused", Some("target-first")), + projection(None, BrowserSelectedTargetEngineDisposition::Unbound), + engine, + ) + .expect("first exact BrowserContext should register"); + assert!(first_handle.is_live()); + + let permit = owner + .prepare_browser_context_removal_for_handle(&first_handle) + .expect("first exact BrowserContext should prepare removal"); + owner + .commit_browser_context_removal( + permit, + projection( + Some("context-reused"), + BrowserSelectedTargetEngineDisposition::Discard(first_owner), + ), + engine, + ) + .expect("first exact BrowserContext should be removed"); + assert!(first_handle.is_retired()); + + let replacement_handle = BrowserContextHandle::staged("context-reused"); + owner + .register_browser_context_with_handle_and_metadata( + "context-reused", + replacement_handle.clone(), + BrowserContextRegistrationMetadata::default(), + topology("context-reused", Some("target-replacement")), + projection(None, BrowserSelectedTargetEngineDisposition::Unbound), + engine, + ) + .expect("same public id should register as a new exact instance"); + + let error = owner + .prepare_browser_context_removal_for_handle(&first_handle) + .expect_err("retired capability must not authorize the replacement context"); + assert_eq!( + error, + BrowserContextRegistryError::BrowserContextHandleProjectionMismatch(BrowserContextId::new( + "context-reused" + )) + ); + assert!(replacement_handle.is_live()); + assert!(owner.browser_context_handle_is_current(&replacement_handle)); +} + +#[test] +fn registration_rejects_handle_for_another_public_context_before_activation() { + let mut owner = BrowserNavigationOwner::new(engine()); + let wrong_handle = BrowserContextHandle::staged("context-other"); + + let error = owner + .register_browser_context_with_handle_and_metadata( + "context-wanted", + wrong_handle.clone(), + BrowserContextRegistrationMetadata::default(), + topology("context-wanted", Some("target-wanted")), + projection(None, BrowserSelectedTargetEngineDisposition::Unbound), + engine, + ) + .expect_err("a handle for another public id must be rejected"); + + assert_eq!( + error, + BrowserContextRegistryError::BrowserContextHandleIdMismatch { + expected: BrowserContextId::new("context-wanted"), + projected: BrowserContextId::new("context-other"), + } + ); + assert!(wrong_handle.reserve_activation()); + wrong_handle.rollback_activation_reservation(); + assert_eq!(owner.browser_context_count(), 0); +} + +#[test] +fn disposal_reservation_blocks_new_owner_work_and_rolls_back_exactly() { + let mut owner = BrowserNavigationOwner::new(engine()); + let page_owner = register_selected(&mut owner, "context-dispose", "target-live"); + let handle = owner + .browser_context_handle("context-dispose") + .expect("registered Context handle") + .clone(); + let navigation = + owner.start_document_navigation(&page_owner, "loader-before-dispose".to_owned()); + + let reservation = owner + .begin_browser_context_disposal(&handle) + .expect("exact live Context should reserve disposal"); + assert!(!owner.browser_context_accepts_owner_work("context-dispose")); + assert!( + owner + .try_start_document_navigation_with_trace( + &page_owner, + "loader-after-dispose".to_owned(), + None, + ) + .is_none(), + "a reserved Context must reject a later Document navigation at Core admission" + ); + assert!( + owner + .prepare_loaded_page_replacement(&page_owner, &navigation) + .is_none(), + "a navigation admitted before disposal must not replace the reserved Context's Page" + ); + assert_eq!( + owner + .begin_browser_context_disposal(&handle) + .expect_err("the exact Context cannot own two disposal reservations"), + BrowserContextRegistryError::BrowserContextDisposing(BrowserContextId::new( + "context-dispose" + )) + ); + assert_eq!( + owner + .activate_browser_context( + "context-dispose", + projection( + Some("context-dispose"), + BrowserSelectedTargetEngineDisposition::Retain(page_owner.clone()), + ), + engine, + ) + .expect_err("ordinary activation must reject a disposing Context"), + BrowserContextRegistryError::BrowserContextDisposing(BrowserContextId::new( + "context-dispose" + )) + ); + + assert!(owner.rollback_browser_context_disposal(reservation)); + assert!(owner.browser_context_accepts_owner_work("context-dispose")); + assert!( + owner + .prepare_loaded_page_replacement(&page_owner, &navigation) + .is_some(), + "rollback must restore the exact Context's Page authority" + ); +} + +#[test] +fn final_disposal_removal_retires_reservation_and_same_id_does_not_alias() { + let mut owner = BrowserNavigationOwner::new(engine()); + let page_owner = register_selected(&mut owner, "context-reused", "target-old"); + let old_handle = owner + .browser_context_handle("context-reused") + .expect("old Context handle") + .clone(); + let reservation = owner + .begin_browser_context_disposal(&old_handle) + .expect("old Context should reserve disposal"); + let permit = owner + .prepare_browser_context_removal_for_disposal(&reservation) + .expect("the exact reservation should authorize terminal removal"); + owner + .commit_browser_context_removal( + permit, + projection( + Some("context-reused"), + BrowserSelectedTargetEngineDisposition::Discard(page_owner), + ), + engine, + ) + .expect("terminal disposal removal should commit"); + assert!(old_handle.is_retired()); + + let replacement_handle = BrowserContextHandle::staged("context-reused"); + owner + .register_browser_context_with_handle_and_metadata( + "context-reused", + replacement_handle.clone(), + BrowserContextRegistrationMetadata::default(), + topology("context-reused", Some("target-new")), + projection(None, BrowserSelectedTargetEngineDisposition::Unbound), + engine, + ) + .expect("same public id should be reusable after exact disposal"); + assert_eq!( + owner + .begin_browser_context_disposal(&old_handle) + .expect_err("the retired capability cannot reserve its replacement"), + BrowserContextRegistryError::BrowserContextHandleProjectionMismatch(BrowserContextId::new( + "context-reused" + )) + ); + assert!(owner.browser_context_handle_is_current(&replacement_handle)); + assert!(owner.browser_context_accepts_owner_work("context-reused")); +} diff --git a/moli-core/src/browser_host/navigation_owner/document_build.rs b/moli-core/src/browser_host/navigation_owner/document_build.rs new file mode 100644 index 0000000000..555c712e91 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/document_build.rs @@ -0,0 +1,219 @@ +use anyhow::Result; +use url::Url; + +use crate::{ + page::{ + DocumentStartScript, EmulatedMediaOverrides, RendererInspectorSessionRestoreSnapshot, + RendererMainDocumentCommit, RuntimeBindingRegistration, SubresourceResourceType, + ViewportSurface, + }, + runtime::{ + BuiltDocumentPage, CommittedDocumentResourceSource, ExternalRawDocumentBodyStream, + NavigationPageStorageHandles, PageVmInitStage, PreparedDocumentPage, + RendererPageReservationToken, RendererReplyBoundary, RendererReservedServiceWorkerClient, + }, +}; + +use super::BrowserNavigationOwner; + +impl BrowserNavigationOwner { + #[allow(clippy::too_many_arguments)] + pub async fn build_active_html_page_from_response( + &mut self, + storage: NavigationPageStorageHandles, + requested_url: Url, + final_url: Url, + navigation_initiator_url: Option, + redirected: bool, + redirect_count: usize, + response_status: u16, + response_headers: Vec<(String, String)>, + response_body: String, + document_start_scripts: Vec, + runtime_bindings: Vec, + runtime_inspector_session_restore_snapshots: Vec, + extra_http_headers: Vec<(String, String)>, + locale_override: Option, + timezone_override: Option, + script_execution_disabled: bool, + bypass_content_security_policy: bool, + cpu_throttling_rate: f64, + emulated_media: EmulatedMediaOverrides, + viewport_surface: Option, + network_offline: bool, + blocked_url_patterns: Vec, + fetch_subresource_interception_enabled: bool, + fetch_subresource_interception_resource_type: Option, + root_frame_id: Option, + main_document_commit: Option, + ) -> Result { + self.active_engine_mut() + .build_html_page_from_response_with_storage_and_inspector_session_restores_async( + storage, + requested_url, + final_url, + navigation_initiator_url, + redirected, + redirect_count, + response_status, + response_headers, + response_body, + document_start_scripts, + runtime_bindings, + runtime_inspector_session_restore_snapshots, + extra_http_headers, + locale_override, + timezone_override, + script_execution_disabled, + bypass_content_security_policy, + cpu_throttling_rate, + emulated_media, + viewport_surface, + network_offline, + blocked_url_patterns, + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + root_frame_id, + main_document_commit, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn prepare_active_document_page_from_response( + &mut self, + page_reservation: RendererPageReservationToken, + storage: NavigationPageStorageHandles, + requested_url: Url, + final_url: Url, + navigation_initiator_url: Option, + redirected: bool, + redirect_count: usize, + response_status: u16, + response_headers: Vec<(String, String)>, + response_body: String, + document_start_scripts: Vec, + runtime_bindings: Vec, + runtime_inspector_session_restore_snapshots: Vec, + extra_http_headers: Vec<(String, String)>, + locale_override: Option, + timezone_override: Option, + script_execution_disabled: bool, + bypass_content_security_policy: bool, + cpu_throttling_rate: f64, + emulated_media: EmulatedMediaOverrides, + viewport_surface: Option, + network_offline: bool, + blocked_url_patterns: Vec, + fetch_subresource_interception_enabled: bool, + fetch_subresource_interception_resource_type: Option, + root_frame_id: Option, + resource_source: CommittedDocumentResourceSource, + main_document_commit: Option, + ) -> Result { + self.active_engine_mut() + .prepare_document_page_from_response_with_storage_and_inspector_session_restores_async( + page_reservation, + storage, + requested_url, + final_url, + navigation_initiator_url, + redirected, + redirect_count, + response_status, + response_headers, + response_body, + document_start_scripts, + runtime_bindings, + runtime_inspector_session_restore_snapshots, + extra_http_headers, + locale_override, + timezone_override, + script_execution_disabled, + bypass_content_security_policy, + cpu_throttling_rate, + emulated_media, + viewport_surface, + network_offline, + blocked_url_patterns, + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + root_frame_id, + resource_source, + main_document_commit, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn prepare_active_streaming_raw_page( + &mut self, + page_reservation: RendererPageReservationToken, + storage: NavigationPageStorageHandles, + requested_url: Url, + final_url: Url, + navigation_initiator_url: Option, + redirected: bool, + redirect_count: usize, + response_status: u16, + response_headers: Vec<(String, String)>, + raw_body: ExternalRawDocumentBodyStream, + document_start_scripts: Vec, + runtime_bindings: Vec, + runtime_inspector_session_restore_snapshots: Vec, + extra_http_headers: Vec<(String, String)>, + locale_override: Option, + timezone_override: Option, + script_execution_disabled: bool, + bypass_content_security_policy: bool, + cpu_throttling_rate: f64, + emulated_media: EmulatedMediaOverrides, + viewport_surface: Option, + network_offline: bool, + blocked_url_patterns: Vec, + fetch_subresource_interception_enabled: bool, + fetch_subresource_interception_resource_type: Option, + stage: PageVmInitStage, + reply_boundary: RendererReplyBoundary, + root_frame_id: Option, + resource_source: CommittedDocumentResourceSource, + reserved_service_worker_client: Option, + main_document_commit: Option, + ) -> Result { + self.active_engine_mut() + .prepare_streaming_raw_page_from_external_body_with_storage_and_inspector_session_restores_async( + page_reservation, + storage, + requested_url, + final_url, + navigation_initiator_url, + redirected, + redirect_count, + response_status, + response_headers, + raw_body, + document_start_scripts, + runtime_bindings, + runtime_inspector_session_restore_snapshots, + extra_http_headers, + locale_override, + timezone_override, + script_execution_disabled, + bypass_content_security_policy, + cpu_throttling_rate, + emulated_media, + viewport_surface, + network_offline, + blocked_url_patterns, + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + stage, + reply_boundary, + root_frame_id, + resource_source, + reserved_service_worker_client, + main_document_commit, + ) + .await + } +} diff --git a/moli-core/src/browser_host/navigation_owner/document_lifecycle_registry.rs b/moli-core/src/browser_host/navigation_owner/document_lifecycle_registry.rs new file mode 100644 index 0000000000..fa9ad5197b --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/document_lifecycle_registry.rs @@ -0,0 +1,401 @@ +use crate::{ + browser_host::{BrowserDocumentLifecycleWaitOutcome, PageResidenceIdentity}, + page::{ + RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, + RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, + RendererLifecycleTerminationStamp, + }, +}; + +use super::{BrowserPageOwnerKey, target_runtime_registry::BrowserTargetRuntimeRegistry}; + +/// Small Browser-owned current-state index used to bootstrap lifecycle fact +/// waits after the bounded journal has evicted an older milestone occurrence. +/// +/// The journal remains the occurrence stream. This registry retains only one +/// exact Page/Document record per live top-level Target; it does not retain +/// protocol events, subscribers, physical Pages, or renderer callbacks. +#[derive(Default)] +pub(super) struct BrowserDocumentLifecycleRegistry; + +pub(super) struct BrowserDocumentLifecycleRecord { + page: PageResidenceIdentity, + document: RendererDocumentLifecycleIdentity, + last_reached: Option, + termination: Option, +} + +impl BrowserDocumentLifecycleRegistry { + pub(super) fn record( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + page: &PageResidenceIdentity, + events: &[RendererDocumentLifecycleEvent], + ) { + for event in events { + let document = RendererDocumentLifecycleIdentity { + frame: event.frame, + document: event.document, + epoch: event.epoch, + }; + let record = runtimes + .entries + .entry(owner.clone()) + .or_default() + .document_lifecycle + .get_or_insert_with(|| BrowserDocumentLifecycleRecord { + page: page.clone(), + document, + last_reached: None, + termination: None, + }); + if record.page != *page || record.document != document { + *record = BrowserDocumentLifecycleRecord { + page: page.clone(), + document, + last_reached: None, + termination: None, + }; + } + match event.kind { + RendererDocumentLifecycleEventKind::Started { .. } => {} + RendererDocumentLifecycleEventKind::Milestone(milestone) => { + record.last_reached = furthest_milestone(record.last_reached, Some(milestone)); + } + RendererDocumentLifecycleEventKind::Terminated { + last_reached, + reason, + } => { + record.last_reached = furthest_milestone(record.last_reached, last_reached); + record.termination = Some(RendererLifecycleTerminationStamp { + sequence: event.sequence, + timestamp_micros: event.timestamp_micros, + reason, + }); + } + } + } + } + + pub(super) fn outcome( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + page: &PageResidenceIdentity, + document: RendererDocumentLifecycleIdentity, + milestone: RendererDocumentLifecycleMilestone, + ) -> Option { + let record = runtimes.entries.get(owner)?.document_lifecycle.as_ref()?; + if record.page != *page || record.document != document { + return None; + } + if record + .last_reached + .is_some_and(|reached| milestone_satisfies(reached, milestone)) + { + return Some(BrowserDocumentLifecycleWaitOutcome::Reached); + } + record.termination.map( + |termination| BrowserDocumentLifecycleWaitOutcome::Interrupted { + last_reached: record.last_reached, + termination, + }, + ) + } + + pub(super) fn retire_page( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + page: &PageResidenceIdentity, + ) { + if runtimes + .entries + .get(owner) + .and_then(|record| record.document_lifecycle.as_ref()) + .is_some_and(|record| record.page == *page) + { + if let Some(record) = runtimes.entries.get_mut(owner) { + record.document_lifecycle = None; + } + runtimes.prune_empty(); + } + } +} + +fn milestone_satisfies( + reached: RendererDocumentLifecycleMilestone, + expected: RendererDocumentLifecycleMilestone, +) -> bool { + matches!( + (reached, expected), + ( + RendererDocumentLifecycleMilestone::DomContentLoaded, + RendererDocumentLifecycleMilestone::DomContentLoaded + ) | ( + RendererDocumentLifecycleMilestone::Load, + RendererDocumentLifecycleMilestone::DomContentLoaded + | RendererDocumentLifecycleMilestone::Load + ) + ) +} + +fn furthest_milestone( + first: Option, + second: Option, +) -> Option { + match (first, second) { + (Some(RendererDocumentLifecycleMilestone::Load), _) + | (_, Some(RendererDocumentLifecycleMilestone::Load)) => { + Some(RendererDocumentLifecycleMilestone::Load) + } + (Some(RendererDocumentLifecycleMilestone::DomContentLoaded), _) + | (_, Some(RendererDocumentLifecycleMilestone::DomContentLoaded)) => { + Some(RendererDocumentLifecycleMilestone::DomContentLoaded) + } + (None, None) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + PageId, + page::{ + RendererDocumentTerminationReason, RendererDocumentToken, RendererFrameToken, + RendererLifecycleEpoch, RendererLifecycleStartReason, + }, + runtime::NavigationEngine, + }; + + fn page(generation: u64) -> PageResidenceIdentity { + PageResidenceIdentity::new( + "context-1".to_owned(), + Some("target-1".to_owned()), + generation, + ) + } + + fn event( + generation: u64, + sequence: u64, + kind: RendererDocumentLifecycleEventKind, + ) -> RendererDocumentLifecycleEvent { + let page_id = PageId::new_for_testing(91); + RendererDocumentLifecycleEvent { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, generation), + epoch: RendererLifecycleEpoch(generation), + sequence, + timestamp_micros: sequence * 10, + kind, + } + } + + fn identity(event: RendererDocumentLifecycleEvent) -> RendererDocumentLifecycleIdentity { + RendererDocumentLifecycleIdentity { + frame: event.frame, + document: event.document, + epoch: event.epoch, + } + } + + #[test] + fn current_snapshot_bootstraps_reached_and_interrupted_waits() { + let owner = BrowserPageOwnerKey::new("context-1", "target-1"); + let page = page(2); + let started = event( + 2, + 1, + RendererDocumentLifecycleEventKind::Started { + reason: RendererLifecycleStartReason::CrossDocumentCommit, + }, + ); + let dcl = event( + 2, + 2, + RendererDocumentLifecycleEventKind::Milestone( + RendererDocumentLifecycleMilestone::DomContentLoaded, + ), + ); + let terminated = event( + 2, + 3, + RendererDocumentLifecycleEventKind::Terminated { + last_reached: Some(RendererDocumentLifecycleMilestone::DomContentLoaded), + reason: RendererDocumentTerminationReason::Stopped, + }, + ); + let mut registry = BrowserDocumentLifecycleRegistry; + let mut runtimes = BrowserTargetRuntimeRegistry::default(); + registry.record(&mut runtimes, &owner, &page, &[started, dcl, terminated]); + + assert_eq!( + registry.outcome( + &runtimes, + &owner, + &page, + identity(started), + RendererDocumentLifecycleMilestone::DomContentLoaded, + ), + Some(BrowserDocumentLifecycleWaitOutcome::Reached) + ); + assert!(matches!( + registry.outcome( + &runtimes, + &owner, + &page, + identity(started), + RendererDocumentLifecycleMilestone::Load, + ), + Some(BrowserDocumentLifecycleWaitOutcome::Interrupted { + last_reached: Some(RendererDocumentLifecycleMilestone::DomContentLoaded), + .. + }) + )); + registry.retire_page(&mut runtimes, &owner, &page); + assert_eq!( + registry.outcome( + &runtimes, + &owner, + &page, + identity(started), + RendererDocumentLifecycleMilestone::DomContentLoaded, + ), + None, + "a Page terminal must retire its current-state bootstrap" + ); + } + + #[test] + fn successor_document_replaces_only_the_same_targets_snapshot() { + let owner = BrowserPageOwnerKey::new("context-1", "target-1"); + let page = page(2); + let first = event( + 1, + 1, + RendererDocumentLifecycleEventKind::Milestone(RendererDocumentLifecycleMilestone::Load), + ); + let successor = event( + 2, + 2, + RendererDocumentLifecycleEventKind::Started { + reason: RendererLifecycleStartReason::ExplicitDocumentOpen, + }, + ); + let mut registry = BrowserDocumentLifecycleRegistry; + let mut runtimes = BrowserTargetRuntimeRegistry::default(); + registry.record(&mut runtimes, &owner, &page, &[first, successor]); + + assert_eq!( + registry.outcome( + &runtimes, + &owner, + &page, + identity(first), + RendererDocumentLifecycleMilestone::Load, + ), + None + ); + assert_eq!( + registry.outcome( + &runtimes, + &owner, + &page, + identity(successor), + RendererDocumentLifecycleMilestone::Load, + ), + None + ); + } + + #[test] + fn later_records_cannot_regress_a_reached_load_snapshot() { + let owner = BrowserPageOwnerKey::new("context-1", "target-1"); + let page = page(2); + let load = event( + 2, + 3, + RendererDocumentLifecycleEventKind::Milestone(RendererDocumentLifecycleMilestone::Load), + ); + let delayed_dcl = event( + 2, + 4, + RendererDocumentLifecycleEventKind::Milestone( + RendererDocumentLifecycleMilestone::DomContentLoaded, + ), + ); + let mut registry = BrowserDocumentLifecycleRegistry; + let mut runtimes = BrowserTargetRuntimeRegistry::default(); + registry.record(&mut runtimes, &owner, &page, &[load, delayed_dcl]); + + assert_eq!( + registry.outcome( + &runtimes, + &owner, + &page, + identity(load), + RendererDocumentLifecycleMilestone::Load, + ), + Some(BrowserDocumentLifecycleWaitOutcome::Reached) + ); + } + + #[test] + fn forgetting_target_runtime_state_retires_only_the_exact_lifecycle_record() { + let forgotten_owner = BrowserPageOwnerKey::new("context-1", "target-1"); + let retained_owner = BrowserPageOwnerKey::new("context-1", "target-2"); + let forgotten_page = page(2); + let retained_page = + PageResidenceIdentity::new("context-1".to_owned(), Some("target-2".to_owned()), 3); + let forgotten_load = event( + 2, + 3, + RendererDocumentLifecycleEventKind::Milestone(RendererDocumentLifecycleMilestone::Load), + ); + let retained_load = event( + 3, + 4, + RendererDocumentLifecycleEventKind::Milestone(RendererDocumentLifecycleMilestone::Load), + ); + let mut navigation_owner = + super::super::BrowserNavigationOwner::new(NavigationEngine::new()); + navigation_owner.document_lifecycles.record( + &mut navigation_owner.target_runtimes, + &forgotten_owner, + &forgotten_page, + &[forgotten_load], + ); + navigation_owner.document_lifecycles.record( + &mut navigation_owner.target_runtimes, + &retained_owner, + &retained_page, + &[retained_load], + ); + + let _ = navigation_owner.forget_target_runtime_state(&forgotten_owner); + + assert_eq!( + navigation_owner.document_lifecycles.outcome( + &navigation_owner.target_runtimes, + &forgotten_owner, + &forgotten_page, + identity(forgotten_load), + RendererDocumentLifecycleMilestone::Load, + ), + None + ); + assert_eq!( + navigation_owner.document_lifecycles.outcome( + &navigation_owner.target_runtimes, + &retained_owner, + &retained_page, + identity(retained_load), + RendererDocumentLifecycleMilestone::Load, + ), + Some(BrowserDocumentLifecycleWaitOutcome::Reached) + ); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/engine_registry.rs b/moli-core/src/browser_host/navigation_owner/engine_registry.rs new file mode 100644 index 0000000000..a44745d126 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/engine_registry.rs @@ -0,0 +1,1028 @@ +use crate::{RendererOutputTransportSender, runtime::NavigationEngine}; + +use super::{ + BrowserNavigationOwner, BrowserPageOwnerKey, BrowserTargetRegistryError, + BrowserTargetResidence, target_runtime_registry::BrowserTargetRuntimeRegistry, +}; + +/// Strong owner of one renderer/navigation runtime. +/// +/// Runtime work stays behind semantic operations on `BrowserNavigationOwner`; +/// frontend adapters may transfer an engine into the registry, but cannot +/// borrow the selected engine back out. +pub(super) struct BrowserPageOwner { + pub(super) engine: NavigationEngine, +} + +impl BrowserPageOwner { + fn new(engine: NavigationEngine) -> Self { + Self { engine } + } +} + +/// What Browser Core must do with the engine selected before a handoff. +/// +/// The owner identity is protocol-neutral. `Retain` is used only when the +/// physical Target still has a Page whose runtime must survive parking; +/// `Discard` permits same-context reuse because no Page is resident. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserSelectedTargetEngineDisposition { + Unbound, + Discard(BrowserPageOwnerKey), + Retain(BrowserPageOwnerKey), +} + +impl BrowserSelectedTargetEngineDisposition { + pub(super) fn expected_owner(&self) -> Option<&BrowserPageOwnerKey> { + match self { + Self::Unbound => None, + Self::Discard(owner) | Self::Retain(owner) => Some(owner), + } + } + + fn owner_to_retain(&self) -> Option<&BrowserPageOwnerKey> { + match self { + Self::Retain(owner) => Some(owner), + Self::Unbound | Self::Discard(_) => None, + } + } +} + +/// Exact same-BrowserContext Target engine selection request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTargetEngineHandoff { + current: BrowserSelectedTargetEngineDisposition, + next: BrowserPageOwnerKey, +} + +impl BrowserTargetEngineHandoff { + pub fn new( + current: BrowserSelectedTargetEngineDisposition, + next: BrowserPageOwnerKey, + ) -> Result { + if let Some(current_owner) = current.expected_owner() + && current_owner.browser_context_id() != next.browser_context_id() + { + return Err(BrowserTargetEngineContextMismatch { + current: current_owner.clone(), + next, + }); + } + Ok(Self { current, next }) + } +} + +/// A same-context Target handoff attempted to cross BrowserContext identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTargetEngineContextMismatch { + current: BrowserPageOwnerKey, + next: BrowserPageOwnerKey, +} + +impl BrowserTargetEngineContextMismatch { + pub fn current(&self) -> &BrowserPageOwnerKey { + &self.current + } + + pub fn next(&self) -> &BrowserPageOwnerKey { + &self.next + } +} + +impl std::fmt::Display for BrowserTargetEngineContextMismatch { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "same-context target engine handoff cannot move from BrowserContext {:?} to {:?}", + self.current.browser_context_id(), + self.next.browser_context_id() + ) + } +} + +impl std::error::Error for BrowserTargetEngineContextMismatch {} + +/// Exact cross-BrowserContext engine selection request. +/// +/// A context without an active Target selects an unbound engine. Unlike a +/// same-context Target handoff, a missing retained successor always creates a +/// new engine because renderer context runtime state cannot cross profiles. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct BrowserContextEngineHandoff { + current: BrowserSelectedTargetEngineDisposition, + next: Option, +} + +impl BrowserContextEngineHandoff { + pub(super) fn new( + current: BrowserSelectedTargetEngineDisposition, + next: Option, + ) -> Self { + Self { current, next } + } +} + +/// Where an exact loaded Target engine resides after physical Page commit. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserTargetEngineResidence { + Selected, + Retained, +} + +/// Observable outcome of one Browser-owned engine selection transaction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserTargetEngineHandoffOutcome { + ReusedSelected, + RestoredRetained, + CreatedReplacement, +} + +/// A handoff named a different current Target than the registry owns. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTargetEngineOwnerMismatch { + selected: Option, + requested: Option, +} + +impl BrowserTargetEngineOwnerMismatch { + pub fn selected(&self) -> Option<&BrowserPageOwnerKey> { + self.selected.as_ref() + } + + pub fn requested(&self) -> Option<&BrowserPageOwnerKey> { + self.requested.as_ref() + } +} + +impl std::fmt::Display for BrowserTargetEngineOwnerMismatch { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "selected target engine owner {:?} does not match requested current owner {:?}", + self.selected, self.requested + ) + } +} + +impl std::error::Error for BrowserTargetEngineOwnerMismatch {} + +/// A Target engine could not be adopted into the exact Browser-owned +/// topology and selected-engine state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserTargetEngineAdoptionError { + Target(BrowserTargetRegistryError), + EngineOwner(BrowserTargetEngineOwnerMismatch), + SelectedTargetProjectionMismatch { + authoritative: Option, + projected: Option, + }, +} + +impl std::fmt::Display for BrowserTargetEngineAdoptionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Target(error) => error.fmt(formatter), + Self::EngineOwner(error) => error.fmt(formatter), + Self::SelectedTargetProjectionMismatch { + authoritative, + projected, + } => write!( + formatter, + "authoritative selected Target {:?} does not match projected current Target {:?}", + authoritative, projected + ), + } + } +} + +impl std::error::Error for BrowserTargetEngineAdoptionError {} + +impl From for BrowserTargetEngineAdoptionError { + fn from(error: BrowserTargetRegistryError) -> Self { + Self::Target(error) + } +} + +impl From for BrowserTargetEngineAdoptionError { + fn from(error: BrowserTargetEngineOwnerMismatch) -> Self { + Self::EngineOwner(error) + } +} + +/// Browser-owned coordinator for unbound and per-Target NavigationEngines. +/// +/// Target-backed engines live in `BrowserTargetRuntimeRegistry`; selection is +/// derived from BrowserContext and Target topology. Only the startup/empty- +/// Context engine has no Target runtime record. +pub(super) struct BrowserTargetEngineRegistry { + unbound: Option, + renderer_output_transport_sender: Option, +} + +impl BrowserTargetEngineRegistry { + pub(super) fn new(engine: NavigationEngine) -> Self { + Self { + unbound: Some(BrowserPageOwner::new(engine)), + renderer_output_transport_sender: None, + } + } + + pub(super) fn selected_engine<'a>( + &'a self, + runtimes: &'a BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + ) -> &'a NavigationEngine { + if let Some(owner) = selected_owner { + return &runtimes + .entries + .get(owner) + .and_then(|runtime| runtime.engine.as_ref()) + .expect("selected Target topology must own an engine") + .engine; + } + &self + .unbound + .as_ref() + .expect("unbound selection must own an engine") + .engine + } + + pub(super) fn selected_engine_mut<'a>( + &'a mut self, + runtimes: &'a mut BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + ) -> &'a mut NavigationEngine { + if let Some(owner) = selected_owner { + return &mut runtimes + .entries + .get_mut(owner) + .and_then(|runtime| runtime.engine.as_mut()) + .expect("selected Target topology must own an engine") + .engine; + } + &mut self + .unbound + .as_mut() + .expect("unbound selection must own an engine") + .engine + } + + pub(super) fn retained_engine_mut<'a>( + &mut self, + runtimes: &'a mut BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + owner: &BrowserPageOwnerKey, + ) -> Option<&'a mut NavigationEngine> { + if selected_owner == Some(owner) { + return None; + } + runtimes + .entries + .get_mut(owner) + .and_then(|runtime| runtime.engine.as_mut()) + .map(|owner| &mut owner.engine) + } + + pub(super) fn set_renderer_output_transport_sender( + &mut self, + runtimes: &BrowserTargetRuntimeRegistry, + sender: RendererOutputTransportSender, + ) { + self.renderer_output_transport_sender = Some(sender.clone()); + if let Some(unbound) = self.unbound.as_ref() { + unbound + .engine + .set_renderer_output_transport_sender(sender.clone()); + } + for owner in runtimes + .entries + .values() + .filter_map(|runtime| runtime.engine.as_ref()) + { + owner + .engine + .set_renderer_output_transport_sender(sender.clone()); + } + } + + pub(super) fn configure_detached_engine(&self, engine: &NavigationEngine) { + if let Some(sender) = self.renderer_output_transport_sender.as_ref() { + engine.set_renderer_output_transport_sender(sender.clone()); + } + } + + fn configure_and_wrap(&self, engine: NavigationEngine) -> BrowserPageOwner { + self.configure_detached_engine(&engine); + BrowserPageOwner::new(engine) + } + + pub(super) fn validate_current( + selected_owner: Option<&BrowserPageOwnerKey>, + requested: &BrowserSelectedTargetEngineDisposition, + ) -> Result<(), BrowserTargetEngineOwnerMismatch> { + let requested_owner = requested.expected_owner(); + if selected_owner == requested_owner { + return Ok(()); + } + Err(BrowserTargetEngineOwnerMismatch { + selected: selected_owner.cloned(), + requested: requested_owner.cloned(), + }) + } + + fn clear_discarded_current( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + disposition: &BrowserSelectedTargetEngineDisposition, + ) { + match disposition { + BrowserSelectedTargetEngineDisposition::Unbound => { + self.unbound = None; + } + BrowserSelectedTargetEngineDisposition::Discard(owner) => { + if let Some(runtime) = runtimes.entries.get_mut(owner) { + runtime.engine = None; + } + } + BrowserSelectedTargetEngineDisposition::Retain(_) => {} + } + } + + fn take_reusable_current( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + disposition: &BrowserSelectedTargetEngineDisposition, + ) -> BrowserPageOwner { + match disposition { + BrowserSelectedTargetEngineDisposition::Unbound => self + .unbound + .take() + .expect("unbound engine handoff must own its runtime"), + BrowserSelectedTargetEngineDisposition::Discard(owner) => runtimes + .entries + .get_mut(owner) + .and_then(|runtime| runtime.engine.take()) + .expect("discardable selected Target must own its runtime"), + BrowserSelectedTargetEngineDisposition::Retain(_) => { + unreachable!("retained engine cannot be moved into its successor") + } + } + } + + pub(super) fn install_unbound_engine( + &mut self, + selected_owner: Option<&BrowserPageOwnerKey>, + engine: NavigationEngine, + ) -> Result<(), BrowserTargetEngineOwnerMismatch> { + if selected_owner.is_some() { + return Err(BrowserTargetEngineOwnerMismatch { + selected: selected_owner.cloned(), + requested: None, + }); + } + self.unbound = Some(self.configure_and_wrap(engine)); + Ok(()) + } + + pub(super) fn adopt_target_engine( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + owner: BrowserPageOwnerKey, + residence: BrowserTargetEngineResidence, + engine: NavigationEngine, + ) -> Result<(), BrowserTargetEngineOwnerMismatch> { + let engine = self.configure_and_wrap(engine); + match residence { + BrowserTargetEngineResidence::Selected => { + if selected_owner != Some(&owner) { + return Err(BrowserTargetEngineOwnerMismatch { + selected: selected_owner.cloned(), + requested: Some(owner), + }); + } + runtimes.entries.entry(owner).or_default().engine = Some(engine); + self.unbound = None; + } + BrowserTargetEngineResidence::Retained => { + if selected_owner == Some(&owner) { + return Err(BrowserTargetEngineOwnerMismatch { + selected: selected_owner.cloned(), + requested: None, + }); + } + runtimes.entries.entry(owner).or_default().engine = Some(engine); + } + } + Ok(()) + } + + pub(super) fn handoff_target_engine( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + handoff: BrowserTargetEngineHandoff, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + Self::validate_current(selected_owner, &handoff.current)?; + if handoff.current.expected_owner() == Some(&handoff.next) { + return Ok(BrowserTargetEngineHandoffOutcome::ReusedSelected); + } + + if runtimes + .entries + .get(&handoff.next) + .is_some_and(|runtime| runtime.engine.is_some()) + { + self.clear_discarded_current(runtimes, &handoff.current); + self.unbound = None; + runtimes.prune_empty(); + return Ok(BrowserTargetEngineHandoffOutcome::RestoredRetained); + } + + if handoff.current.owner_to_retain().is_some() { + let replacement = self.configure_and_wrap(create_replacement()); + runtimes.entries.entry(handoff.next).or_default().engine = Some(replacement); + self.unbound = None; + return Ok(BrowserTargetEngineHandoffOutcome::CreatedReplacement); + } + + // No Page is resident in the current Target, so its selected engine + // can become the next Target's engine without manufacturing a second + // renderer owner. + let previous = self.take_reusable_current(runtimes, &handoff.current); + runtimes.entries.entry(handoff.next).or_default().engine = Some(previous); + runtimes.prune_empty(); + Ok(BrowserTargetEngineHandoffOutcome::ReusedSelected) + } + + pub(super) fn handoff_browser_context_engine( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + handoff: BrowserContextEngineHandoff, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + Self::validate_current(selected_owner, &handoff.current)?; + let next_is_retained = handoff + .next + .as_ref() + .and_then(|owner| runtimes.entries.get(owner)) + .is_some_and(|runtime| runtime.engine.is_some()); + let replacement = + (!next_is_retained).then(|| self.configure_and_wrap(create_replacement())); + self.clear_discarded_current(runtimes, &handoff.current); + match handoff.next { + Some(next) => { + if let Some(replacement) = replacement { + runtimes.entries.entry(next).or_default().engine = Some(replacement); + } + self.unbound = None; + } + None => { + self.unbound = replacement; + } + } + runtimes.prune_empty(); + let outcome = if next_is_retained { + BrowserTargetEngineHandoffOutcome::RestoredRetained + } else { + BrowserTargetEngineHandoffOutcome::CreatedReplacement + }; + Ok(outcome) + } + + pub(super) fn discard_target_page_runtime( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + target_id: &str, + ) { + for (owner, runtime) in &mut runtimes.entries { + if owner.target_id() == target_id && selected_owner != Some(owner) { + runtime.engine = None; + } + } + runtimes.prune_empty(); + } + + pub(super) fn retire_target( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + owner: &BrowserPageOwnerKey, + unbind_selected: bool, + ) { + if let Some(runtime) = runtimes.entries.get_mut(owner) { + if unbind_selected && selected_owner == Some(owner) { + if let Some(engine) = runtime.engine.take() { + self.unbound = Some(engine); + } + } else if selected_owner != Some(owner) { + runtime.engine = None; + } + } + runtimes.prune_empty(); + } + + pub(super) fn retained_count( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + ) -> usize { + self.retained_keys(runtimes, selected_owner).count() + } + + pub(super) fn retained_keys<'a>( + &self, + runtimes: &'a BrowserTargetRuntimeRegistry, + selected_owner: Option<&'a BrowserPageOwnerKey>, + ) -> impl Iterator { + runtimes.entries.iter().filter_map(move |(owner, runtime)| { + (runtime.engine.is_some() && selected_owner != Some(owner)).then_some(owner) + }) + } + + pub(super) fn clone_retained_engine( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + selected_owner: Option<&BrowserPageOwnerKey>, + owner: &BrowserPageOwnerKey, + ) -> Option { + if selected_owner == Some(owner) { + return None; + } + runtimes + .entries + .get(owner) + .and_then(|runtime| runtime.engine.as_ref()) + .map(|owner| owner.engine.clone()) + } + + pub(super) fn retained_renderer_owner_ids<'a>( + &'a self, + runtimes: &'a BrowserTargetRuntimeRegistry, + selected_owner: Option<&'a BrowserPageOwnerKey>, + ) -> impl Iterator + 'a { + self.retained_keys(runtimes, selected_owner) + .filter_map(|owner| runtimes.entries.get(owner)) + .filter_map(|runtime| runtime.engine.as_ref()) + .map(|owner| owner.engine.renderer_owner_id_for_diagnostics()) + } +} + +impl BrowserNavigationOwner { + pub(super) fn active_engine(&self) -> &NavigationEngine { + self.target_engines + .selected_engine(&self.target_runtimes, self.selected_target_engine_owner()) + } + + pub(super) fn active_engine_mut(&mut self) -> &mut NavigationEngine { + let selected_owner = self.selected_target_engine_owner().cloned(); + self.target_engines + .selected_engine_mut(&mut self.target_runtimes, selected_owner.as_ref()) + } + + pub fn selected_target_engine_owner(&self) -> Option<&BrowserPageOwnerKey> { + let browser_context_id = self.browser_contexts.selected()?; + let target_id = self.targets.active_target(browser_context_id)?; + self.target_runtimes.entries.keys().find(|owner| { + owner.browser_context_id() == browser_context_id.as_str() + && owner.target_id() == target_id.as_str() + }) + } + + /// Transitional exact-engine access for activity-source routing. The + /// caller has already resolved the physical active Target; new operations + /// should be expressed as semantic Browser Owner methods instead. + pub fn active_engine_for_activity_source_mut(&mut self) -> &mut NavigationEngine { + self.active_engine_mut() + } + + /// Transitional exact retained-engine access for activity-source routing. + pub fn retained_background_engine_mut( + &mut self, + browser_context_id: &str, + target_id: &str, + ) -> Option<&mut NavigationEngine> { + let selected_owner = self.selected_target_engine_owner().cloned(); + self.target_engines.retained_engine_mut( + &mut self.target_runtimes, + selected_owner.as_ref(), + &BrowserPageOwnerKey::new(browser_context_id, target_id), + ) + } + + pub fn set_renderer_output_transport_sender(&mut self, sender: RendererOutputTransportSender) { + self.target_engines + .set_renderer_output_transport_sender(&self.target_runtimes, sender); + } + + pub fn configure_detached_engine(&self, engine: &NavigationEngine) { + self.target_engines.configure_detached_engine(engine); + } + + pub fn install_unbound_engine( + &mut self, + engine: NavigationEngine, + ) -> Result<(), BrowserTargetEngineOwnerMismatch> { + let selected_owner = self.selected_target_engine_owner().cloned(); + self.target_engines + .install_unbound_engine(selected_owner.as_ref(), engine) + } + + pub fn adopt_target_engine( + &mut self, + owner: BrowserPageOwnerKey, + residence: BrowserTargetEngineResidence, + engine: NavigationEngine, + ) -> Result<(), BrowserTargetEngineOwnerMismatch> { + let selected_owner = self.selected_target_engine_owner().cloned(); + self.target_engines.adopt_target_engine( + &mut self.target_runtimes, + selected_owner.as_ref(), + owner, + residence, + engine, + ) + } + + /// Adopts a loaded engine for one exact registered Target. + /// + /// Browser Core derives selected/retained residence from its own context + /// and Target topology in the same owner call. Protocol cannot choose a + /// residence from a projection and race a later engine-registry mutation. + pub fn adopt_registered_target_engine( + &mut self, + owner: BrowserPageOwnerKey, + engine: NavigationEngine, + ) -> Result { + let target_residence = self.targets.validate_target_owner(&owner)?; + let selected = target_residence == BrowserTargetResidence::Active + && self + .browser_contexts + .selected() + .is_some_and(|browser_context_id| { + browser_context_id.as_str() == owner.browser_context_id() + }); + let engine_residence = if selected { + BrowserTargetEngineResidence::Selected + } else { + BrowserTargetEngineResidence::Retained + }; + self.adopt_target_engine(owner, engine_residence, engine)?; + Ok(engine_residence) + } + + /// Replaces the engine for the Browser-owned selected Target, or the + /// unbound engine when the selected BrowserContext has no active Target. + pub fn adopt_selected_target_engine_or_unbound( + &mut self, + projected_owner: Option, + engine: NavigationEngine, + ) -> Result, BrowserTargetEngineAdoptionError> { + let authoritative_owner = + if let Some(browser_context_id) = self.browser_contexts.selected().cloned() { + self.targets + .active_target_for_registered_context(&browser_context_id)? + .map(|target_id| { + BrowserPageOwnerKey::new(browser_context_id.as_str(), target_id.as_str()) + }) + } else { + None + }; + if authoritative_owner != projected_owner { + return Err( + BrowserTargetEngineAdoptionError::SelectedTargetProjectionMismatch { + authoritative: authoritative_owner, + projected: projected_owner, + }, + ); + } + let Some(owner) = authoritative_owner else { + self.install_unbound_engine(engine)?; + return Ok(None); + }; + self.adopt_registered_target_engine(owner, engine).map(Some) + } + + pub fn handoff_target_engine( + &mut self, + handoff: BrowserTargetEngineHandoff, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + let selected_owner = self.selected_target_engine_owner().cloned(); + self.target_engines.handoff_target_engine( + &mut self.target_runtimes, + selected_owner.as_ref(), + handoff, + create_replacement, + ) + } + + pub fn retained_background_engine_count(&self) -> usize { + self.target_engines + .retained_count(&self.target_runtimes, self.selected_target_engine_owner()) + } + + pub fn retained_background_engine_keys(&self) -> impl Iterator { + self.target_engines + .retained_keys(&self.target_runtimes, self.selected_target_engine_owner()) + } + + pub fn clone_retained_background_engine( + &self, + browser_context_id: &str, + target_id: &str, + ) -> Option { + self.target_engines.clone_retained_engine( + &self.target_runtimes, + self.selected_target_engine_owner(), + &BrowserPageOwnerKey::new(browser_context_id, target_id), + ) + } +} + +#[cfg(test)] +mod tests { + use crate::browser_host::{ + BrowserContextSelectionProjection, BrowserPageResidenceHandle, BrowserTargetHandle, + BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }; + + use super::*; + + fn engine() -> NavigationEngine { + NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics() + } + + fn owner_with_selected_target() -> (BrowserNavigationOwner, BrowserPageOwnerKey) { + let key = BrowserPageOwnerKey::new("context-1", "target-a"); + let mut owner = BrowserNavigationOwner::new(engine()); + owner + .register_browser_context( + key.browser_context_id(), + BrowserTargetTopologyProjection::new( + key.browser_context_id(), + Some(BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged(key.target_id()), + BrowserPageResidenceHandle::default(), + )), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + engine, + ) + .expect("test BrowserContext should register"); + (owner, key) + } + + fn topology_for( + owner: &BrowserNavigationOwner, + active: &BrowserPageOwnerKey, + background: &[&BrowserPageOwnerKey], + ) -> BrowserTargetTopologyProjection { + let slot = |key: &BrowserPageOwnerKey| { + BrowserTargetSlotProjection::new( + owner + .target_handle(key.target_id()) + .expect("test Target handle"), + owner + .page_residence_handle(key) + .expect("test Page residence handle"), + ) + }; + BrowserTargetTopologyProjection::new( + active.browser_context_id(), + Some(slot(active)), + background.iter().map(|key| slot(key)).collect::>(), + ) + } + + fn register_background_target( + owner: &mut BrowserNavigationOwner, + active: &BrowserPageOwnerKey, + background: &BrowserPageOwnerKey, + ) { + let projection = topology_for(owner, active, &[]); + owner + .register_background_target( + background.browser_context_id(), + background.target_id(), + projection, + ) + .expect("test background Target should register"); + } + + #[test] + fn registered_engine_adoption_rejects_non_selected_owner_without_mutation() { + let (mut owner, target_a) = owner_with_selected_target(); + let divergent = BrowserPageOwnerKey::new("context-1", "target-b"); + let selected_renderer_owner = owner.active_renderer_owner_id_for_diagnostics(); + + let error = owner + .adopt_target_engine( + divergent.clone(), + BrowserTargetEngineResidence::Selected, + engine(), + ) + .expect_err("topology must reject a different selected engine owner"); + + assert_eq!( + error, + BrowserTargetEngineOwnerMismatch { + selected: Some(target_a.clone()), + requested: Some(divergent), + } + ); + assert_eq!(owner.selected_target_engine_owner(), Some(&target_a)); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + selected_renderer_owner, + "failed adoption must preserve the selected engine payload" + ); + assert_eq!(owner.retained_background_engine_count(), 0); + } + + #[test] + fn target_handoff_parks_loaded_current_and_restores_exact_retained_engine() { + let (mut owner, target_a) = owner_with_selected_target(); + let target_b = BrowserPageOwnerKey::new("context-1", "target-b"); + owner + .adopt_target_engine( + target_a.clone(), + BrowserTargetEngineResidence::Selected, + engine(), + ) + .expect("target A should bind selected engine"); + let target_a_renderer_owner = owner.active_renderer_owner_id_for_diagnostics(); + register_background_target(&mut owner, &target_a, &target_b); + owner + .adopt_target_engine( + target_b.clone(), + BrowserTargetEngineResidence::Retained, + engine(), + ) + .expect("target B should retain its engine"); + let target_b_renderer_owner = owner + .retained_renderer_owner_ids_for_diagnostics() + .next() + .expect("retained target B renderer owner"); + + let projection = topology_for(&owner, &target_a, &[&target_b]); + let activation = owner + .activate_target( + target_b.browser_context_id(), + target_b.target_id(), + projection, + BrowserContextSelectionProjection::new( + Some(target_a.browser_context_id().to_owned()), + BrowserSelectedTargetEngineDisposition::Retain(target_a.clone()), + ), + engine, + ) + .expect("exact target handoff should commit"); + + assert_eq!( + activation.engine_outcome(), + Some(BrowserTargetEngineHandoffOutcome::RestoredRetained) + ); + assert_eq!(owner.selected_target_engine_owner(), Some(&target_b)); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + target_b_renderer_owner + ); + assert_eq!(owner.retained_background_engine_count(), 1); + assert_eq!( + owner.retained_renderer_owner_ids_for_diagnostics().next(), + Some(target_a_renderer_owner) + ); + } + + #[test] + fn target_handoff_reuses_unloaded_current_without_creating_replacement() { + let (mut owner, target_a) = owner_with_selected_target(); + let target_b = BrowserPageOwnerKey::new("context-1", "target-b"); + owner + .adopt_target_engine( + target_a.clone(), + BrowserTargetEngineResidence::Selected, + engine(), + ) + .expect("target A should bind selected engine"); + let renderer_owner = owner.active_renderer_owner_id_for_diagnostics(); + register_background_target(&mut owner, &target_a, &target_b); + let mut replacement_created = false; + + let projection = topology_for(&owner, &target_a, &[&target_b]); + let activation = owner + .activate_target( + target_b.browser_context_id(), + target_b.target_id(), + projection, + BrowserContextSelectionProjection::new( + Some(target_a.browser_context_id().to_owned()), + BrowserSelectedTargetEngineDisposition::Discard(target_a), + ), + || { + replacement_created = true; + engine() + }, + ) + .expect("unloaded target handoff should commit"); + + assert_eq!( + activation.engine_outcome(), + Some(BrowserTargetEngineHandoffOutcome::ReusedSelected) + ); + assert!(!replacement_created); + assert_eq!(owner.selected_target_engine_owner(), Some(&target_b)); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + renderer_owner + ); + assert_eq!(owner.retained_background_engine_count(), 0); + } + + #[test] + fn target_handoff_type_rejects_cross_context_engine_reuse() { + let current = BrowserPageOwnerKey::new("context-a", "target-a"); + let next = BrowserPageOwnerKey::new("context-b", "target-b"); + + let error = BrowserTargetEngineHandoff::new( + BrowserSelectedTargetEngineDisposition::Discard(current.clone()), + next.clone(), + ) + .expect_err("same-context handoff must not authorize cross-context engine reuse"); + + assert_eq!(error.current(), ¤t); + assert_eq!(error.next(), &next); + } + + #[test] + fn stale_handoff_cannot_move_another_targets_engine() { + let (mut owner, selected) = owner_with_selected_target(); + let target_a = BrowserPageOwnerKey::new("context-1", "target-stale"); + let target_c = BrowserPageOwnerKey::new("context-1", "target-c"); + let renderer_owner = owner.active_renderer_owner_id_for_diagnostics(); + + let error = owner + .handoff_target_engine( + BrowserTargetEngineHandoff::new( + BrowserSelectedTargetEngineDisposition::Retain(target_a.clone()), + target_c, + ) + .expect("test targets share one BrowserContext"), + engine, + ) + .expect_err("stale target A handoff must be rejected"); + + assert_eq!(error.selected(), Some(&selected)); + assert_eq!(error.requested(), Some(&target_a)); + assert_eq!(owner.selected_target_engine_owner(), Some(&selected)); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + renderer_owner + ); + assert_eq!(owner.retained_background_engine_count(), 0); + } + + #[test] + fn unbound_engine_cannot_authorize_a_handoff_that_claims_a_current_target() { + let mut owner = BrowserNavigationOwner::new(engine()); + let target_a = BrowserPageOwnerKey::new("context-1", "target-a"); + let target_b = BrowserPageOwnerKey::new("context-1", "target-b"); + let renderer_owner = owner.active_renderer_owner_id_for_diagnostics(); + + let error = owner + .handoff_target_engine( + BrowserTargetEngineHandoff::new( + BrowserSelectedTargetEngineDisposition::Discard(target_a.clone()), + target_b, + ) + .expect("test targets share one BrowserContext"), + engine, + ) + .expect_err("unbound must not act as a wildcard current owner"); + + assert_eq!(error.selected(), None); + assert_eq!(error.requested(), Some(&target_a)); + assert_eq!(owner.selected_target_engine_owner(), None); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + renderer_owner + ); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/facts.rs b/moli-core/src/browser_host/navigation_owner/facts.rs new file mode 100644 index 0000000000..bc642e1635 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/facts.rs @@ -0,0 +1,557 @@ +use std::sync::Arc; + +use crate::{ + browser_host::{ + BrowserDocumentLifecycleWaitOutcome, BrowserDocumentLifecycleWaitTicket, BrowserFact, + BrowserFactEnvelope, BrowserFactPublishError, BrowserFactSubscriber, + BrowserFactWakeSubscriber, PageResidenceIdentity, + }, + page::{ + RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, + RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, + RendererLifecycleEventStamp, RendererLifecycleTerminationStamp, + }, +}; + +use super::{ + BrowserDocumentNavigation, BrowserNavigationFailure, BrowserNavigationOwner, + BrowserPageOwnerKey, BrowserTargetMetadataTransition, BrowserTargetTermination, + BrowserTargetTerminationKind, +}; + +impl BrowserNavigationOwner { + /// Creates a cursor over the retained window followed by future facts. + pub fn subscribe_browser_facts(&self) -> BrowserFactSubscriber { + self.browser_facts.subscribe() + } + + /// Captures a move-only wait over the common Browser fact journal for one + /// exact current Page and renderer Document. + /// + /// The caller may resolve protocol/session routing before this boundary, + /// but only Browser Core validates the Page generation and chooses the + /// journal cut. A stale physical binding therefore resolves as + /// `Superseded` instead of registering a callback in the frontend Page + /// projection. + pub fn capture_document_lifecycle_wait( + &self, + expected_page: PageResidenceIdentity, + expected_document: RendererDocumentLifecycleIdentity, + milestone: RendererDocumentLifecycleMilestone, + ) -> BrowserDocumentLifecycleWaitTicket { + let Some(owner) = self.page_owner_key_if_current(&expected_page) else { + return BrowserDocumentLifecycleWaitTicket::resolved( + Some(expected_page), + Some(expected_document), + milestone, + BrowserDocumentLifecycleWaitOutcome::Superseded, + ); + }; + if self.has_pending_document_navigation(&owner) { + return BrowserDocumentLifecycleWaitTicket::resolved( + Some(expected_page), + Some(expected_document), + milestone, + BrowserDocumentLifecycleWaitOutcome::Superseded, + ); + } + if let Some(outcome) = self.document_lifecycles.outcome( + &self.target_runtimes, + &owner, + &expected_page, + expected_document, + milestone, + ) { + return BrowserDocumentLifecycleWaitTicket::resolved( + Some(expected_page), + Some(expected_document), + milestone, + outcome, + ); + } + let (wait_subscriber, readiness_subscriber) = self.browser_facts.subscribe_pair(); + BrowserDocumentLifecycleWaitTicket::new( + wait_subscriber, + readiness_subscriber, + expected_page, + expected_document, + milestone, + ) + } + + /// Creates a coalesced application-scheduler wake subscription. + pub fn subscribe_browser_fact_wake(&self) -> BrowserFactWakeSubscriber { + self.browser_facts.subscribe_wake() + } + + /// Returns the currently retained bounded fact window. + pub fn browser_fact_snapshot(&self) -> Vec> { + self.browser_facts.snapshot() + } + + /// Publishes the unique occurrence of one committed top-level Target. + /// + /// The exact initial Page-slot identity distinguishes a later Target that + /// reuses the same public id. DevTools discovery and attachment state are + /// frontend concerns and therefore do not enter this fact. + pub(super) fn record_target_created_fact( + &mut self, + owner: &BrowserPageOwnerKey, + page: &PageResidenceIdentity, + ) -> Result>, BrowserFactPublishError> { + debug_assert_eq!(page.browser_context_id(), owner.browser_context_id()); + debug_assert_eq!(page.target_id(), Some(owner.target_id())); + debug_assert_eq!(self.page_owner_key_if_current(page).as_ref(), Some(owner)); + debug_assert!(self.has_target(owner.target_id())); + self.browser_facts + .publish_batch(page.clone(), vec![BrowserFact::TargetCreated]) + } + + /// Publishes one accepted cross-Document request against the exact Page + /// generation that it supersedes. A predecessor retired by this admission + /// is carried by the same occurrence, so consumers never reconstruct the + /// transaction from adjacent journal entries. + pub(super) fn record_navigation_admission_facts( + &mut self, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + superseded: Option<&BrowserDocumentNavigation>, + page: &PageResidenceIdentity, + ) -> Result>, BrowserFactPublishError> { + debug_assert_eq!(navigation.target_id(), owner.target_id()); + debug_assert_eq!(page.browser_context_id(), owner.browser_context_id()); + debug_assert_eq!(page.target_id(), Some(owner.target_id())); + debug_assert_eq!(self.page_owner_key_if_current(page).as_ref(), Some(owner)); + self.browser_facts.publish_batch( + page.clone(), + vec![BrowserFact::NavigationAccepted { + navigation: navigation.clone(), + superseded_navigation: superseded.cloned(), + }], + ) + } + + /// Publishes the unique non-commit terminal fact for one exact request + /// after its Browser-owned pending state has been retired. + pub(super) fn record_navigation_failed_fact( + &mut self, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + failure: BrowserNavigationFailure, + previous_page: Option, + current_page: &PageResidenceIdentity, + ) -> Result>, BrowserFactPublishError> { + debug_assert_eq!(navigation.target_id(), owner.target_id()); + debug_assert_eq!( + current_page.browser_context_id(), + owner.browser_context_id() + ); + debug_assert_eq!(current_page.target_id(), Some(owner.target_id())); + if let Some(previous_page) = previous_page.as_ref() { + self.document_lifecycles + .retire_page(&mut self.target_runtimes, owner, previous_page); + } + self.browser_facts.publish_batch( + current_page.clone(), + vec![BrowserFact::NavigationFailed { + navigation: navigation.clone(), + failure, + previous_page, + }], + ) + } + + /// Publishes the non-Document success terminal for one exact request after + /// Core has retired its pending authority while retaining the current Page. + pub(super) fn record_navigation_download_fact( + &mut self, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + current_page: &PageResidenceIdentity, + ) -> Result>, BrowserFactPublishError> { + debug_assert_eq!(navigation.target_id(), owner.target_id()); + debug_assert_eq!( + current_page.browser_context_id(), + owner.browser_context_id() + ); + debug_assert_eq!(current_page.target_id(), Some(owner.target_id())); + self.browser_facts.publish_batch( + current_page.clone(), + vec![BrowserFact::NavigationConvertedToDownload { + navigation: navigation.clone(), + }], + ) + } + + /// Publishes the immutable facts for one loaded-Document commit after + /// request, Page generation, history and recovery state have committed + /// together. + /// + /// This is intentionally owner-private: a physical Page projector cannot + /// claim that either the navigation or replacement happened. + /// `current_page` is carried by both envelopes. The atomic navigation/Page + /// replacement occurrence is followed by frozen Target metadata, so + /// consumers can depend on one stable commit -> metadata order without + /// inventing another commit boundary. + pub(super) fn record_loaded_navigation_commit_facts( + &mut self, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + previous_page: &PageResidenceIdentity, + current_page: &PageResidenceIdentity, + metadata: BrowserTargetMetadataTransition, + ) -> Result>, BrowserFactPublishError> { + debug_assert_eq!(navigation.target_id(), owner.target_id()); + debug_assert_eq!( + current_page.browser_context_id(), + owner.browser_context_id() + ); + debug_assert_eq!(current_page.target_id(), Some(owner.target_id())); + debug_assert_eq!( + self.page_owner_key_if_current(current_page).as_ref(), + Some(owner) + ); + debug_assert_eq!( + self.page_owner_key_for_same_slot(previous_page).as_ref(), + Some(owner) + ); + self.document_lifecycles + .retire_page(&mut self.target_runtimes, owner, previous_page); + self.browser_facts.publish_batch( + current_page.clone(), + vec![ + BrowserFact::NavigationCommitted { + navigation: navigation.clone(), + previous_page: previous_page.clone(), + }, + BrowserFact::TargetMetadataChanged { + transition: metadata, + }, + ], + ) + } + + /// Publishes a renderer-observed metadata update for one exact current + /// Page after Browser-owned history accepted the same value. + pub(super) fn record_document_title_changed_fact( + &mut self, + page: &PageResidenceIdentity, + transition: BrowserTargetMetadataTransition, + ) -> Result>, BrowserFactPublishError> { + debug_assert!(transition.is_document_title_change()); + debug_assert!(self.page_owner_key_if_current(page).is_some()); + self.browser_facts.publish_batch( + page.clone(), + vec![BrowserFact::TargetMetadataChanged { transition }], + ) + } + + /// Publishes the immutable Target terminal fact after Target lifecycle, + /// Page generation, request state, runtime ownership and history have + /// committed together. + /// + /// A close has already removed the Target/Page registry entries, so this + /// owner-private producer validates the immutable transaction result + /// instead of asking the now-retired physical projection for authority. + pub(super) fn record_target_termination_facts( + &mut self, + termination: &BrowserTargetTermination, + pending_navigation: Option<&BrowserDocumentNavigation>, + ) -> Result>, BrowserFactPublishError> { + let owner = termination.owner(); + let previous_page = termination.previous_page(); + let terminal_page = termination.terminal_page(); + debug_assert_eq!( + previous_page.browser_context_id(), + owner.browser_context_id() + ); + debug_assert_eq!(previous_page.target_id(), Some(owner.target_id())); + debug_assert_eq!( + terminal_page.browser_context_id(), + owner.browser_context_id() + ); + debug_assert_eq!(terminal_page.target_id(), Some(owner.target_id())); + debug_assert_eq!( + Some(terminal_page.loaded_page_generation()), + previous_page.loaded_page_generation().checked_add(1) + ); + let target_fact = match termination.kind() { + BrowserTargetTerminationKind::Crash => { + debug_assert_eq!( + self.page_owner_key_if_current(terminal_page).as_ref(), + Some(owner) + ); + debug_assert!(self.has_target(owner.target_id())); + BrowserFact::TargetCrashed { + previous_page: previous_page.clone(), + pending_navigation: pending_navigation.cloned(), + } + } + BrowserTargetTerminationKind::Close => { + debug_assert!( + self.capture_page_residence(owner.browser_context_id(), owner.target_id()) + .is_none() + ); + debug_assert!(!self.has_target(owner.target_id())); + BrowserFact::TargetClosed { + previous_page: previous_page.clone(), + pending_navigation: pending_navigation.cloned(), + } + } + }; + self.document_lifecycles + .retire_page(&mut self.target_runtimes, owner, previous_page); + self.browser_facts + .publish_batch(terminal_page.clone(), vec![target_fact]) + } + + /// Records lifecycle current state plus reached/terminated facts accepted + /// for one exact current Page. Started selects the Browser-owned snapshot + /// but remains outside the occurrence journal until a consumer needs that + /// distinct fact. + pub fn record_document_lifecycle_facts( + &mut self, + expected_page: &PageResidenceIdentity, + events: &[RendererDocumentLifecycleEvent], + ) -> Result>, BrowserFactPublishError> { + if events.is_empty() { + return Ok(Vec::new()); + } + let Some(target_id) = expected_page.target_id() else { + return Err(BrowserFactPublishError::TargetlessPageResidence( + expected_page.clone(), + )); + }; + let Some(owner) = self.page_owner_key_if_current(expected_page) else { + return Err(BrowserFactPublishError::StalePageResidence( + expected_page.clone(), + )); + }; + debug_assert_eq!( + owner.browser_context_id(), + expected_page.browser_context_id() + ); + debug_assert_eq!(owner.target_id(), target_id); + self.document_lifecycles + .record(&mut self.target_runtimes, &owner, expected_page, events); + let facts = events + .iter() + .filter_map(|event| { + let document = RendererDocumentLifecycleIdentity { + frame: event.frame, + document: event.document, + epoch: event.epoch, + }; + match event.kind { + RendererDocumentLifecycleEventKind::Started { .. } => None, + RendererDocumentLifecycleEventKind::Milestone(milestone) => { + Some(BrowserFact::DocumentLifecycleReached { + document, + milestone, + stamp: RendererLifecycleEventStamp { + sequence: event.sequence, + timestamp_micros: event.timestamp_micros, + }, + }) + } + RendererDocumentLifecycleEventKind::Terminated { + last_reached, + reason, + } => Some(BrowserFact::DocumentLifecycleTerminated { + document, + last_reached, + termination: RendererLifecycleTerminationStamp { + sequence: event.sequence, + timestamp_micros: event.timestamp_micros, + reason, + }, + }), + } + }) + .collect::>(); + if facts.is_empty() { + return Ok(Vec::new()); + } + self.browser_facts + .publish_batch(expected_page.clone(), facts) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + PageId, + browser_host::BrowserPageOwnerKey, + page::{ + RendererDocumentLifecycleMilestone, RendererDocumentTerminationReason, + RendererDocumentToken, RendererFrameToken, RendererLifecycleEpoch, + }, + runtime::NavigationEngine, + }; + + fn dcl_event() -> RendererDocumentLifecycleEvent { + let page_id = PageId::new_for_testing(11); + RendererDocumentLifecycleEvent { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, 1), + epoch: RendererLifecycleEpoch(1), + sequence: 2, + timestamp_micros: 20, + kind: RendererDocumentLifecycleEventKind::Milestone( + RendererDocumentLifecycleMilestone::DomContentLoaded, + ), + } + } + + fn terminated_event() -> RendererDocumentLifecycleEvent { + RendererDocumentLifecycleEvent { + sequence: 3, + timestamp_micros: 30, + kind: RendererDocumentLifecycleEventKind::Terminated { + last_reached: Some(RendererDocumentLifecycleMilestone::DomContentLoaded), + reason: RendererDocumentTerminationReason::Stopped, + }, + ..dcl_event() + } + } + + #[test] + fn exact_terminated_record_publishes_after_reached_fact() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let page_owner = BrowserPageOwnerKey::new("context-1", "target-1"); + let registration = owner + .page_residences + .begin_target_registration(&mut owner.target_runtimes, page_owner.clone()) + .expect("test Page should stage"); + owner + .page_residences + .commit_target_registration(&mut owner.target_runtimes, registration); + let page = owner + .capture_page_residence(page_owner.browser_context_id(), page_owner.target_id()) + .expect("test Page should be live"); + + let published = owner + .record_document_lifecycle_facts(&page, &[dcl_event(), terminated_event()]) + .expect("exact lifecycle facts should publish"); + + assert_eq!(published.len(), 2); + assert_eq!(published[0].sequence().get(), 1); + assert_eq!(published[1].sequence().get(), 2); + assert!(matches!( + published[0].fact(), + BrowserFact::DocumentLifecycleReached { + milestone: RendererDocumentLifecycleMilestone::DomContentLoaded, + .. + } + )); + assert!(matches!( + published[1].fact(), + BrowserFact::DocumentLifecycleTerminated { + last_reached: Some(RendererDocumentLifecycleMilestone::DomContentLoaded), + termination: RendererLifecycleTerminationStamp { + reason: RendererDocumentTerminationReason::Stopped, + .. + }, + .. + } + )); + } + + #[test] + fn lifecycle_fact_requires_the_exact_current_page_generation() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let page_owner = BrowserPageOwnerKey::new("context-1", "target-1"); + let registration = owner + .page_residences + .begin_target_registration(&mut owner.target_runtimes, page_owner.clone()) + .expect("test Page should stage"); + let handle = owner + .page_residences + .commit_target_registration(&mut owner.target_runtimes, registration); + let original = owner + .capture_page_residence(page_owner.browser_context_id(), page_owner.target_id()) + .expect("test Page should be live"); + handle.advance_generation_for_test_fixture(); + + assert_eq!( + owner.record_document_lifecycle_facts(&original, &[dcl_event()]), + Err(BrowserFactPublishError::StalePageResidence(original)) + ); + assert!(owner.browser_fact_snapshot().is_empty()); + } + + #[test] + fn lifecycle_wait_capture_uses_owner_snapshot_after_journal_eviction() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let page_owner = BrowserPageOwnerKey::new("context-1", "target-1"); + let registration = owner + .page_residences + .begin_target_registration(&mut owner.target_runtimes, page_owner.clone()) + .expect("test Page should stage"); + let handle = owner + .page_residences + .commit_target_registration(&mut owner.target_runtimes, registration); + let page = owner + .capture_page_residence(page_owner.browser_context_id(), page_owner.target_id()) + .expect("test Page should be live"); + let event = dcl_event(); + owner + .record_document_lifecycle_facts(&page, &[event]) + .expect("exact lifecycle fact should publish"); + for sequence in 0..=1_024 { + owner + .browser_facts + .publish_batch( + PageResidenceIdentity::new( + "context-noise".to_owned(), + Some("target-noise".to_owned()), + sequence, + ), + vec![BrowserFact::NavigationAccepted { + navigation: BrowserDocumentNavigation::new( + "target-noise", + format!("loader-noise-{sequence}"), + ), + superseded_navigation: None, + }], + ) + .expect("noise fact should publish"); + } + assert!( + owner + .browser_fact_snapshot() + .iter() + .all(|fact| !matches!(fact.fact(), BrowserFact::DocumentLifecycleReached { .. })), + "the reached occurrence must be outside the bounded journal window" + ); + let document = RendererDocumentLifecycleIdentity { + frame: event.frame, + document: event.document, + epoch: event.epoch, + }; + + let reached = owner.capture_document_lifecycle_wait( + page.clone(), + document, + RendererDocumentLifecycleMilestone::DomContentLoaded, + ); + assert_eq!( + reached.outcome(), + Some(BrowserDocumentLifecycleWaitOutcome::Reached), + "the Browser-owned current snapshot must bootstrap beyond journal retention" + ); + + handle.advance_generation_for_test_fixture(); + let stale = owner.capture_document_lifecycle_wait( + page, + document, + RendererDocumentLifecycleMilestone::Load, + ); + assert_eq!( + stale.outcome(), + Some(BrowserDocumentLifecycleWaitOutcome::Superseded), + "a stale frontend Page binding must not subscribe as the current Document" + ); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/history.rs b/moli-core/src/browser_host/navigation_owner/history.rs new file mode 100644 index 0000000000..a8a49222f0 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/history.rs @@ -0,0 +1,773 @@ +use crate::page::SameDocumentHistoryUpdate; + +/// Current browser Page fields used when creating a joint history entry. +/// +/// This is a Browser Core input rather than a CDP `NavigationEntry`; entry ids, +/// transition state, and document sequence numbers remain core-owned. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserNavigationHistoryPageSnapshot { + url: String, + title: String, +} + +impl BrowserNavigationHistoryPageSnapshot { + pub fn new(url: impl Into, title: impl Into) -> Self { + Self { + url: url.into(), + title: title.into(), + } + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn title(&self) -> &str { + &self.title + } + + pub(super) fn into_typed_entry(self, id: i32) -> BrowserNavigationHistoryEntry { + BrowserNavigationHistoryEntry { + id, + user_typed_url: self.url.clone(), + url: self.url, + title: self.title, + transition_type: "typed".to_owned(), + document_sequence_number: None, + } + } +} + +/// Optional first entry used when a target history is materialized lazily. +/// +/// Browser Core derives initial-empty-Document entries from its Target +/// creation registry. During physical Page migration, a frontend adapter may +/// still provide a loaded-Page snapshot fallback; Core allocates every entry +/// and document-sequence identity in either case. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserNavigationHistorySeed { + url: String, + user_typed_url: String, + title: String, + transition_type: String, +} + +impl BrowserNavigationHistorySeed { + pub(super) fn initial_empty_document(url: impl Into) -> Self { + let url = url.into(); + Self { + user_typed_url: url.clone(), + url, + title: String::new(), + transition_type: "auto_toplevel".to_owned(), + } + } + + pub fn page_snapshot(snapshot: BrowserNavigationHistoryPageSnapshot) -> Self { + Self { + user_typed_url: snapshot.url.clone(), + url: snapshot.url, + title: snapshot.title, + transition_type: "typed".to_owned(), + } + } + + pub(super) fn into_entry(self, id: i32) -> BrowserNavigationHistoryEntry { + BrowserNavigationHistoryEntry { + id, + url: self.url, + user_typed_url: self.user_typed_url, + title: self.title, + transition_type: self.transition_type, + document_sequence_number: None, + } + } +} + +/// Browser-owned representation of one joint session-history entry. +/// +/// Protocol frontends may project these fields into their own wire shape, but +/// they do not own the cursor, entry allocation, or pending traversal state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserNavigationHistoryEntry { + pub id: i32, + pub url: String, + pub user_typed_url: String, + pub title: String, + pub transition_type: String, + pub document_sequence_number: Option, +} + +/// Protocol-neutral destination for one joint session-history traversal. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserHistoryTraversalDestination { + Entry(i32), + Delta(i64), +} + +/// Browser-owned resolution of one history traversal destination. +/// +/// `same_document_delta` is present only when the current and destination +/// entries belong to the same exact Document sequence. Frontends must not +/// infer this from URLs or from a snapshot taken before Browser Owner selects +/// the command. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserHistoryTraversalResolution { + Noop { + entry_id: i32, + url: String, + }, + Entry { + entry_id: i32, + url: String, + same_document_delta: Option, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserHistoryTraversalResolutionError { + NoSuchHistoryEntry, +} + +impl std::fmt::Display for BrowserHistoryTraversalResolutionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoSuchHistoryEntry => formatter.write_str("no such history entry"), + } + } +} + +impl std::error::Error for BrowserHistoryTraversalResolutionError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrowserNavigationHistoryUpdate { + ReplaceCurrent, + ReplaceInitialEmptyDocument, + TraverseToEntry(i32), +} + +/// Why a renderer-completed same-Document history update could not be +/// reconciled with the Browser-owned joint session history. +/// +/// Rejection is atomic: callers may publish neither a target URL projection +/// nor a navigation fact when this result is returned. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserSameDocumentHistoryUpdateError { + CurrentEntryUnavailable, + TraversalIndexOverflow { + current_index: usize, + delta: i64, + }, + TraversalTargetOutsideHistory { + current_index: usize, + delta: i64, + entry_count: usize, + }, + TraversalUrlMismatch { + target_index: usize, + browser_url: String, + renderer_url: String, + }, +} + +impl std::fmt::Display for BrowserSameDocumentHistoryUpdateError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CurrentEntryUnavailable => { + formatter.write_str("same-Document traversal has no current history entry") + } + Self::TraversalIndexOverflow { + current_index, + delta, + } => write!( + formatter, + "same-Document traversal index overflow: current_index={current_index}, delta={delta}" + ), + Self::TraversalTargetOutsideHistory { + current_index, + delta, + entry_count, + } => write!( + formatter, + "same-Document traversal target is outside history: current_index={current_index}, delta={delta}, entry_count={entry_count}" + ), + Self::TraversalUrlMismatch { + target_index, + browser_url, + renderer_url, + } => write!( + formatter, + "same-Document traversal URL diverged at index {target_index}: browser={browser_url:?}, renderer={renderer_url:?}" + ), + } + } +} + +impl std::error::Error for BrowserSameDocumentHistoryUpdateError {} + +/// Authoritative browser state for one target's joint session history. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserNavigationHistory { + entries: Vec, + current_index: Option, + next_entry_id: i32, + next_document_sequence_number: u64, + pending_update: Option, +} + +impl Default for BrowserNavigationHistory { + fn default() -> Self { + Self { + entries: Vec::new(), + current_index: None, + next_entry_id: 1, + next_document_sequence_number: 1, + pending_update: None, + } + } +} + +impl BrowserNavigationHistory { + pub fn clear(&mut self) { + *self = Self::default(); + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn allocate_entry_id(&mut self) -> i32 { + let id = self.next_entry_id; + self.next_entry_id = self.next_entry_id.saturating_add(1); + id + } + + fn push_entry(&mut self, entry: BrowserNavigationHistoryEntry) { + if let Some(current_index) = self.current_index { + self.entries.truncate(current_index + 1); + } + self.entries.push(entry); + self.current_index = self.entries.len().checked_sub(1); + } + + fn allocate_document_sequence_number(&mut self) -> u64 { + let sequence_number = self.next_document_sequence_number; + self.next_document_sequence_number = self.next_document_sequence_number.saturating_add(1); + sequence_number + } + + fn assign_new_document_sequence_number(&mut self, entry: &mut BrowserNavigationHistoryEntry) { + entry.document_sequence_number = Some(self.allocate_document_sequence_number()); + } + + fn assign_current_document_sequence_number( + &mut self, + entry: &mut BrowserNavigationHistoryEntry, + ) { + entry.document_sequence_number = self + .current_index + .and_then(|index| self.entries.get(index)) + .and_then(|entry| entry.document_sequence_number) + .or_else(|| Some(self.allocate_document_sequence_number())); + } + + fn replace_current_entry(&mut self, mut entry: BrowserNavigationHistoryEntry) { + if let Some(current_index) = self.current_index + && let Some(current_entry) = self.entries.get_mut(current_index) + { + entry.id = current_entry.id; + *current_entry = entry; + return; + } + self.push_entry(entry); + } + + fn traverse_to_entry( + &mut self, + entry_id: i32, + mut loaded_entry: BrowserNavigationHistoryEntry, + ) { + if let Some(index) = self.entries.iter().position(|entry| entry.id == entry_id) { + loaded_entry.transition_type = self.entries[index].transition_type.clone(); + loaded_entry.user_typed_url = self.entries[index].user_typed_url.clone(); + loaded_entry.document_sequence_number = self.entries[index].document_sequence_number; + loaded_entry.id = entry_id; + self.entries[index] = loaded_entry; + self.current_index = Some(index); + return; + } + self.push_entry(loaded_entry); + } + + pub fn mark_replace_current(&mut self) { + self.pending_update = Some(BrowserNavigationHistoryUpdate::ReplaceCurrent); + } + + pub fn mark_replace_initial_empty_document(&mut self) { + self.pending_update = Some(BrowserNavigationHistoryUpdate::ReplaceInitialEmptyDocument); + } + + pub fn mark_traverse_to_entry(&mut self, entry_id: i32) { + self.pending_update = Some(BrowserNavigationHistoryUpdate::TraverseToEntry(entry_id)); + } + + pub fn clear_pending_update(&mut self) { + self.pending_update = None; + } + + pub fn snapshot(&self) -> (usize, Vec) { + (self.current_index.unwrap_or(0), self.entries.clone()) + } + + pub(super) fn current_entry(&self) -> Option<&BrowserNavigationHistoryEntry> { + self.current_index + .and_then(|current_index| self.entries.get(current_index)) + } + + /// Resolves and classifies a traversal without mutating the cursor. + /// + /// Browser Core owns both the current cursor and Document sequence + /// identity, so this decision must remain adjacent to that state rather + /// than being reconstructed by a protocol frontend from `snapshot()`. + pub fn resolve_traversal( + &self, + destination: BrowserHistoryTraversalDestination, + ) -> Result { + let current_index = self.current_index.unwrap_or(0); + let target_index = match destination { + BrowserHistoryTraversalDestination::Entry(entry_id) => self + .entries + .iter() + .position(|entry| entry.id == entry_id) + .ok_or(BrowserHistoryTraversalResolutionError::NoSuchHistoryEntry)?, + BrowserHistoryTraversalDestination::Delta(delta) => { + let target_index = current_index as i128 + i128::from(delta); + if target_index < 0 || target_index >= self.entries.len() as i128 { + return Err(BrowserHistoryTraversalResolutionError::NoSuchHistoryEntry); + } + usize::try_from(target_index) + .map_err(|_| BrowserHistoryTraversalResolutionError::NoSuchHistoryEntry)? + } + }; + let target_entry = self + .entries + .get(target_index) + .ok_or(BrowserHistoryTraversalResolutionError::NoSuchHistoryEntry)?; + if target_index == current_index { + return Ok(BrowserHistoryTraversalResolution::Noop { + entry_id: target_entry.id, + url: target_entry.url.clone(), + }); + } + let current_entry = self + .entries + .get(current_index) + .ok_or(BrowserHistoryTraversalResolutionError::NoSuchHistoryEntry)?; + let same_document_delta = (current_entry.document_sequence_number.is_some() + && current_entry.document_sequence_number == target_entry.document_sequence_number) + .then(|| { + let current_index = i64::try_from(current_index) + .map_err(|_| BrowserHistoryTraversalResolutionError::NoSuchHistoryEntry)?; + let target_index = i64::try_from(target_index) + .map_err(|_| BrowserHistoryTraversalResolutionError::NoSuchHistoryEntry)?; + Ok::<_, BrowserHistoryTraversalResolutionError>(target_index - current_index) + }) + .transpose()?; + Ok(BrowserHistoryTraversalResolution::Entry { + entry_id: target_entry.id, + url: target_entry.url.clone(), + same_document_delta, + }) + } + + pub fn can_prune_all_but_current(&self) -> bool { + !matches!( + self.pending_update, + Some(BrowserNavigationHistoryUpdate::TraverseToEntry(_)) + ) && self + .current_index + .is_some_and(|current_index| current_index < self.entries.len()) + } + + pub fn prune_all_but_current(&mut self) -> bool { + if !self.can_prune_all_but_current() { + return false; + } + let Some(current_index) = self.current_index else { + return false; + }; + let Some(current_entry) = self.entries.get(current_index).cloned() else { + return false; + }; + self.entries.clear(); + self.entries.push(current_entry); + self.current_index = Some(0); + true + } + + pub fn seed_entry(&mut self, mut entry: BrowserNavigationHistoryEntry) { + self.assign_new_document_sequence_number(&mut entry); + self.push_entry(entry); + } + + pub fn record_loaded_entry(&mut self, mut entry: BrowserNavigationHistoryEntry) { + match self.pending_update.take() { + Some(BrowserNavigationHistoryUpdate::ReplaceCurrent) => { + entry.transition_type = "reload".to_owned(); + if let Some(current_entry) = + self.current_index.and_then(|index| self.entries.get(index)) + { + entry.user_typed_url = current_entry.user_typed_url.clone(); + } + self.assign_new_document_sequence_number(&mut entry); + self.replace_current_entry(entry); + } + Some(BrowserNavigationHistoryUpdate::ReplaceInitialEmptyDocument) => { + entry.transition_type = "auto_toplevel".to_owned(); + self.assign_new_document_sequence_number(&mut entry); + self.replace_current_entry(entry); + } + Some(BrowserNavigationHistoryUpdate::TraverseToEntry(entry_id)) => { + self.traverse_to_entry(entry_id, entry); + } + None => { + self.assign_new_document_sequence_number(&mut entry); + self.push_entry(entry); + } + } + } + + /// Applies a title observed after the current Document committed. + /// + /// Navigation commit can precede parser completion, so the Browser-owned + /// history entry is initially allowed to carry an empty title. Renderer + /// title output updates only the current entry; callers are responsible + /// for validating the exact Page residence before reaching this method. + pub fn update_current_entry_title(&mut self, title: String) -> Option { + let current_entry = self + .current_index + .and_then(|current_index| self.entries.get_mut(current_index))?; + if current_entry.title == title { + return Some(false); + } + current_entry.title = title; + Some(true) + } + + pub fn record_same_document_update( + &mut self, + url: String, + title: String, + history_update: SameDocumentHistoryUpdate, + ) -> Result<(), BrowserSameDocumentHistoryUpdateError> { + match history_update { + SameDocumentHistoryUpdate::Push | SameDocumentHistoryUpdate::Replace => { + let mut entry = BrowserNavigationHistoryEntry { + id: self.allocate_entry_id(), + url, + user_typed_url: self + .current_index + .and_then(|index| self.entries.get(index)) + .map(|entry| entry.user_typed_url.clone()) + .unwrap_or_default(), + title, + transition_type: "link".to_owned(), + document_sequence_number: None, + }; + self.assign_current_document_sequence_number(&mut entry); + match history_update { + SameDocumentHistoryUpdate::Push => self.push_entry(entry), + SameDocumentHistoryUpdate::Replace => self.replace_current_entry(entry), + SameDocumentHistoryUpdate::Traverse { .. } => unreachable!(), + } + Ok(()) + } + SameDocumentHistoryUpdate::Traverse { delta } => { + let Some(current_index) = self.current_index else { + return Err(BrowserSameDocumentHistoryUpdateError::CurrentEntryUnavailable); + }; + let Ok(signed_current_index) = i64::try_from(current_index) else { + return Err( + BrowserSameDocumentHistoryUpdateError::TraversalIndexOverflow { + current_index, + delta, + }, + ); + }; + let Some(signed_target_index) = signed_current_index.checked_add(delta) else { + return Err( + BrowserSameDocumentHistoryUpdateError::TraversalIndexOverflow { + current_index, + delta, + }, + ); + }; + let Ok(target_index) = usize::try_from(signed_target_index) else { + return Err( + BrowserSameDocumentHistoryUpdateError::TraversalTargetOutsideHistory { + current_index, + delta, + entry_count: self.entries.len(), + }, + ); + }; + let Some(target_entry) = self.entries.get(target_index) else { + return Err( + BrowserSameDocumentHistoryUpdateError::TraversalTargetOutsideHistory { + current_index, + delta, + entry_count: self.entries.len(), + }, + ); + }; + if target_entry.url != url { + return Err( + BrowserSameDocumentHistoryUpdateError::TraversalUrlMismatch { + target_index, + browser_url: target_entry.url.clone(), + renderer_url: url, + }, + ); + } + self.current_index = Some(target_index); + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn late_document_title_updates_the_current_loaded_entry() { + let mut history = BrowserNavigationHistory::default(); + assert_eq!( + history.update_current_entry_title("before commit".to_owned()), + None + ); + + let entry_id = history.allocate_entry_id(); + history.record_loaded_entry(BrowserNavigationHistoryEntry { + id: entry_id, + url: "https://example.test/".to_owned(), + user_typed_url: "https://example.test/".to_owned(), + title: String::new(), + transition_type: "typed".to_owned(), + document_sequence_number: None, + }); + + assert_eq!( + history.update_current_entry_title("Example".to_owned()), + Some(true) + ); + assert_eq!( + history.update_current_entry_title("Example".to_owned()), + Some(false) + ); + let (current_index, entries) = history.snapshot(); + assert_eq!(entries[current_index].title, "Example"); + } + + #[test] + fn traversal_resolution_classifies_document_sequence_without_moving_cursor() { + let mut history = BrowserNavigationHistory::default(); + let first_id = history.allocate_entry_id(); + history.record_loaded_entry(BrowserNavigationHistoryEntry { + id: first_id, + url: "https://example.test/first".to_owned(), + user_typed_url: "https://example.test/first".to_owned(), + title: "First".to_owned(), + transition_type: "typed".to_owned(), + document_sequence_number: None, + }); + history + .record_same_document_update( + "https://example.test/first#state".to_owned(), + "First state".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("same-Document entry should commit"); + let second_document_id = history.allocate_entry_id(); + history.record_loaded_entry(BrowserNavigationHistoryEntry { + id: second_document_id, + url: "https://example.test/second".to_owned(), + user_typed_url: "https://example.test/second".to_owned(), + title: "Second".to_owned(), + transition_type: "typed".to_owned(), + document_sequence_number: None, + }); + history + .record_same_document_update( + "https://example.test/second#state".to_owned(), + "Second state".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("second same-Document entry should commit"); + let before = history.snapshot(); + let current_id = before.1[before.0].id; + + assert_eq!( + history.resolve_traversal(BrowserHistoryTraversalDestination::Entry( + second_document_id, + )), + Ok(BrowserHistoryTraversalResolution::Entry { + entry_id: second_document_id, + url: "https://example.test/second".to_owned(), + same_document_delta: Some(-1), + }) + ); + assert_eq!( + history.resolve_traversal(BrowserHistoryTraversalDestination::Entry(first_id)), + Ok(BrowserHistoryTraversalResolution::Entry { + entry_id: first_id, + url: "https://example.test/first".to_owned(), + same_document_delta: None, + }) + ); + assert_eq!( + history.resolve_traversal(BrowserHistoryTraversalDestination::Entry(current_id)), + Ok(BrowserHistoryTraversalResolution::Noop { + entry_id: current_id, + url: "https://example.test/second#state".to_owned(), + }) + ); + assert_eq!( + history.resolve_traversal(BrowserHistoryTraversalDestination::Delta(1)), + Err(BrowserHistoryTraversalResolutionError::NoSuchHistoryEntry) + ); + assert_eq!( + history.snapshot(), + before, + "resolution must not move the cursor" + ); + } + + #[test] + fn same_document_traversal_moves_cursor_without_allocating_or_appending() { + let mut history = BrowserNavigationHistory::default(); + history + .record_same_document_update( + "https://example.test/a".to_owned(), + "A".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("first push should commit"); + history + .record_same_document_update( + "https://example.test/b".to_owned(), + "B".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("second push should commit"); + + history + .record_same_document_update( + "https://example.test/a".to_owned(), + "ignored during traversal".to_owned(), + SameDocumentHistoryUpdate::Traverse { delta: -1 }, + ) + .expect("in-range traversal should commit"); + let (current_index, entries) = history.snapshot(); + assert_eq!(current_index, 0); + assert_eq!(entries.len(), 2); + assert_eq!( + entries.iter().map(|entry| entry.id).collect::>(), + vec![1, 2] + ); + + history + .record_same_document_update( + "https://example.test/c".to_owned(), + "C".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("push after traversal should commit"); + let (current_index, entries) = history.snapshot(); + assert_eq!(current_index, 1); + assert_eq!( + entries + .iter() + .map(|entry| (entry.id, entry.url.as_str())) + .collect::>(), + vec![(1, "https://example.test/a"), (3, "https://example.test/c")], + "a traversal must neither allocate an id nor survive as a forward entry after push" + ); + } + + #[test] + fn same_document_replace_preserves_entry_id_and_out_of_range_traverse_is_atomic() { + let mut history = BrowserNavigationHistory::default(); + history + .record_same_document_update( + "https://example.test/a".to_owned(), + "A".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("initial push should commit"); + history + .record_same_document_update( + "https://example.test/replaced".to_owned(), + "Replaced".to_owned(), + SameDocumentHistoryUpdate::Replace, + ) + .expect("replace should commit"); + let before = history.snapshot(); + assert_eq!(before.0, 0); + assert_eq!(before.1[0].id, 1); + assert_eq!(before.1[0].url, "https://example.test/replaced"); + + assert!(matches!( + history.record_same_document_update( + "https://example.test/missing".to_owned(), + String::new(), + SameDocumentHistoryUpdate::Traverse { delta: -1 }, + ), + Err( + BrowserSameDocumentHistoryUpdateError::TraversalTargetOutsideHistory { + current_index: 0, + delta: -1, + entry_count: 1, + } + ) + )); + assert_eq!(history.snapshot(), before); + } + + #[test] + fn same_document_traversal_url_divergence_is_typed_and_atomic() { + let mut history = BrowserNavigationHistory::default(); + history + .record_same_document_update( + "https://example.test/a".to_owned(), + "A".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("first push should commit"); + history + .record_same_document_update( + "https://example.test/b".to_owned(), + "B".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("second push should commit"); + let before = history.snapshot(); + + assert_eq!( + history.record_same_document_update( + "https://renderer.example/wrong".to_owned(), + String::new(), + SameDocumentHistoryUpdate::Traverse { delta: -1 }, + ), + Err( + BrowserSameDocumentHistoryUpdateError::TraversalUrlMismatch { + target_index: 0, + browser_url: "https://example.test/a".to_owned(), + renderer_url: "https://renderer.example/wrong".to_owned(), + } + ) + ); + assert_eq!(history.snapshot(), before); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/history_registry.rs b/moli-core/src/browser_host/navigation_owner/history_registry.rs new file mode 100644 index 0000000000..66318bf681 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/history_registry.rs @@ -0,0 +1,785 @@ +use crate::browser_host::{BrowserFactPublishError, PageResidenceIdentity}; +use crate::page::SameDocumentHistoryUpdate; + +use super::{ + BrowserHistoryTraversalDestination, BrowserHistoryTraversalResolution, + BrowserHistoryTraversalResolutionError, BrowserNavigationHistory, + BrowserNavigationHistoryEntry, BrowserNavigationHistoryPageSnapshot, + BrowserNavigationHistorySeed, BrowserNavigationOwner, BrowserPageOwnerKey, + BrowserSameDocumentHistoryUpdateError, BrowserTargetMetadataTransition, + target_runtime_registry::BrowserTargetRuntimeRegistry, +}; + +/// Exact reason an actor-selected Page could not resolve a history traversal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserExactHistoryTraversalResolutionError { + PageResidenceNoLongerCurrent { expected: PageResidenceIdentity }, + History(BrowserHistoryTraversalResolutionError), +} + +impl std::fmt::Display for BrowserExactHistoryTraversalResolutionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PageResidenceNoLongerCurrent { expected } => write!( + formatter, + "history traversal Page is no longer current: browser_context={:?}, target={:?}, generation={}", + expected.browser_context_id(), + expected.target_id(), + expected.loaded_page_generation() + ), + Self::History(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for BrowserExactHistoryTraversalResolutionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PageResidenceNoLongerCurrent { .. } => None, + Self::History(error) => Some(error), + } + } +} + +impl From for BrowserExactHistoryTraversalResolutionError { + fn from(error: BrowserHistoryTraversalResolutionError) -> Self { + Self::History(error) + } +} + +/// Exact reason a same-Document history fact could not commit in Browser Core. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserSameDocumentNavigationCommitError { + PageResidenceNoLongerCurrent { expected: PageResidenceIdentity }, + History(BrowserSameDocumentHistoryUpdateError), +} + +impl std::fmt::Display for BrowserSameDocumentNavigationCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PageResidenceNoLongerCurrent { expected } => write!( + formatter, + "same-Document navigation Page is no longer current: browser_context={:?}, target={:?}, generation={}", + expected.browser_context_id(), + expected.target_id(), + expected.loaded_page_generation() + ), + Self::History(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for BrowserSameDocumentNavigationCommitError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PageResidenceNoLongerCurrent { .. } => None, + Self::History(error) => Some(error), + } + } +} + +impl From for BrowserSameDocumentNavigationCommitError { + fn from(error: BrowserSameDocumentHistoryUpdateError) -> Self { + Self::History(error) + } +} + +/// Authoritative joint session-history registry, keyed by browser Target. +/// +/// `history.rs` owns the history algorithm and value types. This module owns +/// target lookup and lifecycle only, so neither concern grows into the main +/// navigation-owner registry. +#[derive(Default)] +pub(super) struct BrowserNavigationHistoryRegistry; + +#[derive(Clone)] +pub(super) struct BrowserTargetNavigationHistory { + history: BrowserNavigationHistory, + accepts_initial_seed: bool, +} + +impl Default for BrowserTargetNavigationHistory { + fn default() -> Self { + Self { + history: BrowserNavigationHistory::default(), + accepts_initial_seed: true, + } + } +} + +impl BrowserNavigationHistoryRegistry { + fn target_history_mut<'a>( + &mut self, + runtimes: &'a mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> &'a mut BrowserTargetNavigationHistory { + runtimes + .entries + .entry(key.clone()) + .or_default() + .navigation_history + .get_or_insert_default() + } + + fn history_mut<'a>( + &mut self, + runtimes: &'a mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> &'a mut BrowserNavigationHistory { + &mut self.target_history_mut(runtimes, key).history + } + + fn ensure_seeded( + target_history: &mut BrowserTargetNavigationHistory, + seed: Option, + ) { + if !target_history.accepts_initial_seed || !target_history.history.is_empty() { + return; + } + let Some(seed) = seed else { + return; + }; + let entry_id = target_history.history.allocate_entry_id(); + target_history.history.seed_entry(seed.into_entry(entry_id)); + target_history.accepts_initial_seed = false; + } + + fn snapshot( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + seed: Option, + ) -> (usize, Vec) { + let target_history = self.target_history_mut(runtimes, key); + Self::ensure_seeded(target_history, seed); + target_history.history.snapshot() + } + + fn resolve_traversal( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + seed: Option, + destination: BrowserHistoryTraversalDestination, + ) -> Result { + let target_history = self.target_history_mut(runtimes, key); + Self::ensure_seeded(target_history, seed); + target_history.history.resolve_traversal(destination) + } + + fn reset( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + seed: Option, + ) -> bool { + let target_history = self.target_history_mut(runtimes, key); + Self::ensure_seeded(target_history, seed); + target_history.history.prune_all_but_current() + } + + fn can_reset( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + seed: Option, + ) -> bool { + let target_history = self.target_history_mut(runtimes, key); + Self::ensure_seeded(target_history, seed); + target_history.history.can_prune_all_but_current() + } + + fn mark_replace_current( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) { + self.history_mut(runtimes, key).mark_replace_current(); + } + + fn mark_replace_initial_empty_document( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) { + self.history_mut(runtimes, key) + .mark_replace_initial_empty_document(); + } + + fn mark_traverse_to_entry( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + entry_id: i32, + ) { + self.history_mut(runtimes, key) + .mark_traverse_to_entry(entry_id); + } + + fn clear_pending_update( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) { + self.history_mut(runtimes, key).clear_pending_update(); + } + + pub(super) fn record_loaded_page( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + seed: Option, + page: BrowserNavigationHistoryPageSnapshot, + ) { + let target_history = self.target_history_mut(runtimes, key); + Self::ensure_seeded(target_history, seed); + let entry_id = target_history.history.allocate_entry_id(); + target_history + .history + .record_loaded_entry(page.into_typed_entry(entry_id)); + target_history.accepts_initial_seed = false; + } + + fn update_current_title( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + title: String, + ) -> Option { + runtimes + .entries + .get_mut(key)? + .navigation_history + .as_mut()? + .history + .update_current_entry_title(title) + } + + fn record_same_document( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + seed: Option, + url: String, + title: String, + update: SameDocumentHistoryUpdate, + ) -> Result<(), BrowserSameDocumentHistoryUpdateError> { + let entry_already_existed = runtimes + .entries + .get(key) + .is_some_and(|runtime| runtime.navigation_history.is_some()); + let recorded = { + let target_history = self.target_history_mut(runtimes, key); + let seeded_for_this_update = target_history.accepts_initial_seed + && target_history.history.is_empty() + && seed.is_some(); + let seed_rollback = seeded_for_this_update.then(|| target_history.clone()); + Self::ensure_seeded(target_history, seed); + let recorded = target_history + .history + .record_same_document_update(url, title, update); + if recorded.is_ok() { + target_history.accepts_initial_seed = false; + } else if let Some(seed_rollback) = seed_rollback { + *target_history = seed_rollback; + } + recorded + }; + if recorded.is_err() && !entry_already_existed { + if let Some(runtime) = runtimes.entries.get_mut(key) { + runtime.navigation_history = None; + } + runtimes.prune_empty(); + } + recorded + } + + pub(super) fn clear( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) { + let target_history = self.target_history_mut(runtimes, key); + target_history.history.clear(); + target_history.accepts_initial_seed = false; + } +} + +impl BrowserNavigationOwner { + pub fn navigation_history_snapshot( + &mut self, + key: &BrowserPageOwnerKey, + fallback_page_seed: Option, + ) -> (usize, Vec) { + let seed = self + .initial_empty_documents + .history_seed(&self.target_runtimes, key) + .or(fallback_page_seed); + self.navigation_histories + .snapshot(&mut self.target_runtimes, key, seed) + } + + pub fn resolve_navigation_history_traversal( + &mut self, + key: &BrowserPageOwnerKey, + fallback_page_seed: Option, + destination: BrowserHistoryTraversalDestination, + ) -> Result { + let seed = self + .initial_empty_documents + .history_seed(&self.target_runtimes, key) + .or(fallback_page_seed); + self.navigation_histories.resolve_traversal( + &mut self.target_runtimes, + key, + seed, + destination, + ) + } + + /// Resolves one actor-selected history command against its exact Page. + /// + /// Exact residence is validated before lazy history seeding or destination + /// classification, so a queued command cannot observe or mutate its + /// replacement Page's history. + pub fn resolve_exact_navigation_history_traversal( + &mut self, + expected_page: &PageResidenceIdentity, + fallback_page_seed: Option, + destination: BrowserHistoryTraversalDestination, + ) -> Result + { + let Some(key) = self.page_owner_key_if_current(expected_page) else { + return Err( + BrowserExactHistoryTraversalResolutionError::PageResidenceNoLongerCurrent { + expected: expected_page.clone(), + }, + ); + }; + self.resolve_navigation_history_traversal(&key, fallback_page_seed, destination) + .map_err(Into::into) + } + + pub fn reset_navigation_history( + &mut self, + key: &BrowserPageOwnerKey, + fallback_page_seed: Option, + ) -> bool { + let seed = self + .initial_empty_documents + .history_seed(&self.target_runtimes, key) + .or(fallback_page_seed); + self.navigation_histories + .reset(&mut self.target_runtimes, key, seed) + } + + pub fn can_reset_navigation_history( + &mut self, + key: &BrowserPageOwnerKey, + fallback_page_seed: Option, + ) -> bool { + let seed = self + .initial_empty_documents + .history_seed(&self.target_runtimes, key) + .or(fallback_page_seed); + self.navigation_histories + .can_reset(&mut self.target_runtimes, key, seed) + } + + pub fn mark_next_navigation_history_replace_current(&mut self, key: &BrowserPageOwnerKey) { + self.navigation_histories + .mark_replace_current(&mut self.target_runtimes, key); + } + + pub fn mark_next_navigation_history_replace_initial_empty_document( + &mut self, + key: &BrowserPageOwnerKey, + ) { + self.navigation_histories + .mark_replace_initial_empty_document(&mut self.target_runtimes, key); + } + + pub fn mark_next_navigation_history_traverse_to_entry( + &mut self, + key: &BrowserPageOwnerKey, + entry_id: i32, + ) { + self.navigation_histories + .mark_traverse_to_entry(&mut self.target_runtimes, key, entry_id); + } + + pub fn clear_pending_navigation_history_update(&mut self, key: &BrowserPageOwnerKey) { + self.navigation_histories + .clear_pending_update(&mut self.target_runtimes, key); + } + + pub fn record_loaded_page_navigation_history( + &mut self, + key: &BrowserPageOwnerKey, + page: BrowserNavigationHistoryPageSnapshot, + ) { + let seed = self + .initial_empty_documents + .history_seed(&self.target_runtimes, key); + self.navigation_histories + .record_loaded_page(&mut self.target_runtimes, key, seed, page); + } + + /// Updates the current history entry from renderer title output for one + /// exact, still-current Page residence. + /// + /// Returning `None` means either the Page was replaced or no committed + /// history entry exists yet. In both cases no Browser-owned state changes. + pub fn update_current_document_title( + &mut self, + expected_page: &PageResidenceIdentity, + title: String, + ) -> Result, BrowserFactPublishError> { + let Some(key) = self.page_owner_key_if_current(expected_page) else { + return Ok(None); + }; + let changed = + self.navigation_histories + .update_current_title(&mut self.target_runtimes, &key, title); + if changed != Some(true) { + return Ok(changed); + } + let Some(entry) = self + .target_runtimes + .entries + .get(&key) + .and_then(|runtime| runtime.navigation_history.as_ref()) + .and_then(|history| history.history.current_entry()) + else { + return Ok(None); + }; + let transition = BrowserTargetMetadataTransition::document_title_changed( + entry.url.clone(), + entry.title.clone(), + ); + self.record_document_title_changed_fact(expected_page, transition)?; + Ok(Some(true)) + } + + /// Atomically commits a renderer same-Document history fact for one exact + /// Page residence. + /// + /// A rejected traversal leaves both the history cursor and any lazy seed + /// unchanged. The caller may therefore gate physical target projection and + /// frontend facts directly on this typed result. + pub fn commit_same_document_navigation_history( + &mut self, + expected_page: &PageResidenceIdentity, + fallback_page_seed: Option, + url: String, + title: String, + update: SameDocumentHistoryUpdate, + ) -> Result<(), BrowserSameDocumentNavigationCommitError> { + let Some(key) = self.page_owner_key_if_current(expected_page) else { + return Err( + BrowserSameDocumentNavigationCommitError::PageResidenceNoLongerCurrent { + expected: expected_page.clone(), + }, + ); + }; + let seed = self + .initial_empty_documents + .history_seed(&self.target_runtimes, &key) + .or(fallback_page_seed); + self.navigation_histories + .record_same_document(&mut self.target_runtimes, &key, seed, url, title, update) + .map_err(Into::into) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + browser_host::{ + BrowserContextSelectionProjection, BrowserInitialEmptyDocumentSeed, + BrowserPageResidenceHandle, BrowserSelectedTargetEngineDisposition, + BrowserTargetHandle, BrowserTargetSlotProjection, BrowserTargetTerminationKind, + BrowserTargetTopologyProjection, + }, + runtime::NavigationEngine, + }; + + use super::*; + + fn page(url: &str) -> BrowserNavigationHistoryPageSnapshot { + BrowserNavigationHistoryPageSnapshot::new(url, String::new()) + } + + fn register_target( + owner: &mut BrowserNavigationOwner, + key: &BrowserPageOwnerKey, + ) -> BrowserPageResidenceHandle { + let page_residence = BrowserPageResidenceHandle::default(); + owner + .register_browser_context( + key.browser_context_id(), + BrowserTargetTopologyProjection::new( + key.browser_context_id(), + Some(BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged(key.target_id()), + page_residence.clone(), + )), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("test Target topology should register"); + page_residence + } + + #[test] + fn target_histories_are_isolated_and_allocate_inside_core() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let first = BrowserPageOwnerKey::new("context-1", "target-1"); + let second = BrowserPageOwnerKey::new("context-1", "target-2"); + + owner.record_loaded_page_navigation_history(&first, page("https://one.test/")); + owner.record_loaded_page_navigation_history(&second, page("https://two.test/")); + owner.mark_next_navigation_history_replace_current(&first); + owner.record_loaded_page_navigation_history(&first, page("https://one.test/reload")); + + let (_, first_entries) = owner.navigation_history_snapshot(&first, None); + let (_, second_entries) = owner.navigation_history_snapshot(&second, None); + assert_eq!(first_entries.len(), 1); + assert_eq!(first_entries[0].id, 1); + assert_eq!(first_entries[0].transition_type, "reload"); + assert_eq!(second_entries.len(), 1); + assert_eq!(second_entries[0].id, 1); + assert_eq!(second_entries[0].url, "https://two.test/"); + } + + #[test] + fn initial_document_seed_precedes_pending_loaded_page_update() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let _page_residence = register_target(&mut owner, &key); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("live Target should accept initial metadata"); + owner.mark_next_navigation_history_replace_current(&key); + owner.record_loaded_page_navigation_history(&key, page("https://example.test/")); + + let (_, entries) = owner.navigation_history_snapshot(&key, None); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, 1); + assert_eq!(entries[0].url, "https://example.test/"); + assert_eq!(entries[0].user_typed_url, "about:blank"); + assert_eq!(entries[0].transition_type, "reload"); + } + + #[test] + fn direct_target_initial_url_replaces_initial_empty_document_entry() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let _page_residence = register_target(&mut owner, &key); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("live Target should accept initial metadata"); + owner.mark_next_navigation_history_replace_initial_empty_document(&key); + owner.record_loaded_page_navigation_history( + &key, + BrowserNavigationHistoryPageSnapshot::new("https://example.test/direct", "direct"), + ); + + let (current_index, entries) = owner.navigation_history_snapshot(&key, None); + assert_eq!(current_index, 0); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].url, "https://example.test/direct"); + assert_eq!(entries[0].user_typed_url, "https://example.test/direct"); + assert_eq!(entries[0].title, "direct"); + assert_eq!(entries[0].transition_type, "auto_toplevel"); + } + + #[test] + fn page_runtime_discard_preserves_history_but_target_termination_forgets_it() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let _page_residence = register_target(&mut owner, &key); + owner.record_loaded_page_navigation_history(&key, page("https://example.test/")); + + owner.discard_target_page_runtime("target-1"); + assert!( + owner + .target_runtimes + .entries + .get(&key) + .is_some_and(|runtime| runtime.navigation_history.is_some()) + ); + + owner.forget_target("target-1"); + assert!( + owner + .target_runtimes + .entries + .get(&key) + .is_none_or(|runtime| runtime.navigation_history.is_none()) + ); + } + + #[test] + fn crashed_target_history_stays_empty_instead_of_reseeding_initial_document() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let _page_residence = register_target(&mut owner, &key); + owner.record_loaded_page_navigation_history(&key, page("https://example.test/")); + + let request = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .expect("live target should capture crash"); + let permit = owner + .prepare_target_termination(request) + .expect("exact crash should prepare"); + owner + .commit_target_termination(permit) + .expect("exact crash should commit"); + let (_, entries) = owner.navigation_history_snapshot( + &key, + Some(BrowserNavigationHistorySeed::initial_empty_document( + "about:blank", + )), + ); + + assert!(entries.is_empty()); + } + + #[test] + fn rejected_same_document_traversal_leaves_core_history_unchanged() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let _page_residence = register_target(&mut owner, &key); + owner.record_loaded_page_navigation_history(&key, page("https://example.test/current")); + let expected_page = owner + .capture_page_residence("context-1", "target-1") + .expect("registered Target should expose its exact Page"); + let before = owner.navigation_history_snapshot(&key, None); + + let error = owner + .commit_same_document_navigation_history( + &expected_page, + None, + "https://example.test/missing".to_owned(), + String::new(), + SameDocumentHistoryUpdate::Traverse { delta: -1 }, + ) + .expect_err("out-of-range traversal must be rejected"); + + assert!(matches!( + error, + BrowserSameDocumentNavigationCommitError::History( + BrowserSameDocumentHistoryUpdateError::TraversalTargetOutsideHistory { + current_index: 0, + delta: -1, + entry_count: 1, + } + ) + )); + assert_eq!(owner.navigation_history_snapshot(&key, None), before); + } + + #[test] + fn stale_page_cannot_commit_same_document_history() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_target(&mut owner, &key); + owner.record_loaded_page_navigation_history(&key, page("https://example.test/current")); + let stale_page = owner + .capture_page_residence("context-1", "target-1") + .expect("registered Target should expose its exact Page"); + page_residence.advance_generation_for_test_fixture(); + let before = owner.navigation_history_snapshot(&key, None); + + let error = owner + .commit_same_document_navigation_history( + &stale_page, + None, + "https://example.test/current#stale".to_owned(), + String::new(), + SameDocumentHistoryUpdate::Push, + ) + .expect_err("retired Page must not mutate Target history"); + + assert_eq!( + error, + BrowserSameDocumentNavigationCommitError::PageResidenceNoLongerCurrent { + expected: stale_page, + } + ); + assert_eq!(owner.navigation_history_snapshot(&key, None), before); + } + + #[test] + fn stale_page_cannot_resolve_history_destination() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_target(&mut owner, &key); + owner.record_loaded_page_navigation_history(&key, page("https://example.test/first")); + owner.record_loaded_page_navigation_history(&key, page("https://example.test/second")); + let stale_page = owner + .capture_page_residence("context-1", "target-1") + .expect("registered Target should expose its exact Page"); + let before = owner.navigation_history_snapshot(&key, None); + let first_entry_id = before.1[0].id; + page_residence.advance_generation_for_test_fixture(); + + let error = owner + .resolve_exact_navigation_history_traversal( + &stale_page, + None, + BrowserHistoryTraversalDestination::Entry(first_entry_id), + ) + .expect_err("retired Page must not resolve successor history"); + + assert_eq!( + error, + BrowserExactHistoryTraversalResolutionError::PageResidenceNoLongerCurrent { + expected: stale_page, + } + ); + assert_eq!(owner.navigation_history_snapshot(&key, None), before); + } + + #[test] + fn rejected_lazy_seed_restores_pending_history_update() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let _page_residence = register_target(&mut owner, &key); + let expected_page = owner + .capture_page_residence("context-1", "target-1") + .expect("registered Target should expose its exact Page"); + owner.mark_next_navigation_history_traverse_to_entry(&key, 77); + let fallback_seed = + BrowserNavigationHistorySeed::page_snapshot(page("https://example.test/current")); + + assert!(matches!( + owner.commit_same_document_navigation_history( + &expected_page, + Some(fallback_seed.clone()), + "https://example.test/missing".to_owned(), + String::new(), + SameDocumentHistoryUpdate::Traverse { delta: -1 }, + ), + Err(BrowserSameDocumentNavigationCommitError::History( + BrowserSameDocumentHistoryUpdateError::TraversalTargetOutsideHistory { .. } + )) + )); + assert!( + !owner.can_reset_navigation_history(&key, Some(fallback_seed)), + "rejected lazy seeding must restore the pending traversal exactly" + ); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/initial_document.rs b/moli-core/src/browser_host/navigation_owner/initial_document.rs new file mode 100644 index 0000000000..1275308faa --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/initial_document.rs @@ -0,0 +1,235 @@ +use moli_storage_key::MoliStorageKey; + +use crate::browser_host::BrowserTargetId; + +use super::BrowserNavigationHistorySeed; + +/// Immutable creator security context captured when a top-level auxiliary +/// browsing context is created. +/// +/// This is browser metadata. Renderer/Page projections may clone it while +/// constructing the initial Document, but they do not own its lifetime. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserInitialEmptyDocumentCreator { + target_id: BrowserTargetId, + security_origin: String, + secure_context_type: String, +} + +impl BrowserInitialEmptyDocumentCreator { + pub fn new( + target_id: impl Into, + security_origin: impl Into, + secure_context_type: impl Into, + ) -> Self { + Self { + target_id: BrowserTargetId::new(target_id), + security_origin: security_origin.into(), + secure_context_type: secure_context_type.into(), + } + } + + pub fn target_id(&self) -> &str { + self.target_id.as_str() + } + + pub fn security_origin(&self) -> &str { + &self.security_origin + } + + pub fn secure_context_type(&self) -> &str { + &self.secure_context_type + } +} + +/// Immutable metadata used to create one Target's initial empty Document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserInitialEmptyDocumentSeed { + initial_url: String, + creator: Option, + storage_key: Option, +} + +impl BrowserInitialEmptyDocumentSeed { + pub fn new(initial_url: impl Into) -> Self { + Self { + initial_url: initial_url.into(), + creator: None, + storage_key: None, + } + } + + pub fn with_creator(mut self, creator: BrowserInitialEmptyDocumentCreator) -> Self { + self.creator = Some(creator); + self + } + + pub fn with_storage_key(mut self, storage_key: MoliStorageKey) -> Self { + self.storage_key = Some(storage_key); + self + } + + pub fn initial_url(&self) -> &str { + &self.initial_url + } + + pub fn creator(&self) -> Option<&BrowserInitialEmptyDocumentCreator> { + self.creator.as_ref() + } + + pub fn storage_key(&self) -> Option<&MoliStorageKey> { + self.storage_key.as_ref() + } + + pub(super) fn is_initial_empty_document(&self) -> bool { + url::Url::parse(&self.initial_url) + .ok() + .is_some_and(|url| url.scheme() == "about" && url.path() == "blank") + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BrowserInitialEmptyDocumentLifecycle { + Unmaterialized, + Materialized, + ExitedUnmaterialized, + ExitedMaterialized, +} + +impl BrowserInitialEmptyDocumentLifecycle { + fn materialized(self) -> bool { + matches!(self, Self::Materialized | Self::ExitedMaterialized) + } + + fn exited(self) -> bool { + matches!(self, Self::ExitedUnmaterialized | Self::ExitedMaterialized) + } +} + +/// Minimal state retained for one Target's initial empty Document. +/// +/// Target identity remains in the registry key, loader identity is +/// deterministic, and navigation pending state lives in the authoritative +/// document-navigation registry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct BrowserInitialEmptyDocumentRecord { + seed: BrowserInitialEmptyDocumentSeed, + lifecycle: BrowserInitialEmptyDocumentLifecycle, +} + +impl BrowserInitialEmptyDocumentRecord { + pub(super) fn new(seed: BrowserInitialEmptyDocumentSeed) -> Self { + Self { + seed, + lifecycle: BrowserInitialEmptyDocumentLifecycle::Unmaterialized, + } + } + + pub(super) fn snapshot( + &self, + target_id: impl Into, + has_pending_navigation: bool, + ) -> BrowserInitialEmptyDocumentSnapshot { + BrowserInitialEmptyDocumentSnapshot { + target_id: BrowserTargetId::new(target_id), + seed: self.seed.clone(), + lifecycle: self.lifecycle, + pending_cross_document_navigation: self.is_on_initial_empty_document() + && has_pending_navigation, + } + } + + pub(super) fn initial_url(&self) -> &str { + self.seed.initial_url() + } + + pub(super) fn is_on_initial_empty_document(&self) -> bool { + !self.lifecycle.exited() + } + + pub(super) fn materialized(&self) -> bool { + self.lifecycle.materialized() + } + + pub(super) fn history_seed(&self) -> BrowserNavigationHistorySeed { + BrowserNavigationHistorySeed::initial_empty_document(self.initial_url()) + } + + pub(super) fn mark_materialized(&mut self) { + if self.lifecycle == BrowserInitialEmptyDocumentLifecycle::Unmaterialized { + self.lifecycle = BrowserInitialEmptyDocumentLifecycle::Materialized; + } + } + + pub(super) fn rollback_materialized(&mut self) -> bool { + if self.lifecycle != BrowserInitialEmptyDocumentLifecycle::Materialized { + return false; + } + self.lifecycle = BrowserInitialEmptyDocumentLifecycle::Unmaterialized; + true + } + + pub(super) fn mark_exited(&mut self) { + self.lifecycle = match self.lifecycle { + BrowserInitialEmptyDocumentLifecycle::Unmaterialized + | BrowserInitialEmptyDocumentLifecycle::ExitedUnmaterialized => { + BrowserInitialEmptyDocumentLifecycle::ExitedUnmaterialized + } + BrowserInitialEmptyDocumentLifecycle::Materialized + | BrowserInitialEmptyDocumentLifecycle::ExitedMaterialized => { + BrowserInitialEmptyDocumentLifecycle::ExitedMaterialized + } + }; + } +} + +/// Read-only projection of one Target's initial empty Document. +/// +/// This value is assembled on demand from the registry key, the minimal +/// lifecycle record, and authoritative navigation state. It is never retained +/// in the per-Target registry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserInitialEmptyDocumentSnapshot { + target_id: BrowserTargetId, + seed: BrowserInitialEmptyDocumentSeed, + lifecycle: BrowserInitialEmptyDocumentLifecycle, + pending_cross_document_navigation: bool, +} + +impl BrowserInitialEmptyDocumentSnapshot { + pub fn target_id(&self) -> &str { + self.target_id.as_str() + } + + pub fn initial_url(&self) -> &str { + self.seed.initial_url() + } + + pub fn loader_id(&self) -> String { + format!("LID-INITIAL-{}", self.target_id.as_str()) + } + + pub fn creator(&self) -> Option<&BrowserInitialEmptyDocumentCreator> { + self.seed.creator() + } + + pub fn storage_key(&self) -> Option<&MoliStorageKey> { + self.seed.storage_key() + } + + pub fn materialized(&self) -> bool { + self.lifecycle.materialized() + } + + pub fn exited(&self) -> bool { + self.lifecycle.exited() + } + + pub fn pending_cross_document_navigation(&self) -> bool { + self.pending_cross_document_navigation + } + + pub fn is_on_initial_empty_document(&self) -> bool { + !self.exited() + } +} diff --git a/moli-core/src/browser_host/navigation_owner/initial_document_registry.rs b/moli-core/src/browser_host/navigation_owner/initial_document_registry.rs new file mode 100644 index 0000000000..2053da732b --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/initial_document_registry.rs @@ -0,0 +1,440 @@ +use crate::browser_host::PageResidenceIdentity; + +use super::{ + BrowserInitialEmptyDocumentSeed, BrowserInitialEmptyDocumentSnapshot, + BrowserNavigationHistorySeed, BrowserNavigationOwner, BrowserPageOwnerKey, + BrowserTargetRegistryError, initial_document::BrowserInitialEmptyDocumentRecord, + target_runtime_registry::BrowserTargetRuntimeRegistry, +}; + +/// Authoritative initial-empty-Document lifecycle, keyed by browser Target. +/// +/// Page construction remains a renderer/projection participant during the +/// migration, but creation metadata and lifecycle acceptance live here so a +/// frontend disconnect cannot erase or advance browser state. +#[derive(Default)] +pub(super) struct BrowserInitialEmptyDocumentRegistry; + +impl BrowserInitialEmptyDocumentRegistry { + pub(super) fn begin( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + seed: BrowserInitialEmptyDocumentSeed, + ) { + if !seed.is_initial_empty_document() { + if let Some(record) = runtimes.entries.get_mut(owner) { + record.initial_empty_document = None; + } + runtimes.prune_empty(); + return; + } + runtimes + .entries + .entry(owner.clone()) + .or_default() + .initial_empty_document = Some(BrowserInitialEmptyDocumentRecord::new(seed)); + } + + pub(super) fn snapshot( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + has_pending_navigation: bool, + ) -> Option { + runtimes + .entries + .get(owner) + .and_then(|record| record.initial_empty_document.as_ref()) + .map(|record| record.snapshot(owner.target_id(), has_pending_navigation)) + } + + pub(super) fn snapshots<'a>( + &'a self, + runtimes: &'a BrowserTargetRuntimeRegistry, + has_pending_navigation: impl Fn(&BrowserPageOwnerKey) -> bool + 'a, + ) -> impl Iterator + 'a { + runtimes.entries.iter().filter_map(move |(owner, runtime)| { + runtime + .initial_empty_document + .as_ref() + .map(|record| record.snapshot(owner.target_id(), has_pending_navigation(owner))) + }) + } + + pub(super) fn history_seed( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> Option { + runtimes + .entries + .get(owner) + .and_then(|record| record.initial_empty_document.as_ref()) + .map(BrowserInitialEmptyDocumentRecord::history_seed) + } + + pub(super) fn accepts_materialization( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> bool { + runtimes + .entries + .get(owner) + .and_then(|record| record.initial_empty_document.as_ref()) + .is_some_and(|state| state.is_on_initial_empty_document() && !state.materialized()) + } + + pub(super) fn mark_materialized( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> bool { + let Some(state) = runtimes + .entries + .get_mut(owner) + .and_then(|record| record.initial_empty_document.as_mut()) + else { + return false; + }; + if !state.is_on_initial_empty_document() { + return false; + } + state.mark_materialized(); + true + } + + pub(super) fn rollback_materialized( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> bool { + runtimes + .entries + .get_mut(owner) + .and_then(|record| record.initial_empty_document.as_mut()) + .is_some_and(BrowserInitialEmptyDocumentRecord::rollback_materialized) + } + + pub(super) fn mark_exited( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) { + if let Some(state) = runtimes + .entries + .get_mut(owner) + .and_then(|record| record.initial_empty_document.as_mut()) + { + state.mark_exited(); + } + } + + #[cfg(test)] + pub(super) fn mark_exited_for_target( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + target_id: &str, + ) { + for (owner, runtime) in &mut runtimes.entries { + if owner.target_id() == target_id + && let Some(state) = runtime.initial_empty_document.as_mut() + { + state.mark_exited(); + break; + } + } + } + + pub(super) fn can_install_current_page( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> bool { + self.accepts_materialization(runtimes, owner) + } + + pub(super) fn accepts_initial_target_navigation( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + replacement_url: &str, + ) -> bool { + runtimes + .entries + .get(owner) + .and_then(|record| record.initial_empty_document.as_ref()) + .is_some_and(|state| { + state.is_on_initial_empty_document() && state.initial_url() != replacement_url + }) + } +} + +impl BrowserNavigationOwner { + /// Returns whether one exact Page may replace its initial empty Document + /// with the Target creation URL now. + /// + /// Every trigger is advisory. Browser Host re-evaluates this predicate + /// only after selecting the mailbox turn, so duplicate Page.enable, + /// debugger-resume, or create-target inputs collapse against the same + /// pending navigation state. A stale generation or recreated Target can + /// never authorize the successor Page. + pub fn accepts_initial_target_navigation( + &self, + expected: &PageResidenceIdentity, + replacement_url: &str, + ) -> bool { + let Some(owner) = self.page_owner_key_if_current(expected) else { + return false; + }; + !self + .document_navigations + .has_pending(&self.target_runtimes, &owner) + && self + .initial_empty_documents + .accepts_initial_target_navigation(&self.target_runtimes, &owner, replacement_url) + } + + /// Transitional typed metadata install for an already registered Target. + /// + /// Production Target and BrowserContext creation carry + /// `BrowserTargetCreationMetadata` in their registration transaction. This + /// entry point remains for focused lifecycle fixtures and legacy callers; + /// it validates exact live Target ownership and never assumes raw ids are + /// sufficient authority. + pub fn register_target_initial_empty_document( + &mut self, + owner: &BrowserPageOwnerKey, + seed: BrowserInitialEmptyDocumentSeed, + ) -> Result<(), BrowserTargetRegistryError> { + self.targets.validate_target_owner(owner)?; + self.initial_empty_documents + .begin(&mut self.target_runtimes, owner, seed); + Ok(()) + } + + pub fn target_initial_empty_document( + &self, + owner: &BrowserPageOwnerKey, + ) -> Option { + self.initial_empty_documents.snapshot( + &self.target_runtimes, + owner, + self.document_navigations + .has_pending(&self.target_runtimes, owner), + ) + } + + pub fn initial_empty_documents( + &self, + ) -> impl Iterator + '_ { + self.initial_empty_documents + .snapshots(&self.target_runtimes, |owner| { + self.document_navigations + .has_pending(&self.target_runtimes, owner) + }) + } + + pub fn can_install_target_initial_empty_document_page( + &self, + owner: &BrowserPageOwnerKey, + ) -> bool { + self.targets.validate_target_owner(owner).is_ok() + && self + .initial_empty_documents + .can_install_current_page(&self.target_runtimes, owner) + } + + pub fn mark_target_initial_empty_document_exited(&mut self, owner: &BrowserPageOwnerKey) { + self.initial_empty_documents + .mark_exited(&mut self.target_runtimes, owner); + } +} + +#[cfg(test)] +mod tests { + use crate::{ + browser_host::{ + BrowserContextSelectionProjection, BrowserPageResidenceHandle, + BrowserSelectedTargetEngineDisposition, BrowserTargetHandle, + BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }, + runtime::NavigationEngine, + }; + + use super::*; + + fn owner_with_target() -> (BrowserNavigationOwner, BrowserPageOwnerKey) { + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + owner + .register_browser_context( + key.browser_context_id(), + BrowserTargetTopologyProjection::new( + key.browser_context_id(), + Some(BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged(key.target_id()), + BrowserPageResidenceHandle::default(), + )), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("test Target should register"); + (owner, key) + } + + #[test] + fn lifecycle_record_survives_exit_and_rejects_late_materialization() { + let (mut owner, key) = owner_with_target(); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank#initial"), + ) + .expect("live Target should accept initial metadata"); + + assert!(owner.can_install_target_initial_empty_document_page(&key)); + let permit = owner + .prepare_initial_document_page_materialization(&key) + .expect("current initial Document should prepare materialization"); + owner + .commit_page_residence_transition_without_renderer_owner_for_testing(permit) + .expect("exact initial materialization should commit"); + let first = owner + .target_initial_empty_document(&key) + .expect("initial document record"); + assert!(first.materialized()); + assert!(first.is_on_initial_empty_document()); + + owner.mark_target_initial_empty_document_exited(&key); + assert!(!owner.can_install_target_initial_empty_document_page(&key)); + assert!( + owner + .prepare_initial_document_page_materialization(&key) + .is_none() + ); + let exited = owner + .target_initial_empty_document(&key) + .expect("exited metadata remains diagnostic"); + assert!(exited.exited()); + assert!(exited.materialized()); + } + + #[test] + fn non_initial_url_does_not_create_initial_document_authority() { + let (mut owner, key) = owner_with_target(); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("https://example.test/"), + ) + .expect("live Target should accept metadata input"); + assert!(owner.target_initial_empty_document(&key).is_none()); + } + + #[test] + fn snapshot_derives_pending_state_from_document_navigation_registry() { + let (mut owner, key) = owner_with_target(); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("live Target should accept initial metadata"); + + let failed = owner.start_document_navigation(&key, "loader-failed".to_owned()); + assert!( + owner + .target_initial_empty_document(&key) + .expect("initial state") + .pending_cross_document_navigation() + ); + assert!(owner.fail_document_navigation_if_matches( + &key, + &failed, + crate::browser_host::BrowserNavigationFailure::Network { + error_text: "failed".to_owned(), + }, + )); + assert!( + !owner + .target_initial_empty_document(&key) + .expect("initial state") + .pending_cross_document_navigation() + ); + + let committed = owner.start_document_navigation(&key, "loader-committed".to_owned()); + assert!(owner.commit_document_navigation_if_matches(&key, &committed)); + let exited = owner + .target_initial_empty_document(&key) + .expect("creation metadata remains after exit"); + assert!(exited.exited()); + assert!(!exited.pending_cross_document_navigation()); + } + + #[test] + fn initial_target_navigation_acceptance_is_exact_and_coalesces_pending_triggers() { + let (mut owner, key) = owner_with_target(); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("live Target should accept initial metadata"); + let initial_page = owner + .capture_page_residence(key.browser_context_id(), key.target_id()) + .expect("initial Page identity"); + + assert!(!owner.accepts_initial_target_navigation(&initial_page, "about:blank")); + assert!( + owner.accepts_initial_target_navigation(&initial_page, "https://example.test/created") + ); + + let pending = owner.start_document_navigation(&key, "loader-pending".to_owned()); + assert!( + !owner.accepts_initial_target_navigation(&initial_page, "https://example.test/created"), + "a second trigger must collapse behind the accepted navigation" + ); + assert!(owner.fail_document_navigation_if_matches( + &key, + &pending, + crate::browser_host::BrowserNavigationFailure::Canceled { + error_text: "canceled".to_owned(), + }, + )); + + let materialization = owner + .prepare_initial_document_page_materialization(&key) + .expect("initial Document materialization should be current"); + owner + .commit_page_residence_transition_without_renderer_owner_for_testing(materialization) + .expect("materialization should advance the Page generation"); + assert!( + !owner.accepts_initial_target_navigation(&initial_page, "https://example.test/created"), + "a trigger captured before Page generation advance must be stale" + ); + let materialized_page = owner + .capture_page_residence(key.browser_context_id(), key.target_id()) + .expect("materialized Page identity"); + assert!( + owner.accepts_initial_target_navigation( + &materialized_page, + "https://example.test/created" + ) + ); + + owner.mark_target_initial_empty_document_exited(&key); + assert!( + !owner.accepts_initial_target_navigation( + &materialized_page, + "https://example.test/created" + ) + ); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/navigation_trace.rs b/moli-core/src/browser_host/navigation_owner/navigation_trace.rs new file mode 100644 index 0000000000..1e09ddc801 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/navigation_trace.rs @@ -0,0 +1,342 @@ +use std::{ + num::NonZeroU64, + sync::atomic::{AtomicU64, Ordering}, +}; + +use moli_page_types::{BrowserActionId, RendererAgentAttachmentId}; +use moli_trace::{ + BrowserOwnerTraceDocument, BrowserOwnerTraceRecord, emit_browser_owner_trace_record, +}; + +use crate::{RendererDocumentLifecycleIdentity, browser_host::PageResidenceIdentity}; + +use super::{ + BrowserDocumentNavigation, BrowserNavigationOwner, BrowserNavigationRequestId, + BrowserPageOwnerKey, +}; + +static NEXT_BROWSER_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); + +/// Process-local identity of one Browser Owner instance. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct BrowserInstanceId(NonZeroU64); + +impl BrowserInstanceId { + pub(in crate::browser_host) fn allocate() -> Self { + let raw = NEXT_BROWSER_INSTANCE_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .unwrap_or_else(|_| panic!("browser instance id exhausted")); + Self( + NonZeroU64::new(raw) + .unwrap_or_else(|| panic!("browser instance id allocator returned zero")), + ) + } + + pub fn get(self) -> u64 { + self.0.get() + } +} + +/// Producer category for a navigation trace event. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserNavigationTraceSource { + FrontendCommand, + RendererIntent, + Network, + Lifecycle, +} + +impl BrowserNavigationTraceSource { + pub fn label(self) -> &'static str { + match self { + Self::FrontendCommand => "frontend-command", + Self::RendererIntent => "renderer-intent", + Self::Network => "network", + Self::Lifecycle => "lifecycle", + } + } +} + +/// Bounded correlation sidecar for one browser navigation action. +/// +/// The context contains no frontend/session identity. Browser Core stores it +/// only beside the exact pending/committed request, so replacement and target +/// cleanup retire it with the authority it describes. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserNavigationTraceContext { + browser_instance_id: BrowserInstanceId, + browser_action_id: BrowserActionId, + origin: BrowserNavigationTraceSource, + source_page: PageResidenceIdentity, + source_document: Option, +} + +impl BrowserNavigationTraceContext { + pub(super) fn new( + browser_instance_id: BrowserInstanceId, + browser_action_id: BrowserActionId, + origin: BrowserNavigationTraceSource, + source_page: PageResidenceIdentity, + source_document: Option, + ) -> Self { + Self { + browser_instance_id, + browser_action_id, + origin, + source_page, + source_document, + } + } + + pub fn browser_instance_id(&self) -> BrowserInstanceId { + self.browser_instance_id + } + + pub fn browser_action_id(&self) -> BrowserActionId { + self.browser_action_id + } + + pub fn origin(&self) -> BrowserNavigationTraceSource { + self.origin + } + + pub fn source_page(&self) -> &PageResidenceIdentity { + &self.source_page + } + + pub fn source_document(&self) -> Option { + self.source_document + } + + pub(super) fn addresses_owner(&self, owner: &BrowserPageOwnerKey) -> bool { + self.source_page.browser_context_id() == owner.browser_context_id() + && self.source_page.target_id() == Some(owner.target_id()) + } + + pub fn emit(&self, event: BrowserNavigationTraceEvent) { + if !moli_trace::browser_owner_trace_enabled() { + return; + } + let page = event.page.as_ref().unwrap_or(&self.source_page); + let document = event.document.or(self.source_document); + emit_browser_owner_trace_record( + &BrowserOwnerTraceRecord::new( + event.stage, + event.source.label(), + event.owner_state_before, + event.owner_state_after, + ) + .with_browser_instance_id(Some(self.browser_instance_id.get())) + .with_browser_context_id(Some(page.browser_context_id())) + .with_target_id(page.target_id()) + .with_page_residence_generation(Some(page.loaded_page_generation())) + .with_navigation_request_id( + event + .navigation_request_id + .map(BrowserNavigationRequestId::get), + ) + .with_renderer_agent_attachment_id( + event + .renderer_agent_attachment_id + .map(RendererAgentAttachmentId::get), + ) + .with_document_lifecycle_identity(document.map(machine_trace_document)) + .with_browser_action_id(Some(self.browser_action_id.get())) + .with_browser_fact_sequence(event.browser_fact_sequence) + .with_navigation_origin(Some(self.origin.label())) + .with_frontend_projection_sequence(event.frontend_projection_sequence) + .with_renderer_lifecycle( + event.renderer_lifecycle_sequence, + None, + None, + None, + ), + ); + if !moli_trace::browser_owner_human_trace_enabled() { + return; + } + tracing::info!( + target: "moli_browser_owner", + browser_instance_id = self.browser_instance_id.get(), + browser_context_id = page.browser_context_id(), + target_id = ?page.target_id(), + page_residence_generation = page.loaded_page_generation(), + navigation_request_id = ?event.navigation_request_id.map(BrowserNavigationRequestId::get), + renderer_agent_attachment_id = ?event.renderer_agent_attachment_id.map(RendererAgentAttachmentId::get), + document_lifecycle_identity = ?document, + browser_action_id = self.browser_action_id.get(), + browser_fact_sequence = ?event.browser_fact_sequence, + source = event.source.label(), + navigation_origin = self.origin.label(), + owner_state_before = event.owner_state_before, + owner_state_after = event.owner_state_after, + frontend_projection_sequence = ?event.frontend_projection_sequence, + renderer_lifecycle_sequence = ?event.renderer_lifecycle_sequence, + stage = event.stage, + "browser navigation owner trace" + ); + } +} + +fn machine_trace_document( + identity: RendererDocumentLifecycleIdentity, +) -> BrowserOwnerTraceDocument { + BrowserOwnerTraceDocument::new( + identity.document.page_id.as_u64(), + identity.document.lifecycle_document_id_for_diagnostics(), + identity.epoch.0, + ) +} + +impl BrowserNavigationOwner { + /// Creates a trace context for an action already bound to an exact Page + /// residence. The identity may later be rejected as stale; retaining it is + /// what makes that rejection diagnosable. + pub fn renderer_navigation_trace_context( + &self, + source_page: &PageResidenceIdentity, + browser_action_id: BrowserActionId, + source_document: RendererDocumentLifecycleIdentity, + ) -> Option { + moli_trace::browser_owner_trace_enabled().then(|| { + BrowserNavigationTraceContext::new( + self.browser_instance_id, + browser_action_id, + BrowserNavigationTraceSource::RendererIntent, + source_page.clone(), + Some(source_document), + ) + }) + } + + /// Creates a trace context for a browser/frontend action after its neutral + /// Target owner has been resolved. + pub fn owner_navigation_trace_context( + &self, + owner: &BrowserPageOwnerKey, + browser_action_id: BrowserActionId, + origin: BrowserNavigationTraceSource, + source_document: Option, + ) -> Option { + if !moli_trace::browser_owner_trace_enabled() { + return None; + } + let source_page = self + .page_residences + .identity(&self.target_runtimes, owner)?; + Some(BrowserNavigationTraceContext::new( + self.browser_instance_id, + browser_action_id, + origin, + source_page, + source_document, + )) + } +} + +/// One immutable trace transition emitted from the current production path. +#[derive(Clone, Debug)] +pub struct BrowserNavigationTraceEvent { + stage: &'static str, + source: BrowserNavigationTraceSource, + owner_state_before: &'static str, + owner_state_after: &'static str, + navigation_request_id: Option, + renderer_agent_attachment_id: Option, + page: Option, + document: Option, + browser_fact_sequence: Option, + frontend_projection_sequence: Option, + renderer_lifecycle_sequence: Option, +} + +impl BrowserNavigationTraceEvent { + pub fn new( + stage: &'static str, + source: BrowserNavigationTraceSource, + owner_state_before: &'static str, + owner_state_after: &'static str, + ) -> Self { + Self { + stage, + source, + owner_state_before, + owner_state_after, + navigation_request_id: None, + renderer_agent_attachment_id: None, + page: None, + document: None, + browser_fact_sequence: None, + frontend_projection_sequence: None, + renderer_lifecycle_sequence: None, + } + } + + pub fn with_navigation(mut self, navigation: &BrowserDocumentNavigation) -> Self { + self.navigation_request_id = Some(navigation.request_id()); + self + } + + pub fn with_renderer_agent_attachment( + mut self, + attachment_id: Option, + ) -> Self { + self.renderer_agent_attachment_id = attachment_id; + self + } + + pub fn with_page(mut self, page: PageResidenceIdentity) -> Self { + self.page = Some(page); + self + } + + pub fn with_document(mut self, document: RendererDocumentLifecycleIdentity) -> Self { + self.document = Some(document); + self + } + + pub fn with_browser_fact_sequence(mut self, sequence: u64) -> Self { + self.browser_fact_sequence = Some(sequence); + self + } + + pub fn with_frontend_projection_sequence(mut self, sequence: u64) -> Self { + self.frontend_projection_sequence = Some(sequence); + self + } + + pub fn with_renderer_lifecycle_sequence(mut self, sequence: u64) -> Self { + self.renderer_lifecycle_sequence = Some(sequence); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_ids_are_nonzero_and_distinct() { + let first = BrowserInstanceId::allocate(); + let second = BrowserInstanceId::allocate(); + + assert_ne!(first, second); + assert_ne!(first.get(), 0); + } + + #[test] + fn source_labels_are_stable_and_frontend_neutral() { + assert_eq!( + BrowserNavigationTraceSource::FrontendCommand.label(), + "frontend-command" + ); + assert_eq!( + BrowserNavigationTraceSource::RendererIntent.label(), + "renderer-intent" + ); + assert_eq!(BrowserNavigationTraceSource::Network.label(), "network"); + assert_eq!(BrowserNavigationTraceSource::Lifecycle.label(), "lifecycle"); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/page_registry.rs b/moli-core/src/browser_host/navigation_owner/page_registry.rs new file mode 100644 index 0000000000..f64c0472e6 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/page_registry.rs @@ -0,0 +1,617 @@ +use crate::browser_host::page_residence::BrowserPageResidenceAdvanceError; +use crate::browser_host::{ + BrowserContextId, BrowserPageResidenceHandle, BrowserPageRuntimeAccess, + BrowserPageRuntimeOwner, PageResidenceIdentity, +}; +use crate::page::RendererPageLifetimeOwner; + +use super::{ + BrowserNavigationOwner, BrowserPageOwnerKey, BrowserTargetTopologyProjection, + target_runtime_registry::BrowserTargetRuntimeRegistry, +}; + +/// Exact Page-slot registration or physical projection mismatch. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserPageResidenceRegistryError { + DuplicateTarget(BrowserPageOwnerKey), + UnknownTarget(BrowserPageOwnerKey), + TargetNotLive(BrowserPageOwnerKey), + ProjectionMismatch(BrowserPageOwnerKey), + DuplicateProjectedHandle { + first: BrowserPageOwnerKey, + duplicate: BrowserPageOwnerKey, + }, + ContextProjectionMismatch(BrowserContextId), + StaleTransition { + owner: BrowserPageOwnerKey, + expected_generation: u64, + current_generation: u64, + }, + GenerationExhausted(BrowserPageOwnerKey), + RuntimeOwnerMismatch(BrowserPageOwnerKey), +} + +impl std::fmt::Display for BrowserPageResidenceRegistryError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DuplicateTarget(owner) => write!( + formatter, + "Page residence for Target {:?} in BrowserContext {:?} is already registered", + owner.target_id(), + owner.browser_context_id() + ), + Self::UnknownTarget(owner) => write!( + formatter, + "Target {:?} in BrowserContext {:?} has no Page residence", + owner.target_id(), + owner.browser_context_id() + ), + Self::TargetNotLive(owner) => write!( + formatter, + "Target {:?} in BrowserContext {:?} has only a staged Page residence", + owner.target_id(), + owner.browser_context_id() + ), + Self::ProjectionMismatch(owner) => write!( + formatter, + "physical Page residence for Target {:?} in BrowserContext {:?} is not the Browser Core instance", + owner.target_id(), + owner.browser_context_id() + ), + Self::DuplicateProjectedHandle { first, duplicate } => write!( + formatter, + "Targets {:?} and {:?} project the same physical Page residence", + first.target_id(), + duplicate.target_id() + ), + Self::ContextProjectionMismatch(browser_context_id) => write!( + formatter, + "BrowserContext {:?} Page residence count does not match its Target projection", + browser_context_id.as_str() + ), + Self::StaleTransition { + owner, + expected_generation, + current_generation, + } => write!( + formatter, + "Page residence for Target {:?} in BrowserContext {:?} advanced from generation {expected_generation} to {current_generation} before commit", + owner.target_id(), + owner.browser_context_id() + ), + Self::GenerationExhausted(owner) => write!( + formatter, + "Page residence generation for Target {:?} in BrowserContext {:?} is exhausted", + owner.target_id(), + owner.browser_context_id() + ), + Self::RuntimeOwnerMismatch(owner) => write!( + formatter, + "Page runtime payload and renderer lifetime owner for Target {:?} in BrowserContext {:?} do not name the same renderer Page", + owner.target_id(), + owner.browser_context_id() + ), + } + } +} + +impl std::error::Error for BrowserPageResidenceRegistryError {} + +/// Authoritative Target-to-Page-slot capability registry. +/// +/// Browser Core stores the strong slot capability from Target registration +/// until Target retirement and, once a concrete Page commits, the unique +/// renderer Page lifetime owner and mutable Page command/cache payload. +/// Protocol migration storage carries an exact slot clone plus a non-owning, +/// invalidatable Page access; it cannot register, replace, or retire either +/// Browser-owned lifetime by dropping that projection. +#[derive(Default)] +pub(super) struct BrowserPageResidenceRegistry; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BrowserPageResidenceRegistrationState { + Staged, + Live, +} + +pub(super) struct BrowserPageResidenceRecord { + handle: BrowserPageResidenceHandle, + registration_state: BrowserPageResidenceRegistrationState, + renderer_page_owner: Option, + page_runtime_owner: Option, +} + +impl BrowserPageResidenceRecord { + fn staged(handle: BrowserPageResidenceHandle) -> Self { + Self { + handle, + registration_state: BrowserPageResidenceRegistrationState::Staged, + renderer_page_owner: None, + page_runtime_owner: None, + } + } + + fn is_staged_exact(&self, handle: &BrowserPageResidenceHandle) -> bool { + self.registration_state == BrowserPageResidenceRegistrationState::Staged + && self.handle.same_instance(handle) + } + + fn is_live(&self) -> bool { + self.registration_state == BrowserPageResidenceRegistrationState::Live + } + + fn publish_if_staged_exact(&mut self, handle: &BrowserPageResidenceHandle) -> bool { + if !self.is_staged_exact(handle) { + return false; + } + self.registration_state = BrowserPageResidenceRegistrationState::Live; + true + } + + pub(super) fn into_renderer_page_owner(self) -> Option { + let Self { + renderer_page_owner, + page_runtime_owner, + .. + } = self; + drop(page_runtime_owner); + renderer_page_owner + } +} + +impl BrowserPageResidenceRegistry { + pub(super) fn validate_context_registration( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + browser_context_id: &BrowserContextId, + projection: &BrowserTargetTopologyProjection, + ) -> Result<(), BrowserPageResidenceRegistryError> { + let mut projected = Vec::<(BrowserPageOwnerKey, BrowserPageResidenceHandle)>::new(); + for slot in projection.slots() { + let owner = BrowserPageOwnerKey::new(browser_context_id.as_str(), slot.target_id()); + if runtimes + .entries + .get(&owner) + .is_some_and(|runtime| runtime.page_residence.is_some()) + { + return Err(BrowserPageResidenceRegistryError::DuplicateTarget(owner)); + } + if let Some((first, _)) = self + .page_records(runtimes) + .find(|(_, record)| record.handle.same_instance(slot.page_residence_handle())) + { + return Err( + BrowserPageResidenceRegistryError::DuplicateProjectedHandle { + first: first.clone(), + duplicate: owner, + }, + ); + } + if let Some((first, _)) = projected + .iter() + .find(|(_, handle)| handle.same_instance(slot.page_residence_handle())) + { + return Err( + BrowserPageResidenceRegistryError::DuplicateProjectedHandle { + first: first.clone(), + duplicate: owner, + }, + ); + } + projected.push((owner, slot.page_residence_handle().clone())); + } + Ok(()) + } + + pub(super) fn validate_projection( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + browser_context_id: &BrowserContextId, + projection: &BrowserTargetTopologyProjection, + ) -> Result<(), BrowserPageResidenceRegistryError> { + let authoritative_count = self + .page_records(runtimes) + .filter(|(owner, record)| { + owner.browser_context_id() == browser_context_id.as_str() && record.is_live() + }) + .count(); + if authoritative_count != projection.slots().count() { + return Err( + BrowserPageResidenceRegistryError::ContextProjectionMismatch( + browser_context_id.clone(), + ), + ); + } + for slot in projection.slots() { + let owner = BrowserPageOwnerKey::new(browser_context_id.as_str(), slot.target_id()); + let Some(authoritative) = self.record(runtimes, &owner) else { + return Err(BrowserPageResidenceRegistryError::UnknownTarget(owner)); + }; + if !authoritative.is_live() { + return Err(BrowserPageResidenceRegistryError::TargetNotLive(owner)); + } + if !authoritative + .handle + .same_instance(slot.page_residence_handle()) + { + return Err(BrowserPageResidenceRegistryError::ProjectionMismatch(owner)); + } + } + Ok(()) + } + + fn live_handle<'a>( + &self, + runtimes: &'a BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Result<&'a BrowserPageResidenceHandle, BrowserPageResidenceRegistryError> { + let Some(record) = self.record(runtimes, key) else { + return Err(BrowserPageResidenceRegistryError::UnknownTarget( + key.clone(), + )); + }; + if !record.is_live() { + return Err(BrowserPageResidenceRegistryError::TargetNotLive( + key.clone(), + )); + } + Ok(&record.handle) + } + + fn resolve( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + expected: &PageResidenceIdentity, + ) -> Option { + let target_id = expected.target_id()?; + let key = BrowserPageOwnerKey::new(expected.browser_context_id(), target_id); + self.live_handle(runtimes, &key) + .is_ok_and(|handle| handle.is_current(expected)) + .then_some(key) + } + + fn resolve_slot( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + expected: &PageResidenceIdentity, + ) -> Option { + let target_id = expected.target_id()?; + let key = BrowserPageOwnerKey::new(expected.browser_context_id(), target_id); + self.live_handle(runtimes, &key) + .is_ok_and(|handle| handle.owns_identity_instance(expected)) + .then_some(key) + } + + pub(super) fn identity( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Option { + self.live_handle(runtimes, key).ok().map(|handle| { + handle.identity( + key.browser_context_id().to_owned(), + Some(key.target_id().to_owned()), + ) + }) + } + + fn prepare_exact_transition( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Option { + self.identity(runtimes, key) + } + + pub(super) fn prepare_replacement( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Option { + self.prepare_exact_transition(runtimes, key) + } + + pub(super) fn capture_termination( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Option { + self.prepare_exact_transition(runtimes, key) + } + + pub(super) fn accepts_transition( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + expected: &PageResidenceIdentity, + ) -> bool { + self.live_handle(runtimes, key) + .is_ok_and(|handle| handle.is_current(expected)) + } + + fn commit_replacement_with_page_owners( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + expected: &PageResidenceIdentity, + successor_renderer_owner: &mut Option, + successor_runtime_owner: &mut Option, + ) -> Result< + ( + PageResidenceIdentity, + Option, + Option, + ), + BrowserPageResidenceRegistryError, + > { + let Some(record) = runtimes + .entries + .get_mut(key) + .and_then(|runtime| runtime.page_residence.as_mut()) + else { + return Err(BrowserPageResidenceRegistryError::UnknownTarget( + key.clone(), + )); + }; + if !record.is_live() { + return Err(BrowserPageResidenceRegistryError::TargetNotLive( + key.clone(), + )); + } + if successor_renderer_owner + .as_ref() + .zip(successor_runtime_owner.as_ref()) + .is_some_and(|(renderer, runtime)| runtime.page_id() != renderer.page_id()) + || successor_renderer_owner.is_some() != successor_runtime_owner.is_some() + { + return Err(BrowserPageResidenceRegistryError::RuntimeOwnerMismatch( + key.clone(), + )); + } + record + .handle + .try_advance_generation_if_current(expected) + .map_err(|error| match error { + BrowserPageResidenceAdvanceError::InstanceMismatch => { + BrowserPageResidenceRegistryError::ProjectionMismatch(key.clone()) + } + BrowserPageResidenceAdvanceError::StaleGeneration { current_generation } => { + BrowserPageResidenceRegistryError::StaleTransition { + owner: key.clone(), + expected_generation: expected.loaded_page_generation(), + current_generation, + } + } + BrowserPageResidenceAdvanceError::GenerationExhausted => { + BrowserPageResidenceRegistryError::GenerationExhausted(key.clone()) + } + })?; + let current = record.handle.identity( + key.browser_context_id().to_owned(), + Some(key.target_id().to_owned()), + ); + let successor_access = successor_runtime_owner + .as_ref() + .map(BrowserPageRuntimeOwner::access); + let retired_renderer = std::mem::replace( + &mut record.renderer_page_owner, + successor_renderer_owner.take(), + ); + let retired_runtime = std::mem::replace( + &mut record.page_runtime_owner, + successor_runtime_owner.take(), + ); + // Invalidates every stale Protocol access in the same owner turn. The + // separately returned renderer lifetime owner performs acknowledged + // asynchronous teardown without keeping the Page payload addressable. + drop(retired_runtime); + Ok((current, retired_renderer, successor_access)) + } + + pub(super) fn commit_transition_with_page_owners( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + expected: &PageResidenceIdentity, + successor_renderer_owner: &mut Option, + successor_runtime_owner: &mut Option, + ) -> Result< + ( + PageResidenceIdentity, + Option, + Option, + ), + BrowserPageResidenceRegistryError, + > { + self.commit_replacement_with_page_owners( + runtimes, + key, + expected, + successor_renderer_owner, + successor_runtime_owner, + ) + } + + pub(super) fn commit_termination( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + expected: &PageResidenceIdentity, + remove_target: bool, + ) -> Result< + (PageResidenceIdentity, Option), + BrowserPageResidenceRegistryError, + > { + let mut no_successor_renderer = None; + let mut no_successor_runtime = None; + let (terminal, retired, no_successor_access) = self.commit_replacement_with_page_owners( + runtimes, + key, + expected, + &mut no_successor_renderer, + &mut no_successor_runtime, + )?; + debug_assert!(no_successor_access.is_none()); + if remove_target { + let removed = runtimes + .entries + .get_mut(key) + .and_then(|runtime| runtime.page_residence.take()); + debug_assert!( + removed.is_some(), + "same-turn terminal Page commit must remove its exact registry entry" + ); + runtimes.prune_empty(); + } + Ok((terminal, retired)) + } + + #[cfg(test)] + pub(super) fn forget_target( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> Option { + let record = runtimes + .entries + .get_mut(owner) + .and_then(|runtime| runtime.page_residence.take())?; + let renderer_page_owner = record.into_renderer_page_owner(); + runtimes.prune_empty(); + renderer_page_owner + } + + pub(super) fn handle_for_target( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> Option { + self.live_handle(runtimes, owner).ok().cloned() + } + + pub(super) fn renderer_page_id_for_target( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> Option { + runtimes + .entries + .get(owner) + .and_then(|runtime| runtime.page_residence.as_ref()) + .filter(|record| record.is_live()) + .and_then(|record| record.renderer_page_owner.as_ref()) + .map(RendererPageLifetimeOwner::page_id) + } + + pub(super) fn handle_is_current( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + handle: &BrowserPageResidenceHandle, + ) -> bool { + self.live_handle(runtimes, owner) + .is_ok_and(|current| current.same_instance(handle)) + } + + fn record<'a>( + &self, + runtimes: &'a BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> Option<&'a BrowserPageResidenceRecord> { + runtimes + .entries + .get(owner) + .and_then(|runtime| runtime.page_residence.as_ref()) + } + + fn record_mut<'a>( + &mut self, + runtimes: &'a mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> Option<&'a mut BrowserPageResidenceRecord> { + runtimes + .entries + .get_mut(owner) + .and_then(|runtime| runtime.page_residence.as_mut()) + } + + fn page_records<'a>( + &self, + runtimes: &'a BrowserTargetRuntimeRegistry, + ) -> impl Iterator { + runtimes.entries.iter().filter_map(|(owner, runtime)| { + runtime + .page_residence + .as_ref() + .map(|record| (owner, record)) + }) + } +} + +mod transaction; + +impl BrowserNavigationOwner { + /// Captures the exact Browser Core-owned Page residence for one Target. + pub fn capture_page_residence( + &self, + browser_context_id: impl Into, + target_id: impl Into, + ) -> Option { + self.page_residences.identity( + &self.target_runtimes, + &BrowserPageOwnerKey::new(browser_context_id, target_id), + ) + } + + /// Resolves an exact live Page residence to its protocol-neutral owner key. + pub fn page_owner_key_if_current( + &self, + expected: &PageResidenceIdentity, + ) -> Option { + self.page_residences + .resolve(&self.target_runtimes, expected) + } + + /// Resolves the live Target that still owns the same physical Page slot. + /// + /// A successful cross-Document navigation advances the slot generation, + /// so terminal fact projection cannot require the pre-navigation + /// generation to remain current. The stable slot instance still prevents + /// a removed/recreated Target with the same public id from matching. + pub fn page_owner_key_for_same_slot( + &self, + expected: &PageResidenceIdentity, + ) -> Option { + self.page_residences + .resolve_slot(&self.target_runtimes, expected) + } + + /// Returns the registered Page capability for diagnostics/projection. + pub fn page_residence_handle( + &self, + owner: &BrowserPageOwnerKey, + ) -> Option { + self.page_residences + .handle_for_target(&self.target_runtimes, owner) + } + + /// Verifies that a physical slot carries the exact Core-owned Page handle. + pub fn page_residence_handle_is_current( + &self, + owner: &BrowserPageOwnerKey, + handle: &BrowserPageResidenceHandle, + ) -> bool { + self.page_residences + .handle_is_current(&self.target_runtimes, owner, handle) + } + + /// Returns the physical renderer Page id owned by this exact Browser Page + /// residence. Frontend projections deliberately do not affect it. + pub fn renderer_page_id_for_owner(&self, owner: &BrowserPageOwnerKey) -> Option { + self.page_residences + .renderer_page_id_for_target(&self.target_runtimes, owner) + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-core/src/browser_host/navigation_owner/page_registry/tests.rs b/moli-core/src/browser_host/navigation_owner/page_registry/tests.rs new file mode 100644 index 0000000000..43e5842a1f --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/page_registry/tests.rs @@ -0,0 +1,418 @@ +use crate::{ + browser_host::{ + BrowserContextSelectionProjection, BrowserSelectedTargetEngineDisposition, + BrowserTargetHandle, BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }, + runtime::NavigationEngine, +}; + +use super::*; + +fn slot( + target_id: &str, + page_residence: BrowserPageResidenceHandle, +) -> BrowserTargetSlotProjection { + BrowserTargetSlotProjection::new(BrowserTargetHandle::staged(target_id), page_residence) +} + +fn topology( + context_id: &str, + active: Option, +) -> BrowserTargetTopologyProjection { + BrowserTargetTopologyProjection::new( + context_id, + active, + Vec::::new(), + ) +} + +fn register_target( + owner: &mut BrowserNavigationOwner, + context_id: &str, + target_id: &str, +) -> BrowserPageResidenceHandle { + let page_residence = BrowserPageResidenceHandle::default(); + owner + .register_browser_context( + context_id, + topology(context_id, Some(slot(target_id, page_residence.clone()))), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("test Target topology should register"); + page_residence +} + +#[test] +fn staged_target_registration_is_hidden_until_exact_commit() { + let mut registry = BrowserPageResidenceRegistry; + let mut runtimes = BrowserTargetRuntimeRegistry::default(); + let owner = BrowserPageOwnerKey::new("context-1", "target-1"); + let registration = registry + .begin_target_registration(&mut runtimes, owner.clone()) + .expect("fresh Page residence should stage"); + let handle = registration.handle().clone(); + + assert!(registry.identity(&runtimes, &owner).is_none()); + assert!(registry.handle_for_target(&runtimes, &owner).is_none()); + assert_eq!( + registry.live_handle(&runtimes, &owner), + Err(BrowserPageResidenceRegistryError::TargetNotLive( + owner.clone() + )) + ); + + let committed = registry.commit_target_registration(&mut runtimes, registration); + assert!(committed.same_instance(&handle)); + assert!(registry.handle_is_current(&runtimes, &owner, &handle)); +} + +#[test] +fn target_registration_rollback_removes_only_its_staged_residence() { + let mut registry = BrowserPageResidenceRegistry; + let mut runtimes = BrowserTargetRuntimeRegistry::default(); + let owner = BrowserPageOwnerKey::new("context-1", "target-1"); + let first = registry + .begin_target_registration(&mut runtimes, owner.clone()) + .expect("fresh Page residence should stage"); + let first_handle = first.handle().clone(); + + assert!(registry.rollback_target_registration(&mut runtimes, first)); + assert!(!runtimes.entries.contains_key(&owner)); + + let second = registry + .begin_target_registration(&mut runtimes, owner.clone()) + .expect("rolled-back owner should stage again"); + let second_handle = second.handle().clone(); + assert!(!first_handle.same_instance(&second_handle)); + registry.commit_target_registration(&mut runtimes, second); + assert!(registry.handle_is_current(&runtimes, &owner, &second_handle)); +} + +#[test] +fn context_registration_stages_every_page_and_rolls_back_exactly() { + let mut registry = BrowserPageResidenceRegistry; + let mut runtimes = BrowserTargetRuntimeRegistry::default(); + let active_page = BrowserPageResidenceHandle::default(); + let background_page = BrowserPageResidenceHandle::default(); + let projection = BrowserTargetTopologyProjection::new( + "context-1", + Some(slot("target-a", active_page.clone())), + vec![slot("target-b", background_page.clone())], + ); + let active_owner = BrowserPageOwnerKey::new("context-1", "target-a"); + let background_owner = BrowserPageOwnerKey::new("context-1", "target-b"); + + let registration = registry + .begin_context_registration( + &mut runtimes, + &BrowserContextId::new("context-1"), + &projection, + ) + .expect("fresh context Page residences should stage"); + assert!(registry.identity(&runtimes, &active_owner).is_none()); + assert!(registry.identity(&runtimes, &background_owner).is_none()); + + assert!(registry.rollback_context_registration(&mut runtimes, registration)); + assert!(!runtimes.entries.contains_key(&active_owner)); + assert!(!runtimes.entries.contains_key(&background_owner)); +} + +#[test] +fn context_registration_rejects_a_page_capability_live_in_another_context() { + let mut registry = BrowserPageResidenceRegistry; + let mut runtimes = BrowserTargetRuntimeRegistry::default(); + let shared = BrowserPageResidenceHandle::default(); + let first_owner = BrowserPageOwnerKey::new("context-a", "target-a"); + let first_projection = topology("context-a", Some(slot("target-a", shared.clone()))); + let first = registry + .begin_context_registration( + &mut runtimes, + &BrowserContextId::new("context-a"), + &first_projection, + ) + .expect("first context should stage"); + registry.commit_context_registration(&mut runtimes, first); + + let second_owner = BrowserPageOwnerKey::new("context-b", "target-b"); + let second_projection = topology("context-b", Some(slot("target-b", shared.clone()))); + let error = registry + .begin_context_registration( + &mut runtimes, + &BrowserContextId::new("context-b"), + &second_projection, + ) + .expect_err("one physical Page capability cannot authorize two Targets"); + + assert_eq!( + error, + BrowserPageResidenceRegistryError::DuplicateProjectedHandle { + first: first_owner.clone(), + duplicate: second_owner.clone(), + } + ); + assert!(registry.handle_is_current(&runtimes, &first_owner, &shared)); + assert!(!runtimes.entries.contains_key(&second_owner)); +} + +#[test] +fn outstanding_page_stage_rejects_new_target_without_leaking_target_authority() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + owner + .register_browser_context( + "context-1", + topology("context-1", None), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("empty BrowserContext should register"); + let page_owner = BrowserPageOwnerKey::new("context-1", "target-a"); + let outstanding = owner + .page_residences + .begin_target_registration(&mut owner.target_runtimes, page_owner.clone()) + .expect("fixture should hold a staged Page residence"); + + let error = owner + .register_background_target("context-1", "target-a", topology("context-1", None)) + .expect_err("outstanding Page stage must reject duplicate registration"); + + assert_eq!( + error, + super::super::BrowserTargetRegistryError::PageResidence( + BrowserPageResidenceRegistryError::DuplicateTarget(page_owner.clone()) + ) + ); + assert!(!owner.has_target("target-a")); + assert_eq!(owner.target_count(), 0); + assert!( + owner + .capture_page_residence("context-1", "target-a") + .is_none() + ); + assert!( + owner + .page_residences + .rollback_target_registration(&mut owner.target_runtimes, outstanding) + ); +} + +#[test] +fn outstanding_page_stage_rejects_replacement_without_retiring_source_target() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let source_page = register_target(&mut owner, "context-1", "target-a"); + let source_target = owner + .target_handle("target-a") + .expect("source Target handle"); + let source_owner = BrowserPageOwnerKey::new("context-1", "target-a"); + let replacement_owner = BrowserPageOwnerKey::new("context-1", "target-b"); + let renderer_owner = owner.active_renderer_owner_id_for_diagnostics(); + let outstanding = owner + .page_residences + .begin_target_registration(&mut owner.target_runtimes, replacement_owner.clone()) + .expect("fixture should hold a staged replacement Page residence"); + + let error = owner + .replace_active_target( + "context-1", + "target-a", + "target-b", + topology( + "context-1", + Some(BrowserTargetSlotProjection::new( + source_target.clone(), + source_page.clone(), + )), + ), + BrowserContextSelectionProjection::new( + Some("context-1".to_owned()), + BrowserSelectedTargetEngineDisposition::Discard(source_owner.clone()), + ), + NavigationEngine::new, + ) + .expect_err("outstanding Page stage must reject active replacement"); + + assert_eq!( + error, + super::super::BrowserTargetRegistryError::PageResidence( + BrowserPageResidenceRegistryError::DuplicateTarget(replacement_owner.clone()) + ) + ); + assert!(source_target.is_live()); + assert!(!source_target.is_retired()); + assert_eq!( + owner.active_target_id_for_browser_context("context-1"), + Some("target-a") + ); + assert!(owner.has_target("target-a")); + assert!(!owner.has_target("target-b")); + assert_eq!(owner.target_count(), 1); + assert!(owner.page_residence_handle_is_current(&source_owner, &source_page)); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + renderer_owner + ); + assert!( + owner + .page_residences + .rollback_target_registration(&mut owner.target_runtimes, outstanding) + ); +} + +#[test] +fn duplicate_page_capability_rolls_back_joint_context_target_registration() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let first_page = register_target(&mut owner, "context-a", "target-a"); + let first_target = owner + .target_handle("target-a") + .expect("first context Target handle"); + let first_owner = BrowserPageOwnerKey::new("context-a", "target-a"); + let candidate_target = BrowserTargetHandle::staged("target-b"); + let candidate_owner = BrowserPageOwnerKey::new("context-b", "target-b"); + + let error = owner + .register_browser_context( + "context-b", + BrowserTargetTopologyProjection::new( + "context-b", + Some(BrowserTargetSlotProjection::new( + candidate_target.clone(), + first_page.clone(), + )), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + Some("context-a".to_owned()), + BrowserSelectedTargetEngineDisposition::Discard(first_owner.clone()), + ), + NavigationEngine::new, + ) + .expect_err("one Page capability cannot be registered into a second context"); + + assert_eq!( + error, + super::super::BrowserContextRegistryError::PageResidence( + BrowserPageResidenceRegistryError::DuplicateProjectedHandle { + first: first_owner.clone(), + duplicate: candidate_owner, + } + ) + ); + assert!(candidate_target.is_staged()); + assert!(!candidate_target.is_live()); + assert!(first_target.is_live()); + assert_eq!(owner.browser_context_count(), 1); + assert_eq!(owner.target_count(), 1); + assert!(!owner.has_browser_context("context-b")); + assert!(!owner.has_target("target-b")); + assert!(owner.page_residence_handle_is_current(&first_owner, &first_page)); +} + +#[test] +fn registered_page_capability_resolves_only_its_current_generation() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let page_residence = register_target(&mut owner, "context-1", "target-1"); + let original = owner + .capture_page_residence("context-1", "target-1") + .expect("registered Target should expose its Page residence"); + + let key = owner + .page_owner_key_if_current(&original) + .expect("captured residence should resolve"); + assert_eq!(key.browser_context_id(), "context-1"); + assert_eq!(key.target_id(), "target-1"); + + page_residence.advance_generation_for_test_fixture(); + assert!(owner.page_owner_key_if_current(&original).is_none()); + let successor = owner + .capture_page_residence("context-1", "target-1") + .expect("the Target keeps the same Page-slot capability"); + assert_eq!(successor.loaded_page_generation(), 1); + assert_eq!(owner.page_owner_key_if_current(&successor), Some(key)); +} + +#[test] +fn stable_slot_resolution_crosses_generation_but_rejects_same_id_recreation() { + let mut registry = BrowserPageResidenceRegistry; + let mut runtimes = BrowserTargetRuntimeRegistry::default(); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let registration = registry + .begin_target_registration(&mut runtimes, key.clone()) + .expect("fresh Page slot should stage"); + let first_handle = registration.handle().clone(); + registry.commit_target_registration(&mut runtimes, registration); + let original = registry + .identity(&runtimes, &key) + .expect("live Page slot identity"); + + first_handle.advance_generation_for_test_fixture(); + assert!(registry.resolve(&runtimes, &original).is_none()); + assert_eq!( + registry.resolve_slot(&runtimes, &original), + Some(key.clone()) + ); + + registry.forget_target(&mut runtimes, &key); + let replacement = registry + .begin_target_registration(&mut runtimes, key.clone()) + .expect("same public Target id should obtain a new Page slot"); + registry.commit_target_registration(&mut runtimes, replacement); + assert!(registry.resolve_slot(&runtimes, &original).is_none()); +} + +#[test] +fn dropping_protocol_clone_does_not_drop_core_page_authority() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let identity = { + let _protocol_clone = register_target(&mut owner, "context-1", "target-1"); + owner + .capture_page_residence("context-1", "target-1") + .expect("registered Target should expose its Page residence") + }; + + assert!(owner.page_owner_key_if_current(&identity).is_some()); +} + +#[test] +fn arbitrary_same_target_page_handle_is_not_authoritative() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let registered = register_target(&mut owner, "context-1", "target-1"); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let other = BrowserPageResidenceHandle::default(); + + assert!(owner.page_residence_handle_is_current(&key, ®istered)); + assert!(!owner.page_residence_handle_is_current(&key, &other)); +} + +#[test] +fn page_runtime_discard_retains_registered_slot_capability() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let page_residence = register_target(&mut owner, "context-1", "target-1"); + page_residence.advance_generation_for_test_fixture(); + + owner.discard_target_page_runtime("target-1"); + + let identity = owner + .capture_page_residence("context-1", "target-1") + .expect("failed navigation must not unregister the live Target Page slot"); + assert_eq!(identity.loaded_page_generation(), 1); +} + +#[test] +fn target_termination_invalidates_registration_before_slot_drop() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let handle = register_target(&mut owner, "context-1", "target-1"); + let identity = owner + .capture_page_residence("context-1", "target-1") + .expect("registered Target should expose its Page residence"); + + owner.forget_target("target-1"); + + assert!(owner.page_owner_key_if_current(&identity).is_none()); + assert_eq!(handle.generation(), identity.loaded_page_generation()); +} diff --git a/moli-core/src/browser_host/navigation_owner/page_registry/transaction.rs b/moli-core/src/browser_host/navigation_owner/page_registry/transaction.rs new file mode 100644 index 0000000000..227db1468f --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/page_registry/transaction.rs @@ -0,0 +1,185 @@ +use super::*; + +#[derive(Debug)] +struct BrowserPageResidenceRegistration { + owner: BrowserPageOwnerKey, + handle: BrowserPageResidenceHandle, +} + +/// Exact Page-side staging for an initial BrowserContext topology. +/// +/// Entries are reserved in the registry but remain invisible to live Page +/// lookups while staged. The Browser Owner publishes them immediately before +/// publishing the matching Target lifecycle in the same synchronous turn. +#[derive(Debug)] +pub(in crate::browser_host::navigation_owner) struct BrowserPageContextRegistrationTransaction { + registrations: Vec, +} + +/// Exact Page-side staging for one new Target. +#[derive(Debug)] +pub(in crate::browser_host::navigation_owner) struct BrowserPageTargetRegistrationTransaction { + registration: BrowserPageResidenceRegistration, +} + +impl BrowserPageTargetRegistrationTransaction { + #[cfg(test)] + pub(super) fn handle(&self) -> &BrowserPageResidenceHandle { + &self.registration.handle + } +} + +impl BrowserPageResidenceRegistry { + pub(in crate::browser_host::navigation_owner) fn begin_context_registration( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + browser_context_id: &BrowserContextId, + projection: &BrowserTargetTopologyProjection, + ) -> Result { + self.validate_context_registration(runtimes, browser_context_id, projection)?; + let registrations = projection + .slots() + .map(|slot| BrowserPageResidenceRegistration { + owner: BrowserPageOwnerKey::new(browser_context_id.as_str(), slot.target_id()), + handle: slot.page_residence_handle().clone(), + }) + .collect::>(); + + for (index, registration) in registrations.iter().enumerate() { + let runtime = runtimes + .entries + .entry(registration.owner.clone()) + .or_default(); + if runtime.page_residence.is_some() { + let mut exact = true; + for staged in registrations[..index].iter().rev() { + exact &= self.remove_staged_exact(runtimes, staged); + } + debug_assert!( + exact, + "same-turn Page context registration rejection must remove every staged entry" + ); + return Err(BrowserPageResidenceRegistryError::DuplicateTarget( + registration.owner.clone(), + )); + } + runtime.page_residence = Some(BrowserPageResidenceRecord::staged( + registration.handle.clone(), + )); + } + + Ok(BrowserPageContextRegistrationTransaction { registrations }) + } + + pub(in crate::browser_host::navigation_owner) fn rollback_context_registration( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + transaction: BrowserPageContextRegistrationTransaction, + ) -> bool { + let mut exact = true; + for registration in transaction.registrations.iter().rev() { + exact &= self.remove_staged_exact(runtimes, registration); + } + debug_assert!( + exact, + "same-turn BrowserContext Page registration rollback must remove every exact staged entry" + ); + exact + } + + pub(in crate::browser_host::navigation_owner) fn commit_context_registration( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + transaction: BrowserPageContextRegistrationTransaction, + ) { + let exact = transaction + .registrations + .iter() + .all(|registration| self.is_staged_exact(runtimes, registration)); + if exact { + for registration in &transaction.registrations { + let published = self + .record_mut(runtimes, ®istration.owner) + .is_some_and(|record| record.publish_if_staged_exact(®istration.handle)); + debug_assert!(published, "validated Page registration must publish"); + } + } + debug_assert!( + exact, + "only the Browser Owner transaction that staged every Page residence may publish it" + ); + } + + pub(in crate::browser_host::navigation_owner) fn begin_target_registration( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: BrowserPageOwnerKey, + ) -> Result { + let handle = BrowserPageResidenceHandle::default(); + let runtime = runtimes.entries.entry(owner.clone()).or_default(); + if runtime.page_residence.is_some() { + return Err(BrowserPageResidenceRegistryError::DuplicateTarget(owner)); + } + runtime.page_residence = Some(BrowserPageResidenceRecord::staged(handle.clone())); + Ok(BrowserPageTargetRegistrationTransaction { + registration: BrowserPageResidenceRegistration { owner, handle }, + }) + } + + pub(in crate::browser_host::navigation_owner) fn rollback_target_registration( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + transaction: BrowserPageTargetRegistrationTransaction, + ) -> bool { + let exact = self.remove_staged_exact(runtimes, &transaction.registration); + debug_assert!( + exact, + "same-turn Target Page registration rollback must remove its exact staged entry" + ); + exact + } + + pub(in crate::browser_host::navigation_owner) fn commit_target_registration( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + transaction: BrowserPageTargetRegistrationTransaction, + ) -> BrowserPageResidenceHandle { + let registration = transaction.registration; + let published = self + .record_mut(runtimes, ®istration.owner) + .is_some_and(|record| record.publish_if_staged_exact(®istration.handle)); + debug_assert!( + published, + "only the Browser Owner transaction that staged a Page residence may publish it" + ); + registration.handle + } + + fn is_staged_exact( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + registration: &BrowserPageResidenceRegistration, + ) -> bool { + self.record(runtimes, ®istration.owner) + .is_some_and(|record| record.is_staged_exact(®istration.handle)) + } + + fn remove_staged_exact( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + registration: &BrowserPageResidenceRegistration, + ) -> bool { + let exact = self + .record(runtimes, ®istration.owner) + .is_some_and(|record| record.is_staged_exact(®istration.handle)); + if exact { + let removed = runtimes + .entries + .get_mut(®istration.owner) + .and_then(|runtime| runtime.page_residence.take()); + debug_assert!(removed.is_some()); + runtimes.prune_empty(); + } + exact + } +} diff --git a/moli-core/src/browser_host/navigation_owner/page_replacement.rs b/moli-core/src/browser_host/navigation_owner/page_replacement.rs new file mode 100644 index 0000000000..5ccbc07446 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/page_replacement.rs @@ -0,0 +1,655 @@ +use crate::browser_host::{ + BrowserPageRuntimeAccess, BrowserPageRuntimeOwner, PageResidenceIdentity, +}; +use crate::page::RendererPageLifetimeOwner; + +use super::{ + BrowserDocumentNavigation, BrowserNavigationHistoryPageSnapshot, BrowserNavigationOwner, + BrowserNavigationTraceEvent, BrowserPageOwnerKey, BrowserPageResidenceRegistryError, + BrowserTargetMetadataTransition, +}; + +/// Exact Browser Core authorization for one loaded cross-document Page swap. +/// +/// Preparing the permit does not mutate request, history, or Page generation, +/// so protocol-specific renderer attachment preparation may still fail safely. +/// The permit contains no frontend/session identity and can be committed only +/// by the same Browser Owner while its request and Page residence remain exact. +#[derive(Debug)] +pub struct BrowserPageReplacementPermit { + owner: BrowserPageOwnerKey, + navigation: BrowserDocumentNavigation, + previous_page: PageResidenceIdentity, +} + +impl BrowserPageReplacementPermit { + pub fn owner(&self) -> &BrowserPageOwnerKey { + &self.owner + } + + pub fn navigation(&self) -> &BrowserDocumentNavigation { + &self.navigation + } + + pub fn previous_page(&self) -> &PageResidenceIdentity { + &self.previous_page + } +} + +/// Browser-owned result of atomically committing a successor Page residence. +#[derive(Debug)] +pub struct BrowserPageReplacement { + owner: BrowserPageOwnerKey, + navigation: BrowserDocumentNavigation, + previous_page: PageResidenceIdentity, + current_page: PageResidenceIdentity, + retired_renderer_page_owner: Option, + current_page_runtime: Option, +} + +impl BrowserPageReplacement { + pub fn owner(&self) -> &BrowserPageOwnerKey { + &self.owner + } + + pub fn navigation(&self) -> &BrowserDocumentNavigation { + &self.navigation + } + + pub fn previous_page(&self) -> &PageResidenceIdentity { + &self.previous_page + } + + pub fn current_page(&self) -> &PageResidenceIdentity { + &self.current_page + } + + /// Takes the exact renderer Page lifetime retired by this replacement. + pub fn take_retired_renderer_page_owner(&mut self) -> Option { + self.retired_renderer_page_owner.take() + } + + pub fn current_page_runtime(&self) -> Option<&BrowserPageRuntimeAccess> { + self.current_page_runtime.as_ref() + } +} + +/// Exact reason why a previously prepared Page replacement no longer commits. +/// +/// A permit is intentionally revalidated at the owner turn that consumes it. +/// Stale request, recovery, or Page generation state is an ordinary rejected +/// owner action rather than a process-fatal invariant. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserPageReplacementCommitError { + NavigationNoLongerPending { + owner: BrowserPageOwnerKey, + navigation: BrowserDocumentNavigation, + }, + TargetNoLongerAcceptsReplacement { + owner: BrowserPageOwnerKey, + navigation: BrowserDocumentNavigation, + }, + RendererPageOwnerMissing { + owner: BrowserPageOwnerKey, + navigation: BrowserDocumentNavigation, + }, + PageResidence(BrowserPageResidenceRegistryError), +} + +impl std::fmt::Display for BrowserPageReplacementCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NavigationNoLongerPending { owner, navigation } => write!( + formatter, + "navigation {:?} is no longer pending for Target {:?} in BrowserContext {:?}", + navigation, + owner.target_id(), + owner.browser_context_id() + ), + Self::TargetNoLongerAcceptsReplacement { owner, navigation } => write!( + formatter, + "Target {:?} in BrowserContext {:?} no longer accepts replacement from navigation {:?}", + owner.target_id(), + owner.browser_context_id(), + navigation + ), + Self::RendererPageOwnerMissing { owner, navigation } => write!( + formatter, + "replacement navigation {:?} for Target {:?} in BrowserContext {:?} has no renderer Page lifetime owner", + navigation, + owner.target_id(), + owner.browser_context_id() + ), + Self::PageResidence(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for BrowserPageReplacementCommitError {} + +impl From for BrowserPageReplacementCommitError { + fn from(error: BrowserPageResidenceRegistryError) -> Self { + Self::PageResidence(error) + } +} + +impl BrowserNavigationOwner { + pub fn prepare_loaded_page_replacement( + &self, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> Option { + if !self.browser_context_accepts_owner_work(owner.browser_context_id()) + || !self.target_terminations.accepts_page_replacement( + &self.target_runtimes, + owner, + navigation, + ) + || owner.target_id() != navigation.target_id() + || !self + .document_navigations + .accepts_pending(&self.target_runtimes, owner, navigation) + { + return None; + } + let previous_page = self + .page_residences + .prepare_replacement(&self.target_runtimes, owner)?; + Some(BrowserPageReplacementPermit { + owner: owner.clone(), + navigation: navigation.clone(), + previous_page, + }) + } + + /// Commits request identity, Page generation, and joint history in one + /// Browser Owner mutation. The exact request, recovery state, and Page + /// generation are revalidated so a permit may safely cross an actor turn. + fn commit_loaded_page_replacement_inner( + &mut self, + permit: BrowserPageReplacementPermit, + history_page: BrowserNavigationHistoryPageSnapshot, + successor_renderer_page_owner: &mut Option, + successor_page_runtime_owner: &mut Option, + ) -> Result { + if !self.document_navigations.accepts_pending( + &self.target_runtimes, + &permit.owner, + &permit.navigation, + ) { + return Err( + BrowserPageReplacementCommitError::NavigationNoLongerPending { + owner: permit.owner, + navigation: permit.navigation, + }, + ); + } + if !self.browser_context_accepts_owner_work(permit.owner.browser_context_id()) + || !self.target_terminations.accepts_page_replacement( + &self.target_runtimes, + &permit.owner, + &permit.navigation, + ) + { + return Err( + BrowserPageReplacementCommitError::TargetNoLongerAcceptsReplacement { + owner: permit.owner, + navigation: permit.navigation, + }, + ); + } + let Some(request_rollback) = self.document_navigations.commit_with_rollback_if_matches( + &mut self.target_runtimes, + &permit.owner, + &permit.navigation, + ) else { + return Err( + BrowserPageReplacementCommitError::NavigationNoLongerPending { + owner: permit.owner, + navigation: permit.navigation, + }, + ); + }; + let (current_page, retired_renderer_page_owner, current_page_runtime) = + match self.page_residences.commit_transition_with_page_owners( + &mut self.target_runtimes, + &permit.owner, + &permit.previous_page, + successor_renderer_page_owner, + successor_page_runtime_owner, + ) { + Ok(committed) => committed, + Err(error) => { + let rolled_back = self.document_navigations.rollback_commit( + &mut self.target_runtimes, + &permit.owner, + &permit.navigation, + request_rollback, + ); + debug_assert!( + rolled_back, + "same-turn stale Page generation must restore the pending request" + ); + return Err(error.into()); + } + }; + let metadata_transition = BrowserTargetMetadataTransition::navigation_committed( + permit.navigation.clone(), + history_page.url().to_owned(), + history_page.title().to_owned(), + ); + let history_seed = self + .initial_empty_documents + .history_seed(&self.target_runtimes, &permit.owner); + self.navigation_histories.record_loaded_page( + &mut self.target_runtimes, + &permit.owner, + history_seed, + history_page, + ); + self.target_terminations.commit_navigation( + &mut self.target_runtimes, + &permit.owner, + &permit.navigation, + ); + self.initial_empty_documents + .mark_exited(&mut self.target_runtimes, &permit.owner); + if let Some(trace) = self.document_navigations.trace_context( + &self.target_runtimes, + &permit.owner, + &permit.navigation, + ) { + trace.emit( + BrowserNavigationTraceEvent::new( + "page_replacement_committed", + trace.origin(), + "request-pending", + "page-resident", + ) + .with_navigation(&permit.navigation) + .with_page(current_page.clone()), + ); + } + let replacement = BrowserPageReplacement { + owner: permit.owner, + navigation: permit.navigation, + previous_page: permit.previous_page, + current_page, + retired_renderer_page_owner, + current_page_runtime, + }; + if let Err(error) = self.record_loaded_navigation_commit_facts( + replacement.owner(), + replacement.navigation(), + replacement.previous_page(), + replacement.current_page(), + metadata_transition, + ) { + tracing::error!( + %error, + browser_context_id = replacement.owner().browser_context_id(), + target_id = replacement.owner().target_id(), + navigation_request_id = replacement.navigation().request_id().get(), + previous_page_generation = replacement.previous_page().loaded_page_generation(), + current_page_generation = replacement.current_page().loaded_page_generation(), + "failed to publish loaded navigation commit Browser facts" + ); + } + Ok(replacement) + } + + /// Commits a loaded Document replacement and transfers the successor's + /// unique renderer Page lifetime to Browser Host atomically with Page + /// generation, request, history, and facts. + pub fn commit_loaded_page_replacement( + &mut self, + permit: BrowserPageReplacementPermit, + history_page: BrowserNavigationHistoryPageSnapshot, + renderer_page_owner: &mut Option, + page_runtime_owner: &mut Option, + ) -> Result { + if renderer_page_owner.is_none() || page_runtime_owner.is_none() { + return Err( + BrowserPageReplacementCommitError::RendererPageOwnerMissing { + owner: permit.owner, + navigation: permit.navigation, + }, + ); + } + self.commit_loaded_page_replacement_inner( + permit, + history_page, + renderer_page_owner, + page_runtime_owner, + ) + } + + /// Authority-only helper for registry/fact tests that intentionally have + /// no physical renderer Page payload. + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn commit_loaded_page_replacement_without_renderer_owner_for_testing( + &mut self, + permit: BrowserPageReplacementPermit, + history_page: BrowserNavigationHistoryPageSnapshot, + ) -> Result { + let mut no_successor = None; + let mut no_runtime_successor = None; + self.commit_loaded_page_replacement_inner( + permit, + history_page, + &mut no_successor, + &mut no_runtime_successor, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + PageId, + browser_host::{ + BrowserContextSelectionProjection, BrowserFact, BrowserInitialEmptyDocumentSeed, + BrowserPageResidenceHandle, BrowserSelectedTargetEngineDisposition, + BrowserTargetHandle, BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }, + page::{ + RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, + RendererDocumentLifecycleMilestone, RendererDocumentToken, RendererFrameToken, + RendererLifecycleEpoch, + }, + runtime::NavigationEngine, + }; + + use super::*; + + fn page(url: &str) -> BrowserNavigationHistoryPageSnapshot { + BrowserNavigationHistoryPageSnapshot::new(url, "title") + } + + fn dcl_event() -> RendererDocumentLifecycleEvent { + let page_id = PageId::new_for_testing(17); + RendererDocumentLifecycleEvent { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, 2), + epoch: RendererLifecycleEpoch(1), + sequence: 7, + timestamp_micros: 70, + kind: RendererDocumentLifecycleEventKind::Milestone( + RendererDocumentLifecycleMilestone::DomContentLoaded, + ), + } + } + + fn register_target( + owner: &mut BrowserNavigationOwner, + key: &BrowserPageOwnerKey, + ) -> BrowserPageResidenceHandle { + let page_residence = BrowserPageResidenceHandle::default(); + owner + .register_browser_context( + key.browser_context_id(), + BrowserTargetTopologyProjection::new( + key.browser_context_id(), + Some(BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged(key.target_id()), + page_residence.clone(), + )), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("test Target topology should register"); + page_residence + } + + #[test] + fn owner_commit_advances_page_and_commits_request_and_history_together() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_target(&mut owner, &key); + let navigation = owner.start_document_navigation(&key, "loader-1".to_owned()); + let mut subscriber = owner.subscribe_browser_facts(); + let permit = owner + .prepare_loaded_page_replacement(&key, &navigation) + .expect("current request should prepare replacement"); + let previous = permit.previous_page().clone(); + + let replacement = owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + page("https://example.test/next"), + ) + .expect("exact replacement should commit"); + + assert_eq!(replacement.previous_page(), &previous); + assert_eq!(replacement.current_page().loaded_page_generation(), 1); + assert_eq!(page_residence.generation(), 1); + assert!(owner.page_owner_key_if_current(&previous).is_none()); + assert_eq!( + owner.page_owner_key_if_current(replacement.current_page()), + Some(key.clone()) + ); + assert!(owner.accepts_committed_document_navigation(&key, &navigation)); + assert!(!owner.has_pending_document_navigation(&key)); + let (index, history) = owner.navigation_history_snapshot(&key, None); + assert_eq!(index, 0); + assert_eq!(history.len(), 1); + assert_eq!(history[0].url, "https://example.test/next"); + + let created_fact = subscriber + .try_recv() + .expect("Target registration should publish its creation occurrence"); + assert_eq!(created_fact.sequence().get(), 1); + assert_eq!(created_fact.page_residence(), &previous); + assert!(matches!(created_fact.fact(), BrowserFact::TargetCreated)); + let accepted_fact = subscriber + .try_recv() + .expect("live Browser subscriber should receive navigation acceptance"); + assert_eq!(accepted_fact.sequence().get(), 2); + assert_eq!(accepted_fact.page_residence(), &previous); + assert_eq!( + accepted_fact.fact(), + &BrowserFact::NavigationAccepted { + navigation: navigation.clone(), + superseded_navigation: None, + } + ); + let committed_fact = subscriber + .try_recv() + .expect("live Browser subscriber should receive navigation commit"); + assert_eq!(committed_fact.sequence().get(), 3); + assert_eq!(committed_fact.browser_context_id().as_str(), "context-1"); + assert_eq!(committed_fact.target_id().as_str(), "target-1"); + assert_eq!(committed_fact.page_residence(), replacement.current_page()); + assert_eq!( + committed_fact.fact(), + &BrowserFact::NavigationCommitted { + navigation: navigation.clone(), + previous_page: previous.clone(), + } + ); + let metadata_fact = subscriber + .try_recv() + .expect("live Browser subscriber should receive Target metadata transition"); + assert_eq!(metadata_fact.sequence().get(), 4); + assert_eq!(metadata_fact.page_residence(), replacement.current_page()); + assert_eq!( + metadata_fact.fact(), + &BrowserFact::TargetMetadataChanged { + transition: BrowserTargetMetadataTransition::navigation_committed( + navigation.clone(), + "https://example.test/next".to_owned(), + "title".to_owned(), + ), + } + ); + + owner + .record_document_lifecycle_facts(replacement.current_page(), &[dcl_event()]) + .expect("successor lifecycle fact should publish"); + let facts = owner.browser_fact_snapshot(); + assert_eq!(facts.len(), 5); + assert_eq!(facts[0].as_ref(), created_fact.as_ref()); + assert_eq!(facts[1].as_ref(), accepted_fact.as_ref()); + assert_eq!(facts[2].as_ref(), committed_fact.as_ref()); + assert_eq!(facts[3].as_ref(), metadata_fact.as_ref()); + assert_eq!(facts[4].sequence().get(), 5); + assert_eq!(facts[4].page_residence(), replacement.current_page()); + assert!(matches!( + facts[4].fact(), + BrowserFact::DocumentLifecycleReached { + milestone: RendererDocumentLifecycleMilestone::DomContentLoaded, + .. + } + )); + } + + #[test] + fn superseded_request_cannot_prepare_or_advance_page_replacement() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_target(&mut owner, &key); + let stale = owner.start_document_navigation(&key, "loader-1".to_owned()); + let _current = owner.start_document_navigation(&key, "loader-2".to_owned()); + + assert!( + owner + .prepare_loaded_page_replacement(&key, &stale) + .is_none() + ); + assert_eq!(page_residence.generation(), 0); + } + + #[test] + fn prepared_replacement_returns_typed_stale_after_request_is_superseded() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_target(&mut owner, &key); + let stale = owner.start_document_navigation(&key, "loader-1".to_owned()); + let permit = owner + .prepare_loaded_page_replacement(&key, &stale) + .expect("current request should prepare replacement"); + let current = owner.start_document_navigation(&key, "loader-2".to_owned()); + + let error = owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + page("https://example.test/stale"), + ) + .expect_err("a superseded permit must be rejected without panicking"); + + assert_eq!( + error, + BrowserPageReplacementCommitError::NavigationNoLongerPending { + owner: key.clone(), + navigation: stale, + } + ); + assert_eq!(page_residence.generation(), 0); + assert!(owner.accepts_pending_document_navigation(&key, ¤t)); + let (index, history) = owner.navigation_history_snapshot(&key, None); + assert_eq!(index, 0); + assert!(history.is_empty()); + assert!( + owner.browser_fact_snapshot().iter().all(|fact| !matches!( + fact.fact(), + BrowserFact::NavigationCommitted { .. } | BrowserFact::TargetMetadataChanged { .. } + )), + "a stale replacement permit must not publish commit facts" + ); + } + + #[test] + fn stale_page_generation_rolls_back_precommitted_navigation_request() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_target(&mut owner, &key); + let navigation = owner.start_document_navigation(&key, "loader-1".to_owned()); + let permit = owner + .prepare_loaded_page_replacement(&key, &navigation) + .expect("current request should prepare replacement"); + page_residence.advance_generation_for_test_fixture(); + + let error = owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + page("https://example.test/stale"), + ) + .expect_err("a stale Page generation must reject owner commit"); + + assert_eq!( + error, + BrowserPageReplacementCommitError::PageResidence( + BrowserPageResidenceRegistryError::StaleTransition { + owner: key.clone(), + expected_generation: 0, + current_generation: 1, + } + ) + ); + assert!(owner.accepts_pending_document_navigation(&key, &navigation)); + assert!(!owner.accepts_committed_document_navigation(&key, &navigation)); + let (_, history) = owner.navigation_history_snapshot(&key, None); + assert!(history.is_empty()); + } + + #[test] + fn replacement_uses_core_registered_capability_without_frontend_handle() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let current_slot = register_target(&mut owner, &key); + let other_slot = BrowserPageResidenceHandle::default(); + let navigation = owner.start_document_navigation(&key, "loader-1".to_owned()); + + assert!(!owner.page_residence_handle_is_current(&key, &other_slot)); + let permit = owner + .prepare_loaded_page_replacement(&key, &navigation) + .expect("Core must resolve its registered Page capability"); + owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + page("https://example.test/next"), + ) + .expect("exact replacement should commit"); + assert_eq!(current_slot.generation(), 1); + assert_eq!(other_slot.generation(), 0); + } + + #[test] + fn replacement_derives_initial_history_seed_and_exit_inside_owner_commit() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + register_target(&mut owner, &key); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank#created"), + ) + .expect("live Target should accept initial metadata"); + let navigation = owner.start_document_navigation(&key, "loader-1".to_owned()); + let permit = owner + .prepare_loaded_page_replacement(&key, &navigation) + .expect("exact navigation should prepare replacement"); + + owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + page("https://example.test/next"), + ) + .expect("exact replacement should commit"); + + let initial = owner + .target_initial_empty_document(&key) + .expect("creation metadata remains after replacement"); + assert!(initial.exited()); + let (_, history) = owner.navigation_history_snapshot(&key, None); + assert_eq!(history.len(), 2); + assert_eq!(history[0].url, "about:blank#created"); + assert_eq!(history[1].url, "https://example.test/next"); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/page_runtime.rs b/moli-core/src/browser_host/navigation_owner/page_runtime.rs new file mode 100644 index 0000000000..b44a3d6369 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/page_runtime.rs @@ -0,0 +1,245 @@ +use anyhow::Result; +use moli_fetch::{BrowserNavigationRequestKind, FetchConfig, NetworkFetchResult}; +use url::Url; + +use crate::{ + OptionalResourceFetchMask, + network::ResourceRequestClient, + page::{ + CompletedPageCommand, NavigationResponse, Page, PendingPageCommand, + SubresourceAuthCredentials, + }, + runtime::{ + NavigationEngine, NavigationResourceStorageHandles, NavigationRuntimeConfig, + RendererBrowserContextRuntime, RendererBrowserContextRuntimeOwnerAccess, + RendererPageReservationToken, + }, +}; + +use super::{BrowserNavigationOwner, BrowserPageFetchConfiguration}; + +impl BrowserNavigationOwner { + pub fn active_fetch_config(&self) -> &FetchConfig { + self.active_engine().fetch_config() + } + + pub fn active_runtime_config(&self) -> NavigationRuntimeConfig { + self.active_engine().runtime_config() + } + + pub fn active_optional_resource_fetch_mask(&self) -> OptionalResourceFetchMask { + self.active_engine().optional_resource_fetch_mask() + } + + pub fn active_subframe_loading_enabled(&self) -> bool { + self.active_engine().subframe_loading_enabled() + } + + pub fn active_image_fetch_enabled(&self) -> bool { + self.active_engine().image_fetch_enabled() + } + + pub fn active_browser_context_runtime(&self) -> RendererBrowserContextRuntime { + self.active_engine().browser_context_runtime() + } + + pub fn active_browser_context_owner_access(&self) -> RendererBrowserContextRuntimeOwnerAccess { + self.active_engine().browser_context_owner_access() + } + + /// Clones the selected engine into move-owned async work. + /// + /// The clone shares the renderer owner but does not borrow Browser Host + /// state while network or renderer work is pending. A caller that intends + /// engine-local configuration changes to become authoritative must hand it + /// back through an exact Core adoption boundary; an operation-only clone + /// may be dropped while the selected engine keeps the renderer alive. + pub fn clone_active_navigation_engine(&self) -> NavigationEngine { + self.active_engine().clone() + } + + pub fn active_runtime_shares_state_with( + &self, + runtime: &RendererBrowserContextRuntimeOwnerAccess, + ) -> bool { + self.active_engine() + .browser_context_runtime() + .shares_state_with(&runtime.runtime()) + } + + pub fn new_engine_sharing_active_renderer_owner( + &self, + fetch_config: FetchConfig, + ) -> Result { + let mut runtime_config = self.active_runtime_config(); + *runtime_config.fetch_config_mut() = fetch_config; + NavigationEngine::new_with_runtime_config_and_shared_renderer_owner( + runtime_config, + self.active_engine(), + ) + } + + pub fn new_engine_for_browser_context_runtime( + &self, + fetch_config: FetchConfig, + runtime: RendererBrowserContextRuntimeOwnerAccess, + ) -> Result { + let mut runtime_config = self.active_runtime_config(); + *runtime_config.fetch_config_mut() = fetch_config; + NavigationEngine::new_with_runtime_config_and_browser_context_access( + runtime_config, + runtime, + ) + } + + pub fn detached_engine_shares_active_renderer_owner(&self, engine: &NavigationEngine) -> bool { + engine.shares_renderer_owner_with(self.active_engine()) + } + + pub fn configure_active_fetch(&mut self, configuration: BrowserPageFetchConfiguration) { + let engine = self.active_engine_mut(); + engine.set_browser_identity_override(configuration.browser_identity); + engine.set_http_proxy_override(configuration.http_proxy); + engine.set_http_no_proxy_override(configuration.http_no_proxy); + engine.set_tls_verify_host(configuration.tls_verify_host); + engine.set_bypass_service_worker(configuration.bypass_service_worker); + } + + pub fn set_active_bypass_service_worker(&mut self, bypass: bool) { + self.active_engine_mut().set_bypass_service_worker(bypass); + } + + pub fn active_resource_request_client(&self) -> Option { + self.active_engine().resource_request_client() + } + + pub fn ensure_active_resource_runtime_ready( + &mut self, + storage: NavigationResourceStorageHandles, + ) -> Result<()> { + self.active_engine_mut() + .ensure_resource_runtime_ready_for_navigation_storage(storage) + } + + pub fn rebuild_active_resource_request_client( + &mut self, + storage: NavigationResourceStorageHandles, + ) -> Result { + self.active_engine_mut() + .rebuild_resource_request_client_for_navigation_storage(storage) + } + + pub fn ensure_active_cookie_store( + &mut self, + storage: NavigationResourceStorageHandles, + ) -> Result { + self.active_engine_mut() + .ensure_cookie_store_for_navigation_storage(storage) + } + + pub async fn reset_active_resource_runtime(&mut self, page: Option<&mut Page>) { + self.active_engine_mut() + .reset_resource_runtime_async(page) + .await; + } + + pub fn reset_active_resource_runtime_without_loaded_page(&mut self) { + self.active_engine_mut() + .reset_resource_runtime_without_loaded_page(); + } + + pub async fn rebuild_active_resource_runtime_for_page( + &mut self, + storage: NavigationResourceStorageHandles, + page: Option<&mut Page>, + ) -> Result<()> { + self.active_engine_mut() + .rebuild_resource_runtime_for_page_with_storage_async(storage, page) + .await + } + + pub fn reserve_active_page_for_creation(&self) -> RendererPageReservationToken { + self.active_engine().reserve_page_for_creation() + } + + pub fn start_active_page_child_frame_lifecycle_work( + &mut self, + storage: NavigationResourceStorageHandles, + page: &Page, + timeout: std::time::Duration, + ) -> Result { + self.active_engine_mut() + .start_page_child_frame_lifecycle_work_with_storage_best_effort(storage, page, timeout) + } + + pub fn complete_active_page_child_frame_lifecycle_work( + &mut self, + page: &mut Page, + completion: CompletedPageCommand, + ) -> Result<(bool, crate::page::RendererCommandTurnOutput)> { + self.active_engine_mut() + .complete_page_child_frame_lifecycle_work_best_effort(page, completion) + } + + pub fn ensure_active_navigation_response_status( + &self, + raw_url: &str, + status: u16, + allow_auth_challenge: bool, + ) -> Result<()> { + self.active_engine().ensure_navigation_response_status( + raw_url, + status, + allow_auth_challenge, + ) + } + + #[allow(clippy::too_many_arguments)] + pub async fn fetch_active_navigation_response( + &mut self, + storage: NavigationResourceStorageHandles, + initiator_url: Option<&Url>, + browser_navigation_kind: BrowserNavigationRequestKind, + infer_referrer_from_initiator: bool, + method: &str, + raw_url: &str, + body: Option, + request_headers: Vec<(String, String)>, + auth: Option, + ) -> Result> { + self.active_engine_mut() + .fetch_navigation_response_with_storage_async( + storage, + initiator_url, + browser_navigation_kind, + infer_referrer_from_initiator, + method, + raw_url, + body, + request_headers, + auth, + ) + .await + } + + pub fn active_renderer_owner_id_for_diagnostics(&self) -> u64 { + self.active_engine().renderer_owner_id_for_diagnostics() + } + + pub fn retained_renderer_owner_ids_for_diagnostics(&self) -> impl Iterator + '_ { + self.target_engines + .retained_renderer_owner_ids(&self.target_runtimes, self.selected_target_engine_owner()) + } + + pub fn active_document_isolate_model_for_diagnostics(&self) -> &'static str { + self.active_engine() + .document_isolate_model_for_diagnostics() + } + + pub fn active_document_isolate_accounting_for_diagnostics( + &self, + ) -> crate::page::RendererDocumentIsolateAccountingDiagnostics { + self.active_engine() + .document_isolate_accounting_for_diagnostics() + } +} diff --git a/moli-core/src/browser_host/navigation_owner/page_transition.rs b/moli-core/src/browser_host/navigation_owner/page_transition.rs new file mode 100644 index 0000000000..5b09d67f0a --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/page_transition.rs @@ -0,0 +1,685 @@ +use crate::browser_host::{ + BrowserPageRuntimeAccess, BrowserPageRuntimeOwner, PageResidenceIdentity, +}; +use crate::page::RendererPageLifetimeOwner; + +use super::{ + BrowserDocumentNavigation, BrowserNavigationFailure, BrowserNavigationOwner, + BrowserNavigationTraceEvent, BrowserPageOwnerKey, BrowserPageResidenceRegistryError, + BrowserTargetRegistryError, +}; + +/// Browser-owned reason for changing one Target's Page residence outside a +/// successful loaded-Document replacement or Target termination. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserPageResidenceTransitionKind { + InitialDocumentMaterialization, + FailedNavigationDiscard, +} + +#[derive(Debug)] +enum BrowserPageResidenceTransitionRequest { + InitialDocumentMaterialization, + FailedNavigationDiscard { + navigation: BrowserDocumentNavigation, + failure: BrowserNavigationFailure, + }, +} + +impl BrowserPageResidenceTransitionRequest { + fn kind(&self) -> BrowserPageResidenceTransitionKind { + match self { + Self::InitialDocumentMaterialization => { + BrowserPageResidenceTransitionKind::InitialDocumentMaterialization + } + Self::FailedNavigationDiscard { .. } => { + BrowserPageResidenceTransitionKind::FailedNavigationDiscard + } + } + } +} + +/// Exact authorization for one Page residence transition. +/// +/// Preparation freezes the Core-owned Target and Page generation without +/// mutating them. A physical Page projector may use `previous_page` only to +/// verify that it carries the same slot capability before Core commits. +#[derive(Debug)] +pub struct BrowserPageResidenceTransitionPermit { + owner: BrowserPageOwnerKey, + previous_page: PageResidenceIdentity, + request: BrowserPageResidenceTransitionRequest, +} + +impl BrowserPageResidenceTransitionPermit { + pub fn owner(&self) -> &BrowserPageOwnerKey { + &self.owner + } + + pub fn previous_page(&self) -> &PageResidenceIdentity { + &self.previous_page + } + + pub fn kind(&self) -> BrowserPageResidenceTransitionKind { + self.request.kind() + } +} + +/// Browser-owned result of committing a successor Page generation. +/// +/// Protocol may synchronously project a physical `Page` payload or absence to +/// `current_page`, but cannot choose or advance that identity itself. +#[derive(Debug)] +pub struct BrowserPageResidenceTransition { + owner: BrowserPageOwnerKey, + previous_page: PageResidenceIdentity, + current_page: PageResidenceIdentity, + kind: BrowserPageResidenceTransitionKind, + retired_renderer_page_owner: Option, + current_page_runtime: Option, +} + +impl BrowserPageResidenceTransition { + pub fn owner(&self) -> &BrowserPageOwnerKey { + &self.owner + } + + pub fn previous_page(&self) -> &PageResidenceIdentity { + &self.previous_page + } + + pub fn current_page(&self) -> &PageResidenceIdentity { + &self.current_page + } + + pub fn kind(&self) -> BrowserPageResidenceTransitionKind { + self.kind + } + + /// Takes the exact renderer Page lifetime retired by this transition. + pub fn take_retired_renderer_page_owner(&mut self) -> Option { + self.retired_renderer_page_owner.take() + } + + /// Non-owning access to the successor Page payload, when this transition + /// materialized a concrete Page rather than Page absence. + pub fn current_page_runtime(&self) -> Option<&BrowserPageRuntimeAccess> { + self.current_page_runtime.as_ref() + } +} + +/// Exact reason why a prepared Page residence transition became stale. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserPageResidenceTransitionCommitError { + Target(BrowserTargetRegistryError), + PageResidence(BrowserPageResidenceRegistryError), + InitialDocumentNoLongerMaterializable(BrowserPageOwnerKey), + BrowserContextDisposing(BrowserPageOwnerKey), + NavigationNoLongerPending { + owner: BrowserPageOwnerKey, + navigation: BrowserDocumentNavigation, + }, + RendererPageOwnerMissing(BrowserPageOwnerKey), + TransitionKindMismatch { + expected: BrowserPageResidenceTransitionKind, + actual: BrowserPageResidenceTransitionKind, + }, +} + +impl std::fmt::Display for BrowserPageResidenceTransitionCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Target(error) => error.fmt(formatter), + Self::PageResidence(error) => error.fmt(formatter), + Self::InitialDocumentNoLongerMaterializable(owner) => write!( + formatter, + "initial Document for Target {:?} in BrowserContext {:?} is no longer materializable", + owner.target_id(), + owner.browser_context_id() + ), + Self::BrowserContextDisposing(owner) => write!( + formatter, + "BrowserContext {:?} no longer accepts a Page transition for Target {:?}", + owner.browser_context_id(), + owner.target_id() + ), + Self::NavigationNoLongerPending { owner, navigation } => write!( + formatter, + "navigation {:?} is no longer pending for Target {:?} in BrowserContext {:?}", + navigation, + owner.target_id(), + owner.browser_context_id() + ), + Self::RendererPageOwnerMissing(owner) => write!( + formatter, + "initial Document for Target {:?} in BrowserContext {:?} has no renderer Page lifetime owner", + owner.target_id(), + owner.browser_context_id() + ), + Self::TransitionKindMismatch { expected, actual } => write!( + formatter, + "Page residence commit expected {expected:?}, but permit carries {actual:?}" + ), + } + } +} + +impl std::error::Error for BrowserPageResidenceTransitionCommitError {} + +impl From for BrowserPageResidenceTransitionCommitError { + fn from(error: BrowserTargetRegistryError) -> Self { + Self::Target(error) + } +} + +impl From for BrowserPageResidenceTransitionCommitError { + fn from(error: BrowserPageResidenceRegistryError) -> Self { + Self::PageResidence(error) + } +} + +impl BrowserNavigationOwner { + fn prepare_page_residence_transition( + &self, + owner: &BrowserPageOwnerKey, + request: BrowserPageResidenceTransitionRequest, + ) -> Option { + if !self.browser_context_accepts_owner_work(owner.browser_context_id()) { + return None; + } + self.targets.validate_target_owner(owner).ok()?; + let previous_page = self + .page_residences + .prepare_replacement(&self.target_runtimes, owner)?; + Some(BrowserPageResidenceTransitionPermit { + owner: owner.clone(), + previous_page, + request, + }) + } + + /// Prepares the only transition allowed to materialize a Target's initial + /// empty Document. Duplicate or exited creation records are rejected by + /// Core before the physical Page can be installed. + pub fn prepare_initial_document_page_materialization( + &self, + owner: &BrowserPageOwnerKey, + ) -> Option { + if !self + .initial_empty_documents + .accepts_materialization(&self.target_runtimes, owner) + { + return None; + } + self.prepare_page_residence_transition( + owner, + BrowserPageResidenceTransitionRequest::InitialDocumentMaterialization, + ) + } + + /// Prepares retirement of the current Page after a document navigation + /// failure invalidates the committed Document but leaves the Target alive. + pub fn prepare_failed_navigation_page_discard( + &self, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + failure: BrowserNavigationFailure, + ) -> Option { + if navigation.target_id() != owner.target_id() + || !self + .document_navigations + .accepts_pending(&self.target_runtimes, owner, navigation) + { + return None; + } + self.prepare_page_residence_transition( + owner, + BrowserPageResidenceTransitionRequest::FailedNavigationDiscard { + navigation: navigation.clone(), + failure, + }, + ) + } + + /// Commits the Page generation and all browser-owned state associated with + /// the selected transition. No participant work may await between physical + /// permit validation and the matching projection. A permit may safely + /// cross an actor turn because every authoritative input is revalidated. + fn commit_page_residence_transition_inner( + &mut self, + permit: BrowserPageResidenceTransitionPermit, + successor_renderer_page_owner: &mut Option, + successor_page_runtime_owner: &mut Option, + ) -> Result { + let kind = permit.kind(); + if !self.browser_context_accepts_owner_work(permit.owner.browser_context_id()) { + return Err( + BrowserPageResidenceTransitionCommitError::BrowserContextDisposing(permit.owner), + ); + } + self.targets.validate_target_owner(&permit.owner)?; + if kind == BrowserPageResidenceTransitionKind::InitialDocumentMaterialization + && !self + .initial_empty_documents + .accepts_materialization(&self.target_runtimes, &permit.owner) + { + return Err( + BrowserPageResidenceTransitionCommitError::InitialDocumentNoLongerMaterializable( + permit.owner, + ), + ); + } + + let failed_navigation_record = match &permit.request { + BrowserPageResidenceTransitionRequest::FailedNavigationDiscard { + navigation, .. + } => { + let Some(record) = self.document_navigations.take_pending_if_matches( + &mut self.target_runtimes, + &permit.owner, + navigation, + ) else { + return Err( + BrowserPageResidenceTransitionCommitError::NavigationNoLongerPending { + owner: permit.owner, + navigation: navigation.clone(), + }, + ); + }; + Some(record) + } + BrowserPageResidenceTransitionRequest::InitialDocumentMaterialization => None, + }; + let materialization_precommitted = + kind == BrowserPageResidenceTransitionKind::InitialDocumentMaterialization; + if materialization_precommitted + && !self + .initial_empty_documents + .mark_materialized(&mut self.target_runtimes, &permit.owner) + { + return Err( + BrowserPageResidenceTransitionCommitError::InitialDocumentNoLongerMaterializable( + permit.owner, + ), + ); + } + let (current_page, retired_renderer_page_owner, current_page_runtime) = match self + .page_residences + .commit_transition_with_page_owners( + &mut self.target_runtimes, + &permit.owner, + &permit.previous_page, + successor_renderer_page_owner, + successor_page_runtime_owner, + ) { + Ok(committed) => committed, + Err(error) => { + if materialization_precommitted { + let rolled_back = self + .initial_empty_documents + .rollback_materialized(&mut self.target_runtimes, &permit.owner); + debug_assert!( + rolled_back, + "same-turn stale Page generation must restore initial Document state" + ); + } + if let Some(record) = failed_navigation_record { + let rolled_back = self.document_navigations.restore_pending_if_vacant( + &mut self.target_runtimes, + &permit.owner, + record, + ); + debug_assert!( + rolled_back, + "same-turn stale Page generation must restore failed navigation authority" + ); + } + return Err(error.into()); + } + }; + + match &permit.request { + BrowserPageResidenceTransitionRequest::InitialDocumentMaterialization => {} + BrowserPageResidenceTransitionRequest::FailedNavigationDiscard { + navigation, .. + } => { + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + self.target_engines.discard_target_page_runtime( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + permit.owner.target_id(), + ); + self.document_navigations + .forget_target(&mut self.target_runtimes, permit.owner.target_id()); + self.target_terminations.cancel_navigation_if_matches( + &mut self.target_runtimes, + &permit.owner, + navigation, + ); + self.initial_empty_documents + .mark_exited(&mut self.target_runtimes, &permit.owner); + } + } + + if let ( + Some(record), + BrowserPageResidenceTransitionRequest::FailedNavigationDiscard { + navigation, + failure, + }, + ) = (failed_navigation_record, &permit.request) + { + if let Err(error) = self.record_navigation_failed_fact( + &permit.owner, + navigation, + failure.clone(), + Some(permit.previous_page.clone()), + ¤t_page, + ) { + tracing::error!( + %error, + browser_context_id = permit.owner.browser_context_id(), + target_id = permit.owner.target_id(), + previous_page_generation = permit.previous_page.loaded_page_generation(), + current_page_generation = current_page.loaded_page_generation(), + navigation_request_id = navigation.request_id().get(), + loader_id = navigation.loader_id(), + ?failure, + "failed to publish invalidating navigation failure Browser fact" + ); + } + if let Some(trace) = record.trace() { + trace.emit( + BrowserNavigationTraceEvent::new( + "navigation_request_failed_and_page_discarded", + trace.origin(), + "request-pending", + "page-absent", + ) + .with_navigation(navigation) + .with_page(current_page.clone()), + ); + } + } + + Ok(BrowserPageResidenceTransition { + owner: permit.owner, + previous_page: permit.previous_page, + current_page, + kind, + retired_renderer_page_owner, + current_page_runtime, + }) + } + + /// Commits initial Document materialization and transfers its unique + /// renderer Page lifetime to Browser Host in the same owner mutation. + pub fn commit_initial_document_page_materialization( + &mut self, + permit: BrowserPageResidenceTransitionPermit, + renderer_page_owner: &mut Option, + page_runtime_owner: &mut Option, + ) -> Result { + if permit.kind() != BrowserPageResidenceTransitionKind::InitialDocumentMaterialization { + return Err( + BrowserPageResidenceTransitionCommitError::TransitionKindMismatch { + expected: BrowserPageResidenceTransitionKind::InitialDocumentMaterialization, + actual: permit.kind(), + }, + ); + } + if renderer_page_owner.is_none() || page_runtime_owner.is_none() { + return Err( + BrowserPageResidenceTransitionCommitError::RendererPageOwnerMissing(permit.owner), + ); + } + self.commit_page_residence_transition_inner(permit, renderer_page_owner, page_runtime_owner) + } + + /// Commits Page absence after a failed navigation and returns the retired + /// renderer Page lifetime through the transition result. + pub fn commit_failed_navigation_page_discard( + &mut self, + permit: BrowserPageResidenceTransitionPermit, + ) -> Result { + if permit.kind() != BrowserPageResidenceTransitionKind::FailedNavigationDiscard { + return Err( + BrowserPageResidenceTransitionCommitError::TransitionKindMismatch { + expected: BrowserPageResidenceTransitionKind::FailedNavigationDiscard, + actual: permit.kind(), + }, + ); + } + let mut no_successor_renderer = None; + let mut no_successor_runtime = None; + self.commit_page_residence_transition_inner( + permit, + &mut no_successor_renderer, + &mut no_successor_runtime, + ) + } + + /// Authority-only helper for registry tests that intentionally have no + /// physical renderer Page payload. + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn commit_page_residence_transition_without_renderer_owner_for_testing( + &mut self, + permit: BrowserPageResidenceTransitionPermit, + ) -> Result { + let mut no_successor_renderer = None; + let mut no_successor_runtime = None; + self.commit_page_residence_transition_inner( + permit, + &mut no_successor_renderer, + &mut no_successor_runtime, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + browser_host::{ + BrowserContextSelectionProjection, BrowserFact, BrowserInitialEmptyDocumentSeed, + BrowserNavigationFailure, BrowserPageResidenceHandle, + BrowserSelectedTargetEngineDisposition, BrowserTargetHandle, + BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }, + runtime::NavigationEngine, + }; + + use super::*; + + fn owner_with_target() -> ( + BrowserNavigationOwner, + BrowserPageOwnerKey, + BrowserPageResidenceHandle, + ) { + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = BrowserPageResidenceHandle::default(); + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + owner + .register_browser_context( + key.browser_context_id(), + BrowserTargetTopologyProjection::new( + key.browser_context_id(), + Some(BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged(key.target_id()), + page_residence.clone(), + )), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("test Target topology should register"); + (owner, key, page_residence) + } + + #[test] + fn initial_document_materialization_advances_page_and_lifecycle_together() { + let (mut owner, key, page_residence) = owner_with_target(); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank#initial"), + ) + .expect("live Target should accept initial metadata"); + + let permit = owner + .prepare_initial_document_page_materialization(&key) + .expect("current initial Document should prepare"); + let previous = permit.previous_page().clone(); + let transition = owner + .commit_page_residence_transition_without_renderer_owner_for_testing(permit) + .expect("exact initial materialization should commit"); + + assert_eq!(transition.previous_page(), &previous); + assert_eq!(transition.current_page().loaded_page_generation(), 1); + assert_eq!(page_residence.generation(), 1); + assert!( + owner + .target_initial_empty_document(&key) + .expect("initial Document record") + .materialized() + ); + assert!( + owner + .prepare_initial_document_page_materialization(&key) + .is_none(), + "the materialization authority must be one-shot" + ); + } + + #[test] + fn stale_initial_document_permit_cannot_commit_after_another_transition() { + let (mut owner, key, page_residence) = owner_with_target(); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("live Target should accept initial metadata"); + let stale = owner + .prepare_initial_document_page_materialization(&key) + .expect("initial permit"); + page_residence.advance_generation_for_test_fixture(); + + let error = owner + .commit_page_residence_transition_without_renderer_owner_for_testing(stale) + .expect_err("a successor generation must invalidate an old permit"); + assert_eq!( + error, + BrowserPageResidenceTransitionCommitError::PageResidence( + BrowserPageResidenceRegistryError::StaleTransition { + owner: key.clone(), + expected_generation: 0, + current_generation: 1, + } + ) + ); + assert_eq!(page_residence.generation(), 1); + assert!( + !owner + .target_initial_empty_document(&key) + .expect("initial Document record") + .materialized(), + "a stale generation must roll back the precommitted lifecycle bit" + ); + } + + #[test] + fn failed_navigation_discard_advances_page_and_retires_runtime_state() { + let (mut owner, key, page_residence) = owner_with_target(); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("live Target should accept initial metadata"); + let materialize = owner + .prepare_initial_document_page_materialization(&key) + .expect("initial materialization"); + owner + .commit_page_residence_transition_without_renderer_owner_for_testing(materialize) + .expect("initial materialization should commit"); + let navigation = owner.start_document_navigation(&key, "loader-failed".to_owned()); + let failure = BrowserNavigationFailure::Network { + error_text: "net::ERR_FAILED".to_owned(), + }; + + let permit = owner + .prepare_failed_navigation_page_discard(&key, &navigation, failure.clone()) + .expect("live Target should prepare failed navigation discard"); + let previous = permit.previous_page().clone(); + let transition = owner + .commit_page_residence_transition_without_renderer_owner_for_testing(permit) + .expect("exact failed-navigation discard should commit"); + + assert_eq!( + transition.kind(), + BrowserPageResidenceTransitionKind::FailedNavigationDiscard + ); + assert_eq!(transition.current_page().loaded_page_generation(), 2); + assert_eq!(page_residence.generation(), 2); + assert!(!owner.has_pending_document_navigation(&key)); + assert!(!owner.accepts_pending_document_navigation(&key, &navigation)); + assert!( + owner + .target_initial_empty_document(&key) + .expect("initial metadata remains diagnostic") + .exited() + ); + let facts = owner.browser_fact_snapshot(); + assert_eq!(facts.len(), 3); + assert!(matches!(facts[0].fact(), BrowserFact::TargetCreated)); + assert_eq!(facts[2].page_residence(), transition.current_page()); + assert_eq!( + facts[2].fact(), + &BrowserFact::NavigationFailed { + navigation, + failure, + previous_page: Some(previous), + } + ); + } + + #[test] + fn stale_failed_navigation_discard_restores_exact_pending_authority() { + let (mut owner, key, page_residence) = owner_with_target(); + let navigation = owner.start_document_navigation(&key, "loader-failed".to_owned()); + let permit = owner + .prepare_failed_navigation_page_discard( + &key, + &navigation, + BrowserNavigationFailure::Network { + error_text: "net::ERR_FAILED".to_owned(), + }, + ) + .expect("exact failed navigation should prepare"); + page_residence.advance_generation_for_test_fixture(); + + let error = owner + .commit_page_residence_transition_without_renderer_owner_for_testing(permit) + .expect_err("stale Page generation must reject failed discard"); + + assert!(matches!( + error, + BrowserPageResidenceTransitionCommitError::PageResidence( + BrowserPageResidenceRegistryError::StaleTransition { .. } + ) + )); + assert!(owner.accepts_pending_document_navigation(&key, &navigation)); + assert!( + owner + .browser_fact_snapshot() + .iter() + .all(|fact| !matches!(fact.fact(), BrowserFact::NavigationFailed { .. })), + "a stale Page commit must not publish a terminal navigation fact" + ); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/request.rs b/moli-core/src/browser_host/navigation_owner/request.rs new file mode 100644 index 0000000000..2482ca7a88 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/request.rs @@ -0,0 +1,118 @@ +use std::{ + num::NonZeroU64, + sync::atomic::{AtomicU64, Ordering}, +}; + +use crate::browser_host::BrowserTargetId; + +static NEXT_BROWSER_NAVIGATION_REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +/// Browser-owned identity of one navigation request. +/// +/// This identity is independent of CDP/BiDi command ids and frontend +/// sessions. Redirects, renderer attachment work, and late completions use it +/// to decide whether they still belong to the same browser request. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct BrowserNavigationRequestId(NonZeroU64); + +impl BrowserNavigationRequestId { + fn allocate() -> Self { + let raw = NEXT_BROWSER_NAVIGATION_REQUEST_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .unwrap_or_else(|_| panic!("browser navigation request id exhausted")); + Self( + NonZeroU64::new(raw) + .unwrap_or_else(|| panic!("browser navigation request id allocator returned zero")), + ) + } + + pub fn get(self) -> u64 { + self.0.get() + } +} + +/// Exact browser transaction for one cross-document navigation. +/// +/// The token deliberately contains no protocol session or command identity. +/// Frontends may correlate it with their own pending command state, but that +/// correlation cannot affect request equality or completion authorization. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct BrowserDocumentNavigation { + target_id: BrowserTargetId, + loader_id: String, + request_id: BrowserNavigationRequestId, +} + +/// Protocol-neutral terminal reason for one accepted cross-Document request +/// that did not commit a successor Document. +/// +/// Frontend response shapes and command/session ids deliberately do not live +/// here. The diagnostic text is the browser/runtime failure that ended the +/// request and may be projected differently by CDP, BiDi, or Classic. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserNavigationFailure { + Network { + error_text: String, + }, + Commit { + error_text: String, + }, + Canceled { + error_text: String, + }, + Superseded { + replacement: BrowserDocumentNavigation, + }, + TargetCrashed, + TargetClosed, +} + +impl BrowserDocumentNavigation { + pub fn new(target_id: impl Into, loader_id: impl Into) -> Self { + Self { + target_id: BrowserTargetId::new(target_id), + loader_id: loader_id.into(), + request_id: BrowserNavigationRequestId::allocate(), + } + } + + pub fn target_id(&self) -> &str { + self.target_id.as_str() + } + + pub fn loader_id(&self) -> &str { + &self.loader_id + } + + pub fn request_id(&self) -> BrowserNavigationRequestId { + self.request_id + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::size_of; + + #[test] + fn request_identity_is_nonzero_distinct_and_frontend_neutral() { + let first = BrowserDocumentNavigation::new("target-1", "loader-1"); + let second = BrowserDocumentNavigation::new("target-1", "loader-1"); + + assert_ne!(first, second); + assert_ne!(first.request_id(), second.request_id()); + assert_ne!(first.request_id().get(), 0); + assert_eq!(first.target_id(), "target-1"); + assert_eq!(first.loader_id(), "loader-1"); + } + + #[test] + fn optional_request_id_preserves_the_nonzero_niche() { + assert_eq!( + size_of::>(), + size_of::() + ); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/request_registry.rs b/moli-core/src/browser_host/navigation_owner/request_registry.rs new file mode 100644 index 0000000000..fea2e7496e --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/request_registry.rs @@ -0,0 +1,866 @@ +use crate::browser_host::BrowserContextId; + +use super::{ + BrowserDocumentNavigation, BrowserNavigationFailure, BrowserNavigationOwner, + BrowserNavigationTraceContext, BrowserNavigationTraceEvent, BrowserPageOwnerKey, + target_runtime_registry::BrowserTargetRuntimeRegistry, +}; + +#[derive(Clone)] +pub(super) struct BrowserDocumentNavigationRecord { + navigation: BrowserDocumentNavigation, + trace: Option, +} + +impl BrowserDocumentNavigationRecord { + pub(super) fn navigation(&self) -> &BrowserDocumentNavigation { + &self.navigation + } + + pub(super) fn trace(&self) -> Option<&BrowserNavigationTraceContext> { + self.trace.as_ref() + } +} + +#[derive(Default)] +pub(super) struct BrowserTargetDocumentNavigationState { + pending: Option, + committed: Option, +} + +/// Rollback state for a same-turn request commit that precedes the shared +/// Page-generation compare/exchange. +pub(super) struct BrowserDocumentNavigationCommitRollback { + previous_committed: Option, +} + +/// Authoritative cross-document request state keyed by browser Target. +/// +/// Renderer attachment and lifecycle projections may retain the immutable +/// request token, but pending/committed acceptance is decided only here. +#[derive(Default)] +pub(super) struct BrowserDocumentNavigationRegistry; + +impl BrowserDocumentNavigationRegistry { + fn start( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + loader_id: String, + trace: Option, + ) -> ( + BrowserDocumentNavigation, + Option, + ) { + debug_assert!( + trace + .as_ref() + .is_none_or(|trace| trace.addresses_owner(key)), + "navigation trace context must address its exact Browser Page owner" + ); + let navigation = BrowserDocumentNavigation::new(key.target_id(), loader_id); + let record = BrowserDocumentNavigationRecord { + navigation: navigation.clone(), + trace: trace.filter(|trace| trace.addresses_owner(key)), + }; + let state = self.state_mut_or_default(runtimes, key); + let superseded = state.pending.replace(record); + (navigation, superseded) + } + + pub(super) fn accepts_pending( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + self.state(runtimes, key) + .and_then(|state| state.pending.as_ref()) + .map(BrowserDocumentNavigationRecord::navigation) + == Some(navigation) + } + + fn accepts_body_completion( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + let Some(state) = self.state(runtimes, key) else { + return false; + }; + match state.pending.as_ref() { + Some(pending) => pending.navigation() == navigation, + None => state + .committed + .as_ref() + .is_some_and(|committed| committed.navigation() == navigation), + } + } + + fn accepts_committed( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + self.state(runtimes, key) + .and_then(|state| state.committed.as_ref()) + .map(BrowserDocumentNavigationRecord::navigation) + == Some(navigation) + } + + pub(super) fn trace_context<'a>( + &self, + runtimes: &'a BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> Option<&'a BrowserNavigationTraceContext> { + let state = self.state(runtimes, key)?; + if state + .pending + .as_ref() + .is_some_and(|pending| pending.navigation() == navigation) + { + return state.pending.as_ref().and_then(|pending| pending.trace()); + } + state + .committed + .as_ref() + .filter(|committed| committed.navigation() == navigation) + .and_then(|committed| committed.trace()) + } + + pub(super) fn has_pending( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> bool { + self.state(runtimes, key) + .is_some_and(|state| state.pending.is_some()) + } + + fn current_loader_id( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Option { + let state = self.state(runtimes, key)?; + state + .pending + .as_ref() + .or(state.committed.as_ref()) + .map(|record| record.navigation().loader_id().to_owned()) + } + + fn committed_loader_id( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Option { + self.state(runtimes, key)? + .committed + .as_ref() + .map(|record| record.navigation().loader_id().to_owned()) + } + + pub(super) fn commit_if_matches( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + let Some(state) = self.state_mut(runtimes, key) else { + return false; + }; + if state + .pending + .as_ref() + .is_none_or(|pending| pending.navigation() != navigation) + { + return false; + } + state.committed = state.pending.take(); + true + } + + pub(super) fn commit_with_rollback_if_matches( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> Option { + let state = self.state_mut(runtimes, key)?; + if state + .pending + .as_ref() + .is_none_or(|pending| pending.navigation() != navigation) + { + return None; + } + let rollback = BrowserDocumentNavigationCommitRollback { + previous_committed: state.committed.take(), + }; + state.committed = state.pending.take(); + Some(rollback) + } + + pub(super) fn rollback_commit( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + rollback: BrowserDocumentNavigationCommitRollback, + ) -> bool { + let Some(state) = self.state_mut(runtimes, key) else { + return false; + }; + if state.pending.is_some() + || state + .committed + .as_ref() + .is_none_or(|committed| committed.navigation() != navigation) + { + return false; + } + state.pending = state.committed.take(); + state.committed = rollback.previous_committed; + true + } + + pub(super) fn take_pending_if_matches( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> Option { + let state = self.state_mut(runtimes, key)?; + if state + .pending + .as_ref() + .is_none_or(|pending| pending.navigation() != navigation) + { + return None; + } + state.pending.take() + } + + pub(super) fn restore_pending_if_vacant( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + record: BrowserDocumentNavigationRecord, + ) -> bool { + let state = self.state_mut_or_default(runtimes, key); + if state.pending.is_some() { + return false; + } + state.pending = Some(record); + true + } + + pub(super) fn pending_record<'a>( + &self, + runtimes: &'a BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Option<&'a BrowserDocumentNavigationRecord> { + self.state(runtimes, key)?.pending.as_ref() + } + + pub(super) fn forget_target( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + target_id: &str, + ) { + for (owner, runtime) in &mut runtimes.entries { + if owner.target_id() == target_id { + runtime.document_navigation = None; + } + } + runtimes.prune_empty(); + } + + fn state<'a>( + &self, + runtimes: &'a BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Option<&'a BrowserTargetDocumentNavigationState> { + runtimes + .entries + .get(key) + .and_then(|runtime| runtime.document_navigation.as_ref()) + } + + fn state_mut<'a>( + &mut self, + runtimes: &'a mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> Option<&'a mut BrowserTargetDocumentNavigationState> { + runtimes + .entries + .get_mut(key) + .and_then(|runtime| runtime.document_navigation.as_mut()) + } + + fn state_mut_or_default<'a>( + &mut self, + runtimes: &'a mut BrowserTargetRuntimeRegistry, + key: &BrowserPageOwnerKey, + ) -> &'a mut BrowserTargetDocumentNavigationState { + runtimes + .entries + .entry(key.clone()) + .or_default() + .document_navigation + .get_or_insert_default() + } +} + +impl BrowserNavigationOwner { + #[cfg(test)] + pub fn start_document_navigation( + &mut self, + key: &BrowserPageOwnerKey, + loader_id: String, + ) -> BrowserDocumentNavigation { + self.start_document_navigation_with_trace(key, loader_id, None) + } + + #[cfg(test)] + pub fn start_document_navigation_with_trace( + &mut self, + key: &BrowserPageOwnerKey, + loader_id: String, + trace: Option, + ) -> BrowserDocumentNavigation { + self.try_start_document_navigation_with_trace(key, loader_id, trace) + .expect("test navigation Context must not be reserved for disposal") + } + + /// Starts a cross-Document request unless the exact Context is reserved + /// for whole-Context disposal. + /// + /// During migration an unregistered test/projection owner retains the + /// legacy admission shape. A known disposing Context is the only new + /// rejection; callers must not collapse that state with an unknown + /// Context lookup. + pub fn try_start_document_navigation_with_trace( + &mut self, + key: &BrowserPageOwnerKey, + loader_id: String, + trace: Option, + ) -> Option { + if self + .browser_contexts + .is_disposing(&BrowserContextId::new(key.browser_context_id())) + { + return None; + } + let fact_page = self.capture_page_residence(key.browser_context_id(), key.target_id()); + let (navigation, superseded) = + self.document_navigations + .start(&mut self.target_runtimes, key, loader_id, trace); + self.target_terminations + .begin_navigation(&mut self.target_runtimes, key, &navigation); + if let Some(page) = fact_page.as_ref() { + if let Err(error) = self.record_navigation_admission_facts( + key, + &navigation, + superseded + .as_ref() + .map(BrowserDocumentNavigationRecord::navigation), + page, + ) { + tracing::error!( + %error, + browser_context_id = key.browser_context_id(), + target_id = key.target_id(), + page_residence_generation = page.loaded_page_generation(), + navigation_request_id = navigation.request_id().get(), + loader_id = navigation.loader_id(), + "failed to publish accepted navigation Browser fact" + ); + } + } else if self.has_target(key.target_id()) { + tracing::error!( + browser_context_id = key.browser_context_id(), + target_id = key.target_id(), + navigation_request_id = navigation.request_id().get(), + loader_id = navigation.loader_id(), + "accepted navigation for a registered Target without a Browser Page residence" + ); + } + if let Some(superseded) = superseded + && let Some(trace) = superseded.trace() + { + trace.emit( + BrowserNavigationTraceEvent::new( + "navigation_request_superseded", + trace.origin(), + "request-pending", + "request-terminal", + ) + .with_navigation(superseded.navigation()), + ); + } + if let Some(trace) = + self.document_navigations + .trace_context(&self.target_runtimes, key, &navigation) + { + trace.emit( + BrowserNavigationTraceEvent::new( + "navigation_request_started", + trace.origin(), + "action-accepted", + "request-pending", + ) + .with_navigation(&navigation), + ); + } + Some(navigation) + } + + /// Returns the bounded diagnostics sidecar only when `navigation` is the + /// exact pending or committed request for `key`. + pub fn document_navigation_trace_context( + &self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> Option { + self.document_navigations + .trace_context(&self.target_runtimes, key, navigation) + .cloned() + } + + pub fn accepts_pending_document_navigation( + &self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + self.document_navigations + .accepts_pending(&self.target_runtimes, key, navigation) + } + + pub fn accepts_document_body_completion( + &self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + self.document_navigations + .accepts_body_completion(&self.target_runtimes, key, navigation) + } + + /// Returns whether `navigation` is the exact request that committed the + /// current renderer Document for this browser Target. + pub fn accepts_committed_document_navigation( + &self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + self.document_navigations + .accepts_committed(&self.target_runtimes, key, navigation) + } + + pub fn has_pending_document_navigation(&self, key: &BrowserPageOwnerKey) -> bool { + self.document_navigations + .has_pending(&self.target_runtimes, key) + } + + pub fn current_document_loader_id(&self, key: &BrowserPageOwnerKey) -> Option { + self.document_navigations + .current_loader_id(&self.target_runtimes, key) + } + + pub fn committed_document_loader_id(&self, key: &BrowserPageOwnerKey) -> Option { + self.document_navigations + .committed_loader_id(&self.target_runtimes, key) + } + + pub fn commit_document_navigation_if_matches( + &mut self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + let committed = + self.document_navigations + .commit_if_matches(&mut self.target_runtimes, key, navigation); + if committed { + self.target_terminations + .commit_navigation(&mut self.target_runtimes, key, navigation); + self.initial_empty_documents + .mark_exited(&mut self.target_runtimes, key); + } + committed + } + + /// Retires one exact pending request as a non-commit terminal while the + /// current Page remains resident. A stale completion cannot retire its + /// successor or publish a duplicate fact. + pub fn fail_document_navigation_if_matches( + &mut self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + failure: BrowserNavigationFailure, + ) -> bool { + let fact_page = self.capture_page_residence(key.browser_context_id(), key.target_id()); + let Some(record) = self.document_navigations.take_pending_if_matches( + &mut self.target_runtimes, + key, + navigation, + ) else { + return false; + }; + self.target_terminations.cancel_navigation_if_matches( + &mut self.target_runtimes, + key, + navigation, + ); + if let Some(page) = fact_page.as_ref() { + if let Err(error) = + self.record_navigation_failed_fact(key, navigation, failure.clone(), None, page) + { + tracing::error!( + %error, + browser_context_id = key.browser_context_id(), + target_id = key.target_id(), + navigation_request_id = navigation.request_id().get(), + loader_id = navigation.loader_id(), + ?failure, + "failed to publish terminal navigation failure Browser fact" + ); + } + } else if self.has_target(key.target_id()) { + tracing::error!( + browser_context_id = key.browser_context_id(), + target_id = key.target_id(), + navigation_request_id = navigation.request_id().get(), + loader_id = navigation.loader_id(), + ?failure, + "failed navigation for a registered Target without a Browser Page residence" + ); + } + if let Some(trace) = record.trace() { + let mut event = BrowserNavigationTraceEvent::new( + "navigation_request_failed", + trace.origin(), + "request-pending", + "request-terminal", + ) + .with_navigation(navigation); + if let Some(page) = fact_page { + event = event.with_page(page); + } + trace.emit(event); + } + true + } + + /// Retires one exact pending request because its response became a + /// download. This is a successful request terminal, but not a Document + /// commit and therefore must not advance Page generation. + pub fn convert_document_navigation_to_download_if_matches( + &mut self, + key: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + let fact_page = self.capture_page_residence(key.browser_context_id(), key.target_id()); + let Some(record) = self.document_navigations.take_pending_if_matches( + &mut self.target_runtimes, + key, + navigation, + ) else { + return false; + }; + self.target_terminations.cancel_navigation_if_matches( + &mut self.target_runtimes, + key, + navigation, + ); + if let Some(page) = fact_page.as_ref() { + if let Err(error) = self.record_navigation_download_fact(key, navigation, page) { + tracing::error!( + %error, + browser_context_id = key.browser_context_id(), + target_id = key.target_id(), + navigation_request_id = navigation.request_id().get(), + loader_id = navigation.loader_id(), + "failed to publish navigation download Browser fact" + ); + } + } else if self.has_target(key.target_id()) { + tracing::error!( + browser_context_id = key.browser_context_id(), + target_id = key.target_id(), + navigation_request_id = navigation.request_id().get(), + loader_id = navigation.loader_id(), + "download navigation for a registered Target without a Browser Page residence" + ); + } + if let Some(trace) = record.trace() { + let mut event = BrowserNavigationTraceEvent::new( + "navigation_request_converted_to_download", + trace.origin(), + "request-pending", + "request-terminal", + ) + .with_navigation(navigation); + if let Some(page) = fact_page { + event = event.with_page(page); + } + trace.emit(event); + } + true + } +} + +#[cfg(test)] +mod tests { + use crate::{ + browser_host::{ + BrowserContextSelectionProjection, BrowserFact, BrowserNavigationTraceSource, + BrowserPageResidenceHandle, BrowserSelectedTargetEngineDisposition, + BrowserTargetHandle, BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + PageResidenceIdentity, + }, + runtime::NavigationEngine, + }; + use moli_page_types::BrowserActionId; + + use super::*; + + fn owner_with_target() -> (BrowserNavigationOwner, BrowserPageOwnerKey) { + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + owner + .register_browser_context( + key.browser_context_id(), + BrowserTargetTopologyProjection::new( + key.browser_context_id(), + Some(BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged(key.target_id()), + BrowserPageResidenceHandle::default(), + )), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("test Target topology should register"); + (owner, key) + } + + #[test] + fn newer_request_makes_old_pending_and_body_completion_stale() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let first = owner.start_document_navigation(&key, "loader-1".to_owned()); + assert!(owner.accepts_pending_document_navigation(&key, &first)); + assert!(owner.accepts_document_body_completion(&key, &first)); + + let second = owner.start_document_navigation(&key, "loader-2".to_owned()); + assert!(!owner.accepts_pending_document_navigation(&key, &first)); + assert!(!owner.accepts_document_body_completion(&key, &first)); + assert!(owner.accepts_pending_document_navigation(&key, &second)); + assert_ne!(first.request_id(), second.request_id()); + } + + #[test] + fn committed_request_accepts_late_body_until_successor_starts() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let first = owner.start_document_navigation(&key, "loader-1".to_owned()); + assert!(owner.commit_document_navigation_if_matches(&key, &first)); + assert!(owner.accepts_committed_document_navigation(&key, &first)); + assert!(owner.accepts_document_body_completion(&key, &first)); + assert_eq!( + owner.committed_document_loader_id(&key).as_deref(), + Some("loader-1") + ); + + let second = owner.start_document_navigation(&key, "loader-2".to_owned()); + assert!(!owner.accepts_document_body_completion(&key, &first)); + assert!(owner.accepts_document_body_completion(&key, &second)); + } + + #[test] + fn failing_pending_request_restores_committed_body_authority() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let committed = owner.start_document_navigation(&key, "loader-1".to_owned()); + assert!(owner.commit_document_navigation_if_matches(&key, &committed)); + let failed = owner.start_document_navigation(&key, "loader-2".to_owned()); + + assert!(owner.fail_document_navigation_if_matches( + &key, + &failed, + BrowserNavigationFailure::Network { + error_text: "net::ERR_FAILED".to_owned(), + }, + )); + assert!(!owner.accepts_pending_document_navigation(&key, &failed)); + assert!(owner.accepts_document_body_completion(&key, &committed)); + assert_eq!( + owner.current_document_loader_id(&key).as_deref(), + Some("loader-1") + ); + } + + #[test] + fn page_runtime_discard_rejects_every_late_request_completion() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let navigation = owner.start_document_navigation(&key, "loader-1".to_owned()); + + owner.discard_target_page_runtime("target-1"); + + assert!(!owner.accepts_pending_document_navigation(&key, &navigation)); + assert!(!owner.accepts_document_body_completion(&key, &navigation)); + assert!(!owner.has_pending_document_navigation(&key)); + } + + #[test] + fn trace_sidecar_follows_exact_request_commit_and_cleanup() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let first_trace = BrowserNavigationTraceContext::new( + owner.browser_instance_id, + BrowserActionId::allocate(), + BrowserNavigationTraceSource::FrontendCommand, + PageResidenceIdentity::new("context-1".to_owned(), Some("target-1".to_owned()), 3), + None, + ); + let first_action = first_trace.browser_action_id(); + let first = owner.start_document_navigation_with_trace( + &key, + "loader-1".to_owned(), + Some(first_trace), + ); + + assert_eq!( + owner + .document_navigation_trace_context(&key, &first) + .map(|trace| trace.browser_action_id()), + Some(first_action) + ); + assert!(owner.commit_document_navigation_if_matches(&key, &first)); + assert_eq!( + owner + .document_navigation_trace_context(&key, &first) + .map(|trace| trace.browser_action_id()), + Some(first_action), + "commit must move the sidecar with the exact request" + ); + + let second_trace = BrowserNavigationTraceContext::new( + owner.browser_instance_id, + BrowserActionId::allocate(), + BrowserNavigationTraceSource::RendererIntent, + PageResidenceIdentity::new("context-1".to_owned(), Some("target-1".to_owned()), 3), + None, + ); + let second = owner.start_document_navigation_with_trace( + &key, + "loader-2".to_owned(), + Some(second_trace), + ); + assert!(owner.fail_document_navigation_if_matches( + &key, + &second, + BrowserNavigationFailure::Canceled { + error_text: "canceled".to_owned(), + }, + )); + assert!( + owner + .document_navigation_trace_context(&key, &second) + .is_none(), + "failed pending cleanup must retire its trace sidecar" + ); + assert_eq!( + owner + .document_navigation_trace_context(&key, &first) + .map(|trace| trace.browser_action_id()), + Some(first_action), + "clearing a failed successor must retain committed request diagnostics" + ); + } + + #[test] + fn successor_acceptance_carries_superseded_request_in_one_page_generation() { + let (mut owner, key) = owner_with_target(); + let mut subscriber = owner.subscribe_browser_facts(); + let first = owner.start_document_navigation(&key, "loader-1".to_owned()); + let second = owner.start_document_navigation(&key, "loader-2".to_owned()); + + let created = subscriber.try_recv().expect("Target creation occurrence"); + let accepted_first = subscriber.try_recv().expect("first acceptance fact"); + let accepted_second = subscriber.try_recv().expect("successor acceptance fact"); + assert_eq!(created.sequence().get(), 1); + assert!(matches!(created.fact(), BrowserFact::TargetCreated)); + assert_eq!(accepted_first.sequence().get(), 2); + assert_eq!(accepted_second.sequence().get(), 3); + assert_eq!( + accepted_first.page_residence(), + accepted_second.page_residence() + ); + assert_eq!( + accepted_second.fact(), + &BrowserFact::NavigationAccepted { + navigation: second, + superseded_navigation: Some(first), + } + ); + } + + #[test] + fn exact_failure_is_terminal_once_and_a_stale_completion_cannot_duplicate_it() { + let (mut owner, key) = owner_with_target(); + let navigation = owner.start_document_navigation(&key, "loader-failed".to_owned()); + let failure = BrowserNavigationFailure::Network { + error_text: "net::ERR_NAME_NOT_RESOLVED".to_owned(), + }; + + assert!(owner.fail_document_navigation_if_matches(&key, &navigation, failure.clone())); + assert!(!owner.fail_document_navigation_if_matches(&key, &navigation, failure.clone())); + assert!(!owner.has_pending_document_navigation(&key)); + let facts = owner.browser_fact_snapshot(); + assert_eq!(facts.len(), 3); + assert!(matches!(facts[0].fact(), BrowserFact::TargetCreated)); + assert_eq!( + facts[2].fact(), + &BrowserFact::NavigationFailed { + navigation, + failure, + previous_page: None, + } + ); + } + + #[test] + fn download_is_a_distinct_terminal_without_page_replacement() { + let (mut owner, key) = owner_with_target(); + let page = owner + .capture_page_residence(key.browser_context_id(), key.target_id()) + .expect("current Page"); + let navigation = owner.start_document_navigation(&key, "loader-download".to_owned()); + + assert!(owner.convert_document_navigation_to_download_if_matches(&key, &navigation)); + assert!(!owner.convert_document_navigation_to_download_if_matches(&key, &navigation)); + assert_eq!( + owner + .capture_page_residence(key.browser_context_id(), key.target_id()) + .as_ref(), + Some(&page) + ); + let facts = owner.browser_fact_snapshot(); + assert_eq!(facts.len(), 3); + assert!(matches!(facts[0].fact(), BrowserFact::TargetCreated)); + assert_eq!( + facts[2].fact(), + &BrowserFact::NavigationConvertedToDownload { navigation } + ); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/target_creation.rs b/moli-core/src/browser_host/navigation_owner/target_creation.rs new file mode 100644 index 0000000000..57ee01eb8d --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_creation.rs @@ -0,0 +1,53 @@ +use crate::{browser_host::BrowserTargetSessionStorageSeed, network::SharedWebStorageStore}; + +use super::{BrowserInitialEmptyDocumentSeed, BrowserNavigationOwner, BrowserPageOwnerKey}; + +/// Protocol-neutral immutable inputs captured by one Browser Target creation +/// transaction. +/// +/// Keeping this separate from Target topology makes future opener/sandbox +/// metadata additions explicit without growing the registry transaction into +/// a frontend payload container. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BrowserTargetCreationMetadata { + initial_empty_document: Option, + session_storage: BrowserTargetSessionStorageSeed, +} + +impl BrowserTargetCreationMetadata { + pub fn with_initial_empty_document(seed: BrowserInitialEmptyDocumentSeed) -> Self { + Self { + initial_empty_document: Some(seed), + ..Self::default() + } + } + + pub fn with_session_storage_store(mut self, store: SharedWebStorageStore) -> Self { + self.session_storage = BrowserTargetSessionStorageSeed::from_store(store); + self + } + + pub(super) fn initial_empty_document(&self) -> Option<&BrowserInitialEmptyDocumentSeed> { + self.initial_empty_document.as_ref() + } + + pub(super) fn session_storage_store(&self) -> SharedWebStorageStore { + self.session_storage.store() + } +} + +impl BrowserNavigationOwner { + /// Installs infallible creation metadata after every fallible participant + /// has accepted a Target-bearing registration transaction and before its + /// lifecycle handle is published live. + pub(super) fn install_target_creation_metadata( + &mut self, + owner: &BrowserPageOwnerKey, + creation_metadata: &BrowserTargetCreationMetadata, + ) { + if let Some(seed) = creation_metadata.initial_empty_document().cloned() { + self.initial_empty_documents + .begin(&mut self.target_runtimes, owner, seed); + } + } +} diff --git a/moli-core/src/browser_host/navigation_owner/target_metadata.rs b/moli-core/src/browser_host/navigation_owner/target_metadata.rs new file mode 100644 index 0000000000..72d3249eb9 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_metadata.rs @@ -0,0 +1,68 @@ +use super::BrowserDocumentNavigation; + +#[derive(Clone, Debug, Eq, PartialEq)] +enum BrowserTargetMetadataTransitionSource { + NavigationCommitted(BrowserDocumentNavigation), + DocumentTitleChanged, +} + +/// Immutable Browser-owned reason and values for one top-level Target +/// metadata transition. +/// +/// CDP attachment state is deliberately absent. A committed Document freezes +/// its URL and initial title, while later renderer-observed title updates carry +/// the current URL with a title-change source. Merely accepting a navigation, +/// including named-target reuse, is not a metadata transition: Chromium +/// reports the new values only after the successor Document commits. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTargetMetadataTransition { + source: BrowserTargetMetadataTransitionSource, + url: String, + title: String, +} + +impl BrowserTargetMetadataTransition { + pub(super) fn navigation_committed( + navigation: BrowserDocumentNavigation, + url: String, + title: String, + ) -> Self { + Self { + source: BrowserTargetMetadataTransitionSource::NavigationCommitted(navigation), + url, + title, + } + } + + pub(super) fn document_title_changed(url: String, title: String) -> Self { + Self { + source: BrowserTargetMetadataTransitionSource::DocumentTitleChanged, + url, + title, + } + } + + pub fn navigation(&self) -> Option<&BrowserDocumentNavigation> { + match &self.source { + BrowserTargetMetadataTransitionSource::NavigationCommitted(navigation) => { + Some(navigation) + } + BrowserTargetMetadataTransitionSource::DocumentTitleChanged => None, + } + } + + pub fn is_document_title_change(&self) -> bool { + matches!( + self.source, + BrowserTargetMetadataTransitionSource::DocumentTitleChanged + ) + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn title(&self) -> &str { + &self.title + } +} diff --git a/moli-core/src/browser_host/navigation_owner/target_registry.rs b/moli-core/src/browser_host/navigation_owner/target_registry.rs new file mode 100644 index 0000000000..eb942e459a --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_registry.rs @@ -0,0 +1,580 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use crate::browser_host::{ + BrowserContextId, BrowserPageResidenceHandle, BrowserTargetHandle, BrowserTargetId, + BrowserTargetSessionStorageAccess, +}; +use crate::network::SharedWebStorageStore; + +use super::{ + BrowserContextRegistryError, BrowserNavigationOwner, BrowserPageOwnerKey, + BrowserPageResidenceRegistryError, BrowserTargetEngineContextMismatch, + BrowserTargetEngineOwnerMismatch, +}; + +mod transaction; + +/// Exact capabilities carried by one physical top-level Target slot. +/// +/// Browser Core owns both registrations. Protocol migration storage carries +/// clones so a raw Target id or an unrelated Page slot cannot authorize an +/// owner transaction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTargetSlotProjection { + target: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, +} + +impl BrowserTargetSlotProjection { + pub fn new(target: BrowserTargetHandle, page_residence: BrowserPageResidenceHandle) -> Self { + Self { + target, + page_residence, + } + } + + pub fn target_id(&self) -> &str { + self.target.target_id() + } + + pub fn target_handle(&self) -> &BrowserTargetHandle { + &self.target + } + + pub fn page_residence_handle(&self) -> &BrowserPageResidenceHandle { + &self.page_residence + } +} + +/// Physical active/background slots supplied only as an exact projection +/// guard while Target/Page payload storage remains outside Browser Core. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTargetTopologyProjection { + browser_context_id: BrowserContextId, + active_target: Option, + background_targets: Vec, +} + +impl BrowserTargetTopologyProjection { + pub fn new( + browser_context_id: impl Into, + active_target: Option, + background_targets: impl IntoIterator, + ) -> Self { + Self { + browser_context_id: BrowserContextId::new(browser_context_id), + active_target, + background_targets: background_targets.into_iter().collect(), + } + } + + pub fn browser_context_id(&self) -> &str { + self.browser_context_id.as_str() + } + + pub fn active_target_id(&self) -> Option<&str> { + self.active_target + .as_ref() + .map(BrowserTargetSlotProjection::target_id) + } + + pub fn background_target_ids(&self) -> impl Iterator { + self.background_targets + .iter() + .map(BrowserTargetSlotProjection::target_id) + } + + pub(super) fn slots(&self) -> impl Iterator { + self.active_target + .iter() + .chain(self.background_targets.iter()) + } +} + +/// Authoritative residence of one live top-level browser Target. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserTargetResidence { + Active, + Background, +} + +/// Target topology, context selection, or engine identity failed an exact +/// Browser Owner transaction guard. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserTargetRegistryError { + BrowserContext(BrowserContextRegistryError), + PageResidence(BrowserPageResidenceRegistryError), + EngineOwner(BrowserTargetEngineOwnerMismatch), + EngineContext(BrowserTargetEngineContextMismatch), + UnknownBrowserContext(BrowserContextId), + DuplicateBrowserContextProjection(BrowserContextId), + ProjectionContextMismatch { + expected: BrowserContextId, + projected: BrowserContextId, + }, + DuplicateProjectedTarget(BrowserTargetId), + DuplicateTarget(BrowserTargetId), + UnknownTarget(BrowserTargetId), + TargetHandleNotStaged(BrowserTargetId), + TargetHandleNotLive(BrowserTargetId), + TargetHandleProjectionMismatch(BrowserTargetId), + TargetContextMismatch { + target_id: BrowserTargetId, + expected: BrowserContextId, + actual: BrowserContextId, + }, + TopologyProjectionMismatch { + browser_context_id: BrowserContextId, + authoritative_active: Option, + authoritative_background: Vec, + projected_active: Option, + projected_background: Vec, + }, + SelectedBrowserContextRequired { + requested: BrowserContextId, + selected: Option, + }, + TargetIsNotActive(BrowserPageOwnerKey), + TargetIsNotBackground(BrowserPageOwnerKey), + TargetHasCommittedRendererPage(BrowserPageOwnerKey), + TargetTopologyOwnerMismatch(BrowserPageOwnerKey), +} + +impl std::fmt::Display for BrowserTargetRegistryError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BrowserContext(error) => error.fmt(formatter), + Self::PageResidence(error) => error.fmt(formatter), + Self::EngineOwner(error) => error.fmt(formatter), + Self::EngineContext(error) => error.fmt(formatter), + Self::UnknownBrowserContext(id) => write!( + formatter, + "BrowserContext {:?} has no Target topology", + id.as_str() + ), + Self::DuplicateBrowserContextProjection(id) => write!( + formatter, + "BrowserContext {:?} already has a Target topology", + id.as_str() + ), + Self::ProjectionContextMismatch { + expected, + projected, + } => write!( + formatter, + "Target topology projection for BrowserContext {:?} was supplied for {:?}", + expected.as_str(), + projected.as_str() + ), + Self::DuplicateProjectedTarget(id) => write!( + formatter, + "Target {:?} occurs more than once in one physical topology projection", + id.as_str() + ), + Self::DuplicateTarget(id) => { + write!(formatter, "Target {:?} is already registered", id.as_str()) + } + Self::UnknownTarget(id) => { + write!(formatter, "Target {:?} is not registered", id.as_str()) + } + Self::TargetHandleNotStaged(id) => write!( + formatter, + "Target {:?} handle is no longer staged for registration", + id.as_str() + ), + Self::TargetHandleNotLive(id) => write!( + formatter, + "Target {:?} handle is no longer live for retirement", + id.as_str() + ), + Self::TargetHandleProjectionMismatch(id) => write!( + formatter, + "physical Target handle for {:?} is not the live Browser Core instance", + id.as_str() + ), + Self::TargetContextMismatch { + target_id, + expected, + actual, + } => write!( + formatter, + "Target {:?} belongs to BrowserContext {:?}, not {:?}", + target_id.as_str(), + actual.as_str(), + expected.as_str() + ), + Self::TopologyProjectionMismatch { + browser_context_id, + authoritative_active, + authoritative_background, + projected_active, + projected_background, + } => write!( + formatter, + "authoritative Target topology for BrowserContext {:?} ({:?}, {:?}) does not match physical projection ({:?}, {:?})", + browser_context_id.as_str(), + authoritative_active.as_ref().map(BrowserTargetId::as_str), + authoritative_background + .iter() + .map(BrowserTargetId::as_str) + .collect::>(), + projected_active.as_ref().map(BrowserTargetId::as_str), + projected_background + .iter() + .map(BrowserTargetId::as_str) + .collect::>() + ), + Self::SelectedBrowserContextRequired { + requested, + selected, + } => write!( + formatter, + "Target activation requires selected BrowserContext {:?}, current selection is {:?}", + requested.as_str(), + selected.as_ref().map(BrowserContextId::as_str) + ), + Self::TargetIsNotActive(owner) => write!( + formatter, + "Target {:?} is not the active Target in BrowserContext {:?}", + owner.target_id(), + owner.browser_context_id() + ), + Self::TargetIsNotBackground(owner) => write!( + formatter, + "Target {:?} is not a background Target in BrowserContext {:?}", + owner.target_id(), + owner.browser_context_id() + ), + Self::TargetHasCommittedRendererPage(owner) => write!( + formatter, + "Target {:?} in BrowserContext {:?} has a committed renderer Page and requires typed termination", + owner.target_id(), + owner.browser_context_id() + ), + Self::TargetTopologyOwnerMismatch(owner) => write!( + formatter, + "Target {:?} reverse owner is absent from BrowserContext {:?} topology", + owner.target_id(), + owner.browser_context_id() + ), + } + } +} + +impl std::error::Error for BrowserTargetRegistryError {} + +impl From for BrowserTargetRegistryError { + fn from(error: BrowserContextRegistryError) -> Self { + Self::BrowserContext(error) + } +} + +impl From for BrowserTargetRegistryError { + fn from(error: BrowserPageResidenceRegistryError) -> Self { + Self::PageResidence(error) + } +} + +impl From for BrowserTargetRegistryError { + fn from(error: BrowserTargetEngineOwnerMismatch) -> Self { + Self::EngineOwner(error) + } +} + +impl From for BrowserTargetRegistryError { + fn from(error: BrowserTargetEngineContextMismatch) -> Self { + Self::EngineContext(error) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct BrowserContextTargets { + active: Option, + background: Vec, +} + +#[derive(Clone, Debug)] +struct BrowserTargetRecord { + browser_context_id: BrowserContextId, + handle: BrowserTargetHandle, + session_storage_store: SharedWebStorageStore, +} + +impl PartialEq for BrowserTargetRecord { + fn eq(&self, other: &Self) -> bool { + self.browser_context_id == other.browser_context_id + && self.handle == other.handle + && Arc::ptr_eq(&self.session_storage_store, &other.session_storage_store) + } +} + +impl Eq for BrowserTargetRecord {} + +/// Authoritative top-level Target identity, context membership, and +/// active/background topology. +/// +/// Physical Page payload, renderer, DevTools session, opener metadata, and +/// storage payloads deliberately remain outside this registry during Phase 2. +/// The exact Page-slot capability is owned by the sibling `page_registry`. +#[derive(Default)] +pub(super) struct BrowserTargetRegistry { + contexts: HashMap, + owners: HashMap, +} + +impl BrowserTargetRegistry { + pub(super) fn session_storage_access( + &self, + owner: &BrowserPageOwnerKey, + ) -> Option { + let record = self.owners.get(&BrowserTargetId::new(owner.target_id()))?; + if record.browser_context_id.as_str() != owner.browser_context_id() { + return None; + } + Some(BrowserTargetSessionStorageAccess::new( + record.handle.clone(), + record.session_storage_store.clone(), + )) + } + + pub(super) fn validate_context_registration( + &self, + browser_context_id: &BrowserContextId, + projection: &BrowserTargetTopologyProjection, + ) -> Result<(), BrowserContextRegistryError> { + if projection.browser_context_id != *browser_context_id { + return Err( + BrowserContextRegistryError::TargetTopologyProjectionContextMismatch { + expected: browser_context_id.clone(), + projected: projection.browser_context_id.clone(), + }, + ); + } + if self.contexts.contains_key(browser_context_id) { + return Err(BrowserContextRegistryError::DuplicateTargetTopologyContext( + browser_context_id.clone(), + )); + } + let mut projected = HashSet::new(); + for slot in projection.slots() { + let handle = slot.target_handle(); + let target_id = BrowserTargetId::new(slot.target_id()); + if !projected.insert(target_id.clone()) { + return Err(BrowserContextRegistryError::DuplicateProjectedTarget( + target_id, + )); + } + if !handle.is_staged() { + return Err(BrowserContextRegistryError::TargetHandleNotStaged( + target_id, + )); + } + if self.owners.contains_key(&target_id) { + return Err(BrowserContextRegistryError::DuplicateBrowserTarget( + target_id, + )); + } + } + Ok(()) + } + + pub(super) fn validate_projection( + &self, + browser_context_id: &BrowserContextId, + projection: &BrowserTargetTopologyProjection, + ) -> Result<(), BrowserTargetRegistryError> { + if projection.browser_context_id != *browser_context_id { + return Err(BrowserTargetRegistryError::ProjectionContextMismatch { + expected: browser_context_id.clone(), + projected: projection.browser_context_id.clone(), + }); + } + let Some(topology) = self.contexts.get(browser_context_id) else { + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + browser_context_id.clone(), + )); + }; + let projected_active = projection.active_target_id().map(BrowserTargetId::new); + let projected_background = projection + .background_target_ids() + .map(BrowserTargetId::new) + .collect::>(); + if topology.active != projected_active || topology.background != projected_background { + return Err(BrowserTargetRegistryError::TopologyProjectionMismatch { + browser_context_id: browser_context_id.clone(), + authoritative_active: topology.active.clone(), + authoritative_background: topology.background.clone(), + projected_active, + projected_background, + }); + } + for slot in projection.slots() { + let handle = slot.target_handle(); + let target_id = BrowserTargetId::new(slot.target_id()); + let Some(record) = self.owners.get(&target_id) else { + return Err(BrowserTargetRegistryError::UnknownTarget(target_id)); + }; + if record.browser_context_id != *browser_context_id + || !record.handle.same_instance(handle) + || !handle.is_live() + { + return Err(BrowserTargetRegistryError::TargetHandleProjectionMismatch( + target_id, + )); + } + } + Ok(()) + } + + pub(super) fn validate_new_target( + &self, + browser_context_id: &BrowserContextId, + target_id: &BrowserTargetId, + ) -> Result<(), BrowserTargetRegistryError> { + if !self.contexts.contains_key(browser_context_id) { + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + browser_context_id.clone(), + )); + } + if self.owners.contains_key(target_id) { + return Err(BrowserTargetRegistryError::DuplicateTarget( + target_id.clone(), + )); + } + Ok(()) + } + + pub(super) fn validate_target_owner( + &self, + owner: &BrowserPageOwnerKey, + ) -> Result { + let target_id = BrowserTargetId::new(owner.target_id()); + let browser_context_id = BrowserContextId::new(owner.browser_context_id()); + let Some(actual) = self.owners.get(&target_id) else { + return Err(BrowserTargetRegistryError::UnknownTarget(target_id)); + }; + if actual.browser_context_id != browser_context_id { + return Err(BrowserTargetRegistryError::TargetContextMismatch { + target_id, + expected: browser_context_id, + actual: actual.browser_context_id.clone(), + }); + } + if !actual.handle.is_live() { + return Err(BrowserTargetRegistryError::TargetHandleProjectionMismatch( + target_id, + )); + } + let Some(topology) = self.contexts.get(&actual.browser_context_id) else { + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + actual.browser_context_id.clone(), + )); + }; + if topology.active.as_ref() == Some(&target_id) { + return Ok(BrowserTargetResidence::Active); + } + if topology.background.contains(&target_id) { + return Ok(BrowserTargetResidence::Background); + } + Err(BrowserTargetRegistryError::TargetTopologyOwnerMismatch( + owner.clone(), + )) + } + + pub(super) fn active_target( + &self, + browser_context_id: &BrowserContextId, + ) -> Option<&BrowserTargetId> { + self.contexts + .get(browser_context_id) + .and_then(|topology| topology.active.as_ref()) + } + + pub(super) fn active_target_for_registered_context( + &self, + browser_context_id: &BrowserContextId, + ) -> Result, BrowserTargetRegistryError> { + self.contexts + .get(browser_context_id) + .map(|topology| topology.active.as_ref()) + .ok_or_else(|| { + BrowserTargetRegistryError::UnknownBrowserContext(browser_context_id.clone()) + }) + } + + pub(super) fn context_for_target( + &self, + target_id: &BrowserTargetId, + ) -> Option<&BrowserContextId> { + self.owners + .get(target_id) + .map(|record| &record.browser_context_id) + } + + pub(super) fn handle_for_target( + &self, + target_id: &BrowserTargetId, + ) -> Option<&BrowserTargetHandle> { + self.owners.get(target_id).map(|record| &record.handle) + } + + pub(super) fn handle_is_current(&self, handle: &BrowserTargetHandle) -> bool { + self.owners + .get(&BrowserTargetId::new(handle.target_id())) + .is_some_and(|record| record.handle.same_instance(handle) && handle.is_live()) + } + + pub(super) fn target_count(&self) -> usize { + self.owners.len() + } + + pub(super) fn context_target_count(&self, browser_context_id: &BrowserContextId) -> usize { + self.contexts.get(browser_context_id).map_or(0, |topology| { + usize::from(topology.active.is_some()) + topology.background.len() + }) + } + + pub(super) fn ordered_targets_for_context( + &self, + browser_context_id: &BrowserContextId, + ) -> Result, BrowserTargetRegistryError> { + let Some(topology) = self.contexts.get(browser_context_id) else { + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + browser_context_id.clone(), + )); + }; + Ok(topology + .active + .iter() + .cloned() + .map(|target_id| (target_id, BrowserTargetResidence::Active)) + .chain( + topology + .background + .iter() + .cloned() + .map(|target_id| (target_id, BrowserTargetResidence::Background)), + ) + .collect()) + } +} + +impl BrowserNavigationOwner { + /// Returns the exact live Target capability for one public Target id. + pub fn target_handle(&self, target_id: &str) -> Option { + self.targets + .handle_for_target(&BrowserTargetId::new(target_id)) + .cloned() + } + + /// Checks both public Target id and stable Target instance capability. + pub fn target_handle_is_current(&self, handle: &BrowserTargetHandle) -> bool { + self.targets.handle_is_current(handle) + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-core/src/browser_host/navigation_owner/target_registry/tests.rs b/moli-core/src/browser_host/navigation_owner/target_registry/tests.rs new file mode 100644 index 0000000000..1d261db8df --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_registry/tests.rs @@ -0,0 +1,427 @@ +use super::*; +use std::sync::Arc; + +fn slot(handle: &BrowserTargetHandle) -> BrowserTargetSlotProjection { + BrowserTargetSlotProjection::new(handle.clone(), BrowserPageResidenceHandle::default()) +} + +fn register_context( + registry: &mut BrowserTargetRegistry, + browser_context_id: &str, + active: Option<&BrowserTargetHandle>, + background: &[&BrowserTargetHandle], +) { + let projection = BrowserTargetTopologyProjection::new( + browser_context_id, + active.map(slot), + background.iter().map(|handle| slot(handle)), + ); + let registration = registry + .begin_context_registration(projection, HashMap::new()) + .expect("test BrowserContext Target topology should stage"); + registry.commit_context_registration(registration); +} + +fn topology_ids( + registry: &BrowserTargetRegistry, + browser_context_id: &str, +) -> (Option, Vec) { + let topology = registry + .contexts + .get(&BrowserContextId::new(browser_context_id)) + .expect("test BrowserContext Target topology"); + ( + topology.active.as_ref().map(|id| id.as_str().to_owned()), + topology + .background + .iter() + .map(|id| id.as_str().to_owned()) + .collect(), + ) +} + +#[test] +fn context_registration_publishes_handles_only_at_commit() { + let mut registry = BrowserTargetRegistry::default(); + let active = BrowserTargetHandle::staged("target-a"); + let background = BrowserTargetHandle::staged("target-b"); + let projection = + BrowserTargetTopologyProjection::new("context-1", Some(slot(&active)), [slot(&background)]); + + let registration = registry + .begin_context_registration(projection, HashMap::new()) + .expect("exact staged topology should begin registration"); + + assert!(active.is_staged()); + assert!(background.is_staged()); + assert!(!registry.handle_is_current(&active)); + assert!(!registry.handle_is_current(&background)); + + registry.commit_context_registration(registration); + + assert!(registry.handle_is_current(&active)); + assert!(registry.handle_is_current(&background)); +} + +#[test] +fn target_session_storage_follows_exact_registration_and_public_id_reuse() { + let mut registry = BrowserTargetRegistry::default(); + register_context(&mut registry, "context-1", None, &[]); + let browser_context_id = BrowserContextId::new("context-1"); + let target_id = BrowserTargetId::new("target-a"); + let owner = BrowserPageOwnerKey::new("context-1", "target-a"); + let predecessor_store = crate::network::new_shared_web_storage_store(); + + let registration = registry + .begin_background_registration(&browser_context_id, &target_id, predecessor_store.clone()) + .expect("first Target namespace should stage atomically"); + let predecessor = registration.session_storage_access().clone(); + assert!(!predecessor.is_live()); + assert!(Arc::ptr_eq(predecessor.store(), &predecessor_store)); + registry.commit_target_registration(registration); + + assert!(predecessor.is_live()); + assert_eq!( + registry.session_storage_access(&owner), + Some(predecessor.clone()) + ); + + let removal = registry + .begin_target_removal(&owner) + .expect("first Target should reserve exact removal"); + assert!(registry.session_storage_access(&owner).is_none()); + assert!(predecessor.is_live()); + registry.commit_target_removal(removal); + assert!(!predecessor.is_live()); + assert!(predecessor.target_handle().is_retired()); + + let successor_store = crate::network::new_shared_web_storage_store(); + let registration = registry + .begin_background_registration(&browser_context_id, &target_id, successor_store.clone()) + .expect("the retired public id should accept a new exact Target instance"); + let successor = registration.session_storage_access().clone(); + registry.commit_target_registration(registration); + + assert!(successor.is_live()); + assert_ne!(successor.target_handle(), predecessor.target_handle()); + assert!(!Arc::ptr_eq(successor.store(), predecessor.store())); + assert!(Arc::ptr_eq(successor.store(), &successor_store)); + assert_eq!(registry.session_storage_access(&owner), Some(successor)); +} + +#[test] +fn target_session_storage_registration_rollback_publishes_no_access() { + let mut registry = BrowserTargetRegistry::default(); + register_context(&mut registry, "context-1", None, &[]); + let owner = BrowserPageOwnerKey::new("context-1", "target-a"); + let store = crate::network::new_shared_web_storage_store(); + + let registration = registry + .begin_background_registration( + &BrowserContextId::new("context-1"), + &BrowserTargetId::new("target-a"), + store.clone(), + ) + .expect("Target namespace should stage"); + let staged = registration.session_storage_access().clone(); + assert!(registry.rollback_target_registration(registration)); + + assert!(registry.session_storage_access(&owner).is_none()); + assert!(!staged.is_live()); + assert!(staged.target_handle().is_staged()); + assert!(Arc::ptr_eq(staged.store(), &store)); +} + +#[test] +fn context_removal_rolls_back_or_retires_target_session_storage_accesses_atomically() { + let mut registry = BrowserTargetRegistry::default(); + let active = BrowserTargetHandle::staged("target-a"); + let background = BrowserTargetHandle::staged("target-b"); + register_context(&mut registry, "context-1", Some(&active), &[&background]); + let active_owner = BrowserPageOwnerKey::new("context-1", "target-a"); + let background_owner = BrowserPageOwnerKey::new("context-1", "target-b"); + let active_access = registry + .session_storage_access(&active_owner) + .expect("active namespace access") + .clone(); + let background_access = registry + .session_storage_access(&background_owner) + .expect("background namespace access") + .clone(); + + let removal = registry + .begin_context_removal(&BrowserContextId::new("context-1")) + .expect("Context removal should stage all Target namespaces"); + assert!(registry.session_storage_access(&active_owner).is_none()); + assert!(registry.session_storage_access(&background_owner).is_none()); + assert!(active_access.is_live()); + assert!(background_access.is_live()); + assert!(registry.rollback_context_removal(removal)); + assert_eq!( + registry.session_storage_access(&active_owner), + Some(active_access.clone()) + ); + assert_eq!( + registry.session_storage_access(&background_owner), + Some(background_access.clone()) + ); + + let removal = registry + .begin_context_removal(&BrowserContextId::new("context-1")) + .expect("Context removal should restage after rollback"); + registry.commit_context_removal(removal); + assert!(!active_access.is_live()); + assert!(!background_access.is_live()); + assert!(active_access.target_handle().is_retired()); + assert!(background_access.target_handle().is_retired()); +} + +#[test] +fn context_registration_rejects_nonstaged_handle_without_partial_topology() { + let mut registry = BrowserTargetRegistry::default(); + let active = BrowserTargetHandle::staged("target-a"); + let invalid = BrowserTargetHandle::staged("target-b"); + assert!(invalid.reserve_activation()); + invalid.commit_activation_reservation(); + let projection = + BrowserTargetTopologyProjection::new("context-1", Some(slot(&active)), [slot(&invalid)]); + + let error = registry + .begin_context_registration(projection, HashMap::new()) + .expect_err("a live physical handle cannot be registered as staged"); + + assert_eq!( + error, + BrowserContextRegistryError::TargetHandleNotStaged(BrowserTargetId::new("target-b")) + ); + assert!(active.is_staged()); + assert!( + !registry + .contexts + .contains_key(&BrowserContextId::new("context-1")) + ); + assert!(registry.owners.is_empty()); +} + +#[test] +fn context_removal_missing_reverse_owner_is_typed_and_nonmutating() { + let mut registry = BrowserTargetRegistry::default(); + let active = BrowserTargetHandle::staged("target-a"); + let background = BrowserTargetHandle::staged("target-b"); + register_context(&mut registry, "context-1", Some(&active), &[&background]); + registry.owners.remove(&BrowserTargetId::new("target-b")); + + let error = registry + .begin_context_removal(&BrowserContextId::new("context-1")) + .expect_err("missing reverse owner must reject context removal"); + + assert_eq!( + error, + BrowserContextRegistryError::TargetTopologyOwnerMissing { + browser_context_id: BrowserContextId::new("context-1"), + target_id: BrowserTargetId::new("target-b"), + } + ); + assert_eq!( + topology_ids(®istry, "context-1"), + (Some("target-a".to_owned()), vec!["target-b".to_owned()]) + ); + assert!(active.is_live()); +} + +#[test] +fn context_removal_rejects_retired_handle_without_removing_registry_entries() { + let mut registry = BrowserTargetRegistry::default(); + let active = BrowserTargetHandle::staged("target-a"); + register_context(&mut registry, "context-1", Some(&active), &[]); + assert!(active.reserve_retirement()); + active.commit_retirement_reservation(); + + let error = registry + .begin_context_removal(&BrowserContextId::new("context-1")) + .expect_err("already-retired handle must reject context removal"); + + assert_eq!( + error, + BrowserContextRegistryError::TargetHandleNotLive(BrowserTargetId::new("target-a")) + ); + assert_eq!( + topology_ids(®istry, "context-1"), + (Some("target-a".to_owned()), Vec::new()) + ); + assert!( + registry + .owners + .contains_key(&BrowserTargetId::new("target-a")) + ); +} + +#[test] +fn context_removal_rollback_restores_exact_order_and_live_handles() { + let mut registry = BrowserTargetRegistry::default(); + let active = BrowserTargetHandle::staged("target-a"); + let first = BrowserTargetHandle::staged("target-b"); + let second = BrowserTargetHandle::staged("target-c"); + register_context( + &mut registry, + "context-1", + Some(&active), + &[&first, &second], + ); + + let removal = registry + .begin_context_removal(&BrowserContextId::new("context-1")) + .expect("exact context removal should stage"); + assert!( + !registry + .contexts + .contains_key(&BrowserContextId::new("context-1")) + ); + assert!(active.is_live()); + assert!(first.is_live()); + assert!(second.is_live()); + + assert!(registry.rollback_context_removal(removal)); + assert_eq!( + topology_ids(®istry, "context-1"), + ( + Some("target-a".to_owned()), + vec!["target-b".to_owned(), "target-c".to_owned()] + ) + ); + assert!(registry.handle_is_current(&active)); + assert!(registry.handle_is_current(&first)); + assert!(registry.handle_is_current(&second)); +} + +#[test] +fn active_registration_rollback_restores_previous_topology() { + let mut registry = BrowserTargetRegistry::default(); + let active = BrowserTargetHandle::staged("target-a"); + let first = BrowserTargetHandle::staged("target-b"); + let second = BrowserTargetHandle::staged("target-c"); + register_context( + &mut registry, + "context-1", + Some(&active), + &[&first, &second], + ); + + let registration = registry + .begin_active_registration( + &BrowserContextId::new("context-1"), + &BrowserTargetId::new("target-d"), + crate::network::new_shared_web_storage_store(), + ) + .expect("new active Target should stage"); + let staged = registration.handle().clone(); + assert_eq!( + topology_ids(®istry, "context-1"), + ( + Some("target-d".to_owned()), + vec![ + "target-b".to_owned(), + "target-c".to_owned(), + "target-a".to_owned() + ] + ) + ); + + assert!(registry.rollback_target_registration(registration)); + assert_eq!( + topology_ids(®istry, "context-1"), + ( + Some("target-a".to_owned()), + vec!["target-b".to_owned(), "target-c".to_owned()] + ) + ); + assert!(staged.is_staged()); + assert!(registry.handle_is_current(&active)); + assert!( + !registry + .owners + .contains_key(&BrowserTargetId::new("target-d")) + ); +} + +#[test] +fn active_replacement_rollback_restores_exact_source_capability() { + let mut registry = BrowserTargetRegistry::default(); + let active = BrowserTargetHandle::staged("target-a"); + register_context(&mut registry, "context-1", Some(&active), &[]); + + let replacement = registry + .begin_active_replacement( + &BrowserContextId::new("context-1"), + &BrowserTargetId::new("target-a"), + &BrowserTargetId::new("target-b"), + crate::network::new_shared_web_storage_store(), + ) + .expect("exact active replacement should stage"); + let replacement_handle = replacement.replacement_handle().clone(); + assert!(active.is_live()); + assert!(replacement_handle.is_staged()); + assert_eq!( + topology_ids(®istry, "context-1"), + (Some("target-b".to_owned()), Vec::new()) + ); + + assert!(registry.rollback_active_replacement(replacement)); + assert_eq!( + topology_ids(®istry, "context-1"), + (Some("target-a".to_owned()), Vec::new()) + ); + assert!(registry.handle_is_current(&active)); + assert!(replacement_handle.is_staged()); +} + +#[test] +fn activation_rollback_and_commit_preserve_background_vector_order() { + let mut registry = BrowserTargetRegistry::default(); + let active = BrowserTargetHandle::staged("target-a"); + let first = BrowserTargetHandle::staged("target-b"); + let second = BrowserTargetHandle::staged("target-c"); + let third = BrowserTargetHandle::staged("target-d"); + register_context( + &mut registry, + "context-1", + Some(&active), + &[&first, &second, &third], + ); + let owner = BrowserPageOwnerKey::new("context-1", "target-b"); + + let activation = registry + .begin_activation(&owner) + .expect("background Target should stage for activation"); + assert!(registry.rollback_activation(activation)); + assert_eq!( + topology_ids(®istry, "context-1"), + ( + Some("target-a".to_owned()), + vec![ + "target-b".to_owned(), + "target-c".to_owned(), + "target-d".to_owned() + ] + ) + ); + + let activation = registry + .begin_activation(&owner) + .expect("background Target should stage again"); + assert_eq!( + registry.commit_activation(activation), + Some(BrowserTargetId::new("target-a")) + ); + assert_eq!( + topology_ids(®istry, "context-1"), + ( + Some("target-b".to_owned()), + vec![ + "target-c".to_owned(), + "target-d".to_owned(), + "target-a".to_owned() + ] + ) + ); +} diff --git a/moli-core/src/browser_host/navigation_owner/target_registry/transaction.rs b/moli-core/src/browser_host/navigation_owner/target_registry/transaction.rs new file mode 100644 index 0000000000..2e2e64fd7d --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_registry/transaction.rs @@ -0,0 +1,904 @@ +use super::*; + +/// Target-side staging for an initial BrowserContext topology. The registry +/// owns the provisional map entries while engine/context registration decides; +/// the handles remain observably staged until commit. +#[derive(Debug)] +pub(in crate::browser_host::navigation_owner) struct BrowserTargetContextRegistrationTransaction { + browser_context_id: BrowserContextId, + target_ids: Vec, + handles: Vec, + session_storage_accesses: Vec<(BrowserTargetId, BrowserTargetSessionStorageAccess)>, +} + +/// Target-side staging for BrowserContext removal. Exact topology and reverse +/// owners remain available for same-turn rollback while every handle stays +/// observably live under a retirement reservation. +#[derive(Debug)] +pub(in crate::browser_host::navigation_owner) struct BrowserTargetContextRemovalTransaction { + browser_context_id: BrowserContextId, + topology: BrowserContextTargets, + records: Vec<(BrowserTargetId, BrowserTargetRecord)>, +} + +enum BrowserTargetRegistrationTopologyChange { + Background { + index: usize, + }, + Active { + previous_active: Option, + previous_background_index: Option, + }, +} + +/// Reversible new-Target topology and reverse-owner staging around an engine +/// handoff. The new capability is not observably live until commit. +pub(in crate::browser_host::navigation_owner) struct BrowserTargetRegistrationTransaction { + browser_context_id: BrowserContextId, + target_id: BrowserTargetId, + handle: BrowserTargetHandle, + session_storage_access: BrowserTargetSessionStorageAccess, + topology_change: BrowserTargetRegistrationTopologyChange, +} + +impl BrowserTargetRegistrationTransaction { + pub(in crate::browser_host::navigation_owner) fn handle(&self) -> &BrowserTargetHandle { + &self.handle + } + + pub(in crate::browser_host::navigation_owner) fn previous_active_target_id( + &self, + ) -> Option<&BrowserTargetId> { + match &self.topology_change { + BrowserTargetRegistrationTopologyChange::Background { .. } => None, + BrowserTargetRegistrationTopologyChange::Active { + previous_active, .. + } => previous_active.as_ref(), + } + } + + pub(in crate::browser_host::navigation_owner) fn session_storage_access( + &self, + ) -> &BrowserTargetSessionStorageAccess { + &self.session_storage_access + } +} + +/// Reversible exact active-placeholder replacement. Both lifecycle changes +/// are reserved before the engine handoff and published only after it succeeds. +pub(in crate::browser_host::navigation_owner) struct BrowserTargetReplacementTransaction { + browser_context_id: BrowserContextId, + expected_target_id: BrowserTargetId, + previous_record: BrowserTargetRecord, + replacement_target_id: BrowserTargetId, + replacement_handle: BrowserTargetHandle, + replacement_session_storage_access: BrowserTargetSessionStorageAccess, +} + +impl BrowserTargetReplacementTransaction { + pub(in crate::browser_host::navigation_owner) fn replacement_handle( + &self, + ) -> &BrowserTargetHandle { + &self.replacement_handle + } + + pub(in crate::browser_host::navigation_owner) fn replacement_session_storage_access( + &self, + ) -> &BrowserTargetSessionStorageAccess { + &self.replacement_session_storage_access + } +} + +/// Background Target removed from its exact vector slot while the engine +/// registry decides the matching activation handoff. +pub(in crate::browser_host::navigation_owner) struct BrowserTargetActivationTransaction { + browser_context_id: BrowserContextId, + target_id: BrowserTargetId, + background_index: usize, + previous_active: Option, + previous_background_index: Option, +} + +/// Reversible removal owned entirely by one synchronous Browser Owner turn. +/// +/// Target termination removes topology before advancing the Page generation. +/// Keeping the live handle in this private token lets a stale Page commit put +/// the exact Target back without retiring and later reviving its capability. +pub(in crate::browser_host::navigation_owner) struct BrowserTargetRemovalTransaction { + target_id: BrowserTargetId, + browser_context_id: BrowserContextId, + record: BrowserTargetRecord, + residence: BrowserTargetResidence, + background_index: Option, +} + +impl BrowserTargetRegistry { + pub(in crate::browser_host::navigation_owner) fn begin_context_registration( + &mut self, + projection: BrowserTargetTopologyProjection, + mut target_session_storage_stores: HashMap, + ) -> Result { + let browser_context_id = projection.browser_context_id.clone(); + self.validate_context_registration(&browser_context_id, &projection)?; + + let handles = projection + .slots() + .map(|slot| slot.target_handle().clone()) + .collect::>(); + for (index, handle) in handles.iter().enumerate() { + if handle.reserve_activation() { + continue; + } + for reserved in handles[..index].iter().rev() { + reserved.rollback_activation_reservation(); + } + return Err(BrowserContextRegistryError::TargetHandleNotStaged( + BrowserTargetId::new(handle.target_id()), + )); + } + + let topology = BrowserContextTargets { + active: projection + .active_target + .as_ref() + .map(|slot| BrowserTargetId::new(slot.target_id())), + background: projection + .background_targets + .iter() + .map(|slot| BrowserTargetId::new(slot.target_id())) + .collect(), + }; + let target_ids = topology + .active + .iter() + .chain(topology.background.iter()) + .cloned() + .collect::>(); + match self.contexts.entry(browser_context_id.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(topology); + } + std::collections::hash_map::Entry::Occupied(_) => { + for handle in handles.iter().rev() { + handle.rollback_activation_reservation(); + } + return Err(BrowserContextRegistryError::DuplicateTargetTopologyContext( + browser_context_id, + )); + } + } + + let mut session_storage_accesses = Vec::with_capacity(target_ids.len()); + for ((target_id, handle), inserted_index) in target_ids.iter().zip(handles.iter()).zip(0..) + { + let session_storage_store = target_session_storage_stores + .remove(target_id) + .unwrap_or_else(crate::network::new_shared_web_storage_store); + let session_storage_access = BrowserTargetSessionStorageAccess::new( + handle.clone(), + session_storage_store.clone(), + ); + if let std::collections::hash_map::Entry::Vacant(entry) = + self.owners.entry(target_id.clone()) + { + entry.insert(BrowserTargetRecord { + browser_context_id: browser_context_id.clone(), + handle: handle.clone(), + session_storage_store, + }); + session_storage_accesses.push((target_id.clone(), session_storage_access)); + continue; + } + + self.contexts.remove(&browser_context_id); + for inserted_target_id in &target_ids[..inserted_index] { + self.owners.remove(inserted_target_id); + } + for reserved in handles.iter().rev() { + reserved.rollback_activation_reservation(); + } + return Err(BrowserContextRegistryError::DuplicateBrowserTarget( + target_id.clone(), + )); + } + + Ok(BrowserTargetContextRegistrationTransaction { + browser_context_id, + target_ids, + handles, + session_storage_accesses, + }) + } + + pub(in crate::browser_host::navigation_owner) fn rollback_context_registration( + &mut self, + transaction: BrowserTargetContextRegistrationTransaction, + ) -> bool { + let removed_context = self.contexts.remove(&transaction.browser_context_id); + let mut exact = removed_context.is_some(); + for target_id in &transaction.target_ids { + exact &= self.owners.remove(target_id).is_some(); + } + for handle in transaction.handles.iter().rev() { + handle.rollback_activation_reservation(); + } + debug_assert!( + exact, + "same-turn BrowserContext Target registration rollback must recover every staged entry" + ); + exact + } + + pub(in crate::browser_host::navigation_owner) fn commit_context_registration( + &mut self, + transaction: BrowserTargetContextRegistrationTransaction, + ) -> Vec<(BrowserTargetId, BrowserTargetSessionStorageAccess)> { + for handle in &transaction.handles { + handle.commit_activation_reservation(); + } + transaction.session_storage_accesses + } + + pub(in crate::browser_host::navigation_owner) fn begin_context_removal( + &mut self, + browser_context_id: &BrowserContextId, + ) -> Result { + let Some(topology) = self.contexts.get(browser_context_id).cloned() else { + return Err(BrowserContextRegistryError::UnknownBrowserContext( + browser_context_id.clone(), + )); + }; + let target_ids = topology + .active + .iter() + .chain(topology.background.iter()) + .cloned() + .collect::>(); + let mut handles = Vec::with_capacity(target_ids.len()); + for target_id in &target_ids { + let Some(record) = self.owners.get(target_id) else { + return Err(BrowserContextRegistryError::TargetTopologyOwnerMissing { + browser_context_id: browser_context_id.clone(), + target_id: target_id.clone(), + }); + }; + if record.browser_context_id != *browser_context_id { + return Err( + BrowserContextRegistryError::TargetTopologyOwnerContextMismatch { + browser_context_id: browser_context_id.clone(), + target_id: target_id.clone(), + actual_browser_context_id: record.browser_context_id.clone(), + }, + ); + } + handles.push(record.handle.clone()); + } + + for (index, handle) in handles.iter().enumerate() { + if handle.reserve_retirement() { + continue; + } + for reserved in handles[..index].iter().rev() { + reserved.rollback_retirement_reservation(); + } + return Err(BrowserContextRegistryError::TargetHandleNotLive( + BrowserTargetId::new(handle.target_id()), + )); + } + + let mut records = Vec::with_capacity(target_ids.len()); + for target_id in &target_ids { + let Some(record) = self.owners.remove(target_id) else { + for (removed_target_id, removed_record) in records.drain(..) { + self.owners.insert(removed_target_id, removed_record); + } + for handle in handles.iter().rev() { + handle.rollback_retirement_reservation(); + } + return Err(BrowserContextRegistryError::TargetTopologyOwnerMissing { + browser_context_id: browser_context_id.clone(), + target_id: target_id.clone(), + }); + }; + records.push((target_id.clone(), record)); + } + let Some(removed_topology) = self.contexts.remove(browser_context_id) else { + for (target_id, record) in records.drain(..) { + self.owners.insert(target_id, record); + } + for handle in handles.iter().rev() { + handle.rollback_retirement_reservation(); + } + return Err(BrowserContextRegistryError::UnknownBrowserContext( + browser_context_id.clone(), + )); + }; + + Ok(BrowserTargetContextRemovalTransaction { + browser_context_id: browser_context_id.clone(), + topology: removed_topology, + records, + }) + } + + pub(in crate::browser_host::navigation_owner) fn rollback_context_removal( + &mut self, + transaction: BrowserTargetContextRemovalTransaction, + ) -> bool { + let mut exact = match self.contexts.entry(transaction.browser_context_id.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(transaction.topology); + true + } + std::collections::hash_map::Entry::Occupied(_) => false, + }; + for (target_id, record) in transaction.records { + record.handle.rollback_retirement_reservation(); + match self.owners.entry(target_id) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(record); + } + std::collections::hash_map::Entry::Occupied(_) => exact = false, + } + } + debug_assert!( + exact, + "same-turn BrowserContext Target removal rollback must restore exact topology and owners" + ); + exact + } + + pub(in crate::browser_host::navigation_owner) fn commit_context_removal( + &mut self, + transaction: BrowserTargetContextRemovalTransaction, + ) -> Vec { + transaction + .records + .into_iter() + .map(|(target_id, record)| { + record.handle.commit_retirement_reservation(); + target_id + }) + .collect() + } + + pub(in crate::browser_host::navigation_owner) fn begin_background_registration( + &mut self, + browser_context_id: &BrowserContextId, + target_id: &BrowserTargetId, + session_storage_store: SharedWebStorageStore, + ) -> Result { + self.begin_target_registration( + browser_context_id, + target_id, + BrowserTargetResidence::Background, + session_storage_store, + ) + } + + pub(in crate::browser_host::navigation_owner) fn begin_active_registration( + &mut self, + browser_context_id: &BrowserContextId, + target_id: &BrowserTargetId, + session_storage_store: SharedWebStorageStore, + ) -> Result { + self.begin_target_registration( + browser_context_id, + target_id, + BrowserTargetResidence::Active, + session_storage_store, + ) + } + + fn begin_target_registration( + &mut self, + browser_context_id: &BrowserContextId, + target_id: &BrowserTargetId, + residence: BrowserTargetResidence, + session_storage_store: SharedWebStorageStore, + ) -> Result { + self.validate_new_target(browser_context_id, target_id)?; + let handle = BrowserTargetHandle::staged(target_id.as_str()); + if !handle.reserve_activation() { + return Err(BrowserTargetRegistryError::TargetHandleNotStaged( + target_id.clone(), + )); + } + + if let std::collections::hash_map::Entry::Occupied(_) = self.owners.entry(target_id.clone()) + { + handle.rollback_activation_reservation(); + return Err(BrowserTargetRegistryError::DuplicateTarget( + target_id.clone(), + )); + } + let Some(topology) = self.contexts.get_mut(browser_context_id) else { + handle.rollback_activation_reservation(); + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + browser_context_id.clone(), + )); + }; + + let topology_change = match residence { + BrowserTargetResidence::Background => { + let index = topology.background.len(); + topology.background.push(target_id.clone()); + BrowserTargetRegistrationTopologyChange::Background { index } + } + BrowserTargetResidence::Active => { + let previous_active = topology.active.replace(target_id.clone()); + let previous_background_index = previous_active.as_ref().map(|previous| { + let index = topology.background.len(); + topology.background.push(previous.clone()); + index + }); + BrowserTargetRegistrationTopologyChange::Active { + previous_active, + previous_background_index, + } + } + }; + let session_storage_access = + BrowserTargetSessionStorageAccess::new(handle.clone(), session_storage_store.clone()); + self.owners.insert( + target_id.clone(), + BrowserTargetRecord { + browser_context_id: browser_context_id.clone(), + handle: handle.clone(), + session_storage_store, + }, + ); + Ok(BrowserTargetRegistrationTransaction { + browser_context_id: browser_context_id.clone(), + target_id: target_id.clone(), + handle, + session_storage_access, + topology_change, + }) + } + + pub(in crate::browser_host::navigation_owner) fn rollback_target_registration( + &mut self, + transaction: BrowserTargetRegistrationTransaction, + ) -> bool { + let removed_owner = self.owners.remove(&transaction.target_id); + let Some(topology) = self.contexts.get_mut(&transaction.browser_context_id) else { + transaction.handle.rollback_activation_reservation(); + debug_assert!( + false, + "same-turn Target registration rollback lost its context" + ); + return false; + }; + let topology_restored = match transaction.topology_change { + BrowserTargetRegistrationTopologyChange::Background { index } => { + if topology.background.get(index) == Some(&transaction.target_id) { + topology.background.remove(index); + true + } else { + false + } + } + BrowserTargetRegistrationTopologyChange::Active { + previous_active, + previous_background_index, + } => { + let active_matches = topology.active.as_ref() == Some(&transaction.target_id); + let background_matches = match (&previous_active, previous_background_index) { + (Some(previous), Some(index)) => { + topology.background.get(index) == Some(previous) + } + (None, None) => true, + _ => false, + }; + if active_matches && background_matches { + topology.active = previous_active; + if let Some(index) = previous_background_index { + topology.background.remove(index); + } + true + } else { + false + } + } + }; + transaction.handle.rollback_activation_reservation(); + let exact = removed_owner.is_some() && topology_restored; + debug_assert!( + exact, + "same-turn Target registration rollback must restore exact topology and owner" + ); + exact + } + + pub(in crate::browser_host::navigation_owner) fn commit_target_registration( + &mut self, + transaction: BrowserTargetRegistrationTransaction, + ) { + transaction.handle.commit_activation_reservation(); + } + + pub(in crate::browser_host::navigation_owner) fn begin_active_replacement( + &mut self, + browser_context_id: &BrowserContextId, + expected_target_id: &BrowserTargetId, + replacement_target_id: &BrowserTargetId, + replacement_session_storage_store: SharedWebStorageStore, + ) -> Result { + let expected_owner = + BrowserPageOwnerKey::new(browser_context_id.as_str(), expected_target_id.as_str()); + if self.validate_target_owner(&expected_owner)? != BrowserTargetResidence::Active { + return Err(BrowserTargetRegistryError::TargetIsNotActive( + expected_owner, + )); + } + self.validate_new_target(browser_context_id, replacement_target_id)?; + + let replacement_handle = BrowserTargetHandle::staged(replacement_target_id.as_str()); + if !replacement_handle.reserve_activation() { + return Err(BrowserTargetRegistryError::TargetHandleNotStaged( + replacement_target_id.clone(), + )); + } + let Some(previous_record) = self.owners.get(expected_target_id).cloned() else { + replacement_handle.rollback_activation_reservation(); + return Err(BrowserTargetRegistryError::UnknownTarget( + expected_target_id.clone(), + )); + }; + if !previous_record.handle.reserve_retirement() { + replacement_handle.rollback_activation_reservation(); + return Err(BrowserTargetRegistryError::TargetHandleNotLive( + expected_target_id.clone(), + )); + } + let Some(topology) = self.contexts.get_mut(browser_context_id) else { + previous_record.handle.rollback_retirement_reservation(); + replacement_handle.rollback_activation_reservation(); + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + browser_context_id.clone(), + )); + }; + if topology.active.as_ref() != Some(expected_target_id) { + previous_record.handle.rollback_retirement_reservation(); + replacement_handle.rollback_activation_reservation(); + return Err(BrowserTargetRegistryError::TargetIsNotActive( + expected_owner, + )); + } + + let Some(removed) = self.owners.remove(expected_target_id) else { + previous_record.handle.rollback_retirement_reservation(); + replacement_handle.rollback_activation_reservation(); + return Err(BrowserTargetRegistryError::UnknownTarget( + expected_target_id.clone(), + )); + }; + match self.owners.entry(replacement_target_id.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(BrowserTargetRecord { + browser_context_id: browser_context_id.clone(), + handle: replacement_handle.clone(), + session_storage_store: replacement_session_storage_store.clone(), + }); + } + std::collections::hash_map::Entry::Occupied(_) => { + self.owners.insert(expected_target_id.clone(), removed); + previous_record.handle.rollback_retirement_reservation(); + replacement_handle.rollback_activation_reservation(); + return Err(BrowserTargetRegistryError::DuplicateTarget( + replacement_target_id.clone(), + )); + } + } + topology.active = Some(replacement_target_id.clone()); + + Ok(BrowserTargetReplacementTransaction { + browser_context_id: browser_context_id.clone(), + expected_target_id: expected_target_id.clone(), + previous_record: removed, + replacement_target_id: replacement_target_id.clone(), + replacement_session_storage_access: BrowserTargetSessionStorageAccess::new( + replacement_handle.clone(), + replacement_session_storage_store, + ), + replacement_handle, + }) + } + + pub(in crate::browser_host::navigation_owner) fn rollback_active_replacement( + &mut self, + transaction: BrowserTargetReplacementTransaction, + ) -> bool { + let topology_restored = self + .contexts + .get_mut(&transaction.browser_context_id) + .is_some_and(|topology| { + if topology.active.as_ref() != Some(&transaction.replacement_target_id) { + return false; + } + topology.active = Some(transaction.expected_target_id.clone()); + true + }); + let removed_replacement = self.owners.remove(&transaction.replacement_target_id); + let restored_previous = match self.owners.entry(transaction.expected_target_id.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(transaction.previous_record.clone()); + true + } + std::collections::hash_map::Entry::Occupied(_) => false, + }; + transaction + .previous_record + .handle + .rollback_retirement_reservation(); + transaction + .replacement_handle + .rollback_activation_reservation(); + let exact = topology_restored && removed_replacement.is_some() && restored_previous; + debug_assert!( + exact, + "same-turn active Target replacement rollback must restore exact source and topology" + ); + exact + } + + pub(in crate::browser_host::navigation_owner) fn commit_active_replacement( + &mut self, + transaction: BrowserTargetReplacementTransaction, + ) { + transaction + .previous_record + .handle + .commit_retirement_reservation(); + transaction + .replacement_handle + .commit_activation_reservation(); + } + + pub(in crate::browser_host::navigation_owner) fn begin_activation( + &mut self, + owner: &BrowserPageOwnerKey, + ) -> Result { + let residence = self.validate_target_owner(owner)?; + if residence != BrowserTargetResidence::Background { + return Err(BrowserTargetRegistryError::TargetIsNotBackground( + owner.clone(), + )); + } + let browser_context_id = BrowserContextId::new(owner.browser_context_id()); + let target_id = BrowserTargetId::new(owner.target_id()); + let Some(topology) = self.contexts.get_mut(&browser_context_id) else { + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + browser_context_id, + )); + }; + let Some(background_index) = topology + .background + .iter() + .position(|candidate| candidate == &target_id) + else { + return Err(BrowserTargetRegistryError::TargetIsNotBackground( + owner.clone(), + )); + }; + let activated = topology.background.remove(background_index); + let previous_active = topology.active.replace(activated); + let previous_background_index = previous_active.as_ref().map(|previous| { + let index = topology.background.len(); + topology.background.push(previous.clone()); + index + }); + Ok(BrowserTargetActivationTransaction { + browser_context_id, + target_id, + background_index, + previous_active, + previous_background_index, + }) + } + + pub(in crate::browser_host::navigation_owner) fn rollback_activation( + &mut self, + transaction: BrowserTargetActivationTransaction, + ) -> bool { + let Some(topology) = self.contexts.get_mut(&transaction.browser_context_id) else { + debug_assert!( + false, + "same-turn Target activation rollback lost its context" + ); + return false; + }; + if topology.active.as_ref() != Some(&transaction.target_id) { + debug_assert!( + false, + "same-turn Target activation rollback lost its active slot" + ); + return false; + } + let previous_matches = match ( + transaction.previous_active.as_ref(), + transaction.previous_background_index, + ) { + (Some(previous), Some(index)) => topology.background.get(index) == Some(previous), + (None, None) => true, + _ => false, + }; + if !previous_matches || transaction.background_index > topology.background.len() { + debug_assert!( + false, + "same-turn Target activation rollback lost an exact background slot" + ); + return false; + } + topology.active = transaction.previous_active; + if let Some(index) = transaction.previous_background_index { + topology.background.remove(index); + } + topology + .background + .insert(transaction.background_index, transaction.target_id); + true + } + + pub(in crate::browser_host::navigation_owner) fn commit_activation( + &mut self, + transaction: BrowserTargetActivationTransaction, + ) -> Option { + transaction.previous_active + } + + pub(in crate::browser_host::navigation_owner) fn remove_target( + &mut self, + owner: &BrowserPageOwnerKey, + ) -> Result { + let transaction = self.begin_target_removal(owner)?; + Ok(self.commit_target_removal(transaction)) + } + + pub(in crate::browser_host::navigation_owner) fn begin_target_removal( + &mut self, + owner: &BrowserPageOwnerKey, + ) -> Result { + let residence = self.validate_target_owner(owner)?; + let browser_context_id = BrowserContextId::new(owner.browser_context_id()); + let target_id = BrowserTargetId::new(owner.target_id()); + let background_index = match residence { + BrowserTargetResidence::Active => None, + BrowserTargetResidence::Background => { + let index = self + .contexts + .get(&browser_context_id) + .and_then(|topology| { + topology + .background + .iter() + .position(|candidate| candidate == &target_id) + }) + .ok_or_else(|| { + BrowserTargetRegistryError::TargetIsNotBackground(owner.clone()) + })?; + Some(index) + } + }; + let Some(handle) = self + .owners + .get(&target_id) + .map(|record| record.handle.clone()) + else { + return Err(BrowserTargetRegistryError::UnknownTarget(target_id)); + }; + if !handle.reserve_retirement() { + return Err(BrowserTargetRegistryError::TargetHandleNotLive(target_id)); + } + let record = self.owners.remove(&target_id).ok_or_else(|| { + handle.rollback_retirement_reservation(); + BrowserTargetRegistryError::UnknownTarget(target_id.clone()) + })?; + let Some(topology) = self.contexts.get_mut(&browser_context_id) else { + self.owners.insert(target_id.clone(), record); + handle.rollback_retirement_reservation(); + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + browser_context_id, + )); + }; + match residence { + BrowserTargetResidence::Active => { + if topology.active.as_ref() != Some(&target_id) { + self.owners.insert(target_id, record); + handle.rollback_retirement_reservation(); + return Err(BrowserTargetRegistryError::TargetIsNotActive(owner.clone())); + } + topology.active = None; + } + BrowserTargetResidence::Background => { + let Some(index) = background_index + .filter(|index| topology.background.get(*index) == Some(&target_id)) + else { + self.owners.insert(target_id, record); + handle.rollback_retirement_reservation(); + return Err(BrowserTargetRegistryError::TargetIsNotBackground( + owner.clone(), + )); + }; + topology.background.remove(index); + } + } + Ok(BrowserTargetRemovalTransaction { + target_id, + browser_context_id, + record, + residence, + background_index, + }) + } + + pub(in crate::browser_host::navigation_owner) fn rollback_target_removal( + &mut self, + transaction: BrowserTargetRemovalTransaction, + ) -> bool { + let BrowserTargetRemovalTransaction { + target_id, + browser_context_id, + record, + residence, + background_index, + } = transaction; + let Some(topology) = self.contexts.get_mut(&browser_context_id) else { + debug_assert!( + false, + "same-turn Target removal rollback lost its BrowserContext" + ); + return false; + }; + if self.owners.contains_key(&target_id) { + debug_assert!( + false, + "same-turn Target removal rollback found a replacement owner" + ); + return false; + } + let background_index = match residence { + BrowserTargetResidence::Active if topology.active.is_none() => None, + BrowserTargetResidence::Active => { + debug_assert!( + false, + "same-turn active Target rollback found an occupied slot" + ); + return false; + } + BrowserTargetResidence::Background => { + let Some(index) = + background_index.filter(|index| *index <= topology.background.len()) + else { + debug_assert!(false, "same-turn background Target rollback lost its slot"); + return false; + }; + Some(index) + } + }; + record.handle.rollback_retirement_reservation(); + match (residence, background_index) { + (BrowserTargetResidence::Active, _) => topology.active = Some(target_id.clone()), + (BrowserTargetResidence::Background, Some(index)) => { + topology.background.insert(index, target_id.clone()); + } + (BrowserTargetResidence::Background, None) => { + debug_assert!( + false, + "validated background rollback index must remain present" + ); + return false; + } + } + self.owners.insert(target_id, record).is_none() + } + + pub(in crate::browser_host::navigation_owner) fn commit_target_removal( + &mut self, + transaction: BrowserTargetRemovalTransaction, + ) -> BrowserTargetResidence { + transaction.record.handle.commit_retirement_reservation(); + transaction.residence + } +} diff --git a/moli-core/src/browser_host/navigation_owner/target_runtime_registry.rs b/moli-core/src/browser_host/navigation_owner/target_runtime_registry.rs new file mode 100644 index 0000000000..b926af5138 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_runtime_registry.rs @@ -0,0 +1,55 @@ +use std::collections::HashMap; + +use super::{ + BrowserPageOwnerKey, document_lifecycle_registry::BrowserDocumentLifecycleRecord, + engine_registry::BrowserPageOwner, history_registry::BrowserTargetNavigationHistory, + initial_document::BrowserInitialEmptyDocumentRecord, page_registry::BrowserPageResidenceRecord, + request_registry::BrowserTargetDocumentNavigationState, + target_termination::BrowserTargetTerminationState, +}; + +/// All runtime state owned by one exact Browser Target. +/// +/// Module-specific registries remain the transaction boundaries, but their +/// storage is aggregated here so Target retirement has one exact record to +/// remove instead of a collection of independently keyed maps. +#[derive(Default)] +pub(super) struct BrowserTargetRuntimeRecord { + pub(super) engine: Option, + pub(super) page_residence: Option, + pub(super) document_lifecycle: Option, + pub(super) initial_empty_document: Option, + pub(super) navigation_history: Option, + pub(super) document_navigation: Option, + pub(super) termination: Option, +} + +impl BrowserTargetRuntimeRecord { + fn is_empty(&self) -> bool { + self.engine.is_none() + && self.page_residence.is_none() + && self.document_lifecycle.is_none() + && self.initial_empty_document.is_none() + && self.navigation_history.is_none() + && self.document_navigation.is_none() + && self.termination.is_none() + } +} + +#[derive(Default)] +pub(super) struct BrowserTargetRuntimeRegistry { + pub(super) entries: HashMap, +} + +impl BrowserTargetRuntimeRegistry { + pub(super) fn remove( + &mut self, + owner: &BrowserPageOwnerKey, + ) -> Option { + self.entries.remove(owner) + } + + pub(super) fn prune_empty(&mut self) { + self.entries.retain(|_, record| !record.is_empty()); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/target_snapshot.rs b/moli-core/src/browser_host/navigation_owner/target_snapshot.rs new file mode 100644 index 0000000000..a340edda4e --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_snapshot.rs @@ -0,0 +1,247 @@ +use crate::browser_host::{ + BrowserContextHandle, BrowserPageOwnerKey, BrowserTargetHandle, PageResidenceIdentity, +}; + +use super::{ + BrowserContextRegistryError, BrowserInstanceId, BrowserNavigationOwner, + BrowserPageResidenceRegistryError, BrowserTargetRegistryError, BrowserTargetResidence, +}; + +/// One exact top-level Target captured from Browser Core current state. +/// +/// Metadata visible only to a frontend (title, URL, opener and attachment) +/// deliberately remains outside this value. The handles make a delayed +/// consumer unable to address a later Context, Target or Page slot that +/// reused the same public ids. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTargetStateSnapshot { + browser_instance_id: BrowserInstanceId, + browser_context: BrowserContextHandle, + target: BrowserTargetHandle, + page_residence: PageResidenceIdentity, + residence: BrowserTargetResidence, +} + +impl BrowserTargetStateSnapshot { + pub fn browser_instance_id(&self) -> BrowserInstanceId { + self.browser_instance_id + } + + pub fn browser_context_id(&self) -> &str { + self.browser_context.browser_context_id() + } + + pub fn browser_context_handle(&self) -> &BrowserContextHandle { + &self.browser_context + } + + pub fn target_id(&self) -> &str { + self.target.target_id() + } + + pub fn target_handle(&self) -> &BrowserTargetHandle { + &self.target + } + + pub fn page_residence(&self) -> &PageResidenceIdentity { + &self.page_residence + } + + pub fn residence(&self) -> BrowserTargetResidence { + self.residence + } +} + +/// One exact BrowserContext and its ordered top-level Target current state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserContextTargetSnapshot { + browser_instance_id: BrowserInstanceId, + browser_context: BrowserContextHandle, + selected: bool, + targets: Vec, +} + +impl BrowserContextTargetSnapshot { + pub fn browser_instance_id(&self) -> BrowserInstanceId { + self.browser_instance_id + } + + pub fn browser_context_id(&self) -> &str { + self.browser_context.browser_context_id() + } + + pub fn browser_context_handle(&self) -> &BrowserContextHandle { + &self.browser_context + } + + pub fn is_selected(&self) -> bool { + self.selected + } + + pub fn targets(&self) -> &[BrowserTargetStateSnapshot] { + &self.targets + } + + pub fn target(&self, target_id: &str) -> Option<&BrowserTargetStateSnapshot> { + self.targets + .iter() + .find(|target| target.target_id() == target_id) + } +} + +/// Browser-owned current-state resnapshot for all live top-level Targets. +/// +/// Contexts are ordered selected-first followed by Core's inactive order. +/// Targets are ordered active-first followed by Core's background order. This +/// is a resnapshot, not a replay of Target-created occurrences. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTopLevelTargetSnapshot { + browser_instance_id: BrowserInstanceId, + contexts: Vec, +} + +impl BrowserTopLevelTargetSnapshot { + pub fn browser_instance_id(&self) -> BrowserInstanceId { + self.browser_instance_id + } + + pub fn contexts(&self) -> &[BrowserContextTargetSnapshot] { + &self.contexts + } + + pub fn context(&self, browser_context_id: &str) -> Option<&BrowserContextTargetSnapshot> { + self.contexts + .iter() + .find(|context| context.browser_context_id() == browser_context_id) + } + + pub fn target(&self, target_id: &str) -> Option<&BrowserTargetStateSnapshot> { + self.contexts + .iter() + .find_map(|context| context.target(target_id)) + } +} + +impl BrowserNavigationOwner { + /// Captures all live top-level Browser Target identities in one owner turn. + pub fn snapshot_top_level_targets( + &self, + ) -> Result { + let mut contexts = Vec::with_capacity(self.browser_context_count()); + for browser_context_id in self + .browser_contexts + .ordered_context_ids() + .cloned() + .collect::>() + { + let Some(browser_context) = self.browser_contexts.handle(&browser_context_id).cloned() + else { + return Err( + BrowserContextRegistryError::UnknownBrowserContext(browser_context_id).into(), + ); + }; + if !browser_context.is_live() { + return Err(BrowserContextRegistryError::BrowserContextHandleNotLive( + browser_context_id, + ) + .into()); + } + + let mut targets = Vec::new(); + for (target_id, residence) in self + .targets + .ordered_targets_for_context(&browser_context_id)? + { + let Some(target) = self.targets.handle_for_target(&target_id).cloned() else { + return Err(BrowserTargetRegistryError::UnknownTarget(target_id)); + }; + let Some(actual_context_id) = self.targets.context_for_target(&target_id) else { + return Err(BrowserTargetRegistryError::UnknownTarget(target_id)); + }; + if actual_context_id != &browser_context_id { + return Err(BrowserTargetRegistryError::TargetContextMismatch { + target_id, + expected: browser_context_id.clone(), + actual: actual_context_id.clone(), + }); + } + if !target.is_live() { + return Err(BrowserTargetRegistryError::TargetHandleNotLive(target_id)); + } + let owner = + BrowserPageOwnerKey::new(browser_context_id.as_str(), target.target_id()); + let page_residence = self + .page_residences + .identity(&self.target_runtimes, &owner) + .ok_or_else(|| { + BrowserPageResidenceRegistryError::UnknownTarget(owner.clone()) + })?; + targets.push(BrowserTargetStateSnapshot { + browser_instance_id: self.browser_instance_id, + browser_context: browser_context.clone(), + target, + page_residence, + residence, + }); + } + contexts.push(BrowserContextTargetSnapshot { + browser_instance_id: self.browser_instance_id, + browser_context, + selected: self.browser_contexts.selected() == Some(&browser_context_id), + targets, + }); + } + Ok(BrowserTopLevelTargetSnapshot { + browser_instance_id: self.browser_instance_id, + contexts, + }) + } + + /// Checks whether a whole resnapshot originated from this Browser + /// instance. This remains meaningful when the snapshot has no Context or + /// Target entries whose exact handles could otherwise prove provenance. + pub fn browser_top_level_target_snapshot_is_from_current_browser( + &self, + snapshot: &BrowserTopLevelTargetSnapshot, + ) -> bool { + snapshot.browser_instance_id == self.browser_instance_id + } + + /// Checks whether a delayed Context snapshot still addresses the same + /// Browser instance and exact live Context. Selection may have changed. + pub fn browser_context_target_snapshot_is_current( + &self, + snapshot: &BrowserContextTargetSnapshot, + ) -> bool { + snapshot.browser_instance_id == self.browser_instance_id + && self + .browser_contexts + .handle_is_current(&snapshot.browser_context) + } + + /// Checks whether a delayed Target snapshot still addresses the same live + /// Context, Target and Page slot. Document generation and active/background + /// residence may advance without invalidating the stable slot identity. + pub fn browser_target_state_snapshot_is_current( + &self, + snapshot: &BrowserTargetStateSnapshot, + ) -> bool { + if snapshot.browser_instance_id != self.browser_instance_id + || !self + .browser_contexts + .handle_is_current(&snapshot.browser_context) + || !self.targets.handle_is_current(&snapshot.target) + { + return false; + } + let owner = BrowserPageOwnerKey::new(snapshot.browser_context_id(), snapshot.target_id()); + self.targets.validate_target_owner(&owner).is_ok_and(|_| { + self.page_owner_key_for_same_slot(&snapshot.page_residence) + .as_ref() + == Some(&owner) + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-core/src/browser_host/navigation_owner/target_snapshot/tests.rs b/moli-core/src/browser_host/navigation_owner/target_snapshot/tests.rs new file mode 100644 index 0000000000..5ad3100d0b --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_snapshot/tests.rs @@ -0,0 +1,212 @@ +use crate::{ + browser_host::{ + BrowserContextSelectionProjection, BrowserPageResidenceHandle, + BrowserSelectedTargetEngineDisposition, BrowserTargetHandle, BrowserTargetResidence, + BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }, + runtime::NavigationEngine, +}; + +use super::super::BrowserNavigationOwner; + +fn empty_topology(context_id: &str) -> BrowserTargetTopologyProjection { + BrowserTargetTopologyProjection::new( + context_id, + None, + Vec::::new(), + ) +} + +fn selection(context_id: Option<&str>) -> BrowserContextSelectionProjection { + BrowserContextSelectionProjection::new( + context_id.map(str::to_owned), + BrowserSelectedTargetEngineDisposition::Unbound, + ) +} + +fn register_context(owner: &mut BrowserNavigationOwner, context_id: &str) { + let selected = owner.selected_browser_context_id().map(str::to_owned); + owner + .register_browser_context( + context_id, + empty_topology(context_id), + selection(selected.as_deref()), + NavigationEngine::new, + ) + .expect("test BrowserContext should register"); +} + +#[test] +fn snapshot_orders_core_context_and_target_topology() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + register_context(&mut owner, "context-selected"); + register_context(&mut owner, "context-inactive"); + + let active = owner + .register_active_target( + "context-selected", + "target-active", + empty_topology("context-selected"), + selection(Some("context-selected")), + NavigationEngine::new, + ) + .expect("active Target should register"); + owner + .register_background_target( + "context-selected", + "target-background", + BrowserTargetTopologyProjection::new( + "context-selected", + Some(BrowserTargetSlotProjection::new( + active.handle().clone(), + active.page_residence_handle().clone(), + )), + Vec::::new(), + ), + ) + .expect("background Target should register"); + owner + .register_background_target( + "context-inactive", + "target-inactive", + empty_topology("context-inactive"), + ) + .expect("inactive-context Target should register"); + + let snapshot = owner + .snapshot_top_level_targets() + .expect("consistent owner state should snapshot"); + assert_eq!(snapshot.contexts().len(), 2); + assert_eq!( + snapshot.contexts()[0].browser_context_id(), + "context-selected" + ); + assert!(snapshot.contexts()[0].is_selected()); + assert_eq!( + snapshot.contexts()[1].browser_context_id(), + "context-inactive" + ); + assert!(!snapshot.contexts()[1].is_selected()); + assert_eq!(snapshot.contexts()[0].targets().len(), 2); + assert_eq!( + snapshot.contexts()[0].targets()[0].target_id(), + "target-active" + ); + assert_eq!( + snapshot.contexts()[0].targets()[0].residence(), + BrowserTargetResidence::Active + ); + assert_eq!( + snapshot.contexts()[0].targets()[1].target_id(), + "target-background" + ); + assert_eq!( + snapshot.contexts()[0].targets()[1].residence(), + BrowserTargetResidence::Background + ); + assert_eq!( + snapshot.contexts()[1].targets()[0].target_id(), + "target-inactive" + ); +} + +#[test] +fn exact_snapshot_survives_document_generation_and_rejects_other_browser_instance() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + register_context(&mut owner, "context-1"); + let registration = owner + .register_background_target("context-1", "target-1", empty_topology("context-1")) + .expect("Target should register"); + let snapshot = owner + .snapshot_top_level_targets() + .expect("consistent owner state should snapshot"); + let context = snapshot.context("context-1").expect("snapshotted Context"); + let target = snapshot.target("target-1").expect("snapshotted Target"); + assert!(owner.browser_context_target_snapshot_is_current(context)); + assert!(owner.browser_target_state_snapshot_is_current(target)); + + registration + .page_residence_handle() + .advance_generation_for_test_fixture(); + assert!(owner.browser_target_state_snapshot_is_current(target)); + + let mut other = BrowserNavigationOwner::new(NavigationEngine::new()); + register_context(&mut other, "context-1"); + other + .register_background_target("context-1", "target-1", empty_topology("context-1")) + .expect("same public ids should register in another Browser instance"); + assert!(!other.browser_context_target_snapshot_is_current(context)); + assert!(!other.browser_target_state_snapshot_is_current(target)); +} + +#[test] +fn snapshot_rejects_replaced_target_and_page_slot() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let old_target = BrowserTargetHandle::staged("target-old"); + let old_page = BrowserPageResidenceHandle::default(); + owner + .register_browser_context( + "context-1", + BrowserTargetTopologyProjection::new( + "context-1", + Some(BrowserTargetSlotProjection::new( + old_target.clone(), + old_page.clone(), + )), + Vec::::new(), + ), + selection(None), + NavigationEngine::new, + ) + .expect("placeholder Target should register"); + let snapshot = owner + .snapshot_top_level_targets() + .expect("placeholder should snapshot"); + let stale = snapshot + .target("target-old") + .expect("placeholder snapshot") + .clone(); + + owner + .replace_active_target( + "context-1", + "target-old", + "target-new", + BrowserTargetTopologyProjection::new( + "context-1", + Some(BrowserTargetSlotProjection::new(old_target, old_page)), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + Some("context-1".to_owned()), + BrowserSelectedTargetEngineDisposition::Discard( + crate::browser_host::BrowserPageOwnerKey::new("context-1", "target-old"), + ), + ), + NavigationEngine::new, + ) + .expect("exact placeholder should replace"); + + assert!(!owner.browser_target_state_snapshot_is_current(&stale)); + assert!( + owner.browser_target_state_snapshot_is_current( + owner + .snapshot_top_level_targets() + .expect("successor should snapshot") + .target("target-new") + .expect("successor snapshot") + ) + ); +} + +#[test] +fn empty_snapshot_keeps_exact_browser_provenance() { + let owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let snapshot = owner + .snapshot_top_level_targets() + .expect("empty Browser should snapshot"); + assert!(owner.browser_top_level_target_snapshot_is_from_current_browser(&snapshot)); + + let other = BrowserNavigationOwner::new(NavigationEngine::new()); + assert!(!other.browser_top_level_target_snapshot_is_from_current_browser(&snapshot)); +} diff --git a/moli-core/src/browser_host/navigation_owner/target_termination.rs b/moli-core/src/browser_host/navigation_owner/target_termination.rs new file mode 100644 index 0000000000..0da2bd8a8a --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_termination.rs @@ -0,0 +1,1081 @@ +use crate::browser_host::PageResidenceIdentity; +use crate::page::RendererPageLifetimeOwner; + +use super::{ + BrowserDocumentNavigation, BrowserNavigationOwner, BrowserNavigationTraceEvent, + BrowserPageOwnerKey, BrowserPageResidenceRegistryError, BrowserTargetRegistryError, + BrowserTargetResidence, target_runtime_registry::BrowserTargetRuntimeRegistry, +}; + +/// Browser-level terminal transition for one exact Target residence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserTargetTerminationKind { + Crash, + Close, +} + +/// Immutable request captured while an exact Target/Page slot is live. +/// +/// The request contains no frontend session or command identity. Page +/// generation is part of the capability so a delayed action from a replaced +/// Page cannot terminate its successor. +#[derive(Debug)] +pub struct BrowserTargetTerminationRequest { + owner: BrowserPageOwnerKey, + page: PageResidenceIdentity, + kind: BrowserTargetTerminationKind, +} + +impl BrowserTargetTerminationRequest { + pub fn owner(&self) -> &BrowserPageOwnerKey { + &self.owner + } + + pub fn page(&self) -> &PageResidenceIdentity { + &self.page + } + + pub fn kind(&self) -> BrowserTargetTerminationKind { + self.kind + } +} + +/// Exact authorization to commit one Target terminal transition. +/// +/// Preparing this permit is read-only. Browser Core revalidates it at commit +/// so participant/projection preparation cannot make stale work current. +#[derive(Debug)] +pub struct BrowserTargetTerminationPermit { + request: BrowserTargetTerminationRequest, +} + +impl BrowserTargetTerminationPermit { + pub fn request(&self) -> &BrowserTargetTerminationRequest { + &self.request + } +} + +/// Browser-owned result after the terminal transition commits. +#[derive(Debug)] +pub struct BrowserTargetTermination { + owner: BrowserPageOwnerKey, + previous_page: PageResidenceIdentity, + terminal_page: PageResidenceIdentity, + kind: BrowserTargetTerminationKind, + closed_target_residence: Option, + retired_renderer_page_owner: Option, +} + +impl BrowserTargetTermination { + pub fn owner(&self) -> &BrowserPageOwnerKey { + &self.owner + } + + pub fn previous_page(&self) -> &PageResidenceIdentity { + &self.previous_page + } + + /// Exact successor generation representing the now-absent physical Page. + /// Protocol migration storage must project this generation without + /// advancing the shared handle a second time. + pub fn terminal_page(&self) -> &PageResidenceIdentity { + &self.terminal_page + } + + pub fn kind(&self) -> BrowserTargetTerminationKind { + self.kind + } + + pub fn closed_target_residence(&self) -> Option { + self.closed_target_residence + } + + /// Takes the exact renderer Page lifetime retired by this terminal turn. + pub fn take_retired_renderer_page_owner(&mut self) -> Option { + self.retired_renderer_page_owner.take() + } +} + +/// Exact reason why a prepared Target terminal transition no longer commits. +/// +/// A permit may cross an actor turn. Target lifecycle, topology, and Page +/// generation are therefore ordinary commit guards rather than process-fatal +/// invariants. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserTargetTerminationCommitError { + Target(BrowserTargetRegistryError), + PageResidence(BrowserPageResidenceRegistryError), + TargetNoLongerAcceptsTermination { + owner: BrowserPageOwnerKey, + kind: BrowserTargetTerminationKind, + }, +} + +impl std::fmt::Display for BrowserTargetTerminationCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Target(error) => error.fmt(formatter), + Self::PageResidence(error) => error.fmt(formatter), + Self::TargetNoLongerAcceptsTermination { owner, kind } => write!( + formatter, + "Target {:?} in BrowserContext {:?} no longer accepts {kind:?}", + owner.target_id(), + owner.browser_context_id() + ), + } + } +} + +impl std::error::Error for BrowserTargetTerminationCommitError {} + +impl From for BrowserTargetTerminationCommitError { + fn from(error: BrowserTargetRegistryError) -> Self { + Self::Target(error) + } +} + +impl From for BrowserTargetTerminationCommitError { + fn from(error: BrowserPageResidenceRegistryError) -> Self { + Self::PageResidence(error) + } +} + +#[derive(Clone, Debug)] +pub(super) enum BrowserTargetTerminationState { + Crashed, + Recovering(BrowserDocumentNavigation), + Closed, +} + +#[derive(Debug)] +struct BrowserTargetTerminationCommitRollback { + previous: Option, +} + +/// Minimal Target lifecycle authority during the Phase-2 migration. +/// +/// Closed targets disappear from the physical registry immediately after the +/// owner commit. Crashed targets remain addressable and may either close or +/// start one exact recovery navigation. Tracking that recovery request keeps a +/// failed/superseded recovery from accidentally authorizing an unrelated Page +/// replacement. +#[derive(Default)] +pub(super) struct BrowserTargetTerminationRegistry; + +impl BrowserTargetTerminationRegistry { + pub(super) fn accepts_termination( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + kind: BrowserTargetTerminationKind, + ) -> bool { + match kind { + BrowserTargetTerminationKind::Crash => self.state(runtimes, owner).is_none(), + BrowserTargetTerminationKind::Close => !matches!( + self.state(runtimes, owner), + Some(BrowserTargetTerminationState::Closed) + ), + } + } + + pub(super) fn accepts_page_replacement( + &self, + runtimes: &BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) -> bool { + match self.state(runtimes, owner) { + None => true, + Some( + BrowserTargetTerminationState::Crashed | BrowserTargetTerminationState::Closed, + ) => false, + Some(BrowserTargetTerminationState::Recovering(expected)) => expected == navigation, + } + } + + pub(super) fn begin_navigation( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) { + if matches!( + self.state(runtimes, owner), + Some( + BrowserTargetTerminationState::Crashed + | BrowserTargetTerminationState::Recovering(_) + ) + ) { + runtimes + .entries + .entry(owner.clone()) + .or_default() + .termination = Some(BrowserTargetTerminationState::Recovering( + navigation.clone(), + )); + } + } + + pub(super) fn cancel_navigation_if_matches( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) { + if self.state(runtimes, owner).is_some_and(|state| { + matches!( + state, + BrowserTargetTerminationState::Recovering(expected) if expected == navigation + ) + }) { + runtimes + .entries + .entry(owner.clone()) + .or_default() + .termination = Some(BrowserTargetTerminationState::Crashed); + } + } + + pub(super) fn commit_navigation( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + navigation: &BrowserDocumentNavigation, + ) { + if self.state(runtimes, owner).is_some_and(|state| { + matches!( + state, + BrowserTargetTerminationState::Recovering(expected) if expected == navigation + ) + }) { + if let Some(runtime) = runtimes.entries.get_mut(owner) { + runtime.termination = None; + } + runtimes.prune_empty(); + } + } + + fn commit_with_rollback_if_accepted( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + kind: BrowserTargetTerminationKind, + ) -> Option { + if !self.accepts_termination(runtimes, owner, kind) { + return None; + } + let state = match kind { + BrowserTargetTerminationKind::Crash => BrowserTargetTerminationState::Crashed, + BrowserTargetTerminationKind::Close => BrowserTargetTerminationState::Closed, + }; + let previous = runtimes + .entries + .entry(owner.clone()) + .or_default() + .termination + .replace(state); + Some(BrowserTargetTerminationCommitRollback { previous }) + } + + fn rollback_commit( + &mut self, + runtimes: &mut BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + kind: BrowserTargetTerminationKind, + rollback: BrowserTargetTerminationCommitRollback, + ) -> bool { + let current_matches = matches!( + (kind, self.state(runtimes, owner)), + ( + BrowserTargetTerminationKind::Crash, + Some(BrowserTargetTerminationState::Crashed) + ) | ( + BrowserTargetTerminationKind::Close, + Some(BrowserTargetTerminationState::Closed) + ) + ); + if !current_matches { + return false; + } + if let Some(runtime) = runtimes.entries.get_mut(owner) { + runtime.termination = rollback.previous; + } + runtimes.prune_empty(); + true + } + + fn state<'a>( + &self, + runtimes: &'a BrowserTargetRuntimeRegistry, + owner: &BrowserPageOwnerKey, + ) -> Option<&'a BrowserTargetTerminationState> { + runtimes + .entries + .get(owner) + .and_then(|runtime| runtime.termination.as_ref()) + } +} + +impl BrowserNavigationOwner { + /// Captures a protocol-neutral terminal request from the Target's exact + /// physical Page slot. Capture does not change browser lifecycle state. + pub fn capture_target_termination( + &self, + owner: &BrowserPageOwnerKey, + kind: BrowserTargetTerminationKind, + ) -> Option { + if self.targets.validate_target_owner(owner).is_err() + || !self + .target_terminations + .accepts_termination(&self.target_runtimes, owner, kind) + { + return None; + } + let page = self + .page_residences + .capture_termination(&self.target_runtimes, owner)?; + Some(BrowserTargetTerminationRequest { + owner: owner.clone(), + page, + kind, + }) + } + + pub fn prepare_target_termination( + &self, + request: BrowserTargetTerminationRequest, + ) -> Option { + if !self.target_terminations.accepts_termination( + &self.target_runtimes, + request.owner(), + request.kind(), + ) || self.targets.validate_target_owner(request.owner()).is_err() + || request.page().browser_context_id() != request.owner().browser_context_id() + || request.page().target_id() != Some(request.owner().target_id()) + || !self.page_residences.accepts_transition( + &self.target_runtimes, + request.owner(), + request.page(), + ) + { + return None; + } + Some(BrowserTargetTerminationPermit { request }) + } + + /// Commits Target lifecycle, Page generation, request state, runtime + /// ownership, and history in one Browser Owner mutation. + pub fn commit_target_termination( + &mut self, + permit: BrowserTargetTerminationPermit, + ) -> Result { + let request = permit.request; + let kind = request.kind(); + self.targets.validate_target_owner(request.owner())?; + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + let Some(termination_rollback) = self.target_terminations.commit_with_rollback_if_accepted( + &mut self.target_runtimes, + request.owner(), + kind, + ) else { + return Err( + BrowserTargetTerminationCommitError::TargetNoLongerAcceptsTermination { + owner: request.owner().clone(), + kind, + }, + ); + }; + let mut target_removal = if kind == BrowserTargetTerminationKind::Close { + match self.targets.begin_target_removal(request.owner()) { + Ok(removal) => Some(removal), + Err(error) => { + let rolled_back = self.target_terminations.rollback_commit( + &mut self.target_runtimes, + request.owner(), + kind, + termination_rollback, + ); + debug_assert!( + rolled_back, + "same-turn Target removal rejection must restore termination state" + ); + return Err(error.into()); + } + } + } else { + None + }; + let (terminal_page, retired_renderer_page_owner) = + match self.page_residences.commit_termination( + &mut self.target_runtimes, + request.owner(), + request.page(), + kind == BrowserTargetTerminationKind::Close, + ) { + Ok(committed) => committed, + Err(error) => { + if let Some(removal) = target_removal.take() { + let rolled_back = self.targets.rollback_target_removal(removal); + debug_assert!( + rolled_back, + "same-turn stale Page commit must restore Target topology" + ); + } + let rolled_back = self.target_terminations.rollback_commit( + &mut self.target_runtimes, + request.owner(), + kind, + termination_rollback, + ); + debug_assert!( + rolled_back, + "same-turn stale Page commit must restore termination state" + ); + return Err(error.into()); + } + }; + + let closed_target_residence = + target_removal.map(|removal| self.targets.commit_target_removal(removal)); + + let target_id = request.owner().target_id(); + let pending_navigation = self + .document_navigations + .pending_record(&self.target_runtimes, request.owner()) + .cloned(); + self.target_engines.retire_target( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + request.owner(), + kind == BrowserTargetTerminationKind::Close, + ); + match kind { + BrowserTargetTerminationKind::Crash => { + self.document_navigations + .forget_target(&mut self.target_runtimes, target_id); + self.navigation_histories + .clear(&mut self.target_runtimes, request.owner()); + self.initial_empty_documents + .mark_exited(&mut self.target_runtimes, request.owner()); + } + BrowserTargetTerminationKind::Close => { + let removed = self.target_runtimes.remove(request.owner()); + debug_assert!( + removed.is_some(), + "closed Target must retire its exact aggregate runtime record" + ); + } + } + let termination = BrowserTargetTermination { + owner: request.owner, + previous_page: request.page, + terminal_page, + kind, + closed_target_residence, + retired_renderer_page_owner, + }; + if let Err(error) = self.record_target_termination_facts( + &termination, + pending_navigation + .as_ref() + .map(|record| record.navigation()), + ) { + tracing::error!( + %error, + browser_context_id = termination.owner().browser_context_id(), + target_id = termination.owner().target_id(), + previous_page_generation = termination.previous_page().loaded_page_generation(), + terminal_page_generation = termination.terminal_page().loaded_page_generation(), + kind = ?termination.kind(), + "failed to publish committed Target termination Browser fact" + ); + } + if let Some(record) = pending_navigation + && let Some(trace) = record.trace() + { + trace.emit( + BrowserNavigationTraceEvent::new( + match kind { + BrowserTargetTerminationKind::Crash => { + "navigation_request_terminated_by_target_crash" + } + BrowserTargetTerminationKind::Close => { + "navigation_request_terminated_by_target_close" + } + }, + trace.origin(), + "request-pending", + "target-terminal", + ) + .with_navigation(record.navigation()) + .with_page(termination.terminal_page().clone()), + ); + } + Ok(termination) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + browser_host::{ + BrowserContextSelectionProjection, BrowserFact, BrowserInitialEmptyDocumentSeed, + BrowserNavigationHistoryPageSnapshot, BrowserNavigationHistorySeed, + BrowserPageResidenceHandle, BrowserSelectedTargetEngineDisposition, + BrowserTargetEngineResidence, BrowserTargetHandle, BrowserTargetResidence, + BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }, + runtime::NavigationEngine, + }; + + use super::*; + + fn register_test_target( + owner: &mut BrowserNavigationOwner, + key: &BrowserPageOwnerKey, + residence: BrowserTargetResidence, + ) -> BrowserPageResidenceHandle { + let handle = BrowserTargetHandle::staged(key.target_id()); + let page_residence = BrowserPageResidenceHandle::default(); + let slot = BrowserTargetSlotProjection::new(handle, page_residence.clone()); + let (active, background) = match residence { + BrowserTargetResidence::Active => (Some(slot), Vec::new()), + BrowserTargetResidence::Background => (None, vec![slot]), + }; + owner + .register_browser_context( + key.browser_context_id(), + BrowserTargetTopologyProjection::new(key.browser_context_id(), active, background), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("test Target topology should register"); + page_residence + } + + #[test] + fn close_commits_exactly_once_and_retires_all_target_runtime_state() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = + register_test_target(&mut owner, &key, BrowserTargetResidence::Background); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("live target should accept initial metadata"); + let target_handle = owner.target_handle("target-1").expect("live Target handle"); + owner + .adopt_target_engine( + key.clone(), + BrowserTargetEngineResidence::Retained, + NavigationEngine::new(), + ) + .expect("test target should retain its engine"); + let navigation = owner.start_document_navigation(&key, "loader-1".to_owned()); + owner.record_loaded_page_navigation_history( + &key, + BrowserNavigationHistoryPageSnapshot::new("https://example.test/", "title"), + ); + let request = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Close) + .expect("live target should capture close"); + let previous = request.page().clone(); + let permit = owner + .prepare_target_termination(request) + .expect("exact close should prepare"); + let mut subscriber = owner.subscribe_browser_facts(); + + let termination = owner + .commit_target_termination(permit) + .expect("exact close should commit"); + + assert_eq!(termination.previous_page(), &previous); + assert_eq!( + termination.closed_target_residence(), + Some(BrowserTargetResidence::Background) + ); + assert_eq!(termination.terminal_page().loaded_page_generation(), 1); + assert!(page_residence.is_current(termination.terminal_page())); + assert!(owner.page_owner_key_if_current(&previous).is_none()); + assert!( + owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Close) + .is_none(), + "closed target must not accept another terminal action" + ); + assert!(!owner.accepts_pending_document_navigation(&key, &navigation)); + assert_eq!(owner.retained_background_engine_count(), 0); + assert!( + !owner.target_runtimes.entries.contains_key(&key), + "Target close must remove its aggregate runtime record" + ); + assert!(target_handle.is_retired()); + assert!(owner.target_initial_empty_document(&key).is_none()); + let created = subscriber + .try_recv() + .expect("Target registration should publish its creation occurrence"); + assert_eq!(created.sequence().get(), 1); + assert_eq!(created.page_residence(), &previous); + assert!(matches!(created.fact(), BrowserFact::TargetCreated)); + let accepted = subscriber + .try_recv() + .expect("navigation start should publish NavigationAccepted"); + assert_eq!(accepted.sequence().get(), 2); + assert_eq!(accepted.page_residence(), &previous); + assert_eq!( + accepted.fact(), + &BrowserFact::NavigationAccepted { + navigation: navigation.clone(), + superseded_navigation: None, + } + ); + let fact = subscriber + .try_recv() + .expect("close commit should publish its self-contained TargetClosed fact"); + assert_eq!(fact.sequence().get(), 3); + assert_eq!(fact.browser_context_id().as_str(), "context-1"); + assert_eq!(fact.target_id().as_str(), "target-1"); + assert_eq!(fact.page_residence(), termination.terminal_page()); + assert_eq!( + fact.fact(), + &BrowserFact::TargetClosed { + previous_page: previous.clone(), + pending_navigation: Some(navigation), + } + ); + let (_, reseeded) = owner.navigation_history_snapshot( + &key, + Some(BrowserNavigationHistorySeed::initial_empty_document( + "about:blank", + )), + ); + assert_eq!(reseeded.len(), 1, "closed target history must be forgotten"); + } + + #[test] + fn crash_preserves_empty_history_tombstone_and_cannot_be_recaptured() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_test_target(&mut owner, &key, BrowserTargetResidence::Active); + owner + .register_target_initial_empty_document( + &key, + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("live target should accept initial metadata"); + let target_handle = owner.target_handle("target-1").expect("live Target handle"); + owner.record_loaded_page_navigation_history( + &key, + BrowserNavigationHistoryPageSnapshot::new("https://example.test/", "title"), + ); + let navigation = owner.start_document_navigation(&key, "loader-pending".to_owned()); + let request = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .expect("live target should capture crash"); + let previous = request.page().clone(); + let permit = owner + .prepare_target_termination(request) + .expect("exact crash should prepare"); + let mut subscriber = owner.subscribe_browser_facts(); + let termination = owner + .commit_target_termination(permit) + .expect("exact crash should commit"); + assert_eq!(termination.closed_target_residence(), None); + assert_eq!(page_residence.generation(), 1); + assert!( + target_handle.is_live(), + "crash must preserve Target lifetime" + ); + assert!( + owner + .target_initial_empty_document(&key) + .is_some_and(|initial| initial.exited()), + "crash must retire the destroyed initial Document without forgetting Target metadata" + ); + + assert!( + owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .is_none(), + "crashed target must not accept another terminal action" + ); + let (_, history) = owner.navigation_history_snapshot( + &key, + Some(BrowserNavigationHistorySeed::initial_empty_document( + "about:blank", + )), + ); + assert!(history.is_empty(), "crash must retain an empty tombstone"); + let created = subscriber + .try_recv() + .expect("Target registration should publish its creation occurrence"); + assert_eq!(created.sequence().get(), 1); + assert_eq!(created.page_residence(), &previous); + assert!(matches!(created.fact(), BrowserFact::TargetCreated)); + let accepted = subscriber + .try_recv() + .expect("pending navigation should publish NavigationAccepted"); + assert_eq!(accepted.sequence().get(), 2); + assert_eq!(accepted.page_residence(), &previous); + assert_eq!( + accepted.fact(), + &BrowserFact::NavigationAccepted { + navigation: navigation.clone(), + superseded_navigation: None, + } + ); + let fact = subscriber + .try_recv() + .expect("crash commit should publish its self-contained TargetCrashed fact"); + assert_eq!(fact.sequence().get(), 3); + assert_eq!(fact.page_residence(), termination.terminal_page()); + assert_eq!( + fact.fact(), + &BrowserFact::TargetCrashed { + previous_page: previous, + pending_navigation: Some(navigation), + } + ); + } + + #[test] + fn crashed_target_recovers_through_one_exact_navigation() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_test_target(&mut owner, &key, BrowserTargetResidence::Active); + let request = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .expect("live target should capture crash"); + let permit = owner + .prepare_target_termination(request) + .expect("exact crash should prepare"); + owner + .commit_target_termination(permit) + .expect("exact crash should commit"); + + let recovery = owner.start_document_navigation(&key, "loader-recovery".to_owned()); + let permit = owner + .prepare_loaded_page_replacement(&key, &recovery) + .expect("crashed target should authorize its exact recovery navigation"); + owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + BrowserNavigationHistoryPageSnapshot::new( + "https://example.test/recovered", + "title", + ), + ) + .expect("exact recovery replacement should commit"); + assert_eq!(page_residence.generation(), 2); + + assert!( + owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .is_some(), + "successfully recovered target should become crashable again" + ); + } + + #[test] + fn failed_crash_recovery_does_not_authorize_an_unrelated_replacement() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let _page_residence = + register_test_target(&mut owner, &key, BrowserTargetResidence::Active); + let request = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .expect("live target should capture crash"); + let permit = owner + .prepare_target_termination(request) + .expect("exact crash should prepare"); + owner + .commit_target_termination(permit) + .expect("exact crash should commit"); + + let failed = owner.start_document_navigation(&key, "loader-failed".to_owned()); + assert!(owner.fail_document_navigation_if_matches( + &key, + &failed, + crate::browser_host::BrowserNavigationFailure::Network { + error_text: "failed".to_owned(), + }, + )); + let unrelated = BrowserDocumentNavigation::new("target-1", "loader-unrelated"); + + assert!( + owner + .prepare_loaded_page_replacement(&key, &failed) + .is_none() + ); + assert!( + owner + .prepare_loaded_page_replacement(&key, &unrelated) + .is_none() + ); + } + + #[test] + fn crashed_target_can_still_be_closed() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let _page_residence = + register_test_target(&mut owner, &key, BrowserTargetResidence::Active); + owner + .adopt_target_engine( + key.clone(), + BrowserTargetEngineResidence::Selected, + NavigationEngine::new(), + ) + .expect("live target should own the selected engine"); + let crash = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .expect("live target should capture crash"); + let crash = owner + .prepare_target_termination(crash) + .expect("exact crash should prepare"); + owner + .commit_target_termination(crash) + .expect("exact crash should commit"); + assert_eq!( + owner.selected_target_engine_owner(), + Some(&key), + "crash must keep the exact Target engine selected for recovery" + ); + + let close = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Close) + .expect("crashed target should remain closable"); + let close = owner + .prepare_target_termination(close) + .expect("exact crashed-target close should prepare"); + let closed = owner + .commit_target_termination(close) + .expect("exact crashed-target close should commit"); + + assert_eq!(closed.kind(), BrowserTargetTerminationKind::Close); + assert_eq!( + closed.closed_target_residence(), + Some(BrowserTargetResidence::Active) + ); + assert_eq!(closed.terminal_page().loaded_page_generation(), 2); + let facts = owner.browser_fact_snapshot(); + assert_eq!(facts.len(), 3); + assert_eq!(facts[0].sequence().get(), 1); + assert_eq!(facts[1].sequence().get(), 2); + assert_eq!(facts[2].sequence().get(), 3); + assert!(matches!(facts[0].fact(), BrowserFact::TargetCreated)); + assert!(matches!( + facts[1].fact(), + BrowserFact::TargetCrashed { previous_page, .. } + if previous_page.loaded_page_generation() == 0 + )); + assert!(matches!( + facts[2].fact(), + BrowserFact::TargetClosed { previous_page, .. } + if previous_page == facts[1].page_residence() + )); + assert_eq!(facts[2].page_residence(), closed.terminal_page()); + assert_eq!( + owner.selected_target_engine_owner(), + None, + "close must not leave the selected engine keyed to a dead Target" + ); + } + + #[test] + fn page_replacement_makes_delayed_termination_request_stale() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_test_target(&mut owner, &key, BrowserTargetResidence::Active); + let request = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Close) + .expect("live target should capture close"); + + page_residence.advance_generation_for_test_fixture(); + + assert!(owner.prepare_target_termination(request).is_none()); + } + + #[test] + fn stale_close_commit_restores_target_and_preserves_runtime_state() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = + register_test_target(&mut owner, &key, BrowserTargetResidence::Background); + let target_handle = owner.target_handle("target-1").expect("live Target handle"); + owner + .adopt_target_engine( + key.clone(), + BrowserTargetEngineResidence::Retained, + NavigationEngine::new(), + ) + .expect("test target should retain its engine"); + let navigation = owner.start_document_navigation(&key, "loader-1".to_owned()); + owner.record_loaded_page_navigation_history( + &key, + BrowserNavigationHistoryPageSnapshot::new("https://example.test/", "title"), + ); + let request = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Close) + .expect("live target should capture close"); + let permit = owner + .prepare_target_termination(request) + .expect("exact close should prepare"); + + page_residence.advance_generation_for_test_fixture(); + let error = owner + .commit_target_termination(permit) + .expect_err("stale Page generation must reject close"); + + assert!(matches!( + error, + BrowserTargetTerminationCommitError::PageResidence( + BrowserPageResidenceRegistryError::StaleTransition { + expected_generation: 0, + current_generation: 1, + .. + } + ) + )); + assert!(target_handle.is_live()); + assert!(owner.has_target("target-1")); + assert_eq!(owner.browser_context_target_count("context-1"), 1); + assert_eq!(owner.retained_background_engine_count(), 1); + assert!(owner.accepts_pending_document_navigation(&key, &navigation)); + let (_, history) = owner.navigation_history_snapshot(&key, None); + assert_eq!(history.len(), 1, "stale close must not clear history"); + assert!( + owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Close) + .is_some(), + "stale close must restore the previous termination state" + ); + assert!( + owner.browser_fact_snapshot().iter().all(|fact| !matches!( + fact.fact(), + BrowserFact::NavigationFailed { .. } + | BrowserFact::TargetCrashed { .. } + | BrowserFact::TargetClosed { .. } + )), + "a stale termination commit must not publish a Target terminal fact" + ); + } + + #[test] + fn delayed_crash_commit_is_rejected_while_target_is_recovering() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_test_target(&mut owner, &key, BrowserTargetResidence::Active); + let first = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .expect("live target should capture first crash"); + let delayed = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .expect("live target should capture delayed crash"); + let first = owner + .prepare_target_termination(first) + .expect("first crash should prepare"); + let delayed = owner + .prepare_target_termination(delayed) + .expect("delayed crash should prepare while target is live"); + owner + .commit_target_termination(first) + .expect("first crash should commit"); + let recovery = owner.start_document_navigation(&key, "loader-recovery".to_owned()); + + let error = owner + .commit_target_termination(delayed) + .expect_err("recovering target must reject delayed crash"); + + assert_eq!( + error, + BrowserTargetTerminationCommitError::TargetNoLongerAcceptsTermination { + owner: key.clone(), + kind: BrowserTargetTerminationKind::Crash, + } + ); + assert_eq!(page_residence.generation(), 1); + assert!(owner.accepts_pending_document_navigation(&key, &recovery)); + assert!( + owner + .prepare_loaded_page_replacement(&key, &recovery) + .is_some(), + "rejected delayed crash must preserve exact recovery authorization" + ); + } + + #[test] + fn stale_recovery_close_restores_recovering_state_and_target_topology() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let page_residence = register_test_target(&mut owner, &key, BrowserTargetResidence::Active); + let target_handle = owner.target_handle("target-1").expect("live Target handle"); + let crash = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Crash) + .expect("live target should capture crash"); + let crash = owner + .prepare_target_termination(crash) + .expect("exact crash should prepare"); + owner + .commit_target_termination(crash) + .expect("exact crash should commit"); + let recovery = owner.start_document_navigation(&key, "loader-recovery".to_owned()); + let close = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Close) + .expect("recovering target should capture close"); + let close = owner + .prepare_target_termination(close) + .expect("exact recovering-target close should prepare"); + + page_residence.advance_generation_for_test_fixture(); + let error = owner + .commit_target_termination(close) + .expect_err("stale recovering-target close must be rejected"); + + assert!(matches!( + error, + BrowserTargetTerminationCommitError::PageResidence( + BrowserPageResidenceRegistryError::StaleTransition { .. } + ) + )); + assert!(target_handle.is_live()); + assert!(owner.has_target("target-1")); + assert_eq!( + owner.active_target_id_for_browser_context("context-1"), + Some("target-1") + ); + assert!(owner.accepts_pending_document_navigation(&key, &recovery)); + assert!( + owner + .prepare_loaded_page_replacement(&key, &recovery) + .is_some(), + "stale close rollback must restore Recovering, not Crashed or Closed" + ); + } + + #[test] + fn termination_capture_uses_core_registered_capability() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let key = BrowserPageOwnerKey::new("context-1", "target-1"); + let current = register_test_target(&mut owner, &key, BrowserTargetResidence::Active); + let other = BrowserPageResidenceHandle::default(); + + assert!(!owner.page_residence_handle_is_current(&key, &other)); + let request = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Close) + .expect("Core must capture from its registered Page capability"); + let permit = owner + .prepare_target_termination(request) + .expect("exact Core capture should prepare"); + owner + .commit_target_termination(permit) + .expect("exact close should commit"); + assert_eq!(current.generation(), 1); + assert_eq!(other.generation(), 0); + } +} diff --git a/moli-core/src/browser_host/navigation_owner/target_transaction.rs b/moli-core/src/browser_host/navigation_owner/target_transaction.rs new file mode 100644 index 0000000000..1498d82922 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_transaction.rs @@ -0,0 +1,619 @@ +use crate::{ + browser_host::{ + BrowserContextId, BrowserPageResidenceHandle, BrowserTargetHandle, BrowserTargetId, + BrowserTargetSessionStorageAccess, PageResidenceIdentity, + }, + page::RendererPageLifetimeOwner, + runtime::NavigationEngine, +}; + +use super::{ + BrowserContextSelectionProjection, BrowserNavigationOwner, BrowserPageOwnerKey, + BrowserTargetCreationMetadata, BrowserTargetEngineHandoff, BrowserTargetEngineHandoffOutcome, + BrowserTargetRegistryError, BrowserTargetResidence, BrowserTargetTopologyProjection, +}; + +/// Result of registering one top-level Target in an existing BrowserContext. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTargetRegistration { + owner: BrowserPageOwnerKey, + handle: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, + page_residence_identity: PageResidenceIdentity, + residence: BrowserTargetResidence, + previous_active_target_id: Option, + engine_outcome: Option, + session_storage_access: BrowserTargetSessionStorageAccess, +} + +impl BrowserTargetRegistration { + pub fn owner(&self) -> &BrowserPageOwnerKey { + &self.owner + } + + pub fn residence(&self) -> BrowserTargetResidence { + self.residence + } + + pub fn handle(&self) -> &BrowserTargetHandle { + &self.handle + } + + pub fn page_residence_handle(&self) -> &BrowserPageResidenceHandle { + &self.page_residence + } + + /// Exact initial Page-slot identity committed with this Target. + pub fn page_residence_identity(&self) -> &PageResidenceIdentity { + &self.page_residence_identity + } + + pub fn previous_active_target_id(&self) -> Option<&str> { + self.previous_active_target_id + .as_ref() + .map(BrowserTargetId::as_str) + } + + pub fn engine_outcome(&self) -> Option { + self.engine_outcome + } + + pub fn session_storage_access(&self) -> &BrowserTargetSessionStorageAccess { + &self.session_storage_access + } +} + +/// Result of selecting an already registered top-level Target. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserTargetActivation { + owner: BrowserPageOwnerKey, + previous_active_target_id: Option, + changed: bool, + engine_outcome: Option, +} + +impl BrowserTargetActivation { + pub fn owner(&self) -> &BrowserPageOwnerKey { + &self.owner + } + + pub fn previous_active_target_id(&self) -> Option<&str> { + self.previous_active_target_id + .as_ref() + .map(BrowserTargetId::as_str) + } + + pub fn changed(&self) -> bool { + self.changed + } + + pub fn engine_outcome(&self) -> Option { + self.engine_outcome + } +} + +impl BrowserNavigationOwner { + pub fn target_session_storage_access( + &self, + browser_context_id: &str, + target_id: &str, + ) -> Option { + self.targets + .session_storage_access(&BrowserPageOwnerKey::new(browser_context_id, target_id)) + } + + fn publish_target_created_after_registration( + &mut self, + registration: &BrowserTargetRegistration, + ) { + if let Err(error) = self.record_target_created_fact( + registration.owner(), + registration.page_residence_identity(), + ) { + tracing::error!( + %error, + browser_context_id = registration.owner().browser_context_id(), + target_id = registration.owner().target_id(), + "failed to publish top-level Target creation Browser fact" + ); + } + } + + pub fn register_background_target( + &mut self, + browser_context_id: &str, + target_id: &str, + topology_projection: BrowserTargetTopologyProjection, + ) -> Result { + self.register_background_target_with_creation_metadata( + browser_context_id, + target_id, + BrowserTargetCreationMetadata::default(), + topology_projection, + ) + } + + pub fn register_background_target_with_creation_metadata( + &mut self, + browser_context_id: &str, + target_id: &str, + creation_metadata: BrowserTargetCreationMetadata, + topology_projection: BrowserTargetTopologyProjection, + ) -> Result { + let browser_context_id = BrowserContextId::new(browser_context_id); + let target_id = BrowserTargetId::new(target_id); + if !self.browser_contexts.contains(&browser_context_id) { + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + browser_context_id, + )); + } + if !self + .browser_contexts + .accepts_owner_work(&browser_context_id) + { + return Err(super::BrowserContextRegistryError::BrowserContextDisposing( + browser_context_id, + ) + .into()); + } + self.targets + .validate_projection(&browser_context_id, &topology_projection)?; + self.page_residences.validate_projection( + &self.target_runtimes, + &browser_context_id, + &topology_projection, + )?; + let registration = self.targets.begin_background_registration( + &browser_context_id, + &target_id, + creation_metadata.session_storage_store(), + )?; + let handle = registration.handle().clone(); + let session_storage_access = registration.session_storage_access().clone(); + let owner = BrowserPageOwnerKey::new(browser_context_id.as_str(), target_id.as_str()); + let page_registration = match self + .page_residences + .begin_target_registration(&mut self.target_runtimes, owner.clone()) + { + Ok(registration) => registration, + Err(error) => { + let rolled_back = self.targets.rollback_target_registration(registration); + debug_assert!( + rolled_back, + "same-turn Page registration rejection must restore background Target registration" + ); + return Err(error.into()); + } + }; + self.install_target_creation_metadata(&owner, &creation_metadata); + let page_residence = self + .page_residences + .commit_target_registration(&mut self.target_runtimes, page_registration); + self.targets.commit_target_registration(registration); + let page_residence_identity = page_residence.identity( + owner.browser_context_id().to_owned(), + Some(owner.target_id().to_owned()), + ); + let registration = BrowserTargetRegistration { + owner, + handle, + page_residence, + page_residence_identity, + residence: BrowserTargetResidence::Background, + previous_active_target_id: None, + engine_outcome: None, + session_storage_access, + }; + self.publish_target_created_after_registration(®istration); + Ok(registration) + } + + pub fn register_active_target( + &mut self, + browser_context_id: &str, + target_id: &str, + topology_projection: BrowserTargetTopologyProjection, + selection_projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + self.register_active_target_with_creation_metadata( + browser_context_id, + target_id, + BrowserTargetCreationMetadata::default(), + topology_projection, + selection_projection, + create_replacement, + ) + } + + pub fn register_active_target_with_creation_metadata( + &mut self, + browser_context_id: &str, + target_id: &str, + creation_metadata: BrowserTargetCreationMetadata, + topology_projection: BrowserTargetTopologyProjection, + selection_projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + let browser_context_id = BrowserContextId::new(browser_context_id); + let target_id = BrowserTargetId::new(target_id); + self.validate_selected_target_transaction_context( + &browser_context_id, + &topology_projection, + &selection_projection, + )?; + let owner = BrowserPageOwnerKey::new(browser_context_id.as_str(), target_id.as_str()); + let handoff = BrowserTargetEngineHandoff::new( + selection_projection.target_engine().clone(), + owner.clone(), + )?; + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + let registration = self.targets.begin_active_registration( + &browser_context_id, + &target_id, + creation_metadata.session_storage_store(), + )?; + let handle = registration.handle().clone(); + let session_storage_access = registration.session_storage_access().clone(); + let previous_active_target_id = registration.previous_active_target_id().cloned(); + let page_registration = match self + .page_residences + .begin_target_registration(&mut self.target_runtimes, owner.clone()) + { + Ok(page_registration) => page_registration, + Err(error) => { + let rolled_back = self.targets.rollback_target_registration(registration); + debug_assert!( + rolled_back, + "same-turn Page registration rejection must restore active Target registration" + ); + return Err(error.into()); + } + }; + let engine_outcome = match self.target_engines.handoff_target_engine( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + handoff, + create_replacement, + ) { + Ok(outcome) => outcome, + Err(error) => { + let page_rolled_back = self + .page_residences + .rollback_target_registration(&mut self.target_runtimes, page_registration); + let rolled_back = self.targets.rollback_target_registration(registration); + debug_assert!( + page_rolled_back, + "same-turn active Target engine rejection must restore Page registration" + ); + debug_assert!( + rolled_back, + "same-turn active Target registration rejection must restore topology" + ); + return Err(error.into()); + } + }; + self.install_target_creation_metadata(&owner, &creation_metadata); + let page_residence = self + .page_residences + .commit_target_registration(&mut self.target_runtimes, page_registration); + self.targets.commit_target_registration(registration); + let page_residence_identity = page_residence.identity( + owner.browser_context_id().to_owned(), + Some(owner.target_id().to_owned()), + ); + let registration = BrowserTargetRegistration { + owner, + handle, + page_residence, + page_residence_identity, + residence: BrowserTargetResidence::Active, + previous_active_target_id, + engine_outcome: Some(engine_outcome), + session_storage_access, + }; + self.publish_target_created_after_registration(®istration); + Ok(registration) + } + + /// Replaces one exact active staging Target without demoting it. This is + /// used when the browser's bootstrap placeholder becomes the first real + /// top-level Target; ordinary live Target creation must use registration + /// or activation instead. + pub fn replace_active_target( + &mut self, + browser_context_id: &str, + expected_target_id: &str, + replacement_target_id: &str, + topology_projection: BrowserTargetTopologyProjection, + selection_projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + self.replace_active_target_with_creation_metadata( + browser_context_id, + expected_target_id, + replacement_target_id, + BrowserTargetCreationMetadata::default(), + topology_projection, + selection_projection, + create_replacement, + ) + } + + pub fn replace_active_target_with_creation_metadata( + &mut self, + browser_context_id: &str, + expected_target_id: &str, + replacement_target_id: &str, + creation_metadata: BrowserTargetCreationMetadata, + topology_projection: BrowserTargetTopologyProjection, + selection_projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + let browser_context_id = BrowserContextId::new(browser_context_id); + let expected_target_id = BrowserTargetId::new(expected_target_id); + let replacement_target_id = BrowserTargetId::new(replacement_target_id); + self.validate_selected_target_transaction_context( + &browser_context_id, + &topology_projection, + &selection_projection, + )?; + let expected_owner = + BrowserPageOwnerKey::new(browser_context_id.as_str(), expected_target_id.as_str()); + if self + .page_residences + .renderer_page_id_for_target(&self.target_runtimes, &expected_owner) + .is_some() + { + return Err(BrowserTargetRegistryError::TargetHasCommittedRendererPage( + expected_owner, + )); + } + let owner = + BrowserPageOwnerKey::new(browser_context_id.as_str(), replacement_target_id.as_str()); + let handoff = BrowserTargetEngineHandoff::new( + selection_projection.target_engine().clone(), + owner.clone(), + )?; + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + let replacement = self.targets.begin_active_replacement( + &browser_context_id, + &expected_target_id, + &replacement_target_id, + creation_metadata.session_storage_store(), + )?; + let handle = replacement.replacement_handle().clone(); + let session_storage_access = replacement.replacement_session_storage_access().clone(); + let page_registration = match self + .page_residences + .begin_target_registration(&mut self.target_runtimes, owner.clone()) + { + Ok(page_registration) => page_registration, + Err(error) => { + let rolled_back = self.targets.rollback_active_replacement(replacement); + debug_assert!( + rolled_back, + "same-turn Page registration rejection must restore active Target replacement" + ); + return Err(error.into()); + } + }; + let engine_outcome = match self.target_engines.handoff_target_engine( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + handoff, + create_replacement, + ) { + Ok(outcome) => outcome, + Err(error) => { + let page_rolled_back = self + .page_residences + .rollback_target_registration(&mut self.target_runtimes, page_registration); + let rolled_back = self.targets.rollback_active_replacement(replacement); + debug_assert!( + page_rolled_back, + "same-turn active Target replacement rejection must restore Page registration" + ); + debug_assert!( + rolled_back, + "same-turn active Target replacement rejection must restore exact source" + ); + return Err(error.into()); + } + }; + let retired_renderer_page_owner = self.forget_target_runtime_state(&expected_owner); + debug_assert!( + retired_renderer_page_owner.is_none(), + "prevalidated bootstrap placeholder replacement cannot retire a renderer Page" + ); + self.install_target_creation_metadata(&owner, &creation_metadata); + let page_residence = self + .page_residences + .commit_target_registration(&mut self.target_runtimes, page_registration); + self.targets.commit_active_replacement(replacement); + let page_residence_identity = page_residence.identity( + owner.browser_context_id().to_owned(), + Some(owner.target_id().to_owned()), + ); + let registration = BrowserTargetRegistration { + owner, + handle, + page_residence, + page_residence_identity, + residence: BrowserTargetResidence::Active, + previous_active_target_id: Some(expected_target_id), + engine_outcome: Some(engine_outcome), + session_storage_access, + }; + self.publish_target_created_after_registration(®istration); + Ok(registration) + } + + pub fn activate_target( + &mut self, + browser_context_id: &str, + target_id: &str, + topology_projection: BrowserTargetTopologyProjection, + selection_projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result + where + F: FnOnce() -> NavigationEngine, + { + let browser_context_id = BrowserContextId::new(browser_context_id); + self.validate_selected_target_transaction_context( + &browser_context_id, + &topology_projection, + &selection_projection, + )?; + let owner = BrowserPageOwnerKey::new(browser_context_id.as_str(), target_id); + let residence = self.targets.validate_target_owner(&owner)?; + if residence == BrowserTargetResidence::Active { + return Ok(BrowserTargetActivation { + owner, + previous_active_target_id: None, + changed: false, + engine_outcome: None, + }); + } + + let handoff = BrowserTargetEngineHandoff::new( + selection_projection.target_engine().clone(), + owner.clone(), + )?; + let selected_engine_owner = self.selected_target_engine_owner().cloned(); + let activation = self.targets.begin_activation(&owner)?; + let engine_outcome = match self.target_engines.handoff_target_engine( + &mut self.target_runtimes, + selected_engine_owner.as_ref(), + handoff, + create_replacement, + ) { + Ok(outcome) => outcome, + Err(error) => { + let rolled_back = self.targets.rollback_activation(activation); + debug_assert!( + rolled_back, + "same-turn Target activation rejection must restore exact background slot" + ); + return Err(error.into()); + } + }; + let previous_active_target_id = self.targets.commit_activation(activation); + Ok(BrowserTargetActivation { + owner, + previous_active_target_id, + changed: true, + engine_outcome: Some(engine_outcome), + }) + } + + /// Rolls back a Target that was registered as background staging but + /// never became a committed live browsing context. Live Target closure + /// must use the termination transaction instead. + pub fn rollback_staged_background_target( + &mut self, + browser_context_id: &str, + target_id: &str, + topology_projection: BrowserTargetTopologyProjection, + ) -> Result, BrowserTargetRegistryError> { + let browser_context_id = BrowserContextId::new(browser_context_id); + self.targets + .validate_projection(&browser_context_id, &topology_projection)?; + self.page_residences.validate_projection( + &self.target_runtimes, + &browser_context_id, + &topology_projection, + )?; + let owner = BrowserPageOwnerKey::new(browser_context_id.as_str(), target_id); + if self.targets.validate_target_owner(&owner)? != BrowserTargetResidence::Background { + return Err(BrowserTargetRegistryError::TargetIsNotBackground(owner)); + } + self.targets.remove_target(&owner)?; + Ok(self.forget_target_runtime_state(&owner)) + } + + fn validate_selected_target_transaction_context( + &self, + browser_context_id: &BrowserContextId, + topology_projection: &BrowserTargetTopologyProjection, + selection_projection: &BrowserContextSelectionProjection, + ) -> Result<(), BrowserTargetRegistryError> { + if !self.browser_contexts.contains(browser_context_id) { + return Err(BrowserTargetRegistryError::UnknownBrowserContext( + browser_context_id.clone(), + )); + } + if !self.browser_contexts.accepts_owner_work(browser_context_id) { + return Err(super::BrowserContextRegistryError::BrowserContextDisposing( + browser_context_id.clone(), + ) + .into()); + } + self.browser_contexts + .validate_projection(self.selected_target_engine_owner(), selection_projection)?; + if self.browser_contexts.selected() != Some(browser_context_id) { + return Err(BrowserTargetRegistryError::SelectedBrowserContextRequired { + requested: browser_context_id.clone(), + selected: self.browser_contexts.selected().cloned(), + }); + } + self.targets + .validate_projection(browser_context_id, topology_projection)?; + self.page_residences.validate_projection( + &self.target_runtimes, + browser_context_id, + topology_projection, + )?; + self.browser_contexts.validate_selected_target( + selection_projection, + browser_context_id, + self.targets + .active_target(browser_context_id) + .map(BrowserTargetId::as_str), + )?; + Ok(()) + } + + pub fn target_browser_context_id(&self, target_id: &str) -> Option<&str> { + self.targets + .context_for_target(&BrowserTargetId::new(target_id)) + .map(BrowserContextId::as_str) + } + + pub fn active_target_id_for_browser_context(&self, browser_context_id: &str) -> Option<&str> { + self.targets + .active_target(&BrowserContextId::new(browser_context_id)) + .map(BrowserTargetId::as_str) + } + + pub fn has_target(&self, target_id: &str) -> bool { + self.targets + .context_for_target(&BrowserTargetId::new(target_id)) + .is_some() + } + + pub fn target_count(&self) -> usize { + self.targets.target_count() + } + + pub fn browser_context_target_count(&self, browser_context_id: &str) -> usize { + self.targets + .context_target_count(&BrowserContextId::new(browser_context_id)) + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-core/src/browser_host/navigation_owner/target_transaction/tests.rs b/moli-core/src/browser_host/navigation_owner/target_transaction/tests.rs new file mode 100644 index 0000000000..daec979bd2 --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/target_transaction/tests.rs @@ -0,0 +1,439 @@ +use crate::{ + browser_host::{ + BrowserContextSelectionProjection, BrowserFact, BrowserInitialEmptyDocumentCreator, + BrowserInitialEmptyDocumentSeed, BrowserPageOwnerKey, BrowserPageResidenceHandle, + BrowserSelectedTargetEngineDisposition, BrowserTargetCreationMetadata, + BrowserTargetEngineHandoffOutcome, BrowserTargetHandle, BrowserTargetRegistration, + BrowserTargetResidence, BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }, + runtime::NavigationEngine, +}; + +use super::super::BrowserNavigationOwner; + +fn empty_context_projection(context_id: &str) -> BrowserTargetTopologyProjection { + BrowserTargetTopologyProjection::new( + context_id, + None, + Vec::::new(), + ) +} + +fn slot( + target: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, +) -> BrowserTargetSlotProjection { + BrowserTargetSlotProjection::new(target, page_residence) +} + +fn registered_slot(registration: &BrowserTargetRegistration) -> BrowserTargetSlotProjection { + slot( + registration.handle().clone(), + registration.page_residence_handle().clone(), + ) +} + +fn selection( + context_id: Option<&str>, + disposition: BrowserSelectedTargetEngineDisposition, +) -> BrowserContextSelectionProjection { + BrowserContextSelectionProjection::new(context_id.map(str::to_owned), disposition) +} + +fn register_context(owner: &mut BrowserNavigationOwner, context_id: &str) { + owner + .register_browser_context( + context_id, + empty_context_projection(context_id), + selection(None, BrowserSelectedTargetEngineDisposition::Unbound), + NavigationEngine::new, + ) + .expect("test BrowserContext should register"); +} + +#[test] +fn disposing_context_rejects_new_target_registration_until_rollback() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + register_context(&mut owner, "context-disposing"); + let handle = owner + .browser_context_handle("context-disposing") + .expect("registered Context handle") + .clone(); + let reservation = owner + .begin_browser_context_disposal(&handle) + .expect("Context should reserve disposal"); + + let error = owner + .register_background_target( + "context-disposing", + "target-too-late", + empty_context_projection("context-disposing"), + ) + .expect_err("a disposing Context must reject newly registered Targets"); + assert_eq!( + error, + crate::browser_host::BrowserTargetRegistryError::BrowserContext( + crate::browser_host::BrowserContextRegistryError::BrowserContextDisposing( + crate::browser_host::BrowserContextId::new("context-disposing") + ) + ) + ); + assert_eq!(owner.browser_context_target_count("context-disposing"), 0); + + assert!(owner.rollback_browser_context_disposal(reservation)); + owner + .register_background_target( + "context-disposing", + "target-after-rollback", + empty_context_projection("context-disposing"), + ) + .expect("rollback must restore Target registration authority"); +} + +#[test] +fn registration_and_activation_move_exact_target_topology_with_engine_owner() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + register_context(&mut owner, "context-1"); + + let first = owner + .register_active_target( + "context-1", + "target-a", + empty_context_projection("context-1"), + selection( + Some("context-1"), + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("first active Target should register"); + assert_eq!(first.residence(), BrowserTargetResidence::Active); + assert!(first.handle().is_live()); + assert_eq!(first.previous_active_target_id(), None); + assert_eq!( + first.engine_outcome(), + Some(BrowserTargetEngineHandoffOutcome::ReusedSelected) + ); + + let second = owner + .register_background_target( + "context-1", + "target-b", + BrowserTargetTopologyProjection::new( + "context-1", + Some(registered_slot(&first)), + Vec::::new(), + ), + ) + .expect("background Target should register"); + let activated = owner + .activate_target( + "context-1", + "target-b", + BrowserTargetTopologyProjection::new( + "context-1", + Some(registered_slot(&first)), + vec![registered_slot(&second)], + ), + selection( + Some("context-1"), + BrowserSelectedTargetEngineDisposition::Discard( + crate::browser_host::BrowserPageOwnerKey::new("context-1", "target-a"), + ), + ), + NavigationEngine::new, + ) + .expect("background Target should activate"); + + assert!(activated.changed()); + assert_eq!(activated.previous_active_target_id(), Some("target-a")); + assert_eq!( + owner.active_target_id_for_browser_context("context-1"), + Some("target-b") + ); + assert_eq!( + owner.target_browser_context_id("target-a"), + Some("context-1") + ); + assert_eq!(owner.target_count(), 2); +} + +#[test] +fn target_registration_atomically_installs_initial_document_creation_metadata() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + register_context(&mut owner, "context-1"); + let key = BrowserPageOwnerKey::new("context-1", "target-a"); + let creator = + BrowserInitialEmptyDocumentCreator::new("opener-a", "https://opener.example", "Secure"); + + let registration = owner + .register_background_target_with_creation_metadata( + "context-1", + "target-a", + BrowserTargetCreationMetadata::with_initial_empty_document( + BrowserInitialEmptyDocumentSeed::new("about:blank#created") + .with_creator(creator.clone()), + ), + empty_context_projection("context-1"), + ) + .expect("Target and its creation metadata should commit together"); + + assert_eq!(registration.owner(), &key); + let initial = owner + .target_initial_empty_document(&key) + .expect("committed Target must expose its initial Document metadata"); + assert_eq!(initial.initial_url(), "about:blank#created"); + assert_eq!(initial.creator(), Some(&creator)); + assert_eq!(initial.loader_id(), "LID-INITIAL-target-a"); + + let facts = owner.browser_fact_snapshot(); + assert_eq!(facts.len(), 1); + assert!(matches!(facts[0].fact(), BrowserFact::TargetCreated)); + assert_eq!(facts[0].browser_context_id().as_str(), "context-1"); + assert_eq!(facts[0].target_id().as_str(), "target-a"); + assert_eq!( + facts[0].page_residence(), + registration.page_residence_identity() + ); +} + +#[test] +fn bootstrap_active_target_replacement_retires_placeholder_without_demotion() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let placeholder = BrowserTargetHandle::staged("placeholder"); + let placeholder_page = BrowserPageResidenceHandle::default(); + owner + .register_browser_context( + "context-1", + BrowserTargetTopologyProjection::new( + "context-1", + Some(slot(placeholder.clone(), placeholder_page.clone())), + Vec::::new(), + ), + selection(None, BrowserSelectedTargetEngineDisposition::Unbound), + NavigationEngine::new, + ) + .expect("placeholder BrowserContext should register"); + + let replacement = owner + .replace_active_target( + "context-1", + "placeholder", + "target-a", + BrowserTargetTopologyProjection::new( + "context-1", + Some(slot(placeholder.clone(), placeholder_page)), + Vec::::new(), + ), + selection( + Some("context-1"), + BrowserSelectedTargetEngineDisposition::Discard( + crate::browser_host::BrowserPageOwnerKey::new("context-1", "placeholder"), + ), + ), + NavigationEngine::new, + ) + .expect("bootstrap placeholder should replace exactly"); + + assert_eq!(replacement.residence(), BrowserTargetResidence::Active); + assert!(placeholder.is_retired()); + assert!(replacement.handle().is_live()); + assert_eq!(replacement.previous_active_target_id(), Some("placeholder")); + assert_eq!( + owner.active_target_id_for_browser_context("context-1"), + Some("target-a") + ); + assert!(!owner.has_target("placeholder")); + assert_eq!(owner.target_count(), 1); +} + +#[test] +fn retired_active_source_rejects_replacement_without_registering_successor() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let placeholder = BrowserTargetHandle::staged("placeholder"); + let placeholder_page = BrowserPageResidenceHandle::default(); + owner + .register_browser_context( + "context-1", + BrowserTargetTopologyProjection::new( + "context-1", + Some(slot(placeholder.clone(), placeholder_page.clone())), + Vec::::new(), + ), + selection(None, BrowserSelectedTargetEngineDisposition::Unbound), + NavigationEngine::new, + ) + .expect("placeholder BrowserContext should register"); + let renderer_owner = owner.active_renderer_owner_id_for_diagnostics(); + assert!(placeholder.reserve_retirement()); + placeholder.commit_retirement_reservation(); + + let error = owner + .replace_active_target( + "context-1", + "placeholder", + "target-a", + BrowserTargetTopologyProjection::new( + "context-1", + Some(slot(placeholder.clone(), placeholder_page)), + Vec::::new(), + ), + selection( + Some("context-1"), + BrowserSelectedTargetEngineDisposition::Discard(BrowserPageOwnerKey::new( + "context-1", + "placeholder", + )), + ), + NavigationEngine::new, + ) + .expect_err("retired exact source must reject active replacement"); + + assert!(matches!( + error, + crate::browser_host::BrowserTargetRegistryError::TargetHandleProjectionMismatch(_) + )); + assert_eq!( + owner.active_target_id_for_browser_context("context-1"), + Some("placeholder") + ); + assert!(owner.has_target("placeholder")); + assert!(!owner.has_target("target-a")); + assert_eq!(owner.target_count(), 1); + assert_eq!( + owner.active_renderer_owner_id_for_diagnostics(), + renderer_owner + ); +} + +#[test] +fn duplicate_target_and_wrong_physical_projection_are_rejected_without_mutation() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + register_context(&mut owner, "context-1"); + let first = owner + .register_background_target( + "context-1", + "target-a", + empty_context_projection("context-1"), + ) + .expect("background Target should register"); + + assert!( + owner + .register_background_target( + "context-1", + "target-a", + BrowserTargetTopologyProjection::new( + "context-1", + None, + vec![registered_slot(&first)], + ), + ) + .is_err() + ); + assert!( + owner + .register_background_target( + "context-1", + "target-b", + empty_context_projection("context-1"), + ) + .is_err() + ); + let same_id_wrong_instance = BrowserTargetHandle::staged("target-a"); + assert!(matches!( + owner.register_background_target( + "context-1", + "target-b", + BrowserTargetTopologyProjection::new( + "context-1", + None, + vec![slot( + same_id_wrong_instance.clone(), + first.page_residence_handle().clone(), + )], + ), + ), + Err(crate::browser_host::BrowserTargetRegistryError::TargetHandleProjectionMismatch(_)) + )); + assert!(!same_id_wrong_instance.is_live()); + assert!(first.handle().is_live()); + assert_eq!(owner.target_count(), 1); + assert_eq!( + owner.target_browser_context_id("target-a"), + Some("context-1") + ); + assert!(!owner.has_target("target-b")); +} + +#[test] +fn wrong_page_residence_projection_is_rejected_without_mutation() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + register_context(&mut owner, "context-1"); + let first = owner + .register_background_target( + "context-1", + "target-a", + empty_context_projection("context-1"), + ) + .expect("background Target should register"); + let wrong_page = BrowserPageResidenceHandle::default(); + + assert!(matches!( + owner.register_background_target( + "context-1", + "target-b", + BrowserTargetTopologyProjection::new( + "context-1", + None, + vec![slot(first.handle().clone(), wrong_page)], + ), + ), + Err( + crate::browser_host::BrowserTargetRegistryError::PageResidence( + crate::browser_host::BrowserPageResidenceRegistryError::ProjectionMismatch(_) + ) + ) + )); + assert_eq!(owner.target_count(), 1); + assert!(!owner.has_target("target-b")); + assert!(owner.page_residence_handle_is_current(first.owner(), first.page_residence_handle())); +} + +#[test] +fn target_ids_are_globally_unique_across_browser_contexts() { + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + register_context(&mut owner, "context-1"); + owner + .register_browser_context( + "context-2", + empty_context_projection("context-2"), + selection( + Some("context-1"), + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("second BrowserContext should register"); + owner + .register_background_target( + "context-1", + "target-a", + empty_context_projection("context-1"), + ) + .expect("first Target should register"); + + assert!( + owner + .register_background_target( + "context-2", + "target-a", + empty_context_projection("context-2"), + ) + .is_err() + ); + assert_eq!( + owner.target_browser_context_id("target-a"), + Some("context-1") + ); +} diff --git a/moli-core/src/browser_host/navigation_owner/types.rs b/moli-core/src/browser_host/navigation_owner/types.rs new file mode 100644 index 0000000000..6cdd12699c --- /dev/null +++ b/moli-core/src/browser_host/navigation_owner/types.rs @@ -0,0 +1,36 @@ +use crate::browser_host::{BrowserContextId, BrowserTargetId}; + +/// Protocol-neutral key for the strong renderer/navigation owner parked behind +/// one browser Target. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct BrowserPageOwnerKey { + browser_context_id: BrowserContextId, + target_id: BrowserTargetId, +} + +impl BrowserPageOwnerKey { + pub fn new(browser_context_id: impl Into, target_id: impl Into) -> Self { + Self { + browser_context_id: BrowserContextId::new(browser_context_id), + target_id: BrowserTargetId::new(target_id), + } + } + + pub fn browser_context_id(&self) -> &str { + self.browser_context_id.as_str() + } + + pub fn target_id(&self) -> &str { + self.target_id.as_str() + } +} + +/// Browser-owned fetch policy applied to one active Page runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserPageFetchConfiguration { + pub browser_identity: moli_browser_profile::BrowserIdentityProfile, + pub http_proxy: Option, + pub http_no_proxy: Option, + pub tls_verify_host: bool, + pub bypass_service_worker: bool, +} diff --git a/moli-core/src/browser_host/network_artifacts.rs b/moli-core/src/browser_host/network_artifacts.rs new file mode 100644 index 0000000000..d7fdd22209 --- /dev/null +++ b/moli-core/src/browser_host/network_artifacts.rs @@ -0,0 +1,432 @@ +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +use moli_bounded_buffer::{BoundedByteBuffer, ByteLimits, InsertOutcome}; + +use super::BrowserNetworkBody; + +const RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES: usize = 20_000_000; +const RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES: usize = 2_000_000; +const REQUEST_BODY_BUFFER_MAX_TOTAL_BYTES: usize = 200_000_000; +const REQUEST_BODY_BUFFER_MAX_ENTRY_BYTES: usize = 64 * 1024 * 1024; + +/// Browser-owned state of one captured network response body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BrowserNetworkResponseBody { + Pending, + Ready(BrowserNetworkBody), + Failed(String), + Evicted, +} + +impl BrowserNetworkResponseBody { + pub fn body_bytes_limited(&self, limit: usize) -> anyhow::Result> { + match self { + Self::Ready(body) => body.materialize_bytes_limited(limit), + Self::Pending | Self::Failed(_) => { + anyhow::bail!("No data found for resource with given identifier") + } + Self::Evicted => { + anyhow::bail!("Request content was evicted from inspector cache") + } + } + } + + pub fn ready_body(&self) -> Option<&BrowserNetworkBody> { + match self { + Self::Ready(body) => Some(body), + Self::Pending | Self::Failed(_) | Self::Evicted => None, + } + } +} + +/// Shared Browser Host residence for request identities and captured bodies. +/// +/// The store contains no session ids, collector ids, event cursors or IO read +/// offsets. Frontends may keep those projections independently without owning +/// or resetting the underlying browser artifacts. +#[derive(Clone, Default)] +pub struct BrowserNetworkArtifactStore { + inner: Rc>, +} + +impl PartialEq for BrowserNetworkArtifactStore { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.inner, &other.inner) + } +} + +impl Eq for BrowserNetworkArtifactStore {} + +impl std::fmt::Debug for BrowserNetworkArtifactStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let inner = self.inner.borrow(); + formatter + .debug_struct("BrowserNetworkArtifactStore") + .field("next_request_sequence", &inner.next_request_sequence) + .field("request_body_count", &inner.request_bodies.len()) + .field( + "response_body_count", + &(inner.response_bodies.len() + inner.response_terminal.len()), + ) + .finish() + } +} + +#[derive(Debug)] +struct BrowserNetworkArtifactStoreInner { + next_request_sequence: u64, + request_bodies: BoundedByteBuffer, + response_terminal: HashMap, + response_bodies: BoundedByteBuffer, +} + +impl Default for BrowserNetworkArtifactStoreInner { + fn default() -> Self { + Self::with_limits( + ByteLimits::new( + REQUEST_BODY_BUFFER_MAX_TOTAL_BYTES, + REQUEST_BODY_BUFFER_MAX_ENTRY_BYTES, + ), + ByteLimits::new( + RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES, + RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES, + ), + ) + } +} + +impl BrowserNetworkArtifactStoreInner { + fn with_limits(request_limits: ByteLimits, response_limits: ByteLimits) -> Self { + Self { + next_request_sequence: 0, + request_bodies: BoundedByteBuffer::new(request_limits), + response_terminal: HashMap::new(), + response_bodies: BoundedByteBuffer::new(response_limits), + } + } + + fn response_body(&self, request_id: &str) -> Option { + self.response_bodies + .get(request_id) + .cloned() + .map(BrowserNetworkResponseBody::Ready) + .or_else(|| self.response_terminal.get(request_id).cloned()) + } + + fn record_response_body(&mut self, request_id: String, body: BrowserNetworkBody) { + let byte_len = body.len(); + self.response_terminal.remove(&request_id); + match self.response_bodies.insert(request_id, body, byte_len) { + InsertOutcome::Stored { evicted } => { + for (request_id, _) in evicted { + self.response_terminal + .insert(request_id, BrowserNetworkResponseBody::Evicted); + } + } + InsertOutcome::Rejected { key, .. } => { + self.response_terminal + .insert(key, BrowserNetworkResponseBody::Evicted); + } + } + } + + fn record_response_terminal( + &mut self, + request_id: String, + terminal: BrowserNetworkResponseBody, + ) { + self.response_bodies.remove(&request_id); + self.response_terminal.insert(request_id, terminal); + } +} + +impl BrowserNetworkArtifactStore { + pub fn allocate_request_sequence(&self) -> u64 { + let mut inner = self.inner.borrow_mut(); + inner.next_request_sequence = inner.next_request_sequence.wrapping_add(1).max(1); + inner.next_request_sequence + } + + pub fn allocate_request_id(&self) -> String { + format!("REQ-{}", self.allocate_request_sequence()) + } + + pub fn record_request_body(&self, request_id: String, body: BrowserNetworkBody) { + let byte_len = body.len(); + let _ = self + .inner + .borrow_mut() + .request_bodies + .insert(request_id, body, byte_len); + } + + pub fn record_pending_response_body(&self, request_id: String) { + let mut inner = self.inner.borrow_mut(); + if inner.response_bodies.contains_key(request_id.as_str()) + || inner.response_terminal.contains_key(request_id.as_str()) + { + return; + } + inner.record_response_terminal(request_id, BrowserNetworkResponseBody::Pending); + } + + pub fn record_response_body(&self, request_id: String, body: BrowserNetworkBody) { + self.inner + .borrow_mut() + .record_response_body(request_id, body); + } + + pub fn record_failed_response_body(&self, request_id: String, error_text: String) { + self.inner + .borrow_mut() + .record_response_terminal(request_id, BrowserNetworkResponseBody::Failed(error_text)); + } + + pub fn request_body(&self, request_id: &str) -> Option { + self.inner.borrow().request_bodies.get(request_id).cloned() + } + + pub fn response_body(&self, request_id: &str) -> Option { + self.inner.borrow().response_body(request_id) + } + + /// Copies selected candidate artifacts into this authoritative store. + /// + /// BrowserContext candidates are often built before Core registration. + /// Adoption copies only ids visible in that candidate, then its standalone + /// store is dropped. Already-shared stores are detected and left alone. + pub fn adopt_entries_from<'a>( + &self, + candidate: &Self, + request_ids: impl IntoIterator, + ) { + if Rc::ptr_eq(&self.inner, &candidate.inner) { + return; + } + let candidate_next_request_sequence = candidate.inner.borrow().next_request_sequence; + { + let mut inner = self.inner.borrow_mut(); + inner.next_request_sequence = inner + .next_request_sequence + .max(candidate_next_request_sequence); + } + let request_ids = request_ids.into_iter().collect::>(); + for request_id in request_ids { + if let Some(body) = candidate.request_body(request_id) { + self.record_request_body(request_id.to_owned(), body); + } + if let Some(response) = candidate.response_body(request_id) { + match response { + BrowserNetworkResponseBody::Pending => { + self.record_pending_response_body(request_id.to_owned()); + } + BrowserNetworkResponseBody::Ready(body) => { + self.record_response_body(request_id.to_owned(), body); + } + BrowserNetworkResponseBody::Failed(error) => { + self.record_failed_response_body(request_id.to_owned(), error); + } + BrowserNetworkResponseBody::Evicted => { + self.inner.borrow_mut().record_response_terminal( + request_id.to_owned(), + BrowserNetworkResponseBody::Evicted, + ); + } + } + } + } + } + + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn next_request_sequence_for_test(&self) -> u64 { + self.inner.borrow().next_request_sequence + } + + #[cfg(test)] + fn with_limits(request_limits: ByteLimits, response_limits: ByteLimits) -> Self { + Self { + inner: Rc::new(RefCell::new(BrowserNetworkArtifactStoreInner::with_limits( + request_limits, + response_limits, + ))), + } + } + + #[cfg(test)] + fn response_body_bytes(&self) -> usize { + self.inner.borrow().response_bodies.used_bytes() + } + + #[cfg(test)] + fn request_body_bytes(&self) -> usize { + self.inner.borrow().request_bodies.used_bytes() + } + + #[cfg(test)] + fn response_limits(&self) -> ByteLimits { + self.inner.borrow().response_bodies.limits() + } + + #[cfg(test)] + fn request_limits(&self) -> ByteLimits { + self.inner.borrow().request_bodies.limits() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_store_preserves_existing_inspector_budget() { + let store = BrowserNetworkArtifactStore::default(); + + assert_eq!( + store.response_limits(), + ByteLimits::new(20_000_000, 2_000_000) + ); + assert_eq!( + store.request_limits(), + ByteLimits::new(200_000_000, 64 * 1024 * 1024) + ); + } + + #[test] + fn request_store_evicts_oldest_payload_within_host_budget() { + let store = BrowserNetworkArtifactStore::with_limits( + ByteLimits::new(5, 4), + ByteLimits::new(64, 64), + ); + store.record_request_body( + "REQ-first".to_owned(), + BrowserNetworkBody::from_string("aa".to_owned()), + ); + store.record_request_body( + "REQ-second".to_owned(), + BrowserNetworkBody::from_string("bb".to_owned()), + ); + store.record_request_body( + "REQ-third".to_owned(), + BrowserNetworkBody::from_string("ccc".to_owned()), + ); + + assert_eq!(store.request_body_bytes(), 5); + assert!(store.request_body("REQ-first").is_none()); + assert_eq!( + store + .request_body("REQ-second") + .expect("second request body should remain") + .materialize_bytes() + .expect("second request body should materialize"), + b"bb" + ); + } + + #[test] + fn response_store_marks_oldest_payload_evicted() { + let store = BrowserNetworkArtifactStore::with_limits( + ByteLimits::new(64, 64), + ByteLimits::new(5, 4), + ); + store.record_response_body( + "REQ-first".to_owned(), + BrowserNetworkBody::from_string("aa".to_owned()), + ); + store.record_response_body( + "REQ-second".to_owned(), + BrowserNetworkBody::from_string("bb".to_owned()), + ); + store.record_response_body( + "REQ-third".to_owned(), + BrowserNetworkBody::from_string("ccc".to_owned()), + ); + + assert_eq!(store.response_body_bytes(), 5); + assert_eq!( + store + .response_body("REQ-first") + .expect("evicted metadata must remain") + .body_bytes_limited(10) + .expect_err("oldest body should be evicted") + .to_string(), + "Request content was evicted from inspector cache" + ); + assert_eq!( + store + .response_body("REQ-second") + .expect("second response should remain") + .body_bytes_limited(10) + .expect("second body should be readable"), + b"bb" + ); + } + + #[test] + fn response_store_returns_byte_charge_when_ready_body_becomes_terminal() { + let store = BrowserNetworkArtifactStore::with_limits( + ByteLimits::new(64, 64), + ByteLimits::new(8, 4), + ); + store.record_response_body( + "REQ-failed".to_owned(), + BrowserNetworkBody::from_string("body".to_owned()), + ); + assert_eq!(store.response_body_bytes(), 4); + + store.record_failed_response_body("REQ-failed".to_owned(), "network failed".to_owned()); + + assert_eq!(store.response_body_bytes(), 0); + assert!(matches!( + store.response_body("REQ-failed"), + Some(BrowserNetworkResponseBody::Failed(error)) if error == "network failed" + )); + } + + #[test] + fn cloned_store_shares_ids_and_artifacts() { + let first = BrowserNetworkArtifactStore::default(); + let second = first.clone(); + + assert_eq!(first.allocate_request_id(), "REQ-1"); + assert_eq!(second.allocate_request_id(), "REQ-2"); + first.record_request_body( + "REQ-1".to_owned(), + BrowserNetworkBody::from_bytes(b"post".to_vec()), + ); + assert_eq!( + second + .request_body("REQ-1") + .expect("clone should observe the same artifact") + .materialize_bytes() + .expect("body should materialize"), + b"post" + ); + } + + #[test] + fn candidate_entries_are_adopted_without_copying_frontend_state() { + let candidate = BrowserNetworkArtifactStore::default(); + assert_eq!(candidate.allocate_request_id(), "REQ-1"); + candidate.record_response_body( + "REQ-candidate".to_owned(), + BrowserNetworkBody::from_string("body".to_owned()), + ); + let host = BrowserNetworkArtifactStore::default(); + + host.adopt_entries_from(&candidate, ["REQ-candidate"]); + + assert_eq!( + host.response_body("REQ-candidate") + .expect("host should adopt the candidate body") + .body_bytes_limited(16) + .expect("adopted body should remain readable"), + b"body" + ); + assert_eq!( + host.allocate_request_id(), + "REQ-2", + "Host adoption must not reuse an id already allocated by the candidate" + ); + } +} diff --git a/moli-core/src/browser_host/network_body.rs b/moli-core/src/browser_host/network_body.rs new file mode 100644 index 0000000000..c15938ee8b --- /dev/null +++ b/moli-core/src/browser_host/network_body.rs @@ -0,0 +1,589 @@ +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; + +use anyhow::{Context, Result, anyhow}; + +const DEFAULT_MEMORY_LIMIT: usize = 1024 * 1024; +pub const DEFAULT_BODY_MATERIALIZE_LIMIT: usize = 64 * 1024 * 1024; + +static NEXT_SPOOL_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +pub struct CapturedBody { + inner: Arc, +} + +#[derive(Debug)] +enum CapturedBodyInner { + Memory(Vec), + SharedMemory(Arc<[u8]>), + File { path: PathBuf, len: usize }, + Subresource(crate::page::SubresourceResponseBody), +} + +impl Drop for CapturedBodyInner { + fn drop(&mut self) { + if let Self::File { path, .. } = self { + let _ = fs::remove_file(path); + } + } +} + +impl CapturedBody { + pub fn from_string(body: String) -> Self { + Self::from_bytes(body.into_bytes()) + } + + pub fn from_bytes(body: Vec) -> Self { + Self { + inner: Arc::new(CapturedBodyInner::Memory(body)), + } + } + + pub fn from_shared_bytes(body: Arc<[u8]>) -> Self { + Self { + inner: Arc::new(CapturedBodyInner::SharedMemory(body)), + } + } + + pub fn from_bytes_spooled(body: Vec) -> Self { + if body.len() <= DEFAULT_MEMORY_LIMIT { + return Self::from_bytes(body); + } + let mut writer = CapturedBodyWriter::default(); + if writer.append(&body).is_ok() + && let Ok(captured) = writer.finish() + { + return captured; + } + Self::from_bytes(body) + } + + pub fn from_optional_renderer_synthetic_response_body( + body: Option, + ) -> Self { + Self::from_bytes(body.map(|body| body.into_body_bytes()).unwrap_or_default()) + } + + pub fn from_subresource_response_body(body: &crate::page::SubresourceResponseBody) -> Self { + Self { + inner: Arc::new(CapturedBodyInner::Subresource(body.clone())), + } + } + + pub fn len(&self) -> usize { + match self.inner.as_ref() { + CapturedBodyInner::Memory(bytes) => bytes.len(), + CapturedBodyInner::SharedMemory(bytes) => bytes.len(), + CapturedBodyInner::File { len, .. } => *len, + CapturedBodyInner::Subresource(body) => body.len(), + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn materialize_bytes_limited(&self, limit: usize) -> Result> { + ensure_materialize_limit(self.len(), limit)?; + self.materialize_bytes() + } + + pub fn materialize_bytes(&self) -> Result> { + match self.inner.as_ref() { + CapturedBodyInner::Memory(bytes) => Ok(bytes.clone()), + CapturedBodyInner::SharedMemory(bytes) => Ok(bytes.to_vec()), + CapturedBodyInner::File { path, .. } => { + let mut file = File::open(path).with_context(|| { + anyhow!("failed to open captured response body `{}`", path.display()) + })?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).with_context(|| { + anyhow!("failed to read captured response body `{}`", path.display()) + })?; + Ok(bytes) + } + CapturedBodyInner::Subresource(body) => body + .materialize_bytes() + .with_context(|| "failed to materialize captured subresource response body"), + } + } + + pub fn read_range(&self, offset: usize, len: usize) -> Result> { + if len == 0 || offset >= self.len() { + return Ok(Vec::new()); + } + let len = len.min(self.len().saturating_sub(offset)); + match self.inner.as_ref() { + CapturedBodyInner::Memory(bytes) => { + Ok(bytes[offset..offset.saturating_add(len)].to_vec()) + } + CapturedBodyInner::SharedMemory(bytes) => { + Ok(bytes[offset..offset.saturating_add(len)].to_vec()) + } + CapturedBodyInner::File { path, .. } => { + let mut file = File::open(path).with_context(|| { + anyhow!("failed to open captured response body `{}`", path.display()) + })?; + file.seek(SeekFrom::Start(offset as u64)).with_context(|| { + anyhow!("failed to seek captured response body `{}`", path.display()) + })?; + let mut bytes = vec![0; len]; + let read = file.read(&mut bytes).with_context(|| { + anyhow!("failed to read captured response body `{}`", path.display()) + })?; + bytes.truncate(read); + Ok(bytes) + } + CapturedBodyInner::Subresource(body) => body + .read_chunk(offset, len) + .with_context(|| "failed to read captured subresource response body"), + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn materialize_lossy_string(&self) -> Result { + self.materialize_bytes() + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + } + + pub fn chunk_reader(&self, chunk_size: usize) -> Result { + if chunk_size == 0 { + return Err(anyhow!( + "captured response body chunk size must be non-zero" + )); + } + match self.inner.as_ref() { + CapturedBodyInner::Memory(_) | CapturedBodyInner::SharedMemory(_) => { + Ok(CapturedBodyChunkReader { + source: CapturedBodyChunkReaderSource::Memory { + body: self.clone(), + offset: 0, + }, + chunk_size, + }) + } + CapturedBodyInner::File { path, .. } => { + let file = File::open(path).with_context(|| { + anyhow!("failed to open captured response body `{}`", path.display()) + })?; + Ok(CapturedBodyChunkReader { + source: CapturedBodyChunkReaderSource::File { + file, + path: path.clone(), + }, + chunk_size, + }) + } + CapturedBodyInner::Subresource(_) => Ok(CapturedBodyChunkReader { + source: CapturedBodyChunkReaderSource::Subresource { + body: self.clone(), + offset: 0, + }, + chunk_size, + }), + } + } +} + +pub struct CapturedBodyChunkReader { + source: CapturedBodyChunkReaderSource, + chunk_size: usize, +} + +enum CapturedBodyChunkReaderSource { + Memory { body: CapturedBody, offset: usize }, + File { file: File, path: PathBuf }, + Subresource { body: CapturedBody, offset: usize }, +} + +impl CapturedBodyChunkReader { + pub fn next_chunk(&mut self) -> Result>> { + match &mut self.source { + CapturedBodyChunkReaderSource::Memory { body, offset } => { + let bytes: &[u8] = match body.inner.as_ref() { + CapturedBodyInner::Memory(bytes) => bytes, + CapturedBodyInner::SharedMemory(bytes) => bytes, + _ => return Ok(None), + }; + if *offset >= bytes.len() { + return Ok(None); + } + let next_offset = (*offset).saturating_add(self.chunk_size).min(bytes.len()); + let chunk = bytes[*offset..next_offset].to_vec(); + *offset = next_offset; + Ok(Some(chunk)) + } + CapturedBodyChunkReaderSource::File { file, path } => { + let mut buffer = vec![0; self.chunk_size]; + let read = file.read(&mut buffer).with_context(|| { + anyhow!("failed to read captured response body `{}`", path.display()) + })?; + if read == 0 { + return Ok(None); + } + buffer.truncate(read); + Ok(Some(buffer)) + } + CapturedBodyChunkReaderSource::Subresource { body, offset } => { + let CapturedBodyInner::Subresource(source) = body.inner.as_ref() else { + return Ok(None); + }; + let chunk = source + .read_chunk(*offset, self.chunk_size) + .with_context(|| "failed to read captured subresource response body")?; + if chunk.is_empty() { + return Ok(None); + } + *offset = offset.saturating_add(chunk.len()); + Ok(Some(chunk)) + } + } + } +} + +pub fn ensure_materialize_limit(len: usize, limit: usize) -> Result<()> { + if len > limit { + Err(anyhow!( + "response body is {len} bytes, exceeds CDP materialization limit of {limit} bytes" + )) + } else { + Ok(()) + } +} + +impl PartialEq for CapturedBody { + fn eq(&self, other: &Self) -> bool { + self.len() == other.len() && self.materialize_bytes().ok() == other.materialize_bytes().ok() + } +} + +impl Eq for CapturedBody {} + +#[derive(Debug)] +pub struct CapturedBodyWriter { + memory_limit: usize, + len: usize, + memory: Vec, + file: Option, + path: Option, +} + +impl Default for CapturedBodyWriter { + fn default() -> Self { + Self::new(DEFAULT_MEMORY_LIMIT) + } +} + +impl CapturedBodyWriter { + pub fn new(memory_limit: usize) -> Self { + Self { + memory_limit, + len: 0, + memory: Vec::new(), + file: None, + path: None, + } + } + + pub fn append(&mut self, bytes: &[u8]) -> Result<()> { + if bytes.is_empty() { + return Ok(()); + } + if self.file.is_none() && self.len.saturating_add(bytes.len()) <= self.memory_limit { + self.memory.extend_from_slice(bytes); + self.len = self.len.saturating_add(bytes.len()); + return Ok(()); + } + self.ensure_file()?; + if let Some(file) = self.file.as_mut() { + file.write_all(bytes) + .context("failed to append captured response body spool")?; + } + self.len = self.len.saturating_add(bytes.len()); + Ok(()) + } + + pub fn finish(mut self) -> Result { + self.finish_in_place() + } + + pub fn finish_in_place(&mut self) -> Result { + if let Some(file) = self.file.as_mut() { + file.flush() + .context("failed to flush captured response body spool")?; + let _ = self.file.take(); + let path = self + .path + .take() + .expect("captured response body file path should be set"); + Ok(CapturedBody { + inner: Arc::new(CapturedBodyInner::File { + path, + len: self.len, + }), + }) + } else { + Ok(CapturedBody::from_bytes(std::mem::take(&mut self.memory))) + } + } + + fn ensure_file(&mut self) -> Result<()> { + if self.file.is_some() { + return Ok(()); + } + let path = unique_spool_path()?; + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + configure_secure_spool_file_options(&mut options); + let file = options.open(&path).with_context(|| { + anyhow!( + "failed to create captured response body spool `{}`", + path.display() + ) + })?; + // Store the file/path before seeding bytes so Drop can clean up the + // already-created temp file if write_all fails. + self.path = Some(path); + self.file = Some(file); + if !self.memory.is_empty() { + self.file + .as_mut() + .expect("captured response body spool file should be set") + .write_all(&self.memory) + .context("failed to seed captured response body spool")?; + self.memory.clear(); + } + Ok(()) + } +} + +impl Drop for CapturedBodyWriter { + fn drop(&mut self) { + let _ = self.file.take(); + if let Some(path) = self.path.take() { + let _ = fs::remove_file(path); + } + } +} + +impl Write for CapturedBodyWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.append(buf).map_err(io::Error::other)?; + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + if let Some(file) = self.file.as_mut() { + file.flush() + } else { + Ok(()) + } + } +} + +fn unique_spool_path() -> Result { + let root = std::env::temp_dir().join("moli-cdp-body-spool"); + create_secure_spool_root(&root)?; + let id = NEXT_SPOOL_ID.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + Ok(root.join(format!("body-{}-{id}-{nanos}.bin", std::process::id()))) +} + +#[cfg(unix)] +fn create_secure_spool_root(root: &Path) -> Result<()> { + let mut builder = fs::DirBuilder::new(); + builder.recursive(true).mode(0o700); + builder.create(root).with_context(|| { + anyhow!( + "failed to create captured response body spool root `{}`", + root.display() + ) + })?; + // Existing temp dirs may have been created before this code used a + // restrictive mode. Tighten them every time before publishing spool files. + fs::set_permissions(root, fs::Permissions::from_mode(0o700)).with_context(|| { + anyhow!( + "failed to restrict captured response body spool root `{}`", + root.display() + ) + })?; + Ok(()) +} + +#[cfg(not(unix))] +fn create_secure_spool_root(root: &Path) -> Result<()> { + fs::create_dir_all(root).with_context(|| { + anyhow!( + "failed to create captured response body spool root `{}`", + root.display() + ) + }) +} + +#[cfg(unix)] +fn configure_secure_spool_file_options(options: &mut OpenOptions) { + options.mode(0o600); +} + +#[cfg(not(unix))] +fn configure_secure_spool_file_options(_options: &mut OpenOptions) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn captured_body_writer_keeps_small_body_in_memory() -> Result<()> { + let mut writer = CapturedBodyWriter::new(16); + writer.append(b"hello")?; + let body = writer.finish()?; + + assert_eq!(body.len(), 5); + assert_eq!(body.materialize_bytes()?, b"hello"); + Ok(()) + } + + #[test] + fn captured_body_writer_spills_large_body_to_file() -> Result<()> { + let mut writer = CapturedBodyWriter::new(4); + writer.append(b"hello")?; + writer.append(b" world")?; + let body = writer.finish()?; + + assert_eq!(body.len(), 11); + assert_eq!(body.materialize_lossy_string()?, "hello world"); + #[cfg(unix)] + if let CapturedBodyInner::File { path, .. } = body.inner.as_ref() { + assert_eq!(fs::metadata(path)?.permissions().mode() & 0o777, 0o600); + assert_eq!( + fs::metadata(path.parent().expect("spool path should have parent"))? + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + Ok(()) + } + + #[test] + fn captured_body_from_bytes_spooled_reads_ranges_without_materializing_all() -> Result<()> { + let body = CapturedBody::from_bytes_spooled(vec![b'a'; DEFAULT_MEMORY_LIMIT + 8]); + + assert_eq!(body.len(), DEFAULT_MEMORY_LIMIT + 8); + assert_eq!(body.read_range(DEFAULT_MEMORY_LIMIT - 2, 6)?, vec![b'a'; 6]); + assert!(body.read_range(body.len(), 16)?.is_empty()); + #[cfg(unix)] + if let CapturedBodyInner::File { path, .. } = body.inner.as_ref() { + assert_eq!(fs::metadata(path)?.permissions().mode() & 0o777, 0o600); + } + Ok(()) + } + + #[test] + fn captured_body_materialization_limit_rejects_large_body_before_read() -> Result<()> { + let body = CapturedBody::from_bytes(b"hello".to_vec()); + assert_eq!(body.materialize_bytes_limited(5)?, b"hello"); + let error = body.materialize_bytes_limited(4).unwrap_err().to_string(); + assert_eq!( + error, + "response body is 5 bytes, exceeds CDP materialization limit of 4 bytes" + ); + Ok(()) + } + + #[test] + fn captured_body_memory_chunk_reader_reads_in_chunks() -> Result<()> { + let body = CapturedBody::from_bytes(b"hello world".to_vec()); + + let mut reader = body.chunk_reader(4)?; + let mut chunks = Vec::new(); + while let Some(chunk) = reader.next_chunk()? { + chunks.push(chunk); + } + + assert_eq!( + chunks, + vec![b"hell".to_vec(), b"o wo".to_vec(), b"rld".to_vec()] + ); + assert_eq!(body.materialize_bytes()?, b"hello world"); + Ok(()) + } + + #[test] + fn captured_body_shared_memory_reads_without_copying_the_backing() -> Result<()> { + let backing: Arc<[u8]> = Arc::from(&b"hello world"[..]); + let body = CapturedBody::from_shared_bytes(backing.clone()); + + let CapturedBodyInner::SharedMemory(stored) = body.inner.as_ref() else { + panic!("shared bytes should retain the shared-memory body kind"); + }; + assert!(Arc::ptr_eq(stored, &backing)); + assert_eq!(body.read_range(6, 5)?, b"world"); + assert_eq!(body.materialize_bytes()?, b"hello world"); + Ok(()) + } + + #[test] + fn captured_body_file_spool_chunk_reader_reads_in_chunks() -> Result<()> { + let mut writer = CapturedBodyWriter::new(4); + writer.append(b"hello world")?; + let body = writer.finish()?; + + let mut reader = body.chunk_reader(5)?; + let mut chunks = Vec::new(); + while let Some(chunk) = reader.next_chunk()? { + chunks.push(chunk); + } + + assert_eq!( + chunks, + vec![b"hello".to_vec(), b" worl".to_vec(), b"d".to_vec()] + ); + Ok(()) + } + + #[test] + fn captured_body_from_subresource_response_body_reads_shared_source() -> Result<()> { + let subresource_body = crate::page::SubresourceResponseBody::from_text_and_bytes( + "hello world".to_owned(), + b"hello world".to_vec(), + ); + let body = CapturedBody::from_subresource_response_body(&subresource_body); + + assert!(matches!( + body.inner.as_ref(), + CapturedBodyInner::Subresource(_) + )); + assert_eq!(body.len(), 11); + assert_eq!(body.materialize_bytes()?, b"hello world"); + assert_eq!(body.read_range(6, 5)?, b"world"); + + let mut reader = body.chunk_reader(5)?; + let mut chunks = Vec::new(); + while let Some(chunk) = reader.next_chunk()? { + chunks.push(chunk); + } + + assert_eq!( + chunks, + vec![b"hello".to_vec(), b" worl".to_vec(), b"d".to_vec()] + ); + Ok(()) + } +} diff --git a/moli-core/src/browser_host/owner_input.rs b/moli-core/src/browser_host/owner_input.rs new file mode 100644 index 0000000000..2a06b036cb --- /dev/null +++ b/moli-core/src/browser_host/owner_input.rs @@ -0,0 +1,1295 @@ +use std::num::NonZeroU64; + +use crate::page::RendererDocumentSourcedTopLevelLocationNavigation; + +use super::{ + BrowserAuxiliaryNavigationInput, BrowserAuxiliaryNavigationKind, BrowserContextHandle, + BrowserHistoryTraversalDestination, BrowserInitialTargetNavigationCommandInput, + BrowserInitialTargetNavigationInput, BrowserNavigationTraceContext, BrowserTargetHandle, + BrowserTargetTerminationRequest, PageResidenceIdentity, +}; + +/// Protocol-neutral input accepted by the Browser Owner execution lane. +/// +/// This value deliberately has no frontend session, command id, domain +/// subscription, output queue or socket state. Frontends and renderer output +/// projectors may publish it, but only the Browser Owner queue may consume it. +#[derive(Debug)] +pub enum BrowserOwnerInput { + FrontendCommand(BrowserFrontendCommand), + RendererIntent(RendererBrowserIntent), + InitialTargetNavigation(BrowserInitialTargetNavigationInput), + PageTermination(BrowserPageTerminationInput), + TargetTermination(BrowserTargetTerminationInput), +} + +impl BrowserOwnerInput { + pub fn frontend_navigate( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + url: String, + referrer: Option, + ) -> Self { + Self::FrontendCommand(BrowserFrontendCommand::Navigate( + BrowserNavigateCommandInput::new(command_id, page_owner, url, referrer), + )) + } + + pub fn frontend_reload( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + ignore_cache: bool, + script_to_evaluate_on_load: Option, + ) -> Self { + Self::FrontendCommand(BrowserFrontendCommand::Reload( + BrowserReloadCommandInput::new( + command_id, + page_owner, + ignore_cache, + script_to_evaluate_on_load, + ), + )) + } + + pub fn frontend_history_traversal_to_entry( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + entry_id: i32, + ) -> Self { + Self::frontend_history_traversal( + command_id, + page_owner, + BrowserHistoryTraversalDestination::Entry(entry_id), + ) + } + + pub fn frontend_history_traversal_by_delta( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + delta: i64, + ) -> Self { + Self::frontend_history_traversal( + command_id, + page_owner, + BrowserHistoryTraversalDestination::Delta(delta), + ) + } + + pub fn frontend_history_traversal( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + destination: BrowserHistoryTraversalDestination, + ) -> Self { + Self::FrontendCommand(BrowserFrontendCommand::TraverseHistory( + BrowserHistoryTraversalCommandInput::new(command_id, page_owner, destination), + )) + } + + /// Admits one frontend request to stop the currently loading top-level + /// Document in an existing browsing-context slot. + /// + /// Unlike a navigation completion, stop-loading is slot-scoped: Browser + /// Owner resolves the current Document only after this command wins its + /// turn. The captured slot instance still rejects a removed/recreated + /// Target that reuses the same public id. + pub fn frontend_stop_loading( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + ) -> Self { + Self::FrontendCommand(BrowserFrontendCommand::StopLoading( + BrowserStopLoadingCommandInput::new(command_id, page_owner), + )) + } + + /// Admits a frontend dependency on replacement of one exact Target's + /// initial empty Document. + /// + /// The opaque command id recovers only the frontend continuation. Browser + /// Owner selects and revalidates the exact Page and immutable creation URL + /// before any navigation participant starts. + pub fn frontend_ensure_initial_target_navigation( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + url: String, + ) -> Self { + Self::FrontendCommand(BrowserFrontendCommand::EnsureInitialTargetNavigation( + BrowserInitialTargetNavigationCommandInput::new(command_id, page_owner, url), + )) + } + + /// Admits disposal of one exact BrowserContext instance. + /// + /// The public context id remains reusable after disposal, so queued work + /// must carry the Core-issued instance capability. Frontend response + /// correlation and protocol event routing remain outside Browser Owner. + pub fn frontend_dispose_browser_context( + command_id: BrowserCommandId, + browser_context_handle: BrowserContextHandle, + ) -> Self { + Self::FrontendCommand(BrowserFrontendCommand::DisposeBrowserContext( + BrowserContextDisposalCommandInput::new(command_id, browser_context_handle), + )) + } + + /// Admits one frontend decision for an already-paused top-level + /// navigation. + /// + /// The paused request payload and frontend response route remain outside + /// Core. Browser Owner receives only the exact Page capability and the + /// browser-level decision that must be serialized with replacement and + /// termination work for that Page. + pub fn frontend_paused_navigation_decision( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + decision: BrowserPausedNavigationDecision, + ) -> Self { + Self::FrontendCommand(BrowserFrontendCommand::ResolvePausedNavigation( + BrowserPausedNavigationDecisionInput::new(command_id, page_owner, decision), + )) + } + + pub fn renderer_top_level_location_navigation( + page_owner: PageResidenceIdentity, + navigation: RendererDocumentSourcedTopLevelLocationNavigation, + trace: Option, + ) -> Self { + Self::RendererIntent(RendererBrowserIntent::TopLevelLocationNavigation( + RendererTopLevelLocationNavigationInput::new(page_owner, navigation, trace), + )) + } + + /// Admits one renderer-requested joint session-history traversal. + /// + /// The renderer contributes only its relative delta. Browser Owner + /// resolves that delta against the authoritative history cursor after the + /// exact Page wins its mailbox turn; no frontend route participates. + pub fn renderer_top_level_history_traversal( + page_owner: PageResidenceIdentity, + delta: i64, + ) -> Self { + Self::RendererIntent(RendererBrowserIntent::TopLevelHistoryTraversal( + RendererTopLevelHistoryTraversalInput::new(page_owner, delta), + )) + } + + /// Admits one renderer-accepted auxiliary browsing-context navigation. + /// + /// Target creation or named-target resolution has already occurred. Only + /// the exact Page, destination and browser-level navigation kind enter the + /// mailbox; opener/frontend routing remains outside Browser Owner. + pub fn renderer_auxiliary_navigation( + page_owner: PageResidenceIdentity, + url: String, + kind: BrowserAuxiliaryNavigationKind, + ) -> Self { + Self::RendererIntent(RendererBrowserIntent::AuxiliaryNavigation( + BrowserAuxiliaryNavigationInput::new(page_owner, url, kind), + )) + } + + /// Admits foreground selection for one renderer-created auxiliary Target. + /// + /// The matching auxiliary navigation is published first. Keeping this + /// exact Context/Target capability in the same Browser Host mailbox makes + /// activation observe that navigation's admission order without exposing + /// protocol scheduler or frontend identity to Core. + pub fn renderer_auxiliary_target_activation( + browser_context: BrowserContextHandle, + target: BrowserTargetHandle, + ) -> Self { + Self::RendererIntent(RendererBrowserIntent::AuxiliaryTargetActivation( + BrowserAuxiliaryTargetActivationInput::new(browser_context, target, None), + )) + } + + /// Admits one auxiliary navigation whose exact Target becomes foreground + /// only after that navigation reaches its Browser Host terminal. + pub fn renderer_auxiliary_navigation_and_target_activation( + page_owner: PageResidenceIdentity, + url: String, + kind: BrowserAuxiliaryNavigationKind, + browser_context: BrowserContextHandle, + target: BrowserTargetHandle, + ) -> Self { + Self::RendererIntent(RendererBrowserIntent::AuxiliaryTargetActivation( + BrowserAuxiliaryTargetActivationInput::new( + browser_context, + target, + Some(BrowserAuxiliaryNavigationInput::new(page_owner, url, kind)), + ), + )) + } + + /// Admits replacement of one Target's initial empty Document with its + /// immutable creation URL. + /// + /// Target creation, debugger resume, and Page-domain enable may all expose + /// the same browser transition. They publish this one neutral input; the + /// selected Browser Host turn revalidates exact Page and initial-Document + /// state before it starts any network or renderer participant. + pub fn initial_target_navigation(page_owner: PageResidenceIdentity, url: String) -> Self { + Self::InitialTargetNavigation(BrowserInitialTargetNavigationInput::new(page_owner, url)) + } + + /// Admits one exact Page-originated terminal transition. + /// + /// The request already captures BrowserContext, Target and Page generation + /// authority. Protocol may delay publication until an exact renderer + /// output predecessor has crossed ingress, but no frontend identity or + /// response route enters the Browser Host mailbox. + pub fn page_termination(request: BrowserTargetTerminationRequest) -> Self { + Self::PageTermination(BrowserPageTerminationInput::new(request)) + } + + /// Admits one exact browser Target terminal transition. + /// + /// Unlike `PageTermination`, this action removes the Target topology and + /// may promote a retained background Target after the exact close commits. + /// Frontend response correlation and Target-domain event projection remain + /// outside this protocol-neutral input. + pub fn target_termination(request: BrowserTargetTerminationRequest) -> Self { + Self::TargetTermination(BrowserTargetTerminationInput::new(request)) + } + + pub fn kind(&self) -> BrowserOwnerInputKind { + match self { + Self::FrontendCommand(BrowserFrontendCommand::Navigate(_)) => { + BrowserOwnerInputKind::FrontendNavigate + } + Self::FrontendCommand(BrowserFrontendCommand::Reload(_)) => { + BrowserOwnerInputKind::FrontendReload + } + Self::FrontendCommand(BrowserFrontendCommand::TraverseHistory(_)) => { + BrowserOwnerInputKind::FrontendHistoryTraversal + } + Self::FrontendCommand(BrowserFrontendCommand::StopLoading(_)) => { + BrowserOwnerInputKind::FrontendStopLoading + } + Self::FrontendCommand(BrowserFrontendCommand::DisposeBrowserContext(_)) => { + BrowserOwnerInputKind::FrontendDisposeBrowserContext + } + Self::FrontendCommand(BrowserFrontendCommand::ResolvePausedNavigation(_)) => { + BrowserOwnerInputKind::FrontendPausedNavigationDecision + } + Self::FrontendCommand(BrowserFrontendCommand::EnsureInitialTargetNavigation(_)) => { + BrowserOwnerInputKind::FrontendEnsureInitialTargetNavigation + } + Self::RendererIntent(RendererBrowserIntent::TopLevelLocationNavigation(_)) => { + BrowserOwnerInputKind::RendererTopLevelLocationNavigation + } + Self::RendererIntent(RendererBrowserIntent::TopLevelHistoryTraversal(_)) => { + BrowserOwnerInputKind::RendererTopLevelHistoryTraversal + } + Self::RendererIntent(RendererBrowserIntent::AuxiliaryNavigation(_)) => { + BrowserOwnerInputKind::RendererAuxiliaryNavigation + } + Self::RendererIntent(RendererBrowserIntent::AuxiliaryTargetActivation(_)) => { + BrowserOwnerInputKind::RendererAuxiliaryTargetActivation + } + Self::InitialTargetNavigation(_) => BrowserOwnerInputKind::InitialTargetNavigation, + Self::PageTermination(_) => BrowserOwnerInputKind::PageTermination, + Self::TargetTermination(_) => BrowserOwnerInputKind::TargetTermination, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BrowserOwnerInputKind { + FrontendNavigate, + FrontendReload, + FrontendHistoryTraversal, + FrontendStopLoading, + FrontendDisposeBrowserContext, + FrontendPausedNavigationDecision, + FrontendEnsureInitialTargetNavigation, + RendererTopLevelLocationNavigation, + RendererTopLevelHistoryTraversal, + RendererAuxiliaryNavigation, + RendererAuxiliaryTargetActivation, + InitialTargetNavigation, + PageTermination, + TargetTermination, +} + +/// Exact Page terminal action selected by Browser Host. +/// +/// `BrowserTargetTerminationRequest` is move-only and generation-scoped, so a +/// delayed Page.close/Page.crash cannot be retargeted through a later session +/// lookup or terminate a replacement Document. +#[derive(Debug)] +pub struct BrowserPageTerminationInput { + request: BrowserTargetTerminationRequest, +} + +impl BrowserPageTerminationInput { + fn new(request: BrowserTargetTerminationRequest) -> Self { + Self { request } + } + + pub fn request(&self) -> &BrowserTargetTerminationRequest { + &self.request + } + + pub fn into_request(self) -> BrowserTargetTerminationRequest { + self.request + } +} + +/// Exact top-level Target close selected by Browser Host. +/// +/// The request freezes BrowserContext, Target instance and Page generation. +/// A delayed `Target.closeTarget` therefore cannot be redirected through a +/// later frontend attachment or close a replacement Target with the same id. +#[derive(Debug)] +pub struct BrowserTargetTerminationInput { + request: BrowserTargetTerminationRequest, +} + +impl BrowserTargetTerminationInput { + fn new(request: BrowserTargetTerminationRequest) -> Self { + Self { request } + } + + pub fn request(&self) -> &BrowserTargetTerminationRequest { + &self.request + } + + pub fn into_request(self) -> BrowserTargetTerminationRequest { + self.request + } +} + +/// Opaque correlation identity for one protocol-neutral Browser command. +/// +/// The identity does not encode a CDP request id, frontend session or output +/// route. During migration the physical executor uses it only to recover the +/// frontend projection that was prepared before the command entered the +/// Browser Host mailbox. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct BrowserCommandId(NonZeroU64); + +impl BrowserCommandId { + pub fn new(value: NonZeroU64) -> Self { + Self(value) + } + + pub fn get(self) -> u64 { + self.0.get() + } +} + +/// Browser command admitted from any frontend. +/// +/// Frontend correlation and response projection deliberately remain outside +/// this value. The Browser Owner receives only the action and its exact Page +/// authority. +#[derive(Debug)] +pub enum BrowserFrontendCommand { + Navigate(BrowserNavigateCommandInput), + Reload(BrowserReloadCommandInput), + TraverseHistory(BrowserHistoryTraversalCommandInput), + StopLoading(BrowserStopLoadingCommandInput), + DisposeBrowserContext(BrowserContextDisposalCommandInput), + ResolvePausedNavigation(BrowserPausedNavigationDecisionInput), + EnsureInitialTargetNavigation(BrowserInitialTargetNavigationCommandInput), +} + +/// Exact BrowserContext-scoped disposal command. +/// +/// A frontend-visible id alone is insufficient because it may be reused +/// while this command waits behind earlier Browser Owner work. The handle is +/// move-owned by the selected turn and cannot authorize a later Context with +/// the same id. +#[derive(Debug)] +pub struct BrowserContextDisposalCommandInput { + command_id: BrowserCommandId, + browser_context_handle: BrowserContextHandle, +} + +impl BrowserContextDisposalCommandInput { + fn new(command_id: BrowserCommandId, browser_context_handle: BrowserContextHandle) -> Self { + Self { + command_id, + browser_context_handle, + } + } + + pub fn command_id(&self) -> BrowserCommandId { + self.command_id + } + + pub fn browser_context_handle(&self) -> &BrowserContextHandle { + &self.browser_context_handle + } + + pub fn into_parts(self) -> (BrowserCommandId, BrowserContextHandle) { + (self.command_id, self.browser_context_handle) + } +} + +/// Exact Page-scoped top-level navigation command. +/// +/// The Browser Owner classifies the command as same-Document or +/// cross-Document only after selecting and validating this exact Page. +#[derive(Debug)] +pub struct BrowserNavigateCommandInput { + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + url: String, + referrer: Option, +} + +impl BrowserNavigateCommandInput { + fn new( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + url: String, + referrer: Option, + ) -> Self { + Self { + command_id, + page_owner, + url, + referrer, + } + } + + pub fn command_id(&self) -> BrowserCommandId { + self.command_id + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn referrer(&self) -> Option<&str> { + self.referrer.as_deref() + } + + pub fn into_parts( + self, + ) -> ( + BrowserCommandId, + PageResidenceIdentity, + String, + Option, + ) { + (self.command_id, self.page_owner, self.url, self.referrer) + } +} + +/// Exact Page-scoped top-level reload command. +/// +/// The current URL is deliberately absent. Browser Owner resolves it only +/// after selecting and validating `page_owner`, so a queued reload cannot be +/// retargeted to a replacement Page. +#[derive(Debug)] +pub struct BrowserReloadCommandInput { + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + ignore_cache: bool, + script_to_evaluate_on_load: Option, +} + +impl BrowserReloadCommandInput { + fn new( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + ignore_cache: bool, + script_to_evaluate_on_load: Option, + ) -> Self { + Self { + command_id, + page_owner, + ignore_cache, + script_to_evaluate_on_load, + } + } + + pub fn command_id(&self) -> BrowserCommandId { + self.command_id + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn ignore_cache(&self) -> bool { + self.ignore_cache + } + + pub fn script_to_evaluate_on_load(&self) -> Option<&str> { + self.script_to_evaluate_on_load.as_deref() + } + + pub fn into_parts( + self, + ) -> ( + BrowserCommandId, + PageResidenceIdentity, + bool, + Option, + ) { + ( + self.command_id, + self.page_owner, + self.ignore_cache, + self.script_to_evaluate_on_load, + ) + } +} + +/// Exact Page-scoped joint session-history traversal command. +/// +/// Only an entry identity or relative delta crosses the frontend boundary. +/// Destination URL lookup, cursor-relative delta resolution and +/// same/cross-Document classification happen after Browser Owner selects and +/// validates `page_owner`. +#[derive(Debug)] +pub struct BrowserHistoryTraversalCommandInput { + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + destination: BrowserHistoryTraversalDestination, +} + +impl BrowserHistoryTraversalCommandInput { + fn new( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + destination: BrowserHistoryTraversalDestination, + ) -> Self { + Self { + command_id, + page_owner, + destination, + } + } + + pub fn command_id(&self) -> BrowserCommandId { + self.command_id + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn destination(&self) -> BrowserHistoryTraversalDestination { + self.destination + } + + pub fn into_parts( + self, + ) -> ( + BrowserCommandId, + PageResidenceIdentity, + BrowserHistoryTraversalDestination, + ) { + (self.command_id, self.page_owner, self.destination) + } +} + +/// Target/Page-slot-scoped request to stop the active top-level load. +/// +/// The instance capability in `page_owner` identifies the slot captured by the +/// frontend. Its generation records the Document current at admission but does +/// not freeze that Document: Browser Owner deliberately resolves the slot's +/// current generation when the command is selected. +#[derive(Debug)] +pub struct BrowserStopLoadingCommandInput { + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, +} + +impl BrowserStopLoadingCommandInput { + fn new(command_id: BrowserCommandId, page_owner: PageResidenceIdentity) -> Self { + Self { + command_id, + page_owner, + } + } + + pub fn command_id(&self) -> BrowserCommandId { + self.command_id + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn into_parts(self) -> (BrowserCommandId, PageResidenceIdentity) { + (self.command_id, self.page_owner) + } +} + +/// Protocol-neutral resolution of one paused top-level navigation. +/// +/// This enum is deliberately about browser behavior, not Fetch/CDP command +/// spelling. Later frontends can publish the same decision without entering +/// Browser Owner's execution state. +#[derive(Debug)] +pub enum BrowserPausedNavigationDecision { + Fail { error_text: String }, + Continue(BrowserPausedNavigationContinueDecision), + ContinueResponse(BrowserPausedNavigationResponseDecision), + Fulfill(BrowserPausedNavigationFulfillDecision), + Auth(BrowserPausedNavigationAuthDecision), +} + +impl BrowserPausedNavigationDecision { + pub fn fail(error_text: String) -> Self { + Self::Fail { error_text } + } + + pub fn continue_request( + url: Option, + method: Option, + post_data: Option, + headers: Option>, + intercept_response: bool, + ) -> Self { + Self::Continue(BrowserPausedNavigationContinueDecision { + url, + method, + post_data, + headers, + intercept_response, + }) + } + + pub fn continue_response( + response_code: Option, + response_headers: Vec<(String, String)>, + ) -> Self { + Self::ContinueResponse(BrowserPausedNavigationResponseDecision { + response_code, + response_headers, + }) + } + + pub fn fulfill( + response_code: u16, + response_headers: Vec<(String, String)>, + response_body: Option>, + ) -> Self { + Self::Fulfill(BrowserPausedNavigationFulfillDecision { + response_code, + response_headers, + response_body, + }) + } + + pub fn fail_auth() -> Self { + Self::Auth(BrowserPausedNavigationAuthDecision::Fail) + } + + pub fn cancel_auth() -> Self { + Self::Auth(BrowserPausedNavigationAuthDecision::Cancel) + } + + pub fn continue_auth(credentials: crate::page::SubresourceAuthCredentials) -> Self { + Self::Auth(BrowserPausedNavigationAuthDecision::Continue(credentials)) + } +} + +/// Browser-level response overrides supplied while releasing a paused +/// top-level response transfer. +/// +/// Fetch request ids, protocol response phrases and frontend routing remain +/// outside Core. An absent status and empty header list preserve the received +/// response head. +#[derive(Debug)] +pub struct BrowserPausedNavigationResponseDecision { + response_code: Option, + response_headers: Vec<(String, String)>, +} + +impl BrowserPausedNavigationResponseDecision { + pub fn into_parts(self) -> (Option, Vec<(String, String)>) { + (self.response_code, self.response_headers) + } +} + +/// Browser-level synthetic response supplied while resolving a paused +/// top-level navigation. +/// +/// Fetch request ids, response phrases and frontend routing remain outside +/// Core. The optional body preserves the protocol distinction between an +/// omitted body and explicitly supplied bytes until the physical executor +/// starts the synthetic Document build. +#[derive(Debug)] +pub struct BrowserPausedNavigationFulfillDecision { + response_code: u16, + response_headers: Vec<(String, String)>, + response_body: Option>, +} + +impl BrowserPausedNavigationFulfillDecision { + pub fn into_parts(self) -> (u16, Vec<(String, String)>, Option>) { + ( + self.response_code, + self.response_headers, + self.response_body, + ) + } +} + +/// Browser-level resolution of an HTTP authentication challenge that paused +/// one top-level navigation. +/// +/// `Fail` aborts the navigation, `Cancel` exposes the challenged response, and +/// `Continue` retries with already-resolved browser credentials. Protocol +/// request ids, session routing and wire action names stay outside Core. +#[derive(Debug)] +pub enum BrowserPausedNavigationAuthDecision { + Fail, + Cancel, + Continue(crate::page::SubresourceAuthCredentials), +} + +/// Browser-level request overrides supplied while resuming a paused +/// top-level navigation. +/// +/// `Option` preserves the difference between "leave the original value" and +/// an explicit empty body/header set. URL syntax is validated by the frontend +/// before Browser Owner admission; the owner receives a parsed URL rather than +/// protocol text. +#[derive(Debug)] +pub struct BrowserPausedNavigationContinueDecision { + url: Option, + method: Option, + post_data: Option, + headers: Option>, + intercept_response: bool, +} + +impl BrowserPausedNavigationContinueDecision { + pub fn into_parts( + self, + ) -> ( + Option, + Option, + Option, + Option>, + bool, + ) { + ( + self.url, + self.method, + self.post_data, + self.headers, + self.intercept_response, + ) + } +} + +/// Exact Page-scoped input for resolving a paused top-level navigation. +#[derive(Debug)] +pub struct BrowserPausedNavigationDecisionInput { + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + decision: BrowserPausedNavigationDecision, +} + +impl BrowserPausedNavigationDecisionInput { + fn new( + command_id: BrowserCommandId, + page_owner: PageResidenceIdentity, + decision: BrowserPausedNavigationDecision, + ) -> Self { + Self { + command_id, + page_owner, + decision, + } + } + + pub fn command_id(&self) -> BrowserCommandId { + self.command_id + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn decision(&self) -> &BrowserPausedNavigationDecision { + &self.decision + } + + pub fn into_parts( + self, + ) -> ( + BrowserCommandId, + PageResidenceIdentity, + BrowserPausedNavigationDecision, + ) { + (self.command_id, self.page_owner, self.decision) + } +} + +/// Immutable browser action produced by one renderer turn. +#[derive(Debug)] +pub enum RendererBrowserIntent { + TopLevelLocationNavigation(RendererTopLevelLocationNavigationInput), + TopLevelHistoryTraversal(RendererTopLevelHistoryTraversalInput), + AuxiliaryNavigation(BrowserAuxiliaryNavigationInput), + AuxiliaryTargetActivation(BrowserAuxiliaryTargetActivationInput), +} + +/// Exact auxiliary Target selection produced by a renderer turn. +/// +/// A public Target id is insufficient because the Context or Target may be +/// removed and recreated while this input waits. Browser Host and its physical +/// projection both revalidate these Core-issued capabilities before applying +/// foreground selection. +#[derive(Debug)] +pub struct BrowserAuxiliaryTargetActivationInput { + browser_context: BrowserContextHandle, + target: BrowserTargetHandle, + navigation: Option, +} + +impl BrowserAuxiliaryTargetActivationInput { + fn new( + browser_context: BrowserContextHandle, + target: BrowserTargetHandle, + navigation: Option, + ) -> Self { + Self { + browser_context, + target, + navigation, + } + } + + pub fn browser_context(&self) -> &BrowserContextHandle { + &self.browser_context + } + + pub fn target(&self) -> &BrowserTargetHandle { + &self.target + } + + pub fn navigation(&self) -> Option<&BrowserAuxiliaryNavigationInput> { + self.navigation.as_ref() + } + + pub fn into_parts( + self, + ) -> ( + BrowserContextHandle, + BrowserTargetHandle, + Option, + ) { + (self.browser_context, self.target, self.navigation) + } +} + +/// Exact Page-scoped top-level location navigation submitted to Browser Owner. +/// +/// `source_document` remains part of `navigation` as causal metadata. Browser +/// execution authorization uses the exact Page residence, so a same-Page +/// `document.open()` does not discard an already-published action while a Page +/// replacement makes it stale. +#[derive(Debug)] +pub struct RendererTopLevelLocationNavigationInput { + page_owner: PageResidenceIdentity, + navigation: RendererDocumentSourcedTopLevelLocationNavigation, + trace: Option, +} + +impl RendererTopLevelLocationNavigationInput { + fn new( + page_owner: PageResidenceIdentity, + navigation: RendererDocumentSourcedTopLevelLocationNavigation, + trace: Option, + ) -> Self { + Self { + page_owner, + navigation, + trace, + } + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn navigation(&self) -> &RendererDocumentSourcedTopLevelLocationNavigation { + &self.navigation + } + + pub fn into_parts( + self, + ) -> ( + PageResidenceIdentity, + RendererDocumentSourcedTopLevelLocationNavigation, + Option, + ) { + (self.page_owner, self.navigation, self.trace) + } +} + +/// Exact Page-scoped joint session-history traversal produced by a renderer +/// turn. +/// +/// A Page replacement makes the intent stale. The target URL and history +/// entry are deliberately absent: Browser Owner resolves them from its +/// current cursor only after selecting this input. +#[derive(Debug)] +pub struct RendererTopLevelHistoryTraversalInput { + page_owner: PageResidenceIdentity, + delta: i64, +} + +impl RendererTopLevelHistoryTraversalInput { + fn new(page_owner: PageResidenceIdentity, delta: i64) -> Self { + Self { page_owner, delta } + } + + pub fn page_owner(&self) -> &PageResidenceIdentity { + &self.page_owner + } + + pub fn delta(&self) -> i64 { + self.delta + } + + pub fn into_parts(self) -> (PageResidenceIdentity, i64) { + (self.page_owner, self.delta) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frontend_reload_keeps_exact_page_and_neutral_options() { + let command_id = BrowserCommandId::new(NonZeroU64::new(7).expect("non-zero command id")); + let input = BrowserOwnerInput::frontend_reload( + command_id, + PageResidenceIdentity::new( + "context-reload".to_owned(), + Some("target-reload".to_owned()), + 11, + ), + true, + Some("globalThis.reloaded = true".to_owned()), + ); + + assert_eq!(input.kind(), BrowserOwnerInputKind::FrontendReload); + let BrowserOwnerInput::FrontendCommand(BrowserFrontendCommand::Reload(input)) = input + else { + panic!("expected frontend reload input"); + }; + assert_eq!(input.command_id(), command_id); + assert_eq!(input.page_owner().target_id(), Some("target-reload")); + assert_eq!(input.page_owner().loaded_page_generation(), 11); + assert!(input.ignore_cache()); + assert_eq!( + input.script_to_evaluate_on_load(), + Some("globalThis.reloaded = true") + ); + } + + #[test] + fn frontend_history_traversal_keeps_exact_page_and_browser_destination() { + let command_id = BrowserCommandId::new(NonZeroU64::new(8).expect("non-zero command id")); + let input = BrowserOwnerInput::frontend_history_traversal_to_entry( + command_id, + PageResidenceIdentity::new( + "context-history".to_owned(), + Some("target-history".to_owned()), + 12, + ), + 37, + ); + + assert_eq!( + input.kind(), + BrowserOwnerInputKind::FrontendHistoryTraversal + ); + let BrowserOwnerInput::FrontendCommand(BrowserFrontendCommand::TraverseHistory(input)) = + input + else { + panic!("expected frontend history traversal input"); + }; + assert_eq!(input.command_id(), command_id); + assert_eq!(input.page_owner().target_id(), Some("target-history")); + assert_eq!(input.page_owner().loaded_page_generation(), 12); + assert_eq!( + input.destination(), + BrowserHistoryTraversalDestination::Entry(37) + ); + + let delta_input = BrowserOwnerInput::frontend_history_traversal_by_delta( + command_id, + PageResidenceIdentity::new( + "context-history".to_owned(), + Some("target-history".to_owned()), + 12, + ), + -2, + ); + let BrowserOwnerInput::FrontendCommand(BrowserFrontendCommand::TraverseHistory(input)) = + delta_input + else { + panic!("expected delta history traversal input"); + }; + assert_eq!( + input.destination(), + BrowserHistoryTraversalDestination::Delta(-2) + ); + } + + #[test] + fn frontend_stop_loading_keeps_slot_authority_without_frontend_identity() { + let command_id = BrowserCommandId::new(NonZeroU64::new(9).expect("non-zero command id")); + let input = BrowserOwnerInput::frontend_stop_loading( + command_id, + PageResidenceIdentity::new( + "context-stop".to_owned(), + Some("target-stop".to_owned()), + 13, + ), + ); + + assert_eq!(input.kind(), BrowserOwnerInputKind::FrontendStopLoading); + let BrowserOwnerInput::FrontendCommand(BrowserFrontendCommand::StopLoading(input)) = input + else { + panic!("expected frontend stop-loading input"); + }; + assert_eq!(input.command_id(), command_id); + assert_eq!(input.page_owner().browser_context_id(), "context-stop"); + assert_eq!(input.page_owner().target_id(), Some("target-stop")); + assert_eq!(input.page_owner().loaded_page_generation(), 13); + } + + #[test] + fn frontend_context_disposal_keeps_exact_context_without_frontend_identity() { + let command_id = BrowserCommandId::new(NonZeroU64::new(15).expect("non-zero command id")); + let handle = BrowserContextHandle::staged("context-dispose"); + let input = BrowserOwnerInput::frontend_dispose_browser_context(command_id, handle.clone()); + + assert_eq!( + input.kind(), + BrowserOwnerInputKind::FrontendDisposeBrowserContext + ); + let BrowserOwnerInput::FrontendCommand(BrowserFrontendCommand::DisposeBrowserContext( + input, + )) = input + else { + panic!("expected frontend BrowserContext disposal input"); + }; + assert_eq!(input.command_id(), command_id); + assert_eq!(input.browser_context_handle(), &handle); + let (_, actual_handle) = input.into_parts(); + assert_eq!(actual_handle, handle); + } + + #[test] + fn paused_navigation_decision_keeps_exact_page_and_neutral_failure() { + let command_id = BrowserCommandId::new(NonZeroU64::new(10).expect("non-zero command id")); + let input = BrowserOwnerInput::frontend_paused_navigation_decision( + command_id, + PageResidenceIdentity::new( + "context-fetch".to_owned(), + Some("target-fetch".to_owned()), + 14, + ), + BrowserPausedNavigationDecision::fail("net::ERR_ABORTED".to_owned()), + ); + + assert_eq!( + input.kind(), + BrowserOwnerInputKind::FrontendPausedNavigationDecision + ); + let BrowserOwnerInput::FrontendCommand(BrowserFrontendCommand::ResolvePausedNavigation( + input, + )) = input + else { + panic!("expected paused-navigation decision input"); + }; + assert_eq!(input.command_id(), command_id); + assert_eq!(input.page_owner().browser_context_id(), "context-fetch"); + assert_eq!(input.page_owner().target_id(), Some("target-fetch")); + assert_eq!(input.page_owner().loaded_page_generation(), 14); + assert!(matches!( + input.decision(), + BrowserPausedNavigationDecision::Fail { error_text } + if error_text == "net::ERR_ABORTED" + )); + } + + #[test] + fn paused_navigation_continue_keeps_browser_request_overrides() { + let decision = BrowserPausedNavigationDecision::continue_request( + Some(url::Url::parse("https://example.test/continued").expect("continued URL")), + Some("POST".to_owned()), + Some("payload".to_owned()), + Some(vec![("X-Test".to_owned(), "owner".to_owned())]), + true, + ); + let BrowserPausedNavigationDecision::Continue(decision) = decision else { + panic!("expected continue decision"); + }; + let (url, method, post_data, headers, intercept_response) = decision.into_parts(); + assert_eq!( + url.as_ref().map(url::Url::as_str), + Some("https://example.test/continued") + ); + assert_eq!(method.as_deref(), Some("POST")); + assert_eq!(post_data.as_deref(), Some("payload")); + assert_eq!( + headers, + Some(vec![("X-Test".to_owned(), "owner".to_owned())]) + ); + assert!(intercept_response); + } + + #[test] + fn paused_navigation_fulfill_keeps_protocol_neutral_synthetic_response() { + let decision = BrowserPausedNavigationDecision::fulfill( + 201, + vec![("content-type".to_owned(), "text/plain".to_owned())], + Some(vec![0, 0xff, b'a']), + ); + let BrowserPausedNavigationDecision::Fulfill(decision) = decision else { + panic!("expected fulfill decision"); + }; + let (status, headers, body) = decision.into_parts(); + assert_eq!(status, 201); + assert_eq!( + headers, + vec![("content-type".to_owned(), "text/plain".to_owned())] + ); + assert_eq!(body, Some(vec![0, 0xff, b'a'])); + } + + #[test] + fn paused_navigation_auth_decision_keeps_browser_credentials() { + let credentials = crate::page::SubresourceAuthCredentials { + target: crate::page::SubresourceAuthTarget::Server, + scheme: crate::page::SubresourceAuthScheme::Basic, + username: "owner".to_owned(), + password: "secret".to_owned(), + }; + let decision = BrowserPausedNavigationDecision::continue_auth(credentials.clone()); + let BrowserPausedNavigationDecision::Auth(BrowserPausedNavigationAuthDecision::Continue( + actual, + )) = decision + else { + panic!("expected auth continue decision"); + }; + assert_eq!(actual, credentials); + } + + #[test] + fn auxiliary_navigation_keeps_exact_page_and_browser_kind() { + let input = BrowserOwnerInput::renderer_auxiliary_navigation( + PageResidenceIdentity::new( + "context-popup".to_owned(), + Some("target-popup".to_owned()), + 13, + ), + "https://example.test/popup".to_owned(), + BrowserAuxiliaryNavigationKind::InitialDocument, + ); + + assert_eq!( + input.kind(), + BrowserOwnerInputKind::RendererAuxiliaryNavigation + ); + let BrowserOwnerInput::RendererIntent(RendererBrowserIntent::AuxiliaryNavigation(input)) = + input + else { + panic!("expected auxiliary navigation input"); + }; + assert_eq!(input.page_owner().target_id(), Some("target-popup")); + assert_eq!(input.page_owner().loaded_page_generation(), 13); + assert_eq!(input.url(), "https://example.test/popup"); + assert_eq!( + input.kind(), + BrowserAuxiliaryNavigationKind::InitialDocument + ); + } + + #[test] + fn initial_target_navigation_contains_no_frontend_identity() { + let input = BrowserOwnerInput::initial_target_navigation( + PageResidenceIdentity::new( + "context-created".to_owned(), + Some("target-created".to_owned()), + 7, + ), + "https://example.test/created".to_owned(), + ); + + assert_eq!(input.kind(), BrowserOwnerInputKind::InitialTargetNavigation); + let BrowserOwnerInput::InitialTargetNavigation(input) = input else { + panic!("expected initial Target navigation input"); + }; + assert_eq!(input.page_owner().target_id(), Some("target-created")); + assert_eq!(input.page_owner().loaded_page_generation(), 7); + assert_eq!(input.url(), "https://example.test/created"); + } + + #[test] + fn frontend_initial_target_prerequisite_keeps_only_opaque_correlation_and_exact_page() { + let command_id = BrowserCommandId::new(NonZeroU64::new(19).expect("non-zero command id")); + let input = BrowserOwnerInput::frontend_ensure_initial_target_navigation( + command_id, + PageResidenceIdentity::new( + "context-created".to_owned(), + Some("target-created".to_owned()), + 9, + ), + "https://example.test/created".to_owned(), + ); + + assert_eq!( + input.kind(), + BrowserOwnerInputKind::FrontendEnsureInitialTargetNavigation + ); + let BrowserOwnerInput::FrontendCommand( + BrowserFrontendCommand::EnsureInitialTargetNavigation(input), + ) = input + else { + panic!("expected frontend initial Target prerequisite"); + }; + assert_eq!(input.command_id(), command_id); + assert_eq!(input.page_owner().target_id(), Some("target-created")); + assert_eq!(input.page_owner().loaded_page_generation(), 9); + assert_eq!(input.url(), "https://example.test/created"); + } + + #[test] + fn renderer_history_traversal_contains_only_exact_page_and_delta() { + let input = BrowserOwnerInput::renderer_top_level_history_traversal( + PageResidenceIdentity::new( + "context-history".to_owned(), + Some("target-history".to_owned()), + 13, + ), + -2, + ); + + assert_eq!( + input.kind(), + BrowserOwnerInputKind::RendererTopLevelHistoryTraversal + ); + let BrowserOwnerInput::RendererIntent(RendererBrowserIntent::TopLevelHistoryTraversal( + input, + )) = input + else { + panic!("expected renderer history traversal input"); + }; + assert_eq!(input.page_owner().target_id(), Some("target-history")); + assert_eq!(input.page_owner().loaded_page_generation(), 13); + assert_eq!(input.delta(), -2); + } +} diff --git a/moli-core/src/browser_host/page_payload.rs b/moli-core/src/browser_host/page_payload.rs new file mode 100644 index 0000000000..1bdb0fdbb6 --- /dev/null +++ b/moli-core/src/browser_host/page_payload.rs @@ -0,0 +1,248 @@ +use std::{ + cell::{Cell, RefCell}, + fmt, + ops::{Deref, DerefMut}, + rc::Rc, +}; + +use crate::page::{Page, RendererPageLifetimeOwner}; +use crate::{PageId, RendererOwnerLocalHostId}; + +struct BrowserPageRuntimeState { + live: Cell, + page_id: u64, + renderer_page_id: PageId, + renderer_owner_local_host_id: RendererOwnerLocalHostId, + page: RefCell>, +} + +/// Unique Browser Host authority for one mutable physical `Page` payload. +/// +/// The paired access capability may be cloned into protocol projections, but +/// only this owner can keep the payload live. Retiring the owner removes the +/// `Page` from the shared cell before any stale frontend projection can use it. +pub struct BrowserPageRuntimeOwner { + state: Rc, + owns_live_payload: bool, +} + +/// Cloneable, non-owning command/cache access to a Browser-owned `Page`. +/// +/// This value deliberately keeps only the allocation alive. Once the unique +/// owner retires, `checkout_page` returns `None`; retaining or dropping a +/// frontend projection therefore cannot extend Browser Page residence. A +/// checkout also returns `None` while another lease temporarily owns the +/// payload. Callers must use `is_live` for residence tests and release a lease +/// before a two-phase command checks out the same Page for its finish turn. +#[derive(Clone)] +pub struct BrowserPageRuntimeAccess { + state: Rc, +} + +/// Move-owned access to one exact Browser-owned `Page` payload. +/// +/// Checking out the payload deliberately ends the `RefCell` borrow before the +/// caller receives this value, so a renderer command may retain the lease +/// across an async wait without borrowing Browser Host state. On drop the +/// payload returns only while the same runtime owner remains live. A +/// replacement or Target termination invalidates the owner first, causing a +/// late lease to discard the stale Page instead of restoring it into the new +/// generation. +pub struct BrowserPageRuntimeLease { + state: Rc, + page: Option, +} + +impl BrowserPageRuntimeOwner { + pub fn new(page: Page) -> Self { + let page_id = page.page_id(); + let renderer_page_id = page.renderer_page_id(); + let renderer_owner_local_host_id = page.renderer_owner_local_host_id(); + Self { + state: Rc::new(BrowserPageRuntimeState { + live: Cell::new(true), + page_id, + renderer_page_id, + renderer_owner_local_host_id, + page: RefCell::new(Some(page)), + }), + owns_live_payload: true, + } + } + + pub fn access(&self) -> BrowserPageRuntimeAccess { + BrowserPageRuntimeAccess { + state: Rc::clone(&self.state), + } + } + + pub fn page_id(&self) -> u64 { + self.state.page_id + } + + pub fn take_renderer_lifetime_owner(&mut self) -> Option { + self.state + .page + .borrow_mut() + .as_mut() + .and_then(Page::take_renderer_lifetime_owner) + } + + pub fn try_restore_renderer_lifetime_owner( + &mut self, + owner: RendererPageLifetimeOwner, + ) -> Result<(), RendererPageLifetimeOwner> { + let mut page = self.state.page.borrow_mut(); + let Some(page) = page.as_mut() else { + return Err(owner); + }; + page.try_restore_renderer_lifetime_owner(owner) + } + + /// Returns the still-owned candidate after a rejected Browser transaction. + /// A committed owner is moved into the registry and cannot use this path. + pub fn into_page(mut self) -> Option { + self.state.live.set(false); + let page = self.state.page.borrow_mut().take(); + self.owns_live_payload = false; + page + } + + fn retire(&mut self) { + if !self.owns_live_payload { + return; + } + self.owns_live_payload = false; + self.state.live.set(false); + if let Ok(mut page) = self.state.page.try_borrow_mut() { + let _ = page.take(); + } + } +} + +impl Drop for BrowserPageRuntimeOwner { + fn drop(&mut self) { + self.retire(); + } +} + +impl fmt::Debug for BrowserPageRuntimeOwner { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BrowserPageRuntimeOwner") + .field("live", &self.state.live.get()) + .field("page_id", &self.page_id()) + .finish_non_exhaustive() + } +} + +impl BrowserPageRuntimeAccess { + pub fn is_live(&self) -> bool { + self.state.live.get() + } + + pub fn checkout_page(&self) -> Option { + if !self.state.live.get() { + return None; + } + let page = self.state.page.try_borrow_mut().ok()?.take()?; + Some(BrowserPageRuntimeLease { + state: Rc::clone(&self.state), + page: Some(page), + }) + } + + pub fn page_id(&self) -> u64 { + self.state.page_id + } + + pub fn renderer_page_id(&self) -> PageId { + self.state.renderer_page_id + } + + pub fn renderer_owner_local_host_id(&self) -> RendererOwnerLocalHostId { + self.state.renderer_owner_local_host_id + } + + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn from_page_for_test_fixture(page: Page) -> Self { + let page_id = page.page_id(); + let renderer_page_id = page.renderer_page_id(); + let renderer_owner_local_host_id = page.renderer_owner_local_host_id(); + Self { + state: Rc::new(BrowserPageRuntimeState { + live: Cell::new(true), + page_id, + renderer_page_id, + renderer_owner_local_host_id, + page: RefCell::new(Some(page)), + }), + } + } + + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn retire_and_take_page_for_test_fixture(self) -> Option { + self.state.live.set(false); + self.state.page.borrow_mut().take() + } +} + +impl Deref for BrowserPageRuntimeLease { + type Target = Page; + + fn deref(&self) -> &Self::Target { + self.page + .as_ref() + .expect("a live Browser Page runtime lease must contain its payload") + } +} + +impl DerefMut for BrowserPageRuntimeLease { + fn deref_mut(&mut self) -> &mut Self::Target { + self.page + .as_mut() + .expect("a live Browser Page runtime lease must contain its payload") + } +} + +impl Drop for BrowserPageRuntimeLease { + fn drop(&mut self) { + let Some(page) = self.page.take() else { + return; + }; + if !self.state.live.get() { + return; + } + let Ok(mut resident) = self.state.page.try_borrow_mut() else { + self.state.live.set(false); + return; + }; + if resident.is_some() { + self.state.live.set(false); + return; + } + *resident = Some(page); + } +} + +impl fmt::Debug for BrowserPageRuntimeLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BrowserPageRuntimeLease") + .field("owner_live", &self.state.live.get()) + .field("page_id", &self.page.as_ref().map(Page::page_id)) + .finish_non_exhaustive() + } +} + +impl fmt::Debug for BrowserPageRuntimeAccess { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BrowserPageRuntimeAccess") + .field("live", &self.state.live.get()) + .field("page_id", &self.state.page_id) + .finish_non_exhaustive() + } +} diff --git a/moli-core/src/browser_host/page_residence.rs b/moli-core/src/browser_host/page_residence.rs new file mode 100644 index 0000000000..2c8fb58fa6 --- /dev/null +++ b/moli-core/src/browser_host/page_residence.rs @@ -0,0 +1,315 @@ +use std::{ + fmt, + hash::{Hash, Hasher}, + num::NonZeroU64, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use super::{BrowserContextId, BrowserTargetId}; + +static NEXT_PAGE_RESIDENCE_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +struct PageResidenceInstanceId(NonZeroU64); + +impl PageResidenceInstanceId { + fn allocate() -> Self { + let raw = NEXT_PAGE_RESIDENCE_INSTANCE_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .unwrap_or_else(|_| panic!("browser Page residence instance id exhausted")); + Self( + NonZeroU64::new(raw) + .unwrap_or_else(|| panic!("browser Page residence allocator returned zero")), + ) + } +} + +#[derive(Debug)] +pub(super) struct BrowserPageResidenceState { + instance_id: PageResidenceInstanceId, + generation: AtomicU64, +} + +/// Stable capability for one physical browser Page slot. +/// +/// Moving a target between active and parked storage moves this handle with +/// the slot. Installing or retiring the renderer Page advances its generation, +/// so work captured from the old Page cannot address its replacement. +#[derive(Clone)] +pub struct BrowserPageResidenceHandle { + state: Arc, +} + +impl Default for BrowserPageResidenceHandle { + fn default() -> Self { + Self { + state: Arc::new(BrowserPageResidenceState { + instance_id: PageResidenceInstanceId::allocate(), + generation: AtomicU64::new(0), + }), + } + } +} + +impl fmt::Debug for BrowserPageResidenceHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BrowserPageResidenceHandle") + .field("instance_id", &self.state.instance_id) + .field("generation", &self.generation()) + .finish() + } +} + +impl BrowserPageResidenceHandle { + pub fn generation(&self) -> u64 { + self.state.generation.load(Ordering::Acquire) + } + + pub(super) fn try_advance_generation_if_current( + &self, + expected: &PageResidenceIdentity, + ) -> Result { + self.state.try_advance_generation_if_current(expected) + } + + /// Fixture-only seam for downstream tests that synthesize Page-slot reuse + /// without constructing a complete Browser Owner transaction. + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn advance_generation_for_test_fixture(&self) -> u64 { + self.state.advance_generation() + } + + /// Fixture-only seam for tests that need an exact synthetic generation. + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn set_generation_for_test_fixture(&self, generation: u64) { + self.state.generation.store(generation, Ordering::Release); + } + + pub fn identity( + &self, + browser_context_id: String, + target_id: Option, + ) -> PageResidenceIdentity { + PageResidenceIdentity { + browser_context_id: BrowserContextId::new(browser_context_id), + target_id: target_id.map(BrowserTargetId::new), + instance_id: Some(self.state.instance_id), + loaded_page_generation: self.generation(), + } + } + + /// Returns whether `identity` names this exact physical Page slot at its + /// current generation. + pub fn is_current(&self, identity: &PageResidenceIdentity) -> bool { + self.state.is_current(identity) + } + + pub(super) fn owns_identity_instance(&self, identity: &PageResidenceIdentity) -> bool { + identity.instance_id == Some(self.state.instance_id) + } + + pub(super) fn same_instance(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.state, &other.state) + } +} + +impl PartialEq for BrowserPageResidenceHandle { + fn eq(&self, other: &Self) -> bool { + self.state.instance_id == other.state.instance_id + } +} + +impl Eq for BrowserPageResidenceHandle {} + +impl Hash for BrowserPageResidenceHandle { + fn hash(&self, state: &mut H) { + self.state.instance_id.hash(state); + } +} + +impl BrowserPageResidenceState { + #[cfg(any(test, feature = "test-support"))] + pub(super) fn advance_generation(&self) -> u64 { + let previous = self + .generation + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current.checked_add(1) + }) + .unwrap_or_else(|_| panic!("browser Page residence generation exhausted")); + previous + 1 + } + + fn try_advance_generation_if_current( + &self, + expected: &PageResidenceIdentity, + ) -> Result { + if expected.instance_id != Some(self.instance_id) { + return Err(BrowserPageResidenceAdvanceError::InstanceMismatch); + } + let expected_generation = expected.loaded_page_generation; + let Some(successor_generation) = expected_generation.checked_add(1) else { + return Err(BrowserPageResidenceAdvanceError::GenerationExhausted); + }; + self.generation + .compare_exchange( + expected_generation, + successor_generation, + Ordering::AcqRel, + Ordering::Acquire, + ) + .map(|_| successor_generation) + .map_err( + |current_generation| BrowserPageResidenceAdvanceError::StaleGeneration { + current_generation, + }, + ) + } + + pub(super) fn is_current(&self, identity: &PageResidenceIdentity) -> bool { + identity.instance_id == Some(self.instance_id) + && identity.loaded_page_generation == self.generation.load(Ordering::Acquire) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum BrowserPageResidenceAdvanceError { + InstanceMismatch, + StaleGeneration { current_generation: u64 }, + GenerationExhausted, +} + +/// Identifies one installed renderer Page residence within a browser Target. +/// +/// This identity deliberately excludes frontend/session identity. Attaching, +/// detaching, or replacing a DevTools session cannot change whether a +/// browser-owned action still addresses the same Page. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)] +pub struct PageResidenceIdentity { + browser_context_id: BrowserContextId, + target_id: Option, + instance_id: Option, + loaded_page_generation: u64, +} + +impl PageResidenceIdentity { + /// Creates a synthetic identity for compatibility fixtures. + /// + /// Live browser actions must capture their identity from a + /// `BrowserPageResidenceHandle`; a synthetic identity has no slot instance + /// capability and cannot authorize an owner lookup. + pub fn new( + browser_context_id: String, + target_id: Option, + loaded_page_generation: u64, + ) -> Self { + Self { + browser_context_id: BrowserContextId::new(browser_context_id), + target_id: target_id.map(BrowserTargetId::new), + instance_id: None, + loaded_page_generation, + } + } + + pub fn browser_context_id(&self) -> &str { + self.browser_context_id.as_str() + } + + pub(super) fn browser_context_identity(&self) -> &BrowserContextId { + &self.browser_context_id + } + + pub fn target_id(&self) -> Option<&str> { + self.target_id.as_ref().map(BrowserTargetId::as_str) + } + + pub(super) fn target_identity(&self) -> Option<&BrowserTargetId> { + self.target_id.as_ref() + } + + pub fn loaded_page_generation(&self) -> u64 { + self.loaded_page_generation + } + + /// Returns whether both identities name the same live Page-slot instance. + /// + /// The loaded Document generation may differ: Target creation precedes + /// initial-Document materialization, but the stable Page-slot capability + /// must remain the same. Synthetic compatibility identities deliberately + /// cannot prove instance equality. + pub fn same_residence_instance(&self, other: &Self) -> bool { + self.instance_id.is_some() + && self.browser_context_id == other.browser_context_id + && self.target_id == other.target_id + && self.instance_id == other.instance_id + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_residence_identity_has_no_frontend_component() { + let handle = BrowserPageResidenceHandle::default(); + let identity = handle.identity("context-1".to_owned(), Some("target-1".to_owned())); + + assert_eq!(identity.browser_context_id(), "context-1"); + assert_eq!(identity.target_id(), Some("target-1")); + assert_eq!(identity.loaded_page_generation(), 0); + assert_ne!( + identity, + PageResidenceIdentity::new("context-1".to_owned(), Some("target-1".to_owned()), 0,), + "a live capture must contain a slot instance capability" + ); + assert_ne!( + identity, + handle.identity("context-2".to_owned(), Some("target-1".to_owned())), + "browser context remains part of exact Page identity" + ); + assert_ne!( + identity, + handle.identity("context-1".to_owned(), Some("target-2".to_owned())), + "browser Target remains part of exact Page identity" + ); + } + + #[test] + fn distinct_slots_and_successor_generations_reject_old_identity() { + let first = BrowserPageResidenceHandle::default(); + let second = BrowserPageResidenceHandle::default(); + let original = first.identity("context-1".to_owned(), Some("target-1".to_owned())); + + assert!(first.state.is_current(&original)); + assert!(!second.state.is_current(&original)); + assert_eq!(first.advance_generation_for_test_fixture(), 1); + assert!(!first.state.is_current(&original)); + } + + #[test] + fn residence_instance_identity_survives_generation_but_rejects_id_reuse() { + let first = BrowserPageResidenceHandle::default(); + let original = first.identity("context-1".to_owned(), Some("target-1".to_owned())); + + first.advance_generation_for_test_fixture(); + let successor = first.identity("context-1".to_owned(), Some("target-1".to_owned())); + assert!(original.same_residence_instance(&successor)); + + let reused_public_id = BrowserPageResidenceHandle::default() + .identity("context-1".to_owned(), Some("target-1".to_owned())); + assert!(!original.same_residence_instance(&reused_public_id)); + assert!( + !original.same_residence_instance(&PageResidenceIdentity::new( + "context-1".to_owned(), + Some("target-1".to_owned()), + 0, + )) + ); + } +} diff --git a/moli-core/src/browser_host/policy.rs b/moli-core/src/browser_host/policy.rs new file mode 100644 index 0000000000..37bdc4277f --- /dev/null +++ b/moli-core/src/browser_host/policy.rs @@ -0,0 +1,321 @@ +use moli_browser_profile::BrowserIdentityProfile; +use moli_fetch::FetchConfig; +use serde_json::Value; + +/// Browser-owned bounds for the single lightweight top-level window surface. +/// +/// CDP and WebDriver may project this value differently, but changing or +/// disconnecting a frontend must not reset the underlying Browser policy. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserWindowBounds { + pub left: Option, + pub top: Option, + pub width: Option, + pub height: Option, + pub window_state: String, +} + +impl Default for BrowserWindowBounds { + fn default() -> Self { + Self { + left: None, + top: None, + width: None, + height: None, + window_state: "normal".to_owned(), + } + } +} + +/// Browser-global network availability projected into renderer surfaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmulatedNetworkConditions { + offline: bool, +} + +impl EmulatedNetworkConditions { + pub fn offline() -> Self { + Self { offline: true } + } + + pub fn navigator_online(self) -> bool { + !self.offline + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct EmulatedGeolocationOverride { + pub latitude: f64, + pub longitude: f64, + pub accuracy: f64, + pub altitude: Option, + pub altitude_accuracy: Option, + pub heading: Option, + pub speed: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum EmulatedGeolocationOverrideState { + Position(EmulatedGeolocationOverride), + PositionUnavailable, +} + +impl EmulatedGeolocationOverrideState { + pub fn position(&self) -> Option<&EmulatedGeolocationOverride> { + match self { + Self::Position(position) => Some(position), + Self::PositionUnavailable => None, + } + } +} + +/// Normalized permission policy installed by a browser-level frontend command. +/// +/// The permission descriptor remains an extensible JSON object because the +/// renderer permission surface accepts Chromium's evolving descriptor shape; +/// session ids, command ids and event subscriptions are intentionally absent. +#[derive(Debug, Clone, PartialEq)] +pub struct BrowserPermissionOverride { + pub permission: Value, + pub setting: String, + pub origin: Option, + pub embedded_origin: Option, + pub browser_context_id: Option, +} + +/// Protocol-neutral mutation accepted by the Browser Host policy owner. +/// +/// The migration adapter applies this synchronously today. Keeping the +/// mutation move-owned and closed over known variants lets a later Host queue +/// carry the same value without exposing an arbitrary re-entrant closure over +/// the authoritative allocation. +#[derive(Debug, PartialEq)] +pub enum BrowserHostPolicyUpdate { + ReplaceWindowBounds(BrowserWindowBounds), + ReplacePermissionOverrides(Vec), + SetBaseBrowserIdentity(BrowserIdentityProfile), + SetGlobalExtraHeaders(Vec<(String, String)>), + SetGlobalBrowserIdentityOverride(Option), + SetGlobalNetworkConditions(Option), + SetGlobalGeolocationOverride(Option), + SetGlobalCacheDisabled(bool), + SetBaseHttpProxy(Option), + SetBaseHttpNoProxy(Option), + SetBaseTlsVerifyHost(bool), +} + +/// Move-owned read view used by navigation and renderer configuration paths. +/// +/// Keeping this separate from [`BrowserHostPolicyState`] avoids cloning +/// permission descriptors or window state on every resource-runtime rebuild. +#[derive(Debug, Clone, PartialEq)] +pub struct BrowserHostNetworkPolicySnapshot { + base_browser_identity: BrowserIdentityProfile, + global_extra_headers: Vec<(String, String)>, + global_browser_identity_override: Option, + global_network_conditions: Option, + global_geolocation_override: Option, + global_cache_disabled: bool, + base_http_proxy: Option, + base_http_no_proxy: Option, + base_tls_verify_host: bool, +} + +impl BrowserHostNetworkPolicySnapshot { + pub fn base_browser_identity(&self) -> &BrowserIdentityProfile { + &self.base_browser_identity + } + + pub fn global_extra_headers(&self) -> &[(String, String)] { + &self.global_extra_headers + } + + pub fn global_browser_identity_override(&self) -> Option<&BrowserIdentityProfile> { + self.global_browser_identity_override.as_ref() + } + + pub fn global_network_conditions(&self) -> Option { + self.global_network_conditions + } + + pub fn global_geolocation_override(&self) -> Option<&EmulatedGeolocationOverrideState> { + self.global_geolocation_override.as_ref() + } + + pub fn global_cache_disabled(&self) -> bool { + self.global_cache_disabled + } + + pub fn base_http_proxy(&self) -> Option<&str> { + self.base_http_proxy.as_deref() + } + + pub fn base_http_no_proxy(&self) -> Option<&str> { + self.base_http_no_proxy.as_deref() + } + + pub fn base_tls_verify_host(&self) -> bool { + self.base_tls_verify_host + } +} + +/// One application-owned source of truth for browser-global behavior policy. +/// +/// Context- and Target-specific overrides remain in their exact runtime +/// owners. Frontends may update this state synchronously through +/// [`super::BrowserHostState`], then apply the resulting policy to existing +/// renderer Pages without retaining a mutable Host borrow across a wait. +#[derive(Debug, Clone, PartialEq)] +pub struct BrowserHostPolicyState { + window_bounds: BrowserWindowBounds, + permission_overrides: Vec, + base_browser_identity: BrowserIdentityProfile, + global_extra_headers: Vec<(String, String)>, + global_browser_identity_override: Option, + global_network_conditions: Option, + global_geolocation_override: Option, + global_cache_disabled: bool, + base_http_proxy: Option, + base_http_no_proxy: Option, + base_tls_verify_host: bool, +} + +impl BrowserHostPolicyState { + pub fn from_fetch_config(fetch_config: &FetchConfig) -> Self { + Self { + window_bounds: BrowserWindowBounds::default(), + permission_overrides: Vec::new(), + base_browser_identity: fetch_config.browser_identity().clone(), + global_extra_headers: Vec::new(), + global_browser_identity_override: None, + global_network_conditions: None, + global_geolocation_override: None, + global_cache_disabled: false, + base_http_proxy: fetch_config.http_proxy().map(str::to_owned), + base_http_no_proxy: fetch_config.http_no_proxy().map(str::to_owned), + base_tls_verify_host: fetch_config.tls_verify_host(), + } + } + + pub fn window_bounds(&self) -> &BrowserWindowBounds { + &self.window_bounds + } + + pub fn network_snapshot(&self) -> BrowserHostNetworkPolicySnapshot { + BrowserHostNetworkPolicySnapshot { + base_browser_identity: self.base_browser_identity.clone(), + global_extra_headers: self.global_extra_headers.clone(), + global_browser_identity_override: self.global_browser_identity_override.clone(), + global_network_conditions: self.global_network_conditions, + global_geolocation_override: self.global_geolocation_override.clone(), + global_cache_disabled: self.global_cache_disabled, + base_http_proxy: self.base_http_proxy.clone(), + base_http_no_proxy: self.base_http_no_proxy.clone(), + base_tls_verify_host: self.base_tls_verify_host, + } + } + + pub fn permission_overrides(&self) -> &[BrowserPermissionOverride] { + &self.permission_overrides + } + + pub fn base_browser_identity(&self) -> &BrowserIdentityProfile { + &self.base_browser_identity + } + + pub fn global_extra_headers(&self) -> &[(String, String)] { + &self.global_extra_headers + } + + pub fn global_browser_identity_override(&self) -> Option<&BrowserIdentityProfile> { + self.global_browser_identity_override.as_ref() + } + + pub fn global_network_conditions(&self) -> Option { + self.global_network_conditions + } + + pub fn global_geolocation_override(&self) -> Option<&EmulatedGeolocationOverrideState> { + self.global_geolocation_override.as_ref() + } + + pub fn global_cache_disabled(&self) -> bool { + self.global_cache_disabled + } + + pub fn base_http_proxy(&self) -> Option<&str> { + self.base_http_proxy.as_deref() + } + + pub fn base_http_no_proxy(&self) -> Option<&str> { + self.base_http_no_proxy.as_deref() + } + + pub fn base_tls_verify_host(&self) -> bool { + self.base_tls_verify_host + } + + pub(crate) fn apply_update(&mut self, update: BrowserHostPolicyUpdate) { + match update { + BrowserHostPolicyUpdate::ReplaceWindowBounds(bounds) => { + self.window_bounds = bounds; + } + BrowserHostPolicyUpdate::ReplacePermissionOverrides(overrides) => { + self.permission_overrides = overrides; + } + BrowserHostPolicyUpdate::SetBaseBrowserIdentity(identity) => { + self.base_browser_identity = identity; + } + BrowserHostPolicyUpdate::SetGlobalExtraHeaders(headers) => { + self.global_extra_headers = headers; + } + BrowserHostPolicyUpdate::SetGlobalBrowserIdentityOverride(identity) => { + self.global_browser_identity_override = identity; + } + BrowserHostPolicyUpdate::SetGlobalNetworkConditions(conditions) => { + self.global_network_conditions = conditions; + } + BrowserHostPolicyUpdate::SetGlobalGeolocationOverride(override_state) => { + self.global_geolocation_override = override_state; + } + BrowserHostPolicyUpdate::SetGlobalCacheDisabled(disabled) => { + self.global_cache_disabled = disabled; + } + BrowserHostPolicyUpdate::SetBaseHttpProxy(proxy) => { + self.base_http_proxy = proxy; + } + BrowserHostPolicyUpdate::SetBaseHttpNoProxy(no_proxy) => { + self.base_http_no_proxy = no_proxy; + } + BrowserHostPolicyUpdate::SetBaseTlsVerifyHost(enabled) => { + self.base_tls_verify_host = enabled; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_defaults_follow_the_browser_fetch_configuration() { + let mut fetch_config = FetchConfig::default(); + fetch_config.set_user_agent("HostPolicy/1.0"); + fetch_config.set_http_proxy(Some("http://proxy.test:8080".to_owned())); + fetch_config.set_http_no_proxy(Some("localhost".to_owned())); + fetch_config.set_tls_verify_host(false); + + let policy = BrowserHostPolicyState::from_fetch_config(&fetch_config); + + assert_eq!( + policy.base_browser_identity().user_agent(), + "HostPolicy/1.0" + ); + assert_eq!(policy.base_http_proxy(), Some("http://proxy.test:8080")); + assert_eq!(policy.base_http_no_proxy(), Some("localhost")); + assert!(!policy.base_tls_verify_host()); + assert!(policy.permission_overrides().is_empty()); + assert_eq!(policy.window_bounds(), &BrowserWindowBounds::default()); + } +} diff --git a/moli-core/src/browser_host/state.rs b/moli-core/src/browser_host/state.rs new file mode 100644 index 0000000000..83b6cf397d --- /dev/null +++ b/moli-core/src/browser_host/state.rs @@ -0,0 +1,463 @@ +use std::{ + cell::{Ref, RefCell, RefMut}, + fmt, + rc::Rc, +}; + +use crate::runtime::NavigationEngine; +use crate::runtime::{ + RendererBrowserContextRuntimeOwner, RendererBrowserContextRuntimeOwnerAccess, +}; + +use super::context_runtime_registry::BrowserContextRuntimeRegistry; +use super::identity_allocator::BrowserHostIdentityState; +use super::{ + BrowserCommandId, BrowserContextHandle, BrowserContextRegistration, + BrowserContextRegistrationMetadata, BrowserContextRemoval, BrowserContextRemovalPermit, + BrowserContextRuntimeRegistryError, BrowserContextSelectionProjection, BrowserDownloadPolicy, + BrowserDownloadPolicyState, BrowserDownloadPolicyUpdate, BrowserDownloadRegistry, + BrowserFactSubscriber, BrowserFactWakeSubscriber, BrowserHostNetworkPolicySnapshot, + BrowserHostPolicyState, BrowserHostPolicyUpdate, BrowserNavigationOwner, + BrowserNetworkArtifactStore, BrowserTargetIdAllocator, BrowserTargetTopologyProjection, +}; + +/// Application-owned residence for authoritative Browser Host state. +/// +/// The renderer runtime is deliberately single-threaded, so this residence is +/// `Rc`/`RefCell` rather than a misleading cross-thread mutex. Protocol +/// adapters may clone this capability, but the authoritative registries, +/// navigation engines and fact journal live in one shared Host allocation and +/// therefore are not embedded in a frontend connection. +#[derive(Clone)] +pub struct BrowserHostState { + inner: Rc, +} + +struct BrowserHostStateInner { + navigation_owner: RefCell>, + policy: RefCell, + download_policy: RefCell, + download_registry: BrowserDownloadRegistry, + network_artifacts: BrowserNetworkArtifactStore, + identities: BrowserHostIdentityState, + renderer_runtime_roots: RefCell, +} + +impl Drop for BrowserHostStateInner { + fn drop(&mut self) { + let roots = self.renderer_runtime_roots.get_mut(); + roots.terminate_renderer_producers(); + + // Navigation engines own the last Page/renderer leases. Release them + // before the BrowserContext roots close fetch admission and join their + // semantic/curl owners. + drop(self.navigation_owner.get_mut().take()); + + roots.shutdown_network_and_join(); + } +} + +impl fmt::Debug for BrowserHostState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BrowserHostState") + .finish_non_exhaustive() + } +} + +impl BrowserHostState { + pub fn new(active_engine: NavigationEngine) -> Self { + Self::new_with_target_id_allocator(active_engine, BrowserTargetIdAllocator::default()) + } + + pub fn new_with_target_id_allocator( + active_engine: NavigationEngine, + target_id_allocator: BrowserTargetIdAllocator, + ) -> Self { + let policy = BrowserHostPolicyState::from_fetch_config(active_engine.fetch_config()); + Self { + inner: Rc::new(BrowserHostStateInner { + navigation_owner: RefCell::new(Some(BrowserNavigationOwner::new(active_engine))), + policy: RefCell::new(policy), + download_policy: RefCell::new(BrowserDownloadPolicyState::default()), + download_registry: BrowserDownloadRegistry::default(), + network_artifacts: BrowserNetworkArtifactStore::default(), + identities: BrowserHostIdentityState::new(target_id_allocator), + renderer_runtime_roots: RefCell::new(BrowserContextRuntimeRegistry::default()), + }), + } + } + + /// Borrows the authoritative Browser state for one short synchronous + /// owner operation. + pub fn navigation_owner(&self) -> Ref<'_, BrowserNavigationOwner> { + Ref::map(self.inner.navigation_owner.borrow(), |owner| { + owner + .as_ref() + .expect("Browser Host state was already taken for teardown") + }) + } + + /// Mutably borrows the authoritative Browser state for one short + /// synchronous owner operation. + pub(crate) fn navigation_owner_mut(&self) -> RefMut<'_, BrowserNavigationOwner> { + RefMut::map(self.inner.navigation_owner.borrow_mut(), |owner| { + owner + .as_mut() + .expect("Browser Host state was already taken for teardown") + }) + } + + /// Registers one exact physical BrowserContext runtime root together with + /// its authoritative Core topology transaction. + /// + /// The root is move-owned before the Context handle becomes live. A Core + /// rejection drops only this uncommitted candidate; a successful commit + /// installs the root in Browser Host before returning to Protocol. + pub fn register_browser_context_with_runtime( + &self, + browser_context_id: String, + browser_context_handle: BrowserContextHandle, + registration_metadata: BrowserContextRegistrationMetadata, + target_topology: BrowserTargetTopologyProjection, + selection_projection: BrowserContextSelectionProjection, + renderer_runtime_owner: RendererBrowserContextRuntimeOwner, + create_replacement: F, + ) -> Result + where + F: FnOnce(RendererBrowserContextRuntimeOwnerAccess) -> NavigationEngine, + { + self.inner.renderer_runtime_roots.borrow_mut().register( + browser_context_handle.clone(), + renderer_runtime_owner, + |renderer_runtime_access| { + self.navigation_owner_mut() + .register_browser_context_with_handle_and_metadata( + browser_context_id, + browser_context_handle, + registration_metadata, + target_topology, + selection_projection, + || create_replacement(renderer_runtime_access), + ) + }, + ) + } + + /// Commits one exact Context removal and returns its unique renderer and + /// network runtime root in the same synchronous Browser Host turn. + pub fn commit_browser_context_removal_with_runtime( + &self, + permit: BrowserContextRemovalPermit, + projection: BrowserContextSelectionProjection, + create_unbound_replacement: F, + ) -> Result< + (BrowserContextRemoval, RendererBrowserContextRuntimeOwner), + BrowserContextRuntimeRegistryError, + > + where + F: FnOnce() -> NavigationEngine, + { + let browser_context_handle = permit.browser_context_handle().clone(); + self.inner + .renderer_runtime_roots + .borrow_mut() + .remove(browser_context_handle, || { + self.navigation_owner_mut().commit_browser_context_removal( + permit, + projection, + create_unbound_replacement, + ) + }) + } + + /// Commits selected Context removal plus Core-chosen successor activation + /// and returns the predecessor's exact runtime root. + pub fn commit_browser_context_removal_with_successor_runtime( + &self, + permit: BrowserContextRemovalPermit, + projection: BrowserContextSelectionProjection, + create_replacement: F, + ) -> Result< + (BrowserContextRemoval, RendererBrowserContextRuntimeOwner), + BrowserContextRuntimeRegistryError, + > + where + F: FnOnce() -> NavigationEngine, + { + let browser_context_handle = permit.browser_context_handle().clone(); + self.inner + .renderer_runtime_roots + .borrow_mut() + .remove(browser_context_handle, || { + self.navigation_owner_mut() + .commit_browser_context_removal_with_successor( + permit, + projection, + create_replacement, + ) + }) + } + + /// Returns a cloneable, non-owning capability for an exact live Context. + pub fn renderer_runtime_owner_access( + &self, + browser_context_handle: &BrowserContextHandle, + ) -> Option { + self.inner + .renderer_runtime_roots + .borrow() + .owner_access(browser_context_handle) + } + + #[cfg(test)] + fn renderer_runtime_root_count_for_test(&self) -> usize { + self.inner.renderer_runtime_roots.borrow().len() + } + + pub fn subscribe_browser_facts(&self) -> BrowserFactSubscriber { + self.navigation_owner().subscribe_browser_facts() + } + + pub fn subscribe_browser_fact_wake(&self) -> BrowserFactWakeSubscriber { + self.navigation_owner().subscribe_browser_fact_wake() + } + + /// Returns an owned snapshot so frontend application work never retains a + /// mutable Browser Host borrow across renderer or network waits. + pub fn policy_snapshot(&self) -> BrowserHostPolicyState { + self.inner.policy.borrow().clone() + } + + pub fn network_policy_snapshot(&self) -> BrowserHostNetworkPolicySnapshot { + self.inner.policy.borrow().network_snapshot() + } + + /// Applies one move-owned mutation in a short synchronous Browser Host + /// step. No policy borrow or caller-provided callback escapes this method. + pub fn apply_policy_update(&self, update: BrowserHostPolicyUpdate) { + self.inner.policy.borrow_mut().apply_update(update); + } + + pub fn download_policy_snapshot(&self) -> BrowserDownloadPolicyState { + self.inner.download_policy.borrow().clone() + } + + pub fn effective_download_policy( + &self, + browser_context_id: Option<&str>, + ) -> BrowserDownloadPolicy { + self.inner + .download_policy + .borrow() + .effective_for_browser_context(browser_context_id) + } + + pub fn apply_download_policy_update(&self, update: BrowserDownloadPolicyUpdate) { + self.inner.download_policy.borrow_mut().apply(update); + } + + pub fn download_registry(&self) -> BrowserDownloadRegistry { + self.inner.download_registry.clone() + } + + pub fn network_artifacts(&self) -> BrowserNetworkArtifactStore { + self.inner.network_artifacts.clone() + } + + pub fn allocate_browser_context_sequence(&self) -> u64 { + self.inner.identities.allocate_browser_context_sequence() + } + + pub fn allocate_target_sequence(&self) -> u64 { + self.inner.identities.allocate_target_sequence() + } + + pub fn allocate_browser_command_id(&self) -> BrowserCommandId { + self.inner.identities.allocate_browser_command_id() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + OptionalResourceFetchMask, + browser_host::{BrowserSelectedTargetEngineDisposition, BrowserTargetSlotProjection}, + runtime::RendererBrowserContextRuntime, + }; + use moli_fetch::FetchConfig; + + fn context_projection(browser_context_id: Option<&str>) -> BrowserContextSelectionProjection { + BrowserContextSelectionProjection::new( + browser_context_id.map(str::to_owned), + BrowserSelectedTargetEngineDisposition::Unbound, + ) + } + + fn empty_topology(browser_context_id: &str) -> BrowserTargetTopologyProjection { + BrowserTargetTopologyProjection::new( + browser_context_id, + None, + Vec::::new(), + ) + } + + #[test] + fn application_target_allocator_spans_browser_hosts() { + let target_ids = BrowserTargetIdAllocator::default(); + let first = BrowserHostState::new_with_target_id_allocator( + NavigationEngine::new(), + target_ids.clone(), + ); + let second = + BrowserHostState::new_with_target_id_allocator(NavigationEngine::new(), target_ids); + + assert_eq!(first.allocate_target_sequence(), 1); + assert_eq!(second.allocate_target_sequence(), 2); + } + + fn engine_for_context(runtime: RendererBrowserContextRuntimeOwnerAccess) -> NavigationEngine { + NavigationEngine::new_with_fetch_config_and_browser_context_access( + FetchConfig::default(), + runtime, + OptionalResourceFetchMask::NONE, + true, + ) + .expect("live BrowserContext root should create a replacement engine") + } + + #[test] + fn cloned_residence_keeps_authoritative_state_alive() { + let application_residence = BrowserHostState::new(NavigationEngine::new()); + let frontend_capability = application_residence.clone(); + let browser_instance_id = frontend_capability.navigation_owner().browser_instance_id(); + + drop(frontend_capability); + + assert_eq!( + application_residence + .navigation_owner() + .browser_instance_id(), + browser_instance_id + ); + } + + #[test] + fn cloned_residence_observes_one_browser_policy_state() { + let application_residence = BrowserHostState::new(NavigationEngine::new()); + let frontend_capability = application_residence.clone(); + + frontend_capability + .apply_policy_update(BrowserHostPolicyUpdate::SetGlobalCacheDisabled(true)); + let mut bounds = frontend_capability + .policy_snapshot() + .window_bounds() + .clone(); + bounds.width = Some(1280); + frontend_capability + .apply_policy_update(BrowserHostPolicyUpdate::ReplaceWindowBounds(bounds)); + drop(frontend_capability); + + let policy = application_residence.policy_snapshot(); + assert!(policy.global_cache_disabled()); + assert_eq!(policy.window_bounds().width, Some(1280)); + } + + #[test] + fn exact_runtime_root_rolls_back_with_context_rejection_and_survives_same_id_reuse() { + let state = BrowserHostState::new(NavigationEngine::new()); + let first_handle = BrowserContextHandle::staged("context-reused"); + let first_root = RendererBrowserContextRuntime::new(); + let first_runtime_id = first_root.handle().id(); + + state + .register_browser_context_with_runtime( + "context-reused".to_owned(), + first_handle.clone(), + BrowserContextRegistrationMetadata::default(), + empty_topology("context-reused"), + context_projection(None), + first_root, + engine_for_context, + ) + .expect("first exact Context and runtime root should register"); + assert_eq!(state.renderer_runtime_root_count_for_test(), 1); + assert_eq!( + state + .renderer_runtime_owner_access(&first_handle) + .expect("registered exact root") + .runtime() + .id(), + first_runtime_id + ); + + let rejected_permit = state + .navigation_owner() + .prepare_browser_context_removal_for_handle(&first_handle) + .expect("first removal permit"); + let error = state + .commit_browser_context_removal_with_runtime( + rejected_permit, + context_projection(None), + NavigationEngine::new, + ) + .expect_err("a stale physical selection must reject Context removal"); + assert!(matches!( + error, + BrowserContextRuntimeRegistryError::Context(_) + )); + assert_eq!(state.renderer_runtime_root_count_for_test(), 1); + assert!(first_handle.is_live()); + assert_eq!( + state + .renderer_runtime_owner_access(&first_handle) + .expect("typed rejection must restore the exact runtime root") + .runtime() + .id(), + first_runtime_id + ); + + let permit = state + .navigation_owner() + .prepare_browser_context_removal_for_handle(&first_handle) + .expect("second removal permit"); + let (_, mut retired_first_root) = state + .commit_browser_context_removal_with_runtime( + permit, + context_projection(Some("context-reused")), + NavigationEngine::new, + ) + .expect("exact Context and root should retire together"); + assert_eq!(state.renderer_runtime_root_count_for_test(), 0); + assert!(first_handle.is_retired()); + assert_eq!(retired_first_root.handle().id(), first_runtime_id); + + let replacement_handle = BrowserContextHandle::staged("context-reused"); + let replacement_root = RendererBrowserContextRuntime::new(); + let replacement_runtime_id = replacement_root.handle().id(); + state + .register_browser_context_with_runtime( + "context-reused".to_owned(), + replacement_handle.clone(), + BrowserContextRegistrationMetadata::default(), + empty_topology("context-reused"), + context_projection(None), + replacement_root, + engine_for_context, + ) + .expect("same public id should register as a new exact Context"); + assert_eq!(state.renderer_runtime_root_count_for_test(), 1); + assert_eq!( + state + .renderer_runtime_owner_access(&replacement_handle) + .expect("replacement exact root") + .runtime() + .id(), + replacement_runtime_id + ); + assert_ne!(first_runtime_id, replacement_runtime_id); + + retired_first_root.shutdown_and_join(); + } +} diff --git a/moli-core/src/browser_host/target_handle.rs b/moli-core/src/browser_host/target_handle.rs new file mode 100644 index 0000000000..9cc4ab89b7 --- /dev/null +++ b/moli-core/src/browser_host/target_handle.rs @@ -0,0 +1,277 @@ +use std::{ + fmt, + hash::{Hash, Hasher}, + num::NonZeroU64, + sync::{ + Arc, + atomic::{AtomicU8, AtomicU64, Ordering}, + }, +}; + +use super::BrowserTargetId; + +static NEXT_TARGET_INSTANCE_ID: AtomicU64 = AtomicU64::new(1); + +const TARGET_STAGED: u8 = 0; +const TARGET_LIVE: u8 = 1; +const TARGET_RETIRED: u8 = 2; +const TARGET_ACTIVATION_RESERVED: u8 = 3; +const TARGET_RETIREMENT_RESERVED: u8 = 4; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct BrowserTargetInstanceId(NonZeroU64); + +impl BrowserTargetInstanceId { + fn allocate() -> Self { + let raw = NEXT_TARGET_INSTANCE_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .unwrap_or_else(|_| panic!("browser Target instance id exhausted")); + Self( + NonZeroU64::new(raw) + .unwrap_or_else(|| panic!("browser Target instance allocator returned zero")), + ) + } +} + +struct BrowserTargetHandleState { + instance_id: BrowserTargetInstanceId, + target_id: BrowserTargetId, + lifecycle: AtomicU8, +} + +/// Stable capability for one browser Target instance. +/// +/// A physical active/background slot moves this handle with its payload. Only +/// Browser Core may activate a staged handle or retire a live handle, so a +/// stale physical slot cannot become authoritative merely by reusing the same +/// public Target id. +#[derive(Clone)] +pub struct BrowserTargetHandle { + state: Arc, +} + +impl BrowserTargetHandle { + /// Allocates a staged Target capability. + /// + /// Constructing the handle does not make a Target live. Browser Core must + /// accept it through a context/Target registration transaction first. + pub fn staged(target_id: impl Into) -> Self { + Self { + state: Arc::new(BrowserTargetHandleState { + instance_id: BrowserTargetInstanceId::allocate(), + target_id: BrowserTargetId::new(target_id), + lifecycle: AtomicU8::new(TARGET_STAGED), + }), + } + } + + pub fn target_id(&self) -> &str { + self.state.target_id.as_str() + } + + pub fn is_live(&self) -> bool { + matches!( + self.state.lifecycle.load(Ordering::Acquire), + TARGET_LIVE | TARGET_RETIREMENT_RESERVED + ) + } + + pub fn is_retired(&self) -> bool { + self.state.lifecycle.load(Ordering::Acquire) == TARGET_RETIRED + } + + pub(super) fn is_staged(&self) -> bool { + matches!( + self.state.lifecycle.load(Ordering::Acquire), + TARGET_STAGED | TARGET_ACTIVATION_RESERVED + ) + } + + /// Reserves the staged-to-live transition for one synchronous Browser + /// Owner transaction. Public lifecycle observers continue to see a staged + /// handle until the registry publishes the commit. + pub(super) fn reserve_activation(&self) -> bool { + self.state + .lifecycle + .compare_exchange( + TARGET_STAGED, + TARGET_ACTIVATION_RESERVED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + pub(super) fn commit_activation_reservation(&self) { + let committed = self + .state + .lifecycle + .compare_exchange( + TARGET_ACTIVATION_RESERVED, + TARGET_LIVE, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + debug_assert!( + committed, + "only the registry transaction that reserved Target activation may publish it" + ); + } + + pub(super) fn rollback_activation_reservation(&self) { + let rolled_back = self + .state + .lifecycle + .compare_exchange( + TARGET_ACTIVATION_RESERVED, + TARGET_STAGED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + debug_assert!( + rolled_back, + "only an uncommitted Target activation reservation may be rolled back" + ); + } + + /// Reserves the live-to-retired transition while keeping the handle + /// observably live until the matching registry topology commit. + pub(super) fn reserve_retirement(&self) -> bool { + self.state + .lifecycle + .compare_exchange( + TARGET_LIVE, + TARGET_RETIREMENT_RESERVED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + pub(super) fn commit_retirement_reservation(&self) { + let committed = self + .state + .lifecycle + .compare_exchange( + TARGET_RETIREMENT_RESERVED, + TARGET_RETIRED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + debug_assert!( + committed, + "only the registry transaction that reserved Target retirement may publish it" + ); + } + + pub(super) fn rollback_retirement_reservation(&self) { + let rolled_back = self + .state + .lifecycle + .compare_exchange( + TARGET_RETIREMENT_RESERVED, + TARGET_LIVE, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + debug_assert!( + rolled_back, + "only an uncommitted Target retirement reservation may be rolled back" + ); + } + + pub(super) fn same_instance(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.state, &other.state) + } +} + +impl From for BrowserTargetHandle { + fn from(target_id: String) -> Self { + Self::staged(target_id) + } +} + +impl From<&str> for BrowserTargetHandle { + fn from(target_id: &str) -> Self { + Self::staged(target_id) + } +} + +impl fmt::Debug for BrowserTargetHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BrowserTargetHandle") + .field("instance_id", &self.state.instance_id) + .field("target_id", &self.target_id()) + .field("live", &self.is_live()) + .field("retired", &self.is_retired()) + .finish() + } +} + +impl PartialEq for BrowserTargetHandle { + fn eq(&self, other: &Self) -> bool { + self.state.instance_id == other.state.instance_id + } +} + +impl Eq for BrowserTargetHandle {} + +impl Hash for BrowserTargetHandle { + fn hash(&self, state: &mut H) { + self.state.instance_id.hash(state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_public_id_does_not_alias_target_instance() { + let first = BrowserTargetHandle::staged("TID-1"); + let second = BrowserTargetHandle::staged("TID-1"); + + assert_ne!(first, second); + assert!(first.same_instance(&first.clone())); + assert!(!first.same_instance(&second)); + } + + #[test] + fn core_lifecycle_is_exact_once() { + let handle = BrowserTargetHandle::staged("TID-1"); + + assert!(handle.is_staged()); + assert!(handle.reserve_activation()); + assert!(handle.is_staged()); + handle.commit_activation_reservation(); + assert!(handle.is_live()); + assert!(!handle.reserve_activation()); + assert!(handle.reserve_retirement()); + assert!(handle.is_live()); + handle.commit_retirement_reservation(); + assert!(handle.is_retired()); + assert!(!handle.reserve_retirement()); + } + + #[test] + fn uncommitted_lifecycle_reservations_restore_the_observable_state() { + let handle = BrowserTargetHandle::staged("TID-1"); + + assert!(handle.reserve_activation()); + handle.rollback_activation_reservation(); + assert!(handle.is_staged()); + + assert!(handle.reserve_activation()); + handle.commit_activation_reservation(); + assert!(handle.reserve_retirement()); + handle.rollback_retirement_reservation(); + assert!(handle.is_live()); + } +} diff --git a/moli-core/src/browser_host/target_session_storage.rs b/moli-core/src/browser_host/target_session_storage.rs new file mode 100644 index 0000000000..605d25eb19 --- /dev/null +++ b/moli-core/src/browser_host/target_session_storage.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use crate::network::{SharedWebStorageStore, new_shared_web_storage_store}; + +use super::BrowserTargetHandle; + +/// Creation-time payload for one top-level Target's session-storage namespace. +/// +/// The seed is move-owned by the Target registration transaction. Cloning it +/// only clones the shared candidate store; the exact Target capability is not +/// created until Browser Core accepts the registration. +#[derive(Clone)] +pub(crate) struct BrowserTargetSessionStorageSeed { + store: SharedWebStorageStore, +} + +impl Default for BrowserTargetSessionStorageSeed { + fn default() -> Self { + Self { + store: new_shared_web_storage_store(), + } + } +} + +impl BrowserTargetSessionStorageSeed { + pub(crate) fn from_store(store: SharedWebStorageStore) -> Self { + Self { store } + } + + pub(super) fn store(&self) -> SharedWebStorageStore { + self.store.clone() + } +} + +impl std::fmt::Debug for BrowserTargetSessionStorageSeed { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BrowserTargetSessionStorageSeed") + .finish_non_exhaustive() + } +} + +impl PartialEq for BrowserTargetSessionStorageSeed { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.store, &other.store) + } +} + +impl Eq for BrowserTargetSessionStorageSeed {} + +/// Non-authoritative access to the session-storage namespace of one exact +/// live top-level Target. +/// +/// The Target registry owns the namespace association. This access carries the +/// exact Target instance capability, so a same-public-id replacement cannot +/// authorize the predecessor's namespace. Store clones already captured by an +/// in-flight renderer operation may finish after retirement, but they cannot +/// become the namespace of a new Target instance. +#[derive(Clone)] +pub struct BrowserTargetSessionStorageAccess { + target: BrowserTargetHandle, + store: SharedWebStorageStore, +} + +impl BrowserTargetSessionStorageAccess { + pub(super) fn new(target: BrowserTargetHandle, store: SharedWebStorageStore) -> Self { + Self { target, store } + } + + pub fn target_handle(&self) -> &BrowserTargetHandle { + &self.target + } + + pub fn is_live(&self) -> bool { + self.target.is_live() + } + + /// Returns the shared store for an already-authorized exact Target route. + /// + /// This method does not itself authorize a command. Callers must first + /// resolve the exact live Target handle; `is_live` is exposed for access + /// projection and stale-completion checks. + pub fn store(&self) -> &SharedWebStorageStore { + &self.store + } +} + +impl std::fmt::Debug for BrowserTargetSessionStorageAccess { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("BrowserTargetSessionStorageAccess") + .field("target", &self.target) + .field("live", &self.is_live()) + .finish_non_exhaustive() + } +} + +impl PartialEq for BrowserTargetSessionStorageAccess { + fn eq(&self, other: &Self) -> bool { + self.target == other.target && Arc::ptr_eq(&self.store, &other.store) + } +} + +impl Eq for BrowserTargetSessionStorageAccess {} diff --git a/moli-core/src/browser_host/turn.rs b/moli-core/src/browser_host/turn.rs new file mode 100644 index 0000000000..87b4f1220c --- /dev/null +++ b/moli-core/src/browser_host/turn.rs @@ -0,0 +1,38 @@ +use super::{BrowserOwnerInput, BrowserOwnerInputKind}; + +/// One input selected by the Browser Host for immediate execution. +/// +/// Construction is restricted to the Core Browser Host module; production +/// turns are issued by the actor after FIFO mailbox selection. Physical +/// migration adapters may consume this capability, but cannot manufacture a +/// turn from a raw renderer intent or choose a different mailbox entry. +#[derive(Debug)] +#[must_use = "a selected Browser Host turn must be consumed by its executor"] +pub struct BrowserHostTurn { + input: BrowserOwnerInput, +} + +impl BrowserHostTurn { + pub(super) fn new(input: BrowserOwnerInput) -> Self { + Self { input } + } + + pub fn kind(&self) -> BrowserOwnerInputKind { + self.input.kind() + } + + pub fn into_input(self) -> BrowserOwnerInput { + self.input + } +} + +/// Physical execution boundary used by [`super::BrowserHostActor`]. +/// +/// Implementations may temporarily project into protocol-owned Page payload, +/// but they receive only an actor-selected [`BrowserHostTurn`]. The trait has +/// no frontend identity, command id, subscription or socket contract. +pub trait BrowserHostTurnExecutor { + type Output; + + fn execute_browser_host_turn(&mut self, turn: BrowserHostTurn) -> Self::Output; +} diff --git a/moli-core/src/lib.rs b/moli-core/src/lib.rs index 4670f9ff11..ef3dd803f6 100644 --- a/moli-core/src/lib.rs +++ b/moli-core/src/lib.rs @@ -4,6 +4,7 @@ //! `runtime`, `page`, `network`, and `protocol_types`, while the heavier //! implementation now lives in the split internal crates. +pub mod browser_host; mod config; mod dom; pub mod network; diff --git a/moli-core/src/page/js_evaluation.rs b/moli-core/src/page/js_evaluation.rs index 278a07e0ed..5fbce4c5ca 100644 --- a/moli-core/src/page/js_evaluation.rs +++ b/moli-core/src/page/js_evaluation.rs @@ -241,11 +241,25 @@ impl Page { } pub async fn ensure_isolated_worlds_attached_to_inspector_async(&mut self) -> Result<()> { - self.dispatch_unit_page_command_async( - RendererPageCommand::EnsureIsolatedWorldsAttachedToInspector, + let pending = self.start_ensure_isolated_worlds_attached_to_inspector()?; + self.finish_ensure_isolated_worlds_attached_to_inspector(pending.wait().await?) + } + + pub fn start_ensure_isolated_worlds_attached_to_inspector(&self) -> Result { + self.start_page_command(RendererPageCommand::EnsureIsolatedWorldsAttachedToInspector) + } + + pub fn finish_ensure_isolated_worlds_attached_to_inspector( + &mut self, + completion: CompletedPageCommand, + ) -> Result<()> { + let reply = self.finish_page_command(completion); + expect_page_reply!( + reply, "ensure isolated worlds attached to inspector", + "a unit reply", + RendererPageReply::Unit => Ok(()), ) - .await } pub async fn inspector_execution_context_id_for_isolated_context_async( @@ -271,13 +285,27 @@ impl Page { &mut self, execution_context_id: i64, ) -> Result> { - let reply = self - .dispatch_page_command_async( - RendererPageCommand::IsolatedExecutionContextIdForInspectorContext( - execution_context_id, - ), - ) - .await?; + let pending = + self.start_isolated_execution_context_id_for_inspector_context(execution_context_id)?; + self.finish_isolated_execution_context_id_for_inspector_context(pending.wait().await?) + } + + pub fn start_isolated_execution_context_id_for_inspector_context( + &self, + execution_context_id: i64, + ) -> Result { + self.start_page_command( + RendererPageCommand::IsolatedExecutionContextIdForInspectorContext( + execution_context_id, + ), + ) + } + + pub fn finish_isolated_execution_context_id_for_inspector_context( + &mut self, + completion: CompletedPageCommand, + ) -> Result> { + let reply = self.finish_page_command(completion); expect_page_reply!( reply, "synthetic isolated context id page command", @@ -287,9 +315,19 @@ impl Page { } pub async fn runtime_realm_inventory_async(&mut self) -> Result> { - let reply = self - .dispatch_page_command_async(RendererPageCommand::RuntimeRealmInventory) - .await?; + let pending = self.start_runtime_realm_inventory()?; + self.finish_runtime_realm_inventory(pending.wait().await?) + } + + pub fn start_runtime_realm_inventory(&self) -> Result { + self.start_page_command(RendererPageCommand::RuntimeRealmInventory) + } + + pub fn finish_runtime_realm_inventory( + &mut self, + completion: CompletedPageCommand, + ) -> Result> { + let reply = self.finish_page_command(completion); expect_page_reply!( reply, "runtime realm inventory page command", @@ -1185,6 +1223,28 @@ impl Page { .await } + pub fn start_run_page_surface_override_script( + &self, + source: &str, + ) -> Result { + self.start_page_command(RendererPageCommand::RunPageSurfaceOverrideScript { + source: source.to_owned(), + }) + } + + pub fn finish_run_page_surface_override_script( + &mut self, + completion: CompletedPageCommand, + ) -> Result<()> { + let reply = self.finish_page_command(completion); + expect_page_reply!( + reply, + "run page surface override script", + "a unit reply", + RendererPageReply::Unit => Ok(()), + ) + } + pub fn finish_document_start_script_result( &mut self, completion: CompletedPageCommand, diff --git a/moli-core/src/page/mod.rs b/moli-core/src/page/mod.rs index f11943d511..8657cfb779 100644 --- a/moli-core/src/page/mod.rs +++ b/moli-core/src/page/mod.rs @@ -34,8 +34,8 @@ use std::fmt; use std::sync::Arc; use crate::renderer::{ - RendererPageCommand, RendererPageCommandPending, RendererPageHandle, RendererPageReply, - RendererPageState, + RendererPageClientHandle, RendererPageCommand, RendererPageCommandPending, RendererPageHandle, + RendererPageReply, RendererPageState, }; use anyhow::Result; pub use command_dispatch::{ @@ -188,12 +188,40 @@ pub use crate::renderer::{ pub struct Page { page_state: PageStateCache, - handle: RendererPageHandle, + handle: RendererPageClientHandle, + renderer_lifetime_owner: Option, renderer_agent_attachment_id: Option, renderer_devtools_command_session_id: Option, page_creation_artifacts: Option>, } +/// Unique lifetime authority for one exact renderer Page. +/// +/// A protocol `Page` projection only needs the cloneable command capability. +/// Browser Host takes this value when the corresponding Page residence +/// commits, so dropping a frontend projection cannot close the renderer Page. +pub struct RendererPageLifetimeOwner { + handle: Box, +} + +impl fmt::Debug for RendererPageLifetimeOwner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RendererPageLifetimeOwner") + .field("page_id", &self.handle.page_id()) + .finish_non_exhaustive() + } +} + +impl RendererPageLifetimeOwner { + pub fn page_id(&self) -> u64 { + self.handle.page_id() + } + + pub async fn close_async(mut self) -> Result<()> { + self.handle.close_async().await + } +} + impl fmt::Debug for Page { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Page") @@ -211,9 +239,13 @@ impl Page { handle: RendererPageHandle, page_state: Arc, ) -> Self { + let client = handle.client_handle(); Self { page_state: PageStateCache::new(page_state), - handle, + handle: client, + renderer_lifetime_owner: Some(RendererPageLifetimeOwner { + handle: Box::new(handle), + }), renderer_agent_attachment_id: None, renderer_devtools_command_session_id: None, page_creation_artifacts: None, @@ -225,9 +257,13 @@ impl Page { page_state: Arc, page_creation_artifacts: RendererPageCreationArtifacts, ) -> Self { + let client = handle.client_handle(); Self { page_state: PageStateCache::new(page_state), - handle, + handle: client, + renderer_lifetime_owner: Some(RendererPageLifetimeOwner { + handle: Box::new(handle), + }), renderer_agent_attachment_id: None, renderer_devtools_command_session_id: None, page_creation_artifacts: Some(Box::new(page_creation_artifacts)), @@ -274,20 +310,50 @@ impl Page { self.renderer_agent_attachment_id = Some(id); } + /// Detaches the unique renderer Page lifetime authority from this command + /// projection. Browser Host consumes it only after an exact residence + /// commit; a rejected commit must restore it before deterministic cleanup. + #[doc(hidden)] + pub fn take_renderer_lifetime_owner(&mut self) -> Option { + self.renderer_lifetime_owner.take() + } + + /// Restores a candidate owner after Browser Host rejected its residence + /// transition. A mismatched or already-owned Page leaves the candidate + /// untouched for the caller to close. + #[doc(hidden)] + pub fn try_restore_renderer_lifetime_owner( + &mut self, + owner: RendererPageLifetimeOwner, + ) -> Result<(), RendererPageLifetimeOwner> { + if self.renderer_lifetime_owner.is_some() || owner.page_id() != self.handle.page_id() { + return Err(owner); + } + self.renderer_lifetime_owner = Some(owner); + Ok(()) + } + #[doc(hidden)] pub fn take_committed_document_post_response_continuation( &mut self, ) -> Option { - self.handle - .take_committed_document_post_response_continuation() + self.renderer_lifetime_owner.as_mut().and_then(|owner| { + owner + .handle + .take_committed_document_post_response_continuation() + }) } /// Deterministically releases this page from the renderer owner. /// - /// Dropping `Page` also schedules a detached best-effort cleanup, but code - /// that needs teardown acknowledgement should await `close_async()` instead. + /// A freshly created standalone `Page` still owns that lifetime and closes + /// it here. After Browser Host adoption this value is a command projection, + /// so closing or dropping it cannot retire the Browser-owned renderer Page. pub async fn close_async(mut self) -> Result<()> { - self.handle.close_async().await + match self.renderer_lifetime_owner.take() { + Some(owner) => owner.close_async().await, + None => Ok(()), + } } // Synchronous Page methods below must stay local: they read the cached @@ -295,7 +361,7 @@ impl Page { // owner goes through an explicit async method. #[cfg(test)] pub(crate) fn handle_for_testing(&self) -> RendererPageTestingHandle { - RendererPageTestingHandle::new_for_testing(&self.handle) + RendererPageTestingHandle::new_for_client_for_testing(&self.handle) } #[cfg(all(test, debug_assertions))] diff --git a/moli-core/src/page/subresource_support.rs b/moli-core/src/page/subresource_support.rs index 794b282bd9..6f3e23bd51 100644 --- a/moli-core/src/page/subresource_support.rs +++ b/moli-core/src/page/subresource_support.rs @@ -13,15 +13,29 @@ impl Page { } pub async fn stop_document_lifecycle_async(&mut self) -> Result<()> { - let reply = self - .dispatch_page_command_async(RendererPageCommand::StopDocumentLifecycle) - .await?; + let pending = self.start_stop_document_lifecycle()?; + self.finish_stop_document_lifecycle(pending.wait().await?) + .map(|_| ()) + } + + pub fn start_stop_document_lifecycle(&self) -> Result { + self.start_page_command(RendererPageCommand::StopDocumentLifecycle) + } + + pub fn finish_stop_document_lifecycle( + &mut self, + completion: CompletedPageCommand, + ) -> Result> { + let output = self.finish_page_command_turn(completion); + let (completion, predecessor) = output.into_completion_and_predecessor(); + let (reply, _, _) = completion.into_parts(); expect_page_reply!( reply, "stop document lifecycle page command", "a unit reply", RendererPageReply::Unit => Ok(()), - ) + )?; + Ok(predecessor) } pub async fn continue_pending_subresource_fetch_async( diff --git a/moli-core/src/renderer.rs b/moli-core/src/renderer.rs index 9e94698407..9aa4c7219d 100644 --- a/moli-core/src/renderer.rs +++ b/moli-core/src/renderer.rs @@ -20,9 +20,9 @@ pub(crate) use moli_renderer_v8::{ RendererDomFocusOutcome, RendererDomSnapshotCaptureOptions, RendererDomSnapshotCapturePayload, RendererInspectorCommandEnvelope, RendererInspectorCommandRoute, RendererInspectorIngressTicket, RendererLayoutMetrics, RendererOwnerCommand, - RendererOwnerHandle, RendererOwnerReply, RendererPageCommand, RendererPageCommandPending, - RendererPageCookieFacadeSnapshotReply, RendererPageDumpOptions, RendererPageHandle, - RendererPageReply, RendererPageState, RendererPendingDownloadActivation, + RendererOwnerHandle, RendererOwnerReply, RendererPageClientHandle, RendererPageCommand, + RendererPageCommandPending, RendererPageCookieFacadeSnapshotReply, RendererPageDumpOptions, + RendererPageHandle, RendererPageReply, RendererPageState, RendererPendingDownloadActivation, RendererPerformanceMetricSnapshot, RendererRuntimeHeapUsage, RendererRuntimeInspectorResponseSender, RendererRuntimeRemoteObjectResolution, ScriptRunOutcome, diff --git a/moli-core/src/runtime/storage_partition.rs b/moli-core/src/runtime/storage_partition.rs index 5bc1da92f8..a999d2845a 100644 --- a/moli-core/src/runtime/storage_partition.rs +++ b/moli-core/src/runtime/storage_partition.rs @@ -10,8 +10,7 @@ use moli_browser_profile::{ save_cookie_cache as save_profile_cookie_cache, }; use moli_cookie_jar::{ - BrowserCookieStore, CookieSource, SharedBrowserCookieStore, StoredCookie, - new_shared_browser_cookie_store, + CookieSource, SharedBrowserCookieStore, StoredCookie, new_shared_browser_cookie_store, }; use moli_renderer_v8::{ SharedIndexedDbManager, SharedServiceWorkerResourceStore, downgrade_indexed_db_manager, @@ -21,6 +20,7 @@ use moli_storage_service::{ new_shared_json_storage_bucket_store_with_storage_service, new_shared_storage_bucket_store_with_storage_service_and_indexed_db_manager, }; +use parking_lot::Mutex; use crate::{ network::{ @@ -47,10 +47,12 @@ pub struct StoragePartitionState { service_worker_resource_store: SharedServiceWorkerResourceStore, http_cache_root: Option, profile: Option>, + profile_flush_lock: Mutex<()>, } #[derive(Clone)] pub struct StoragePartitionSharedStorageHandles { + cookie_store: SharedBrowserCookieStore, web_storage_store: SharedWebStorageStore, indexed_db_manager: SharedIndexedDbManager, storage_bucket_store: SharedStorageBucketStore, @@ -58,6 +60,10 @@ pub struct StoragePartitionSharedStorageHandles { } impl StoragePartitionSharedStorageHandles { + pub fn cookie_store(&self) -> SharedBrowserCookieStore { + self.cookie_store.clone() + } + pub fn web_storage_store(&self) -> SharedWebStorageStore { self.web_storage_store.clone() } @@ -222,6 +228,7 @@ impl StoragePartitionState { service_worker_resource_store, http_cache_root, profile, + profile_flush_lock: Mutex::new(()), }) } @@ -255,6 +262,7 @@ impl StoragePartitionState { pub fn shared_storage_handles(&self) -> StoragePartitionSharedStorageHandles { StoragePartitionSharedStorageHandles { + cookie_store: self.cookie_store.clone(), web_storage_store: self.web_storage_store.clone(), indexed_db_manager: self.indexed_db_manager.clone(), storage_bucket_store: self.storage_bucket_store.clone(), @@ -290,25 +298,14 @@ impl StoragePartitionState { import_cookies_into_store(&self.cookie_store, cookies) } - pub fn commit_cookie_delta( - &self, - initial_cookies: &[StoredCookie], - final_cookies: Option>, - ) -> Result<()> { - let Some(final_cookies) = final_cookies else { - return Ok(()); - }; - { - let mut cookie_store = self.cookie_store.lock(); - commit_cookie_delta_to_store(&mut cookie_store, initial_cookies, final_cookies)?; - } - self.flush() - } - pub fn flush(&self) -> Result<()> { let Some(profile) = self.profile.as_ref() else { return Ok(()); }; + // Several Browser/frontend lifetimes may request a checkpoint at the + // same time. Serialize the snapshot and write at the application-owned + // partition so an older writer cannot finish after a newer checkpoint. + let _flush_guard = self.profile_flush_lock.lock(); let cookies_path = profile.default_partition().cookies_path(); save_profile_cookie_cache(cookies_path, self.cookies()?).with_context(|| { anyhow!( @@ -342,53 +339,6 @@ pub(crate) fn import_cookies_into_store( Ok(accepted) } -fn commit_cookie_delta_to_store( - cookie_store: &mut BrowserCookieStore, - initial_cookies: &[StoredCookie], - final_cookies: Vec, -) -> Result<()> { - let final_cookies = final_cookies - .into_iter() - .filter(|cookie| !cookie.is_expired()) - .collect::>(); - let current_cookies = cookie_store.cookies(); - - for initial in initial_cookies.iter().filter(|cookie| !cookie.is_expired()) { - let cookie_still_exists_in_session = final_cookies - .iter() - .any(|cookie| same_cookie_key(cookie, initial)); - if cookie_still_exists_in_session { - continue; - } - let current_still_matches_initial = current_cookies - .iter() - .any(|cookie| same_cookie_key(cookie, initial) && cookie == initial); - if current_still_matches_initial { - cookie_store.delete_cookies( - Some(initial.name.as_str()), - Some(initial.domain.as_str()), - Some(initial.path.as_str()), - None, - ); - } - } - - for cookie in final_cookies { - let report = cookie_store.upsert_with_request_url_report(cookie, None, CookieSource::Cdp); - if !report.is_accepted() { - return Err(anyhow!("failed to commit cookie into storage partition")); - } - } - Ok(()) -} - -fn same_cookie_key(left: &StoredCookie, right: &StoredCookie) -> bool { - left.name == right.name - && left.domain == right.domain - && left.path == right.path - && left.partition_key == right.partition_key -} - #[cfg(test)] mod tests { use std::{ @@ -633,76 +583,4 @@ mod tests { assert_eq!(session_store.lock().get_item(origin, "session"), None); Ok(()) } - - #[test] - fn profile_partition_commit_cookie_delta_persists_deletions() -> Result<()> { - let profile_dir = TempProfileDir::new("commit-delete"); - let paths = BrowserProfilePaths::new(&profile_dir.path); - let partition = StoragePartitionState::open(Some(&profile_dir.path))?; - let initial_cookies = vec![stored_cookie("sid", "old"), stored_cookie("theme", "dark")]; - - partition.import_cookies(initial_cookies.clone())?; - partition - .commit_cookie_delta(&initial_cookies, Some(vec![stored_cookie("theme", "dark")]))?; - - let cookies = load_profile_cookie_cache(&paths.cookies_path)?; - assert_eq!(cookies.len(), 1); - assert_eq!(cookies[0].name, "theme"); - assert_eq!(cookies[0].value, "dark"); - Ok(()) - } - - #[test] - fn profile_partition_commit_cookie_delta_uses_full_cookie_key() -> Result<()> { - let profile_dir = TempProfileDir::new("commit-cookie-key"); - let paths = BrowserProfilePaths::new(&profile_dir.path); - let partition = StoragePartitionState::open(Some(&profile_dir.path))?; - let root = scoped_cookie("sid", "root", "example.com", "/"); - let app = scoped_cookie("sid", "app", "example.com", "/app"); - let subdomain = scoped_cookie("sid", "sub", "sub.example.com", "/"); - let initial_cookies = vec![root.clone(), app.clone(), subdomain.clone()]; - - partition.import_cookies(initial_cookies.clone())?; - partition.commit_cookie_delta( - &initial_cookies, - Some(vec![ - app.clone(), - scoped_cookie("sid", "sub-new", "sub.example.com", "/"), - ]), - )?; - - let cookies = load_profile_cookie_cache(&paths.cookies_path)?; - assert!( - find_cookie(&cookies, "sid", "example.com", "/").is_none(), - "missing root-path cookie should be deleted" - ); - assert_eq!( - find_cookie(&cookies, "sid", "example.com", "/app").map(|cookie| cookie.value.as_str()), - Some("app") - ); - assert_eq!( - find_cookie(&cookies, "sid", "sub.example.com", "/") - .map(|cookie| cookie.value.as_str()), - Some("sub-new") - ); - Ok(()) - } - - #[test] - fn profile_partition_commit_cookie_delta_preserves_concurrent_update() -> Result<()> { - let profile_dir = TempProfileDir::new("commit-concurrent"); - let paths = BrowserProfilePaths::new(&profile_dir.path); - let partition = StoragePartitionState::open(Some(&profile_dir.path))?; - let initial_cookies = vec![stored_cookie("sid", "old")]; - - partition.import_cookies(initial_cookies.clone())?; - partition.import_cookies(vec![stored_cookie("sid", "newer")])?; - partition.commit_cookie_delta(&initial_cookies, Some(Vec::new()))?; - - let cookies = load_profile_cookie_cache(&paths.cookies_path)?; - assert_eq!(cookies.len(), 1); - assert_eq!(cookies[0].name, "sid"); - assert_eq!(cookies[0].value, "newer"); - Ok(()) - } } diff --git a/moli-core/tests/fetch_behaviors.rs b/moli-core/tests/fetch_behaviors.rs index 2bfb078f8d..a055dbbc7b 100644 --- a/moli-core/tests/fetch_behaviors.rs +++ b/moli-core/tests/fetch_behaviors.rs @@ -750,7 +750,7 @@ async fn fetch_with_load_waits_for_runtime_owned_async_classic_execution() -> Re } #[tokio::test(flavor = "multi_thread")] -async fn fetch_with_domcontentloaded_stops_before_fast_runtime_owned_async_classic_execution() +async fn fetch_with_domcontentloaded_observes_a_valid_fast_runtime_owned_async_classic_race() -> Result<()> { let server = FixtureServer::spawn().await?; let browser = Browser::new(AppConfig::default())?; @@ -765,30 +765,30 @@ async fn fetch_with_domcontentloaded_stops_before_fast_runtime_owned_async_class ) .await?; + let dcl_order = + diagnostic_global(&page, "runtimeOwnedAsyncFastDclOrder").and_then(JsValueSnapshot::as_str); + match dcl_order { + Some("after-append:async=true,dcl:interactive") => assert_eq!( + diagnostic_global(&page, "runtimeOwnedAsyncFastLoadOrder"), + None, + "the DCL snapshot must precede a later async-script task" + ), + Some( + "after-append:async=true,external-script:interactive,load:interactive,dcl:interactive", + ) => assert_eq!( + diagnostic_global(&page, "runtimeOwnedAsyncFastLoadOrder"), + Some(&JsValueSnapshot::String( + "after-append:async=true,external-script:interactive,load:interactive".to_owned() + )), + "a ready async-script task may finish before the queued DCL task" + ), + other => panic!("runtime-owned async classic produced an invalid DCL order: {other:?}"), + } assert_eq!( - diagnostic_global(&page, "runtimeOwnedAsyncFastDclOrder"), - Some(&JsValueSnapshot::String( - "after-append:async=true,dcl:interactive".to_owned() - )) + diagnostic_global(&page, "runtimeOwnedAsyncFastFinalOrder"), + None, + "the exact DCL snapshot must always precede Window load" ); - if let Some(load_order) = - diagnostic_global(&page, "runtimeOwnedAsyncFastLoadOrder").and_then(JsValueSnapshot::as_str) - { - assert!( - load_order - .starts_with("after-append:async=true,dcl:interactive,external-script:interactive"), - "fast runtime-owned async script overtook DOMContentLoaded: {load_order}" - ); - } - if let Some(final_order) = diagnostic_global(&page, "runtimeOwnedAsyncFastFinalOrder") - .and_then(JsValueSnapshot::as_str) - { - assert!( - final_order - .starts_with("after-append:async=true,dcl:interactive,external-script:interactive"), - "fast runtime-owned async script overtook DOMContentLoaded: {final_order}" - ); - } server.shutdown().await; Ok(()) @@ -829,7 +829,7 @@ async fn fetch_with_domcontentloaded_keeps_runtime_owned_async_script_behind_def } #[tokio::test(flavor = "multi_thread")] -async fn fetch_with_domcontentloaded_stops_before_runtime_owned_module_execution() -> Result<()> { +async fn fetch_with_domcontentloaded_observes_a_valid_runtime_owned_module_race() -> Result<()> { let server = FixtureServer::spawn().await?; let browser = Browser::new(AppConfig::default())?; @@ -843,11 +843,17 @@ async fn fetch_with_domcontentloaded_stops_before_runtime_owned_module_execution ) .await?; - assert_eq!( - diagnostic_global(&page, "runtimeOwnedAsyncModuleDclOrder"), - Some(&JsValueSnapshot::String( - "ready:loading,after-append:async=true,dcl:interactive".to_owned() - )) + let dcl_order = diagnostic_global(&page, "runtimeOwnedAsyncModuleDclOrder") + .and_then(JsValueSnapshot::as_str); + assert!( + matches!( + dcl_order, + Some("ready:loading,after-append:async=true,dcl:interactive") + | Some( + "ready:loading,after-append:async=true,module,module-microtask,dcl:interactive" + ) + ), + "runtime-owned async module produced an invalid DCL order: {dcl_order:?}" ); assert_eq!( diagnostic_global(&page, "runtimeOwnedAsyncModuleFinalOrder"), diff --git a/moli-core/tests/scripts.rs b/moli-core/tests/scripts.rs index e03d6af4b0..07672dd000 100644 --- a/moli-core/tests/scripts.rs +++ b/moli-core/tests/scripts.rs @@ -1864,7 +1864,8 @@ async fn runtime_owned_external_async_does_not_block_domcontentloaded() -> Resul } #[tokio::test(flavor = "multi_thread")] -async fn runtime_owned_external_async_fast_does_not_overtake_domcontentloaded() -> Result<()> { +async fn runtime_owned_external_async_fast_can_run_on_either_side_of_domcontentloaded() -> Result<()> +{ let server = FixtureServer::spawn().await?; let browser = Browser::new(AppConfig::default())?; @@ -1875,25 +1876,28 @@ async fn runtime_owned_external_async_fast_does_not_overtake_domcontentloaded() )) .await?; - assert_eq!( - diagnostic_global(&page, "runtimeOwnedAsyncFastDclOrder"), - Some(&JsValueSnapshot::String( - "after-append:async=true,dcl:interactive".to_owned() - )) - ); + let dcl_order = + diagnostic_global(&page, "runtimeOwnedAsyncFastDclOrder").and_then(JsValueSnapshot::as_str); + let (expected_load_order, expected_final_order) = match dcl_order { + Some("after-append:async=true,dcl:interactive") => ( + "after-append:async=true,dcl:interactive,external-script:interactive,load:interactive", + "after-append:async=true,dcl:interactive,external-script:interactive,load:interactive,window-load:complete", + ), + Some( + "after-append:async=true,external-script:interactive,load:interactive,dcl:interactive", + ) => ( + "after-append:async=true,external-script:interactive,load:interactive", + "after-append:async=true,external-script:interactive,load:interactive,dcl:interactive,window-load:complete", + ), + other => panic!("runtime-owned async classic produced an invalid DCL order: {other:?}"), + }; assert_eq!( diagnostic_global(&page, "runtimeOwnedAsyncFastLoadOrder"), - Some(&JsValueSnapshot::String( - "after-append:async=true,dcl:interactive,external-script:interactive,load:interactive" - .to_owned() - )) + Some(&JsValueSnapshot::String(expected_load_order.to_owned())) ); assert_eq!( diagnostic_global(&page, "runtimeOwnedAsyncFastFinalOrder"), - Some(&JsValueSnapshot::String( - "after-append:async=true,dcl:interactive,external-script:interactive,load:interactive,window-load:complete" - .to_owned() - )) + Some(&JsValueSnapshot::String(expected_final_order.to_owned())) ); server.shutdown().await; diff --git a/moli-fetch/src/streaming_response.rs b/moli-fetch/src/streaming_response.rs index 10abf7a395..e9ce2609dd 100644 --- a/moli-fetch/src/streaming_response.rs +++ b/moli-fetch/src/streaming_response.rs @@ -349,22 +349,19 @@ impl Drop for StreamingRawResponse { if self.completion.is_some() { self.cancel_handle.cancel(); } - // Release the exact transport owner before dropping the completion - // receiver makes stream termination observable to other tasks. + // Dropping the completion receiver can wake an owner that immediately + // tries to reap the exact transport runtime. Release that runtime's + // lease first instead of relying on the declaration-order destruction + // of this struct's fields. self.lifetime_lease = None; } } #[cfg(test)] mod tests { - use std::{ - future::Future, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - task::Poll, - }; + use std::{future::Future, sync::Arc, task::Poll}; + + use parking_lot::Mutex; use super::*; @@ -382,22 +379,6 @@ mod tests { } } - struct CompletionOrderLease { - completion_tx: Option>>, - dropped_before_completion_closed: Arc, - } - - impl Drop for CompletionOrderLease { - fn drop(&mut self) { - let completion_tx = self - .completion_tx - .as_ref() - .expect("test completion sender should remain installed"); - self.dropped_before_completion_closed - .store(!completion_tx.is_closed(), Ordering::SeqCst); - } - } - #[tokio::test] async fn cancelling_html_finish_keeps_transfer_cancellation_armed() { let (body_tx, body_rx) = mpsc::unbounded_channel(); @@ -469,27 +450,47 @@ mod tests { } #[test] - fn streaming_raw_response_drops_lifetime_lease_before_completion_receiver() { + fn dropping_raw_response_releases_lifetime_lease_before_completion_receiver() { + struct CompletionMustRemainOpenOnDrop { + completion_tx: Arc>>>>, + } + + impl Drop for CompletionMustRemainOpenOnDrop { + fn drop(&mut self) { + let completion_tx = self.completion_tx.lock(); + assert!( + !completion_tx + .as_ref() + .expect("completion sender should remain present") + .is_closed(), + "the runtime lease must be released before completion observers are woken" + ); + } + } + let (body_tx, body_rx) = mpsc::unbounded_channel(); drop(body_tx); let (completion_tx, completion_rx) = oneshot::channel(); - let dropped_before_completion_closed = Arc::new(AtomicBool::new(false)); - let response = StreamingRawResponse::new_with_head( + let completion_tx = Arc::new(Mutex::new(Some(completion_tx))); + let stream = StreamingRawResponse::new_with_head( sample_response_head(), body_rx, FetchCancelHandle::new(), completion_rx, ) - .with_lifetime_lease(CompletionOrderLease { - completion_tx: Some(completion_tx), - dropped_before_completion_closed: Arc::clone(&dropped_before_completion_closed), + .with_lifetime_lease(CompletionMustRemainOpenOnDrop { + completion_tx: Arc::clone(&completion_tx), }); - drop(response); + drop(stream); assert!( - dropped_before_completion_closed.load(Ordering::SeqCst), - "the exact transport owner must be released before completion receiver closure is observable" + completion_tx + .lock() + .as_ref() + .expect("completion sender should remain present") + .is_closed(), + "response destruction should still close its completion receiver" ); } } diff --git a/moli-page-types/src/browser_identity.rs b/moli-page-types/src/browser_identity.rs new file mode 100644 index 0000000000..78292314f1 --- /dev/null +++ b/moli-page-types/src/browser_identity.rs @@ -0,0 +1,53 @@ +use std::{ + num::NonZeroU64, + sync::atomic::{AtomicU64, Ordering}, +}; + +static NEXT_BROWSER_ACTION_ID: AtomicU64 = AtomicU64::new(1); + +/// Protocol-neutral identity of one browser action. +/// +/// Renderer intents allocate this identity before publication. Browser- and +/// frontend-originated actions use the same identity space, so diagnostics can +/// follow an action across renderer output, Browser Owner admission, request +/// replacement, and frontend projection without using a CDP command/session +/// id as authority. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct BrowserActionId(NonZeroU64); + +impl BrowserActionId { + pub fn allocate() -> Self { + let raw = NEXT_BROWSER_ACTION_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + current.checked_add(1) + }) + .unwrap_or_else(|_| panic!("browser action id exhausted")); + Self( + NonZeroU64::new(raw) + .unwrap_or_else(|| panic!("browser action id allocator returned zero")), + ) + } + + pub fn get(self) -> u64 { + self.0.get() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::size_of; + + #[test] + fn action_ids_are_nonzero_distinct_and_keep_the_option_niche() { + let first = BrowserActionId::allocate(); + let second = BrowserActionId::allocate(); + + assert_ne!(first, second); + assert_ne!(first.get(), 0); + assert_eq!( + size_of::>(), + size_of::() + ); + } +} diff --git a/moli-page-types/src/lib.rs b/moli-page-types/src/lib.rs index 9b272fccc4..8ae481ba61 100644 --- a/moli-page-types/src/lib.rs +++ b/moli-page-types/src/lib.rs @@ -4,6 +4,7 @@ //! responses, script execution reports, subresource records, and frame //! snapshots that are shared by the facade, renderer, and CDP crates. +mod browser_identity; mod inspector_identity; mod inspector_state; mod layout; @@ -44,6 +45,7 @@ use moli_web_mime::is_json_module_mime; const SUBRESOURCE_RESPONSE_BODY_MEMORY_LIMIT: usize = 1024 * 1024; static NEXT_SUBRESOURCE_RESPONSE_BODY_SPOOL_ID: AtomicU64 = AtomicU64::new(1); +pub use browser_identity::BrowserActionId; pub use inspector_identity::{ DevToolsSessionKey, FrontendCommandId, RendererAgentAttachmentId, RendererCallId, RendererCallIdOutOfRange, RendererDevToolsAgentToken, RendererDevToolsCommandId, diff --git a/moli-protocol-server/src/browser_host/execution_lane.rs b/moli-protocol-server/src/browser_host/execution_lane.rs new file mode 100644 index 0000000000..0a0081e4a4 --- /dev/null +++ b/moli-protocol-server/src/browser_host/execution_lane.rs @@ -0,0 +1,99 @@ +use moli_core::browser_host::{BrowserHostActor, BrowserHostTurnSelection}; +use moli_protocol::{ + BrowserHostTurnDispatch, BrowserHostTurnExecutorOwner, CdpRendererOwnerTurnOutcome, + CompletedBrowserHostTurn, DevToolsHostAdapter, +}; +use tokio::sync::mpsc; + +/// Application-side execution lane for Browser Host inputs and exact +/// participant completions. +/// +/// Core owns FIFO input selection. The application-owned DevTools adapter +/// temporarily supplies the remaining renderer/protocol projection. Socket +/// frontends never enter this seam: a short Core turn may register a +/// move-owned wait, and the completed value is later received as a separate +/// owner-loop input. +pub(crate) struct BrowserHostOwnerLane { + actor: BrowserHostActor, + executor_owner: BrowserHostTurnExecutorOwner, + completion_tx: mpsc::UnboundedSender, + completion_rx: mpsc::UnboundedReceiver, +} + +pub(crate) enum BrowserHostOwnerWake { + TurnSelected, + ParticipantCompleted(Box), + Closed, +} + +impl BrowserHostOwnerLane { + pub(crate) fn new(actor: BrowserHostActor) -> Self { + let (completion_tx, completion_rx) = mpsc::unbounded_channel(); + Self { + actor, + executor_owner: BrowserHostTurnExecutorOwner::for_application_owner_lane(), + completion_tx, + completion_rx, + } + } + + pub(crate) fn has_ready_input(&self) -> bool { + self.actor.has_ready_input() + } + + pub(crate) async fn recv_wake(&mut self) -> BrowserHostOwnerWake { + let actor = &mut self.actor; + let completion_rx = &mut self.completion_rx; + tokio::select! { + biased; + maybe_completed = completion_rx.recv() => { + match maybe_completed { + Some(completed) => { + BrowserHostOwnerWake::ParticipantCompleted(Box::new(completed)) + } + None => BrowserHostOwnerWake::Closed, + } + } + selection = actor.select_next_turn_when_ready() => { + match selection { + BrowserHostTurnSelection::Selected => BrowserHostOwnerWake::TurnSelected, + BrowserHostTurnSelection::Closed => BrowserHostOwnerWake::Closed, + } + } + } + } + + pub(crate) fn start_next_turn( + &mut self, + host_adapter: &mut DevToolsHostAdapter, + ) -> Option { + let dispatch = self + .executor_owner + .start_next_turn(&mut self.actor, host_adapter)?; + Some(self.register_dispatch(dispatch)) + } + + pub(crate) async fn complete_turn( + &mut self, + host_adapter: &mut DevToolsHostAdapter, + completed: CompletedBrowserHostTurn, + ) -> CdpRendererOwnerTurnOutcome { + let dispatch = self + .executor_owner + .complete_turn(host_adapter, completed) + .await; + self.register_dispatch(dispatch) + } + + fn register_dispatch(&self, dispatch: BrowserHostTurnDispatch) -> CdpRendererOwnerTurnOutcome { + let (outcome, pending) = dispatch.into_parts(); + if let Some(pending) = pending { + let completion_tx = self.completion_tx.clone(); + tokio::task::spawn_local(async move { + let completed = pending.wait().await; + let _ = completion_tx.send(completed); + }); + } + outcome + } +} diff --git a/moli-protocol-server/src/browser_host/mod.rs b/moli-protocol-server/src/browser_host/mod.rs new file mode 100644 index 0000000000..b3e845e77d --- /dev/null +++ b/moli-protocol-server/src/browser_host/mod.rs @@ -0,0 +1,3 @@ +mod execution_lane; + +pub(crate) use execution_lane::{BrowserHostOwnerLane, BrowserHostOwnerWake}; diff --git a/moli-protocol-server/src/cdp_scheduler.rs b/moli-protocol-server/src/cdp_scheduler.rs index df16133d51..9155863c1b 100644 --- a/moli-protocol-server/src/cdp_scheduler.rs +++ b/moli-protocol-server/src/cdp_scheduler.rs @@ -1,26 +1,30 @@ use std::{ collections::{HashMap, VecDeque}, - sync::{Arc, atomic::AtomicU64}, time::{Duration, Instant}, }; -use moli_cookie_jar::StoredCookie; use moli_core::page::RendererVisualStateToken; use moli_core::{ - RendererOutputTransportMessage, page::RendererDocumentLifecycleMilestone, - runtime::NavigationRuntimeConfig, + RendererOutputTransportMessage, + browser_host::{ + BrowserFactSequence, BrowserFactWakeSubscriber, BrowserHostActor, BrowserHostState, + BrowserTargetIdAllocator, + }, + page::RendererDocumentLifecycleMilestone, + runtime::{NavigationEngine, NavigationRuntimeConfig}, }; use moli_protocol::{ - BackgroundNavigationCompletion, BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection, - CdpInitialStoragePartition, CdpRendererCommandAccess, CdpSchedulerEvent, - CdpTargetHostLifecycleObserver, CommandDispatchContext, CompletedCdpCommandDispatch, + BackgroundNavigationCancellation, BackgroundNavigationCompletion, BackgroundNavigationGateKey, + BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection, CdpInitialStoragePartition, + CdpRendererCommandAccess, CdpSchedulerEvent, CdpTargetHostLifecycleObserver, + CommandDispatchContext, CompletedCdpCommandDispatch, CompletedDeferredMainDocumentLoadCompletion, DeferredMainDocumentLoadCompletionOutputAction, DeferredMainDocumentLoadCompletionOutputInterest, DeferredMainDocumentLoadObservationId, - DeferredMainDocumentLoadPredecessorCandidate, DevToolsPageResidenceIdentity, - PageScreencastCaptureCompletion, PageScreencastCaptureStart, PageScreencastRegistration, - PageScreencastSubscriptionStatus, ParsedCdpCommand, PendingCdpCommandDispatch, - PendingDeferredMainDocumentLoadCompletion, PendingPageScreencastCapture, - ProtocolSchedulerWorkKind, RuntimeCommandOutputBarriers, + DeferredMainDocumentLoadPredecessorCandidate, DevToolsHostAdapter, + DevToolsPageResidenceIdentity, PageScreencastCaptureCompletion, PageScreencastCaptureStart, + PageScreencastRegistration, PageScreencastSubscriptionStatus, ParsedCdpCommand, + PendingCdpCommandDispatch, PendingDeferredMainDocumentLoadCompletion, + PendingPageScreencastCapture, ProtocolSchedulerWorkKind, RuntimeCommandOutputBarriers, conn::{RuntimeInspectorResponseReady, RuntimeInspectorResponseReadySender}, devtools_runtime::{ DevToolsCommand, DevToolsCommandResult, DevToolsError, DevToolsNavigationWait, @@ -35,7 +39,11 @@ const PAGE_SCREENCAST_RETRY_INTERVAL: Duration = Duration::from_secs(1); mod actor; mod adapter_scheduler; mod command_dispatch; +mod context_disposal_dispatch; +mod fetch_dispatch; mod frontend_control; +mod navigation_dispatch; +mod owner_inputs; mod protocol_residence; mod runtime_command_barrier; mod runtime_dispatch; @@ -47,7 +55,13 @@ pub(crate) use adapter_scheduler::{ pub(crate) use command_dispatch::{ CommandDispatchState, CommandDispatchStepOutput, CommandTurnOutput, }; -pub(crate) use frontend_control::{CdpCookieSnapshot, CdpOwnerActorLifecycle}; +pub(crate) use frontend_control::CdpOwnerActorLifecycle; +pub(crate) use owner_inputs::{ + BrowserHostExecutionLane, BrowserHostExecutionWake, CdpBackgroundEventReceiver, + CdpBackgroundNavigationCompletionReceiver, CdpRendererPublicationReceiver, + CdpSchedulerEventReceivers, CdpSchedulerInterleavedInput, NavigationRendererPublicationBuffer, + RendererTransportSource, +}; use protocol_residence::{ ClientTurnPredecessor, ProtocolSchedulerResidence, ProtocolSchedulerStep, SchedulerQueues, }; @@ -57,28 +71,29 @@ pub(crate) use runtime_dispatch::{ }; pub(crate) struct CdpTargetHostIntegration { - target_id_allocator: Arc, - tab_target_id_allocator: Arc, + target_id_allocator: BrowserTargetIdAllocator, lifecycle_observer: CdpTargetHostLifecycleObserver, } impl CdpTargetHostIntegration { pub(crate) fn new( - target_id_allocator: Arc, - tab_target_id_allocator: Arc, + target_id_allocator: BrowserTargetIdAllocator, lifecycle_observer: CdpTargetHostLifecycleObserver, ) -> Self { Self { target_id_allocator, - tab_target_id_allocator, lifecycle_observer, } } - fn install(self, conn: &mut CdpConnection) { - conn.set_shared_target_id_allocator(self.target_id_allocator); - conn.set_shared_tab_target_id_allocator(self.tab_target_id_allocator); - conn.set_target_host_lifecycle_observer(self.lifecycle_observer); + fn target_id_allocator(&self) -> BrowserTargetIdAllocator { + self.target_id_allocator.clone() + } + + fn install(self, host_adapter: &mut DevToolsHostAdapter) { + host_adapter + .control() + .set_target_host_lifecycle_observer(self.lifecycle_observer); } } @@ -110,9 +125,18 @@ pub(crate) enum CommandStartAction { }, } +/// Application scheduler for one persistent DevTools Host adapter. +/// +/// Despite the historical name, this value is not a per-WebSocket frontend. +/// Frontend endpoints attach to the surrounding owner task through separate +/// queues and routing state. pub(crate) struct CdpScheduler { - conn: CdpConnection, - pending_navigation_background_events: VecDeque, + /// Owner-task-resident renderer/DevTools adapter. This is not a socket + /// frontend connection; raw CDP frontends attach through + /// `CdpFrontendEndpoint` and `CdpFrontendRouter`. + host_adapter: DevToolsHostAdapter, + background_navigation_gate: BackgroundNavigationGate, + pending_navigation_background_events: VecDeque, runtime_command_output_barriers: RuntimeCommandOutputBarriers, queues: SchedulerQueues, page_screencasts: HashMap, PageScreencastSchedule>, @@ -123,17 +147,12 @@ impl CdpScheduler { &mut self, context: &moli_protocol::devtools_runtime::DevToolsCommandContext, ) -> Option { - self.conn + self.host_adapter + .commands() .page_residence_identity_for_devtools_context(context) } } -#[derive(Debug)] -struct PendingNavigationBackgroundEvent { - target_id: Option, - event: BackgroundProtocolEvent, -} - #[derive(Debug)] struct PageScreencastSchedule { registration: PageScreencastRegistration, @@ -158,10 +177,53 @@ fn next_page_screencast_deadline(now: TokioInstant, interval: Duration) -> Tokio now + interval } -fn append_unique_target_ids(target_ids: &mut Vec, additional: Vec) { - for target_id in additional { - if !target_ids.contains(&target_id) { - target_ids.push(target_id); +#[derive(Debug, Default)] +struct BackgroundNavigationGate { + /// `Page.navigate` background tasks whose lifecycle + /// `BackgroundNavigationCompletion` has not yet been drained. The command + /// response may be produced independently; this gate does not claim where + /// that response falls relative to response-head or body EOF. + pending: HashMap, +} + +impl BackgroundNavigationGate { + fn has_inflight_navigation(&self) -> bool { + !self.pending.is_empty() + } + + fn note_navigation_started( + &mut self, + key: BackgroundNavigationGateKey, + cancellation: BackgroundNavigationCancellation, + ) { + if self.pending.contains_key(&key) { + return; + } + let pending_before = self.pending.len(); + self.pending.retain(|pending, pending_cancellation| { + if !key.supersedes(pending) { + return true; + } + pending_cancellation.cancel(); + false + }); + let superseded = pending_before - self.pending.len(); + if superseded > 0 { + tracing::debug!( + superseded, + ?key, + "retired superseded background navigation gate" + ); + } + self.pending.insert(key, cancellation); + } + + fn note_navigation_completion_drained(&mut self, key: &BackgroundNavigationGateKey) { + if self.pending.remove(key).is_none() { + tracing::debug!( + ?key, + "background navigation completion did not match any pending gate" + ); } } } @@ -217,68 +279,24 @@ impl ForegroundNavigationNetworkBarrier { } } -pub(crate) type CdpBackgroundEventReceiver = mpsc::UnboundedReceiver; -pub(crate) type CdpBackgroundNavigationCompletionReceiver = - 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) renderer_publication_rx: CdpRendererPublicationReceiver, -} - -/// One move-owned scheduler input selected from the independent producer -/// channels used by direct CDP/WebDriver command execution. -/// -/// Receiving an input and applying it are deliberately separate operations. -/// Several command waits race input readiness against a renderer reply or a -/// deadline. If such a race also awaited projection inside the selected -/// future, a later-ready reply could cancel that future after `recv()` had -/// removed a concrete publication from its channel. Keeping the selected -/// value in the caller makes ownership unambiguous: once dequeued, the input -/// is completed before the command wait selects again. -pub(crate) enum CdpSchedulerInterleavedInput { - BackgroundNavigationCompletion(BackgroundNavigationCompletion), - BackgroundEvent(BackgroundProtocolEvent), - RendererPublication(RendererOutputTransportMessage), -} - -impl CdpSchedulerEventReceivers { - pub(crate) async fn recv_interleaved_input(&mut self) -> Option { - tokio::select! { - biased; - maybe_completion = self.background_navigation_completion_rx.recv() => { - maybe_completion.map( - CdpSchedulerInterleavedInput::BackgroundNavigationCompletion, - ) - } - maybe_event = self.background_event_rx.recv() => { - maybe_event.map(CdpSchedulerInterleavedInput::BackgroundEvent) - } - maybe_publication = self.renderer_publication_rx.recv() => { - maybe_publication.map(CdpSchedulerInterleavedInput::RendererPublication) - } - } - } -} - #[derive(Debug, Default)] pub(crate) struct ProtocolOutputSequence { events: Vec, } -/// Terminal renderer transport together with the concrete protocol prefix -/// already projected before that boundary. +/// Terminal application-scheduler progress together with the concrete +/// protocol prefix already projected before that boundary. /// /// Direct CDP/WebDriver paths buffer their protocol output until the command -/// boundary. Dropping that prefix while returning the terminal error would -/// violate the same notification-before-response rule enforced by the actor. -pub(crate) struct RendererOutputTransportFailure { +/// boundary. Dropping that prefix while returning a renderer/fact ingress +/// error would violate the same notification-before-response rule enforced by +/// the actor. +pub(crate) struct CdpSchedulerProgressFailure { protocol_output: ProtocolOutputSequence, error: DevToolsError, } -impl RendererOutputTransportFailure { +impl CdpSchedulerProgressFailure { fn new(protocol_output: ProtocolOutputSequence, error: DevToolsError) -> Self { Self { protocol_output, @@ -324,7 +342,7 @@ impl ProtocolOutputSequence { self.events.is_empty() } - fn contains_document_load_for_since( + fn contains_document_lifecycle_for_since( &self, key: &moli_protocol::DevToolsDocumentLifecycleWaitKey, start_index: usize, @@ -332,7 +350,7 @@ impl ProtocolOutputSequence { self.events .iter() .skip(start_index) - .any(|event| event.matches_document_load_wait_key(key)) + .any(|event| event.matches_document_lifecycle_wait_key(key)) } fn contains_download_start_for_frame_since(&self, frame_id: &str, start_index: usize) -> bool { @@ -347,23 +365,6 @@ impl ProtocolOutputSequence { self.events.append(&mut other.events); } - fn navigation_gate_target_ids(&self, conn: &CdpConnection) -> Vec { - let mut target_ids = Vec::new(); - for event in &self.events { - let Some(target_id) = conn.background_navigation_target_id_for_event(event) else { - // A publication is released atomically. If even one event has - // no exact owner, keep the whole batch behind the conservative - // connection-wide gate rather than attributing it to a known - // sibling event's target. - return Vec::new(); - }; - if !target_ids.contains(&target_id) { - target_ids.push(target_id); - } - } - target_ids - } - fn split_network_observations(self) -> (Self, Self) { let mut network = Vec::new(); let mut remaining = Vec::new(); @@ -472,14 +473,15 @@ pub(crate) fn drain_pending_background_events( impl CdpScheduler { pub(crate) fn has_pending_javascript_dialog(&self) -> bool { - self.conn.has_pending_javascript_dialog() + self.host_adapter.view().has_pending_javascript_dialog() } pub(crate) fn set_automation_javascript_dialog_handler_enabled( &mut self, enabled: bool, ) -> bool { - self.conn + self.host_adapter + .control() .set_automation_javascript_dialog_handler_enabled(enabled) } @@ -487,7 +489,8 @@ impl CdpScheduler { &mut self, sender: RuntimeInspectorResponseReadySender, ) { - self.conn + self.host_adapter + .control() .set_runtime_inspector_response_ready_sender(sender); } @@ -533,7 +536,11 @@ impl CdpScheduler { .collect::>(); let mut next_deadline = None; for (session_id, registration, deadline) in schedules { - match self.conn.page_screencast_subscription_status(®istration) { + match self + .host_adapter + .projection() + .page_screencast_subscription_status(®istration) + { PageScreencastSubscriptionStatus::Inactive => { if self.page_screencast_schedule_matches(&session_id, registration.generation()) { @@ -571,7 +578,11 @@ impl CdpScheduler { .collect::>(); let mut pending = Vec::with_capacity(due.len()); for (session_id, registration, known_visual_state) in due { - match self.conn.page_screencast_subscription_status(®istration) { + match self + .host_adapter + .projection() + .page_screencast_subscription_status(®istration) + { PageScreencastSubscriptionStatus::Inactive => { if self.page_screencast_schedule_matches(&session_id, registration.generation()) { @@ -582,7 +593,8 @@ impl CdpScheduler { | PageScreencastSubscriptionStatus::AwaitingAck => {} PageScreencastSubscriptionStatus::Ready => { match self - .conn + .host_adapter + .projection() .start_page_screencast_frame_capture(®istration, known_visual_state) { PageScreencastCaptureStart::Pending(capture) => pending.push(capture), @@ -618,7 +630,10 @@ impl CdpScheduler { ) -> Option { let session_id = completed.session_id().map(str::to_owned); let generation = completed.generation(); - let completion = self.conn.complete_page_screencast_frame_capture(completed); + let completion = self + .host_adapter + .projection() + .complete_page_screencast_frame_capture(completed); if !self.page_screencast_schedule_matches(&session_id, generation) { return None; } @@ -677,11 +692,13 @@ impl CdpScheduler { ) -> ProtocolOutputSequence { let mut response_events = Vec::new(); let mut background_events = Vec::new(); - self.conn.route_registered_runtime_inspector_response_into( - response, - &mut response_events, - &mut background_events, - ); + self.host_adapter + .projection() + .route_registered_runtime_inspector_response_into( + response, + &mut response_events, + &mut background_events, + ); let mut output = ProtocolOutputSequence::from_background_events(background_events); output.append(ProtocolOutputSequence::from_background_events( response_events, @@ -689,14 +706,31 @@ impl CdpScheduler { output } - fn new(conn: CdpConnection) -> Self { - Self { - conn, - pending_navigation_background_events: VecDeque::new(), - runtime_command_output_barriers: RuntimeCommandOutputBarriers::default(), - queues: SchedulerQueues::default(), - page_screencasts: HashMap::new(), - } + #[cfg(test)] + fn new(conn: CdpConnection) -> (Self, BrowserHostActor, BrowserFactWakeSubscriber) { + let browser_host_state = conn.browser_host_state(); + Self::new_with_browser_host_state(conn, browser_host_state) + } + + fn new_with_browser_host_state( + mut conn: CdpConnection, + browser_host_state: BrowserHostState, + ) -> (Self, BrowserHostActor, BrowserFactWakeSubscriber) { + let (browser_host_actor, browser_host_handle) = BrowserHostActor::new(browser_host_state); + let browser_fact_wake = conn.subscribe_browser_fact_wake(); + conn.install_browser_host_handle(browser_host_handle); + ( + Self { + host_adapter: DevToolsHostAdapter::for_application_owner(conn), + background_navigation_gate: BackgroundNavigationGate::default(), + pending_navigation_background_events: VecDeque::new(), + runtime_command_output_barriers: RuntimeCommandOutputBarriers::default(), + queues: SchedulerQueues::default(), + page_screencasts: HashMap::new(), + }, + browser_host_actor, + browser_fact_wake, + ) } pub(crate) fn new_with_initial_state_runtime_config( @@ -715,37 +749,58 @@ impl CdpScheduler { navigation_runtime_config: NavigationRuntimeConfig, target_host_integration: Option, ) -> (Self, CdpSchedulerEventReceivers) { - let mut scheduler = Self::new( - CdpConnection::new_with_initial_storage_partition_and_runtime_config( - initial_storage_partition, - navigation_runtime_config, - ), + let target_id_allocator = target_host_integration + .as_ref() + .map(CdpTargetHostIntegration::target_id_allocator) + .unwrap_or_default(); + let browser_host_state = BrowserHostState::new_with_target_id_allocator( + NavigationEngine::new_with_runtime_config(navigation_runtime_config), + target_id_allocator, ); + let conn = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state.clone(), + initial_storage_partition, + ); + let (mut scheduler, browser_host_actor, browser_fact_wake_rx) = + Self::new_with_browser_host_state(conn, browser_host_state); if let Some(target_host_integration) = target_host_integration { - target_host_integration.install(&mut scheduler.conn); + target_host_integration.install(&mut scheduler.host_adapter); } - scheduler.conn.install_default_browser_target(); - scheduler.conn.enable_default_target_on_auto_attach(); - let (background_event_tx, background_event_rx) = mpsc::unbounded_channel(); scheduler - .conn + .host_adapter + .control() + .install_default_browser_target(); + scheduler + .host_adapter + .control() + .enable_default_target_on_auto_attach(); + let (background_event_tx, background_event_rx) = + moli_protocol::browser_background_output_channel(); + scheduler + .host_adapter + .control() .set_background_event_sender(background_event_tx); let (background_navigation_completion_tx, background_navigation_completion_rx) = mpsc::unbounded_channel(); scheduler - .conn + .host_adapter + .control() .set_background_navigation_completion_sender(background_navigation_completion_tx); let (renderer_publication_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); scheduler - .conn + .host_adapter + .control() .set_renderer_publication_sender(renderer_publication_tx); ( scheduler, CdpSchedulerEventReceivers { + browser_host: BrowserHostExecutionLane::new(browser_host_actor), + browser_fact_wake_rx, background_event_rx, background_navigation_completion_rx, renderer_publication_rx, + navigation_renderer_publications: NavigationRendererPublicationBuffer::default(), }, ) } @@ -773,22 +828,27 @@ impl CdpScheduler { CommandOutputReleasePermit, CommandDispatchContext, ) { - let (response_flush_permit, response_flush_context) = - self.conn.begin_command_response_flush_permit(); + let (response_flush_permit, response_flush_context) = self + .host_adapter + .projection() + .begin_command_response_flush_permit(); let mut command_context = CommandDispatchContext::new(response_flush_context); let dispatch_step = self - .conn + .host_adapter + .commands() .start_parsed_command_dispatch_with_context(command, &mut command_context); // Dispatch registers a session-local renderer call id before the // renderer task can be observed by this actor. The response barrier // must use that exact id rather than infer one from the frontend CDP // request id. let runtime_output_barrier = if command.runtime_command_executes_page_javascript() { - self.runtime_command_output_barriers.admit( - &self.conn, - command.request().id(), - command.command_output_session_id(), - ) + self.host_adapter + .projection() + .admit_runtime_command_output_barrier( + &mut self.runtime_command_output_barriers, + command.request().id(), + command.command_output_session_id(), + ) } else { None }; @@ -837,7 +897,8 @@ impl CdpScheduler { command_context: &mut CommandDispatchContext, ) -> CommandTaskStep { match self - .conn + .host_adapter + .commands() .complete_pending_command_dispatch_with_context(completed, command_context) .await { @@ -876,10 +937,6 @@ impl CdpScheduler { } } - pub(crate) fn snapshot_profile_backed_cookies(&mut self) -> Option> { - self.conn.snapshot_profile_backed_cookies() - } - pub(crate) async fn execute_devtools_command_with_protocol_messages( &mut self, command: DevToolsCommand, @@ -896,20 +953,44 @@ impl CdpScheduler { background_command_id: Option, ) -> DevToolsCommandExecution { let navigation_wait = devtools_navigation_wait(&command); - let navigation_context = command.context().clone(); let outcome = self - .conn + .host_adapter + .commands() .execute_devtools_command_with_protocol_events_with_background_command_id( command, background_command_id, ) .await; - let (mut result, scheduler_events, protocol_events, renderer_output_predecessor) = - outcome.into_complete_parts(); + self.finish_devtools_command_dispatch_outcome_with_protocol_messages( + receivers, + navigation_wait, + outcome, + drain_load_completion, + ) + .await + } + + async fn finish_devtools_command_dispatch_outcome_with_protocol_messages( + &mut self, + receivers: Option<&mut CdpSchedulerEventReceivers>, + navigation_wait: Option, + outcome: moli_protocol::conn::DevToolsCommandDispatchOutcome, + drain_load_completion: bool, + ) -> DevToolsCommandExecution { + let ( + mut result, + scheduler_events, + protocol_events, + renderer_output_boundary, + post_renderer_output_events, + post_response_events, + renderer_output_predecessor, + ) = outcome.into_fenced_complete_parts(); + let mut receivers = receivers; self.apply_scheduler_events(scheduler_events); let mut protocol_output = ProtocolOutputSequence::empty(); if let Some(predecessor) = renderer_output_predecessor { - if let Some(receivers) = receivers { + if let Some(receivers) = receivers.as_deref_mut() { match self .project_renderer_output_predecessor_before_devtools_result( receivers, @@ -925,7 +1006,8 @@ impl CdpScheduler { } } } else if self - .conn + .host_adapter + .view() .renderer_output_cursor_is_projected(predecessor.cursor()) { protocol_output.append( @@ -941,12 +1023,48 @@ impl CdpScheduler { } protocol_output .append(self.route_background_events_around_inflight_navigation(protocol_events)); + if let Some(boundary) = renderer_output_boundary { + if let Some(receivers) = receivers { + match self + .project_navigation_renderer_output_boundary_before_devtools_result( + receivers, &boundary, + ) + .await + { + Ok(output) => protocol_output.append(output), + Err(failure) => { + let (output, error) = failure.into_parts(); + protocol_output.append(output); + result = Err(error); + } + } + } else if self + .host_adapter + .view() + .renderer_output_cursor_is_projected(boundary.cursor()) + { + protocol_output.append( + self.complete_renderer_output_predecessor_before_runtime_response(&boundary) + .await, + ); + } else { + result = Err(DevToolsError::new( + moli_protocol::devtools_runtime::DevToolsErrorKind::Internal, + "DevTools command produced a renderer insertion boundary without an ingress receiver", + )); + } + } + protocol_output.append( + self.route_background_events_around_inflight_navigation(post_renderer_output_events), + ); + protocol_output + .append(self.route_background_events_around_inflight_navigation(post_response_events)); if drain_load_completion && result.is_ok() && matches!(navigation_wait, Some(DevToolsNavigationWait::Load)) { protocol_output.append( - self.drain_deferred_main_document_load_completion_for_wait(&navigation_context) + self.drain_deferred_main_document_load_completion_for_wait() .await, ); } @@ -960,9 +1078,10 @@ impl CdpScheduler { &mut self, receivers: &mut CdpSchedulerEventReceivers, message: Value, - ) -> Result { + ) -> Result { let outcome = self - .conn + .host_adapter + .commands() .process_message_with_turn_outcome_async(&message.to_string()) .await; self.apply_renderer_owner_turn_outcome(receivers, outcome) @@ -970,18 +1089,23 @@ impl CdpScheduler { } pub(crate) fn enable_network_listener_for_target(&mut self, target_id: &str) -> bool { - self.conn.enable_network_listener_for_target(target_id) + self.host_adapter + .control() + .enable_network_listener_for_target(target_id) } pub(crate) fn disable_network_listener_for_target(&mut self, target_id: &str) -> bool { - self.conn.disable_network_listener_for_target(target_id) + self.host_adapter + .control() + .disable_network_listener_for_target(target_id) } pub(crate) fn enable_file_dialog_opened_listener_for_target( &mut self, target_id: &str, ) -> bool { - self.conn + self.host_adapter + .control() .enable_file_dialog_opened_listener_for_target(target_id) } @@ -989,29 +1113,49 @@ impl CdpScheduler { &mut self, target_id: &str, ) -> bool { - self.conn + self.host_adapter + .control() .disable_file_dialog_opened_listener_for_target(target_id) } pub(crate) fn enable_webdriver_bidi_download_events(&mut self) -> bool { - self.conn.enable_webdriver_bidi_download_events() + self.host_adapter + .control() + .enable_webdriver_bidi_download_events() } pub(crate) fn disable_webdriver_bidi_download_events(&mut self) -> bool { - self.conn.disable_webdriver_bidi_download_events() + self.host_adapter + .control() + .disable_webdriver_bidi_download_events() + } + + pub(crate) fn enable_webdriver_bidi_target_lifecycle_projection(&mut self) -> bool { + self.host_adapter + .control() + .enable_webdriver_bidi_target_lifecycle_projection() + } + + pub(crate) fn disable_webdriver_bidi_target_lifecycle_projection(&mut self) -> bool { + self.host_adapter + .control() + .disable_webdriver_bidi_target_lifecycle_projection() } pub(crate) fn worker_target_id_for_session(&self, session_id: Option<&str>) -> Option { - self.conn.worker_target_id_for_session(session_id) + self.host_adapter + .view() + .worker_target_id_for_session(session_id) } pub(crate) async fn enable_runtime_listener_for_target( &mut self, receivers: &mut CdpSchedulerEventReceivers, target_id: &str, - ) -> Result { + ) -> Result { let Some(outcome) = self - .conn + .host_adapter + .control() .enable_runtime_listener_for_target(target_id) .await else { @@ -1025,9 +1169,10 @@ impl CdpScheduler { &mut self, receivers: &mut CdpSchedulerEventReceivers, target_id: &str, - ) -> Result { + ) -> Result { let Some(outcome) = self - .conn + .host_adapter + .control() .disable_runtime_listener_for_target(target_id) .await else { @@ -1038,19 +1183,9 @@ impl CdpScheduler { } pub(crate) fn replace_target_discovery_enabled(&mut self, enabled: bool) -> bool { - self.conn.replace_root_target_discovery_enabled(enabled) - } - - pub(crate) async fn execute_devtools_command_with_external_load_wait( - &mut self, - receivers: &mut CdpSchedulerEventReceivers, - command: DevToolsCommand, - ) -> Result { - self.execute_devtools_command_with_external_load_wait_and_protocol_messages( - receivers, command, - ) - .await - .result + self.host_adapter + .control() + .replace_root_target_discovery_enabled(enabled) } pub(crate) async fn execute_devtools_command_with_external_load_wait_and_protocol_messages( @@ -1113,15 +1248,13 @@ impl CdpScheduler { let navigation_context = command.context().clone(); let validate_root_document_lifecycle = navigation_lifecycle_milestone.is_some() && self - .conn + .host_adapter + .view() .devtools_context_routes_to_top_level_target(&navigation_context); let mut foreground_navigation_network_barrier = ForegroundNavigationNetworkBarrier::for_navigation_wait(navigation_wait); let mut protocol_output = match self - .drain_inflight_background_navigation_before_internal_command( - receivers, - &navigation_context, - ) + .drain_inflight_background_navigation_before_internal_command(receivers) .await { Ok(output) => output, @@ -1154,57 +1287,76 @@ impl CdpScheduler { }; } let navigation_command_output_start = protocol_output.len(); - let mut execution = - if runtime_dispatch::devtools_command_uses_interleaved_runtime_dispatch(&command) { - match timeout { - Some(timeout) => { - self.execute_devtools_runtime_command_with_interleaved_progress_timeout( - receivers, command, timeout, - ) - .await - } - None => { - self.execute_devtools_runtime_command_with_interleaved_progress( - receivers, command, - ) - .await - } + let mut execution = if runtime_dispatch::devtools_command_uses_interleaved_runtime_dispatch( + &command, + ) { + match timeout { + Some(timeout) => { + self.execute_devtools_runtime_command_with_interleaved_progress_timeout( + receivers, command, timeout, + ) + .await } - } else { - match timeout { - Some(timeout) => { - match tokio::time::timeout( - timeout, - self.execute_devtools_command_with_protocol_messages_inner( - Some(&mut *receivers), - command, - false, - background_command_id, - ), - ) - .await - { - Ok(execution) => execution, - Err(_) => DevToolsCommandExecution { - result: Err(DevToolsError::new( - moli_protocol::devtools_runtime::DevToolsErrorKind::Timeout, - "script timed out", - )), - protocol_output: ProtocolOutputSequence::empty(), - }, - } - } - None => { + None => { + self.execute_devtools_runtime_command_with_interleaved_progress( + receivers, command, + ) + .await + } + } + } else if context_disposal_dispatch::devtools_command_uses_browser_owner_context_disposal( + &command, + ) { + self.execute_devtools_browser_owner_context_disposal_with_interleaved_progress( + receivers, command, timeout, + ) + .await + } else if fetch_dispatch::devtools_command_uses_interleaved_fetch_dispatch(&command) { + self.execute_devtools_fetch_with_interleaved_progress(receivers, command, timeout) + .await + } else if navigation_dispatch::devtools_command_uses_browser_owner_dispatch(&command) { + self.execute_devtools_browser_owner_navigation_with_interleaved_progress( + receivers, + command, + timeout, + background_command_id, + ) + .await + } else { + match timeout { + Some(timeout) => { + match tokio::time::timeout( + timeout, self.execute_devtools_command_with_protocol_messages_inner( Some(&mut *receivers), command, false, background_command_id, - ) - .await + ), + ) + .await + { + Ok(execution) => execution, + Err(_) => DevToolsCommandExecution { + result: Err(DevToolsError::new( + moli_protocol::devtools_runtime::DevToolsErrorKind::Timeout, + "script timed out", + )), + protocol_output: ProtocolOutputSequence::empty(), + }, } } - }; + None => { + self.execute_devtools_command_with_protocol_messages_inner( + Some(&mut *receivers), + command, + false, + background_command_id, + ) + .await + } + } + }; let expected_document_loader_id = devtools_navigation_result_loader_id(&execution.result); let mut document_lifecycle_wait_key = if validate_root_document_lifecycle && execution.result.is_ok() { @@ -1212,11 +1364,13 @@ impl CdpScheduler { .as_deref() .zip(navigation_lifecycle_milestone) .and_then(|(loader_id, milestone)| { - self.conn.capture_devtools_document_lifecycle_wait_key( - &navigation_context, - loader_id, - milestone, - ) + self.host_adapter + .commands() + .capture_devtools_document_lifecycle_wait_key( + &navigation_context, + loader_id, + milestone, + ) }) } else { None @@ -1264,22 +1418,28 @@ impl CdpScheduler { .as_deref() .zip(navigation_lifecycle_milestone) .and_then(|(loader_id, milestone)| { - self.conn.capture_devtools_document_lifecycle_wait_key( - &navigation_context, - loader_id, - milestone, - ) + self.host_adapter + .commands() + .capture_devtools_document_lifecycle_wait_key( + &navigation_context, + loader_id, + milestone, + ) }); } + // Browser Host completion and the renderer lifecycle publication are + // independent inputs. Either can win the scheduler select, so both + // DCL and load waits must observe the exact Document key instead of + // assuming that draining load work also dequeued its renderer fact. if execution.result.is_ok() - && matches!( - navigation_lifecycle_milestone, - Some(RendererDocumentLifecycleMilestone::DomContentLoaded) - ) + && navigation_lifecycle_milestone.is_some() && let Some(key) = document_lifecycle_wait_key.as_ref() + && !execution + .protocol_output + .contains_document_lifecycle_for_since(key, navigation_command_output_start) { let output = self - .wait_for_document_lifecycle_observer(receivers, &navigation_context, key) + .wait_for_document_lifecycle_observer(receivers, key) .await; let output = match output { Ok(output) => output, @@ -1312,29 +1472,25 @@ impl CdpScheduler { ) }); let observed_lifecycle_protocol_event = - matches!( - navigation_lifecycle_milestone, - Some(RendererDocumentLifecycleMilestone::Load) - ) && document_lifecycle_wait_key.as_ref().is_some_and(|key| { + document_lifecycle_wait_key.as_ref().is_some_and(|key| { execution .protocol_output - .contains_document_load_for_since(key, navigation_command_output_start) + .contains_document_lifecycle_for_since(key, navigation_command_output_start) }); // A pre-commit navigation can legitimately have no renderer key yet // (for example while an auth challenge owns the response). Its // background command response remains the completion authority. if !observed_download && !observed_lifecycle_protocol_event { if let Some(key) = document_lifecycle_wait_key.as_ref() { - let wait_state = self - .conn - .devtools_document_lifecycle_wait_state(&navigation_context, key); + let wait_state = key.state(); if let Some(error) = devtools_document_lifecycle_wait_error(wait_state, key.milestone()) { execution.result = Err(error); } } else if !self - .conn + .host_adapter + .view() .devtools_context_routes_to_top_level_target(&navigation_context) { execution.result = Err(DevToolsError::new( @@ -1343,10 +1499,6 @@ impl CdpScheduler { )); } } - if let Some(key) = document_lifecycle_wait_key.as_ref() { - self.conn - .release_devtools_document_lifecycle_wait_key(&navigation_context, key); - } } execution .protocol_output @@ -1360,15 +1512,11 @@ impl CdpScheduler { async fn drain_inflight_background_navigation_before_internal_command( &mut self, receivers: &mut CdpSchedulerEventReceivers, - context: &moli_protocol::devtools_runtime::DevToolsCommandContext, - ) -> Result { + ) -> Result { let mut out = ProtocolOutputSequence::empty(); - while self - .conn - .has_inflight_background_navigation_for_devtools_context(context) - { - let Some(input) = receivers.recv_interleaved_input().await else { - return Err(RendererOutputTransportFailure::new( + while self.has_inflight_background_navigation() { + let Some(input) = receivers.recv_interleaved_input(true).await else { + return Err(CdpSchedulerProgressFailure::new( out, renderer_output_transport_terminal_error( &receivers.renderer_publication_rx, @@ -1387,17 +1535,30 @@ impl CdpScheduler { async fn wait_for_document_lifecycle_observer( &mut self, receivers: &mut CdpSchedulerEventReceivers, - context: &moli_protocol::devtools_runtime::DevToolsCommandContext, key: &moli_protocol::DevToolsDocumentLifecycleWaitKey, - ) -> Result { + ) -> Result { let mut out = ProtocolOutputSequence::empty(); - while self - .conn - .devtools_document_lifecycle_wait_state(context, key) - == moli_protocol::DevToolsDocumentLifecycleWaitState::Pending - { - let Some(input) = receivers.recv_interleaved_input().await else { - return Err(RendererOutputTransportFailure::new( + loop { + out.append( + self.complete_ready_owner_and_protocol_residences_for_external_load_wait(receivers) + .await?, + ); + if out.contains_document_lifecycle_for_since(key, 0) { + return Ok(out); + } + let wait_state = key.state(); + match wait_state { + moli_protocol::DevToolsDocumentLifecycleWaitState::Pending + | moli_protocol::DevToolsDocumentLifecycleWaitState::Reached => {} + moli_protocol::DevToolsDocumentLifecycleWaitState::Interrupted + | moli_protocol::DevToolsDocumentLifecycleWaitState::Superseded + | moli_protocol::DevToolsDocumentLifecycleWaitState::Unavailable => { + return Ok(out); + } + } + let navigation_gate_open = self.has_inflight_background_navigation(); + let Some(input) = receivers.recv_interleaved_input(navigation_gate_open).await else { + return Err(CdpSchedulerProgressFailure::new( out, renderer_output_transport_terminal_error( &receivers.renderer_publication_rx, @@ -1410,7 +1571,6 @@ impl CdpScheduler { .await?, ); } - Ok(out) } /// Waits for one exact target's current Document to reach `milestone`. @@ -1435,7 +1595,8 @@ impl CdpScheduler { loop { let navigation_state = self - .conn + .host_adapter + .commands() .devtools_context_document_navigation_state(context); let loader_id = match navigation_state.clone() { DevToolsDocumentNavigationState::Unavailable => { @@ -1449,14 +1610,28 @@ impl CdpScheduler { } DevToolsDocumentNavigationState::PendingNavigation | DevToolsDocumentNavigationState::AwaitingCommit => { - let ready = self - .complete_ready_protocol_residences_for_external_load_wait() - .await; + let ready = match self + .complete_ready_owner_and_protocol_residences_for_external_load_wait( + receivers, + ) + .await + { + Ok(ready) => ready, + Err(failure) => { + let (progress, error) = failure.into_parts(); + protocol_output.append(progress); + return DevToolsCommandExecution { + result: Err(error), + protocol_output, + }; + } + }; let ready_was_empty = ready.is_empty(); protocol_output.append(ready); if !ready_was_empty || self - .conn + .host_adapter + .commands() .devtools_context_document_navigation_state(context) != navigation_state { @@ -1484,21 +1659,34 @@ impl CdpScheduler { }; let Some(key) = self - .conn + .host_adapter + .commands() .capture_devtools_document_lifecycle_wait_key(context, &loader_id, milestone) else { // The target route and lifecycle journal are published by // distinct owner turns. Let ordered ingress settle them, then // resolve the target again; never infer a Document from an old // loader id. - let ready = self - .complete_ready_protocol_residences_for_external_load_wait() - .await; + let ready = match self + .complete_ready_owner_and_protocol_residences_for_external_load_wait(receivers) + .await + { + Ok(ready) => ready, + Err(failure) => { + let (progress, error) = failure.into_parts(); + protocol_output.append(progress); + return DevToolsCommandExecution { + result: Err(error), + protocol_output, + }; + } + }; let ready_was_empty = ready.is_empty(); protocol_output.append(ready); if !ready_was_empty || self - .conn + .host_adapter + .commands() .devtools_context_document_navigation_state(context) != (DevToolsDocumentNavigationState::Committed { loader_id: loader_id.clone(), @@ -1526,27 +1714,19 @@ impl CdpScheduler { }; loop { - let wait_state = self - .conn - .devtools_document_lifecycle_wait_state(context, &key); + let wait_state = key.state(); match wait_state { DevToolsDocumentLifecycleWaitState::Reached => { - self.conn - .release_devtools_document_lifecycle_wait_key(context, &key); return DevToolsCommandExecution { result: Ok(DevToolsCommandResult::Empty), protocol_output, }; } DevToolsDocumentLifecycleWaitState::Superseded => { - self.conn - .release_devtools_document_lifecycle_wait_key(context, &key); break; } DevToolsDocumentLifecycleWaitState::Interrupted | DevToolsDocumentLifecycleWaitState::Unavailable => { - self.conn - .release_devtools_document_lifecycle_wait_key(context, &key); return DevToolsCommandExecution { result: Err(devtools_document_lifecycle_wait_error( wait_state, milestone, @@ -1558,17 +1738,23 @@ impl CdpScheduler { DevToolsDocumentLifecycleWaitState::Pending => {} } - let ready = self - .complete_ready_protocol_residences_for_external_load_wait() - .await; + let ready = match self + .complete_ready_owner_and_protocol_residences_for_external_load_wait(receivers) + .await + { + Ok(ready) => ready, + Err(failure) => { + let (progress, error) = failure.into_parts(); + protocol_output.append(progress); + return DevToolsCommandExecution { + result: Err(error), + protocol_output, + }; + } + }; let ready_was_empty = ready.is_empty(); protocol_output.append(ready); - if !ready_was_empty - || self - .conn - .devtools_document_lifecycle_wait_state(context, &key) - != wait_state - { + if !ready_was_empty || key.state() != wait_state { continue; } match self @@ -1581,8 +1767,6 @@ impl CdpScheduler { Err(failure) => { let (progress, error) = failure.into_parts(); protocol_output.append(progress); - self.conn - .release_devtools_document_lifecycle_wait_key(context, &key); return DevToolsCommandExecution { result: Err(error), protocol_output, @@ -1598,20 +1782,26 @@ impl CdpScheduler { receivers: &mut CdpSchedulerEventReceivers, started: Instant, timeout: Option, - ) -> Result { + ) -> Result { use moli_protocol::devtools_runtime::DevToolsErrorKind; + let navigation_gate_open = self.has_inflight_background_navigation(); let input = match timeout { Some(timeout) => { let Some(remaining) = timeout.checked_sub(started.elapsed()) else { - return Err(RendererOutputTransportFailure::without_output( + return Err(CdpSchedulerProgressFailure::without_output( DevToolsError::new(DevToolsErrorKind::Timeout, "navigation wait timed out"), )); }; - match tokio::time::timeout(remaining, receivers.recv_interleaved_input()).await { + match tokio::time::timeout( + remaining, + receivers.recv_interleaved_input(navigation_gate_open), + ) + .await + { Ok(progress) => progress, Err(_) => { - return Err(RendererOutputTransportFailure::without_output( + return Err(CdpSchedulerProgressFailure::without_output( DevToolsError::new( DevToolsErrorKind::Timeout, "navigation wait timed out", @@ -1620,10 +1810,10 @@ impl CdpScheduler { } } } - None => receivers.recv_interleaved_input().await, + None => receivers.recv_interleaved_input(navigation_gate_open).await, }; let input = input.ok_or_else(|| { - RendererOutputTransportFailure::without_output(DevToolsError::new( + CdpSchedulerProgressFailure::without_output(DevToolsError::new( DevToolsErrorKind::NoSuchSession, "Protocol runtime stopped while waiting for document navigation", )) @@ -1634,14 +1824,11 @@ impl CdpScheduler { async fn drain_deferred_main_document_load_completion_for_wait( &mut self, - context: &moli_protocol::devtools_runtime::DevToolsCommandContext, ) -> ProtocolOutputSequence { let mut out = ProtocolOutputSequence::empty(); loop { - if self - .conn - .has_inflight_background_navigation_for_devtools_context(context) - || !self.front_protocol_residence_is_main_document_load_action_for_context(context) + if self.has_inflight_background_navigation() + || !self.front_protocol_residence_is_main_document_load_action() { return out; } @@ -1659,7 +1846,7 @@ impl CdpScheduler { residence, ProtocolSchedulerResidence::ProtocolWork { work, .. } if work.kind() - == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction + == ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection && work.is_ready() ) }) @@ -1677,14 +1864,14 @@ impl CdpScheduler { &mut self, receivers: &mut CdpSchedulerEventReceivers, context: &moli_protocol::devtools_runtime::DevToolsCommandContext, - ) -> Result { + ) -> Result { let mut out = ProtocolOutputSequence::empty(); loop { out.append(self.drain_background_events_around_inflight_navigation( &mut receivers.background_event_rx, )); out.append( - self.drain_deferred_main_document_load_completion_for_wait(context) + self.drain_deferred_main_document_load_completion_for_wait() .await, ); out.append(self.drain_background_events_around_inflight_navigation( @@ -1694,14 +1881,15 @@ impl CdpScheduler { return Ok(out); } out.append( - self.complete_ready_protocol_residences_for_external_load_wait() - .await, + self.complete_ready_owner_and_protocol_residences_for_external_load_wait(receivers) + .await?, ); if !self.has_deferred_main_document_load_completion_for_devtools_context(context) { return Ok(out); } - let Some(input) = receivers.recv_interleaved_input().await else { - return Err(RendererOutputTransportFailure::new( + let navigation_gate_open = self.has_inflight_background_navigation(); + let Some(input) = receivers.recv_interleaved_input(navigation_gate_open).await else { + return Err(CdpSchedulerProgressFailure::new( out, renderer_output_transport_terminal_error( &receivers.renderer_publication_rx, @@ -1719,10 +1907,20 @@ impl CdpScheduler { } } - pub(crate) async fn complete_ready_protocol_residences_for_external_load_wait( + pub(crate) async fn complete_ready_owner_and_protocol_residences_for_external_load_wait( &mut self, - ) -> ProtocolOutputSequence { + receivers: &mut CdpSchedulerEventReceivers, + ) -> Result { let mut out = ProtocolOutputSequence::empty(); + while receivers.browser_host.has_ready_input() { + let outcome = self + .complete_next_browser_owner_input(&mut receivers.browser_host) + .expect("ready Browser Host lane must select one exact turn"); + out.append( + self.apply_renderer_owner_turn_outcome(receivers, outcome) + .await?, + ); + } let mut snapshot = self.queues.take_external_load_wait_snapshot(); while let Some(mut residence) = snapshot.pop_front() { self.queues @@ -1731,23 +1929,59 @@ impl CdpScheduler { let pending_load_observation = matches!( &residence, ProtocolSchedulerResidence::ProtocolWork { work, .. } - if work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction + if work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection && !work.is_ready() ); if has_pending_scheduler_predecessor || pending_load_observation { snapshot.push_front(residence); self.queues.restore_snapshot_to_front(snapshot); - return out; + return Ok(out); } out.append(self.complete_protocol_residence(residence).await); } - out + Ok(out) + } + + #[cfg(test)] + pub(crate) fn publish_browser_owner_input_for_test( + &self, + input: moli_core::browser_host::BrowserOwnerInput, + ) { + self.host_adapter + .view() + .browser_host_handle_for_test_support() + .expect("test scheduler should have a Browser Host handle") + .publish(input) + .expect("test Browser Host actor should remain live"); + } + + /// Completes one independently queued Browser Owner turn. + /// + /// Application loops invoke this only after the Core actor's mailbox + /// receive has selected an exact turn. Execution happens outside the + /// selecting `tokio::select!` branch future, so sibling frontend readiness + /// cannot cancel a dequeued turn or make renderer publication recursive. + pub(crate) fn complete_next_browser_owner_input( + &mut self, + browser_host: &mut BrowserHostExecutionLane, + ) -> Option { + browser_host.start_next_turn(&mut self.host_adapter) + } + + pub(crate) async fn complete_browser_host_participant( + &mut self, + browser_host: &mut BrowserHostExecutionLane, + completed: moli_protocol::CompletedBrowserHostTurn, + ) -> moli_protocol::CdpRendererOwnerTurnOutcome { + browser_host + .complete_turn(&mut self.host_adapter, completed) + .await } pub(crate) async fn complete_ready_protocol_residences_after_command( &mut self, ) -> ProtocolOutputSequence { - if self.has_pending_javascript_dialog() { + if self.has_inflight_background_navigation() || self.has_pending_javascript_dialog() { return ProtocolOutputSequence::empty(); } let snapshot = self.queues.take_command_followup_snapshot(); @@ -1760,6 +1994,9 @@ impl CdpScheduler { &mut self, predecessor: &moli_core::RendererOutputFence, ) -> ProtocolOutputSequence { + if self.has_inflight_background_navigation() { + return ProtocolOutputSequence::empty(); + } let cursor = predecessor.cursor(); let snapshot = self .queues @@ -1778,23 +2015,35 @@ impl CdpScheduler { /// scheduler counterpart of the frontend actor's response fence. pub(crate) async fn project_renderer_output_predecessor_before_devtools_result( &mut self, - receivers: &mut CdpSchedulerEventReceivers, + receivers: &mut impl RendererTransportSource, predecessor: &moli_core::RendererOutputFence, - ) -> Result { + ) -> Result { let mut output = ProtocolOutputSequence::empty(); while !self - .conn + .host_adapter + .view() .renderer_output_cursor_is_projected(predecessor.cursor()) { - let Some(publication) = receivers.renderer_publication_rx.recv().await else { - return Err(RendererOutputTransportFailure::new( + let predecessor_stream = predecessor.cursor().stream(); + let Some(publication) = receivers + .receive_concrete_renderer_transport(predecessor_stream, false) + .await + else { + return Err(CdpSchedulerProgressFailure::new( output, renderer_output_transport_terminal_error( - &receivers.renderer_publication_rx, + receivers.renderer_publication_receiver(), "the command predecessor was projected", ), )); }; + let Some(publication) = receivers.admit_or_buffer_renderer_publication( + publication, + self.has_inflight_background_navigation(), + None, + ) else { + continue; + }; output.append(self.ingest_renderer_publication_now(publication).await); } output.append( @@ -1804,125 +2053,108 @@ impl CdpScheduler { Ok(output) } + /// Projects the commit cursor that terminates one Browser-owned direct + /// navigation and releases that exact renderer stream. + /// + /// Unlike an ordinary command predecessor, this boundary is the point at + /// which publications buffered during the navigation gate may re-enter + /// ordered ingress. Keeping the stream reserved after the terminal Host + /// result would let renderer lifecycle state reach load while its child + /// frame and lifecycle facts remain invisible to Protocol. + pub(crate) async fn project_navigation_renderer_output_boundary_before_devtools_result( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + boundary: &moli_core::RendererOutputFence, + ) -> Result { + let mut output = ProtocolOutputSequence::empty(); + let boundary_stream = boundary.cursor().stream(); + while !self + .host_adapter + .view() + .renderer_output_cursor_is_projected(boundary.cursor()) + { + let Some(publication) = receivers + .recv_concrete_renderer_transport(boundary_stream, true) + .await + else { + return Err(CdpSchedulerProgressFailure::new( + output, + renderer_output_transport_terminal_error( + &receivers.renderer_publication_rx, + "the navigation renderer boundary was projected", + ), + )); + }; + let Some(publication) = receivers.admit_or_buffer_navigation_renderer_publication( + publication, + self.has_inflight_background_navigation(), + Some(boundary_stream), + ) else { + continue; + }; + output.append(self.ingest_renderer_publication_now(publication).await); + } + output.append( + self.complete_renderer_output_predecessor_before_runtime_response(boundary) + .await, + ); + Ok(output) + } + async fn complete_protocol_residence_snapshot( &mut self, mut snapshot: VecDeque, ) -> ProtocolOutputSequence { let mut out = ProtocolOutputSequence::empty(); - let mut retained = VecDeque::new(); - let mut blocked_target_ids = Vec::new(); while let Some(mut residence) = snapshot.pop_front() { - if self.has_pending_javascript_dialog() { - retained.push_back(residence); - retained.append(&mut snapshot); - self.queues.restore_snapshot_to_front(retained); + if (self.has_inflight_background_navigation() + && !residence.bypasses_inflight_navigation_gate()) + || self.has_pending_javascript_dialog() + { + snapshot.push_front(residence); + self.queues.restore_snapshot_to_front(snapshot); return out; } - let target_ids = self.protocol_residence_navigation_gate_target_ids(&residence); - let blocked_by_prior_residence = target_ids - .iter() - .any(|target_id| blocked_target_ids.contains(target_id)); - let blocked_by_navigation = !residence.bypasses_inflight_navigation_gate() - && self.protocol_targets_have_inflight_background_navigation(&target_ids); - if blocked_by_prior_residence || blocked_by_navigation { - if target_ids.is_empty() { - retained.push_back(residence); - retained.append(&mut snapshot); - self.queues.restore_snapshot_to_front(retained); - return out; - } - append_unique_target_ids(&mut blocked_target_ids, target_ids); - retained.push_back(residence); - continue; - } self.queues .satisfy_checked_out_client_turn_predecessor(&mut residence); if !residence.is_ready_to_complete() { - if target_ids.is_empty() { - retained.push_back(residence); - retained.append(&mut snapshot); - self.queues.restore_snapshot_to_front(retained); - return out; - } - append_unique_target_ids(&mut blocked_target_ids, target_ids); - retained.push_back(residence); - continue; + snapshot.push_front(residence); + self.queues.restore_snapshot_to_front(snapshot); + return out; } out.append(self.complete_protocol_residence(residence).await); } - if !retained.is_empty() { - self.queues.restore_snapshot_to_front(retained); - } out } - fn protocol_residence_navigation_gate_target_ids( - &self, - residence: &ProtocolSchedulerResidence, - ) -> Vec { - match residence { - ProtocolSchedulerResidence::RendererOutputPublication(work) => { - work.output.navigation_gate_target_ids(&self.conn) - } - ProtocolSchedulerResidence::ProtocolWork { work, .. } => work - .navigation_gate_target_id() - .map(str::to_owned) - .into_iter() - .collect(), - } - } - - fn protocol_targets_have_inflight_background_navigation(&self, target_ids: &[String]) -> bool { - if target_ids.is_empty() { - return self.has_inflight_background_navigation(); - } - target_ids.iter().any(|target_id| { - self.conn - .has_inflight_background_navigation_for_target(target_id) - }) - } - - fn next_ungated_protocol_residence_index(&self) -> Option { - // A target-local navigation is an ordering barrier only for later - // work from the same target. Keep those lanes ordered while allowing - // an independent target to advance, matching Chromium's per-frame - // NavigationRequest ownership. - let mut blocked_target_ids = Vec::new(); - for (index, residence) in self.queues.protocol_residences.iter().enumerate() { - let target_ids = self.protocol_residence_navigation_gate_target_ids(residence); - if target_ids - .iter() - .any(|target_id| blocked_target_ids.contains(target_id)) - { - continue; - } - if !residence.bypasses_inflight_navigation_gate() - && self.protocol_targets_have_inflight_background_navigation(&target_ids) - { - if target_ids.is_empty() { - return None; - } - append_unique_target_ids(&mut blocked_target_ids, target_ids); - continue; - } - if !residence.should_yield_to_client_turn() && !residence.is_ready_to_complete() { - if target_ids.is_empty() { - return None; - } - append_unique_target_ids(&mut blocked_target_ids, target_ids); - continue; - } - return Some(index); - } - None - } - pub(crate) async fn complete_interleaved_scheduler_input( &mut self, receivers: &mut CdpSchedulerEventReceivers, input: CdpSchedulerInterleavedInput, - ) -> Result { + ) -> Result { match input { + CdpSchedulerInterleavedInput::BrowserHostTurn => { + let Some(outcome) = + self.complete_next_browser_owner_input(&mut receivers.browser_host) + else { + return Ok(ProtocolOutputSequence::empty()); + }; + self.apply_renderer_owner_turn_outcome(receivers, outcome) + .await + } + CdpSchedulerInterleavedInput::BrowserHostCompletion(completed) => { + let outcome = self + .complete_browser_host_participant(&mut receivers.browser_host, *completed) + .await; + self.apply_renderer_owner_turn_outcome(receivers, outcome) + .await + } + CdpSchedulerInterleavedInput::DetachedNavigationCompletion(completed) => Ok(self + .complete_detached_devtools_browser_owner_navigation(receivers, *completed) + .await), + CdpSchedulerInterleavedInput::BrowserFactWake(sequence) => { + self.complete_browser_fact_wake(sequence) + } CdpSchedulerInterleavedInput::BackgroundNavigationCompletion(completion) => { self.drain_background_navigation_completion_with_progress_barrier( completion, receivers, @@ -1932,21 +2164,70 @@ impl CdpScheduler { CdpSchedulerInterleavedInput::BackgroundEvent(event) => { Ok(self.route_background_event_around_inflight_navigation(event)) } - CdpSchedulerInterleavedInput::RendererPublication(publication) => { + CdpSchedulerInterleavedInput::RendererPublication { + publication, + navigation_gate_open, + } => { + let Some(publication) = receivers.admit_or_buffer_navigation_renderer_publication( + publication, + navigation_gate_open, + None, + ) else { + return Ok(ProtocolOutputSequence::empty()); + }; Ok(self.ingest_renderer_publication_now(publication).await) } } } - fn front_protocol_residence_is_main_document_load_action_for_context( - &self, - context: &moli_protocol::devtools_runtime::DevToolsCommandContext, - ) -> bool { + fn complete_browser_fact_wake( + &mut self, + through: BrowserFactSequence, + ) -> Result { + self.host_adapter + .projection() + .capture_browser_fact_wake(through) + .map(|()| ProtocolOutputSequence::empty()) + .map_err(|error| { + CdpSchedulerProgressFailure::without_output(DevToolsError::new( + moli_protocol::devtools_runtime::DevToolsErrorKind::Internal, + format!("Browser fact frontend ingress failed: {error}"), + )) + }) + } + + pub(crate) async fn complete_detached_devtools_browser_owner_navigation( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + completed: moli_protocol::CompletedDevToolsBrowserOwnerNavigationCommand, + ) -> ProtocolOutputSequence { + let outcome = self + .host_adapter + .commands() + .complete_devtools_browser_owner_navigation_command(completed) + .await; + let execution = self + .finish_devtools_command_dispatch_outcome_with_protocol_messages( + Some(receivers), + None, + outcome, + false, + ) + .await; + if let Err(error) = &execution.result { + tracing::debug!( + ?error, + "detached direct navigation reached a terminal frontend result" + ); + } + execution.protocol_output + } + + fn front_protocol_residence_is_main_document_load_action(&self) -> bool { matches!( self.queues.protocol_residences.front(), Some(ProtocolSchedulerResidence::ProtocolWork { work, .. }) - if work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction - && work.observes_main_document_load_for_devtools_context(&self.conn, context) + if work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection ) } @@ -1958,19 +2239,22 @@ impl CdpScheduler { matches!( residence, ProtocolSchedulerResidence::ProtocolWork { work, .. } - if work.observes_main_document_load_for_devtools_context(&self.conn, context) + if self + .host_adapter.view() + .observes_main_document_load_for_devtools_context(work, context) ) }) } pub(crate) fn has_inflight_background_navigation(&self) -> bool { - self.conn.has_inflight_background_navigation() + self.background_navigation_gate.has_inflight_navigation() } pub(crate) fn command_waits_for_navigation_flush(&self, command: &ParsedCdpCommand) -> bool { command.renderer_access() == CdpRendererCommandAccess::MainThread && self - .conn + .host_adapter + .view() .renderer_document_navigation_is_suspended_for_session_owner(command.session_id()) } @@ -1978,21 +2262,15 @@ impl CdpScheduler { &mut self, event: BackgroundProtocolEvent, ) -> ProtocolOutputSequence { - if !event.route_is_current(&self.conn) { + if !self + .host_adapter + .view() + .background_event_route_is_current(&event) + { return ProtocolOutputSequence::empty(); } + let has_inflight_navigation = self.has_inflight_background_navigation(); let should_wait = event.should_wait_for_background_navigation_completion(); - let navigation_target_id = should_wait - .then(|| self.conn.background_navigation_target_id_for_event(&event)) - .flatten(); - let has_inflight_navigation = should_wait - && navigation_target_id.as_deref().map_or_else( - || self.has_inflight_background_navigation(), - |target_id| { - self.conn - .has_inflight_background_navigation_for_target(target_id) - }, - ); if moli_trace::cdp_runtime_trace_enabled() && let Some((method, resource_type, request_id, url)) = event.trace_network_summary() { @@ -2007,7 +2285,7 @@ impl CdpScheduler { should_wait, ); } - if has_inflight_navigation { + if has_inflight_navigation && should_wait { if moli_trace::cdp_runtime_trace_enabled() { tracing::info!( target: "moli_cdp_runtime", @@ -2015,11 +2293,7 @@ impl CdpScheduler { pending_background_events = self.pending_navigation_background_events.len() + 1, ); } - self.pending_navigation_background_events - .push_back(PendingNavigationBackgroundEvent { - target_id: navigation_target_id, - event, - }); + self.pending_navigation_background_events.push_back(event); return ProtocolOutputSequence::empty(); } ProtocolOutputSequence::from_background_event(event) @@ -2048,30 +2322,19 @@ impl CdpScheduler { } fn drain_pending_navigation_background_events(&mut self) -> ProtocolOutputSequence { - let mut events = Vec::new(); - let mut retained = VecDeque::new(); - while let Some(pending) = self.pending_navigation_background_events.pop_front() { + let events = self + .pending_navigation_background_events + .drain(..) // The navigation gate deliberately extends an event's residence // beyond its projection turn. Reauthorize its frozen route at // the actual release boundary: the in-flight navigation may have // replaced the root Document or detached its session meanwhile. - if !pending.event.route_is_current(&self.conn) { - continue; - } - let remains_gated = pending.target_id.as_deref().map_or_else( - || self.has_inflight_background_navigation(), - |target_id| { - self.conn - .has_inflight_background_navigation_for_target(target_id) - }, - ); - if remains_gated { - retained.push_back(pending); - } else { - events.push(pending.event); - } - } - self.pending_navigation_background_events = retained; + .filter(|event| { + self.host_adapter + .view() + .background_event_route_is_current(event) + }) + .collect::>(); ProtocolOutputSequence::from_background_events(events) } @@ -2079,6 +2342,9 @@ impl CdpScheduler { &mut self, prefix: &mut ProtocolOutputSequence, ) { + if self.has_inflight_background_navigation() { + return; + } prefix.append(self.drain_pending_navigation_background_events()); } @@ -2102,6 +2368,10 @@ impl CdpScheduler { ); } match event { + CdpSchedulerEvent::BackgroundNavigationStarted { key, cancellation } => { + self.background_navigation_gate + .note_navigation_started(key, cancellation); + } CdpSchedulerEvent::ProtocolWorkPublished { work } => { if moli_trace::cdp_nav_timing_enabled() { tracing::info!( @@ -2145,11 +2415,11 @@ impl CdpScheduler { output } - async fn apply_renderer_owner_turn_outcome( + pub(crate) async fn apply_renderer_owner_turn_outcome( &mut self, - receivers: &mut CdpSchedulerEventReceivers, + receivers: &mut impl RendererTransportSource, outcome: moli_protocol::CdpRendererOwnerTurnOutcome, - ) -> Result { + ) -> Result { let (output, post_renderer_output, renderer_output_boundary, renderer_output_predecessor) = self.materialize_renderer_owner_turn_outcome(outcome); assert!( @@ -2229,7 +2499,7 @@ impl CdpScheduler { mut load_predecessors: Vec, mut future_load_predecessor: Option, ) -> ProtocolOutputSequence { - let pending_scheduler_events = self.conn.take_scheduler_events(); + let pending_scheduler_events = self.host_adapter.projection().take_scheduler_events(); self.apply_scheduler_events(pending_scheduler_events); let renderer_output_cursor = match &publication { RendererOutputTransportMessage::Publication(output) => Some(output.cursor()), @@ -2260,7 +2530,8 @@ impl CdpScheduler { } let trace_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now); let outcome = self - .conn + .host_adapter + .projection() .ingest_renderer_output_turn_async( publication, &mut self.runtime_command_output_barriers, @@ -2403,7 +2674,8 @@ impl CdpScheduler { return ProtocolOutputSequence::empty(); }; let completion = self - .conn + .host_adapter + .projection() .release_runtime_command_output_barrier_turn_async( &mut self.runtime_command_output_barriers, runtime_barrier, @@ -2420,18 +2692,15 @@ impl CdpScheduler { } fn next_protocol_scheduler_step(&self) -> ProtocolSchedulerStep { - let Some(index) = self.next_ungated_protocol_residence_index() else { + if self.has_inflight_background_navigation() + && !self.queues.front_bypasses_inflight_navigation_gate() + { return ProtocolSchedulerStep::Wait; - }; - let residence = self - .queues - .protocol_residences - .get(index) - .expect("selected protocol residence must exist"); - if residence.should_yield_to_client_turn() { + } + if self.queues.front_needs_client_turn_predecessor() { return ProtocolSchedulerStep::SatisfyClientTurnPredecessor; } - if residence.is_ready_to_complete() { + if self.queues.should_complete_next_residence() { return ProtocolSchedulerStep::CompleteReadyResidence; } ProtocolSchedulerStep::Wait @@ -2445,28 +2714,44 @@ impl CdpScheduler { protocol_residence_len = self.queues.protocol_residence_len(), ); } - let Some(index) = self.next_ungated_protocol_residence_index() else { - return; - }; - self.queues.satisfy_client_turn_predecessor_at(index); + self.queues.satisfy_front_client_turn_predecessor(); } fn next_ready_protocol_residence_is_main_document_load_action(&self) -> bool { - let Some(index) = self.next_ungated_protocol_residence_index() else { - return false; - }; matches!( - self.queues.protocol_residences.get(index), + self.queues.protocol_residences.front(), Some(ProtocolSchedulerResidence::ProtocolWork { work, client_turn_predecessor: ClientTurnPredecessor::Satisfied, load_predecessors, .. }) if load_predecessors.is_empty() - && work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction + && work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection ) } + /// Reports a front exact-load residence whose lifecycle terminal is + /// already authoritative, including the one adapter turn needed to cross + /// its producer boundary. + /// + /// This is narrower than general protocol readiness. Actor loops use it + /// only to keep an already-reached old-Document load fact ahead of a + /// queued replacement; a pending load fact ticket never delays Browser Owner + /// selection. + pub(crate) fn has_causally_ready_main_document_load_residence(&self) -> bool { + !self.has_inflight_background_navigation() + && matches!( + self.queues.protocol_residences.front(), + Some(ProtocolSchedulerResidence::ProtocolWork { + work, + load_predecessors, + .. + }) if load_predecessors.is_empty() + && work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection + && work.is_ready() + ) + } + pub(crate) fn route_renderer_output_for_deferred_load_completion( &self, output: &RendererOutputTransportMessage, @@ -2476,10 +2761,7 @@ impl CdpScheduler { } pub(crate) async fn complete_next_protocol_residence(&mut self) -> ProtocolOutputSequence { - let Some(index) = self.next_ungated_protocol_residence_index() else { - return ProtocolOutputSequence::empty(); - }; - let Some(residence) = self.queues.take_protocol_residence_at(index) else { + let Some(residence) = self.queues.pop_next_protocol_residence() else { return ProtocolOutputSequence::empty(); }; self.complete_protocol_residence(residence).await @@ -2490,7 +2772,8 @@ impl CdpScheduler { session_id: Option<&str>, ) -> ProtocolOutputSequence { let outcome = self - .conn + .host_adapter + .projection() .project_protocol_local_command_outputs_turn_async(session_id) .await; self.apply_protocol_only_turn_outcome(outcome) @@ -2545,8 +2828,9 @@ impl CdpScheduler { ); let load_observation_id = work.main_document_load_observation_id(); let outcome = self - .conn - .complete_ready_protocol_scheduler_work_turn(work) + .host_adapter + .projection() + .complete_ready_protocol_scheduler_work_turn(*work) .await; out.append(self.apply_protocol_only_turn_outcome(outcome)); if let Some(observation_id) = load_observation_id { @@ -2600,22 +2884,21 @@ impl CdpScheduler { pub(crate) fn start_next_deferred_load_completion( &mut self, ) -> Option { - let index = self.next_ungated_protocol_residence_index()?; let should_start = matches!( - self.queues.protocol_residences.get(index), + self.queues.protocol_residences.front(), Some(ProtocolSchedulerResidence::ProtocolWork { work, client_turn_predecessor: ClientTurnPredecessor::Satisfied, load_predecessors, .. }) if load_predecessors.is_empty() - && work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction + && work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection ); if !should_start { return None; } let Some(ProtocolSchedulerResidence::ProtocolWork { work, .. }) = - self.queues.take_protocol_residence_at(index) + self.queues.pop_next_protocol_residence() else { return None; }; @@ -2643,7 +2926,8 @@ impl CdpScheduler { let trace_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now); let observation_id = completion.observation_id(); let outcome = self - .conn + .host_adapter + .projection() .complete_deferred_main_document_load_completion_for_scheduler(completion) .await; let output = self.apply_protocol_only_turn_outcome(outcome); @@ -2669,10 +2953,18 @@ impl CdpScheduler { Option, ) { let trace_started = moli_trace::cdp_runtime_trace_enabled().then(Instant::now); - let outcome = self - .conn + let gate_key = completion.background_navigation_gate_key(); + let (outcome, disposition) = self + .host_adapter + .projection() .drain_background_navigation_completion_turn_async(completion) .await; + if disposition.is_terminal() + && let Some(key) = gate_key + { + self.background_navigation_gate + .note_navigation_completion_drained(&key); + } let (out, post_renderer_output, renderer_output_boundary, renderer_output_predecessor) = self.materialize_renderer_owner_turn_outcome(outcome); if let Some(started) = trace_started { @@ -2737,7 +3029,7 @@ impl CdpScheduler { &mut self, completion: BackgroundNavigationCompletion, receivers: &mut CdpSchedulerEventReceivers, - ) -> Result { + ) -> Result { let (mut output, completion_output, renderer_output_boundary) = self .materialize_background_navigation_completion_with_progress_barrier( completion, @@ -2746,15 +3038,25 @@ impl CdpScheduler { .await; let Some(predecessor) = renderer_output_boundary else { output.append(completion_output); + while let Some(publication) = receivers + .take_buffered_renderer_publication(self.has_inflight_background_navigation()) + { + output.append(self.ingest_renderer_publication_now(publication).await); + } return Ok(output); }; while !self - .conn + .host_adapter + .view() .renderer_output_cursor_is_projected(predecessor.cursor()) { - let Some(publication) = receivers.renderer_publication_rx.recv().await else { - return Err(RendererOutputTransportFailure::new( + let predecessor_stream = predecessor.cursor().stream(); + let Some(publication) = receivers + .recv_concrete_renderer_transport(predecessor_stream, true) + .await + else { + return Err(CdpSchedulerProgressFailure::new( output, renderer_output_transport_terminal_error( &receivers.renderer_publication_rx, @@ -2762,6 +3064,13 @@ impl CdpScheduler { ), )); }; + let Some(publication) = receivers.admit_or_buffer_navigation_renderer_publication( + publication, + self.has_inflight_background_navigation(), + Some(predecessor_stream), + ) else { + continue; + }; output.append(self.ingest_renderer_publication_now(publication).await); } output.append( @@ -2769,6 +3078,11 @@ impl CdpScheduler { .await, ); output.append(completion_output); + while let Some(publication) = + receivers.take_buffered_renderer_publication(self.has_inflight_background_navigation()) + { + output.append(self.ingest_renderer_publication_now(publication).await); + } Ok(output) } } diff --git a/moli-protocol-server/src/cdp_scheduler/actor.rs b/moli-protocol-server/src/cdp_scheduler/actor.rs index e99d34a2e3..dedb007f65 100644 --- a/moli-protocol-server/src/cdp_scheduler/actor.rs +++ b/moli-protocol-server/src/cdp_scheduler/actor.rs @@ -3,7 +3,10 @@ use std::{ time::Instant, }; -use moli_core::{RendererOutputFence, RendererOutputTransportMessage}; +use moli_core::{ + RendererOutputFence, RendererOutputStreamIdentity, RendererOutputTransportMessage, + browser_host::BrowserFactSequence, +}; use moli_protocol::{ BackgroundNavigationCompletion, BackgroundProtocolEvent, CdpSchedulerEvent, CommandDispatchContext, CompletedCdpCommandDispatch, CompletedPageScreencastCapture, @@ -22,12 +25,13 @@ 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, + BrowserHostExecutionLane, BrowserHostExecutionWake, CdpBackgroundEventReceiver, + CdpBackgroundNavigationCompletionReceiver, CdpOwnerActorLifecycle, + CdpRendererPublicationReceiver, CdpScheduler, CdpSchedulerEventReceivers, CommandDispatchState, + CommandDispatchStepOutput, CommandOutputReleasePermit, CommandStartAction, CommandTaskStep, + CommandTurnOutput, NavigationRendererPublicationBuffer, ProtocolAdapterScheduler, + ProtocolAdapterSchedulerAdvance, ProtocolAdapterSchedulerInput, ProtocolOutputSequence, + RendererTransportSource, }; struct PendingRuntimeDeferredReplyState { @@ -54,6 +58,7 @@ struct InFlightCommandState { dispatch: CommandDispatchState, output_release_permit: CommandOutputReleasePermit, command_context: CommandDispatchContext, + holds_navigation_renderer_publication_gate: bool, pending_turn: u64, } @@ -79,31 +84,46 @@ fn page_javascript_owner_is_blocked( .any(|state| state.metadata.executes_page_javascript) } +fn pending_command_navigation_renderer_gate_open(in_flight_commands: &InFlightCommands) -> bool { + in_flight_commands + .values() + .any(|state| state.holds_navigation_renderer_publication_gate) +} + enum SchedulerInput { + BrowserFactWake(BrowserFactSequence), BackgroundNavigationCompletion(BackgroundNavigationCompletion), BackgroundEvent(BackgroundProtocolEvent), - RendererPublication(RendererOutputTransportMessage), + RendererPublication { + publication: RendererOutputTransportMessage, + navigation_gate_open: bool, + }, DeferredRuntimeInspectorResponse(Box), AdapterScheduler(ProtocolAdapterSchedulerInput), } struct SchedulerInputReceivers { + browser_fact_wake_rx: moli_core::browser_host::BrowserFactWakeSubscriber, background_event_rx: CdpBackgroundEventReceiver, background_navigation_completion_rx: CdpBackgroundNavigationCompletionReceiver, renderer_publication_rx: CdpRendererPublicationReceiver, - buffered_renderer_publications: VecDeque, + navigation_renderer_publications: NavigationRendererPublicationBuffer, ready_background_inputs_before_runtime_response: VecDeque, } impl SchedulerInputReceivers { - fn new(receivers: CdpSchedulerEventReceivers) -> Self { - Self { - background_event_rx: receivers.background_event_rx, - background_navigation_completion_rx: receivers.background_navigation_completion_rx, - renderer_publication_rx: receivers.renderer_publication_rx, - buffered_renderer_publications: VecDeque::new(), - ready_background_inputs_before_runtime_response: VecDeque::new(), - } + fn new(receivers: CdpSchedulerEventReceivers) -> (Self, BrowserHostExecutionLane) { + ( + Self { + browser_fact_wake_rx: receivers.browser_fact_wake_rx, + background_event_rx: receivers.background_event_rx, + background_navigation_completion_rx: receivers.background_navigation_completion_rx, + renderer_publication_rx: receivers.renderer_publication_rx, + navigation_renderer_publications: receivers.navigation_renderer_publications, + ready_background_inputs_before_runtime_response: VecDeque::new(), + }, + receivers.browser_host, + ) } fn queue_ready_background_inputs_before_runtime_response( @@ -123,6 +143,10 @@ impl SchedulerInputReceivers { self.ready_background_inputs_before_runtime_response .push_back(SchedulerInput::BackgroundNavigationCompletion(completion)); } + if let Ok(sequence) = self.browser_fact_wake_rx.try_recv() { + self.ready_background_inputs_before_runtime_response + .push_back(SchedulerInput::BrowserFactWake(sequence)); + } while let Ok(event) = self.background_event_rx.try_recv() { self.ready_background_inputs_before_runtime_response .push_back(SchedulerInput::BackgroundEvent(event)); @@ -137,20 +161,73 @@ impl SchedulerInputReceivers { /// cursor fence. /// /// The transport contains only typed stream controls and concrete - /// publications. Every message is safe to admit while a Page JavaScript - /// stack is blocked because protocol never re-enters renderer state to - /// discover its payload. - async fn recv_concrete_renderer_transport(&mut self) -> Option { - if let Some(publication) = self.buffered_renderer_publications.pop_front() { + /// publications. A crossing command fence may admit controls and + /// unrelated streams, but a stream whose `MainDocumentCommit` precedes + /// Browser commit stays move-owned in `navigation_renderer_publications` + /// until the exact navigation boundary releases it. + async fn recv_concrete_renderer_transport( + &mut self, + predecessor_stream: RendererOutputStreamIdentity, + releases_navigation_stream: bool, + ) -> Option { + if let Some(publication) = self + .navigation_renderer_publications + .take_for_predecessor(predecessor_stream, releases_navigation_stream) + { return Some(publication); } self.renderer_publication_rx.recv().await } } +impl RendererTransportSource for SchedulerInputReceivers { + fn renderer_publication_receiver(&self) -> &CdpRendererPublicationReceiver { + &self.renderer_publication_rx + } + + fn admit_or_buffer_renderer_publication( + &mut self, + publication: RendererOutputTransportMessage, + navigation_gate_open: bool, + released_navigation_stream: Option, + ) -> Option { + self.admit_or_buffer_navigation_renderer_publication( + publication, + navigation_gate_open, + released_navigation_stream, + ) + } + + async fn receive_concrete_renderer_transport( + &mut self, + predecessor_stream: RendererOutputStreamIdentity, + releases_navigation_stream: bool, + ) -> Option { + self.recv_concrete_renderer_transport(predecessor_stream, releases_navigation_stream) + .await + } +} + impl SchedulerInputReceivers { - fn take_buffered_renderer_publication(&mut self) -> Option { - self.buffered_renderer_publications.pop_front() + fn take_buffered_renderer_publication( + &mut self, + navigation_gate_open: bool, + ) -> Option { + self.navigation_renderer_publications + .take_releasable(navigation_gate_open) + } + + fn admit_or_buffer_navigation_renderer_publication( + &mut self, + publication: RendererOutputTransportMessage, + navigation_gate_open: bool, + released_navigation_stream: Option, + ) -> Option { + self.navigation_renderer_publications.admit_or_buffer( + publication, + navigation_gate_open, + released_navigation_stream, + ) } async fn recv( @@ -159,6 +236,7 @@ impl SchedulerInputReceivers { has_pending_runtime_deferred_reply: bool, adapter_scheduler: &mut ProtocolAdapterScheduler, page_javascript_blocked: bool, + navigation_gate_open: bool, ) -> Option { if let Some(input) = self .ready_background_inputs_before_runtime_response @@ -166,8 +244,11 @@ impl SchedulerInputReceivers { { return Some(input); } - if let Some(publication) = self.take_buffered_renderer_publication() { - return Some(SchedulerInput::RendererPublication(publication)); + if let Some(publication) = self.take_buffered_renderer_publication(navigation_gate_open) { + return Some(SchedulerInput::RendererPublication { + publication, + navigation_gate_open, + }); } if has_pending_runtime_deferred_reply { tokio::select! { @@ -180,11 +261,17 @@ impl SchedulerInputReceivers { maybe_completion = self.background_navigation_completion_rx.recv() => { maybe_completion.map(SchedulerInput::BackgroundNavigationCompletion) } + wake = self.browser_fact_wake_rx.recv() => { + wake.ok().map(SchedulerInput::BrowserFactWake) + } maybe_event = self.background_event_rx.recv() => { maybe_event.map(SchedulerInput::BackgroundEvent) } maybe_publication = self.renderer_publication_rx.recv() => { - maybe_publication.map(SchedulerInput::RendererPublication) + maybe_publication.map(|publication| SchedulerInput::RendererPublication { + publication, + navigation_gate_open, + }) } input = adapter_scheduler.recv_input(), if !page_javascript_blocked => { Some(SchedulerInput::AdapterScheduler(input)) @@ -196,11 +283,17 @@ impl SchedulerInputReceivers { maybe_completion = self.background_navigation_completion_rx.recv() => { maybe_completion.map(SchedulerInput::BackgroundNavigationCompletion) } + wake = self.browser_fact_wake_rx.recv() => { + wake.ok().map(SchedulerInput::BrowserFactWake) + } maybe_event = self.background_event_rx.recv() => { maybe_event.map(SchedulerInput::BackgroundEvent) } maybe_publication = self.renderer_publication_rx.recv() => { - maybe_publication.map(SchedulerInput::RendererPublication) + maybe_publication.map(|publication| SchedulerInput::RendererPublication { + publication, + navigation_gate_open, + }) } maybe_response = deferred_runtime_response_rx.recv() => { maybe_response.map(|response| SchedulerInput::DeferredRuntimeInspectorResponse(Box::new(response))) @@ -219,7 +312,7 @@ pub(crate) fn spawn_cdp_scheduler_actor( frontend_router: CdpFrontendRouter, frontend_receivers: CdpFrontendReceivers, owner_lifecycle: Option, -) -> JoinHandle { +) -> JoinHandle<()> { tokio::task::spawn_local(run_cdp_scheduler_actor( scheduler, receivers, @@ -235,12 +328,13 @@ async fn run_cdp_scheduler_actor( frontend_router: CdpFrontendRouter, mut frontend_receivers: CdpFrontendReceivers, owner_lifecycle: Option, -) -> CdpCookieSnapshot { - let mut scheduler_input_rx = SchedulerInputReceivers::new(receivers); +) { + let (mut scheduler_input_rx, mut browser_host) = SchedulerInputReceivers::new(receivers); let (deferred_runtime_response_tx, mut deferred_runtime_response_rx) = mpsc::unbounded_channel(); scheduler - .conn + .host_adapter + .control() .set_runtime_inspector_response_ready_sender(deferred_runtime_response_tx.clone()); let mut adapter_scheduler = ProtocolAdapterScheduler::::default(); let mut pending_runtime_deferred_replies: VecDeque = @@ -255,15 +349,83 @@ async fn run_cdp_scheduler_actor( let mut frontend_control = CdpFrontendControlState::default(); loop { - if scheduler_input_rx.renderer_publication_rx.is_closed() { - break; + if adapter_scheduler.load_projection_precedes_browser_owner(&scheduler) { + let Some(input) = adapter_scheduler + .recv_load_projection_predecessor_input(&scheduler) + .await + else { + break; + }; + if !handle_adapter_scheduler_input( + &frontend_router, + &mut scheduler, + &mut adapter_scheduler, + &mut pending_runtime_deferred_replies, + input, + ) + .await + { + break; + } + continue; } let page_javascript_blocked = page_javascript_owner_is_blocked(&scheduler, &in_flight_commands); + let navigation_gate_open = scheduler.has_inflight_background_navigation() + || pending_command_navigation_renderer_gate_open(&in_flight_commands); adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked); let page_screencast_deadline = scheduler.next_page_screencast_deadline(); tokio::select! { biased; + wake = browser_host.recv_wake() => { + let outcome = match wake { + BrowserHostExecutionWake::TurnSelected => { + scheduler.complete_next_browser_owner_input(&mut browser_host) + } + BrowserHostExecutionWake::ParticipantCompleted(completed) => { + Some( + scheduler + .complete_browser_host_participant(&mut browser_host, *completed) + .await, + ) + } + BrowserHostExecutionWake::DetachedNavigationCompleted(_) => { + tracing::error!( + "actor scheduler received a direct-frontend navigation completion" + ); + None + } + BrowserHostExecutionWake::Closed => break, + }; + let output = if let Some(outcome) = outcome { + match scheduler + .apply_renderer_owner_turn_outcome(&mut scheduler_input_rx, outcome) + .await + { + Ok(output) => output, + Err(failure) => { + let (_, error) = failure.into_parts(); + tracing::error!( + error = %error.message, + "terminating frontend after Browser Host renderer predecessor failure" + ); + break; + } + } + } else { + ProtocolOutputSequence::empty() + }; + if !flush_protocol_output_with_runtime_deferred_reply_routing( + &frontend_router, + &mut scheduler, + &mut pending_runtime_deferred_replies, + output, + ) + .await + { + break; + } + } maybe_completion = pending_command_completion_rx.recv(), if !in_flight_commands.is_empty() => { let Some(completion) = maybe_completion else { break; @@ -389,6 +551,7 @@ async fn run_cdp_scheduler_actor( !pending_runtime_deferred_replies.is_empty(), &mut adapter_scheduler, page_javascript_blocked, + navigation_gate_open, ) => { let Some(input) = maybe_input else { break; @@ -414,7 +577,6 @@ async fn run_cdp_scheduler_actor( } } } - CdpCookieSnapshot::from_profile_backed_cookies(scheduler.snapshot_profile_backed_cookies()) } async fn wait_for_page_screencast_deadline(deadline: Option) { @@ -447,6 +609,27 @@ async fn handle_scheduler_input( ); } let ok = match input { + SchedulerInput::BrowserFactWake(sequence) => { + let output = match scheduler.complete_browser_fact_wake(sequence) { + Ok(output) => output, + Err(failure) => { + let (_, error) = failure.into_parts(); + tracing::error!( + error = %error.message, + browser_fact_sequence = sequence.get(), + "terminating frontend after Browser fact ingress failure" + ); + return false; + } + }; + flush_protocol_output_with_runtime_deferred_reply_routing( + frontend_router, + scheduler, + pending_runtime_deferred_replies, + output, + ) + .await + } SchedulerInput::BackgroundNavigationCompletion(_) => { if !flush_background_completion_input( frontend_router, @@ -493,6 +676,7 @@ async fn handle_scheduler_input( pending_runtime_deferred_replies, adapter_scheduler, renderer_output_predecessor.as_ref(), + false, ) .await { @@ -507,7 +691,19 @@ async fn handle_scheduler_input( ) .await } - SchedulerInput::RendererPublication(publication) => { + SchedulerInput::RendererPublication { + publication, + navigation_gate_open, + } => { + let Some(publication) = scheduler_input_rx + .admit_or_buffer_navigation_renderer_publication( + publication, + navigation_gate_open, + None, + ) + else { + return true; + }; ingest_and_flush_renderer_publication( frontend_router, scheduler, @@ -547,17 +743,30 @@ async fn flush_renderer_publication_predecessor( pending_runtime_deferred_replies: &mut VecDeque, adapter_scheduler: &mut ProtocolAdapterScheduler, predecessor: Option<&RendererOutputFence>, + releases_navigation_stream: bool, ) -> bool { let Some(predecessor) = predecessor else { return true; }; while !scheduler - .conn + .host_adapter + .view() .renderer_output_cursor_is_projected(predecessor.cursor()) { - let Some(publication) = scheduler_input_rx.recv_concrete_renderer_transport().await else { + let predecessor_stream = predecessor.cursor().stream(); + let Some(publication) = scheduler_input_rx + .recv_concrete_renderer_transport(predecessor_stream, releases_navigation_stream) + .await + else { return false; }; + let Some(publication) = scheduler_input_rx.admit_or_buffer_navigation_renderer_publication( + publication, + scheduler.has_inflight_background_navigation(), + releases_navigation_stream.then_some(predecessor_stream), + ) else { + continue; + }; if !ingest_and_flush_renderer_publication( frontend_router, scheduler, @@ -634,6 +843,7 @@ async fn flush_background_completion_input( pending_runtime_deferred_replies, adapter_scheduler, renderer_output_predecessor.as_ref(), + true, ) .await { @@ -695,7 +905,8 @@ impl RuntimeDeferredReplyCompletion { if let Some(predecessor) = self.output.take_renderer_output_predecessor() { assert!( scheduler - .conn + .host_adapter + .view() .renderer_output_cursor_is_projected(predecessor.cursor()), "a deferred Runtime response must not cross its renderer owner edge before its command-turn cursor is projected" ); @@ -952,9 +1163,10 @@ async fn fail_runtime_deferred_reply_for_loose_protocol_response( "runtime deferred reply saw a loose protocol response; deferred replies must complete through the typed renderer response receiver" ); let output_session_id = pending.output_session_id; - pending - .pending - .forget_scheduler_deferred_inspector_reply(&mut scheduler.conn); + scheduler + .host_adapter + .commands() + .forget_cdp_deferred_inspector_reply(pending.pending); let command_output = pending .dispatch .complete_with_turn_output(CommandTurnOutput::new( @@ -992,9 +1204,10 @@ async fn complete_runtime_deferred_reply_for_renderer_response( let mut pending = pending_runtime_deferred_replies .remove(index) .expect("pending runtime deferred reply index came from position()"); - pending - .pending - .route_scheduler_deferred_inspector_response(&mut scheduler.conn, response) + scheduler + .host_adapter + .commands() + .route_cdp_deferred_inspector_response(&mut pending.pending, response) .await; Ok( complete_runtime_deferred_reply_state(scheduler, pending, renderer_output_predecessor) @@ -1064,9 +1277,10 @@ async fn complete_runtime_deferred_reply_state( ) -> RuntimeDeferredReplyAdvance { let command_id = pending.pending.command_id(); let session_id = pending.pending.session_id().map(str::to_owned); - let completed = pending - .pending - .complete_scheduler_deferred_inspector_reply(&mut scheduler.conn); + let completed = scheduler + .host_adapter + .commands() + .complete_cdp_deferred_inspector_reply(pending.pending); match scheduler .complete_pending_command_dispatch_with_context(completed, &mut pending.command_context) .await @@ -1542,6 +1756,7 @@ async fn start_ready_command_dispatch( adapter_scheduler, &metadata, output, + false, ) .await; trace_in_flight_command( @@ -1592,6 +1807,8 @@ async fn start_ready_command_dispatch( } CommandTaskStep::Pending(pending) => { trace_command(command, "command_dispatch_pending", None, None, None); + let holds_navigation_renderer_publication_gate = + pending.holds_navigation_renderer_publication_gate(); let token = take_next_in_flight_command_token(next_in_flight_command_token); in_flight_commands.insert( token, @@ -1600,6 +1817,7 @@ async fn start_ready_command_dispatch( dispatch: CommandDispatchState::pending_command(), output_release_permit, command_context, + holds_navigation_renderer_publication_gate, pending_turn: 0, }, ); @@ -1686,6 +1904,7 @@ async fn flush_completed_command_output( adapter_scheduler: &mut ProtocolAdapterScheduler, metadata: &InFlightCommandMetadata, mut output: CommandTurnOutput, + releases_navigation_stream: bool, ) -> bool { let renderer_output_predecessor = output.take_renderer_output_predecessor(); if !flush_renderer_publication_predecessor( @@ -1695,6 +1914,7 @@ async fn flush_completed_command_output( pending_runtime_deferred_replies, adapter_scheduler, renderer_output_predecessor.as_ref(), + releases_navigation_stream, ) .await { @@ -1760,6 +1980,7 @@ async fn flush_completed_command_output( pending_runtime_deferred_replies, adapter_scheduler, renderer_output_boundary.as_ref(), + releases_navigation_stream, ) .await { @@ -1927,6 +2148,8 @@ async fn handle_pending_command_completion( pending_kind = pending.kind_name(), ); } + state.holds_navigation_renderer_publication_gate = + pending.holds_navigation_renderer_publication_gate(); state.pending_turn = state.pending_turn.wrapping_add(1); let token = completion.token; in_flight_commands.insert(token, state); @@ -1956,6 +2179,7 @@ async fn handle_pending_command_completion( adapter_scheduler, &state.metadata, output, + state.holds_navigation_renderer_publication_gate, ) .await; trace_in_flight_command( @@ -2020,12 +2244,13 @@ fn trace_scheduler_input(input: &SchedulerInput, stage: &'static str) { fn scheduler_input_kind(input: &SchedulerInput) -> &'static str { match input { + SchedulerInput::BrowserFactWake(_) => "browser_fact_wake", SchedulerInput::BackgroundNavigationCompletion(_) => "background_navigation_completion", SchedulerInput::BackgroundEvent(_) => "background_event", SchedulerInput::DeferredRuntimeInspectorResponse(_) => { "deferred_runtime_inspector_response" } - SchedulerInput::RendererPublication(_) => "renderer_publication", + SchedulerInput::RendererPublication { .. } => "renderer_publication", SchedulerInput::AdapterScheduler(ProtocolAdapterSchedulerInput::Turn) => { "protocol_adapter_turn" } @@ -2075,7 +2300,10 @@ fn trace_command( #[cfg(test)] mod tests { - use moli_core::RendererRuntimeInspectorAsyncCompletion; + use moli_core::{ + PageId, RendererOutputStreamControl, RendererOutputStreamIdentity, + RendererOutputTransportMessage, RendererRuntimeInspectorAsyncCompletion, + }; use moli_protocol::CdpConnection; use serde_json::json; @@ -2090,16 +2318,23 @@ mod tests { #[tokio::test] async fn ready_background_events_precede_runtime_response_without_starving_it() { - let (background_event_tx, background_event_rx) = mpsc::unbounded_channel(); + let (background_event_tx, background_event_rx) = + moli_protocol::browser_background_output_channel(); let (_background_navigation_tx, background_navigation_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, - renderer_publication_rx, - }); + let (_scheduler, browser_host_actor, browser_fact_wake_rx) = + CdpScheduler::new(CdpConnection::new()); + let (mut receivers, _browser_host) = + SchedulerInputReceivers::new(CdpSchedulerEventReceivers { + browser_host: BrowserHostExecutionLane::new(browser_host_actor), + browser_fact_wake_rx, + background_event_rx, + background_navigation_completion_rx, + renderer_publication_rx, + navigation_renderer_publications: NavigationRendererPublicationBuffer::default(), + }); let (runtime_response_tx, mut runtime_response_rx) = mpsc::unbounded_channel(); let mut adapter_scheduler = ProtocolAdapterScheduler::default(); @@ -2123,6 +2358,7 @@ mod tests { true, &mut adapter_scheduler, false, + false, ) .await .expect("ready background event should be received"); @@ -2140,6 +2376,7 @@ mod tests { true, &mut adapter_scheduler, false, + false, ) .await .expect("snapshotted runtime response should be received"); @@ -2154,15 +2391,85 @@ mod tests { false, &mut adapter_scheduler, false, + false, ) .await .expect("later background event should remain queued"); assert!(matches!(third, SchedulerInput::BackgroundEvent(_))); } + #[tokio::test] + async fn navigation_gate_keeps_receiving_renderer_stream_controls() { + let (background_event_tx, background_event_rx) = + moli_protocol::browser_background_output_channel(); + let (_background_navigation_tx, background_navigation_completion_rx) = + mpsc::unbounded_channel(); + let (renderer_publication_tx, renderer_publication_rx) = + moli_core::renderer_output_transport_channel(); + let (_scheduler, browser_host_actor, browser_fact_wake_rx) = + CdpScheduler::new(CdpConnection::new()); + let (mut receivers, _browser_host) = + SchedulerInputReceivers::new(CdpSchedulerEventReceivers { + browser_host: BrowserHostExecutionLane::new(browser_host_actor), + browser_fact_wake_rx, + background_event_rx, + background_navigation_completion_rx, + renderer_publication_rx, + navigation_renderer_publications: NavigationRendererPublicationBuffer::default(), + }); + let (_runtime_response_tx, mut runtime_response_rx) = mpsc::unbounded_channel(); + let mut adapter_scheduler = ProtocolAdapterScheduler::default(); + let stream = + RendererOutputStreamIdentity::new_page_for_protocol_test(PageId::new_for_testing(731)); + renderer_publication_tx + .send(RendererOutputStreamControl::Opened { stream }.into()) + .expect("renderer publication receiver should be alive"); + + let selected = receivers + .recv( + &mut runtime_response_rx, + false, + &mut adapter_scheduler, + false, + true, + ) + .await + .expect("an open navigation gate should still receive renderer controls"); + assert!(matches!( + selected, + SchedulerInput::RendererPublication { + publication: RendererOutputTransportMessage::StreamControl( + RendererOutputStreamControl::Opened { stream: selected_stream } + ), + navigation_gate_open: true, + } if selected_stream == stream + )); + + background_event_tx + .send(BackgroundProtocolEvent::immediate(json!({ + "method": "Network.responseReceived", + "params": {} + }))) + .expect("background event receiver should be alive"); + assert!(matches!( + receivers + .recv( + &mut runtime_response_rx, + false, + &mut adapter_scheduler, + false, + true, + ) + .await + .expect("background event should remain observable through the gate"), + SchedulerInput::BackgroundEvent(_) + )); + } + #[test] fn unmatched_runtime_inspector_response_is_not_downgraded_to_protocol_output() { - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); let output = route_unmatched_runtime_inspector_response( &mut scheduler, diff --git a/moli-protocol-server/src/cdp_scheduler/adapter_scheduler.rs b/moli-protocol-server/src/cdp_scheduler/adapter_scheduler.rs index 39174758cb..36c071ed6d 100644 --- a/moli-protocol-server/src/cdp_scheduler/adapter_scheduler.rs +++ b/moli-protocol-server/src/cdp_scheduler/adapter_scheduler.rs @@ -1,8 +1,8 @@ use moli_core::RendererOutputTransportMessage; use moli_protocol::{ CompletedDeferredMainDocumentLoadCompletion, DeferredMainDocumentLoadCompletionOutputAction, - DeferredMainDocumentLoadCompletionOutputInterest, DeferredMainDocumentLoadObservationId, - PendingDeferredMainDocumentLoadCompletion, + DeferredMainDocumentLoadCompletionOutputInterest, DeferredMainDocumentLoadCompletionReadiness, + DeferredMainDocumentLoadObservationId, PendingDeferredMainDocumentLoadCompletion, }; use tokio::sync::mpsc; @@ -10,9 +10,9 @@ use super::{CdpScheduler, ProtocolOutputSequence, protocol_residence::ProtocolSc /// Connection-local scheduling state shared by CDP, BiDi and Classic adapters. /// -/// The protocol scheduler owns concrete output and browser-owner work. This -/// driver owns only the asynchronous adapter boundary needed to select that -/// work in a later client turn: +/// The protocol scheduler owns concrete protocol output; the separate Browser +/// Owner queue owns browser actions. This driver owns only the asynchronous +/// adapter boundary needed to select protocol work in a later client turn: /// /// - one coalesced self-turn signal; /// - at most one exact main-document load observation; @@ -21,7 +21,7 @@ use super::{CdpScheduler, ProtocolOutputSequence, protocol_residence::ProtocolSc /// /// It never stores a renderer publication, Page task capability or protocol /// transport route. Switching a Classic connection to BiDi therefore keeps -/// this value alive instead of recreating scheduler or load-observer state. +/// this value alive instead of recreating scheduler or fact-wait state. pub(crate) struct ProtocolAdapterScheduler { turn_tx: mpsc::UnboundedSender<()>, turn_rx: mpsc::UnboundedReceiver<()>, @@ -34,6 +34,7 @@ pub(crate) struct ProtocolAdapterScheduler { struct PendingAdapterLoadObservation { observation_id: DeferredMainDocumentLoadObservationId, output_interest: DeferredMainDocumentLoadCompletionOutputInterest, + readiness: DeferredMainDocumentLoadCompletionReadiness, attachment: A, } @@ -44,7 +45,7 @@ pub(crate) enum ProtocolAdapterSchedulerInput { /// Result of consuming one shared adapter-scheduler input. /// -/// `DeferredLoadStarted` deliberately does not expose the pending observer or +/// `DeferredLoadStarted` deliberately does not expose the pending fact ticket or /// its wake interest. The exact identity and adapter attachment remain owned /// by `ProtocolAdapterScheduler` until `DeferredLoadCompleted`. pub(crate) enum ProtocolAdapterSchedulerAdvance { @@ -84,6 +85,47 @@ impl ProtocolAdapterScheduler { self.pending_load.is_some() } + /// Reports an exact load terminal that is already an authoritative fact. + /// + /// Application actor loops use this only to order a ready lifecycle + /// projection before a queued replacement. A still-pending fact wait never + /// blocks Browser Owner progress, because that replacement may be the + /// operation that settles it. + pub(crate) fn has_ready_deferred_load_completion(&self) -> bool { + self.pending_load + .as_ref() + .is_some_and(|pending| pending.readiness.is_terminal()) + } + + pub(crate) fn load_projection_precedes_browser_owner(&self, scheduler: &CdpScheduler) -> bool { + self.has_ready_deferred_load_completion() + || (!self.has_pending_load() + && scheduler.has_causally_ready_main_document_load_residence()) + } + + /// Receives the one exact adapter input needed to advance a causally ready + /// load projection before Browser Owner selection. + /// + /// A terminal wait bypasses an unrelated queued self turn. A ready load + /// residence that has not yet attached its fact ticket uses the existing + /// coalesced self-turn channel, preserving the producer-turn boundary + /// without a scheduler yield or sleep. + pub(crate) async fn recv_load_projection_predecessor_input( + &mut self, + scheduler: &CdpScheduler, + ) -> Option { + if !self.load_projection_precedes_browser_owner(scheduler) { + return None; + } + if self.has_ready_deferred_load_completion() { + return self.load_completion_rx.recv().await.map(|completion| { + ProtocolAdapterSchedulerInput::DeferredLoadCompletion(Box::new(completion)) + }); + } + self.schedule_turn_if_needed(scheduler, false); + Some(self.recv_input().await) + } + pub(crate) fn pending_load_attachment_mut(&mut self) -> Option<&mut A> { self.pending_load .as_mut() @@ -219,9 +261,11 @@ impl ProtocolAdapterScheduler { .expect("ready load residence must produce an exact pending observation"); let observation_id = pending.observation_id(); let output_interest = pending.output_interest(); + let readiness = pending.readiness(); self.pending_load = Some(PendingAdapterLoadObservation { observation_id, output_interest, + readiness, attachment: make_load_attachment(), }); self.spawn_load_wait(pending); @@ -241,8 +285,9 @@ impl ProtocolAdapterScheduler { /// An in-flight exact load observation reserves this driver's one load /// attachment slot; it does not precede unrelated scheduler work. /// `CdpScheduler` already makes exact `load_predecessors` authoritative. - /// Keeping no-predecessor owner actions runnable is required because a - /// replacement or termination action may itself settle the observation. + /// Keeping unrelated ready residences runnable is required because the + /// separately selected Browser Owner may publish the replacement or + /// termination fact that settles this ticket. fn next_scheduler_step(&self, scheduler: &CdpScheduler) -> ProtocolSchedulerStep { let step = scheduler.next_protocol_scheduler_step(); self.enforce_load_attachment_capacity( @@ -329,7 +374,8 @@ mod tests { CdpConnection, CdpSchedulerEvent, ProtocolSchedulerWork, test_support::{ deferred_main_document_load_observation_id, - deferred_main_document_load_output_interest, root_frame_stopped_loading_work, + deferred_main_document_load_output_interest, + reached_deferred_main_document_load_readiness, root_frame_stopped_loading_work, }, }; use tokio::task::LocalSet; @@ -359,7 +405,8 @@ mod tests { #[test] fn pending_exact_load_observation_allows_independent_protocol_residence() { let observation_id = deferred_main_document_load_observation_id(1); - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); scheduler.apply_scheduler_events(vec![CdpSchedulerEvent::ProtocolWorkPublished { work: protocol_observation(1), }]); @@ -370,6 +417,7 @@ mod tests { page_residence(), None, ), + readiness: reached_deferred_main_document_load_readiness(), attachment: (), }), ..Default::default() @@ -402,6 +450,7 @@ mod tests { page_residence(), None, ), + readiness: reached_deferred_main_document_load_readiness(), attachment: (), }), ..Default::default() @@ -441,7 +490,8 @@ mod tests { async fn self_turn_is_coalesced_and_preserves_the_client_turn_boundary() { LocalSet::new() .run_until(async { - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); scheduler.apply_scheduler_events(vec![CdpSchedulerEvent::ProtocolWorkPublished { work: protocol_observation(1), }]); @@ -496,6 +546,7 @@ mod tests { page_residence(), None, ), + readiness: reached_deferred_main_document_load_readiness(), attachment: AdapterMode::Classic, }), ..Default::default() diff --git a/moli-protocol-server/src/cdp_scheduler/context_disposal_dispatch.rs b/moli-protocol-server/src/cdp_scheduler/context_disposal_dispatch.rs new file mode 100644 index 0000000000..87da30ff13 --- /dev/null +++ b/moli-protocol-server/src/cdp_scheduler/context_disposal_dispatch.rs @@ -0,0 +1,212 @@ +use std::{future, future::Future, time::Duration}; + +use moli_protocol::{ + CompletedDevToolsBrowserOwnerContextDisposalCommand, + DevToolsBrowserOwnerContextDisposalCommandTaskStep, + PendingDevToolsBrowserOwnerContextDisposalCommand, + devtools_runtime::{DevToolsCommand, DevToolsError, DevToolsErrorKind}, +}; +use tokio::time::{Instant as TokioInstant, sleep_until}; + +use super::{ + CdpScheduler, CdpSchedulerEventReceivers, DevToolsCommandExecution, ProtocolOutputSequence, +}; + +pub(super) fn devtools_command_uses_browser_owner_context_disposal( + command: &DevToolsCommand, +) -> bool { + matches!(command, DevToolsCommand::RemoveBrowserContext(_)) +} + +impl CdpScheduler { + /// Waits for a typed Context-disposal result while continuing to select + /// Browser Host turns and independent protocol ingress. + pub(super) async fn execute_devtools_browser_owner_context_disposal_with_interleaved_progress( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + command: DevToolsCommand, + timeout: Option, + ) -> DevToolsCommandExecution { + let start = self + .host_adapter + .commands() + .try_start_devtools_browser_owner_context_disposal_command(command) + .await; + match start { + Err(command) => { + return self + .execute_devtools_command_with_protocol_messages_inner( + Some(receivers), + command, + false, + None, + ) + .await; + } + Ok(DevToolsBrowserOwnerContextDisposalCommandTaskStep::Complete(outcome)) => { + return self + .finish_devtools_command_dispatch_outcome_with_protocol_messages( + Some(receivers), + None, + *outcome, + false, + ) + .await; + } + Ok(DevToolsBrowserOwnerContextDisposalCommandTaskStep::Pending(pending)) => { + self.wait_for_devtools_browser_owner_context_disposal( + receivers, + *pending, + timeout.map(|timeout| TokioInstant::now() + timeout), + ) + .await + } + } + } + + async fn wait_for_devtools_browser_owner_context_disposal( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + pending: PendingDevToolsBrowserOwnerContextDisposalCommand, + deadline: Option, + ) -> DevToolsCommandExecution { + let mut protocol_output = ProtocolOutputSequence::empty(); + let mut completion = Box::pin(pending.wait()); + loop { + let ready_completion = std::future::poll_fn(|cx| { + std::task::Poll::Ready(match completion.as_mut().poll(cx) { + std::task::Poll::Ready(completed) => Some(completed), + std::task::Poll::Pending => None, + }) + }) + .await; + if let Some(completed) = ready_completion { + return self + .finish_devtools_browser_owner_context_disposal_completion( + receivers, + completed, + protocol_output, + ) + .await; + } + + let ready_output = self + .complete_ready_protocol_residences_after_command() + .await; + if !ready_output.is_empty() { + protocol_output.append(ready_output); + continue; + } + + let mut timed_out = false; + tokio::select! { + biased; + completed = &mut completion => { + return self + .finish_devtools_browser_owner_context_disposal_completion( + receivers, + completed, + protocol_output, + ) + .await; + } + _ = wait_until_context_disposal_deadline(deadline) => { + timed_out = true; + } + maybe_input = receivers.recv_interleaved_input(false) => { + let Some(input) = maybe_input else { + return DevToolsCommandExecution { + result: Err(DevToolsError::new( + DevToolsErrorKind::Internal, + "SchedulerInputClosed", + )), + protocol_output, + }; + }; + match self.complete_interleaved_scheduler_input(receivers, input).await { + Ok(output) => protocol_output.append(output), + Err(failure) => { + let (output, error) = failure.into_parts(); + protocol_output.append(output); + return DevToolsCommandExecution { + result: Err(error), + protocol_output, + }; + } + } + } + } + if timed_out { + // Dropping only the frontend receiver is intentional. The + // accepted Host task retains its reservation and continues; + // its terminal projection is settled as detached output. + drop(completion); + return DevToolsCommandExecution { + result: Err(DevToolsError::new( + DevToolsErrorKind::Timeout, + "script timed out", + )), + protocol_output, + }; + } + } + } + + async fn finish_devtools_browser_owner_context_disposal_completion( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + completed: CompletedDevToolsBrowserOwnerContextDisposalCommand, + mut protocol_output: ProtocolOutputSequence, + ) -> DevToolsCommandExecution { + let outcome = self + .host_adapter + .commands() + .complete_devtools_browser_owner_context_disposal_command(completed) + .await; + let mut execution = self + .finish_devtools_command_dispatch_outcome_with_protocol_messages( + Some(receivers), + None, + outcome, + false, + ) + .await; + protocol_output.append(execution.protocol_output); + execution.protocol_output = protocol_output; + execution + } +} + +async fn wait_until_context_disposal_deadline(deadline: Option) { + match deadline { + Some(deadline) => sleep_until(deadline).await, + None => future::pending().await, + } +} + +#[cfg(test)] +mod tests { + use moli_protocol::devtools_runtime::{ + DevToolsBrowserContextId, DevToolsCommandContext, DevToolsProtocol, + DevToolsRemoveBrowserContextCommand, + }; + + use super::*; + + #[test] + fn remove_browser_context_uses_interleaved_owner_dispatch() { + let command = DevToolsCommand::RemoveBrowserContext(DevToolsRemoveBrowserContextCommand { + context: DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverBidi, + session_id: None, + target_id: None, + browser_context_id: None, + }, + browser_context_id: DevToolsBrowserContextId::from("BID-owner"), + }); + + assert!(devtools_command_uses_browser_owner_context_disposal( + &command + )); + } +} diff --git a/moli-protocol-server/src/cdp_scheduler/fetch_dispatch.rs b/moli-protocol-server/src/cdp_scheduler/fetch_dispatch.rs new file mode 100644 index 0000000000..479fec054a --- /dev/null +++ b/moli-protocol-server/src/cdp_scheduler/fetch_dispatch.rs @@ -0,0 +1,221 @@ +use std::{future, future::Future, time::Duration}; + +use moli_protocol::{ + CompletedDevToolsFetchCommand, DevToolsFetchCommandTaskStep, PendingDevToolsFetchCommand, + devtools_runtime::{DevToolsCommand, DevToolsError, DevToolsErrorKind}, +}; +use tokio::time::{Instant as TokioInstant, sleep_until}; + +use super::{ + CdpScheduler, CdpSchedulerEventReceivers, DevToolsCommandExecution, ProtocolOutputSequence, +}; + +pub(super) fn devtools_command_uses_interleaved_fetch_dispatch(command: &DevToolsCommand) -> bool { + matches!( + command, + DevToolsCommand::ContinueInterceptedRequest(_) + | DevToolsCommand::ContinueInterceptedResponse(_) + | DevToolsCommand::ContinueWithAuth(_) + | DevToolsCommand::FailInterceptedRequest(_) + | DevToolsCommand::FulfillInterceptedRequest(_) + ) +} + +impl CdpScheduler { + /// Executes one typed Fetch terminal decision while keeping Browser Host + /// and independent protocol ingress live. + /// + /// Main-Document decisions enter the shared Browser Owner FIFO. A + /// subresource decision keeps its exact Page participant. Neither variant + /// borrows the physical connection while its participant is pending. + pub(super) async fn execute_devtools_fetch_with_interleaved_progress( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + command: DevToolsCommand, + timeout: Option, + ) -> DevToolsCommandExecution { + let start = self + .host_adapter + .commands() + .try_start_devtools_fetch_command_task(command) + .await; + match start { + Err(command) => { + return self + .execute_devtools_command_with_protocol_messages_inner( + Some(receivers), + command, + false, + None, + ) + .await; + } + Ok(DevToolsFetchCommandTaskStep::Complete(outcome)) => { + return self + .finish_devtools_command_dispatch_outcome_with_protocol_messages( + Some(receivers), + None, + *outcome, + false, + ) + .await; + } + Ok(DevToolsFetchCommandTaskStep::Pending(pending)) => { + self.wait_for_devtools_fetch_command( + receivers, + *pending, + timeout.map(|timeout| TokioInstant::now() + timeout), + ) + .await + } + } + } + + async fn wait_for_devtools_fetch_command( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + pending: PendingDevToolsFetchCommand, + deadline: Option, + ) -> DevToolsCommandExecution { + let navigation_gate_open = pending.holds_navigation_renderer_publication_gate(); + let mut protocol_output = ProtocolOutputSequence::empty(); + let mut completion = Box::pin(pending.wait()); + loop { + // The Host terminal turn can publish both the original navigation + // effects and this frontend completion. Observe the exact command + // completion before selecting unrelated post-command work. + let ready_completion = std::future::poll_fn(|cx| { + std::task::Poll::Ready(match completion.as_mut().poll(cx) { + std::task::Poll::Ready(completed) => Some(completed), + std::task::Poll::Pending => None, + }) + }) + .await; + if let Some(completed) = ready_completion { + return self + .finish_devtools_fetch_command_completion(receivers, completed, protocol_output) + .await; + } + + let ready_output = self + .complete_ready_protocol_residences_after_command() + .await; + if !ready_output.is_empty() { + protocol_output.append(ready_output); + continue; + } + + let mut timed_out = false; + tokio::select! { + biased; + completed = &mut completion => { + return self + .finish_devtools_fetch_command_completion( + receivers, + completed, + protocol_output, + ) + .await; + } + _ = wait_until_fetch_deadline(deadline) => { + timed_out = true; + } + maybe_input = receivers.recv_interleaved_input(navigation_gate_open) => { + let Some(input) = maybe_input else { + return DevToolsCommandExecution { + result: Err(DevToolsError::new( + DevToolsErrorKind::Internal, + "SchedulerInputClosed", + )), + protocol_output, + }; + }; + match self.complete_interleaved_scheduler_input(receivers, input).await { + Ok(output) => protocol_output.append(output), + Err(failure) => { + let (output, error) = failure.into_parts(); + protocol_output.append(output); + return DevToolsCommandExecution { + result: Err(error), + protocol_output, + }; + } + } + } + } + if timed_out { + // This abandons only the caller's typed reply wait; the Host + // or Page participant was already accepted and is not + // canceled through its completion receiver. A detached + // main-Document Host projection is settled by the reply-send + // fallback when this receiver has gone away. + drop(completion); + return DevToolsCommandExecution { + result: Err(DevToolsError::new( + DevToolsErrorKind::Timeout, + "script timed out", + )), + protocol_output, + }; + } + } + } + + async fn finish_devtools_fetch_command_completion( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + completed: CompletedDevToolsFetchCommand, + mut protocol_output: ProtocolOutputSequence, + ) -> DevToolsCommandExecution { + let outcome = self + .host_adapter + .commands() + .complete_devtools_fetch_command_task(completed) + .await; + let mut execution = self + .finish_devtools_command_dispatch_outcome_with_protocol_messages( + Some(receivers), + None, + outcome, + false, + ) + .await; + protocol_output.append(execution.protocol_output); + execution.protocol_output = protocol_output; + execution + } +} + +async fn wait_until_fetch_deadline(deadline: Option) { + match deadline { + Some(deadline) => sleep_until(deadline).await, + None => future::pending().await, + } +} + +#[cfg(test)] +mod tests { + use moli_protocol::devtools_runtime::{ + DevToolsCommandContext, DevToolsFailInterceptedRequestCommand, DevToolsProtocol, + DevToolsRequestId, + }; + + use super::*; + + #[test] + fn terminal_fetch_decision_uses_interleaved_dispatch() { + let command = + DevToolsCommand::FailInterceptedRequest(DevToolsFailInterceptedRequestCommand { + context: DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverBidi, + session_id: None, + target_id: None, + browser_context_id: None, + }, + request_id: DevToolsRequestId::from("request-owner"), + error_text: "Aborted".to_owned(), + }); + + assert!(devtools_command_uses_interleaved_fetch_dispatch(&command)); + } +} diff --git a/moli-protocol-server/src/cdp_scheduler/frontend_control.rs b/moli-protocol-server/src/cdp_scheduler/frontend_control.rs index c20de2ba8d..74a7dde645 100644 --- a/moli-protocol-server/src/cdp_scheduler/frontend_control.rs +++ b/moli-protocol-server/src/cdp_scheduler/frontend_control.rs @@ -1,5 +1,7 @@ -use moli_cookie_jar::StoredCookie; -use tokio::sync::mpsc; +use std::collections::HashMap; + +use anyhow::Result; +use tokio::sync::{mpsc, oneshot}; use crate::{cdp_frontend::CdpFrontendControlRequest, cdp_frontend_router::CdpFrontendRouter}; @@ -8,31 +10,15 @@ use super::CdpScheduler; mod target_control; -#[derive(Debug, Clone, Default)] -pub(crate) struct CdpCookieSnapshot { - profile_backed_cookies: Option>, -} - -impl CdpCookieSnapshot { - pub(crate) fn from_profile_backed_cookies(cookies: Option>) -> Self { - Self { - profile_backed_cookies: cookies, - } - } - - pub(crate) fn into_profile_backed_cookies(self) -> Option> { - self.profile_backed_cookies - } -} - pub(crate) struct CdpOwnerActorLifecycle { - pub(crate) checkpoint_tx: mpsc::UnboundedSender, + pub(crate) profile_flush_tx: mpsc::UnboundedSender<()>, } #[derive(Default)] pub(super) struct CdpFrontendControlState { next_frontend_id: u64, target_control: CdpFrontendTargetControl, + pending_target_close_completions: HashMap>>>, } impl CdpFrontendControlState { @@ -85,7 +71,7 @@ impl CdpFrontendControlState { .detach_frontend_session(scheduler, frontend_router, &session_id) .await; } - send_cookie_checkpoint(scheduler, owner_lifecycle); + send_profile_flush_request(owner_lifecycle); } Err(Err(_)) => {} } @@ -134,7 +120,7 @@ impl CdpFrontendControlState { .detach_frontend_session(scheduler, frontend_router, &session_id) .await; } - send_cookie_checkpoint(scheduler, owner_lifecycle); + send_profile_flush_request(owner_lifecycle); } Err(Err(_)) => {} } @@ -145,7 +131,7 @@ impl CdpFrontendControlState { self.target_control .detach_frontend_session(scheduler, frontend_router, &session_id) .await; - send_cookie_checkpoint(scheduler, owner_lifecycle); + send_profile_flush_request(owner_lifecycle); } true } @@ -154,12 +140,18 @@ impl CdpFrontendControlState { self.target_control .detach_frontend_session(scheduler, frontend_router, &session_id) .await; - send_cookie_checkpoint(scheduler, owner_lifecycle); + send_profile_flush_request(owner_lifecycle); } true } CdpFrontendControlRequest::TargetDestroyed { target_id } => { frontend_router.unregister_frontends_for_target(&target_id); + if let Some(completions) = self.pending_target_close_completions.remove(&target_id) + { + for completion in completions { + let _ = completion.send(Ok(())); + } + } true } CdpFrontendControlRequest::ActivateTarget { @@ -181,8 +173,17 @@ impl CdpFrontendControlState { .target_control .close_target(scheduler, frontend_router, &target_id) .await; - let _ = completion_tx.send(result); - send_cookie_checkpoint(scheduler, owner_lifecycle); + match result { + Ok(()) => self + .pending_target_close_completions + .entry(target_id) + .or_default() + .push(completion_tx), + Err(error) => { + let _ = completion_tx.send(Err(error)); + } + } + send_profile_flush_request(owner_lifecycle); true } CdpFrontendControlRequest::CreateManagedTarget { @@ -199,7 +200,7 @@ impl CdpFrontendControlState { .close_target(scheduler, frontend_router, &target.page_target_id) .await; } - send_cookie_checkpoint(scheduler, owner_lifecycle); + send_profile_flush_request(owner_lifecycle); true } CdpFrontendControlRequest::EnsureDefaultTarget { completion_tx } => { @@ -223,16 +224,11 @@ impl CdpFrontendControlState { } } -fn send_cookie_checkpoint( - scheduler: &mut CdpScheduler, - owner_lifecycle: Option<&CdpOwnerActorLifecycle>, -) { +fn send_profile_flush_request(owner_lifecycle: Option<&CdpOwnerActorLifecycle>) { let Some(owner_lifecycle) = owner_lifecycle else { return; }; - let snapshot = - CdpCookieSnapshot::from_profile_backed_cookies(scheduler.snapshot_profile_backed_cookies()); - let _ = owner_lifecycle.checkpoint_tx.send(snapshot); + let _ = owner_lifecycle.profile_flush_tx.send(()); } #[cfg(test)] diff --git a/moli-protocol-server/src/cdp_scheduler/frontend_control/target_control.rs b/moli-protocol-server/src/cdp_scheduler/frontend_control/target_control.rs index 9aa7b64a99..cdc155e7e1 100644 --- a/moli-protocol-server/src/cdp_scheduler/frontend_control/target_control.rs +++ b/moli-protocol-server/src/cdp_scheduler/frontend_control/target_control.rs @@ -28,7 +28,7 @@ impl CdpFrontendTargetControl { frontend_router: &CdpFrontendRouter, target_id: &str, ) -> Result { - if target_id == scheduler.conn.default_tab_target_id() { + if target_id == scheduler.host_adapter.view().default_tab_target_id() { self.ensure_default_target_is_materialized(scheduler, frontend_router) .await?; } @@ -58,7 +58,7 @@ impl CdpFrontendTargetControl { .as_str() .with_context(|| format!("CDP target {target_id} did not return an attach session"))? .to_owned(); - if target_id == scheduler.conn.default_target_id() { + if target_id == scheduler.host_adapter.view().default_target_id() { self.default_page_materialized = true; } Ok(session_id) @@ -145,7 +145,8 @@ impl CdpFrontendTargetControl { .map(str::to_owned) .context("Target.createTarget returned no targetId")?; let tab_target_id = scheduler - .conn + .host_adapter + .view() .tab_target_id_for_page_target_id(&page_target_id) .map(str::to_owned) .context("created page target has no tab target")?; @@ -212,7 +213,7 @@ impl CdpFrontendTargetControl { // A fresh connection may reuse its unclaimed default placeholder for // the first createTarget. The protocol server publishes that default // ID permanently, so materialize it before creating another target. - let default_target_id = scheduler.conn.default_target_id().to_owned(); + let default_target_id = scheduler.host_adapter.view().default_target_id().to_owned(); let control_session_id = self .ensure_browser_control_session(scheduler, frontend_router) .await?; @@ -260,7 +261,8 @@ impl CdpFrontendTargetControl { command["sessionId"] = json!(session_id); } let outcome = scheduler - .conn + .host_adapter + .commands() .process_message_with_turn_outcome_async(&command.to_string()) .await; let (protocol_events, scheduler_events, renderer_output_predecessor) = diff --git a/moli-protocol-server/src/cdp_scheduler/navigation_dispatch.rs b/moli-protocol-server/src/cdp_scheduler/navigation_dispatch.rs new file mode 100644 index 0000000000..73077b186c --- /dev/null +++ b/moli-protocol-server/src/cdp_scheduler/navigation_dispatch.rs @@ -0,0 +1,268 @@ +use std::{ + future::{self, Future}, + time::Duration, +}; + +use moli_protocol::{ + CompletedDevToolsBrowserOwnerNavigationCommand, DevToolsBrowserOwnerNavigationCommandTaskStep, + PendingDevToolsBrowserOwnerNavigationCommand, + devtools_runtime::{DevToolsCommand, DevToolsError, DevToolsErrorKind}, +}; +use tokio::time::{Instant as TokioInstant, sleep_until}; + +use super::{ + CdpScheduler, CdpSchedulerEventReceivers, DevToolsCommandExecution, ProtocolOutputSequence, +}; + +pub(super) fn devtools_command_uses_browser_owner_dispatch(command: &DevToolsCommand) -> bool { + matches!( + command, + DevToolsCommand::Navigate(_) + | DevToolsCommand::Reload(_) + | DevToolsCommand::TraverseHistory(_) + ) +} + +impl CdpScheduler { + /// Executes one direct frontend navigation while continuing to service the + /// independently-owned Browser Host lane. + /// + /// A child-frame navigation is returned by Protocol as not Browser-owned + /// and keeps its existing Page/renderer path. Once a top-level command is + /// published, only Host turns and exact participant completions can advance + /// it; this loop merely waits for the neutral result and projects outputs. + pub(super) async fn execute_devtools_browser_owner_navigation_with_interleaved_progress( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + command: DevToolsCommand, + timeout: Option, + background_command_id: Option, + ) -> DevToolsCommandExecution { + let navigation_wait = super::devtools_navigation_wait(&command); + let start = self + .host_adapter + .commands() + .try_start_devtools_browser_owner_navigation_command(command, background_command_id) + .await; + match start { + Err(command) => { + return self + .execute_non_browser_owner_navigation_with_timeout( + receivers, + command, + timeout, + background_command_id, + ) + .await; + } + Ok(DevToolsBrowserOwnerNavigationCommandTaskStep::Complete(outcome)) => { + return self + .finish_devtools_command_dispatch_outcome_with_protocol_messages( + Some(receivers), + navigation_wait, + *outcome, + false, + ) + .await; + } + Ok(DevToolsBrowserOwnerNavigationCommandTaskStep::Pending(pending)) => { + self.wait_for_devtools_browser_owner_navigation( + receivers, + *pending, + navigation_wait, + timeout.map(|timeout| TokioInstant::now() + timeout), + ) + .await + } + } + } + + async fn execute_non_browser_owner_navigation_with_timeout( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + command: DevToolsCommand, + timeout: Option, + background_command_id: Option, + ) -> DevToolsCommandExecution { + let execution = self.execute_devtools_command_with_protocol_messages_inner( + Some(receivers), + command, + false, + background_command_id, + ); + let Some(timeout) = timeout else { + return execution.await; + }; + match tokio::time::timeout(timeout, execution).await { + Ok(execution) => execution, + Err(_) => navigation_timeout_execution(ProtocolOutputSequence::empty()), + } + } + + async fn wait_for_devtools_browser_owner_navigation( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + pending: PendingDevToolsBrowserOwnerNavigationCommand, + navigation_wait: Option, + deadline: Option, + ) -> DevToolsCommandExecution { + let mut protocol_output = ProtocolOutputSequence::empty(); + let mut completion = Box::pin(pending.wait()); + loop { + // A terminal Browser Host apply sends the neutral frontend result + // synchronously. Observe that exact result before selecting + // post-command protocol work published by the same apply turn; + // otherwise a deferred load action can overtake the response and + // make its renderer publication look already observed. + let ready_completion = std::future::poll_fn(|cx| { + std::task::Poll::Ready(match completion.as_mut().poll(cx) { + std::task::Poll::Ready(completed) => Some(completed), + std::task::Poll::Pending => None, + }) + }) + .await; + if let Some(completed) = ready_completion { + return self + .finish_devtools_browser_owner_navigation_completion( + receivers, + completed, + navigation_wait, + protocol_output, + ) + .await; + } + + let ready_output = self + .complete_ready_protocol_residences_after_command() + .await; + if !ready_output.is_empty() { + protocol_output.append(ready_output); + continue; + } + + // This foreground command owns the same commit-before-renderer + // ingress invariant as a background navigation. Do not dequeue + // the new Page's renderer stream before the terminal Browser Host + // projection installs its exact root-Document binding and exposes + // the stream's release boundary. + let navigation_gate_open = true; + let mut timed_out = false; + tokio::select! { + biased; + completed = &mut completion => { + return self + .finish_devtools_browser_owner_navigation_completion( + receivers, + completed, + navigation_wait, + protocol_output, + ) + .await; + } + _ = wait_until_navigation_deadline(deadline) => { + timed_out = true; + } + maybe_input = receivers.recv_interleaved_input(navigation_gate_open) => { + let Some(input) = maybe_input else { + return DevToolsCommandExecution { + result: Err(DevToolsError::new( + DevToolsErrorKind::Internal, + "SchedulerInputClosed", + )), + protocol_output, + }; + }; + // Once a concrete input is dequeued, finish its projection + // before racing the frontend completion or timeout again. + match self.complete_interleaved_scheduler_input(receivers, input).await { + Ok(output) => protocol_output.append(output), + Err(failure) => { + let (output, error) = failure.into_parts(); + protocol_output.append(output); + return DevToolsCommandExecution { + result: Err(error), + protocol_output, + }; + } + } + } + } + if timed_out { + receivers + .browser_host + .detach_navigation_completion(completion); + return navigation_timeout_execution(protocol_output); + } + } + } + + async fn finish_devtools_browser_owner_navigation_completion( + &mut self, + receivers: &mut CdpSchedulerEventReceivers, + completed: CompletedDevToolsBrowserOwnerNavigationCommand, + navigation_wait: Option, + mut protocol_output: ProtocolOutputSequence, + ) -> DevToolsCommandExecution { + let outcome = self + .host_adapter + .commands() + .complete_devtools_browser_owner_navigation_command(completed) + .await; + let mut execution = self + .finish_devtools_command_dispatch_outcome_with_protocol_messages( + Some(receivers), + navigation_wait, + outcome, + false, + ) + .await; + protocol_output.append(execution.protocol_output); + execution.protocol_output = protocol_output; + execution + } +} + +async fn wait_until_navigation_deadline(deadline: Option) { + match deadline { + Some(deadline) => sleep_until(deadline).await, + None => future::pending().await, + } +} + +fn navigation_timeout_execution( + protocol_output: ProtocolOutputSequence, +) -> DevToolsCommandExecution { + DevToolsCommandExecution { + result: Err(DevToolsError::new( + DevToolsErrorKind::Timeout, + "script timed out", + )), + protocol_output, + } +} + +#[cfg(test)] +mod tests { + use moli_protocol::devtools_runtime::{ + DevToolsCommand, DevToolsCommandContext, DevToolsHistoryTraversalDestination, + DevToolsNavigationWait, DevToolsProtocol, DevToolsTargetId, DevToolsTraverseHistoryCommand, + }; + + use super::devtools_command_uses_browser_owner_dispatch; + + #[test] + fn history_delta_enters_browser_owner_dispatch() { + let command = DevToolsCommand::TraverseHistory(DevToolsTraverseHistoryCommand { + context: DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverBidi, + session_id: None, + target_id: Some(DevToolsTargetId::from("target-history")), + browser_context_id: None, + }, + destination: DevToolsHistoryTraversalDestination::Delta(-1), + wait: DevToolsNavigationWait::Load, + }); + + assert!(devtools_command_uses_browser_owner_dispatch(&command)); + } +} diff --git a/moli-protocol-server/src/cdp_scheduler/owner_inputs.rs b/moli-protocol-server/src/cdp_scheduler/owner_inputs.rs new file mode 100644 index 0000000000..682303a8fe --- /dev/null +++ b/moli-protocol-server/src/cdp_scheduler/owner_inputs.rs @@ -0,0 +1,476 @@ +use std::{ + collections::{HashSet, VecDeque}, + future::Future, + pin::Pin, +}; + +use moli_core::{ + RendererOutputItem, RendererOutputStreamIdentity, RendererOutputTransportMessage, + RendererProtocolObservation, + browser_host::{BrowserFactSequence, BrowserFactWakeSubscriber}, +}; +use moli_protocol::{ + BackgroundNavigationCompletion, BackgroundProtocolEvent, BrowserBackgroundOutputReceiver, + CompletedBrowserHostTurn, CompletedDevToolsBrowserOwnerNavigationCommand, +}; +use tokio::sync::mpsc; + +use crate::browser_host::{BrowserHostOwnerLane, BrowserHostOwnerWake}; + +pub(crate) type CdpBackgroundEventReceiver = BrowserBackgroundOutputReceiver; +pub(crate) type CdpBackgroundNavigationCompletionReceiver = + mpsc::UnboundedReceiver; +pub(crate) type CdpRendererPublicationReceiver = moli_core::RendererOutputTransportReceiver; + +/// The exact renderer transport capability needed to cross an owner-turn +/// predecessor. +/// +/// Both the persistent service loop and the CDP actor own this same +/// capability, although the latter stores its Browser Host lane separately. +/// Keeping the boundary as a trait prevents either loop from regaining access +/// to the rest of the other's input mux. +pub(crate) trait RendererTransportSource { + fn renderer_publication_receiver(&self) -> &CdpRendererPublicationReceiver; + + fn admit_or_buffer_renderer_publication( + &mut self, + publication: RendererOutputTransportMessage, + navigation_gate_open: bool, + released_navigation_stream: Option, + ) -> Option; + + fn receive_concrete_renderer_transport( + &mut self, + predecessor_stream: RendererOutputStreamIdentity, + releases_navigation_stream: bool, + ) -> impl Future>; +} + +/// Holds only renderer publications whose main-Document fact arrived before +/// the independently transported Browser commit. +/// +/// Stream controls are never parked here, and fences for other streams can +/// skip a held publication without reordering records inside either stream. +/// The exact navigation boundary releases only its own stream. +#[derive(Default)] +pub(crate) struct NavigationRendererPublicationBuffer { + publications: VecDeque, + blocked_streams: HashSet, +} + +impl NavigationRendererPublicationBuffer { + pub(crate) fn take_releasable( + &mut self, + navigation_gate_open: bool, + ) -> Option { + if !navigation_gate_open { + self.blocked_streams.clear(); + return self.publications.pop_front(); + } + let position = self.publications.iter().position(|publication| { + renderer_publication_stream(publication) + .is_none_or(|stream| !self.blocked_streams.contains(&stream)) + })?; + self.publications.remove(position) + } + + pub(crate) fn admit_or_buffer( + &mut self, + publication: RendererOutputTransportMessage, + navigation_gate_open: bool, + released_navigation_stream: Option, + ) -> Option { + let Some(stream) = renderer_publication_stream(&publication) else { + return Some(publication); + }; + if released_navigation_stream == Some(stream) { + self.blocked_streams.remove(&stream); + return Some(publication); + } + if !navigation_gate_open { + return Some(publication); + } + if renderer_main_document_commit_stream(&publication) == Some(stream) { + self.blocked_streams.insert(stream); + } + if !self.blocked_streams.contains(&stream) { + return Some(publication); + } + self.publications.push_back(publication); + None + } + + pub(crate) fn take_for_predecessor( + &mut self, + predecessor_stream: RendererOutputStreamIdentity, + releases_navigation_stream: bool, + ) -> Option { + if releases_navigation_stream { + self.blocked_streams.remove(&predecessor_stream); + } + let position = self.publications.iter().position(|publication| { + renderer_publication_stream(publication) == Some(predecessor_stream) + && !self.blocked_streams.contains(&predecessor_stream) + })?; + self.publications.remove(position) + } +} + +/// CDP/frontend input mux around the independent Browser Host owner lane. +/// +/// Detached navigation command completions remain frontend correlation state; +/// they are intentionally stored here rather than in `BrowserHostOwnerLane`. +pub(crate) struct BrowserHostExecutionLane { + owner: BrowserHostOwnerLane, + detached_navigation_completions: Vec< + Pin + 'static>>, + >, +} + +pub(crate) enum BrowserHostExecutionWake { + TurnSelected, + ParticipantCompleted(Box), + DetachedNavigationCompleted(Box), + Closed, +} + +impl BrowserHostExecutionLane { + pub(crate) fn new(actor: moli_core::browser_host::BrowserHostActor) -> Self { + Self { + owner: BrowserHostOwnerLane::new(actor), + detached_navigation_completions: Vec::new(), + } + } + + pub(crate) fn has_ready_input(&self) -> bool { + self.owner.has_ready_input() + } + + pub(crate) fn has_detached_navigation_wait(&self) -> bool { + !self.detached_navigation_completions.is_empty() + } + + pub(crate) fn detach_navigation_completion( + &mut self, + completion: Pin< + Box + 'static>, + >, + ) { + self.detached_navigation_completions.push(completion); + } + + pub(crate) async fn recv_wake(&mut self) -> BrowserHostExecutionWake { + let owner = &mut self.owner; + let detached_navigation_completions = &mut self.detached_navigation_completions; + tokio::select! { + biased; + completed = poll_detached_navigation_completion(detached_navigation_completions), if !detached_navigation_completions.is_empty() => { + BrowserHostExecutionWake::DetachedNavigationCompleted(Box::new(completed)) + } + wake = owner.recv_wake() => { + match wake { + BrowserHostOwnerWake::TurnSelected => BrowserHostExecutionWake::TurnSelected, + BrowserHostOwnerWake::ParticipantCompleted(completed) => { + BrowserHostExecutionWake::ParticipantCompleted(completed) + } + BrowserHostOwnerWake::Closed => BrowserHostExecutionWake::Closed, + } + } + } + } + + pub(crate) fn start_next_turn( + &mut self, + host_adapter: &mut moli_protocol::DevToolsHostAdapter, + ) -> Option { + self.owner.start_next_turn(host_adapter) + } + + pub(crate) async fn complete_turn( + &mut self, + host_adapter: &mut moli_protocol::DevToolsHostAdapter, + completed: CompletedBrowserHostTurn, + ) -> moli_protocol::CdpRendererOwnerTurnOutcome { + self.owner.complete_turn(host_adapter, completed).await + } +} + +async fn poll_detached_navigation_completion( + completions: &mut Vec< + Pin + 'static>>, + >, +) -> CompletedDevToolsBrowserOwnerNavigationCommand { + std::future::poll_fn(|cx| { + for index in 0..completions.len() { + if let std::task::Poll::Ready(completed) = completions[index].as_mut().poll(cx) { + drop(completions.swap_remove(index)); + return std::task::Poll::Ready(completed); + } + } + std::task::Poll::Pending + }) + .await +} + +/// Application-side input residences that can wake one browser/protocol owner +/// loop. +/// +/// The Browser Host actor deliberately lives here rather than in +/// `CdpScheduler`: the scheduler is a physical/protocol projection adapter, +/// while this composition-side value owns the mailbox receiver and selects +/// exact Browser Host turns independently of frontend input readiness. +pub(crate) struct CdpSchedulerEventReceivers { + pub(crate) browser_host: BrowserHostExecutionLane, + pub(crate) browser_fact_wake_rx: BrowserFactWakeSubscriber, + pub(crate) background_event_rx: CdpBackgroundEventReceiver, + pub(crate) background_navigation_completion_rx: CdpBackgroundNavigationCompletionReceiver, + pub(crate) renderer_publication_rx: CdpRendererPublicationReceiver, + pub(crate) navigation_renderer_publications: NavigationRendererPublicationBuffer, +} + +/// One move-owned input selected while direct CDP/WebDriver execution waits +/// for browser/protocol progress. +/// +/// Receiving and applying remain separate: once a branch dequeues a concrete +/// renderer/protocol value, the caller completes it before selecting again. +/// The Browser Host start variant contains no raw owner input. Its exact turn +/// stays inside `BrowserHostActor` until the caller invokes the synchronous +/// start adapter. A participant completion is already move-owned and can only +/// resume the exact pending operation that produced it. +pub(crate) enum CdpSchedulerInterleavedInput { + BrowserHostTurn, + BrowserHostCompletion(Box), + DetachedNavigationCompletion(Box), + BrowserFactWake(BrowserFactSequence), + BackgroundNavigationCompletion(BackgroundNavigationCompletion), + BackgroundEvent(BackgroundProtocolEvent), + RendererPublication { + publication: RendererOutputTransportMessage, + navigation_gate_open: bool, + }, +} + +impl CdpSchedulerEventReceivers { + pub(crate) fn renderer_navigation_gate_open( + &self, + background_navigation_gate_open: bool, + ) -> bool { + background_navigation_gate_open || self.browser_host.has_detached_navigation_wait() + } + + pub(crate) fn take_buffered_renderer_publication( + &mut self, + navigation_gate_open: bool, + ) -> Option { + self.navigation_renderer_publications + .take_releasable(navigation_gate_open) + } + + pub(crate) fn admit_or_buffer_navigation_renderer_publication( + &mut self, + publication: RendererOutputTransportMessage, + navigation_gate_open: bool, + released_navigation_stream: Option, + ) -> Option { + self.navigation_renderer_publications.admit_or_buffer( + publication, + navigation_gate_open, + released_navigation_stream, + ) + } + + pub(crate) async fn recv_concrete_renderer_transport( + &mut self, + predecessor_stream: RendererOutputStreamIdentity, + releases_navigation_stream: bool, + ) -> Option { + if let Some(publication) = self + .navigation_renderer_publications + .take_for_predecessor(predecessor_stream, releases_navigation_stream) + { + return Some(publication); + } + self.renderer_publication_rx.recv().await + } + + pub(crate) async fn recv_interleaved_input( + &mut self, + navigation_gate_open: bool, + ) -> Option { + let navigation_gate_open = self.renderer_navigation_gate_open(navigation_gate_open); + if let Some(publication) = self.take_buffered_renderer_publication(navigation_gate_open) { + return Some(CdpSchedulerInterleavedInput::RendererPublication { + publication, + navigation_gate_open, + }); + } + tokio::select! { + biased; + wake = self.browser_host.recv_wake() => { + match wake { + BrowserHostExecutionWake::TurnSelected => { + Some(CdpSchedulerInterleavedInput::BrowserHostTurn) + } + BrowserHostExecutionWake::ParticipantCompleted(completed) => { + Some(CdpSchedulerInterleavedInput::BrowserHostCompletion(completed)) + } + BrowserHostExecutionWake::DetachedNavigationCompleted(completed) => { + Some(CdpSchedulerInterleavedInput::DetachedNavigationCompletion(completed)) + } + BrowserHostExecutionWake::Closed => None, + } + } + wake = self.browser_fact_wake_rx.recv() => { + wake.ok().map(CdpSchedulerInterleavedInput::BrowserFactWake) + } + maybe_completion = self.background_navigation_completion_rx.recv() => { + maybe_completion.map( + CdpSchedulerInterleavedInput::BackgroundNavigationCompletion, + ) + } + maybe_event = self.background_event_rx.recv() => { + maybe_event.map(CdpSchedulerInterleavedInput::BackgroundEvent) + } + // A navigation gate controls admission of the exact replacement + // stream, not receipt of every renderer stream. Old-Document + // terminal facts and stream controls must keep reaching the + // protocol owner while the replacement participant is pending. + maybe_publication = self.renderer_publication_rx.recv() => { + maybe_publication.map(|publication| { + CdpSchedulerInterleavedInput::RendererPublication { + publication, + navigation_gate_open, + } + }) + } + } + } +} + +impl RendererTransportSource for CdpSchedulerEventReceivers { + fn renderer_publication_receiver(&self) -> &CdpRendererPublicationReceiver { + &self.renderer_publication_rx + } + + fn admit_or_buffer_renderer_publication( + &mut self, + publication: RendererOutputTransportMessage, + navigation_gate_open: bool, + released_navigation_stream: Option, + ) -> Option { + self.admit_or_buffer_navigation_renderer_publication( + publication, + navigation_gate_open, + released_navigation_stream, + ) + } + + async fn receive_concrete_renderer_transport( + &mut self, + predecessor_stream: RendererOutputStreamIdentity, + releases_navigation_stream: bool, + ) -> Option { + self.recv_concrete_renderer_transport(predecessor_stream, releases_navigation_stream) + .await + } +} + +fn renderer_publication_stream( + message: &RendererOutputTransportMessage, +) -> Option { + match message { + RendererOutputTransportMessage::Publication(publication) => { + Some(publication.cursor().stream()) + } + RendererOutputTransportMessage::StreamControl(_) + | RendererOutputTransportMessage::PageReservationReleased { .. } + | RendererOutputTransportMessage::CursorLeaseDeclared { .. } + | RendererOutputTransportMessage::CursorLeaseReleased { .. } => None, + } +} + +fn renderer_main_document_commit_stream( + message: &RendererOutputTransportMessage, +) -> Option { + let RendererOutputTransportMessage::Publication(publication) = message else { + return None; + }; + publication + .records() + .iter() + .any(|record| { + matches!( + record.item(), + RendererOutputItem::Observation(RendererProtocolObservation::MainDocumentCommit(_)) + ) + }) + .then(|| publication.cursor().stream()) +} + +#[cfg(test)] +mod tests { + use moli_core::{ + PageId, RendererOutputCursor, RendererOutputPublication, RendererOutputRecord, + RendererOutputStreamControl, page::RendererMainDocumentCommit, + }; + + use super::*; + + #[test] + fn navigation_publication_buffer_releases_only_the_exact_commit_stream() { + let navigation_stream = + RendererOutputStreamIdentity::new_page_for_protocol_test(PageId::new_for_testing(801)); + let unrelated_stream = + RendererOutputStreamIdentity::new_page_for_protocol_test(PageId::new_for_testing(802)); + let commit_publication: RendererOutputTransportMessage = + RendererOutputPublication::new_for_test( + RendererOutputCursor::new_for_test(navigation_stream, 1), + vec![RendererOutputRecord::new_for_test( + RendererOutputItem::Observation( + RendererProtocolObservation::MainDocumentCommit( + RendererMainDocumentCommit { + frame_id: "frame-navigation-buffer".to_owned(), + loader_id: "loader-navigation-buffer".to_owned(), + url: "https://example.test/navigation-buffer".to_owned(), + unreachable_url: None, + security_origin: "https://example.test".to_owned(), + secure_context_type: "Secure".to_owned(), + timestamp: 1.0, + }, + ), + ), + )], + ) + .into(); + let mut buffer = NavigationRendererPublicationBuffer::default(); + + assert!( + buffer + .admit_or_buffer(commit_publication.clone(), true, None) + .is_none(), + "a main-Document publication must wait for the Browser commit" + ); + assert!(buffer.take_releasable(true).is_none()); + assert!( + buffer + .take_for_predecessor(unrelated_stream, false) + .is_none(), + "an unrelated command fence must skip the held navigation stream" + ); + + let control: RendererOutputTransportMessage = RendererOutputStreamControl::Opened { + stream: unrelated_stream, + } + .into(); + assert_eq!( + buffer.admit_or_buffer(control.clone(), true, None), + Some(control), + "stream controls remain admissible while a navigation commit is pending" + ); + assert_eq!( + buffer.take_for_predecessor(navigation_stream, true), + Some(commit_publication), + "the exact navigation boundary releases its own concrete publication" + ); + } +} diff --git a/moli-protocol-server/src/cdp_scheduler/protocol_residence.rs b/moli-protocol-server/src/cdp_scheduler/protocol_residence.rs index 19d00a3bc6..ea8da8e71a 100644 --- a/moli-protocol-server/src/cdp_scheduler/protocol_residence.rs +++ b/moli-protocol-server/src/cdp_scheduler/protocol_residence.rs @@ -12,7 +12,7 @@ use super::ProtocolOutputSequence; /// /// This is deliberately not a generic source signal. Every renderer /// publication has already been frozen before this decision is made, and -/// the queue contains only frozen output or an exact browser-owner action. +/// the queue contains only frozen output or an exact protocol continuation. /// The adapter may therefore either wait, satisfy the explicit client-turn /// predecessor, or complete one ready residence. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -40,12 +40,12 @@ pub(super) enum ProtocolSchedulerResidence { /// are its client-turn boundary and, when present, exact main-document /// load observations. RendererOutputPublication(RendererOutputPublicationWork), - /// A concrete browser-owner action or protocol continuation. + /// A concrete Browser-fact projection or explicit protocol continuation. /// /// The payload already owns its exact route and lifetime identity. The /// client-turn predecessor controls only when the scheduler may select it. ProtocolWork { - work: ProtocolSchedulerWork, + work: Box, client_turn_predecessor: ClientTurnPredecessor, /// Exact Page/Document scope in which a load action published by the /// still-pending command turn may replace the provisional @@ -115,7 +115,7 @@ impl ProtocolSchedulerResidence { ) } - pub(super) fn should_yield_to_client_turn(&self) -> bool { + fn should_yield_to_client_turn(&self) -> bool { matches!( self, Self::RendererOutputPublication(RendererOutputPublicationWork { @@ -205,17 +205,15 @@ impl ProtocolSchedulerResidence { #[derive(Debug)] pub(super) struct SchedulerQueues { - /// Concrete protocol output and owner work in scheduler admission order. + /// Concrete protocol output and owner work in scheduler selection order. /// /// Renderer source publications are concrete before admission. A projected /// event batch may briefly accept the load observation still being /// published by the current command turn; once exact, concrete owner work /// from the same ingress turn inherits that predecessor as well. Publication /// stream cursor remains the admission invariant, while an exact - /// predecessor may deliberately insert its load owner action before work - /// that was admitted earlier. Selection normally follows this order; a - /// target-local navigation gate may let an unrelated target advance while - /// preserving order within each target lane. + /// predecessor may deliberately insert its load fact projection before work + /// that was admitted earlier. pub(super) protocol_residences: VecDeque, next_protocol_work_publish_sequence: u64, } @@ -234,6 +232,12 @@ impl SchedulerQueues { self.protocol_residences.len() } + pub(super) fn front_bypasses_inflight_navigation_gate(&self) -> bool { + self.protocol_residences + .front() + .is_some_and(ProtocolSchedulerResidence::bypasses_inflight_navigation_gate) + } + pub(super) fn enqueue_renderer_output_publication( &mut self, renderer_output_cursor: RendererOutputCursor, @@ -300,7 +304,7 @@ impl SchedulerQueues { self.protocol_residences.insert( insertion_index, ProtocolSchedulerResidence::ProtocolWork { - work, + work: Box::new(work), client_turn_predecessor: ClientTurnPredecessor::Pending, load_predecessors, future_load_predecessor: if load_observation_id.is_none() { @@ -374,21 +378,6 @@ impl SchedulerQueues { self.protocol_residences.pop_front() } - pub(super) fn satisfy_client_turn_predecessor_at(&mut self, index: usize) { - self.close_future_load_predecessor_window(); - self.protocol_residences - .get_mut(index) - .expect("selected protocol residence must still exist") - .mark_client_turn_yielded(); - } - - pub(super) fn take_protocol_residence_at( - &mut self, - index: usize, - ) -> Option { - self.protocol_residences.remove(index) - } - fn take_snapshot( &mut self, mut selected: impl FnMut(&ProtocolSchedulerResidence) -> bool, @@ -518,7 +507,6 @@ fn protocol_residence_is_external_load_wait_activity( if future_load_predecessor.is_none() && load_predecessors.is_empty() && (work.is_root_frame_stopped_loading() - || work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction - || work.is_top_level_location_navigation_owner_action()) + || work.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection) ) } diff --git a/moli-protocol-server/src/cdp_scheduler/runtime_dispatch.rs b/moli-protocol-server/src/cdp_scheduler/runtime_dispatch.rs index fa078314e2..c77ba3d8ac 100644 --- a/moli-protocol-server/src/cdp_scheduler/runtime_dispatch.rs +++ b/moli-protocol-server/src/cdp_scheduler/runtime_dispatch.rs @@ -17,7 +17,7 @@ use tokio::{ use super::{ CdpScheduler, CdpSchedulerEventReceivers, CdpSchedulerInterleavedInput, - DevToolsCommandExecution, ProtocolOutputSequence, RendererOutputTransportFailure, + CdpSchedulerProgressFailure, DevToolsCommandExecution, ProtocolOutputSequence, }; pub(crate) struct PendingDevToolsRuntimeDeferredReplyExecution { @@ -54,8 +54,9 @@ impl CdpScheduler { pending: &mut PendingDevToolsRuntimeCommandDispatch, response: RuntimeInspectorResponseReady, ) -> bool { - pending - .route_scheduler_deferred_inspector_response(&mut self.conn, response) + self.host_adapter + .commands() + .route_runtime_deferred_inspector_response(pending, response) .await } @@ -92,7 +93,8 @@ impl CdpScheduler { ) -> DevToolsCommandExecution { let mut protocol_output = ProtocolOutputSequence::empty(); let mut step = self - .conn + .host_adapter + .commands() .start_devtools_runtime_command_dispatch(command) .await; loop { @@ -158,7 +160,8 @@ impl CdpScheduler { } }; step = self - .conn + .host_adapter + .commands() .complete_devtools_runtime_command_dispatch(completed) .await; } @@ -174,7 +177,8 @@ impl CdpScheduler { ) -> DevToolsRuntimeCommandProgress { let protocol_output = ProtocolOutputSequence::empty(); let step = self - .conn + .host_adapter + .commands() .start_devtools_runtime_command_dispatch(command) .await; self.continue_devtools_runtime_command_until_deferred_reply_or_complete( @@ -228,9 +232,9 @@ impl CdpScheduler { pending: Box, ) { let pending = *pending; - pending - .pending - .forget_scheduler_deferred_inspector_reply(&mut self.conn); + self.host_adapter + .commands() + .forget_runtime_deferred_inspector_reply(pending.pending); } async fn continue_devtools_runtime_command_until_deferred_reply_or_complete( @@ -352,7 +356,8 @@ impl CdpScheduler { } }; step = self - .conn + .host_adapter + .commands() .complete_devtools_runtime_command_dispatch(completed) .await; } @@ -364,7 +369,7 @@ impl CdpScheduler { &mut self, receivers: &mut CdpSchedulerEventReceivers, input: CdpSchedulerInterleavedInput, - ) -> Result { + ) -> Result { match input { // A deferred Runtime reply owns command-correlated background // events directly. Preserve that routing while sharing the same @@ -410,6 +415,7 @@ impl CdpScheduler { protocol_output.append(ready_output); continue; } + let navigation_gate_open = self.has_inflight_background_navigation(); tokio::select! { biased; mut completed = &mut pending_completion => { @@ -419,7 +425,7 @@ impl CdpScheduler { _ = wait_until_runtime_deadline(deadline) => { return Err(runtime_command_timeout_error()); } - maybe_input = receivers.recv_interleaved_input() => { + maybe_input = receivers.recv_interleaved_input(navigation_gate_open) => { let Some(input) = maybe_input else { return Ok(None); }; @@ -462,7 +468,9 @@ impl CdpScheduler { match pending.take_scheduler_deferred_inspector_reply_receiver() { Some(response_rx) => response_rx, None => { - pending.forget_scheduler_deferred_inspector_reply(&mut self.conn); + self.host_adapter + .commands() + .forget_runtime_deferred_inspector_reply(pending); return Err(DevToolsError::new( DevToolsErrorKind::Internal, "RuntimeDeferredInspectorResponseMissing", @@ -477,7 +485,9 @@ impl CdpScheduler { .await; let mut command_events = ready_output.take_protocol_events_with_id(command_id); if !command_events.is_empty() { - pending.forget_scheduler_deferred_inspector_reply(&mut self.conn); + self.host_adapter + .commands() + .forget_runtime_deferred_inspector_reply(pending); return Err(DevToolsError::new( DevToolsErrorKind::Internal, "RuntimeDeferredReplyLooseProtocolResponse", @@ -487,6 +497,7 @@ impl CdpScheduler { if !ready_output.is_empty() { protocol_output.append(ready_output); } + let navigation_gate_open = self.has_inflight_background_navigation(); tokio::select! { biased; renderer_response = &mut renderer_response_rx, if !renderer_response_done => { @@ -501,8 +512,9 @@ impl CdpScheduler { .route_renderer_response_to_devtools_pending(&mut pending, response) .await { - let mut completed = - pending.complete_scheduler_deferred_inspector_reply(&mut self.conn); + let mut completed = self + .host_adapter.commands() + .complete_runtime_deferred_inspector_reply(pending); completed .append_interleaved_protocol_events(interleaved_command_events); return Ok(Some(completed)); @@ -510,10 +522,11 @@ impl CdpScheduler { continue; } _ = wait_until_runtime_deadline(deadline) => { - pending.forget_scheduler_deferred_inspector_reply(&mut self.conn); + self.host_adapter.commands() + .forget_runtime_deferred_inspector_reply(pending); return Err(runtime_command_timeout_error()); } - maybe_input = receivers.recv_interleaved_input() => { + maybe_input = receivers.recv_interleaved_input(navigation_gate_open) => { let Some(input) = maybe_input else { return Ok(None); }; @@ -530,7 +543,8 @@ impl CdpScheduler { }; let mut command_events = output.take_protocol_events_with_id(command_id); if !command_events.is_empty() { - pending.forget_scheduler_deferred_inspector_reply(&mut self.conn); + self.host_adapter.commands() + .forget_runtime_deferred_inspector_reply(pending); return Err(DevToolsError::new( DevToolsErrorKind::Internal, "RuntimeDeferredReplyLooseProtocolResponse", @@ -583,10 +597,14 @@ impl CdpScheduler { response_wait_handle, } = pending; drop(response_wait_handle); - let mut completed = pending.complete_scheduler_deferred_inspector_reply(&mut self.conn); + let mut completed = self + .host_adapter + .commands() + .complete_runtime_deferred_inspector_reply(pending); completed.append_interleaved_protocol_events(interleaved_command_events); let step = self - .conn + .host_adapter + .commands() .complete_devtools_runtime_command_dispatch(completed) .await; self.continue_devtools_runtime_command_until_deferred_reply_or_complete( diff --git a/moli-protocol-server/src/cdp_scheduler/tests.rs b/moli-protocol-server/src/cdp_scheduler/tests.rs index b372b749f9..42d7ed5651 100644 --- a/moli-protocol-server/src/cdp_scheduler/tests.rs +++ b/moli-protocol-server/src/cdp_scheduler/tests.rs @@ -4,37 +4,40 @@ use moli_core::{ RendererOutputRecord, RendererOutputStreamControl, RendererOutputStreamIdentity, RendererOutputTransportMessage, RendererProtocolObservation, RendererRuntimeInspectorAsyncCompletion, + browser_host::{ + BrowserContextSelectionProjection, BrowserNavigationOwner, BrowserOwnerInput, + BrowserPageResidenceHandle, BrowserSelectedTargetEngineDisposition, BrowserTargetHandle, + BrowserTargetSlotProjection, BrowserTargetTopologyProjection, PageResidenceIdentity, + }, page::{ RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, - RendererDocumentLifecycleMilestone, RendererDocumentToken, RendererFrameToken, - RendererLifecycleEpoch, + RendererDocumentLifecycleMilestone, RendererDocumentSourcedTopLevelLocationNavigation, + RendererDocumentToken, RendererFrameToken, RendererLifecycleEpoch, }, + runtime::NavigationEngine, }; use moli_protocol::{ - BackgroundProtocolEvent, CdpConnection, CdpSchedulerEvent, - DeferredMainDocumentLoadCompletionOutputAction, + BackgroundNavigationCancellation, BackgroundNavigationGateKey, BackgroundProtocolEvent, + CdpConnection, CdpSchedulerEvent, DeferredMainDocumentLoadCompletionOutputAction, DeferredMainDocumentLoadCompletionOutputInterest, DeferredMainDocumentLoadPredecessorCandidate, ProtocolSchedulerWork, conn::RuntimeInspectorResponseReady, devtools_runtime::{ - AutomationEvent, BrowserDownloadWillBeginEvent, DevToolsCommand, DevToolsCommandContext, - DevToolsCommandResult, DevToolsCreateTargetCommand, DevToolsFrameId, DevToolsLoaderId, - DevToolsNavigationWait, DevToolsNetworkResourceType, DevToolsProtocol, DevToolsRequestId, - DevToolsSessionId, DevToolsTargetId, NavigationFrameEvent, NavigationFrameEventKind, - NetworkRequestEvent, + AutomationEvent, BrowserDownloadWillBeginEvent, DevToolsFrameId, DevToolsLoaderId, + DevToolsNavigationWait, DevToolsNetworkResourceType, DevToolsRequestId, DevToolsTargetId, + NavigationFrameEvent, NavigationFrameEventKind, NetworkRequestEvent, }, test_support::{ - arm_background_navigation_request, arm_background_navigation_request_for_target, + background_navigation_gate_key as make_background_navigation_gate_key, deferred_main_document_load_observation_id, deferred_main_document_load_output_interest, root_frame_stopped_loading_work as make_root_frame_stopped_loading_work, - root_frame_stopped_loading_work_for_target, settle_background_navigation_request, }, }; use serde_json::json; use super::{ - CdpScheduler, ForegroundNavigationNetworkBarrier, ProtocolOutputSequence, - ProtocolSchedulerResidence, ProtocolSchedulerStep, SchedulerQueues, + BackgroundNavigationGate, CdpScheduler, ForegroundNavigationNetworkBarrier, + ProtocolOutputSequence, ProtocolSchedulerResidence, ProtocolSchedulerStep, SchedulerQueues, devtools_navigation_lifecycle_milestone, drain_pending_background_events, next_page_screencast_deadline, page_screencast_interval, }; @@ -92,6 +95,39 @@ fn navigation_started_automation_event() -> AutomationEvent { }) } +fn browser_fact_wake_source() -> ( + BrowserNavigationOwner, + PageResidenceIdentity, + moli_core::browser_host::BrowserFactWakeSubscriber, +) { + let key = + moli_core::browser_host::BrowserPageOwnerKey::new("context-fact-wake", "target-fact-wake"); + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + owner + .register_browser_context( + key.browser_context_id(), + BrowserTargetTopologyProjection::new( + key.browser_context_id(), + Some(BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged(key.target_id()), + BrowserPageResidenceHandle::default(), + )), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("fact wake source Target should register"); + let page = owner + .capture_page_residence(key.browser_context_id(), key.target_id()) + .expect("fact wake source should expose its Page"); + let wake = owner.subscribe_browser_fact_wake(); + (owner, page, wake) +} + #[test] fn protocol_output_sequence_preserves_background_event_sidecar() { let message = json!({ @@ -172,7 +208,7 @@ fn navigation_download_observation_ignores_output_before_command_boundary() { #[test] fn drain_pending_background_events_preserves_typed_sidecar() { - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let (tx, mut rx) = moli_protocol::browser_background_output_channel(); let message = json!({ "method": "Page.frameStartedNavigating", "params": { @@ -268,14 +304,6 @@ fn protocol_output_loose_response_split_ignores_typed_runtime_response() { fn network_response_event( resource_type: DevToolsNetworkResourceType, request_id: &str, -) -> BackgroundProtocolEvent { - network_response_event_for_target(resource_type, request_id, "TID-nav") -} - -fn network_response_event_for_target( - resource_type: DevToolsNetworkResourceType, - request_id: &str, - target_id: &str, ) -> BackgroundProtocolEvent { let message = json!({ "method": "Network.responseReceived", @@ -290,7 +318,7 @@ fn network_response_event_for_target( } }); let automation_event = AutomationEvent::NetworkResponseStarted(NetworkRequestEvent { - target_id: DevToolsTargetId::from(target_id), + target_id: DevToolsTargetId::from("TID-nav"), frame_id: Some(DevToolsFrameId::from("FRAME-nav")), request_id: DevToolsRequestId::from(request_id), loader_id: Some(DevToolsLoaderId::from("LOADER-nav")), @@ -326,13 +354,12 @@ fn network_response_event_for_target( BackgroundProtocolEvent::immediate_automation_event(message, automation_event) } -fn network_request_event_for_target( +fn network_request_event( resource_type: DevToolsNetworkResourceType, request_id: &str, - target_id: &str, ) -> BackgroundProtocolEvent { let (_, Some(AutomationEvent::NetworkResponseStarted(mut network_event))) = - network_response_event_for_target(resource_type, request_id, target_id).into_parts() + network_response_event(resource_type, request_id).into_parts() else { unreachable!("network response fixture should retain its typed sidecar") }; @@ -355,13 +382,12 @@ fn network_request_event_for_target( ) } -fn network_finished_event_for_target( +fn network_finished_event( resource_type: DevToolsNetworkResourceType, request_id: &str, - target_id: &str, ) -> BackgroundProtocolEvent { let (_, Some(AutomationEvent::NetworkResponseStarted(network_event))) = - network_response_event_for_target(resource_type, request_id, target_id).into_parts() + network_response_event(resource_type, request_id).into_parts() else { unreachable!("network response fixture should retain its typed sidecar") }; @@ -378,34 +404,6 @@ fn network_finished_event_for_target( ) } -#[test] -fn mixed_owner_protocol_output_keeps_the_conservative_global_gate() { - let mut conn = CdpConnection::new(); - let navigation = arm_background_navigation_request(&mut conn, "LOADER-known"); - let target_id = navigation.target_id().to_owned(); - let known = network_response_event_for_target( - DevToolsNetworkResourceType::Xhr, - "REQ-known", - &target_id, - ); - assert_eq!( - ProtocolOutputSequence::from_background_event(known.clone()) - .navigation_gate_target_ids(&conn), - [target_id] - ); - - let unresolved = BackgroundProtocolEvent::immediate(json!({ - "method": "Runtime.consoleAPICalled", - "sessionId": "SID-missing", - "params": {} - })); - let mixed = ProtocolOutputSequence::from_background_events(vec![known, unresolved]); - assert!( - mixed.navigation_gate_target_ids(&conn).is_empty(), - "an atomic batch with any unresolved owner must remain connection-gated" - ); -} - fn output_request_ids(output: ProtocolOutputSequence) -> Vec { output .into_background_events() @@ -487,6 +485,26 @@ fn root_document_lifecycle_identity( } } +fn background_navigation_gate_key() -> BackgroundNavigationGateKey { + background_navigation_gate_key_for("TID-nav", "SID-nav", "FRAME-nav", "LOADER-nav", 1) +} + +fn background_navigation_gate_key_for( + target_id: &str, + session_id: &str, + frame_id: &str, + loader_id: &str, + navigation_request_id: u64, +) -> BackgroundNavigationGateKey { + make_background_navigation_gate_key( + Some(target_id.to_owned()), + Some(session_id.to_owned()), + frame_id.to_owned(), + loader_id.to_owned(), + Some(navigation_request_id), + ) +} + fn root_frame_stopped_loading_work(publish_sequence: u64, frame_id: &str) -> ProtocolSchedulerWork { make_root_frame_stopped_loading_work( publish_sequence, @@ -605,7 +623,8 @@ fn concrete_renderer_output_precedes_work_published_by_the_same_ingress() { #[test] fn protocol_work_published_by_held_ingress_inherits_exact_load_predecessor() { let load = deferred_main_document_load_observation_id(1); - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); scheduler.apply_scheduler_events_with_load_predecessors( vec![CdpSchedulerEvent::ProtocolWorkPublished { work: root_frame_stopped_loading_work(1, "FRAME-1"), @@ -654,7 +673,7 @@ fn newly_published_load_binds_every_residence_from_deferred_renderer_ingress() { assert_eq!( queues.bind_main_document_load_predecessor(load, &interest), 0, - "the load owner action must be inserted before every residence produced by the ingress" + "the load fact projection must be inserted before every residence produced by the ingress" ); assert!(matches!( queues.protocol_residences.front(), @@ -834,7 +853,8 @@ async fn runtime_response_releases_its_frozen_renderer_publication_predecessor() let publication = post_load_renderer_publication(PageId::new_for_testing(1), 1); let candidate = future_load_candidate(&publication); let cursor = publication_cursor(&publication); - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); scheduler.queues.enqueue_renderer_output_publication( cursor, ProtocolOutputSequence::from_messages(vec![json!({ @@ -863,7 +883,8 @@ async fn runtime_response_cannot_release_renderer_output_with_an_exact_load_pred let publication = post_load_renderer_publication(PageId::new_for_testing(1), 1); let load = deferred_main_document_load_observation_id(1); let cursor = publication_cursor(&publication); - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); scheduler.queues.enqueue_renderer_output_publication( cursor, ProtocolOutputSequence::from_messages(vec![json!({ @@ -919,7 +940,9 @@ async fn specialized_wait_yield_closes_candidate_retained_in_the_main_fifo() { let candidate = future_load_candidate(&publication); let interest = load_interest_for_publication(&publication); let unrelated_later_load = deferred_main_document_load_observation_id(1); - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); + let _browser_host = super::BrowserHostExecutionLane::new(browser_host_actor); scheduler.queues.enqueue_renderer_output_publication( cursor, ProtocolOutputSequence::from_messages(vec![json!({"method": "Runtime.consoleAPICalled"})]), @@ -930,8 +953,9 @@ async fn specialized_wait_yield_closes_candidate_retained_in_the_main_fifo() { work: root_frame_stopped_loading_work(1, "FRAME-1"), }]); + let snapshot = scheduler.queues.take_external_load_wait_snapshot(); let _ = scheduler - .complete_ready_protocol_residences_for_external_load_wait() + .complete_protocol_residence_snapshot(snapshot) .await; assert_eq!( @@ -948,7 +972,8 @@ fn scheduler_holds_concrete_renderer_output_until_its_exact_load_predecessor_fin let publication = post_load_renderer_publication(PageId::new_for_testing(7), 1); let cursor = publication_cursor(&publication); let load = deferred_main_document_load_observation_id(1); - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); scheduler.queues.enqueue_renderer_output_publication( cursor, ProtocolOutputSequence::from_messages(vec![json!({"method": "Runtime.consoleAPICalled"})]), @@ -977,7 +1002,8 @@ async fn renderer_stream_control_is_consumed_without_protocol_residence() { )), } .into(); - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); let output = scheduler.ingest_renderer_publication_now(control).await; assert!(output.is_empty()); @@ -990,21 +1016,26 @@ async fn renderer_stream_control_is_consumed_without_protocol_residence() { #[tokio::test] async fn closed_renderer_transport_fails_an_unprojected_command_fence() { - let (_background_event_tx, background_event_rx) = tokio::sync::mpsc::unbounded_channel(); + let (_background_event_tx, background_event_rx) = + moli_protocol::browser_background_output_channel(); let (_navigation_tx, background_navigation_completion_rx) = tokio::sync::mpsc::unbounded_channel(); let (renderer_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); drop(renderer_tx); + let (mut scheduler, browser_host_actor, browser_fact_wake_rx) = + CdpScheduler::new(CdpConnection::new()); let mut receivers = super::CdpSchedulerEventReceivers { + browser_host: super::BrowserHostExecutionLane::new(browser_host_actor), + browser_fact_wake_rx, background_event_rx, background_navigation_completion_rx, renderer_publication_rx, + navigation_renderer_publications: super::NavigationRendererPublicationBuffer::default(), }; let stream = RendererOutputStreamIdentity::new_page_for_protocol_test(PageId::new_for_testing(90)); let predecessor = RendererOutputFence::new_for_test(RendererOutputCursor::new_for_test(stream, 1)); - let mut scheduler = CdpScheduler::new(CdpConnection::new()); let failure = scheduler .project_renderer_output_predecessor_before_devtools_result(&mut receivers, &predecessor) @@ -1021,7 +1052,8 @@ async fn closed_renderer_transport_fails_an_unprojected_command_fence() { #[test] fn concrete_protocol_output_waits_for_its_client_turn_predecessor() { - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); scheduler.apply_scheduler_events(vec![CdpSchedulerEvent::ProtocolWorkPublished { work: root_frame_stopped_loading_work(1, "FRAME-1"), }]); @@ -1048,9 +1080,267 @@ fn concrete_protocol_output_waits_for_its_client_turn_predecessor() { ); } +#[tokio::test] +async fn browser_host_mailbox_wakes_independently_of_protocol_client_turn_predecessor() { + let page_id = PageId::new_for_testing(71); + let source_document = RendererDocumentLifecycleIdentity { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, 1), + epoch: RendererLifecycleEpoch(1), + }; + let input = BrowserOwnerInput::renderer_top_level_location_navigation( + PageResidenceIdentity::new( + "context-browser-owner-lane".to_owned(), + Some("target-browser-owner-lane".to_owned()), + 1, + ), + RendererDocumentSourcedTopLevelLocationNavigation::new( + source_document, + "https://example.test/browser-owner-lane".to_owned(), + ), + None, + ); + let (mut scheduler, browser_host_actor, browser_fact_wake_rx) = + CdpScheduler::new(CdpConnection::new()); + let (_background_event_tx, background_event_rx) = + moli_protocol::browser_background_output_channel(); + let (_navigation_tx, background_navigation_completion_rx) = + tokio::sync::mpsc::unbounded_channel(); + let (_renderer_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); + let mut receivers = super::CdpSchedulerEventReceivers { + browser_host: super::BrowserHostExecutionLane::new(browser_host_actor), + browser_fact_wake_rx, + background_event_rx, + background_navigation_completion_rx, + renderer_publication_rx, + navigation_renderer_publications: super::NavigationRendererPublicationBuffer::default(), + }; + scheduler.apply_scheduler_events(vec![CdpSchedulerEvent::ProtocolWorkPublished { + work: root_frame_stopped_loading_work(1, "FRAME-1"), + }]); + let (selected, ()) = tokio::join!(receivers.recv_interleaved_input(false), async { + scheduler.publish_browser_owner_input_for_test(input); + }); + let selected = selected.expect("Browser Host publication should wake the idle input wait"); + assert!(matches!( + &selected, + super::CdpSchedulerInterleavedInput::BrowserHostTurn + )); + + assert!(receivers.browser_host.has_ready_input()); + assert_eq!(scheduler.queues.protocol_residence_len(), 1); + assert_eq!( + scheduler.next_protocol_scheduler_step(), + ProtocolSchedulerStep::SatisfyClientTurnPredecessor + ); + + let output = scheduler + .complete_interleaved_scheduler_input(&mut receivers, selected) + .await; + let output = match output { + Ok(output) => output, + Err(_) => panic!("live renderer transport should accept the selected Host turn"), + }; + assert!( + output.is_empty(), + "the synthetic exact Page residence is intentionally stale" + ); + assert!(!receivers.browser_host.has_ready_input()); + assert_eq!( + scheduler.next_protocol_scheduler_step(), + ProtocolSchedulerStep::SatisfyClientTurnPredecessor, + "Browser Owner progress must not satisfy or enter the protocol frontend queue" + ); +} + +#[tokio::test] +async fn browser_fact_wakes_idle_scheduler_even_while_renderer_ingress_is_gated() { + let (mut fact_owner, page, browser_fact_wake_rx) = browser_fact_wake_source(); + let target_creation_sequence = fact_owner + .browser_fact_snapshot() + .last() + .expect("fact source registration should publish TargetCreated") + .sequence() + .get(); + let (_background_event_tx, background_event_rx) = + moli_protocol::browser_background_output_channel(); + let (_navigation_tx, background_navigation_completion_rx) = + tokio::sync::mpsc::unbounded_channel(); + let (_renderer_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); + let (_scheduler, browser_host_actor, _unused_scheduler_fact_wake) = + CdpScheduler::new(CdpConnection::new()); + let mut receivers = super::CdpSchedulerEventReceivers { + browser_host: super::BrowserHostExecutionLane::new(browser_host_actor), + browser_fact_wake_rx, + background_event_rx, + background_navigation_completion_rx, + renderer_publication_rx, + navigation_renderer_publications: super::NavigationRendererPublicationBuffer::default(), + }; + let page_id = PageId::new_for_testing(791); + let lifecycle = RendererDocumentLifecycleEvent { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, 1), + epoch: RendererLifecycleEpoch(1), + sequence: 1, + timestamp_micros: 10, + kind: RendererDocumentLifecycleEventKind::Milestone( + RendererDocumentLifecycleMilestone::Load, + ), + }; + + fact_owner + .record_document_lifecycle_facts(&page, &[lifecycle]) + .expect("Browser Owner should publish without waiting for a frontend"); + let selected = receivers + .recv_interleaved_input(true) + .await + .expect("Browser fact publication should wake an idle scheduler"); + + assert!(matches!( + selected, + super::CdpSchedulerInterleavedInput::BrowserFactWake(sequence) + if sequence.get() == target_creation_sequence + 1 + )); +} + +#[tokio::test] +async fn interleaved_navigation_gate_keeps_receiving_renderer_stream_controls() { + let (background_event_tx, background_event_rx) = + moli_protocol::browser_background_output_channel(); + let (_navigation_tx, background_navigation_completion_rx) = + tokio::sync::mpsc::unbounded_channel(); + let (renderer_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); + let (_scheduler, browser_host_actor, browser_fact_wake_rx) = + CdpScheduler::new(CdpConnection::new()); + let mut receivers = super::CdpSchedulerEventReceivers { + browser_host: super::BrowserHostExecutionLane::new(browser_host_actor), + browser_fact_wake_rx, + background_event_rx, + background_navigation_completion_rx, + renderer_publication_rx, + navigation_renderer_publications: super::NavigationRendererPublicationBuffer::default(), + }; + let stream = + RendererOutputStreamIdentity::new_page_for_protocol_test(PageId::new_for_testing(732)); + renderer_tx + .send(RendererOutputStreamControl::Opened { stream }.into()) + .expect("renderer publication receiver should be alive"); + + let selected = receivers + .recv_interleaved_input(true) + .await + .expect("an open navigation gate should still receive renderer controls"); + assert!(matches!( + selected, + super::CdpSchedulerInterleavedInput::RendererPublication { + publication: RendererOutputTransportMessage::StreamControl( + RendererOutputStreamControl::Opened { stream: selected_stream } + ), + navigation_gate_open: true, + } if selected_stream == stream + )); + + background_event_tx + .send(BackgroundProtocolEvent::immediate(json!({ + "method": "Network.responseReceived", + "params": {} + }))) + .expect("background event receiver should be alive"); + assert!(matches!( + receivers + .recv_interleaved_input(true) + .await + .expect("background event should remain observable through the gate"), + super::CdpSchedulerInterleavedInput::BackgroundEvent(_) + )); +} + +#[tokio::test] +async fn detached_frontend_navigation_wait_gates_only_its_exact_commit_stream() { + let (background_event_tx, background_event_rx) = + moli_protocol::browser_background_output_channel(); + let (_navigation_tx, background_navigation_completion_rx) = + tokio::sync::mpsc::unbounded_channel(); + let (renderer_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); + let (_scheduler, browser_host_actor, browser_fact_wake_rx) = + CdpScheduler::new(CdpConnection::new()); + let mut receivers = super::CdpSchedulerEventReceivers { + browser_host: super::BrowserHostExecutionLane::new(browser_host_actor), + browser_fact_wake_rx, + background_event_rx, + background_navigation_completion_rx, + renderer_publication_rx, + navigation_renderer_publications: super::NavigationRendererPublicationBuffer::default(), + }; + receivers + .browser_host + .detach_navigation_completion(Box::pin(std::future::pending::< + moli_protocol::CompletedDevToolsBrowserOwnerNavigationCommand, + >())); + assert!(receivers.renderer_navigation_gate_open(false)); + + let stream = + RendererOutputStreamIdentity::new_page_for_protocol_test(PageId::new_for_testing(733)); + let commit_publication: RendererOutputTransportMessage = + RendererOutputPublication::new_for_test( + RendererOutputCursor::new_for_test(stream, 1), + vec![RendererOutputRecord::new_for_test( + RendererOutputItem::Observation(RendererProtocolObservation::MainDocumentCommit( + moli_core::page::RendererMainDocumentCommit { + frame_id: "FRAME-detached-navigation-gate".to_owned(), + loader_id: "LOADER-detached-navigation-gate".to_owned(), + url: "https://example.test/detached-navigation-gate".to_owned(), + unreachable_url: None, + security_origin: "https://example.test".to_owned(), + secure_context_type: "Secure".to_owned(), + timestamp: 1.0, + }, + )), + )], + ) + .into(); + renderer_tx + .send(commit_publication.clone()) + .expect("renderer publication receiver should be alive"); + let selected = receivers + .recv_interleaved_input(false) + .await + .expect("detached navigation should keep receiving concrete renderer transport"); + let super::CdpSchedulerInterleavedInput::RendererPublication { + publication: selected, + navigation_gate_open, + } = selected + else { + panic!("renderer transport should win while it is the only ready input"); + }; + assert!(navigation_gate_open); + assert!( + receivers + .admit_or_buffer_navigation_renderer_publication(selected, true, None) + .is_none(), + "the exact main-Document commit must remain behind the detached navigation gate" + ); + + background_event_tx + .send(BackgroundProtocolEvent::immediate(json!({ + "method": "Network.responseReceived", + "params": {} + }))) + .expect("background event receiver should be alive"); + assert!(matches!( + receivers + .recv_interleaved_input(false) + .await + .expect("background event should wake the detached navigation wait"), + super::CdpSchedulerInterleavedInput::BackgroundEvent(_) + )); +} + #[test] fn concrete_protocol_output_preserves_protocol_publish_order() { - let mut scheduler = CdpScheduler::new(CdpConnection::new()); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); scheduler.apply_scheduler_events(vec![ CdpSchedulerEvent::ProtocolWorkPublished { work: root_frame_stopped_loading_work(1, "FRAME-1"), @@ -1103,176 +1393,102 @@ fn concrete_protocol_output_rejects_missing_earlier_publication() { ); } -#[tokio::test] -async fn background_navigation_blocks_only_its_target_protocol_residences() { - let mut conn = CdpConnection::new(); - let navigation = arm_background_navigation_request(&mut conn, "LOADER-nav"); - let navigation_target_id = navigation.target_id().to_owned(); - let browser_context_id = conn.default_browser_context_id().to_owned(); - let mut scheduler = CdpScheduler::new(conn); - scheduler.apply_scheduler_events(vec![ - CdpSchedulerEvent::ProtocolWorkPublished { - work: root_frame_stopped_loading_work_for_target( - 1, - vec![Some("SID-A".to_owned())], - browser_context_id.clone(), - navigation_target_id, - "FRAME-A".to_owned(), - "LOADER-A".to_owned(), - ), - }, - CdpSchedulerEvent::ProtocolWorkPublished { - work: root_frame_stopped_loading_work_for_target( - 2, - vec![Some("SID-B".to_owned())], - browser_context_id, - "TID-independent".to_owned(), - "FRAME-B".to_owned(), - "LOADER-B".to_owned(), - ), - }, - ]); +#[test] +fn background_navigation_blocks_concrete_protocol_residence_not_renderer_ingress() { + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); + scheduler + .background_navigation_gate + .note_navigation_started( + background_navigation_gate_key(), + BackgroundNavigationCancellation::new(), + ); + scheduler.apply_scheduler_events(vec![CdpSchedulerEvent::ProtocolWorkPublished { + work: root_frame_stopped_loading_work(1, "FRAME-1"), + }]); assert_eq!( scheduler.next_protocol_scheduler_step(), - ProtocolSchedulerStep::SatisfyClientTurnPredecessor, - "the independent target must advance around target A's navigation" - ); - scheduler.satisfy_front_protocol_residence_client_turn_predecessor(); - assert_eq!( - scheduler.next_protocol_scheduler_step(), - ProtocolSchedulerStep::CompleteReadyResidence - ); - assert!( - scheduler - .complete_next_protocol_residence() - .await - .is_empty() - ); - let remaining_sequences = scheduler - .queues - .protocol_residences - .iter() - .map(|residence| match residence { - ProtocolSchedulerResidence::ProtocolWork { work, .. } => work.publish_sequence().get(), - _ => panic!("test enqueued protocol work only"), - }) - .collect::>(); - assert_eq!(remaining_sequences, [1]); - assert_eq!( - scheduler.next_protocol_scheduler_step(), - ProtocolSchedulerStep::Wait, - "target A's own residence must remain gated" + ProtocolSchedulerStep::Wait ); } -#[tokio::test] -async fn protocol_residence_snapshot_skips_another_targets_navigation_gate() { - let mut conn = CdpConnection::new(); - let navigation = arm_background_navigation_request(&mut conn, "LOADER-A"); - let target_a = navigation.target_id().to_owned(); - let browser_context_id = conn.default_browser_context_id().to_owned(); - let mut scheduler = CdpScheduler::new(conn); - scheduler.apply_scheduler_events(vec![ - CdpSchedulerEvent::ProtocolWorkPublished { - work: root_frame_stopped_loading_work_for_target( - 1, - vec![Some("SID-A".to_owned())], - browser_context_id.clone(), - target_a, - "FRAME-A".to_owned(), - "LOADER-A".to_owned(), - ), - }, - CdpSchedulerEvent::ProtocolWorkPublished { - work: root_frame_stopped_loading_work_for_target( - 2, - vec![Some("SID-B".to_owned())], - browser_context_id, - "TID-B".to_owned(), - "FRAME-B".to_owned(), - "LOADER-B".to_owned(), - ), - }, - ]); - let snapshot = std::mem::take(&mut scheduler.queues.protocol_residences); - +#[test] +fn replacement_navigation_retires_only_the_superseded_frame_gate() { + let mut gate = BackgroundNavigationGate::default(); + let source = background_navigation_gate_key_for( + "TID-nav", + "SID-source", + "FRAME-nav", + "LOADER-source", + 1, + ); + let replacement = background_navigation_gate_key_for( + "TID-nav", + "SID-replacement", + "FRAME-nav", + "LOADER-replacement", + 2, + ); + let unrelated = background_navigation_gate_key_for( + "TID-other", + "SID-other", + "FRAME-other", + "LOADER-other", + 3, + ); + let source_cancellation = BackgroundNavigationCancellation::new(); + let replacement_cancellation = BackgroundNavigationCancellation::new(); + let unrelated_cancellation = BackgroundNavigationCancellation::new(); + + gate.note_navigation_started(source.clone(), source_cancellation.clone()); + gate.note_navigation_started(unrelated.clone(), unrelated_cancellation.clone()); + gate.note_navigation_started(replacement.clone(), replacement_cancellation.clone()); + assert!(source_cancellation.is_cancelled()); + assert!(!replacement_cancellation.is_cancelled()); + assert!(!unrelated_cancellation.is_cancelled()); + + gate.note_navigation_completion_drained(&source); assert!( - scheduler - .complete_protocol_residence_snapshot(snapshot) - .await - .is_empty() + gate.has_inflight_navigation(), + "a stale completion must not clear either live navigation gate" ); - let remaining_sequences = scheduler - .queues - .protocol_residences - .iter() - .map(|residence| match residence { - ProtocolSchedulerResidence::ProtocolWork { work, .. } => work.publish_sequence().get(), - _ => panic!("test enqueued protocol work only"), - }) - .collect::>(); - assert_eq!(remaining_sequences, [1]); -} - -#[test] -fn replacement_navigation_cancels_and_exactly_settles_the_target_owned_request() { - let mut conn = CdpConnection::new(); - let source = arm_background_navigation_request(&mut conn, "LOADER-source"); - let replacement = arm_background_navigation_request(&mut conn, "LOADER-replacement"); - assert!(source.is_cancelled()); - assert!(!replacement.is_cancelled()); - - assert!(!settle_background_navigation_request(&mut conn, &source)); + gate.note_navigation_completion_drained(&replacement); assert!( - conn.has_inflight_background_navigation(), - "a stale completion must not clear the replacement request" + gate.has_inflight_navigation(), + "replacing one frame must not clear an unrelated target's gate" ); - assert!(settle_background_navigation_request( - &mut conn, - &replacement - )); - assert!(!conn.has_inflight_background_navigation()); + gate.note_navigation_completion_drained(&unrelated); + assert!(!gate.has_inflight_navigation()); } #[test] fn scheduler_defers_subresource_network_events_until_background_navigation_gate_clears() { - let mut conn = CdpConnection::new(); - let navigation = arm_background_navigation_request(&mut conn, "LOADER-nav"); - let target_id = navigation.target_id().to_owned(); - let mut scheduler = CdpScheduler::new(conn); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); + let key = background_navigation_gate_key(); + scheduler + .background_navigation_gate + .note_navigation_started(key.clone(), BackgroundNavigationCancellation::new()); let document_output = scheduler.route_background_event_around_inflight_navigation( - network_response_event_for_target( - DevToolsNetworkResourceType::Document, - "REQ-document", - &target_id, - ), + network_response_event(DevToolsNetworkResourceType::Document, "REQ-document"), ); assert_eq!(output_request_ids(document_output), ["REQ-document"]); let script_output = scheduler.route_background_event_around_inflight_navigation( - network_response_event_for_target( - DevToolsNetworkResourceType::Script, - "REQ-script", - &target_id, - ), + network_response_event(DevToolsNetworkResourceType::Script, "REQ-script"), ); assert!(script_output.is_empty()); let script_terminal = scheduler.route_background_event_around_inflight_navigation( - network_finished_event_for_target( - DevToolsNetworkResourceType::Script, - "REQ-script", - &target_id, - ), + network_finished_event(DevToolsNetworkResourceType::Script, "REQ-script"), ); assert!(script_terminal.is_empty()); assert_eq!(scheduler.pending_navigation_background_events.len(), 2); - assert!(settle_background_navigation_request( - &mut scheduler.conn, - &navigation - )); + scheduler + .background_navigation_gate + .note_navigation_completion_drained(&key); let released = scheduler.drain_pending_navigation_background_events(); let released = released .into_background_events() @@ -1300,124 +1516,35 @@ fn scheduler_defers_subresource_network_events_until_background_navigation_gate_ ); } -#[tokio::test] -async fn target_a_navigation_does_not_defer_target_b_network_events() { - let mut conn = CdpConnection::new(); - conn.install_default_browser_target(); - let context = DevToolsCommandContext { - protocol: DevToolsProtocol::WebDriverBidi, - session_id: Some(DevToolsSessionId::from("bidi-test")), - target_id: None, - browser_context_id: None, - }; - let create_target = |context: DevToolsCommandContext| { - DevToolsCommand::CreateTarget(DevToolsCreateTargetCommand { - context, - url: "about:blank".to_owned(), - browser_context_id: None, - activate: false, - }) - }; - let first_create = conn - .execute_devtools_command(create_target(context.clone())) - .await; - let (first_result, _) = first_create.into_parts(); - let DevToolsCommandResult::CreateTarget(first_result) = - first_result.expect("first target should be created") - else { - panic!("expected create-target result") - }; - let first_target_id = first_result.target_id.into_string(); - let second_create = conn.execute_devtools_command(create_target(context)).await; - let (second_result, _) = second_create.into_parts(); - let DevToolsCommandResult::CreateTarget(second_result) = - second_result.expect("second target should be created") - else { - panic!("expected create-target result") - }; - let second_target_id = second_result.target_id.into_string(); - assert_ne!(first_target_id, second_target_id); - let navigation = arm_background_navigation_request(&mut conn, "LOADER-A"); - let target_a = navigation.target_id().to_owned(); - let target_b = if target_a == second_target_id { - first_target_id - } else { - second_target_id - }; - assert!(conn.has_inflight_background_navigation_for_target(&target_a)); - assert!(!conn.has_inflight_background_navigation_for_target(&target_b)); - - let mut scheduler = CdpScheduler::new(conn); - let target_b_output = scheduler.route_background_event_around_inflight_navigation( - network_response_event_for_target(DevToolsNetworkResourceType::Xhr, "REQ-B", &target_b), - ); - assert_eq!(output_request_ids(target_b_output), ["REQ-B"]); - - let navigation_b = - arm_background_navigation_request_for_target(&mut scheduler.conn, &target_b, "LOADER-B"); - let target_b_held = scheduler.route_background_event_around_inflight_navigation( - network_response_event_for_target( - DevToolsNetworkResourceType::Xhr, - "REQ-B-held", - &target_b, - ), - ); - assert!(target_b_held.is_empty()); - let target_a_output = scheduler.route_background_event_around_inflight_navigation( - network_response_event_for_target(DevToolsNetworkResourceType::Xhr, "REQ-A", &target_a), - ); - assert!(target_a_output.is_empty()); - assert_eq!(scheduler.pending_navigation_background_events.len(), 2); - - assert!(settle_background_navigation_request( - &mut scheduler.conn, - &navigation - )); - assert_eq!( - output_request_ids(scheduler.drain_pending_navigation_background_events()), - ["REQ-A"], - "settling target A must not release target B's held event" - ); - assert_eq!(scheduler.pending_navigation_background_events.len(), 1); - assert!(settle_background_navigation_request( - &mut scheduler.conn, - &navigation_b - )); - assert_eq!( - output_request_ids(scheduler.drain_pending_navigation_background_events()), - ["REQ-B-held"] - ); -} - #[test] fn navigation_gate_release_precedes_later_renderer_boundary_network_output() { - let mut conn = CdpConnection::new(); - let navigation = arm_background_navigation_request(&mut conn, "LOADER-nav"); - let target_id = navigation.target_id().to_owned(); - let mut scheduler = CdpScheduler::new(conn); + let (mut scheduler, _browser_host_actor, _browser_fact_wake) = + CdpScheduler::new(CdpConnection::new()); + let key = background_navigation_gate_key(); + scheduler + .background_navigation_gate + .note_navigation_started(key.clone(), BackgroundNavigationCancellation::new()); let request_id = "REQ-boundary-race"; assert!( scheduler - .route_background_event_around_inflight_navigation(network_request_event_for_target( + .route_background_event_around_inflight_navigation(network_request_event( DevToolsNetworkResourceType::Xhr, request_id, - &target_id, )) .is_empty() ); - assert!(settle_background_navigation_request( - &mut scheduler.conn, - &navigation - )); + scheduler + .background_navigation_gate + .note_navigation_completion_drained(&key); let mut output = ProtocolOutputSequence::empty(); scheduler.append_navigation_gate_release_before_renderer_boundary(&mut output); output.append(ProtocolOutputSequence::from_background_event( - network_response_event_for_target(DevToolsNetworkResourceType::Xhr, request_id, &target_id), + network_response_event(DevToolsNetworkResourceType::Xhr, request_id), )); output.append(ProtocolOutputSequence::from_background_event( - network_finished_event_for_target(DevToolsNetworkResourceType::Xhr, request_id, &target_id), + network_finished_event(DevToolsNetworkResourceType::Xhr, request_id), )); let methods = output diff --git a/moli-protocol-server/src/lib.rs b/moli-protocol-server/src/lib.rs index c125d5ca2d..ca62cd8c07 100644 --- a/moli-protocol-server/src/lib.rs +++ b/moli-protocol-server/src/lib.rs @@ -3,6 +3,7 @@ //! This crate owns the transport, frontend routing, and protocol actor //! scheduling used by CDP, WebDriver BiDi, and WebDriver Classic. +mod browser_host; mod cdp_frontend; mod cdp_frontend_router; mod cdp_scheduler; @@ -13,3 +14,6 @@ pub mod runtime_thread_budget; pub use config::ServerConfig; pub use protocol_server::ProtocolServer; + +#[cfg(test)] +pub(crate) use moli_cookie_cache as cookie_cache; diff --git a/moli-protocol-server/src/protocol_server/cdp_owner.rs b/moli-protocol-server/src/protocol_server/cdp_owner.rs index e41544f49a..4502dc2494 100644 --- a/moli-protocol-server/src/protocol_server/cdp_owner.rs +++ b/moli-protocol-server/src/protocol_server/cdp_owner.rs @@ -1,10 +1,13 @@ use std::{ collections::HashMap, - sync::{Arc, Weak, atomic::AtomicU64}, + sync::{Arc, Weak}, }; use anyhow::{Context, Result, bail}; -use moli_core::runtime::{NavigationRuntimeConfig, storage_partition::StoragePartitionState}; +use moli_core::{ + browser_host::BrowserTargetIdAllocator, + runtime::{NavigationRuntimeConfig, storage_partition::StoragePartitionState}, +}; use moli_protocol::CdpInitialStoragePartition; use parking_lot::Mutex; use tokio::sync::{Notify, mpsc, oneshot}; @@ -13,14 +16,12 @@ use crate::{ cdp_frontend::{CdpFrontendEndpoint, CdpFrontendReceivers, cdp_frontend_channel}, cdp_frontend_router::CdpFrontendRouter, cdp_scheduler::{ - CdpCookieSnapshot, CdpOwnerActorLifecycle, CdpScheduler, CdpTargetHostIntegration, - spawn_cdp_scheduler_actor, + CdpOwnerActorLifecycle, CdpScheduler, CdpTargetHostIntegration, spawn_cdp_scheduler_actor, }, }; use super::{ - CookieProfileCommit, SharedCookieProfile, cdp_agent_host::SharedCdpAgentHostDirectory, - protocol_local_executor::spawn_protocol_local_task, + cdp_agent_host::SharedCdpAgentHostDirectory, protocol_local_executor::spawn_protocol_local_task, }; use self::checkpoint::spawn_checkpoint_worker; @@ -52,9 +53,7 @@ struct CdpOwnerRecord { #[derive(Clone)] struct CdpOwnerRuntimeConfig { directory: SharedCdpAgentHostDirectory, - target_id_allocator: Arc, - tab_target_id_allocator: Arc, - cookie_profile: SharedCookieProfile, + target_id_allocator: BrowserTargetIdAllocator, storage_partition: Arc, navigation_runtime_config: NavigationRuntimeConfig, } @@ -62,9 +61,7 @@ struct CdpOwnerRuntimeConfig { impl SharedCdpOwnerRegistry { pub(super) fn new( directory: SharedCdpAgentHostDirectory, - target_id_allocator: Arc, - tab_target_id_allocator: Arc, - cookie_profile: SharedCookieProfile, + target_id_allocator: BrowserTargetIdAllocator, storage_partition: Arc, navigation_runtime_config: NavigationRuntimeConfig, ) -> Self { @@ -73,8 +70,6 @@ impl SharedCdpOwnerRegistry { config: CdpOwnerRuntimeConfig { directory, target_id_allocator, - tab_target_id_allocator, - cookie_profile, storage_partition, navigation_runtime_config, }, @@ -118,16 +113,13 @@ impl SharedCdpOwnerRegistry { ); } - let initial_cookies = self.inner.config.cookie_profile.snapshot(); let initial_storage_partition = CdpInitialStoragePartition::from_storage_partition( - initial_cookies.clone(), self.inner.config.storage_partition.as_ref(), ); - let (checkpoint_tx, checkpoint_rx) = mpsc::unbounded_channel(); + let (profile_flush_tx, profile_flush_rx) = mpsc::unbounded_channel(); let checkpoint_worker = spawn_checkpoint_worker( - self.inner.config.cookie_profile.clone(), - initial_cookies, - checkpoint_rx, + self.inner.config.storage_partition.clone(), + profile_flush_rx, ); let owner_finished_rx = spawn_owner_task( receivers, @@ -135,13 +127,12 @@ impl SharedCdpOwnerRegistry { self.inner.config.navigation_runtime_config.clone(), Some(CdpTargetHostIntegration::new( self.inner.config.target_id_allocator.clone(), - self.inner.config.tab_target_id_allocator.clone(), lifecycle_observer, )), Some(CdpOwnerActorLifecycle { - checkpoint_tx: checkpoint_tx.clone(), + profile_flush_tx: profile_flush_tx.clone(), }), - checkpoint_tx, + profile_flush_tx, ); let weak_registry = Arc::downgrade(&self.inner); let directory = self.inner.config.directory.clone(); @@ -202,7 +193,7 @@ fn spawn_owner_task( navigation_runtime_config: NavigationRuntimeConfig, target_host_integration: Option, owner_lifecycle: Option, - checkpoint_tx: mpsc::UnboundedSender, + profile_flush_tx: mpsc::UnboundedSender<()>, ) -> oneshot::Receiver<()> { spawn_protocol_local_task("cdp-owner", move || async move { let (scheduler, scheduler_receivers) = @@ -218,8 +209,8 @@ fn spawn_owner_task( receivers, owner_lifecycle, ); - let snapshot = actor.await.unwrap_or_default(); - let _ = checkpoint_tx.send(snapshot); + let _ = actor.await; + let _ = profile_flush_tx.send(()); }) } diff --git a/moli-protocol-server/src/protocol_server/cdp_owner/checkpoint.rs b/moli-protocol-server/src/protocol_server/cdp_owner/checkpoint.rs index d38801c038..8cb1ef7c2b 100644 --- a/moli-protocol-server/src/protocol_server/cdp_owner/checkpoint.rs +++ b/moli-protocol-server/src/protocol_server/cdp_owner/checkpoint.rs @@ -1,28 +1,30 @@ -use moli_cookie_jar::StoredCookie; -use tokio::sync::mpsc; +use std::sync::Arc; -use super::{CdpCookieSnapshot, CookieProfileCommit, SharedCookieProfile}; +use moli_core::runtime::storage_partition::StoragePartitionState; +use tokio::sync::mpsc; +/// Flushes the application-owned profile after Browser Host checkpoints. +/// +/// Cookie mutations already live in `StoragePartitionState`; a checkpoint is +/// therefore only a persistence request. It never carries a frontend snapshot +/// and cannot merge stale connection state back into the Browser profile. pub(super) fn spawn_checkpoint_worker( - cookie_profile: SharedCookieProfile, - mut checkpoint_baseline: Vec, - mut checkpoint_rx: mpsc::UnboundedReceiver, + storage_partition: Arc, + mut checkpoint_rx: mpsc::UnboundedReceiver<()>, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { - while let Some(snapshot) = checkpoint_rx.recv().await { - let Some(checkpoint_cookies) = snapshot.into_profile_backed_cookies() else { - continue; - }; - let commit = - CookieProfileCommit::new(checkpoint_baseline.clone(), checkpoint_cookies.clone()); - let profile = cookie_profile.clone(); - match tokio::task::spawn_blocking(move || profile.commit_and_save(commit)).await { - Ok(Ok(())) => checkpoint_baseline = checkpoint_cookies, + while checkpoint_rx.recv().await.is_some() { + // Coalesce a burst of detach/Target lifecycle checkpoints before + // entering the blocking filesystem boundary. + while checkpoint_rx.try_recv().is_ok() {} + let partition = storage_partition.clone(); + match tokio::task::spawn_blocking(move || partition.flush()).await { + Ok(Ok(())) => {} Ok(Err(error)) => { - tracing::warn!(?error, "failed to persist CDP owner cookie checkpoint"); + tracing::warn!(?error, "failed to flush CDP owner profile checkpoint"); } Err(error) => { - tracing::warn!(?error, "CDP owner cookie checkpoint worker panicked"); + tracing::warn!(?error, "CDP owner profile checkpoint worker panicked"); } } } @@ -31,10 +33,42 @@ pub(super) fn spawn_checkpoint_worker( #[cfg(test)] mod tests { + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + use moli_browser_profile::BrowserProfilePaths; use moli_cookie_jar::{StoredCookie, StoredCookieSameSite, StoredCookieSourceScheme}; use super::*; + struct TempProfile { + path: PathBuf, + } + + impl TempProfile { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + Self { + path: std::env::temp_dir().join(format!( + "moli-cdp-profile-flush-{}-{nonce}", + std::process::id() + )), + } + } + } + + impl Drop for TempProfile { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + fn stored_cookie(name: &str, value: &str) -> StoredCookie { StoredCookie { name: name.to_owned(), @@ -56,27 +90,25 @@ mod tests { } #[tokio::test] - async fn checkpoint_worker_advances_cookie_delta_baseline() { - let cookie_profile = SharedCookieProfile::new(Vec::new(), Vec::new()); + async fn checkpoint_flushes_live_browser_owned_cookie_store() { + let profile = TempProfile::new(); + let partition = + Arc::new(StoragePartitionState::open(Some(&profile.path)).expect("open test profile")); + partition + .import_cookies([stored_cookie("sid", "live")]) + .expect("import live cookie"); let (checkpoint_tx, checkpoint_rx) = mpsc::unbounded_channel(); - let worker = spawn_checkpoint_worker(cookie_profile.clone(), Vec::new(), checkpoint_rx); + let worker = spawn_checkpoint_worker(partition, checkpoint_rx); - checkpoint_tx - .send(CdpCookieSnapshot::from_profile_backed_cookies(Some(vec![ - stored_cookie("sid", "first"), - ]))) - .expect("send added-cookie checkpoint"); - checkpoint_tx - .send(CdpCookieSnapshot::from_profile_backed_cookies(Some( - Vec::new(), - ))) - .expect("send deleted-cookie checkpoint"); + checkpoint_tx.send(()).expect("send profile flush"); drop(checkpoint_tx); worker.await.expect("checkpoint worker"); - assert!( - cookie_profile.snapshot().is_empty(), - "the second checkpoint must remove a cookie created by the first checkpoint" - ); + let paths = BrowserProfilePaths::new(&profile.path); + let persisted = crate::cookie_cache::load_cookie_cache(&paths.cookies_path) + .expect("read persisted cookie cache"); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].name, "sid"); + assert_eq!(persisted[0].value, "live"); } } diff --git a/moli-protocol-server/src/protocol_server/devtools_host_service.rs b/moli-protocol-server/src/protocol_server/devtools_host_service.rs new file mode 100644 index 0000000000..772d6b4852 --- /dev/null +++ b/moli-protocol-server/src/protocol_server/devtools_host_service.rs @@ -0,0 +1,255 @@ +use std::time::Duration; + +use axum::extract::ws::WebSocket; +use moli_core::{page::RendererDocumentLifecycleMilestone, runtime::NavigationRuntimeConfig}; +use moli_protocol::{ + CdpInitialStoragePartition, DevToolsPageResidenceIdentity, + devtools_runtime::{ + DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, DevToolsError, + DevToolsErrorKind, + }, +}; +use tokio::sync::{mpsc, oneshot}; + +use super::{ + protocol_local_executor::spawn_protocol_local_task, webdriver_bidi::SharedBidiSessionRegistry, +}; + +mod actor; + +/// Cloneable frontend endpoint for one application-owned DevTools Host. +/// +/// The endpoint owns no scheduler, Browser Host actor, renderer receiver, or +/// protocol progress loop. Classic and BiDi frontends may wait for their own +/// replies through this channel without gaining the ability to pump Browser +/// progress. +#[derive(Debug, Clone)] +pub(super) struct DevToolsHostServiceHandle { + tx: mpsc::UnboundedSender, +} + +pub(super) struct AttachedBidiFrontendLifetime { + detached_rx: oneshot::Receiver<()>, +} + +pub(super) struct DevToolsHostCommandExecution { + pub(super) result: Result, + pub(super) page_residence: Option, +} + +impl AttachedBidiFrontendLifetime { + pub(super) async fn wait(self) { + let _ = self.detached_rx.await; + } +} + +#[derive(Debug)] +pub(super) enum BidiFrontendSession { + Standalone, + Existing { + session_id: String, + file_prompt_handler: Option, + }, +} + +enum DevToolsHostServiceRequest { + Execute { + command: Box, + timeout: Option, + pending_navigation_timeout: Option, + expected_page: Option, + response_tx: oneshot::Sender, + }, + WaitForDocumentLifecycle { + context: DevToolsCommandContext, + milestone: RendererDocumentLifecycleMilestone, + timeout: Option, + response_tx: oneshot::Sender>, + }, + SetJavaScriptDialogHandlerEnabled { + enabled: bool, + response_tx: oneshot::Sender>, + }, + AttachBidi { + socket: Box, + web_socket_url: String, + session: BidiFrontendSession, + session_registry: SharedBidiSessionRegistry, + response_tx: oneshot::Sender>, + }, + Shutdown { + response_tx: oneshot::Sender<()>, + }, +} + +impl DevToolsHostServiceHandle { + pub(super) fn spawn( + initial_storage_partition: CdpInitialStoragePartition, + navigation_runtime_config: NavigationRuntimeConfig, + ) -> (Self, oneshot::Receiver<()>) { + let (tx, rx) = mpsc::unbounded_channel(); + let finished = spawn_protocol_local_task("devtools-host", move || { + actor::run_devtools_host_service( + rx, + initial_storage_partition, + navigation_runtime_config, + ) + }); + (Self { tx }, finished) + } + + pub(super) async fn execute( + &self, + command: DevToolsCommand, + timeout: Option, + pending_navigation_timeout: Option, + ) -> Result { + self.execute_with_page_residence(command, timeout, pending_navigation_timeout, None) + .await + .result + } + + pub(super) async fn execute_with_page_residence( + &self, + command: DevToolsCommand, + timeout: Option, + pending_navigation_timeout: Option, + expected_page: Option, + ) -> DevToolsHostCommandExecution { + let (response_tx, response_rx) = oneshot::channel(); + if self + .tx + .send(DevToolsHostServiceRequest::Execute { + command: Box::new(command), + timeout, + pending_navigation_timeout, + expected_page, + response_tx, + }) + .is_err() + { + return DevToolsHostCommandExecution { + result: Err(devtools_host_stopped_error()), + page_residence: None, + }; + } + response_rx + .await + .unwrap_or_else(|_| DevToolsHostCommandExecution { + result: Err(devtools_host_stopped_error()), + page_residence: None, + }) + } + + pub(super) async fn wait_for_document_lifecycle( + &self, + context: DevToolsCommandContext, + milestone: RendererDocumentLifecycleMilestone, + timeout: Option, + ) -> Result<(), DevToolsError> { + let (response_tx, response_rx) = oneshot::channel(); + self.tx + .send(DevToolsHostServiceRequest::WaitForDocumentLifecycle { + context, + milestone, + timeout, + response_tx, + }) + .map_err(|_| devtools_host_stopped_error())?; + response_rx + .await + .map_err(|_| devtools_host_stopped_error())? + } + + pub(super) async fn set_javascript_dialog_handler_enabled( + &self, + enabled: bool, + ) -> Result<(), DevToolsError> { + let (response_tx, response_rx) = oneshot::channel(); + self.tx + .send( + DevToolsHostServiceRequest::SetJavaScriptDialogHandlerEnabled { + enabled, + response_tx, + }, + ) + .map_err(|_| devtools_host_stopped_error())?; + response_rx + .await + .map_err(|_| devtools_host_stopped_error())? + } + + pub(super) async fn attach_bidi( + &self, + socket: WebSocket, + web_socket_url: String, + session: BidiFrontendSession, + session_registry: SharedBidiSessionRegistry, + ) -> Option { + let (response_tx, response_rx) = oneshot::channel(); + self.tx + .send(DevToolsHostServiceRequest::AttachBidi { + socket: Box::new(socket), + web_socket_url, + session, + session_registry, + response_tx, + }) + .ok()?; + response_rx.await.ok().flatten() + } + + pub(super) async fn shutdown(&self) { + let (response_tx, response_rx) = oneshot::channel(); + if self + .tx + .send(DevToolsHostServiceRequest::Shutdown { response_tx }) + .is_err() + { + return; + } + let _ = response_rx.await; + } +} + +fn devtools_host_stopped_error() -> DevToolsError { + DevToolsError::new( + DevToolsErrorKind::NoSuchSession, + "DevTools Host service is closed", + ) +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use moli_core::runtime::storage_partition::StoragePartitionState; + + use super::*; + + #[tokio::test] + async fn cloned_service_handle_keeps_exact_host_actor_alive() { + let partition = Arc::new( + StoragePartitionState::open(None) + .expect("in-memory DevTools Host storage partition should open"), + ); + let initial_storage_partition = + CdpInitialStoragePartition::from_storage_partition(partition.as_ref()); + let (host, host_finished) = DevToolsHostServiceHandle::spawn( + initial_storage_partition, + NavigationRuntimeConfig::default(), + ); + let surviving_frontend = host.clone(); + drop(host); + + surviving_frontend + .set_javascript_dialog_handler_enabled(true) + .await + .expect("remaining frontend handle should reach the same live Host actor"); + surviving_frontend.shutdown().await; + tokio::time::timeout(Duration::from_secs(1), host_finished) + .await + .expect("Host actor should finish after explicit service shutdown") + .expect("Host actor should report completion"); + } +} diff --git a/moli-protocol-server/src/protocol_server/devtools_host_service/actor.rs b/moli-protocol-server/src/protocol_server/devtools_host_service/actor.rs new file mode 100644 index 0000000000..c6c57112b8 --- /dev/null +++ b/moli-protocol-server/src/protocol_server/devtools_host_service/actor.rs @@ -0,0 +1,675 @@ +use std::time::Instant; + +use crate::cdp_scheduler::{ + BrowserHostExecutionWake, CdpScheduler, CdpSchedulerEventReceivers, + DevToolsPageCommandExecution, ProtocolAdapterScheduler, +}; + +use super::*; +use crate::protocol_server::webdriver_bidi::{BidiSocketActor, BidiSocketActorInput}; + +struct AttachedBidiFrontend { + actor: BidiSocketActor, + session_registry: SharedBidiSessionRegistry, + detached_tx: Option>, +} + +impl AttachedBidiFrontend { + async fn release( + &mut self, + scheduler: &mut CdpScheduler, + receivers: &mut CdpSchedulerEventReceivers, + ) { + self.actor.release_event_sources(scheduler, receivers).await; + self.actor + .release_session(&mut self.session_registry.lock()); + if let Some(detached_tx) = self.detached_tx.take() { + let _ = detached_tx.send(()); + } + } +} + +enum DevToolsHostRequestOutcome { + Continue, + AttachedBidi(Box), + DetachBidi, + Shutdown, +} + +async fn handle_devtools_host_request( + scheduler: &mut CdpScheduler, + receivers: &mut CdpSchedulerEventReceivers, + request: DevToolsHostServiceRequest, + mut attached_bidi: Option<&mut AttachedBidiFrontend>, +) -> DevToolsHostRequestOutcome { + match request { + DevToolsHostServiceRequest::Execute { + command, + timeout, + pending_navigation_timeout, + expected_page, + response_tx, + } => { + let execution = execute_devtools_command_with_pending_navigation_wait( + scheduler, + receivers, + *command, + timeout, + pending_navigation_timeout, + expected_page.as_ref(), + ) + .await; + let DevToolsPageCommandExecution { + execution, + page_residence, + } = execution; + let result = execution.result; + let keep_attached = if let Some(attached) = attached_bidi.as_mut() { + attached + .actor + .send_or_route_protocol_output( + scheduler, + receivers, + execution.protocol_output, + None, + ) + .await + } else { + true + }; + let _ = response_tx.send(DevToolsHostCommandExecution { + result, + page_residence, + }); + if keep_attached { + DevToolsHostRequestOutcome::Continue + } else { + DevToolsHostRequestOutcome::DetachBidi + } + } + DevToolsHostServiceRequest::WaitForDocumentLifecycle { + context, + milestone, + timeout, + response_tx, + } => { + let execution = scheduler + .wait_for_devtools_context_document_lifecycle( + receivers, &context, milestone, timeout, + ) + .await; + let result = execution.result.map(|_| ()); + let keep_attached = if let Some(attached) = attached_bidi.as_mut() { + attached + .actor + .send_or_route_protocol_output( + scheduler, + receivers, + execution.protocol_output, + None, + ) + .await + } else { + true + }; + let _ = response_tx.send(result); + if keep_attached { + DevToolsHostRequestOutcome::Continue + } else { + DevToolsHostRequestOutcome::DetachBidi + } + } + DevToolsHostServiceRequest::SetJavaScriptDialogHandlerEnabled { + enabled, + response_tx, + } => { + let result = scheduler + .set_automation_javascript_dialog_handler_enabled(enabled) + .then_some(()) + .ok_or_else(|| { + DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "DevTools Host has no browser context", + ) + }); + let _ = response_tx.send(result); + DevToolsHostRequestOutcome::Continue + } + DevToolsHostServiceRequest::AttachBidi { + socket, + web_socket_url, + session, + session_registry, + response_tx, + } => { + if attached_bidi.is_some() { + let _ = response_tx.send(None); + return DevToolsHostRequestOutcome::Continue; + } + let mut actor = BidiSocketActor::new(*socket, web_socket_url); + match session { + BidiFrontendSession::Standalone => {} + BidiFrontendSession::Existing { + session_id, + file_prompt_handler, + } => { + let attached = { + let mut registry = session_registry.lock(); + actor.attach_existing_session(session_id, &mut registry) + }; + if !attached { + let _ = response_tx.send(None); + return DevToolsHostRequestOutcome::Continue; + } + actor.set_file_prompt_handler_for_script_commands( + file_prompt_handler.as_deref(), + ); + } + } + actor.install_frontend_scheduler_hooks(scheduler); + let (detached_tx, detached_rx) = oneshot::channel(); + let _ = response_tx.send(Some(AttachedBidiFrontendLifetime { detached_rx })); + DevToolsHostRequestOutcome::AttachedBidi(Box::new(AttachedBidiFrontend { + actor, + session_registry, + detached_tx: Some(detached_tx), + })) + } + DevToolsHostServiceRequest::Shutdown { response_tx } => { + let _ = response_tx.send(()); + DevToolsHostRequestOutcome::Shutdown + } + } +} + +async fn execute_devtools_command_with_pending_navigation_wait( + scheduler: &mut CdpScheduler, + receivers: &mut CdpSchedulerEventReceivers, + command: DevToolsCommand, + timeout: Option, + pending_navigation_timeout: Option, + expected_page: Option<&DevToolsPageResidenceIdentity>, +) -> DevToolsPageCommandExecution { + let mut execution = execute_devtools_command_once( + scheduler, + receivers, + command.clone(), + timeout, + expected_page, + ) + .await; + + let Some(pending_navigation_timeout) = pending_navigation_timeout else { + return execution; + }; + let started = Instant::now(); + loop { + if !result_is_navigation_changing_document(&execution.execution.result) { + return execution; + } + let Some(remaining) = pending_navigation_timeout.checked_sub(started.elapsed()) else { + execution.execution.result = Err(pending_navigation_timeout_error()); + return execution; + }; + let mut progress = match scheduler + .complete_ready_owner_and_protocol_residences_for_external_load_wait(receivers) + .await + { + Ok(progress) => progress, + Err(failure) => { + let (progress, error) = failure.into_parts(); + execution.execution.protocol_output.append(progress); + execution.execution.result = Err(error); + return execution; + } + }; + if progress.is_empty() { + let navigation_gate_open = scheduler.has_inflight_background_navigation(); + let input = match tokio::time::timeout( + remaining, + receivers.recv_interleaved_input(navigation_gate_open), + ) + .await + { + Ok(Some(input)) => input, + Ok(None) => { + execution.execution.result = Err(DevToolsError::new( + DevToolsErrorKind::NoSuchSession, + "DevTools Host stopped while waiting for navigation", + )); + return execution; + } + Err(_) => { + execution.execution.result = Err(pending_navigation_timeout_error()); + return execution; + } + }; + progress = match scheduler + .complete_interleaved_scheduler_input(receivers, input) + .await + { + Ok(progress) => progress, + Err(failure) => { + let (progress, error) = failure.into_parts(); + execution.execution.protocol_output.append(progress); + execution.execution.result = Err(error); + return execution; + } + }; + } + execution.execution.protocol_output.append(progress); + + let retry_timeout = match timeout { + Some(timeout) => { + let Some(remaining) = pending_navigation_timeout.checked_sub(started.elapsed()) + else { + execution.execution.result = Err(pending_navigation_timeout_error()); + return execution; + }; + Some(timeout.min(remaining)) + } + None => None, + }; + let retry = execute_devtools_command_once( + scheduler, + receivers, + command.clone(), + retry_timeout, + expected_page, + ) + .await; + execution + .execution + .protocol_output + .append(retry.execution.protocol_output); + execution.execution.result = retry.execution.result; + execution.page_residence = retry.page_residence; + } +} + +async fn execute_devtools_command_once( + scheduler: &mut CdpScheduler, + receivers: &mut CdpSchedulerEventReceivers, + command: DevToolsCommand, + timeout: Option, + expected_page: Option<&DevToolsPageResidenceIdentity>, +) -> DevToolsPageCommandExecution { + scheduler + .execute_devtools_command_with_external_load_wait_and_page_residence( + receivers, + command, + timeout, + expected_page, + ) + .await +} + +fn result_is_navigation_changing_document( + result: &Result, +) -> bool { + matches!( + result, + Err(error) if error.kind == DevToolsErrorKind::NavigationChangingDocument + ) +} + +fn pending_navigation_timeout_error() -> DevToolsError { + DevToolsError::new(DevToolsErrorKind::Timeout, "navigation wait timed out") +} + +pub(super) async fn run_devtools_host_service( + mut rx: mpsc::UnboundedReceiver, + initial_storage_partition: CdpInitialStoragePartition, + navigation_runtime_config: NavigationRuntimeConfig, +) { + let (mut scheduler, mut receivers) = CdpScheduler::new_with_initial_state_runtime_config( + initial_storage_partition, + navigation_runtime_config, + ); + let mut attached_bidi: Option = None; + let mut adapter_scheduler = ProtocolAdapterScheduler::<()>::default(); + loop { + if adapter_scheduler.load_projection_precedes_browser_owner(&scheduler) { + let Some(input) = adapter_scheduler + .recv_load_projection_predecessor_input(&scheduler) + .await + else { + break; + }; + let keep_attached = if let Some(attached) = attached_bidi.as_mut() { + attached + .actor + .handle_adapter_scheduler_input( + &mut adapter_scheduler, + &mut scheduler, + &mut receivers, + input, + ) + .await + } else { + let _ = adapter_scheduler + .advance_input(&mut scheduler, input, || ()) + .await; + true + }; + if !keep_attached && let Some(mut attached) = attached_bidi.take() { + attached.release(&mut scheduler, &mut receivers).await; + } + continue; + } + if attached_bidi.is_some() { + let mut detach_bidi = false; + let mut shutdown_requested = false; + { + let attached = attached_bidi.as_mut().expect("attached BiDi frontend"); + let page_javascript_blocked = scheduler.has_pending_javascript_dialog(); + let navigation_gate_open = receivers + .renderer_navigation_gate_open(scheduler.has_inflight_background_navigation()); + adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked); + tokio::select! { + biased; + wake = receivers.browser_host.recv_wake() => { + let output = match wake { + BrowserHostExecutionWake::TurnSelected => { + let Some(outcome) = scheduler.complete_next_browser_owner_input( + &mut receivers.browser_host, + ) else { + continue; + }; + match scheduler + .apply_renderer_owner_turn_outcome(&mut receivers, outcome) + .await + { + Ok(output) => output, + Err(failure) => { + let (output, error) = failure.into_parts(); + tracing::error!( + error = %error.message, + "detaching BiDi frontend after Browser Host renderer predecessor failure" + ); + detach_bidi = true; + output + } + } + } + BrowserHostExecutionWake::ParticipantCompleted(completed) => { + let outcome = scheduler + .complete_browser_host_participant( + &mut receivers.browser_host, + *completed, + ) + .await; + match scheduler + .apply_renderer_owner_turn_outcome(&mut receivers, outcome) + .await + { + Ok(output) => output, + Err(failure) => { + let (output, error) = failure.into_parts(); + tracing::error!( + error = %error.message, + "detaching BiDi frontend after Browser Host participant predecessor failure" + ); + detach_bidi = true; + output + } + } + } + BrowserHostExecutionWake::DetachedNavigationCompleted(completed) => { + scheduler + .complete_detached_devtools_browser_owner_navigation( + &mut receivers, + *completed, + ) + .await + } + BrowserHostExecutionWake::Closed => break, + }; + if !attached.actor.send_or_route_protocol_output( + &mut scheduler, + &mut receivers, + output, + None, + ).await { + detach_bidi = true; + } + } + completion = receivers.background_navigation_completion_rx.recv() => { + let Some(completion) = completion else { + break; + }; + if !attached.actor.handle_background_navigation_completion( + &mut scheduler, + &mut receivers, + completion, + ).await { + detach_bidi = true; + } + } + event = receivers.background_event_rx.recv() => { + let Some(event) = event else { + break; + }; + let output = scheduler.route_background_event_around_inflight_navigation(event); + if !attached.actor.send_or_route_protocol_output( + &mut scheduler, + &mut receivers, + output, + None, + ).await { + detach_bidi = true; + } + } + publication = receivers.renderer_publication_rx.recv(), if !page_javascript_blocked && !navigation_gate_open => { + let Some(publication) = publication else { + break; + }; + if !attached.actor.handle_renderer_publication( + &mut adapter_scheduler, + &mut scheduler, + &mut receivers, + publication, + ).await { + detach_bidi = true; + } + } + actor_input = attached.actor.recv_attached_input( + &mut adapter_scheduler, + page_javascript_blocked, + ) => { + match actor_input { + BidiSocketActorInput::Socket(Some(message)) => { + if !attached.actor.handle_socket_message( + &mut scheduler, + &mut receivers, + &attached.session_registry, + message, + ).await { + detach_bidi = true; + } + } + BidiSocketActorInput::Socket(None) => { + detach_bidi = true; + } + BidiSocketActorInput::AdapterScheduler(input) => { + if !attached.actor.handle_adapter_scheduler_input( + &mut adapter_scheduler, + &mut scheduler, + &mut receivers, + input, + ).await { + detach_bidi = true; + } + } + BidiSocketActorInput::RuntimeResponseReady(Some(response)) => { + if !attached.actor.handle_runtime_response_ready( + &mut scheduler, + &mut receivers, + *response, + ).await { + detach_bidi = true; + } + } + BidiSocketActorInput::RuntimeResponseReady(None) => { + detach_bidi = true; + } + } + } + request = rx.recv() => { + let Some(request) = request else { + break; + }; + match handle_devtools_host_request( + &mut scheduler, + &mut receivers, + request, + Some(attached), + ).await { + DevToolsHostRequestOutcome::Continue => {} + DevToolsHostRequestOutcome::AttachedBidi(mut duplicate) => { + duplicate.release(&mut scheduler, &mut receivers).await; + } + DevToolsHostRequestOutcome::DetachBidi => { + detach_bidi = true; + } + DevToolsHostRequestOutcome::Shutdown => { + attached.release(&mut scheduler, &mut receivers).await; + shutdown_requested = true; + } + } + } + } + } + if shutdown_requested { + return; + } + if detach_bidi && let Some(mut attached) = attached_bidi.take() { + attached.release(&mut scheduler, &mut receivers).await; + } + } else { + let page_javascript_blocked = scheduler.has_pending_javascript_dialog(); + let navigation_gate_open = receivers + .renderer_navigation_gate_open(scheduler.has_inflight_background_navigation()); + adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked); + tokio::select! { + biased; + wake = receivers.browser_host.recv_wake() => { + let result = match wake { + BrowserHostExecutionWake::TurnSelected => { + let Some(outcome) = scheduler.complete_next_browser_owner_input( + &mut receivers.browser_host, + ) else { + continue; + }; + scheduler + .apply_renderer_owner_turn_outcome(&mut receivers, outcome) + .await + } + BrowserHostExecutionWake::ParticipantCompleted(completed) => { + let outcome = scheduler + .complete_browser_host_participant( + &mut receivers.browser_host, + *completed, + ) + .await; + scheduler + .apply_renderer_owner_turn_outcome(&mut receivers, outcome) + .await + } + BrowserHostExecutionWake::DetachedNavigationCompleted(completed) => { + Ok(scheduler + .complete_detached_devtools_browser_owner_navigation( + &mut receivers, + *completed, + ) + .await) + } + BrowserHostExecutionWake::Closed => break, + }; + if let Err(failure) = result { + let (_, error) = failure.into_parts(); + tracing::error!( + error = %error.message, + "stopping DevTools Host after Browser Host renderer predecessor failure" + ); + break; + } + } + completion = receivers.background_navigation_completion_rx.recv() => { + let Some(completion) = completion else { + break; + }; + if scheduler + .drain_background_navigation_completion_with_progress_barrier( + completion, + &mut receivers, + ) + .await + .is_err() + { + break; + } + } + event = receivers.background_event_rx.recv() => { + let Some(event) = event else { + break; + }; + let _ = scheduler.route_background_event_around_inflight_navigation(event); + } + publication = receivers.renderer_publication_rx.recv(), if !page_javascript_blocked && !navigation_gate_open => { + let Some(publication) = publication else { + break; + }; + let _ = adapter_scheduler + .ingest_renderer_publication(&mut scheduler, publication) + .await; + } + input = adapter_scheduler.recv_input(), if !page_javascript_blocked => { + let _ = adapter_scheduler + .advance_input(&mut scheduler, input, || ()) + .await; + } + request = rx.recv() => { + let Some(request) = request else { + break; + }; + match handle_devtools_host_request( + &mut scheduler, + &mut receivers, + request, + None, + ).await { + DevToolsHostRequestOutcome::Continue => {} + DevToolsHostRequestOutcome::AttachedBidi(attached) => { + attached_bidi = Some(*attached); + } + DevToolsHostRequestOutcome::DetachBidi => {} + DevToolsHostRequestOutcome::Shutdown => return, + } + if attached_bidi.is_none() { + ingest_ready_renderer_publications( + &mut adapter_scheduler, + &mut scheduler, + &mut receivers, + ).await; + } + } + } + } + } + if let Some(mut attached) = attached_bidi.take() { + attached.release(&mut scheduler, &mut receivers).await; + } +} + +async fn ingest_ready_renderer_publications( + adapter_scheduler: &mut ProtocolAdapterScheduler<()>, + scheduler: &mut CdpScheduler, + receivers: &mut CdpSchedulerEventReceivers, +) { + while let Ok(publication) = receivers.renderer_publication_rx.try_recv() { + let _ = adapter_scheduler + .ingest_renderer_publication(scheduler, publication) + .await; + } +} diff --git a/moli-protocol-server/src/protocol_server/mod.rs b/moli-protocol-server/src/protocol_server/mod.rs index bf93f07c98..a82d3a8cad 100644 --- a/moli-protocol-server/src/protocol_server/mod.rs +++ b/moli-protocol-server/src/protocol_server/mod.rs @@ -1,9 +1,4 @@ -#[cfg(test)] -use std::path::PathBuf; -use std::{ - net::SocketAddr, - sync::{Arc, atomic::AtomicU64}, -}; +use std::{net::SocketAddr, sync::Arc}; use anyhow::Result; use anyhow::{Context, anyhow}; @@ -19,26 +14,23 @@ use axum::{ use moli_cookie_jar::StoredCookie; use moli_core::{ LayoutPolicy, OptionalResourceFetchMask, + browser_host::BrowserTargetIdAllocator, runtime::{NavigationRuntimeConfig, storage_partition::StoragePartitionState}, }; use moli_fetch::FetchConfig; #[cfg(test)] use moli_protocol::DEFAULT_CDP_TAB_TARGET_ID; use moli_protocol::{CdpInitialStoragePartition, DEFAULT_CDP_PAGE_TARGET_ID}; -#[cfg(test)] -use parking_lot::Mutex; use tokio::net::TcpListener; use tracing::info; -#[cfg(test)] -use moli_cookie_cache as cookie_cache; - pub use crate::config::ServerConfig; mod cdp; mod cdp_agent_host; mod cdp_owner; mod cdp_socket; +mod devtools_host_service; mod protocol_local_executor; mod tcp_options; mod webdriver_bidi; @@ -744,7 +736,6 @@ struct AppState { cdp_agent_host_directory: SharedCdpAgentHostDirectory, cdp_owner_registry: SharedCdpOwnerRegistry, devtools_frontend_url: String, - cookie_profile: SharedCookieProfile, storage_partition: Arc, fetch_config: FetchConfig, optional_resource_fetch_mask: OptionalResourceFetchMask, @@ -780,7 +771,6 @@ impl AppState { ) -> anyhow::Result { Ok(Self::from_parts( addr, - SharedCookieProfile::from_storage_partition(storage_partition.clone()), storage_partition, navigation_runtime_config, )) @@ -788,18 +778,14 @@ impl AppState { fn from_parts( addr: SocketAddr, - cookie_profile: SharedCookieProfile, storage_partition: Arc, navigation_runtime_config: NavigationRuntimeConfig, ) -> Self { let cdp_agent_host_directory = SharedCdpAgentHostDirectory::default(); - let cdp_target_id_allocator = Arc::new(AtomicU64::new(0)); - let cdp_tab_target_id_allocator = Arc::new(AtomicU64::new(0)); + let cdp_target_id_allocator = BrowserTargetIdAllocator::default(); let cdp_owner_registry = SharedCdpOwnerRegistry::new( cdp_agent_host_directory.clone(), cdp_target_id_allocator, - cdp_tab_target_id_allocator, - cookie_profile.clone(), storage_partition.clone(), navigation_runtime_config.clone(), ); @@ -814,7 +800,6 @@ impl AppState { devtools_frontend_url: format!( "/devtools/inspector.html?ws={addr}/devtools/page/{DEFAULT_TARGET_ID}" ), - cookie_profile, storage_partition, fetch_config: navigation_runtime_config.fetch_config().clone(), optional_resource_fetch_mask: navigation_runtime_config.optional_resource_fetch_mask(), @@ -823,185 +808,20 @@ impl AppState { } } - fn initial_storage_partition( - &self, - initial_cookies: Vec, - ) -> CdpInitialStoragePartition { - CdpInitialStoragePartition::from_storage_partition( - initial_cookies, - self.storage_partition.as_ref(), - ) + fn initial_storage_partition(&self) -> CdpInitialStoragePartition { + CdpInitialStoragePartition::from_storage_partition(self.storage_partition.as_ref()) } } -#[derive(Debug, Clone)] -struct SharedCookieProfile { - backing: SharedCookieProfileBacking, -} - -#[derive(Debug, Clone)] -enum SharedCookieProfileBacking { - #[cfg(test)] - Legacy(Arc>), - StoragePartition(Arc), -} - -#[derive(Debug, Clone, Default)] -struct CookieProfileCommit { - initial_cookies: Vec, - final_cookies: Option>, -} - -impl CookieProfileCommit { - fn new(initial_cookies: Vec, final_cookies: Vec) -> Self { - Self { - initial_cookies, - final_cookies: Some(final_cookies), - } - } - - fn unchanged() -> Self { - Self { - initial_cookies: Vec::new(), - final_cookies: None, - } - } - - fn from_optional_profile_backed_snapshot( - initial_cookies: Vec, - final_cookies: Option>, - ) -> Self { - match final_cookies { - Some(final_cookies) => Self::new(initial_cookies, final_cookies), - None => Self::unchanged(), - } - } - - fn is_unchanged(&self) -> bool { - self.final_cookies.is_none() - } -} - -impl SharedCookieProfile { - #[cfg(test)] - fn new(initial_cookies: Vec, cache_paths: Vec) -> Self { - Self { - backing: SharedCookieProfileBacking::Legacy(Arc::new(Mutex::new(CookieProfile { - cookies: initial_cookies, - cache_paths, - }))), - } - } - - fn from_storage_partition(storage_partition: Arc) -> Self { - Self { - backing: SharedCookieProfileBacking::StoragePartition(storage_partition), - } - } - - fn snapshot(&self) -> Vec { - match &self.backing { - #[cfg(test)] - SharedCookieProfileBacking::Legacy(inner) => inner.lock().cookies.clone(), - SharedCookieProfileBacking::StoragePartition(storage_partition) => storage_partition - .cookies() - .expect("storage partition cookie snapshot should succeed"), - } - } - - fn commit_and_save(&self, commit: CookieProfileCommit) -> Result<()> { - if commit.is_unchanged() { - return Ok(()); - } - - match &self.backing { - #[cfg(test)] - SharedCookieProfileBacking::Legacy(inner) => { - let (cookies, cache_paths) = { - let mut profile = inner.lock(); - commit_cookie_profile(&mut profile.cookies, commit); - (profile.cookies.clone(), profile.cache_paths.clone()) - }; - - for path in cache_paths { - cookie_cache::save_cookie_cache(&path, cookies.clone()).with_context(|| { - anyhow!("failed to save cookie cache `{}`", path.display()) - })?; - } - Ok(()) - } - SharedCookieProfileBacking::StoragePartition(storage_partition) => { - storage_partition.commit_cookie_delta(&commit.initial_cookies, commit.final_cookies) - } - } - } - - #[cfg(test)] - fn merge_and_save(&self, cookies: Vec) -> Result<()> { - self.commit_and_save(CookieProfileCommit::new(Vec::new(), cookies)) - } -} - -#[cfg(test)] -#[derive(Debug)] -struct CookieProfile { - cookies: Vec, - cache_paths: Vec, -} - -#[cfg(test)] -fn merge_cookie_profiles(base: &mut Vec, updates: Vec) { - for cookie in updates.into_iter().filter(|cookie| !cookie.is_expired()) { - if let Some(existing) = base - .iter_mut() - .find(|existing| same_cookie_key(existing, &cookie)) - { - *existing = cookie; - } else { - base.push(cookie); - } - } - base.retain(|cookie| !cookie.is_expired()); -} - -#[cfg(test)] -fn commit_cookie_profile(base: &mut Vec, commit: CookieProfileCommit) { - let Some(final_cookies) = commit.final_cookies else { - return; - }; - let final_cookies = final_cookies - .into_iter() - .filter(|cookie| !cookie.is_expired()) - .collect::>(); - - for initial in commit - .initial_cookies - .iter() - .filter(|cookie| !cookie.is_expired()) - { - let cookie_still_exists_in_session = final_cookies - .iter() - .any(|cookie| same_cookie_key(cookie, initial)); - if cookie_still_exists_in_session { - continue; - } - if let Some(position) = base - .iter() - .position(|cookie| same_cookie_key(cookie, initial) && cookie == initial) - { - base.remove(position); - } +async fn flush_storage_partition_profile( + storage_partition: Arc, + surface: &'static str, +) { + match tokio::task::spawn_blocking(move || storage_partition.flush()).await { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::warn!(?error, surface, "failed to flush browser profile"), + Err(error) => tracing::warn!(?error, surface, "browser profile flush task panicked"), } - - merge_cookie_profiles(base, final_cookies); -} - -#[cfg(test)] -fn same_cookie_key(left: &StoredCookie, right: &StoredCookie) -> bool { - left.name == right.name - && left.domain == right.domain - && left.path == right.path - && left.partition_key == right.partition_key } #[cfg(test)] diff --git a/moli-protocol-server/src/protocol_server/tests/bidi.rs b/moli-protocol-server/src/protocol_server/tests/bidi.rs index 6054051d0d..45cc539928 100644 --- a/moli-protocol-server/src/protocol_server/tests/bidi.rs +++ b/moli-protocol-server/src/protocol_server/tests/bidi.rs @@ -369,6 +369,65 @@ async fn websocket_bidi_existing_classic_session_shares_classic_runtime_context( protocol_server.abort(); } +#[tokio::test] +async fn websocket_bidi_detach_does_not_stop_classic_devtools_host_service() { + let (cdp_addr, protocol_server) = spawn_test_protocol_server().await; + let session_id = classic_new_session_on_server(cdp_addr).await; + let mut socket = connect_classic_session_bidi_socket(cdp_addr, &session_id).await; + + let initial_tree = + send_bidi_command(&mut socket, 1, "browsingContext.getTree", json!({})).await; + assert_eq!(initial_tree["type"], json!("success")); + + socket + .close(None) + .await + .expect("close attached Classic-session BiDi frontend"); + let closed = timeout(Duration::from_secs(1), socket.next()) + .await + .expect("server should acknowledge attached BiDi frontend close"); + assert!(matches!( + closed, + Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) + )); + + let page_url = classic_data_url_for_bidi_test( + "Classic survives BiDi detach
alive
", + ); + let navigated = classic_request_on_server_with_body( + cdp_addr, + "POST", + &format!("/session/{session_id}/url"), + json!({ "url": page_url }), + ) + .await; + assert_eq!(navigated, json!({ "value": null })); + + let title = classic_request_on_server_with_body( + cdp_addr, + "GET", + &format!("/session/{session_id}/title"), + json!({}), + ) + .await; + assert_eq!(title, json!({ "value": "Classic survives BiDi detach" })); + + let mut reattached = connect_classic_session_bidi_socket(cdp_addr, &session_id).await; + let tree = send_bidi_command(&mut reattached, 2, "browsingContext.getTree", json!({})).await; + assert_eq!(tree["type"], json!("success")); + assert_eq!(tree["result"]["contexts"][0]["url"], json!(page_url)); + + let _ = reattached.close(None).await; + let _ = classic_request_on_server_with_body( + cdp_addr, + "DELETE", + &format!("/session/{session_id}"), + json!({}), + ) + .await; + protocol_server.abort(); +} + #[tokio::test] async fn websocket_bidi_navigation_stales_classic_element_from_replaced_page() { let (cdp_addr, protocol_server) = spawn_test_protocol_server().await; @@ -1474,10 +1533,19 @@ async fn websocket_bidi_session_subscribe_emits_context_created_before_create_re .iter() .position(|message| message["id"] == json!(3_u64)) .expect("create response"); - let event_index = messages + let event_indexes = messages .iter() - .position(|message| message["method"] == json!("browsingContext.contextCreated")) - .expect("contextCreated event"); + .enumerate() + .filter_map(|(index, message)| { + (message["method"] == json!("browsingContext.contextCreated")).then_some(index) + }) + .collect::>(); + assert_eq!( + event_indexes.len(), + 1, + "one exact contextCreated event is required: {messages:#?}" + ); + let event_index = event_indexes[0]; assert!( event_index < response_index, "contextCreated should be emitted before browsingContext.create resolves: {messages:#?}" @@ -2226,12 +2294,28 @@ async fn websocket_bidi_session_subscribe_emits_context_destroyed_on_close() { .expect("close response"); assert_eq!(close["type"], json!("success")); - let event = messages + let response_index = messages .iter() - .find(|message| message["method"] == json!("browsingContext.contextDestroyed")) - .unwrap_or_else(|| { - panic!("expected contextDestroyed before close response: {messages:#?}") - }); + .position(|message| message["id"] == json!(4_u64)) + .expect("close response"); + let event_indexes = messages + .iter() + .enumerate() + .filter_map(|(index, message)| { + (message["method"] == json!("browsingContext.contextDestroyed")).then_some(index) + }) + .collect::>(); + assert_eq!( + event_indexes.len(), + 1, + "one exact contextDestroyed event is required: {messages:#?}" + ); + let event_index = event_indexes[0]; + assert!( + event_index < response_index, + "contextDestroyed should be emitted before browsingContext.close resolves: {messages:#?}" + ); + let event = &messages[event_index]; assert_eq!(event["type"], json!("event")); assert_eq!(event["params"]["context"], json!(context_id.clone())); assert_eq!(event["params"]["url"], json!("about:blank")); @@ -4351,12 +4435,29 @@ async fn websocket_bidi_network_auth_required_intercept_blocks_matching_request( )) .await .expect("send network.continueWithAuth cancel"); - let continue_messages = recv_until_id(&mut socket, 6).await; + let mut continue_messages = recv_until_id(&mut socket, 6).await; + if !continue_messages + .iter() + .any(|message| message["id"] == json!(5_u64)) + { + continue_messages.extend(recv_until_id(&mut socket, 5).await); + } let continue_auth = continue_messages .iter() .find(|message| message["id"] == json!(6_u64)) .expect("network.continueWithAuth response"); assert_eq!(continue_auth["type"], json!("success")); + let navigate = continue_messages + .iter() + .find(|message| message["id"] == json!(5_u64)) + .expect("cancelled auth should complete the waiting navigation response"); + assert_eq!( + navigate["type"], + json!("success"), + "auth cancellation should commit the 401 document: {continue_messages:?}" + ); + assert!(navigate["result"]["navigation"].as_str().is_some()); + assert_eq!(navigate["result"]["url"], json!(auth_url)); let _ = socket.close(None).await; protocol_server.abort(); @@ -12341,6 +12442,108 @@ async fn websocket_bidi_set_viewport_user_contexts_apply_and_inherit() { protocol_server.abort(); } +#[tokio::test] +async fn websocket_bidi_script_created_target_lifecycle_uses_committed_navigation() { + let (cdp_addr, protocol_server) = spawn_test_protocol_server().await; + let (mut socket, opener_context_id) = bidi_session_with_context(cdp_addr).await; + const POPUP_URL: &str = "data:text/html,Lifecycle Popup
popup
"; + + let subscribe = send_bidi_command( + &mut socket, + 3, + "session.subscribe", + json!({ + "events": [ + "browsingContext.domContentLoaded", + "browsingContext.load" + ] + }), + ) + .await; + assert_eq!(subscribe["type"], json!("success")); + + socket + .send(WsMessage::Text( + json!({ + "id": 4_u64, + "method": "script.callFunction", + "params": { + "functionDeclaration": format!( + "() => window.open({}, '_blank') !== null", + serde_json::to_string(POPUP_URL).expect("serialize popup URL") + ), + "awaitPromise": false, + "target": { + "context": opener_context_id.clone() + } + } + }) + .to_string() + .into(), + )) + .await + .expect("send script-created popup command"); + let mut messages = recv_until_id(&mut socket, 4).await; + let call_response = messages + .iter() + .find(|message| message["id"] == json!(4_u64)) + .expect("script.callFunction response"); + assert_eq!(call_response["type"], json!("success")); + + let is_committed_popup_load = |message: &serde_json::Value| { + message["method"] == json!("browsingContext.load") + && message["params"]["context"] != json!(opener_context_id) + && message["params"]["url"] == json!(POPUP_URL) + && message["params"]["navigation"].as_str().is_some() + }; + if !messages.iter().any(is_committed_popup_load) { + messages.append(&mut recv_until_match(&mut socket, is_committed_popup_load).await); + } + + let popup_context_id = messages + .iter() + .find(|message| is_committed_popup_load(message)) + .and_then(|message| message["params"]["context"].as_str()) + .expect("fact-backed popup load context") + .to_owned(); + let lifecycle_events = messages + .iter() + .filter(|message| { + message["params"]["context"] == json!(popup_context_id) + && matches!( + message["method"].as_str(), + Some("browsingContext.domContentLoaded" | "browsingContext.load") + ) + }) + .collect::>(); + assert_eq!( + lifecycle_events.len(), + 2, + "one committed Document should produce one DCL and one load: {messages:#?}" + ); + assert_eq!( + lifecycle_events[0]["method"], + json!("browsingContext.domContentLoaded") + ); + assert_eq!(lifecycle_events[1]["method"], json!("browsingContext.load")); + let navigation = lifecycle_events[0]["params"]["navigation"] + .as_str() + .expect("committed popup navigation id"); + for event in lifecycle_events { + assert_eq!(event["params"]["url"], json!(POPUP_URL)); + assert_eq!(event["params"]["navigation"], json!(navigation)); + assert!( + event["params"]["timestamp"] + .as_u64() + .is_some_and(|value| value > 0), + "committed lifecycle should retain a real timestamp: {event:?}" + ); + } + + let _ = socket.close(None).await; + protocol_server.abort(); +} + #[tokio::test] async fn websocket_bidi_set_viewport_user_context_inherits_through_window_open() { // Ported from Chromium/WPT @@ -17082,13 +17285,21 @@ async fn websocket_bidi_preload_channel_emits_after_wait_none_navigation() { 4, "script.addPreloadScript", json!({ - "functionDeclaration": "(channel) => channel('wait-none-preload')", - "arguments": [{ - "type": "channel", - "value": { - "channel": "wait_none_channel" + "functionDeclaration": "(first, second) => { first('wait-none-first'); second('wait-none-second'); }", + "arguments": [ + { + "type": "channel", + "value": { + "channel": "wait_none_channel_first" + } + }, + { + "type": "channel", + "value": { + "channel": "wait_none_channel_second" + } } - }], + ], "contexts": [context_id.clone()] }), ) @@ -17120,7 +17331,7 @@ async fn websocket_bidi_preload_channel_emits_after_wait_none_navigation() { &mut socket, navigate_messages, "script.message", - 1, + 2, ) .await; let navigate = bidi_message_by_id(&messages, 5); @@ -17129,22 +17340,54 @@ async fn websocket_bidi_preload_channel_emits_after_wait_none_navigation() { json!("success"), "wait=none navigation should return successfully: {messages:#?}" ); - let script_message = bidi_events_by_method(&messages, "script.message") - .pop() - .expect("wait=none preload script.message event"); - let realm = script_message["params"]["source"]["realm"] + let navigate_position = messages + .iter() + .position(|message| message["id"] == json!(5)) + .expect("wait=none navigation response position"); + let script_message_positions = messages + .iter() + .enumerate() + .filter_map(|(position, message)| { + (message["method"] == json!("script.message")).then_some(position) + }) + .collect::>(); + assert_eq!(script_message_positions.len(), 2); + assert!( + navigate_position < script_message_positions[0] + && script_message_positions[0] < script_message_positions[1], + "Chromium-compatible wait=none ordering is navigate response, then FIFO channel messages: {messages:#?}" + ); + let script_messages = bidi_events_by_method(&messages, "script.message"); + let first_realm = script_messages[0]["params"]["source"]["realm"] + .as_str() + .expect("first wait=none preload script.message realm"); + let second_realm = script_messages[1]["params"]["source"]["realm"] .as_str() - .expect("wait=none preload script.message realm"); + .expect("second wait=none preload script.message realm"); assert_eq!( - script_message["params"], + script_messages[0]["params"], json!({ - "channel": "wait_none_channel", + "channel": "wait_none_channel_first", "data": { "type": "string", - "value": "wait-none-preload" + "value": "wait-none-first" }, "source": { - "realm": realm, + "realm": first_realm, + "context": context_id.clone() + } + }) + ); + assert_eq!( + script_messages[1]["params"], + json!({ + "channel": "wait_none_channel_second", + "data": { + "type": "string", + "value": "wait-none-second" + }, + "source": { + "realm": second_realm, "context": context_id } }) diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs index 8143dd3f5f..865f580157 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs @@ -357,7 +357,7 @@ async fn wait_for_target_list( label: &str, predicate: impl Fn(&[serde_json::Value]) -> bool, ) -> Vec { - timeout(Duration::from_secs(5), async { + let result = timeout(Duration::from_secs(5), async { loop { let response = fetch_server_json(addr, "/json/list").await; if let Some(targets) = response.as_array() @@ -368,8 +368,14 @@ async fn wait_for_target_list( tokio::time::sleep(Duration::from_millis(10)).await; } }) - .await - .unwrap_or_else(|_| panic!("{label}")) + .await; + match result { + Ok(targets) => targets, + Err(_) => { + let observed = fetch_server_json(addr, "/json/list").await; + panic!("{label}; observed={observed:#?}"); + } + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -894,7 +900,7 @@ async fn page_agent_host_and_tab_host_survive_document_navigation() { "document navigation must update the Page target without mirroring a Tab event: {target_events:#?}" ); - let descriptors = timeout(Duration::from_secs(5), async { + let descriptors = match timeout(Duration::from_secs(5), async { loop { let descriptors = fetch_server_json(addr, "/json/list?for_tab").await; if descriptors.as_array().is_some_and(|targets| { @@ -910,7 +916,13 @@ async fn page_agent_host_and_tab_host_survive_document_navigation() { } }) .await - .expect("Tab discovery metadata should follow its primary Page"); + { + Ok(descriptors) => descriptors, + Err(_) => { + let observed = fetch_server_json(addr, "/json/list?for_tab").await; + panic!("Tab discovery metadata should follow its primary Page: {observed:#?}"); + } + }; assert!(descriptors.as_array().is_some_and(|targets| { targets.iter().any(|target| { target["id"] == json!(DEFAULT_TARGET_ID) diff --git a/moli-protocol-server/src/protocol_server/tests/classic.rs b/moli-protocol-server/src/protocol_server/tests/classic.rs index 6768d32b06..31eb772690 100644 --- a/moli-protocol-server/src/protocol_server/tests/classic.rs +++ b/moli-protocol-server/src/protocol_server/tests/classic.rs @@ -8891,6 +8891,15 @@ async fn webdriver_classic_history_traversal_preserves_live_same_document_and_fa let session_id = session["value"]["sessionId"] .as_str() .expect("classic session id"); + + let initial_back = classic_request_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/back"), + ) + .await; + assert_eq!(initial_back, json!({ "value": null })); + let page_url = format!("http://{fixture_addr}/page"); let navigated = classic_request_json_with_body( diff --git a/moli-protocol-server/src/protocol_server/tests/misc.rs b/moli-protocol-server/src/protocol_server/tests/misc.rs index d2012b9dc4..80694b7d0b 100644 --- a/moli-protocol-server/src/protocol_server/tests/misc.rs +++ b/moli-protocol-server/src/protocol_server/tests/misc.rs @@ -37,154 +37,6 @@ fn protocol_server_constructor_preserves_subframe_loading_policy() { assert!(!server.navigation_runtime_config.subframe_loading_enabled()); } -#[test] -fn merge_cookie_profiles_replaces_existing_cookie_by_storage_key() { - let mut cookies = vec![stored_cookie("sid", "old"), stored_cookie("theme", "dark")]; - - merge_cookie_profiles( - &mut cookies, - vec![stored_cookie("sid", "new"), stored_cookie("extra", "1")], - ); - - assert_eq!(cookies.len(), 3); - assert_eq!( - cookies - .iter() - .find(|cookie| cookie.name == "sid") - .map(|cookie| cookie.value.as_str()), - Some("new") - ); - assert_eq!( - cookies - .iter() - .find(|cookie| cookie.name == "theme") - .map(|cookie| cookie.value.as_str()), - Some("dark") - ); - assert_eq!( - cookies - .iter() - .find(|cookie| cookie.name == "extra") - .map(|cookie| cookie.value.as_str()), - Some("1") - ); -} - -#[test] -fn commit_cookie_profile_removes_initial_cookie_missing_from_final_snapshot() { - let deleted = stored_cookie("sid", "old"); - let mut cookies = vec![deleted.clone(), stored_cookie("theme", "dark")]; - - commit_cookie_profile( - &mut cookies, - CookieProfileCommit::new(vec![deleted], vec![stored_cookie("theme", "dark")]), - ); - - assert_eq!(cookies.len(), 1); - assert_eq!(cookies[0].name, "theme"); -} - -#[test] -fn commit_cookie_profile_preserves_concurrent_update_when_session_deleted_stale_cookie() { - let initial = stored_cookie("sid", "old"); - let concurrent = stored_cookie("sid", "newer"); - let mut cookies = vec![concurrent]; - - commit_cookie_profile( - &mut cookies, - CookieProfileCommit::new(vec![initial], Vec::new()), - ); - - assert_eq!(cookies.len(), 1); - assert_eq!(cookies[0].name, "sid"); - assert_eq!(cookies[0].value, "newer"); -} - -#[test] -fn commit_cookie_profile_merges_final_updates_and_drops_expired_cookies() { - let mut expired = stored_cookie("expired", "gone"); - expired.expires = Some(time::OffsetDateTime::now_utc() - time::Duration::days(1)); - let mut cookies = vec![ - stored_cookie("sid", "old"), - stored_cookie("theme", "dark"), - expired.clone(), - ]; - - commit_cookie_profile( - &mut cookies, - CookieProfileCommit::new( - vec![stored_cookie("sid", "old")], - vec![ - stored_cookie("sid", "new"), - stored_cookie("extra", "1"), - expired, - ], - ), - ); - - assert_eq!( - cookies - .iter() - .find(|cookie| cookie.name == "sid") - .map(|cookie| cookie.value.as_str()), - Some("new") - ); - assert_eq!( - cookies - .iter() - .find(|cookie| cookie.name == "theme") - .map(|cookie| cookie.value.as_str()), - Some("dark") - ); - assert!(cookies.iter().any(|cookie| cookie.name == "extra")); - assert!(!cookies.iter().any(|cookie| cookie.name == "expired")); -} - -#[test] -fn shared_cookie_profile_merge_and_save_writes_cache_file() -> anyhow::Result<()> { - let path = std::env::temp_dir().join(format!( - "moli-cdp-cookie-profile-{}-{}.json", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - let profile = SharedCookieProfile::new(vec![stored_cookie("sid", "old")], vec![path.clone()]); - - profile.merge_and_save(vec![stored_cookie("sid", "new")])?; - - let loaded = cookie_cache::load_cookie_cache(&path)?; - let _ = std::fs::remove_file(&path); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].name, "sid"); - assert_eq!(loaded[0].value, "new"); - Ok(()) -} - -#[test] -fn shared_cookie_profile_partition_backing_uses_storage_partition() -> anyhow::Result<()> { - let profile = TempDir::new("shared-cookie-profile-partition"); - let paths = BrowserProfilePaths::new(&profile.path); - let partition = Arc::new(StoragePartitionState::open(Some(&profile.path))?); - partition.import_cookies(vec![stored_cookie("sid", "old")])?; - let profile = SharedCookieProfile::from_storage_partition(partition.clone()); - - let initial_cookies = profile.snapshot(); - profile.commit_and_save(CookieProfileCommit::new( - initial_cookies, - vec![stored_cookie("sid", "new")], - ))?; - - let partition_cookies = partition.cookies()?; - assert_eq!(partition_cookies.len(), 1); - assert_eq!(partition_cookies[0].value, "new"); - let persisted = cookie_cache::load_cookie_cache(&paths.cookies_path)?; - assert_eq!(persisted.len(), 1); - assert_eq!(persisted[0].value, "new"); - Ok(()) -} - #[test] fn app_state_storage_partition_backing_derives_initial_partition_from_owner() -> anyhow::Result<()> { @@ -196,14 +48,14 @@ fn app_state_storage_partition_backing_derives_initial_partition_from_owner() -> OptionalResourceFetchMask::NONE, true, )?; - let initial_storage_partition = - state.initial_storage_partition(vec![stored_cookie("owner-cookie", "owner-value")]); + partition.import_cookies([stored_cookie("owner-cookie", "owner-value")])?; + let initial_storage_partition = state.initial_storage_partition(); let mut conn = moli_protocol::CdpConnection::new_with_initial_storage_partition(initial_storage_partition); - assert!(conn.snapshot_profile_backed_cookies().is_none()); + assert!(conn.snapshot_cookies().is_empty()); conn.install_default_browser_target(); - let cookies = conn.snapshot_profile_backed_cookies().unwrap(); + let cookies = conn.snapshot_cookies(); assert_eq!(cookies.len(), 1); assert_eq!(cookies[0].name, "owner-cookie"); assert_eq!(cookies[0].value, "owner-value"); diff --git a/moli-protocol-server/src/protocol_server/tests/mod.rs b/moli-protocol-server/src/protocol_server/tests/mod.rs index b09f9ded56..ae6b170d2c 100644 --- a/moli-protocol-server/src/protocol_server/tests/mod.rs +++ b/moli-protocol-server/src/protocol_server/tests/mod.rs @@ -29,6 +29,8 @@ use tokio::time::{sleep, timeout}; use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage}; use tower::util::ServiceExt; +use crate::cookie_cache; + struct DedicatedFixtureServer { shutdown_tx: Option>, thread: Option>, diff --git a/moli-protocol-server/src/protocol_server/tests/websocket.rs b/moli-protocol-server/src/protocol_server/tests/websocket.rs index bfc44c7187..fce2c4c3ab 100644 --- a/moli-protocol-server/src/protocol_server/tests/websocket.rs +++ b/moli-protocol-server/src/protocol_server/tests/websocket.rs @@ -64,6 +64,116 @@ async fn wait_for_profile_lock_release(paths: &BrowserProfilePaths) { } } +async fn bidi_new_session_and_context( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> String { + socket + .send(WsMessage::Text( + json!({ + "id": 1_u64, + "method": "session.new", + "params": {} + }) + .to_string() + .into(), + )) + .await + .expect("send session.new"); + let session = recv_ws_json(socket).await; + assert_eq!(session["type"], json!("success"), "{session:?}"); + + socket + .send(WsMessage::Text( + json!({ + "id": 2_u64, + "method": "browsingContext.create", + "params": { "type": "tab" } + }) + .to_string() + .into(), + )) + .await + .expect("send browsingContext.create"); + let created = recv_ws_json(socket).await; + assert_eq!(created["type"], json!("success"), "{created:?}"); + created["result"]["context"] + .as_str() + .expect("created context id") + .to_owned() +} + +async fn bidi_set_profile_cookie( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + id: u64, + context: &str, + value: &str, +) { + socket + .send(WsMessage::Text( + json!({ + "id": id, + "method": "storage.setCookie", + "params": { + "cookie": { + "name": "sid", + "value": { "type": "string", "value": value }, + "domain": "example.test", + "path": "/" + }, + "partition": { "type": "context", "context": context } + } + }) + .to_string() + .into(), + )) + .await + .expect("send storage.setCookie"); + let response = recv_ws_json(socket).await; + assert_eq!(response["id"], json!(id), "{response:?}"); + assert_eq!(response["type"], json!("success"), "{response:?}"); +} + +async fn bidi_profile_cookie_value( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + id: u64, + context: &str, +) -> String { + socket + .send(WsMessage::Text( + json!({ + "id": id, + "method": "storage.getCookies", + "params": { + "filter": { + "name": "sid", + "domain": "example.test", + "path": "/" + }, + "partition": { "type": "context", "context": context } + } + }) + .to_string() + .into(), + )) + .await + .expect("send storage.getCookies"); + let response = recv_ws_json(socket).await; + assert_eq!(response["id"], json!(id), "{response:?}"); + assert_eq!(response["type"], json!("success"), "{response:?}"); + response["result"]["cookies"] + .as_array() + .and_then(|cookies| cookies.first()) + .and_then(|cookie| cookie["value"]["value"].as_str()) + .unwrap_or_else(|| panic!("expected sid cookie value: {response:?}")) + .to_owned() +} + #[derive(Clone, Copy)] enum ProfileCookieDeleteCommand { NetworkClearBrowserCookies, @@ -464,6 +574,53 @@ async fn websocket_cdp_imported_cookies_with_profile_dir_persist_across_server_r fixture_server.abort(); } +#[tokio::test] +async fn websocket_bidi_profile_cookie_state_is_live_and_stale_close_cannot_overwrite() { + let profile = TempDir::new("bidi-live-cookie-profile"); + let paths = BrowserProfilePaths::new(&profile.path); + let (cdp_addr, protocol_server) = + spawn_profiled_test_protocol_server(profile.path.clone()).await; + let (mut first, _) = connect_async(format!("ws://{cdp_addr}/session")) + .await + .expect("connect first profiled BiDi frontend"); + let (mut second, _) = connect_async(format!("ws://{cdp_addr}/session")) + .await + .expect("connect second profiled BiDi frontend"); + let first_context = bidi_new_session_and_context(&mut first).await; + let second_context = bidi_new_session_and_context(&mut second).await; + + bidi_set_profile_cookie(&mut first, 3, &first_context, "old").await; + assert_eq!( + bidi_profile_cookie_value(&mut second, 3, &second_context).await, + "old", + "a concurrent frontend must observe the application-owned live cookie store" + ); + bidi_set_profile_cookie(&mut second, 4, &second_context, "newer").await; + + let _ = first.close(None).await; + let persisted = wait_for_cookie_profile(&paths.cookies_path, |cookies| { + cookies + .iter() + .any(|cookie| cookie.name == "sid" && cookie.value == "newer") + }) + .await; + assert!( + persisted + .iter() + .any(|cookie| cookie.name == "sid" && cookie.value == "newer"), + "closing a frontend with an older view must flush current Browser state: {persisted:?}" + ); + assert_eq!( + bidi_profile_cookie_value(&mut second, 5, &second_context).await, + "newer", + "closing another frontend must not roll live Browser state back" + ); + + let _ = second.close(None).await; + abort_test_cdp_server(protocol_server).await; + wait_for_profile_lock_release(&paths).await; +} + #[tokio::test] async fn websocket_cdp_indexeddb_profile_persists_across_server_restart() { let profile = TempDir::new("indexeddb-profile"); @@ -2537,20 +2694,11 @@ async fn websocket_cdp_replacement_retires_hanging_precommit_navigation() { .iter() .find(|message| message["id"] == json!(6_u64)) .expect("superseded Page.navigate response"); - assert!( - superseded_response.get("error").is_none(), - "Chromium reports a superseded Page.navigate as a successful command: {superseded_response:#?}" - ); - assert_eq!( - superseded_response["result"]["frameId"], - json!(session.target_id) - ); + assert_eq!(superseded_response["error"]["code"], json!(-32000)); assert_eq!( - superseded_response["result"]["errorText"], - json!("net::ERR_ABORTED") + superseded_response["error"]["message"], + json!("Navigation aborted") ); - assert_eq!(superseded_response["result"]["isDownload"], json!(false)); - assert!(superseded_response["result"].get("loaderId").is_none()); let replacement_document = cdp_runtime_evaluate_string( &mut socket, &session.session_id, @@ -3858,8 +4006,26 @@ document.addEventListener('readystatechange', () => { "method": "Runtime.evaluate", "sessionId": session.session_id, "params": { - "expression": "JSON.stringify({ marker: globalThis.blockedDeferProtocolMarker, ready: document.readyState, deferExecuted: globalThis.blockedDeferExecuted === true })", - "returnByValue": true + "expression": r#"new Promise((resolve) => { + const snapshot = () => JSON.stringify({ + marker: globalThis.blockedDeferProtocolMarker, + ready: document.readyState, + deferExecuted: globalThis.blockedDeferExecuted === true + }); + if (document.readyState === 'interactive') { + resolve(snapshot()); + return; + } + const onReadyStateChange = () => { + if (document.readyState === 'interactive') { + document.removeEventListener('readystatechange', onReadyStateChange); + resolve(snapshot()); + } + }; + document.addEventListener('readystatechange', onReadyStateChange); + })"#, + "returnByValue": true, + "awaitPromise": true } }) .to_string() @@ -4191,6 +4357,225 @@ async fn websocket_cdp_parser_script_navigation_progresses_without_followup_comm drop(fixture_server); } +#[tokio::test] +async fn websocket_cdp_ready_source_load_precedes_renderer_replacement() { + const PASSIVE_PROGRESS_TIMEOUT: Duration = Duration::from_secs(5); + + async fn source_page() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + r#"
source
"#, + ) + } + + let replacement_requested = Arc::new(tokio::sync::Notify::new()); + let requested_for_replacement = Arc::clone(&replacement_requested); + let fixture_app = Router::new() + .route("/load-handler-source", get(source_page)) + .route( + "/load-handler-replacement", + get(move || { + let requested_for_replacement = Arc::clone(&requested_for_replacement); + async move { + requested_for_replacement.notify_one(); + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + "
replacement
", + ) + } + }), + ); + let (fixture_addr, fixture_server) = + spawn_dedicated_fixture_server(fixture_app, "load-handler-navigation-order"); + + let (cdp_addr, protocol_server) = spawn_test_protocol_server().await; + let (mut socket, _) = connect_async(format!( + "ws://{cdp_addr}/devtools/browser/{DEFAULT_BROWSER_ID}" + )) + .await + .expect("connect to cdp websocket"); + let browser_context_id = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &browser_context_id).await; + let _ = send_cdp_command( + &mut socket, + 3, + "Page.enable", + Some(&session.session_id), + json!({}), + ) + .await; + let _ = send_cdp_command( + &mut socket, + 4, + "Page.setLifecycleEventsEnabled", + Some(&session.session_id), + json!({ "enabled": true }), + ) + .await; + + let source_url = format!("http://{fixture_addr}/load-handler-source"); + socket + .send(WsMessage::Text( + json!({ + "id": 5_u64, + "method": "Page.navigate", + "sessionId": session.session_id, + "params": { "url": source_url } + }) + .to_string() + .into(), + )) + .await + .expect("send source Page.navigate"); + + timeout(PASSIVE_PROGRESS_TIMEOUT, replacement_requested.notified()) + .await + .expect("load handler replacement must start without a follow-up CDP command"); + let replacement_url = format!("http://{fixture_addr}/load-handler-replacement"); + let messages = timeout( + PASSIVE_PROGRESS_TIMEOUT, + recv_until_match(&mut socket, |message| { + message["sessionId"].as_str() == Some(session.session_id.as_str()) + && message["method"] == json!("Page.frameNavigated") + && message["params"]["frame"]["url"].as_str() == Some(replacement_url.as_str()) + }), + ) + .await + .expect("replacement frame must arrive"); + let source_loader_id = messages + .iter() + .find(|message| message["id"] == json!(5_u64)) + .and_then(|message| message["result"]["loaderId"].as_str()) + .expect("source Page.navigate loaderId"); + let source_load_index = messages + .iter() + .position(|message| { + message["sessionId"].as_str() == Some(session.session_id.as_str()) + && message["method"] == json!("Page.lifecycleEvent") + && message["params"]["name"] == json!("load") + && message["params"]["loaderId"].as_str() == Some(source_loader_id) + }) + .expect("the already-reached source load fact must be projected"); + let replacement_frame_index = messages + .iter() + .position(|message| { + message["sessionId"].as_str() == Some(session.session_id.as_str()) + && message["method"] == json!("Page.frameNavigated") + && message["params"]["frame"]["url"].as_str() == Some(replacement_url.as_str()) + }) + .expect("replacement frame must be projected"); + assert!( + source_load_index < replacement_frame_index, + "an authoritative source load terminal must not be overtaken by replacement selection: {messages:#?}" + ); + + let _ = socket.close(None).await; + abort_test_cdp_server(protocol_server).await; + drop(fixture_server); +} + +#[tokio::test] +async fn websocket_cdp_parser_script_navigation_progresses_with_page_domain_disabled() { + const PASSIVE_PROGRESS_TIMEOUT: Duration = Duration::from_secs(5); + + async fn source_page() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + r#"
source
"#, + ) + } + + let script_requested = Arc::new(tokio::sync::Notify::new()); + let release_script = Arc::new(tokio::sync::Notify::new()); + let replacement_requested = Arc::new(tokio::sync::Notify::new()); + let requested_for_script = Arc::clone(&script_requested); + let release_for_script = Arc::clone(&release_script); + let requested_for_replacement = Arc::clone(&replacement_requested); + let fixture_app = Router::new() + .route("/source-disabled", get(source_page)) + .route( + "/navigate-disabled.js", + get(move || { + let requested_for_script = Arc::clone(&requested_for_script); + let release_for_script = Arc::clone(&release_for_script); + async move { + requested_for_script.notify_one(); + release_for_script.notified().await; + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/javascript")], + "location.href = '/replacement-disabled';", + ) + } + }), + ) + .route( + "/replacement-disabled", + get(move || { + let requested_for_replacement = Arc::clone(&requested_for_replacement); + async move { + requested_for_replacement.notify_one(); + ( + [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")], + "
replacement
", + ) + } + }), + ); + let (fixture_addr, fixture_server) = + spawn_dedicated_fixture_server(fixture_app, "page-disabled-parser-navigation"); + + let (cdp_addr, protocol_server) = spawn_test_protocol_server().await; + let (mut socket, _) = connect_async(format!( + "ws://{cdp_addr}/devtools/browser/{DEFAULT_BROWSER_ID}" + )) + .await + .expect("connect to cdp websocket"); + let browser_context_id = cdp_create_browser_context(&mut socket, 1).await; + let session = cdp_create_attached_target(&mut socket, 2, &browser_context_id).await; + + let source_url = format!("http://{fixture_addr}/source-disabled"); + socket + .send(WsMessage::Text( + json!({ + "id": 3_u64, + "method": "Page.navigate", + "sessionId": session.session_id, + "params": { "url": source_url } + }) + .to_string() + .into(), + )) + .await + .expect("send source Page.navigate without enabling Page"); + timeout(PASSIVE_PROGRESS_TIMEOUT, script_requested.notified()) + .await + .expect("parser-blocking navigation script request should start"); + let navigate_messages = timeout(PASSIVE_PROGRESS_TIMEOUT, recv_until_id(&mut socket, 3)) + .await + .expect("Page.navigate should respond while the parser script is blocked"); + assert!( + navigate_messages + .iter() + .any(|message| message["id"] == json!(3_u64) && message.get("result").is_some()), + "Page.navigate should succeed without Page.enable: {navigate_messages:#?}" + ); + + // Reading the response above is observational. No command or Page-domain + // subscription follows it; Browser Owner must still start the replacement. + release_script.notify_one(); + timeout(PASSIVE_PROGRESS_TIMEOUT, replacement_requested.notified()) + .await + .expect("replacement request must start while the Page domain is disabled"); + + let _ = socket.close(None).await; + abort_test_cdp_server(protocol_server).await; + drop(fixture_server); +} + #[tokio::test] async fn websocket_cdp_replacement_retires_blocked_parser_defer_lifecycle() { async fn source_page() -> impl IntoResponse { diff --git a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs index aad4a1b7fa..7b77b47d67 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs @@ -13,20 +13,21 @@ use axum::{ response::{IntoResponse, Response}, }; use futures_util::SinkExt; -use moli_cookie_jar::StoredCookie; -use moli_core::{RendererOutputTransportMessage, runtime::NavigationRuntimeConfig}; +use moli_core::{ + RendererOutputTransportMessage, + browser_host::BrowserNavigateCommandOutcome, + runtime::{NavigationRuntimeConfig, storage_partition::StoragePartitionState}, +}; use moli_protocol::{ - BackgroundCommandResponsePayload, BackgroundProtocolEvent, CdpInitialStoragePartition, + BackgroundProtocolEvent, CdpInitialStoragePartition, conn::RuntimeInspectorResponseReady, devtools_runtime::{ AutomationEvent, DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, DevToolsDomObjectReferenceCommand, DevToolsDomObjectReferenceOperation, DevToolsError, - DevToolsErrorKind, DevToolsFrameId, DevToolsGetBrowserContextsCommand, - DevToolsGetFrameTreesCommand, DevToolsGetLayoutMetricsCommand, DevToolsGetRealmsCommand, - DevToolsGetTargetInfoCommand, DevToolsNavigationWait, DevToolsProtocol, - DevToolsRemoteHandleId, DevToolsSessionId, DevToolsSetFileInputFilesCommand, - DevToolsTargetId, DevToolsTargetInfo, DevToolsTargetKind, NavigationFrameEvent, - NavigationFrameEventKind, NavigationLifecycleEvent, TargetLifecycleEvent, + DevToolsErrorKind, DevToolsGetBrowserContextsCommand, DevToolsGetFrameTreesCommand, + DevToolsGetLayoutMetricsCommand, DevToolsGetRealmsCommand, DevToolsGetTargetInfoCommand, + DevToolsNavigationWait, DevToolsProtocol, DevToolsRemoteHandleId, DevToolsSessionId, + DevToolsSetFileInputFilesCommand, DevToolsTargetId, DevToolsTargetInfo, DevToolsTargetKind, webdriver_bidi_navigation_id_from_loader_id, }, }; @@ -49,28 +50,27 @@ use tokio::time::sleep; use tracing::warn; use crate::cdp_scheduler::{ - CdpScheduler, CdpSchedulerEventReceivers, DevToolsRuntimeCommandProgress, - PendingDevToolsRuntimeDeferredReplyExecution, ProtocolAdapterScheduler, - ProtocolAdapterSchedulerAdvance, ProtocolAdapterSchedulerInput, ProtocolOutputSequence, - RendererOutputTransportFailure, + CdpScheduler, CdpSchedulerEventReceivers, CdpSchedulerProgressFailure, + DevToolsRuntimeCommandProgress, PendingDevToolsRuntimeDeferredReplyExecution, + ProtocolAdapterScheduler, ProtocolAdapterSchedulerAdvance, ProtocolAdapterSchedulerInput, + ProtocolOutputSequence, }; use super::webdriver_files::selected_files_from_paths; use super::{ - AppState, CookieProfileCommit, SharedCookieProfile, - protocol_local_executor::spawn_protocol_local_task, + AppState, + devtools_host_service::{BidiFrontendSession, DevToolsHostServiceHandle}, + flush_storage_partition_profile, }; pub(super) async fn ws_bidi_session_upgrade_handler( ws: WebSocketUpgrade, State(state): State, ) -> Response { - let initial_cookies = state.cookie_profile.snapshot(); - let initial_cookie_snapshot = initial_cookies.clone(); - let initial_storage_partition = state.initial_storage_partition(initial_cookies); + let initial_storage_partition = state.initial_storage_partition(); let web_socket_url = state.bidi_ws_url; let session_registry = state.bidi_session_registry; - let cookie_profile = state.cookie_profile; + let storage_partition = state.storage_partition; let navigation_runtime_config = NavigationRuntimeConfig::new( state.fetch_config, state.optional_resource_fetch_mask, @@ -82,8 +82,7 @@ pub(super) async fn ws_bidi_session_upgrade_handler( socket, web_socket_url, session_registry, - cookie_profile, - initial_cookie_snapshot, + storage_partition, initial_storage_partition, navigation_runtime_config, ) @@ -133,133 +132,32 @@ async fn handle_bidi_session_socket( socket: WebSocket, web_socket_url: String, session_registry: SharedBidiSessionRegistry, - cookie_profile: SharedCookieProfile, - initial_cookie_snapshot: Vec, + storage_partition: Arc, initial_storage_partition: CdpInitialStoragePartition, navigation_runtime_config: NavigationRuntimeConfig, ) { - let cookie_commit = spawn_protocol_local_task("bidi-socket", move || { - handle_bidi_session_socket_local( + let (host, host_finished) = + DevToolsHostServiceHandle::spawn(initial_storage_partition, navigation_runtime_config); + match host + .attach_bidi( socket, web_socket_url, + BidiFrontendSession::Standalone, session_registry, - initial_cookie_snapshot, - initial_storage_partition, - navigation_runtime_config, ) - }) - .await; - match cookie_commit { - Ok(cookie_commit) => { - if let Err(error) = cookie_profile.commit_and_save(cookie_commit) { - warn!(?error, "failed to persist BiDi cookie profile"); - } - } - Err(error) => warn!( - ?error, - "BiDi socket worker failed before cookie profile writeback" - ), + .await + { + Some(frontend) => frontend.wait().await, + None => warn!("failed to attach standalone WebDriver BiDi frontend"), } -} - -async fn handle_bidi_session_socket_local( - socket: WebSocket, - web_socket_url: String, - session_registry: SharedBidiSessionRegistry, - initial_cookie_snapshot: Vec, - initial_storage_partition: CdpInitialStoragePartition, - navigation_runtime_config: NavigationRuntimeConfig, -) -> CookieProfileCommit { - let mut actor = BidiSocketActor::new(socket, web_socket_url); - let (mut scheduler, mut receivers) = CdpScheduler::new_with_initial_state_runtime_config( - initial_storage_partition, - navigation_runtime_config, - ); - actor.install_runtime_response_ready_sender(&mut scheduler); - let mut adapter_scheduler = ProtocolAdapterScheduler::<()>::default(); - loop { - let page_javascript_blocked = scheduler.has_pending_javascript_dialog(); - adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked); - tokio::select! { - biased; - maybe_message = actor.socket.recv() => { - let Some(message) = maybe_message else { - break; - }; - if !actor.handle_socket_message( - &mut scheduler, - &mut receivers, - &session_registry, - message, - ).await { - break; - } - } - maybe_completion = receivers.background_navigation_completion_rx.recv() => { - let Some(completion) = maybe_completion else { - break; - }; - if !actor.handle_background_navigation_completion( - &mut scheduler, - &mut receivers, - completion, - ).await { - break; - } - } - maybe_event = receivers.background_event_rx.recv() => { - let Some(event) = maybe_event else { - break; - }; - let output = scheduler.route_background_event_around_inflight_navigation(event); - if !actor - .send_or_route_protocol_output(&mut scheduler, &mut receivers, output, None) - .await - { - break; - } - } - maybe_publication = receivers.renderer_publication_rx.recv(), if !page_javascript_blocked => { - let Some(publication) = maybe_publication else { - break; - }; - if !actor.handle_renderer_publication( - &mut adapter_scheduler, - &mut scheduler, - &mut receivers, - publication, - ).await { - break; - } - } - maybe_response = actor.runtime_response_ready_rx.recv() => { - let Some(response) = maybe_response else { - break; - }; - if !actor.handle_runtime_response_ready(&mut scheduler, &mut receivers, response).await { - break; - } - } - input = adapter_scheduler.recv_input(), if !page_javascript_blocked => { - if !actor.handle_adapter_scheduler_input( - &mut adapter_scheduler, - &mut scheduler, - &mut receivers, - input, - ).await { - break; - } - } - } + host.shutdown().await; + if let Err(error) = host_finished.await { + warn!( + ?error, + "DevTools Host stopped before standalone BiDi teardown completed" + ); } - actor - .release_event_sources(&mut scheduler, &mut receivers) - .await; - actor.release_session(&mut session_registry.lock()); - CookieProfileCommit::from_optional_profile_backed_snapshot( - initial_cookie_snapshot, - scheduler.snapshot_profile_backed_cookies(), - ) + flush_storage_partition_profile(storage_partition, "webdriver-bidi").await; } pub(in crate::protocol_server) struct BidiSocketActor { @@ -267,6 +165,7 @@ pub(in crate::protocol_server) struct BidiSocketActor { bidi: BidiConnectionState, input_action_states: BTreeMap, pending_navigation_response: Option, + pending_target_close_responses: Vec, pending_runtime_command: Option, runtime_response_ready_tx: mpsc::UnboundedSender, runtime_response_ready_rx: mpsc::UnboundedReceiver, @@ -286,18 +185,20 @@ impl BidiSocketActor { bidi: BidiConnectionState::with_web_socket_url(web_socket_url), input_action_states: BTreeMap::new(), pending_navigation_response: None, + pending_target_close_responses: Vec::new(), pending_runtime_command: None, runtime_response_ready_tx, runtime_response_ready_rx, } } - pub(in crate::protocol_server) fn install_runtime_response_ready_sender( + pub(in crate::protocol_server) fn install_frontend_scheduler_hooks( &self, scheduler: &mut CdpScheduler, ) { scheduler .set_runtime_inspector_response_ready_sender(self.runtime_response_ready_tx.clone()); + scheduler.enable_webdriver_bidi_target_lifecycle_projection(); } pub(in crate::protocol_server) fn attach_existing_session( @@ -339,6 +240,7 @@ impl BidiSocketActor { &mut events, ) .await; + scheduler.disable_webdriver_bidi_target_lifecycle_projection(); } fn release_pending_runtime_command_state(&mut self, scheduler: &mut CdpScheduler) { @@ -392,6 +294,7 @@ impl BidiSocketActor { &mut self.input_action_states, session_registry, &mut self.pending_navigation_response, + &mut self.pending_target_close_responses, &mut self.pending_runtime_command, &self.runtime_response_ready_tx, message, @@ -503,6 +406,7 @@ impl BidiSocketActor { output, owner_context, &mut self.pending_navigation_response, + &mut self.pending_target_close_responses, ) .await; } @@ -525,6 +429,7 @@ impl BidiSocketActor { output, None, &mut self.pending_navigation_response, + &mut self.pending_target_close_responses, ) .await; }; @@ -555,6 +460,7 @@ impl BidiSocketActor { output, None, &mut self.pending_navigation_response, + &mut self.pending_target_close_responses, ) .await; }; @@ -589,6 +495,7 @@ impl BidiSocketActor { receivers, &mut self.bidi, &mut self.pending_navigation_response, + &mut self.pending_target_close_responses, pending_command, *execution, ) @@ -607,6 +514,7 @@ impl BidiSocketActor { protocol_output, None, &mut self.pending_navigation_response, + &mut self.pending_target_close_responses, ) .await; self.pending_runtime_command = Some(pending_command); @@ -624,6 +532,7 @@ async fn handle_bidi_socket_message( input_action_states: &mut BTreeMap, session_registry: &SharedBidiSessionRegistry, pending_navigation_response: &mut Option, + pending_target_close_responses: &mut Vec, pending_runtime_command: &mut Option, runtime_response_ready_tx: &mpsc::UnboundedSender, message: Result, @@ -740,6 +649,9 @@ async fn handle_bidi_socket_message( let pending_navigation_candidate = devtools_command.as_ref().and_then(|dispatch| { pending_navigation_response_for_dispatch(dispatch, command_channel.as_deref()) }); + let pending_target_close_candidate = devtools_command + .as_ref() + .and_then(pending_target_close_response_target_for_dispatch); let command_start = if pending_runtime_command.is_some() && (devtools_command.is_some() || input_command.is_some()) { @@ -809,6 +721,10 @@ async fn handle_bidi_socket_message( pending.pending_navigation_candidate = pending_navigation_candidate; let initial_sources = std::mem::take(&mut pending.completion.event_sources).into_sources(); + let pending_close_responses = take_pending_target_close_responses_from_sources( + pending_target_close_responses, + &initial_sources, + ); let mut bidi_events = subscribed_bidi_events_from_devtools_event_sources( Some(&*scheduler), bidi, @@ -829,6 +745,9 @@ async fn handle_bidi_socket_message( if !send_bidi_json_events(socket, bidi_events).await { return false; } + if !send_bidi_json_events(socket, pending_close_responses).await { + return false; + } *pending_runtime_command = Some(pending); return true; } @@ -841,14 +760,24 @@ async fn handle_bidi_socket_message( command_params.as_ref(), &command_output.response, ); - let defer_current_response = pending_navigation_candidate.is_some() + let defer_navigation_response = pending_navigation_candidate.is_some() && pending_navigation_response.is_none() && bidi_response_is_missing_devtools_command_result(&command_output.response) && sources_include_auth_required_pause(&command_output.event_sources); - if defer_current_response { + if defer_navigation_response { *pending_navigation_response = pending_navigation_candidate; } - let pending_response_from_event_sources = (!defer_current_response) + let defer_target_close_response = pending_target_close_candidate.is_some() + && command_output.response["type"] == json!("success"); + if let Some(target_id) = pending_target_close_candidate + && defer_target_close_response + { + pending_target_close_responses.push(BidiPendingTargetCloseResponse { + target_id, + response: command_output.response.clone(), + }); + } + let pending_response_from_event_sources = (!defer_navigation_response) .then(|| { take_pending_navigation_response_from_sources( pending_navigation_response, @@ -856,7 +785,7 @@ async fn handle_bidi_socket_message( ) }) .flatten(); - let pending_response_from_post_response_event_sources = (!defer_current_response) + let pending_response_from_post_response_event_sources = (!defer_navigation_response) .then(|| { take_pending_navigation_response_from_sources( pending_navigation_response, @@ -864,6 +793,16 @@ async fn handle_bidi_socket_message( ) }) .flatten(); + let pending_target_close_responses_from_event_sources = + take_pending_target_close_responses_from_sources( + pending_target_close_responses, + &command_output.event_sources, + ); + let pending_target_close_responses_from_post_response_event_sources = + take_pending_target_close_responses_from_sources( + pending_target_close_responses, + &command_output.post_response_event_sources, + ); let mut bidi_events = subscribed_bidi_events_from_devtools_event_sources( Some(&*scheduler), bidi, @@ -943,7 +882,11 @@ async fn handle_bidi_socket_message( if !send_bidi_json_events(socket, bidi_events).await { return false; } - if !defer_current_response + if !send_bidi_json_events(socket, pending_target_close_responses_from_event_sources).await { + return false; + } + if !defer_navigation_response + && !defer_target_close_response && socket .send(Message::Text(command_output.response.to_string().into())) .await @@ -962,6 +905,14 @@ async fn handle_bidi_socket_message( if !send_bidi_json_events(socket, post_response_bidi_events).await { return false; } + if !send_bidi_json_events( + socket, + pending_target_close_responses_from_post_response_event_sources, + ) + .await + { + return false; + } if let Some(response) = pending_response_from_post_response_event_sources && socket .send(Message::Text(response.to_string().into())) @@ -979,6 +930,7 @@ async fn handle_bidi_socket_message( receivers, bidi, pending_navigation_response, + pending_target_close_responses, command_output.event_context.as_deref(), ) .await @@ -1011,6 +963,7 @@ async fn complete_and_send_bidi_pending_runtime_command( receivers: &mut CdpSchedulerEventReceivers, bidi: &mut BidiConnectionState, pending_navigation_response: &mut Option, + pending_target_close_responses: &mut Vec, pending: BidiPendingRuntimeCommand, execution: crate::cdp_scheduler::DevToolsCommandExecution, ) -> bool { @@ -1055,6 +1008,16 @@ async fn complete_and_send_bidi_pending_runtime_command( ) }) .flatten(); + let pending_target_close_responses_from_event_sources = + take_pending_target_close_responses_from_sources( + pending_target_close_responses, + &command_output.event_sources, + ); + let pending_target_close_responses_from_post_response_event_sources = + take_pending_target_close_responses_from_sources( + pending_target_close_responses, + &command_output.post_response_event_sources, + ); let mut bidi_events = subscribed_bidi_events_from_devtools_event_sources( Some(&*scheduler), bidi, @@ -1107,6 +1070,9 @@ async fn complete_and_send_bidi_pending_runtime_command( if !send_bidi_json_events(socket, bidi_events).await { return false; } + if !send_bidi_json_events(socket, pending_target_close_responses_from_event_sources).await { + return false; + } if !defer_current_response && socket .send(Message::Text(command_output.response.to_string().into())) @@ -1126,6 +1092,14 @@ async fn complete_and_send_bidi_pending_runtime_command( if !send_bidi_json_events(socket, post_response_bidi_events).await { return false; } + if !send_bidi_json_events( + socket, + pending_target_close_responses_from_post_response_event_sources, + ) + .await + { + return false; + } if let Some(response) = pending_response_from_post_response_event_sources && socket .send(Message::Text(response.to_string().into())) @@ -1143,6 +1117,7 @@ async fn complete_and_send_bidi_pending_runtime_command( receivers, bidi, pending_navigation_response, + pending_target_close_responses, command_output.event_context.as_deref(), ) .await @@ -1165,6 +1140,7 @@ async fn drain_and_send_bidi_navigation_after_response( receivers: &mut CdpSchedulerEventReceivers, bidi: &mut BidiConnectionState, pending_navigation_response: &mut Option, + pending_target_close_responses: &mut Vec, event_context: Option<&str>, ) -> bool { let (background_navigation_sources, transport_is_live) = @@ -1183,6 +1159,10 @@ async fn drain_and_send_bidi_navigation_after_response( ) }) .flatten(); + let pending_close_responses = take_pending_target_close_responses_from_sources( + pending_target_close_responses, + &background_navigation_sources, + ); let mut background_navigation_events = subscribed_bidi_events_from_devtools_event_sources( Some(&*scheduler), bidi, @@ -1204,6 +1184,9 @@ async fn drain_and_send_bidi_navigation_after_response( if !send_bidi_json_events(socket, background_navigation_events).await { return false; } + if !send_bidi_json_events(socket, pending_close_responses).await { + return false; + } if let Some(response) = pending_response && socket .send(Message::Text(response.to_string().into())) @@ -1401,7 +1384,7 @@ fn materialize_bidi_event_source_hook_output( scheduler: &CdpScheduler, bidi: &mut BidiConnectionState, events: &mut Vec, - result: Result, + result: Result, owner_context: Option<&str>, ) -> Result { match result { @@ -1428,6 +1411,7 @@ async fn send_bidi_protocol_output( output: ProtocolOutputSequence, owner_context: Option<&str>, pending_navigation_response: &mut Option, + pending_target_close_responses: &mut Vec, ) -> bool { if output.is_empty() { return true; @@ -1435,6 +1419,8 @@ async fn send_bidi_protocol_output( let sources = BidiDevToolsEventSources::from_protocol_output(output).into_sources(); let pending_response = take_pending_navigation_response_from_sources(pending_navigation_response, &sources); + let pending_close_responses = + take_pending_target_close_responses_from_sources(pending_target_close_responses, &sources); let mut events = subscribed_bidi_events_from_devtools_event_sources( Some(&*scheduler), bidi, @@ -1450,6 +1436,9 @@ async fn send_bidi_protocol_output( if !send_bidi_json_events(socket, events).await { return false; } + if !send_bidi_json_events(socket, pending_close_responses).await { + return false; + } if let Some(response) = pending_response && socket .send(Message::Text(response.to_string().into())) @@ -1495,12 +1484,8 @@ struct BidiPendingRuntimeCommand { struct BidiDevToolsCommandCompletion { id: u64, - session_id: String, event_sources: BidiDevToolsEventSources, event_context: Option, - close_target_event: Option, - create_target_browser_context_id: - Option, observe_browsing_context_load: bool, script_may_create_targets: bool, previous_target_discovery: Option, @@ -1526,6 +1511,12 @@ struct BidiPendingNavigationResponse { background_command_id: u64, } +#[derive(Debug, Clone)] +struct BidiPendingTargetCloseResponse { + target_id: String, + response: serde_json::Value, +} + enum BidiDevToolsEventSource { ProtocolMessage(serde_json::Value), ProtocolMessageWithAutomationEvent { @@ -1534,9 +1525,8 @@ enum BidiDevToolsEventSource { }, CommandResponse { command_id: Option, - response: BackgroundCommandResponsePayload, + browser_navigate_outcome: Option, }, - AutomationEvent(Box), } #[derive(Default)] @@ -1544,15 +1534,15 @@ struct BidiDevToolsEventSources { sources: Vec, } -struct BidiRendererOutputTransportFailure { +struct BidiSchedulerProgressFailure { event_sources: BidiDevToolsEventSources, error: DevToolsError, } -impl BidiRendererOutputTransportFailure { - fn from_renderer( +impl BidiSchedulerProgressFailure { + fn from_scheduler( mut event_sources: BidiDevToolsEventSources, - failure: crate::cdp_scheduler::RendererOutputTransportFailure, + failure: crate::cdp_scheduler::CdpSchedulerProgressFailure, ) -> Self { let (output, error) = failure.into_parts(); event_sources.extend_protocol_output(output); @@ -1571,7 +1561,7 @@ fn bidi_command_output_from_renderer_transport_failure( id: u64, event_context: Option, mut preceding_sources: BidiDevToolsEventSources, - failure: BidiRendererOutputTransportFailure, + failure: BidiSchedulerProgressFailure, ) -> BidiDevToolsCommandOutput { let (failure_sources, error) = failure.into_parts(); preceding_sources.append(failure_sources); @@ -1597,17 +1587,15 @@ impl BidiDevToolsEventSources { } } - fn push_automation_event(&mut self, event: AutomationEvent) { - self.sources - .push(BidiDevToolsEventSource::AutomationEvent(Box::new(event))); - } - fn push_background_event(&mut self, event: BackgroundProtocolEvent) { + let browser_navigate_outcome = event + .browser_navigate_command_outcome() + .map(|(_, outcome)| outcome.clone()); let event = match event.into_command_response_payload() { - Ok((command_id, _, response)) => { + Ok((command_id, _, _response)) => { self.sources.push(BidiDevToolsEventSource::CommandResponse { command_id, - response, + browser_navigate_outcome, }); return; } @@ -1631,27 +1619,6 @@ impl BidiDevToolsEventSources { self.sources.append(&mut other.sources); } - fn push_initial_load_events_for_script_created_targets(&mut self) { - let load_events = self - .sources - .iter() - .filter_map(|source| match source { - BidiDevToolsEventSource::ProtocolMessage(message) => { - initial_load_events_for_target_created_message(message) - } - BidiDevToolsEventSource::ProtocolMessageWithAutomationEvent { message, .. } => { - initial_load_events_for_target_created_message(message) - } - BidiDevToolsEventSource::CommandResponse { .. } => None, - BidiDevToolsEventSource::AutomationEvent(_) => None, - }) - .flatten() - .collect::>(); - for event in load_events { - self.push_automation_event(event); - } - } - fn into_sources(self) -> Vec { self.sources } @@ -1675,6 +1642,56 @@ fn pending_navigation_response_for_dispatch( }) } +fn pending_target_close_response_target_for_dispatch( + dispatch: &BidiDevToolsCommandDispatch, +) -> Option { + let DevToolsCommand::CloseTarget(command) = &dispatch.command else { + return None; + }; + Some(command.target_id.as_str().to_owned()) +} + +fn source_target_destroyed_id(source: &BidiDevToolsEventSource) -> Option<&str> { + let automation_event = match source { + BidiDevToolsEventSource::ProtocolMessageWithAutomationEvent { + automation_event, .. + } => automation_event.as_ref(), + BidiDevToolsEventSource::ProtocolMessage(_) + | BidiDevToolsEventSource::CommandResponse { .. } => return None, + }; + let AutomationEvent::TargetDestroyed(event) = automation_event else { + return None; + }; + Some(event.target_id.as_str()) +} + +fn take_pending_target_close_responses_from_sources( + pending: &mut Vec, + sources: &[BidiDevToolsEventSource], +) -> Vec { + if pending.is_empty() { + return Vec::new(); + } + let destroyed_target_ids = sources + .iter() + .filter_map(source_target_destroyed_id) + .collect::>(); + if destroyed_target_ids.is_empty() { + return Vec::new(); + } + let mut released = Vec::new(); + let mut retained = Vec::new(); + for pending_response in std::mem::take(pending) { + if destroyed_target_ids.contains(pending_response.target_id.as_str()) { + released.push(pending_response.response); + } else { + retained.push(pending_response); + } + } + *pending = retained; + released +} + fn bidi_response_is_missing_devtools_command_result(response: &serde_json::Value) -> bool { response["type"] == json!("error") && response["message"].as_str() == Some("MissingDevToolsCommandResult") @@ -1682,9 +1699,6 @@ fn bidi_response_is_missing_devtools_command_result(response: &serde_json::Value fn sources_include_auth_required_pause(sources: &[BidiDevToolsEventSource]) -> bool { sources.iter().any(|source| match source { - BidiDevToolsEventSource::AutomationEvent(event) => { - matches!(event.as_ref(), AutomationEvent::NetworkAuthRequired(_)) - } BidiDevToolsEventSource::CommandResponse { .. } => false, BidiDevToolsEventSource::ProtocolMessage(message) => { message["method"] == json!("Fetch.authRequired") @@ -1706,46 +1720,39 @@ fn take_pending_navigation_response_from_sources( sources: &[BidiDevToolsEventSource], ) -> Option { let pending_response = pending.as_ref()?; - let response = sources - .iter() - .find_map(|source| match source { - BidiDevToolsEventSource::CommandResponse { - command_id, - response, - } => pending_navigation_response_from_command_response( - pending_response, - *command_id, - response, - ), - BidiDevToolsEventSource::ProtocolMessage(_) - | BidiDevToolsEventSource::ProtocolMessageWithAutomationEvent { .. } - | BidiDevToolsEventSource::AutomationEvent(_) => None, - }) - .or_else(|| { - sources.iter().find_map(|source| match source { - BidiDevToolsEventSource::ProtocolMessage(message) - | BidiDevToolsEventSource::ProtocolMessageWithAutomationEvent { message, .. } => { - pending_navigation_response_from_protocol_message(pending_response, message) - } - BidiDevToolsEventSource::CommandResponse { .. } - | BidiDevToolsEventSource::AutomationEvent(_) => None, - }) - })?; + let response = sources.iter().find_map(|source| match source { + BidiDevToolsEventSource::CommandResponse { + command_id, + browser_navigate_outcome: Some(outcome), + } => { + pending_navigation_response_from_browser_outcome(pending_response, *command_id, outcome) + } + BidiDevToolsEventSource::ProtocolMessage(_) + | BidiDevToolsEventSource::ProtocolMessageWithAutomationEvent { .. } + | BidiDevToolsEventSource::CommandResponse { + browser_navigate_outcome: None, + .. + } => None, + })?; *pending = None; Some(response) } -fn pending_navigation_response_from_command_response( +fn pending_navigation_response_from_browser_outcome( pending: &BidiPendingNavigationResponse, command_id: Option, - response: &BackgroundCommandResponsePayload, + outcome: &BrowserNavigateCommandOutcome, ) -> Option { if command_id != Some(pending.background_command_id) { return None; } - match response { - BackgroundCommandResponsePayload::Success { result } => { - let navigation = bidi_navigation_value_from_command_result(result); + match outcome { + BrowserNavigateCommandOutcome::Completed(result) => { + let navigation = result + .loader_id() + .map(webdriver_bidi_navigation_id_from_loader_id) + .map(|navigation_id| json!(navigation_id.into_string())) + .unwrap_or(serde_json::Value::Null); Some(bidi_message_with_channel( success_response( pending.id, @@ -1757,106 +1764,17 @@ fn pending_navigation_response_from_command_response( pending.channel.as_deref(), )) } - BackgroundCommandResponsePayload::Error { message, .. } => Some(bidi_message_with_channel( + BrowserNavigateCommandOutcome::Rejected(error) => Some(bidi_message_with_channel( error_response( Some(pending.id), BidiErrorCode::UnsupportedOperation, - message, + error.message(), ), pending.channel.as_deref(), )), } } -fn pending_navigation_response_from_protocol_message( - pending: &BidiPendingNavigationResponse, - message: &serde_json::Value, -) -> Option { - if message.get("id").and_then(serde_json::Value::as_u64) != Some(pending.background_command_id) - { - return None; - } - if let Some(result) = message.get("result") { - let navigation = bidi_navigation_value_from_command_result(result); - return Some(bidi_message_with_channel( - success_response( - pending.id, - json!({ - "navigation": navigation, - "url": pending.url, - }), - ), - pending.channel.as_deref(), - )); - } - let error = message.get("error")?; - let error_message = error - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("navigation command failed"); - Some(bidi_message_with_channel( - error_response( - Some(pending.id), - BidiErrorCode::UnsupportedOperation, - error_message, - ), - pending.channel.as_deref(), - )) -} - -fn bidi_navigation_value_from_command_result(result: &serde_json::Value) -> serde_json::Value { - if let Some(navigation) = result.get("navigation") { - return navigation.clone(); - } - result - .get("loaderId") - .and_then(serde_json::Value::as_str) - .map(webdriver_bidi_navigation_id_from_loader_id) - .map(|navigation_id| json!(navigation_id.into_string())) - .unwrap_or(serde_json::Value::Null) -} - -fn initial_load_events_for_target_created_message( - message: &serde_json::Value, -) -> Option> { - if message.get("method").and_then(serde_json::Value::as_str) != Some("Target.targetCreated") { - return None; - } - let target_info = &message["params"]["targetInfo"]; - if target_info.get("type").and_then(serde_json::Value::as_str) != Some("page") { - return None; - } - let target_id = target_info.get("targetId")?.as_str()?; - let url = target_info - .get("url") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_owned(); - let target_id = DevToolsTargetId::from(target_id); - let frame_id = DevToolsFrameId::from(target_id.as_str()); - Some(vec![ - AutomationEvent::NavigationFrame(NavigationFrameEvent { - target_id: target_id.clone(), - frame_id: frame_id.clone(), - parent_frame_id: None, - loader_id: None, - url: url.clone(), - kind: NavigationFrameEventKind::Navigated, - frame_name: None, - security_origin: None, - secure_context_type: None, - }), - AutomationEvent::Load(NavigationLifecycleEvent { - target_id, - frame_id, - navigation_id: None, - loader_id: None, - url, - timestamp: 0.0, - }), - ]) -} - fn subscribed_bidi_events_from_devtools_event_sources( scheduler: Option<&CdpScheduler>, bidi: &mut BidiConnectionState, @@ -1891,13 +1809,6 @@ fn subscribed_bidi_events_from_devtools_event_sources( ); } BidiDevToolsEventSource::CommandResponse { .. } => {} - BidiDevToolsEventSource::AutomationEvent(event) => { - events.extend( - bidi.subscribed_bidi_events_from_automation_events(std::iter::once( - event.as_ref(), - )), - ); - } } } events @@ -1936,7 +1847,7 @@ fn bidi_protocol_message_owner_context( async fn enable_bidi_runtime_protocol_sources( scheduler: &mut CdpScheduler, receivers: &mut CdpSchedulerEventReceivers, -) -> Result { +) -> Result { scheduler .execute_internal_protocol_message( receivers, @@ -1952,7 +1863,7 @@ async fn enable_bidi_runtime_protocol_sources( async fn disable_bidi_runtime_protocol_sources( scheduler: &mut CdpScheduler, receivers: &mut CdpSchedulerEventReceivers, -) -> Result { +) -> Result { scheduler .execute_internal_protocol_message( receivers, @@ -1968,7 +1879,7 @@ async fn disable_bidi_runtime_protocol_sources( async fn enable_bidi_network_protocol_sources( scheduler: &mut CdpScheduler, receivers: &mut CdpSchedulerEventReceivers, -) -> Result { +) -> Result { scheduler .execute_internal_protocol_message( receivers, @@ -2083,10 +1994,6 @@ async fn start_bidi_devtools_command( bidi.file_prompt_handler_for_script_commands(), ); let event_context = bidi_event_context_from_devtools_command(&dispatch.command); - let close_target_id = match &dispatch.command { - DevToolsCommand::CloseTarget(command) => Some(command.target_id.as_str().to_owned()), - _ => None, - }; let script_may_create_targets = matches!( &dispatch.command, DevToolsCommand::EvaluateScript(_) | DevToolsCommand::CallFunction(_) @@ -2110,11 +2017,6 @@ async fn start_bidi_devtools_command( .complete_ready_protocol_residences_after_command() .await, ); - let close_target_event = if let Some(target_id) = close_target_id.as_deref() { - bidi_target_lifecycle_event_for_target(scheduler, &dispatch.session_id, target_id).await - } else { - None - }; if let Some(error) = validate_bidi_top_level_context_command(scheduler, &dispatch.session_id, &dispatch.command) .await @@ -2127,20 +2029,16 @@ async fn start_bidi_devtools_command( event_context, }); } - let previous_target_discovery = ((observe_context_created || observe_browsing_context_load) - && script_may_create_targets) + // Target discovery owns contextCreated visibility only. DCL/load consume + // the independent automation sidecars authorized by exact Document + // lifecycle facts; a lifecycle subscription must not make Target output + // the occurrence authority again. + let previous_target_discovery = (observe_context_created && script_may_create_targets) .then(|| scheduler.replace_target_discovery_enabled(true)); - let create_target_browser_context_id = match &dispatch.command { - DevToolsCommand::CreateTarget(command) => command.browser_context_id.clone(), - _ => None, - }; let completion = BidiDevToolsCommandCompletion { id: dispatch.id, - session_id: dispatch.session_id.clone(), event_sources, event_context, - close_target_event, - create_target_browser_context_id, observe_browsing_context_load, script_may_create_targets, previous_target_discovery, @@ -2201,11 +2099,8 @@ async fn complete_bidi_devtools_command_execution( ) -> BidiDevToolsCommandOutput { let BidiDevToolsCommandCompletion { id, - session_id, mut event_sources, event_context, - close_target_event, - create_target_browser_context_id, observe_browsing_context_load, script_may_create_targets, previous_target_discovery, @@ -2225,42 +2120,11 @@ async fn complete_bidi_devtools_command_execution( ); } } - let created_target_id = match &execution.result { - Ok(DevToolsCommandResult::CreateTarget(result)) => { - Some(result.target_id.as_str().to_owned()) - } - _ => None, - }; - let close_succeeded = matches!(&execution.result, Ok(DevToolsCommandResult::CloseTarget(_))); - if let Some(target_id) = created_target_id.as_deref() { - let mut event = - match bidi_target_lifecycle_event_for_target(scheduler, &session_id, target_id).await { - Some(event) => event, - None => TargetLifecycleEvent { - target_id: DevToolsTargetId::from(target_id), - browser_context_id: create_target_browser_context_id.clone(), - kind: DevToolsTargetKind::Page, - url: "about:blank".to_owned(), - target_info: None, - }, - }; - if let Some(browser_context_id) = create_target_browser_context_id { - event.browser_context_id = Some(browser_context_id); - event.target_info = None; - } - event_sources.push_automation_event(AutomationEvent::TargetCreated(event)); - } - if close_succeeded && let Some(event) = close_target_event { - event_sources.push_automation_event(AutomationEvent::TargetDestroyed(event)); - } - if observe_browsing_context_load && script_may_create_targets { - event_sources.push_initial_load_events_for_script_created_targets(); - } let response = match execution.result { Ok(result) => bidi_response_from_devtools_result(id, result), Err(error) => bidi_response_from_devtools_error(id, error), }; - let mut post_response_event_sources = + let post_response_event_sources = match complete_bidi_post_response_protocol_residences(scheduler, receivers).await { Ok(sources) => sources, Err(failure) => { @@ -2275,9 +2139,6 @@ async fn complete_bidi_devtools_command_execution( ); } }; - if observe_browsing_context_load && script_may_create_targets { - post_response_event_sources.push_initial_load_events_for_script_created_targets(); - } if let Some(previous_target_discovery) = previous_target_discovery { scheduler.replace_target_discovery_enabled(previous_target_discovery); } @@ -3097,15 +2958,6 @@ fn frame_tree_contains_target(frame_tree: &serde_json::Value, target_id: &str) - .any(|child| frame_tree_contains_target(child, target_id)) } -async fn bidi_target_lifecycle_event_for_target( - scheduler: &mut CdpScheduler, - session_id: &str, - target_id: &str, -) -> Option { - let result = bidi_target_info_for_target(scheduler, session_id, target_id).await?; - target_lifecycle_event_from_target_info(result) -} - async fn bidi_target_info_for_target( scheduler: &mut CdpScheduler, session_id: &str, @@ -3129,19 +2981,6 @@ async fn bidi_target_info_for_target( Some(result.target_info) } -fn target_lifecycle_event_from_target_info( - info: DevToolsTargetInfo, -) -> Option { - let target_id = info.target_id.clone()?; - Some(TargetLifecycleEvent { - target_id, - browser_context_id: info.browser_context_id.clone(), - kind: info.kind, - url: info.url.clone(), - target_info: Some(info), - }) -} - fn bidi_event_context_from_devtools_command(command: &DevToolsCommand) -> Option { match command { DevToolsCommand::Navigate(command) => command.context.target_id.as_ref(), @@ -3157,7 +2996,7 @@ fn bidi_event_context_from_devtools_command(command: &DevToolsCommand) -> Option async fn complete_bidi_post_response_protocol_residences( scheduler: &mut CdpScheduler, receivers: &mut CdpSchedulerEventReceivers, -) -> Result { +) -> Result { let mut event_sources = BidiDevToolsEventSources::from_protocol_output( scheduler .complete_ready_protocol_residences_after_command() @@ -3170,7 +3009,7 @@ async fn complete_bidi_post_response_protocol_residences( async fn drain_bidi_background_navigation_before_command( scheduler: &mut CdpScheduler, receivers: &mut CdpSchedulerEventReceivers, -) -> Result { +) -> 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 { @@ -3182,7 +3021,7 @@ async fn drain_bidi_background_navigation_before_command( { Ok(output) => event_sources.extend_protocol_output(output), Err(failure) => { - return Err(BidiRendererOutputTransportFailure::from_renderer( + return Err(BidiSchedulerProgressFailure::from_scheduler( event_sources, failure, )); @@ -3196,7 +3035,7 @@ async fn drain_bidi_background_navigation_before_command( async fn drain_ready_bidi_background_navigation( scheduler: &mut CdpScheduler, receivers: &mut CdpSchedulerEventReceivers, -) -> Result { +) -> Result { let mut event_sources = BidiDevToolsEventSources::default(); event_sources.extend_protocol_output( scheduler @@ -3209,7 +3048,7 @@ async fn drain_ready_bidi_background_navigation( { Ok(output) => event_sources.extend_protocol_output(output), Err(failure) => { - return Err(BidiRendererOutputTransportFailure::from_renderer( + return Err(BidiSchedulerProgressFailure::from_scheduler( event_sources, failure, )); @@ -3234,11 +3073,51 @@ impl SharedBidiSessionRegistry { #[cfg(test)] mod tests { - use moli_protocol::devtools_runtime::RuntimeConsoleEvent; + use moli_core::browser_host::BrowserNavigateCommandResult; + use moli_protocol::devtools_runtime::{ + DevToolsBrowserContextId, DevToolsTargetKind, RuntimeConsoleEvent, TargetLifecycleEvent, + }; use serde_json::json; use super::*; + #[test] + fn pending_target_close_response_requires_exact_destroyed_target() { + let mut pending = vec![ + BidiPendingTargetCloseResponse { + target_id: "TID-a".to_owned(), + response: json!({"type": "success", "id": 7_u64, "result": {}}), + }, + BidiPendingTargetCloseResponse { + target_id: "TID-b".to_owned(), + response: json!({"type": "success", "id": 8_u64, "result": {}}), + }, + ]; + let output = ProtocolOutputSequence::from_background_event( + BackgroundProtocolEvent::immediate_automation_event( + json!({"method": "Moli.automationOnly", "params": {}}), + AutomationEvent::TargetDestroyed(TargetLifecycleEvent { + target_id: DevToolsTargetId::from("TID-b"), + browser_context_id: Some(DevToolsBrowserContextId::from("BID-b")), + kind: DevToolsTargetKind::Page, + url: "https://example.test/b".to_owned(), + target_info: None, + }), + ), + ); + let sources = BidiDevToolsEventSources::from_protocol_output(output).into_sources(); + + let released = take_pending_target_close_responses_from_sources(&mut pending, &sources); + + assert_eq!( + released, + vec![json!({"type": "success", "id": 8_u64, "result": {}})] + ); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].target_id, "TID-a"); + assert_eq!(pending[0].response["id"], json!(7_u64)); + } + #[test] fn protocol_output_hook_prefers_automation_sidecar_over_protocol_message_parse() { let mut bidi = BidiConnectionState::new(); @@ -3297,7 +3176,7 @@ mod tests { } #[test] - fn pending_navigation_response_consumes_typed_command_response() { + fn pending_navigation_response_consumes_exact_browser_outcome() { let mut pending = Some(BidiPendingNavigationResponse { id: 5, url: "https://example.test/auth".to_owned(), @@ -3313,11 +3192,15 @@ mod tests { })), BidiDevToolsEventSource::CommandResponse { command_id: Some(42), - response: BackgroundCommandResponsePayload::Success { - result: json!({ - "navigation": "navigation-from-typed-command-response", - }), - }, + browser_navigate_outcome: Some(BrowserNavigateCommandOutcome::Completed( + BrowserNavigateCommandResult::new( + "https://example.test/auth", + None, + Some("LOADER-typed-outcome".to_owned()), + None, + None, + ), + )), }, ]; @@ -3330,7 +3213,7 @@ mod tests { assert_eq!(response["goog:channel"], json!("chan")); assert_eq!( response["result"]["navigation"], - json!("navigation-from-typed-command-response") + json!("navigation-LOADER-typed-outcome") ); assert_eq!( response["result"]["url"], @@ -3339,7 +3222,7 @@ mod tests { } #[test] - fn pending_navigation_response_falls_back_to_raw_protocol_error() { + fn pending_navigation_response_ignores_raw_protocol_error_without_typed_outcome() { let mut pending = Some(BidiPendingNavigationResponse { id: 7, url: "https://example.test/error".to_owned(), @@ -3354,17 +3237,9 @@ mod tests { } }))]; - let response = take_pending_navigation_response_from_sources(&mut pending, &sources) - .expect("raw protocol error should complete pending navigation as a fallback"); + let response = take_pending_navigation_response_from_sources(&mut pending, &sources); - assert!(pending.is_none()); - assert_eq!(response["type"], json!("error")); - assert_eq!(response["id"], json!(7_u64)); - assert_eq!(response["goog:channel"], json!("chan")); - assert_eq!(response["error"], json!("unsupported operation")); - assert_eq!( - response["message"], - json!("navigation failed before typed response") - ); + assert!(response.is_none()); + assert!(pending.is_some()); } } diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic.rs b/moli-protocol-server/src/protocol_server/webdriver_classic.rs index ba7ed13acc..e6217726c4 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic.rs @@ -47,11 +47,11 @@ use moli_protocol_webdriver_classic::{ get_element_computed_label_command, get_element_computed_role_command, get_element_css_value_command, get_element_displayed_command, get_element_enabled_command, get_element_property_reference_command, get_element_rect_reference_command, - get_element_rendered_text_command, get_element_text_reference_command, history_traversal_entry, - layout_metrics_command, matched_capabilities_from_new_session_params, navigate_command, - navigation_history_command, new_session_response, page_load_strategy_from_capabilities, - page_source_command, parse_timeouts, print_page_command, refresh_command, - release_remote_object_command, resolve_element_reference_command_with_execution_context, + get_element_rendered_text_command, get_element_text_reference_command, layout_metrics_command, + matched_capabilities_from_new_session_params, navigate_command, new_session_response, + page_load_strategy_from_capabilities, page_source_command, parse_timeouts, print_page_command, + refresh_command, release_remote_object_command, + resolve_element_reference_command_with_execution_context, resolve_shadow_root_reference_command_with_execution_context, screenshot_command, shadow_root_attached_command, status_response as classic_status_response, timeouts_value, title_command, traverse_history_command, unhandled_prompt_behavior_from_capabilities, @@ -61,11 +61,11 @@ use serde_json::{Map, Value, json}; use tokio::time::sleep; use tracing::warn; -use super::AppState; use super::webdriver_files::{ downloadable_file_bytes, downloadable_file_zip_base64, selected_files_from_paths, unique_download_directory, uploaded_file_from_base64_zip, }; +use super::{AppState, flush_storage_partition_profile}; mod alerts; mod cookies; @@ -1654,11 +1654,8 @@ pub(super) async fn webdriver_classic_new_session( } else { None }; - let initial_cookies = state.cookie_profile.snapshot(); - let initial_cookie_snapshot = initial_cookies.clone(); - let initial_storage_partition = state.initial_storage_partition(initial_cookies); + let initial_storage_partition = state.initial_storage_partition(); let runtime = ClassicSessionRuntimeHandle::spawn( - initial_cookie_snapshot, initial_storage_partition, moli_core::runtime::NavigationRuntimeConfig::new( state.fetch_config.clone(), @@ -1762,10 +1759,8 @@ pub(super) async fn webdriver_classic_delete_session( .lock() .release_session(&session_id); if let Some(runtime) = runtime { - let cookie_commit = runtime.shutdown().await; - if let Err(error) = state.cookie_profile.commit_and_save(cookie_commit) { - warn!(?error, "failed to persist Classic cookie profile"); - } + runtime.shutdown().await; + flush_storage_partition_profile(state.storage_partition, "webdriver-classic").await; return classic_webdriver_json_response(StatusCode::OK, delete_session_response()); } classic_error_into_response(ClassicError::new( @@ -2590,34 +2585,19 @@ async fn webdriver_classic_traverse_history( Err(error) => return classic_error_into_response(error), }; let context = classic_top_level_context(&binding); - let history = match binding + let command = traverse_history_command( + &context, + i64::from(delta), + binding.page_load_strategy.navigation_wait(), + ); + let traversal = binding .runtime - .execute(navigation_history_command(&context)) - .await - { - Ok(DevToolsCommandResult::GetNavigationHistory(history)) => history, - Ok(_) => { - return classic_error_into_response(ClassicError::new( - ClassicErrorCode::UnknownError, - "navigation history returned an unexpected result", - )); - } - Err(error) => return classic_error_into_response(classic_error_from_devtools_error(error)), - }; - let Some((entry_id, url)) = history_traversal_entry(&history, delta) else { - return classic_success_into_response(Value::Null); - }; - match webdriver_classic_execute_page_load_command( - &binding, - traverse_history_command( - &context, - entry_id, - url, - binding.page_load_strategy.navigation_wait(), - ), - ) - .await - { + .execute_inner( + command, + binding.timeouts.page_load.map(Duration::from_millis), + ) + .await; + match traversal { Ok(DevToolsCommandResult::TraverseHistory(result)) => { if !result.same_document { classic_reset_to_top_level_browsing_context(&state, &session_id); @@ -2629,7 +2609,13 @@ async fn webdriver_classic_traverse_history( ClassicErrorCode::UnknownError, "history traversal returned an unexpected result", )), - Err(error) => classic_error_into_response(error), + Err(error) if error.kind == DevToolsErrorKind::NoSuchHistoryEntry => { + classic_success_into_response(Value::Null) + } + Err(error) if error.kind == DevToolsErrorKind::Timeout => classic_error_into_response( + ClassicError::new(ClassicErrorCode::Timeout, "page load timed out"), + ), + Err(error) => classic_error_into_response(classic_error_from_devtools_error(error)), } } 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..85930e2bec 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs @@ -1,13 +1,6 @@ -use std::{ - collections::BTreeMap, - fs, - path::PathBuf, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{collections::BTreeMap, fs, path::PathBuf, sync::Arc, time::Duration}; use axum::extract::ws::WebSocket; -use moli_cookie_jar::StoredCookie; use moli_core::{page::RendererDocumentLifecycleMilestone, runtime::NavigationRuntimeConfig}; use moli_protocol::{ CdpInitialStoragePartition, DevToolsPageResidenceIdentity, @@ -29,15 +22,11 @@ use moli_protocol_webdriver_classic::{ use parking_lot::Mutex; use tokio::sync::{mpsc, oneshot}; -use crate::cdp_scheduler::{ - CdpScheduler, CdpSchedulerEventReceivers, DevToolsCommandExecution, - DevToolsPageCommandExecution, ProtocolAdapterScheduler, -}; - -use super::super::webdriver_bidi::{ - BidiSocketActor, BidiSocketActorInput, SharedBidiSessionRegistry, +use super::super::{ + devtools_host_service::{BidiFrontendSession, DevToolsHostServiceHandle}, + protocol_local_executor::spawn_protocol_local_task, + webdriver_bidi::SharedBidiSessionRegistry, }; -use super::super::{CookieProfileCommit, protocol_local_executor::spawn_protocol_local_task}; const CLASSIC_SCRIPT_TERMINATION_TIMEOUT: Duration = Duration::from_secs(5); @@ -45,7 +34,6 @@ const CLASSIC_SCRIPT_TERMINATION_TIMEOUT: Duration = Duration::from_secs(5); pub(in crate::protocol_server) struct SharedClassicSessionRegistry { inner: Arc>, } - impl SharedClassicSessionRegistry { pub(super) fn lock(&self) -> parking_lot::MutexGuard<'_, ClassicSessionManager> { self.inner.lock() @@ -477,18 +465,14 @@ pub(in crate::protocol_server) struct ClassicSessionRuntimeHandle { impl ClassicSessionRuntimeHandle { pub(super) fn spawn( - initial_cookie_snapshot: Vec, initial_storage_partition: CdpInitialStoragePartition, navigation_runtime_config: NavigationRuntimeConfig, ) -> Self { + let (host, host_finished) = + DevToolsHostServiceHandle::spawn(initial_storage_partition, navigation_runtime_config); let (tx, rx) = mpsc::unbounded_channel(); let _runtime_finished_rx = spawn_protocol_local_task("classic-session", move || { - classic_session_runtime_loop( - rx, - initial_cookie_snapshot, - initial_storage_partition, - navigation_runtime_config, - ) + classic_session_frontend_loop(rx, host, host_finished) }); Self { tx } } @@ -807,18 +791,16 @@ impl ClassicSessionRuntimeHandle { })? } - pub(super) async fn shutdown(self) -> CookieProfileCommit { + pub(super) async fn shutdown(self) { let (response_tx, response_rx) = oneshot::channel(); if self .tx .send(ClassicSessionRuntimeRequest::Shutdown { response_tx }) .is_err() { - return CookieProfileCommit::unchanged(); + return; } - response_rx - .await - .unwrap_or_else(|_| CookieProfileCommit::unchanged()) + let _ = response_rx.await; } pub(in crate::protocol_server) async fn attach_bidi_socket( @@ -917,41 +899,19 @@ enum ClassicSessionRuntimeRequest { response_tx: oneshot::Sender>, }, Shutdown { - response_tx: oneshot::Sender, + response_tx: oneshot::Sender<()>, }, } -struct ClassicAttachedBidiSocket { - actor: BidiSocketActor, - session_registry: SharedBidiSessionRegistry, -} - -impl ClassicAttachedBidiSocket { - async fn release_session( - &mut self, - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, - ) { - self.actor.release_event_sources(scheduler, receivers).await; - self.actor - .release_session(&mut self.session_registry.lock()); - } -} - -enum ClassicSessionRuntimeRequestOutcome { +enum ClassicSessionFrontendRequestOutcome { Continue, - AttachedBidi(Box), - DetachBidi, - Shutdown(CookieProfileCommit), + Shutdown(oneshot::Sender<()>), } -async fn handle_classic_session_runtime_request( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, - initial_cookie_snapshot: &[StoredCookie], +async fn handle_classic_session_frontend_request( + host: &DevToolsHostServiceHandle, request: ClassicSessionRuntimeRequest, - mut attached_bidi: Option<&mut ClassicAttachedBidiSocket>, -) -> ClassicSessionRuntimeRequestOutcome { +) -> ClassicSessionFrontendRequestOutcome { match request { ClassicSessionRuntimeRequest::Execute { command, @@ -961,81 +921,46 @@ async fn handle_classic_session_runtime_request( expected_page, response_tx, } => { - if expected_page.as_ref().is_some_and(|expected| { - scheduler.page_residence_identity_for_devtools_context(command.context()) - != Some(expected.clone()) - }) { - let _ = response_tx.send(ClassicSessionRuntimeCommandExecution::error( - DevToolsError::new( - DevToolsErrorKind::NoSuchNode, - "DOM reference belongs to a replaced Page", - ), - )); - return ClassicSessionRuntimeRequestOutcome::Continue; - } let termination_context = command.context().clone(); - let mut execution = execute_classic_devtools_command_with_pending_navigation_retry( - scheduler, - receivers, - *command, - timeout, - pending_navigation_timeout, - expected_page.as_ref(), - ) - .await; + let execution = host + .execute_with_page_residence( + *command, + timeout, + pending_navigation_timeout, + expected_page, + ) + .await; if terminate_execution_on_timeout && matches!( - execution.execution.result, - Err(ref error) if error.kind == DevToolsErrorKind::Timeout + &execution.result, + Err(error) if error.kind == DevToolsErrorKind::Timeout ) { // Finish the IO-side termination before the HTTP handler // releases argument handles or admits the next Classic // command on this session. - let termination = execute_classic_devtools_command_once( - scheduler, - receivers, - DevToolsCommand::TerminateExecution(DevToolsTerminateExecutionCommand { - context: termination_context, - }), - Some(CLASSIC_SCRIPT_TERMINATION_TIMEOUT), - execution.page_residence.as_ref(), - ) - .await; - if let Err(error) = &termination.execution.result { + let termination = host + .execute_with_page_residence( + DevToolsCommand::TerminateExecution(DevToolsTerminateExecutionCommand { + context: termination_context, + }), + Some(CLASSIC_SCRIPT_TERMINATION_TIMEOUT), + None, + execution.page_residence.clone(), + ) + .await; + if let Err(error) = &termination.result { tracing::warn!( ?error, "failed to terminate timed-out WebDriver Classic script execution" ); } - execution - .execution - .protocol_output - .append(termination.execution.protocol_output); } - let result = execution.execution.result; - let keep_attached = if let Some(attached) = attached_bidi.as_mut() { - attached - .actor - .send_or_route_protocol_output( - scheduler, - receivers, - execution.execution.protocol_output, - None, - ) - .await - } else { - true - }; let _ = response_tx.send(ClassicSessionRuntimeCommandExecution { - result, + result: execution.result, page_residence: execution.page_residence, }); - if keep_attached { - ClassicSessionRuntimeRequestOutcome::Continue - } else { - ClassicSessionRuntimeRequestOutcome::DetachBidi - } + ClassicSessionFrontendRequestOutcome::Continue } ClassicSessionRuntimeRequest::WaitForDocumentLifecycle { context, @@ -1043,31 +968,11 @@ async fn handle_classic_session_runtime_request( timeout, response_tx, } => { - let execution = scheduler - .wait_for_devtools_context_document_lifecycle( - receivers, &context, milestone, timeout, - ) + let result = host + .wait_for_document_lifecycle(context, milestone, timeout) .await; - let result = execution.result.map(|_| ()); - let keep_attached = if let Some(attached) = attached_bidi.as_mut() { - attached - .actor - .send_or_route_protocol_output( - scheduler, - receivers, - execution.protocol_output, - None, - ) - .await - } else { - true - }; let _ = response_tx.send(result); - if keep_attached { - ClassicSessionRuntimeRequestOutcome::Continue - } else { - ClassicSessionRuntimeRequestOutcome::DetachBidi - } + ClassicSessionFrontendRequestOutcome::Continue } ClassicSessionRuntimeRequest::AttachBidiSocket { socket, @@ -1077,26 +982,20 @@ async fn handle_classic_session_runtime_request( session_registry, response_tx, } => { - if attached_bidi.is_some() { - let _ = response_tx.send(false); - return ClassicSessionRuntimeRequestOutcome::Continue; - } - let mut actor = BidiSocketActor::new(*socket, web_socket_url); - let attached = { - let mut registry = session_registry.lock(); - actor.attach_existing_session(session_id, &mut registry) - }; - if !attached { - let _ = response_tx.send(false); - return ClassicSessionRuntimeRequestOutcome::Continue; - } - actor.install_runtime_response_ready_sender(scheduler); - actor.set_file_prompt_handler_for_script_commands(file_prompt_handler.as_deref()); - let _ = response_tx.send(true); - ClassicSessionRuntimeRequestOutcome::AttachedBidi(Box::new(ClassicAttachedBidiSocket { - actor, - session_registry, - })) + let attached = host + .attach_bidi( + *socket, + web_socket_url, + BidiFrontendSession::Existing { + session_id, + file_prompt_handler, + }, + session_registry, + ) + .await + .is_some(); + let _ = response_tx.send(attached); + ClassicSessionFrontendRequestOutcome::Continue } ClassicSessionRuntimeRequest::FrameIdForIndex { session_id, @@ -1106,8 +1005,7 @@ async fn handle_classic_session_runtime_request( response_tx, } => { let result = resolve_classic_frame_id_for_index( - scheduler, - receivers, + host, &session_id, &target_id, current_frame_id.as_deref(), @@ -1115,7 +1013,7 @@ async fn handle_classic_session_runtime_request( ) .await; let _ = response_tx.send(result); - ClassicSessionRuntimeRequestOutcome::Continue + ClassicSessionFrontendRequestOutcome::Continue } ClassicSessionRuntimeRequest::FrameIdForElement { session_id, @@ -1126,8 +1024,7 @@ async fn handle_classic_session_runtime_request( response_tx, } => { let result = resolve_classic_frame_id_for_element( - scheduler, - receivers, + host, &session_id, &target_id, current_frame_id.as_deref(), @@ -1136,7 +1033,7 @@ async fn handle_classic_session_runtime_request( ) .await; let _ = response_tx.send(result); - ClassicSessionRuntimeRequestOutcome::Continue + ClassicSessionFrontendRequestOutcome::Continue } ClassicSessionRuntimeRequest::ParentFrameId { session_id, @@ -1144,16 +1041,11 @@ async fn handle_classic_session_runtime_request( current_frame_id, response_tx, } => { - let result = resolve_classic_parent_frame_id( - scheduler, - receivers, - &session_id, - &target_id, - ¤t_frame_id, - ) - .await; + let result = + resolve_classic_parent_frame_id(host, &session_id, &target_id, ¤t_frame_id) + .await; let _ = response_tx.send(result); - ClassicSessionRuntimeRequestOutcome::Continue + ClassicSessionFrontendRequestOutcome::Continue } ClassicSessionRuntimeRequest::BrowsingContextExists { session_id, @@ -1162,426 +1054,57 @@ async fn handle_classic_session_runtime_request( response_tx, } => { let result = resolve_classic_browsing_context_exists( - scheduler, - receivers, + host, &session_id, &target_id, frame_id.as_deref(), ) .await; let _ = response_tx.send(result); - ClassicSessionRuntimeRequestOutcome::Continue + ClassicSessionFrontendRequestOutcome::Continue } ClassicSessionRuntimeRequest::SetJavaScriptDialogHandlerEnabled { enabled, response_tx, } => { - let result = scheduler - .set_automation_javascript_dialog_handler_enabled(enabled) - .then_some(()) - .ok_or_else(|| { - DevToolsError::new( - DevToolsErrorKind::NoSuchTarget, - "Classic session has no browser context", - ) - }); + let result = host.set_javascript_dialog_handler_enabled(enabled).await; let _ = response_tx.send(result); - ClassicSessionRuntimeRequestOutcome::Continue + ClassicSessionFrontendRequestOutcome::Continue } ClassicSessionRuntimeRequest::Shutdown { response_tx } => { - let cookie_commit = CookieProfileCommit::from_optional_profile_backed_snapshot( - initial_cookie_snapshot.to_vec(), - scheduler.snapshot_profile_backed_cookies(), - ); - let _ = response_tx.send(cookie_commit.clone()); - ClassicSessionRuntimeRequestOutcome::Shutdown(cookie_commit) - } - } -} - -async fn execute_classic_devtools_command_with_pending_navigation_retry( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, - command: DevToolsCommand, - timeout: Option, - pending_navigation_timeout: Option, - expected_page: Option<&DevToolsPageResidenceIdentity>, -) -> ClassicDevToolsCommandExecution { - let mut execution = execute_classic_devtools_command_once( - scheduler, - receivers, - command.clone(), - timeout, - expected_page, - ) - .await; - - let Some(pending_navigation_timeout) = pending_navigation_timeout else { - return execution; - }; - let started = Instant::now(); - loop { - if !classic_runtime_result_is_navigation_changing_document(&execution.execution.result) { - return execution; - } - let Some(remaining) = pending_navigation_timeout.checked_sub(started.elapsed()) else { - execution.execution.result = Err(classic_pending_navigation_timeout_error()); - return execution; - }; - let mut progress = scheduler - .complete_ready_protocol_residences_for_external_load_wait() - .await; - if progress.is_empty() { - let input = match tokio::time::timeout(remaining, receivers.recv_interleaved_input()) - .await - { - Ok(Some(input)) => input, - Ok(None) => { - execution.execution.result = Err(DevToolsError::new( - DevToolsErrorKind::NoSuchSession, - "Classic session runtime stopped while waiting for navigation", - )); - return execution; - } - Err(_) => { - execution.execution.result = Err(classic_pending_navigation_timeout_error()); - return execution; - } - }; - // Once selected, finish the move-owned input outside the timeout - // race. In particular, an admitted renderer publication must not - // disappear merely because the navigation deadline expires while - // its protocol projection is awaiting an owner action. - progress = match scheduler - .complete_interleaved_scheduler_input(receivers, input) - .await - { - Ok(progress) => progress, - Err(failure) => { - let (progress, error) = failure.into_parts(); - execution.execution.protocol_output.append(progress); - execution.execution.result = Err(error); - return execution; - } - }; + ClassicSessionFrontendRequestOutcome::Shutdown(response_tx) } - execution.execution.protocol_output.append(progress); - - let retry_timeout = match timeout { - Some(timeout) => { - let Some(remaining) = pending_navigation_timeout.checked_sub(started.elapsed()) - else { - execution.execution.result = Err(classic_pending_navigation_timeout_error()); - return execution; - }; - Some(timeout.min(remaining)) - } - None => None, - }; - let retry = execute_classic_devtools_command_once( - scheduler, - receivers, - command.clone(), - retry_timeout, - expected_page, - ) - .await; - execution - .execution - .protocol_output - .append(retry.execution.protocol_output); - execution.execution.result = retry.execution.result; - execution.page_residence = retry.page_residence; - } -} - -async fn execute_classic_devtools_command_once( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, - command: DevToolsCommand, - timeout: Option, - expected_page: Option<&DevToolsPageResidenceIdentity>, -) -> ClassicDevToolsCommandExecution { - let DevToolsPageCommandExecution { - execution, - page_residence, - } = scheduler - .execute_devtools_command_with_external_load_wait_and_page_residence( - receivers, - command, - timeout, - expected_page, - ) - .await; - ClassicDevToolsCommandExecution { - execution, - page_residence, } } -struct ClassicDevToolsCommandExecution { - execution: DevToolsCommandExecution, - page_residence: Option, -} - -fn classic_runtime_result_is_navigation_changing_document( - result: &Result, -) -> bool { - matches!( - result, - Err(error) if error.kind == DevToolsErrorKind::NavigationChangingDocument - ) -} - -fn classic_pending_navigation_timeout_error() -> DevToolsError { - DevToolsError::new(DevToolsErrorKind::Timeout, "navigation wait timed out") -} - -async fn classic_session_runtime_loop( +async fn classic_session_frontend_loop( mut rx: mpsc::UnboundedReceiver, - initial_cookie_snapshot: Vec, - initial_storage_partition: CdpInitialStoragePartition, - navigation_runtime_config: NavigationRuntimeConfig, -) -> CookieProfileCommit { - let (mut scheduler, mut receivers) = CdpScheduler::new_with_initial_state_runtime_config( - initial_storage_partition, - navigation_runtime_config, - ); - let mut attached_bidi: Option = None; - let mut adapter_scheduler = ProtocolAdapterScheduler::<()>::default(); - loop { - if receivers.renderer_publication_rx.is_closed() { - break; - } - if attached_bidi.is_some() { - let mut detach_bidi = false; - let mut shutdown_cookies = None; - { - let attached = attached_bidi.as_mut().expect("attached BiDi socket"); - let page_javascript_blocked = scheduler.has_pending_javascript_dialog(); - adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked); - tokio::select! { - biased; - completion = receivers.background_navigation_completion_rx.recv() => { - let Some(completion) = completion else { - break; - }; - if !attached.actor.handle_background_navigation_completion( - &mut scheduler, - &mut receivers, - completion, - ).await - { - detach_bidi = true; - } - } - event = receivers.background_event_rx.recv() => { - let Some(event) = event else { - break; - }; - let output = scheduler.route_background_event_around_inflight_navigation(event); - if !attached.actor.send_or_route_protocol_output( - &mut scheduler, - &mut receivers, - output, - None, - ).await - { - detach_bidi = true; - } - } - publication = receivers.renderer_publication_rx.recv(), if !page_javascript_blocked => { - let Some(publication) = publication else { - break; - }; - if !attached.actor.handle_renderer_publication( - &mut adapter_scheduler, - &mut scheduler, - &mut receivers, - publication, - ).await - { - detach_bidi = true; - } - } - actor_input = attached.actor.recv_attached_input( - &mut adapter_scheduler, - page_javascript_blocked, - ) => { - match actor_input { - BidiSocketActorInput::Socket(Some(message)) => { - if !attached.actor.handle_socket_message( - &mut scheduler, - &mut receivers, - &attached.session_registry, - message, - ).await - { - detach_bidi = true; - } - } - BidiSocketActorInput::Socket(None) => { - detach_bidi = true; - } - BidiSocketActorInput::AdapterScheduler(input) => { - if !attached.actor.handle_adapter_scheduler_input( - &mut adapter_scheduler, - &mut scheduler, - &mut receivers, - input, - ).await - { - detach_bidi = true; - } - } - BidiSocketActorInput::RuntimeResponseReady(Some(response)) => { - if !attached - .actor - .handle_runtime_response_ready( - &mut scheduler, - &mut receivers, - *response, - ) - .await - { - detach_bidi = true; - } - } - BidiSocketActorInput::RuntimeResponseReady(None) => { - detach_bidi = true; - } - } - } - request = rx.recv() => { - let Some(request) = request else { - break; - }; - match handle_classic_session_runtime_request( - &mut scheduler, - &mut receivers, - &initial_cookie_snapshot, - request, - Some(attached), - ) - .await - { - ClassicSessionRuntimeRequestOutcome::Continue => {} - ClassicSessionRuntimeRequestOutcome::AttachedBidi(mut duplicate) => { - duplicate - .release_session(&mut scheduler, &mut receivers) - .await; - } - ClassicSessionRuntimeRequestOutcome::DetachBidi => { - detach_bidi = true; - } - ClassicSessionRuntimeRequestOutcome::Shutdown(cookies) => { - attached - .release_session(&mut scheduler, &mut receivers) - .await; - shutdown_cookies = Some(cookies); - } - } - } - } - } - if let Some(cookies) = shutdown_cookies { - return cookies; - } - if detach_bidi && let Some(mut attached) = attached_bidi.take() { - attached - .release_session(&mut scheduler, &mut receivers) - .await; - } - } else { - let page_javascript_blocked = scheduler.has_pending_javascript_dialog(); - adapter_scheduler.schedule_turn_if_needed(&scheduler, page_javascript_blocked); - tokio::select! { - biased; - completion = receivers.background_navigation_completion_rx.recv() => { - let Some(completion) = completion else { - break; - }; - if scheduler - .drain_background_navigation_completion_with_progress_barrier( - completion, - &mut receivers, - ) - .await - .is_err() - { - // No frontend is attached in this branch, but a - // renderer-output terminal still retires the shared - // protocol owner. Continuing the loop would let later - // Classic commands observe a half-delivered runtime. - break; - } - } - event = receivers.background_event_rx.recv() => { - let Some(event) = event else { - break; - }; - let _ = scheduler.route_background_event_around_inflight_navigation(event); - } - publication = receivers.renderer_publication_rx.recv(), if !page_javascript_blocked => { - let Some(publication) = publication else { - break; - }; - let _ = adapter_scheduler - .ingest_renderer_publication(&mut scheduler, publication) - .await; - } - input = adapter_scheduler.recv_input(), if !page_javascript_blocked => { - let _ = adapter_scheduler - .advance_input(&mut scheduler, input, || ()) - .await; - } - request = rx.recv() => { - let Some(request) = request else { - break; - }; - match handle_classic_session_runtime_request( - &mut scheduler, - &mut receivers, - &initial_cookie_snapshot, - request, - None, - ) - .await - { - ClassicSessionRuntimeRequestOutcome::Continue => {} - ClassicSessionRuntimeRequestOutcome::AttachedBidi(attached) => { - attached_bidi = Some(*attached); - } - ClassicSessionRuntimeRequestOutcome::DetachBidi => {} - ClassicSessionRuntimeRequestOutcome::Shutdown(cookies) => return cookies, - } - if attached_bidi.is_none() { - classic_session_ingest_ready_renderer_publications( - &mut adapter_scheduler, - &mut scheduler, - &mut receivers, - ) - .await; - } - } + host: DevToolsHostServiceHandle, + host_finished: oneshot::Receiver<()>, +) { + while let Some(request) = rx.recv().await { + match handle_classic_session_frontend_request(&host, request).await { + ClassicSessionFrontendRequestOutcome::Continue => {} + ClassicSessionFrontendRequestOutcome::Shutdown(response_tx) => { + host.shutdown().await; + let _ = host_finished.await; + let _ = response_tx.send(()); + return; } } } - CookieProfileCommit::from_optional_profile_backed_snapshot( - initial_cookie_snapshot, - scheduler.snapshot_profile_backed_cookies(), - ) + host.shutdown().await; + let _ = host_finished.await; } async fn resolve_classic_frame_id_for_index( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, + host: &DevToolsHostServiceHandle, session_id: &str, target_id: &str, current_frame_id: Option<&str>, index: usize, ) -> Result { - let frame_tree = classic_frame_tree(scheduler, receivers, session_id, target_id).await?; + let frame_tree = classic_frame_tree(host, session_id, target_id).await?; let siblings = match current_frame_id { Some(frame_id) => { classic_child_frames_for_frame_id(&frame_tree, frame_id).ok_or_else(|| { @@ -1601,17 +1124,14 @@ async fn resolve_classic_frame_id_for_index( } async fn resolve_classic_frame_id_for_element( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, + host: &DevToolsHostServiceHandle, session_id: &str, target_id: &str, current_frame_id: Option<&str>, expected_page: &DevToolsPageResidenceIdentity, element_reference: DevToolsDomNodeReference, ) -> Result { - let frame_tree = - classic_frame_tree_on_page(scheduler, receivers, session_id, target_id, expected_page) - .await?; + let frame_tree = classic_frame_tree_on_page(host, session_id, target_id, expected_page).await?; let candidate_frames = match current_frame_id { Some(frame_id) => { classic_child_frames_for_frame_id(&frame_tree, frame_id).ok_or_else(|| { @@ -1628,8 +1148,7 @@ async fn resolve_classic_frame_id_for_element( continue; }; let owner = classic_frame_owner_reference_on_page( - scheduler, - receivers, + host, session_id, target_id, frame_id, @@ -1647,8 +1166,7 @@ async fn resolve_classic_frame_id_for_element( } async fn classic_frame_owner_reference_on_page( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, + host: &DevToolsHostServiceHandle, session_id: &str, target_id: &str, frame_id: &str, @@ -1660,16 +1178,18 @@ async fn classic_frame_owner_reference_on_page( target_id: Some(DevToolsTargetId::from(target_id)), browser_context_id: None, }; - ensure_classic_command_page_is_current(scheduler, &context, expected_page)?; - match scheduler - .execute_devtools_command_with_external_load_wait( - receivers, + match host + .execute_with_page_residence( DevToolsCommand::GetFrameOwner(DevToolsGetFrameOwnerCommand { context, frame_id: DevToolsFrameId::new(frame_id), }), + None, + None, + Some(expected_page.clone()), ) .await + .result { Ok(DevToolsCommandResult::GetFrameOwner(owner)) => Ok(owner), Ok(_) => Err(DevToolsError::new( @@ -1693,13 +1213,12 @@ fn classic_frame_owner_matches_reference( } async fn resolve_classic_parent_frame_id( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, + host: &DevToolsHostServiceHandle, session_id: &str, target_id: &str, current_frame_id: &str, ) -> Result, DevToolsError> { - let frame_tree = classic_frame_tree(scheduler, receivers, session_id, target_id).await?; + let frame_tree = classic_frame_tree(host, session_id, target_id).await?; if classic_frame_exists(&frame_tree, current_frame_id) { let parent_frame_id = classic_parent_frame_id_for_frame_id(&frame_tree, current_frame_id); Ok(parent_frame_id.filter(|parent_frame_id| parent_frame_id != target_id)) @@ -1712,19 +1231,17 @@ async fn resolve_classic_parent_frame_id( } async fn resolve_classic_browsing_context_exists( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, + host: &DevToolsHostServiceHandle, session_id: &str, target_id: &str, frame_id: Option<&str>, ) -> Result { - let frame_tree = classic_frame_tree(scheduler, receivers, session_id, target_id).await?; + let frame_tree = classic_frame_tree(host, session_id, target_id).await?; Ok(frame_id.is_none_or(|frame_id| classic_frame_exists(&frame_tree, frame_id))) } async fn classic_frame_tree( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, + host: &DevToolsHostServiceHandle, session_id: &str, target_id: &str, ) -> Result { @@ -1734,13 +1251,14 @@ async fn classic_frame_tree( target_id: Some(DevToolsTargetId::from(target_id)), browser_context_id: None, }; - match scheduler - .execute_devtools_command_with_external_load_wait( - receivers, + match host + .execute( DevToolsCommand::GetFrameTree(DevToolsGetFrameTreeCommand { context, max_depth: None, }), + None, + None, ) .await { @@ -1754,8 +1272,7 @@ async fn classic_frame_tree( } async fn classic_frame_tree_on_page( - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, + host: &DevToolsHostServiceHandle, session_id: &str, target_id: &str, expected_page: &DevToolsPageResidenceIdentity, @@ -1766,16 +1283,18 @@ async fn classic_frame_tree_on_page( target_id: Some(DevToolsTargetId::from(target_id)), browser_context_id: None, }; - ensure_classic_command_page_is_current(scheduler, &context, expected_page)?; - match scheduler - .execute_devtools_command_with_external_load_wait( - receivers, + match host + .execute_with_page_residence( DevToolsCommand::GetFrameTree(DevToolsGetFrameTreeCommand { context, max_depth: None, }), + None, + None, + Some(expected_page.clone()), ) .await + .result { Ok(DevToolsCommandResult::GetFrameTree(result)) => Ok(result.frame_tree), Ok(_) => Err(DevToolsError::new( @@ -1786,23 +1305,6 @@ async fn classic_frame_tree_on_page( } } -fn ensure_classic_command_page_is_current( - scheduler: &mut CdpScheduler, - context: &DevToolsCommandContext, - expected_page: &DevToolsPageResidenceIdentity, -) -> Result<(), DevToolsError> { - if scheduler.page_residence_identity_for_devtools_context(context) - == Some(expected_page.clone()) - { - Ok(()) - } else { - Err(DevToolsError::new( - DevToolsErrorKind::NoSuchNode, - "DOM reference belongs to a replaced Page", - )) - } -} - fn classic_child_frames_for_frame_id<'a>( frame_tree: &'a serde_json::Value, frame_id: &str, @@ -1873,17 +1375,6 @@ impl ValueExt for serde_json::Value { } } -async fn classic_session_ingest_ready_renderer_publications( - adapter_scheduler: &mut ProtocolAdapterScheduler<()>, - scheduler: &mut CdpScheduler, - receivers: &mut CdpSchedulerEventReceivers, -) { - while let Ok(publication) = receivers.renderer_publication_rx.try_recv() { - let _ = adapter_scheduler - .ingest_renderer_publication(scheduler, publication) - .await; - } -} #[cfg(test)] mod tests { use super::*; diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic/window.rs b/moli-protocol-server/src/protocol_server/webdriver_classic/window.rs index cb5b5ae77c..da03ba2150 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic/window.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic/window.rs @@ -1,3 +1,9 @@ +use super::super::{AppState, flush_storage_partition_profile}; +use super::{ + classic_error_into_response, classic_json_body, classic_session_binding, + classic_success_into_response, classic_top_level_browsing_context_binding, + classic_top_level_browsing_context_binding_without_prompt_handling, +}; use axum::{ body::Bytes, extract::{Path, State}, @@ -14,14 +20,6 @@ use moli_protocol_webdriver_classic::{ window_handles_from_targets, }; use serde_json::json; -use tracing::warn; - -use super::super::AppState; -use super::{ - classic_error_into_response, classic_json_body, classic_session_binding, - classic_success_into_response, classic_top_level_browsing_context_binding, - classic_top_level_browsing_context_binding_without_prompt_handling, -}; pub(in crate::protocol_server) async fn webdriver_classic_get_window( Path(session_id): Path, @@ -280,10 +278,9 @@ pub(in crate::protocol_server) async fn webdriver_classic_close_window( .lock() .release_session(&session_id); if let Some(runtime) = runtime { - let cookie_commit = runtime.shutdown().await; - if let Err(error) = state.cookie_profile.commit_and_save(cookie_commit) { - warn!(?error, "failed to persist Classic cookie profile"); - } + runtime.shutdown().await; + flush_storage_partition_profile(state.storage_partition.clone(), "webdriver-classic") + .await; } } diff --git a/moli-protocol-webdriver-bidi/src/events.rs b/moli-protocol-webdriver-bidi/src/events.rs index 0944eb0a6f..77da500146 100644 --- a/moli-protocol-webdriver-bidi/src/events.rs +++ b/moli-protocol-webdriver-bidi/src/events.rs @@ -187,10 +187,10 @@ fn runtime_realm_id_from_protocol_context( owner_context: Option<&str>, ) -> Option { owner_scoped_shared_worker_realm_id_from_protocol_context(context, aux_data, owner_context) + .or_else(|| context["uniqueId"].as_str().map(str::to_owned)) .or_else(|| { owner_scoped_service_worker_realm_id_from_protocol_context(aux_data, owner_context) }) - .or_else(|| context["uniqueId"].as_str().map(str::to_owned)) } fn runtime_context_type_from_protocol_context( diff --git a/moli-protocol-webdriver-bidi/src/protocol/events.rs b/moli-protocol-webdriver-bidi/src/protocol/events.rs index ea91e4a082..6e0bb8e0ef 100644 --- a/moli-protocol-webdriver-bidi/src/protocol/events.rs +++ b/moli-protocol-webdriver-bidi/src/protocol/events.rs @@ -497,18 +497,18 @@ impl BidiLogEventState { aux_data, owner_context, ) - .or_else(|| { - owner_scoped_service_worker_realm_id_from_protocol_context( - aux_data, - owner_context, - ) - }) .or_else(|| { context .get("uniqueId") .and_then(Value::as_str) .map(str::to_owned) }) + .or_else(|| { + owner_scoped_service_worker_realm_id_from_protocol_context( + aux_data, + owner_context, + ) + }) .unwrap_or_else(|| execution_context_id.to_string()); let browsing_context = owner_context .map(str::to_owned) diff --git a/moli-protocol-webdriver-bidi/src/tests.rs b/moli-protocol-webdriver-bidi/src/tests.rs index e564aa8f9c..f5790717a7 100644 --- a/moli-protocol-webdriver-bidi/src/tests.rs +++ b/moli-protocol-webdriver-bidi/src/tests.rs @@ -5701,7 +5701,7 @@ fn context_scoped_subscribe_projects_raw_worker_runtime_context_as_shared_worker } #[test] -fn context_scoped_service_worker_protocol_events_use_stable_owner_realm() { +fn context_scoped_service_worker_protocol_events_use_exact_runtime_realm() { let (mut state, mut registry) = bidi_connection_with_session(); record_bidi_context_tree(&mut state, &[("TID-service-worker", "BID-service-worker")]); let subscribe = state.handle_message_with_session_registry( @@ -5758,18 +5758,18 @@ fn context_scoped_service_worker_protocol_events_use_stable_owner_realm() { assert_eq!(events[0]["method"], json!("script.realmCreated")); assert_eq!( events[0]["params"]["realm"], - json!("service-worker-TID-service-worker") + json!("TID-service-worker:-5857654653247543937.8461351526676111284") ); assert_eq!(events[0]["params"]["type"], json!("service-worker")); assert_eq!(events[1]["method"], json!("log.entryAdded")); assert_eq!( events[1]["params"]["source"]["realm"], - json!("service-worker-TID-service-worker") + json!("TID-service-worker:-5857654653247543937.8461351526676111284") ); assert_eq!(events[2]["method"], json!("script.realmDestroyed")); assert_eq!( events[2]["params"]["realm"], - json!("service-worker-TID-service-worker") + json!("TID-service-worker:-5857654653247543937.8461351526676111284") ); } @@ -7030,8 +7030,6 @@ fn serializes_devtools_navigate_result_to_bidi_response() { "LOADER-1", )), url: "https://example.test/".to_owned(), - error_text: None, - is_download: None, }, ), ); diff --git a/moli-protocol-webdriver-classic/src/commands/mod.rs b/moli-protocol-webdriver-classic/src/commands/mod.rs index e59ac9c0ed..00fe9d8721 100644 --- a/moli-protocol-webdriver-classic/src/commands/mod.rs +++ b/moli-protocol-webdriver-classic/src/commands/mod.rs @@ -38,8 +38,8 @@ pub use elements::{ }; pub use errors::classic_error_from_devtools_error; pub use navigation::{ - current_url_command, history_traversal_entry, navigate_command, navigation_history_command, - page_source_command, refresh_command, title_command, traverse_history_command, + current_url_command, navigate_command, page_source_command, refresh_command, title_command, + traverse_history_command, }; pub use script::{execute_async_command, execute_sync_command}; pub use window::{ diff --git a/moli-protocol-webdriver-classic/src/commands/navigation.rs b/moli-protocol-webdriver-classic/src/commands/navigation.rs index ed1ee4a7b2..052e1371a9 100644 --- a/moli-protocol-webdriver-classic/src/commands/navigation.rs +++ b/moli-protocol-webdriver-classic/src/commands/navigation.rs @@ -1,8 +1,7 @@ use moli_protocol::devtools_runtime::{ - DevToolsCommand, DevToolsEvaluateScriptCommand, DevToolsGetNavigationHistoryCommand, - DevToolsGetNavigationHistoryResult, DevToolsGetOuterHtmlCommand, DevToolsGetTargetsCommand, - DevToolsHistoryTraversalDestination, DevToolsNavigateCommand, DevToolsNavigationWait, - DevToolsReloadCommand, DevToolsResultOwnership, DevToolsTargetId, + DevToolsCommand, DevToolsEvaluateScriptCommand, DevToolsGetOuterHtmlCommand, + DevToolsGetTargetsCommand, DevToolsHistoryTraversalDestination, DevToolsNavigateCommand, + DevToolsNavigationWait, DevToolsReloadCommand, DevToolsResultOwnership, DevToolsTargetId, DevToolsTraverseHistoryCommand, }; use serde_json::Value; @@ -77,36 +76,14 @@ pub fn refresh_command( }) } -pub fn navigation_history_command(context: &ClassicDevToolsCommandContext) -> DevToolsCommand { - DevToolsCommand::GetNavigationHistory(DevToolsGetNavigationHistoryCommand { - context: context.command_context(), - }) -} - -pub fn history_traversal_entry( - history: &DevToolsGetNavigationHistoryResult, - delta: i32, -) -> Option<(i32, String)> { - let target_index = history.current_index as i64 + i64::from(delta); - if target_index < 0 || target_index >= history.entries.len() as i64 { - return None; - } - let entry = &history.entries[target_index as usize]; - Some((entry.id, entry.url.clone())) -} - pub fn traverse_history_command( context: &ClassicDevToolsCommandContext, - entry_id: i32, - url: impl Into, + delta: i64, wait: DevToolsNavigationWait, ) -> DevToolsCommand { DevToolsCommand::TraverseHistory(DevToolsTraverseHistoryCommand { context: context.command_context(), - destination: DevToolsHistoryTraversalDestination::Entry { - entry_id, - url: url.into(), - }, + destination: DevToolsHistoryTraversalDestination::Delta(delta), wait, }) } diff --git a/moli-protocol-webdriver-classic/src/lib.rs b/moli-protocol-webdriver-classic/src/lib.rs index 90312ce0be..f8de568617 100644 --- a/moli-protocol-webdriver-classic/src/lib.rs +++ b/moli-protocol-webdriver-classic/src/lib.rs @@ -59,10 +59,9 @@ pub use commands::{ get_element_property_reference_command, get_element_rect_command, get_element_rect_reference_command, get_element_rendered_text_command, get_element_shadow_root_command, get_element_tag_name_command, get_element_text_command, - get_element_text_reference_command, history_traversal_entry, layout_metrics_command, - navigate_command, navigation_history_command, new_window_command, new_window_type, - page_source_command, print_page_command, refresh_command, release_remote_object_command, - resolve_element_command, resolve_element_reference_command, + get_element_text_reference_command, layout_metrics_command, navigate_command, + new_window_command, new_window_type, page_source_command, print_page_command, refresh_command, + release_remote_object_command, resolve_element_command, resolve_element_reference_command, resolve_element_reference_command_with_execution_context, resolve_shadow_root_command, resolve_shadow_root_reference_command, resolve_shadow_root_reference_command_with_execution_context, screenshot_command, diff --git a/moli-protocol-webdriver-classic/src/tests.rs b/moli-protocol-webdriver-classic/src/tests.rs index 3786bacb5d..8fb6d0eed9 100644 --- a/moli-protocol-webdriver-classic/src/tests.rs +++ b/moli-protocol-webdriver-classic/src/tests.rs @@ -2,13 +2,12 @@ use moli_protocol::devtools_runtime::{ DevToolsCaptureScreenshotClip, DevToolsCommand, DevToolsDomBoxModel, DevToolsDomGeometryOperation, DevToolsDomGeometryResult, DevToolsDomNodeReference, DevToolsDomQuad, DevToolsError, DevToolsErrorKind, DevToolsGetAttributesResult, - DevToolsGetCookiesResult, DevToolsGetNavigationHistoryResult, DevToolsGetPropertyResult, - DevToolsGetTargetsResult, DevToolsGetTextResult, DevToolsHistoryTraversalDestination, - DevToolsKeyEventType, DevToolsLocateNodesLocator, DevToolsLocateNodesTextMatch, - DevToolsMouseEventType, DevToolsNavigationWait, DevToolsPointerType, - DevToolsPrintToPdfTransferMode, DevToolsProtocol, DevToolsRemoteHandleId, - DevToolsResultOwnership, DevToolsSessionId, DevToolsTargetId, DevToolsTargetKind, - DevToolsTouchEventType, DevToolsViewportSetting, DevToolsWindowState, + DevToolsGetCookiesResult, DevToolsGetPropertyResult, DevToolsGetTargetsResult, + DevToolsGetTextResult, DevToolsHistoryTraversalDestination, DevToolsKeyEventType, + DevToolsLocateNodesLocator, DevToolsLocateNodesTextMatch, DevToolsMouseEventType, + DevToolsNavigationWait, DevToolsPointerType, DevToolsPrintToPdfTransferMode, DevToolsProtocol, + DevToolsRemoteHandleId, DevToolsResultOwnership, DevToolsSessionId, DevToolsTargetId, + DevToolsTargetKind, DevToolsTouchEventType, DevToolsViewportSetting, DevToolsWindowState, }; use serde_json::json; @@ -3336,59 +3335,13 @@ fn maps_refresh_and_history_traversal_to_shared_devtools_commands() { Some("TID-1") ); - let history_command = navigation_history_command(&context); - let DevToolsCommand::GetNavigationHistory(history_command) = history_command else { - panic!("expected GetNavigationHistory command"); - }; - assert_eq!( - history_command - .context - .target_id - .as_ref() - .map(DevToolsTargetId::as_str), - Some("TID-1") - ); - - let history = DevToolsGetNavigationHistoryResult { - current_index: 1, - entries: vec![ - moli_protocol::devtools_runtime::DevToolsNavigationHistoryEntry { - id: 7, - url: "https://example.test/first".to_owned(), - user_typed_url: "https://example.test/first".to_owned(), - title: "first".to_owned(), - transition_type: "typed".to_owned(), - }, - moli_protocol::devtools_runtime::DevToolsNavigationHistoryEntry { - id: 8, - url: "https://example.test/second".to_owned(), - user_typed_url: "https://example.test/second".to_owned(), - title: "second".to_owned(), - transition_type: "typed".to_owned(), - }, - ], - }; - assert_eq!( - history_traversal_entry(&history, -1), - Some((7, "https://example.test/first".to_owned())) - ); - assert_eq!(history_traversal_entry(&history, 1), None); - - let traverse = traverse_history_command( - &context, - 7, - "https://example.test/first", - DevToolsNavigationWait::Load, - ); + let traverse = traverse_history_command(&context, -1, DevToolsNavigationWait::Load); let DevToolsCommand::TraverseHistory(traverse) = traverse else { panic!("expected TraverseHistory command"); }; assert_eq!( traverse.destination, - DevToolsHistoryTraversalDestination::Entry { - entry_id: 7, - url: "https://example.test/first".to_owned(), - } + DevToolsHistoryTraversalDestination::Delta(-1) ); assert_eq!(traverse.wait, DevToolsNavigationWait::Load); assert_eq!( diff --git a/moli-protocol/Cargo.toml b/moli-protocol/Cargo.toml index 26c5269d7a..7ee31d4271 100644 --- a/moli-protocol/Cargo.toml +++ b/moli-protocol/Cargo.toml @@ -6,13 +6,12 @@ edition = "2024" build = "build.rs" [features] -test-support = [] +test-support = ["moli-core/test-support"] [dependencies] anyhow = "1.0.100" base64 = "0.22" indexmap = "2.6" -moli-bounded-buffer = { path = "../moli-bounded-buffer" } moli-core = { path = "../moli-core" } moli-header-field = { path = "../moli-header-field" } moli-page-types = { path = "../moli-page-types" } @@ -49,6 +48,7 @@ sanitize-filename = "=0.6.0" [dev-dependencies] axum = { version = "0.8.6", features = ["ws"] } +moli-core = { path = "../moli-core", features = ["test-support"] } moli-cookie-jar = { path = "../moli-cookie-jar", features = ["test-support"] } moli-image = { path = "../moli-image" } moli-storage-service = { path = "../moli-storage-service" } diff --git a/moli-protocol/src/conn.rs b/moli-protocol/src/conn.rs index 917d8dce28..f88600e97b 100644 --- a/moli-protocol/src/conn.rs +++ b/moli-protocol/src/conn.rs @@ -1,10 +1,7 @@ use std::{ collections::{HashMap, HashSet}, path::PathBuf, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, + sync::Arc, }; use moli_cookie_jar::{StoredCookie, StoredCookieQueryReport}; @@ -15,11 +12,20 @@ use serde_json::json; use crate::devtools_runtime::{ DevToolsCommandContext, DevToolsTargetFilterEntry, DevToolsTargetInfo, DevToolsTargetKind, }; -use crate::domains::command_output::{BackgroundProtocolEventBuffer, CommandOutputBuffer}; +use crate::domains::command_output::BackgroundProtocolEventBuffer; +#[cfg(test)] +use crate::domains::command_output::CommandOutputBuffer; use moli_core::{ LayoutPolicy, OptionalResourceFetchMask, RendererOutputPublicationOrdering, RendererOutputTransportMessage, + browser_host::{ + BrowserCommandId, BrowserContextRegistrationMetadata, BrowserDownloadPolicy, + BrowserDownloadPolicyUpdate, BrowserDownloadRegistry, BrowserHostHandle, + BrowserHostNetworkPolicySnapshot, BrowserHostPolicyState, BrowserHostPolicyUpdate, + BrowserHostState, BrowserInitialEmptyDocumentSeed, BrowserPermissionOverride, + BrowserTargetCreationMetadata, + }, network::{SharedWebStorageStore, new_shared_web_storage_store}, page::{NavigationResponse, Page, SubresourceAuthCredentials}, runtime::{ @@ -31,9 +37,26 @@ pub const DEFAULT_CDP_PAGE_TARGET_ID: &str = "moli-default"; pub const DEFAULT_CDP_TAB_TARGET_ID: &str = "moli-default-tab"; mod activity_source; +mod background_output; mod bidi_channel_work; mod body_spool; mod browser_context; +mod browser_document_navigation; +mod browser_document_page_builder; +mod browser_fact_projection; +mod browser_host_executor_residence; +mod browser_host_turn_executor; +mod browser_initial_document; +mod browser_navigation_history; +mod browser_owner_input; +mod browser_page_replacement; +mod browser_target_metadata_projection; +pub(crate) use browser_page_replacement::{ + LoadedPageReplacementOutcome, LoadedPageReplacementStart, PendingLoadedPageReplacement, +}; +pub(crate) mod browser_target_engine_handoff; +pub(crate) use browser_target_engine_handoff::TargetActivationTransition; +mod browser_target_termination; mod command_owner_scope; mod command_view; mod cookie_manager_surface; @@ -43,13 +66,13 @@ mod cookie_policy_surface; mod cookie_store_boundary; mod devtools_command; mod dispatch; +mod download_event_projection; mod downloads; mod fetch_support; mod inspector_route; mod output; mod page_state; mod popup_activation_work; -mod popup_navigation_work; mod protocol_output; mod renderer_command_turn; mod resource_runtime_support; @@ -62,9 +85,12 @@ mod settings; mod site_data_manager_surface; mod state; mod target; -mod top_level_navigation_work; pub use crate::domains::network::IoStreamState; +pub use background_output::{ + BackgroundEventSender, BackgroundOutputClosed, BrowserBackgroundOutputReceiver, + browser_background_output_channel, +}; #[cfg(test)] pub(crate) use bidi_channel_work::BidiChannelOwnerActionKind; pub(crate) use bidi_channel_work::{ @@ -72,10 +98,33 @@ pub(crate) use bidi_channel_work::{ BidiChannelPageOwner, }; pub(crate) use body_spool::{CapturedBody, CapturedBodyWriter}; +pub(crate) use browser_context::{CdpSessionRoute, TargetHandlerAccessMode}; pub(crate) use browser_context::{ - PageLifecycleEventsEnableResult, SessionOwnerInspectorEnableResult, + ClosedPageTarget, PageLifecycleEventsEnableResult, ProjectedActiveTargetClose, + ProjectedClosedPageTarget, SessionOwnerInspectorEnableResult, SessionOwnerRuntimeFrontendEnableResult, TargetEmulationSessionStateMut, - TargetNavigationLoadInputs, + TargetLoadedNavigationCommitState, TargetNavigationLoadInputs, TargetProjectionError, +}; +pub use browser_fact_projection::BrowserFactProjectionError; +pub(crate) use browser_fact_projection::BrowserTargetCreatedFactProjection; +pub(crate) use browser_host_executor_residence::BrowserHostTurnExecution; +pub use browser_host_executor_residence::BrowserHostTurnExecutorOwner; +pub use browser_host_turn_executor::{ + BrowserHostTurnDispatch, CompletedBrowserHostTurn, PendingBrowserHostTurn, +}; +pub(crate) use browser_owner_input::{ + BrowserOwnerInputPublicationError, BrowserOwnerPausedNavigationSidecar, + CompletedBrowserOwnerContextDisposalCommand, + CompletedBrowserOwnerInitialTargetNavigationCommand, CompletedBrowserOwnerNavigationCommand, + CompletedBrowserOwnerPausedNavigationDecisionCommand, CompletedBrowserOwnerStopLoadingCommand, + PendingBrowserOwnerContextDisposalCommand, PendingBrowserOwnerInitialTargetNavigationCommand, + PendingBrowserOwnerNavigationCommand, PendingBrowserOwnerPausedNavigationDecisionCommand, + PendingBrowserOwnerStopLoadingCommand, +}; +pub(crate) use browser_target_termination::{ + BrowserPageTargetTerminationStart, BrowserTargetCloseStart, BrowserTargetTerminationProjection, + BrowserTargetTerminationProjectionKind, CompletedBrowserTargetClose, + PendingBrowserPageTargetTermination, PendingBrowserTargetClose, }; pub(crate) use command_owner_scope::CommandOwnerScope; pub use command_view::Cmd; @@ -94,7 +143,6 @@ pub(crate) use cookie_owner::{ pub use devtools_command::DevToolsCommandDispatchOutcome; pub(crate) use devtools_command::DevToolsCommandExecutionOutput; pub use dispatch::{CdpCommandTaskStep, CompletedCdpCommandDispatch, PendingCdpCommandDispatch}; -pub(crate) use downloads::SharedDownloadRegistry; pub(crate) use fetch_support::PendingStreamingDocumentResponseNavigation; pub(crate) use fetch_support::{ ClaimedSubresourceContinueRequest, CompletedFetchResponseBodyStreamReadDispatch, @@ -117,7 +165,7 @@ pub use moli_protocol_cdp::{ CdpRendererCommandAccess, CdpRendererCommandPolicy, CdpRendererCommandReplacement, CdpRendererCommandReplayDispatch, CdpRequest, ParsedCdpCommand, }; -pub(crate) use target::{CdpSessionRoute, TargetActivationTransition, TargetHandlerAccessMode}; +pub(crate) use popup_activation_work::PopupTargetActivationAction; #[derive(Clone, Debug)] pub enum CdpTargetHostLifecycleDelta { @@ -190,9 +238,9 @@ struct CommandResponseFlushDeferredReleases { releases: Vec, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Debug)] pub struct DevToolsDocumentLifecycleWaitKey { - registration_id: state::RendererDocumentLifecycleWaiterId, + lifecycle_wait: moli_core::browser_host::BrowserDocumentLifecycleWaitTicket, renderer_document: moli_core::page::RendererDocumentToken, renderer_epoch: moli_core::page::RendererLifecycleEpoch, milestone: moli_core::page::RendererDocumentLifecycleMilestone, @@ -208,6 +256,47 @@ impl DevToolsDocumentLifecycleWaitKey { pub fn milestone(&self) -> moli_core::page::RendererDocumentLifecycleMilestone { self.milestone } + + pub fn state(&self) -> DevToolsDocumentLifecycleWaitState { + match self.lifecycle_wait.outcome() { + None => DevToolsDocumentLifecycleWaitState::Pending, + Some(moli_core::browser_host::BrowserDocumentLifecycleWaitOutcome::Reached) => { + DevToolsDocumentLifecycleWaitState::Reached + } + Some(moli_core::browser_host::BrowserDocumentLifecycleWaitOutcome::Interrupted { + .. + }) => DevToolsDocumentLifecycleWaitState::Interrupted, + Some(moli_core::browser_host::BrowserDocumentLifecycleWaitOutcome::Superseded) => { + DevToolsDocumentLifecycleWaitState::Superseded + } + Some(moli_core::browser_host::BrowserDocumentLifecycleWaitOutcome::Unavailable(_)) => { + DevToolsDocumentLifecycleWaitState::Unavailable + } + } + } + + #[cfg(test)] + pub(crate) fn resolved_for_event_match_test( + renderer_document: moli_core::page::RendererDocumentToken, + renderer_epoch: moli_core::page::RendererLifecycleEpoch, + milestone: moli_core::page::RendererDocumentLifecycleMilestone, + frame_id: impl Into, + loader_id: impl Into, + ) -> Self { + Self { + lifecycle_wait: moli_core::browser_host::BrowserDocumentLifecycleWaitTicket::resolved( + None, + None, + milestone, + moli_core::browser_host::BrowserDocumentLifecycleWaitOutcome::Reached, + ), + renderer_document, + renderer_epoch, + milestone, + frame_id: frame_id.into(), + loader_id: loader_id.into(), + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -233,44 +322,6 @@ pub enum DevToolsDocumentNavigationState { Committed { loader_id: String }, } -fn devtools_document_lifecycle_wait_state_for_slot( - slot: &TargetRuntimeSlot, - key: &DevToolsDocumentLifecycleWaitKey, -) -> DevToolsDocumentLifecycleWaitState { - let page_slot = slot.page_slot(); - let Some(outcome) = page_slot.renderer_document_lifecycle_waiter_outcome( - key.registration_id, - key.renderer_document, - key.renderer_epoch, - &key.frame_id, - &key.loader_id, - ) else { - return DevToolsDocumentLifecycleWaitState::Superseded; - }; - match outcome { - moli_core::page::RendererDocumentLifecycleWaitOutcome::Reached(_) => { - DevToolsDocumentLifecycleWaitState::Reached - } - moli_core::page::RendererDocumentLifecycleWaitOutcome::Interrupted(_) => { - DevToolsDocumentLifecycleWaitState::Interrupted - } - moli_core::page::RendererDocumentLifecycleWaitOutcome::Pending => { - match page_slot.renderer_document_lifecycle_binding() { - None => DevToolsDocumentLifecycleWaitState::Unavailable, - Some(binding) - if binding.renderer_document == key.renderer_document - && binding.renderer_epoch == key.renderer_epoch - && binding.frame_id == key.frame_id - && binding.loader_id == key.loader_id => - { - DevToolsDocumentLifecycleWaitState::Pending - } - Some(_) => DevToolsDocumentLifecycleWaitState::Superseded, - } - } - } -} - impl CommandResponseFlushPermit { fn finish_deferred_releases(&self) -> Vec { let mut deferred = self.deferred_releases.lock(); @@ -389,6 +440,12 @@ impl CommandDispatchContext { self.terminal_response_delivery_override } + /// Starts a move-owned participant projection with the same response + /// flush lifetime but without copying already-produced command output. + pub(crate) fn detached_participant_context(&self) -> Self { + Self::new(self.response_flush.clone()) + } + pub(crate) fn push_protocol_event(&mut self, event: BackgroundProtocolEvent) { self.protocol_events_mut().push(event); } @@ -489,29 +546,76 @@ impl CommandDispatchContext { pub fn take_renderer_output_predecessor(&mut self) -> Option { self.renderer_output_predecessor.take() } + + /// Reattaches output accumulated while Browser Host owned a command + /// participant to the frontend's original response boundary. + /// + /// The normal Page.navigate path detaches before producing any output, so + /// the frontend context contains only its response-flush capability. The + /// merge fallback deliberately preserves both event segments without a + /// release-build panic if a future caller adds an earlier projection. + pub(crate) fn absorb_detached_participant_projection( + &mut self, + mut detached: CommandDispatchContext, + ) { + let has_projection = self.protocol_events_len() != 0 + || self.renderer_output_boundary.is_some() + || !self.post_response_events.is_empty() + || self.renderer_output_predecessor.is_some(); + if !has_projection { + detached.response_flush = self.response_flush.clone(); + *self = detached; + return; + } + + tracing::error!( + "frontend command produced output before its Browser Host participant projection" + ); + let (before_boundary, boundary, after_boundary) = + detached.take_renderer_fenced_protocol_events(); + if self.renderer_output_boundary.is_none() { + self.protocol_events.extend(before_boundary); + self.renderer_output_boundary = boundary; + self.post_renderer_output_events.extend(after_boundary); + } else { + if boundary.is_some() { + tracing::error!( + "detached Browser Host projection introduced a second renderer boundary" + ); + } + self.post_renderer_output_events.extend(before_boundary); + self.post_renderer_output_events.extend(after_boundary); + } + self.post_response_events + .extend(detached.take_post_response_events()); + if let Some(predecessor) = detached.take_renderer_output_predecessor() { + self.set_renderer_output_predecessor(predecessor); + } + } } pub(crate) use moli_protocol_cdp::{DEFAULT_LOADER_ID, monotonic_timestamp_seconds}; pub(crate) use output::NavigationBackgroundEvent; pub use output::{ - BackgroundCommandResponsePayload, BackgroundEventSender, BackgroundProtocolEvent, - PageScreencastFrameMetadata, RuntimeInspectorAsyncCompletionReceiver, - RuntimeInspectorResponseReady, RuntimeInspectorResponseReadySender, build_event, + BackgroundCommandResponsePayload, BackgroundProtocolEvent, PageScreencastFrameMetadata, + RuntimeInspectorAsyncCompletionReceiver, RuntimeInspectorResponseReady, + RuntimeInspectorResponseReadySender, build_event, }; pub(crate) use output::{ BackgroundCommandResponsePayloadRef, BackgroundServiceWorkerErrorMessage, BackgroundServiceWorkerRegistration, BackgroundServiceWorkerVersion, build_command_success_response, }; -pub(crate) use page_state::{LoadedNavigationPageCommit, LoadedNavigationRendererAttachmentCommit}; -pub(crate) use popup_activation_work::PopupTargetActivationAction; -pub(crate) use popup_navigation_work::{ - PopupTargetNavigationKind, PopupTargetNavigationOwnerAction, +pub(crate) use page_state::{ + LoadedNavigationRendererAttachmentCommit, PreparedLoadedNavigationPageCommit, }; pub(crate) use runtime_eval::{ - ClaimedPendingInspectorAwait, ClaimedPendingInspectorAwaitOwner, RuntimeBindingCallEvent, - RuntimeEnableReplayEvent, renderer_command_turn_frontend_protocol_response, - runtime_remote_object_ids_in_map, + BidiChannelOwnerActionStep, ClaimedPendingInspectorAwait, ClaimedPendingInspectorAwaitOwner, + CompletedBidiChannelOwnerAction, CompletedRendererCallReplayBatch, + CompletedRuntimeProtocolMessageNormalization, PendingBidiChannelOwnerAction, + PendingRendererCallReplayBatch, PendingRuntimeProtocolMessageNormalization, + RuntimeBindingCallEvent, RuntimeEnableReplayEvent, RuntimeProtocolMessageCompletionStep, + renderer_command_turn_frontend_protocol_response, runtime_remote_object_ids_in_map, }; pub use runtime_eval::{ CompletedMoliDiagnosticsDispatch, CompletedRuntimeBindingPageCommandDispatch, @@ -525,14 +629,22 @@ pub use runtime_eval::{ }; pub(crate) use runtime_load::decode_data_url_response; pub(crate) use runtime_load::{ - BackgroundNavigationBodyCompletionSink, BackgroundNavigationEarlyResult, - BackgroundNavigationLoadJob, CompletedInitialDocumentPageBuild, FailedInitialDocumentPageBuild, + BackgroundBufferedResponseNavigationLoadJob, BackgroundCapturedResponseNavigationLoadJob, + BackgroundInterceptedNavigationFetchJob, BackgroundInterceptedNavigationFetchMode, + BackgroundInterceptedNavigationFetchResult, BackgroundNavigationBodyCompletionSink, + BackgroundNavigationEarlyOutcome, BackgroundNavigationLoadJob, + BackgroundPausedStreamingResponseNavigationPreparationJob, + BackgroundStreamingResponseCollectionJob, BackgroundStreamingResponseNavigationLoadJob, + CompletedInitialDocumentPageBuild, FailedInitialDocumentPageBuild, InitialDocumentPageInstallResult, InitialDocumentPageOwner, PausedResponsePreparedDocument, PendingInitialDocumentPageBuild, ResponseCommitReady, }; use scheduler_hooks::CdpSchedulerHooks; use scheduler_state::CdpConnectionSchedulerState; -pub use scheduler_state::{CdpRendererOwnerTurnOutcome, CdpSchedulerEvent, CdpTurnOutcome}; +pub use scheduler_state::{ + BackgroundNavigationCancellation, BackgroundNavigationGateKey, CdpRendererOwnerTurnOutcome, + CdpSchedulerEvent, CdpTurnOutcome, +}; #[cfg(test)] pub(crate) use site_data_manager_surface::{ BrowserContextReservedSiteDataOwnerState, BrowserContextSiteDataManagerOwnerState, @@ -558,8 +670,7 @@ pub(crate) use state::{ ParkedTargetOwnerState, PendingBidiChannelListener, PendingInspectorAwait, PendingRendererCommandKey, PerformanceTimeDomain, PreparedRendererCallDispatch, ProfilerAction, ProfilerInspectorCommand, RendererCommandCorrelation, RendererCommandDescriptor, - RendererCommandReplay, RendererDocumentLifecycleObservation, RendererDocumentLifecycleObserver, - RendererMainDocumentCommitSeed, RendererPageResidenceIdentity, + RendererCommandReplay, RendererMainDocumentCommitSeed, RendererPageResidenceIdentity, ServiceWorkerRuntimeExceptionSnapshot, ServiceWorkerTargetState, SharedWorkerTargetState, SiteDataClearOptions, TargetIdentityState, TargetInitialEmptyDocumentCreator, TargetOwnerState, TargetPageAttachmentId, TargetPageProtocolAttachmentIdentity, TargetPageResidenceIdentity, @@ -580,19 +691,29 @@ pub(crate) use state::{ }; pub(crate) use target::{ PreparedTargetAttach, PreparedTargetHostClosure, PreparedTargetHostDelta, - TargetAttachSessionCommit, TargetBindingCleanupAction, TargetClosureCleanupPlan, + TargetAttachRollbackPlan, TargetAttachSessionCommit, TargetAutoAttachedSessionDetachPlan, + TargetBindingCleanupAction, TargetBindingCleanupPlan, TargetClosureCleanupPlan, TargetSessionDetachCleanupPlan, }; use target::{ TargetClosurePlan, TargetControlPlane, TargetEventPlan, TargetHostDelta, target_destroyed_automation_events, }; -pub(crate) use top_level_navigation_work::TopLevelLocationNavigationOwnerAction; pub struct PendingDeferredMainDocumentLoadCompletion { inner: crate::domains::activity::PendingDeferredMainDocumentLoadCompletionActivity, } +/// Read-only readiness for one exact deferred main-document load terminal. +/// +/// This probe lets an application adapter preserve causal ordering after the +/// one-shot completion has moved into its async wait task. It carries no Page +/// capability and cannot consume or publish the lifecycle terminal. +#[derive(Clone, Debug)] +pub struct DeferredMainDocumentLoadCompletionReadiness { + inner: moli_core::browser_host::BrowserDocumentLifecycleWaitReadiness, +} + pub struct CompletedDeferredMainDocumentLoadCompletion { inner: crate::domains::activity::CompletedDeferredMainDocumentLoadCompletionActivity, } @@ -638,30 +759,6 @@ pub enum DeferredMainDocumentLoadCompletionOutputAction { Queue, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct ConnectionNetworkRequestIdAllocator { - next_sequence: u64, -} - -impl ConnectionNetworkRequestIdAllocator { - pub(crate) fn allocate_sequence(&mut self) -> u64 { - self.next_sequence = self - .next_sequence - .checked_add(1) - .expect("connection network request id sequence exhausted"); - self.next_sequence - } - - pub(crate) fn allocate_request_id(&mut self) -> String { - format!("REQ-{}", self.allocate_sequence()) - } - - #[cfg(test)] - pub(crate) fn next_sequence_for_test(&self) -> u64 { - self.next_sequence - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct IdleNavigationEngineReleaseResult { pub(crate) reset: bool, @@ -705,6 +802,12 @@ impl PendingDeferredMainDocumentLoadCompletion { self.inner.observation_id() } + pub fn readiness(&self) -> DeferredMainDocumentLoadCompletionReadiness { + DeferredMainDocumentLoadCompletionReadiness { + inner: self.inner.fact_readiness(), + } + } + pub async fn wait(self) -> CompletedDeferredMainDocumentLoadCompletion { CompletedDeferredMainDocumentLoadCompletion { inner: self.inner.wait().await, @@ -712,6 +815,19 @@ impl PendingDeferredMainDocumentLoadCompletion { } } +impl DeferredMainDocumentLoadCompletionReadiness { + pub fn is_terminal(&self) -> bool { + self.inner.is_terminal() + } + + #[cfg(feature = "test-support")] + pub(crate) fn reached_for_test_support() -> Self { + Self { + inner: moli_core::browser_host::BrowserDocumentLifecycleWaitReadiness::terminal_for_test_support(), + } + } +} + impl CompletedDeferredMainDocumentLoadCompletion { pub(crate) fn new( inner: crate::domains::activity::CompletedDeferredMainDocumentLoadCompletionActivity, @@ -875,16 +991,14 @@ impl CdpInitialStoragePartition { } } - pub fn from_storage_partition( - cookies: Vec, - storage_partition: &StoragePartitionState, - ) -> Self { - Self::new( - BrowserContextStoragePartitionHandles::from_storage_partition( - cookies, - storage_partition, - ), - ) + /// Creates a default-partition view over the Browser-owned stores. + /// + /// The returned handle shares the live cookie store instead of taking a + /// connection-local snapshot that must be merged back on frontend + /// teardown. The application-owned [`StoragePartitionState`] therefore + /// remains the only profile cookie authority. + pub fn from_storage_partition(storage_partition: &StoragePartitionState) -> Self { + Self::new(BrowserContextStoragePartitionHandles::from_storage_partition(storage_partition)) } fn into_parts(self) -> (BrowserContextStoragePartitionHandles, SharedWebStorageStore) { @@ -1039,56 +1153,21 @@ pub(crate) struct ServiceWorkerAutoAttachRelatedOwnerSession { pub(crate) wait_for_debugger_on_start: bool, } -/// Drop-addressable slot for the connection's active renderer owner. +/// Persistent renderer/DevTools projection for one application-owned Host +/// adapter. /// -/// `CdpConnection::drop` must release this engine before joining its extracted -/// BrowserContext network roots. A plain field would only be dropped after the -/// Drop implementation returned, which reverses that order. -struct NavigationEngineOwnerSlot { - engine: Option, -} - -impl NavigationEngineOwnerSlot { - fn new(engine: NavigationEngine) -> Self { - Self { - engine: Some(engine), - } - } - - fn replace(&mut self, engine: NavigationEngine) -> NavigationEngine { - self.engine - .replace(engine) - .expect("CDP active navigation engine was already taken") - } - - fn take(&mut self) -> Option { - self.engine.take() - } -} - -impl std::ops::Deref for NavigationEngineOwnerSlot { - type Target = NavigationEngine; - - fn deref(&self) -> &Self::Target { - self.engine - .as_ref() - .expect("CDP active navigation engine was already taken") - } -} - -impl std::ops::DerefMut for NavigationEngineOwnerSlot { - fn deref_mut(&mut self) -> &mut Self::Target { - self.engine - .as_mut() - .expect("CDP active navigation engine was already taken") - } -} - -/// Persistent per-connection state. +/// The type name is retained for direct typed APIs and tests, but production +/// socket frontends do not own this value. The application wraps it in +/// [`crate::DevToolsHostAdapter`]; CDP endpoints attach through their own +/// command and output queues. pub struct CdpConnection { - // Browser/session routing state. + // Transitional physical Context/Target/Page projections. Authoritative + // topology, renderer Page lifetime owners and per-Context runtime roots + // live in BrowserHostState; these values still combine remaining physical + // command/cache payload with CDP session and subscription projection. pub browser_context: Option, pub inactive_browser_contexts: Vec, + // Browser/session routing state. browser_session_ids: HashSet, /// When true, every newly-created target is immediately auto-attached. pub auto_attach: bool, @@ -1105,64 +1184,54 @@ pub struct CdpConnection { pub auto_attach_wait_for_debugger_on_start: bool, auto_attach_owner_sessions: HashMap, AutoAttachOwnerPolicy>, target_control: TargetControlPlane, + webdriver_bidi_target_lifecycle_projection_enabled: bool, service_worker_auto_attach_related_owners: Vec, service_worker_pause_on_start_owner_sessions: HashSet>, dedicated_worker_pause_on_start_owner_sessions: HashSet>, install_default_target_on_auto_attach: bool, - next_bc_id: u32, - next_target_id: u32, - shared_target_id_allocator: Option>, - next_tab_target_id: u32, - shared_tab_target_id_allocator: Option>, + // BrowserContext, Target and Browser command sequences live in the Host. + // These remaining counters identify only this frontend/session/runtime. next_session_id: u32, next_page_domain_subscription_generation: u64, next_internal_runtime_command_id: u64, - network_request_id_allocator: ConnectionNetworkRequestIdAllocator, + prepared_browser_owner_commands: + HashMap, none_session_owner_route_override: Option, pending_runtime_await_jobs: HashMap, claimed_pending_inspector_await_owners: HashMap, - // Browser profile, permissions, download and global IO state. - pub window_bounds: BrowserWindowBounds, - pub download_behavior: BrowserDownloadBehavior, - pub permission_overrides: Vec, + // Browser download policy and active artifact registry live in + // BrowserHostState. Only CDP/BiDi event subscriptions are connection-local. + download_event_subscriptions: BrowserDownloadEventSubscriptions, next_global_io_stream_id: u64, - base_browser_identity: moli_browser_profile::BrowserIdentityProfile, - global_extra_headers: Vec<(String, String)>, - global_browser_identity_override: Option, - global_network_conditions: Option, - global_geolocation_override: Option, - global_cache_disabled: bool, pub(crate) network_data_collectors: crate::domains::network::NetworkDataCollectorStore, - base_http_proxy: Option, - base_http_no_proxy: Option, - base_tls_verify_host: bool, initial_storage_partition: CdpInitialStoragePartitionOwner, pub(crate) global_io_streams: HashMap, pub(crate) tracing_state: crate::domains::tracing::TracingState, - download_registry: SharedDownloadRegistry, // Transport/scheduler integration hooks. These are channels out of the // renderer/browser owner into the outer CDP scheduler; they should not grow // into protocol routing state. scheduler_hooks: CdpSchedulerHooks, + /// Migration-period application handle for protocol-neutral Browser Owner + /// input. Protocol may publish through it but cannot select or execute a + /// Browser Host turn. + browser_host_handle: Option, target_host_lifecycle_observer: Option, // Scheduler-visible queues that are still stored on the connection while // source-specific queue ownership is being migrated outward. scheduler_state: CdpConnectionSchedulerState, - // Renderer/page owner state. This must remain single-owner and current - // thread affine while V8/DOM/Page state still lives inside NavigationEngine. - engine: NavigationEngineOwnerSlot, - // Background or inactive navigations run on fresh NavigationEngine - // instances so they do not block the active page owner. A loaded Page only - // keeps a weak renderer handle; the engine is the strong owner of that - // renderer runtime. Keep engines keyed by browser-context/target while the - // page is parked in the background or in an inactive browser context, and - // swap them back into `engine` when that owner becomes active. - retained_background_navigation_engines: HashMap<(String, String), NavigationEngine>, + // Frontend-owned cursor and projection state over immutable Browser facts. + // This cursor may lag or close without backpressuring Browser Owner state. + browser_fact_projector: browser_fact_projection::CdpBrowserFactProjector, + + // Access capability for the application-owned Browser Host residence. + // The authoritative registries, renderer engines and fact journal live in + // `BrowserHostState`; they are no longer embedded in this Protocol value. + browser_host_state: BrowserHostState, } impl Default for CdpConnection { @@ -1171,51 +1240,16 @@ impl Default for CdpConnection { } } -impl Drop for CdpConnection { - fn drop(&mut self) { - // Separate per-context roots from target/page state so teardown can - // follow the observable producer order even though NavigationEngines - // and detached local tasks retain only weak owner access. - let mut contexts = Vec::new(); - if let Some(context) = self.browser_context.take() { - contexts.push(context); - } - contexts.append(&mut self.inactive_browser_contexts); - let roots = contexts - .iter_mut() - .filter_map(BrowserContext::take_renderer_runtime_owner_for_teardown) - .collect::>(); - - let mut roots = roots; - for root in &mut roots { - root.terminate_renderer_producers_for_owner_shutdown(); - } - - // Dropping contexts releases active/background target and Page state. - drop(contexts); - - // RenderRuntimeOwner joins happen when the last JsRuntime-backed - // NavigationEngine handle is released. Do that explicitly here rather - // than relying on field drop after this method returns. - self.retained_background_navigation_engines.clear(); - drop(self.engine.take()); - - // Only after active and retained renderer owners are gone may the - // context roots close fetch admission and join semantic/curl owners. - for root in &mut roots { - root.shutdown_network_and_join(); - } - drop(roots); - } -} - impl CdpConnection { pub fn new() -> Self { Self::new_with_initial_storage_partition(CdpInitialStoragePartition::memory()) } pub(crate) fn layout_policy(&self) -> LayoutPolicy { - self.engine.layout_policy() + self.browser_host_state + .navigation_owner() + .active_runtime_config() + .layout_policy() } pub fn has_pending_javascript_dialog(&self) -> bool { @@ -1249,11 +1283,77 @@ impl CdpConnection { } pub fn enable_webdriver_bidi_download_events(&mut self) -> bool { - self.download_behavior.enable_webdriver_bidi_events() + self.download_event_subscriptions + .enable_webdriver_bidi_events() } pub fn disable_webdriver_bidi_download_events(&mut self) -> bool { - self.download_behavior.disable_webdriver_bidi_events() + self.download_event_subscriptions + .disable_webdriver_bidi_events() + } + + pub(crate) fn set_browser_download_events_enabled_for_session( + &mut self, + session_id: Option<&str>, + enabled: bool, + ) { + self.download_event_subscriptions + .set_browser_events_enabled_for_session(session_id, enabled); + } + + pub(crate) fn set_automation_download_events_enabled_for_browser_context( + &mut self, + browser_context_id: Option<&str>, + enabled: bool, + ) { + self.download_event_subscriptions + .set_automation_events_enabled_for_browser_context(browser_context_id, enabled); + } + + pub(crate) fn clear_automation_download_events_for_browser_context( + &mut self, + browser_context_id: &str, + ) { + self.download_event_subscriptions + .clear_automation_events_for_browser_context(browser_context_id); + } + + pub(crate) fn ensure_automation_download_event_override_for_browser_context( + &mut self, + browser_context_id: &str, + ) { + self.download_event_subscriptions + .ensure_automation_event_override_for_browser_context(browser_context_id); + } + + pub(crate) fn automation_download_events_enabled_for_browser_context( + &self, + browser_context_id: Option<&str>, + ) -> bool { + self.download_event_subscriptions + .automation_events_enabled_for_browser_context(browser_context_id) + } + + #[cfg(test)] + pub(crate) fn browser_download_event_session_ids_for_test(&self) -> Vec> { + self.download_event_subscriptions + .browser_event_session_ids() + } + + pub fn enable_webdriver_bidi_target_lifecycle_projection(&mut self) -> bool { + let changed = !self.webdriver_bidi_target_lifecycle_projection_enabled; + self.webdriver_bidi_target_lifecycle_projection_enabled = true; + changed + } + + pub fn disable_webdriver_bidi_target_lifecycle_projection(&mut self) -> bool { + let changed = self.webdriver_bidi_target_lifecycle_projection_enabled; + self.webdriver_bidi_target_lifecycle_projection_enabled = false; + changed + } + + pub(crate) fn webdriver_bidi_target_lifecycle_projection_enabled(&self) -> bool { + self.webdriver_bidi_target_lifecycle_projection_enabled } pub fn new_with_initial_storage_partition( @@ -1316,23 +1416,38 @@ impl CdpConnection { initial_storage_partition: CdpInitialStoragePartition, navigation_runtime_config: NavigationRuntimeConfig, ) -> Self { - Self::new_with_initial_storage_partition_owner_and_runtime_config( + let browser_host_state = BrowserHostState::new(NavigationEngine::new_with_runtime_config( + navigation_runtime_config, + )); + Self::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state, + initial_storage_partition, + ) + } + + /// Builds one Protocol adapter over an already-created Browser Host + /// residence. Application composition roots use this constructor so the + /// Host allocation is established before the frontend adapter. + #[doc(hidden)] + pub fn new_with_browser_host_state_and_initial_storage_partition( + browser_host_state: BrowserHostState, + initial_storage_partition: CdpInitialStoragePartition, + ) -> Self { + Self::new_with_browser_host_state_and_initial_storage_partition_owner( + browser_host_state, CdpInitialStoragePartitionOwner::from_initial_storage_partition( initial_storage_partition, ), - navigation_runtime_config, ) } - fn new_with_initial_storage_partition_owner_and_runtime_config( + fn new_with_browser_host_state_and_initial_storage_partition_owner( + browser_host_state: BrowserHostState, initial_storage_partition: CdpInitialStoragePartitionOwner, - navigation_runtime_config: NavigationRuntimeConfig, ) -> Self { - let fetch_config = navigation_runtime_config.fetch_config(); - let base_browser_identity = fetch_config.browser_identity().clone(); - let base_http_proxy = fetch_config.http_proxy().map(str::to_owned); - let base_http_no_proxy = fetch_config.http_no_proxy().map(str::to_owned); - let base_tls_verify_host = fetch_config.tls_verify_host(); + let browser_fact_projector = browser_fact_projection::CdpBrowserFactProjector::new( + browser_host_state.subscribe_browser_facts(), + ); Self { browser_context: None, inactive_browser_contexts: Vec::new(), @@ -1344,60 +1459,74 @@ impl CdpConnection { auto_attach_wait_for_debugger_on_start: false, auto_attach_owner_sessions: HashMap::new(), target_control: TargetControlPlane::default(), + webdriver_bidi_target_lifecycle_projection_enabled: false, service_worker_auto_attach_related_owners: Vec::new(), service_worker_pause_on_start_owner_sessions: HashSet::new(), dedicated_worker_pause_on_start_owner_sessions: HashSet::new(), install_default_target_on_auto_attach: false, - window_bounds: BrowserWindowBounds::default(), - download_behavior: BrowserDownloadBehavior::default(), - permission_overrides: Vec::new(), - next_bc_id: 0, + download_event_subscriptions: BrowserDownloadEventSubscriptions::default(), next_global_io_stream_id: 0, - next_target_id: 0, - shared_target_id_allocator: None, - next_tab_target_id: 0, - shared_tab_target_id_allocator: None, next_session_id: 0, next_page_domain_subscription_generation: 0, next_internal_runtime_command_id: 902_000_000, - network_request_id_allocator: ConnectionNetworkRequestIdAllocator::default(), + prepared_browser_owner_commands: HashMap::new(), pending_runtime_await_jobs: HashMap::new(), claimed_pending_inspector_await_owners: HashMap::new(), - base_browser_identity, - global_extra_headers: Vec::new(), - global_browser_identity_override: None, - global_network_conditions: None, - global_geolocation_override: None, - global_cache_disabled: false, network_data_collectors: crate::domains::network::NetworkDataCollectorStore::default(), - base_http_proxy, - base_http_no_proxy, - base_tls_verify_host, initial_storage_partition, global_io_streams: HashMap::new(), tracing_state: crate::domains::tracing::TracingState::default(), - download_registry: SharedDownloadRegistry::default(), scheduler_hooks: CdpSchedulerHooks::default(), + browser_host_handle: None, target_host_lifecycle_observer: None, scheduler_state: CdpConnectionSchedulerState::default(), none_session_owner_route_override: None, - engine: NavigationEngineOwnerSlot::new(NavigationEngine::new_with_runtime_config( - navigation_runtime_config, - )), - retained_background_navigation_engines: HashMap::new(), + browser_fact_projector, + browser_host_state, } } - pub fn set_background_event_sender(&mut self, sender: BackgroundEventSender) { - self.scheduler_hooks.set_background_event_sender(sender); + pub fn browser_host_state(&self) -> BrowserHostState { + self.browser_host_state.clone() + } + + pub(crate) fn browser_host_policy_snapshot(&self) -> BrowserHostPolicyState { + self.browser_host_state.policy_snapshot() + } + + #[cfg(test)] + pub(crate) fn browser_download_policy_snapshot( + &self, + ) -> moli_core::browser_host::BrowserDownloadPolicyState { + self.browser_host_state.download_policy_snapshot() + } + + pub(crate) fn effective_browser_download_policy( + &self, + browser_context_id: Option<&str>, + ) -> BrowserDownloadPolicy { + self.browser_host_state + .effective_download_policy(browser_context_id) + } + + pub(crate) fn apply_browser_download_policy_update(&self, update: BrowserDownloadPolicyUpdate) { + self.browser_host_state.apply_download_policy_update(update); + } + + pub(crate) fn browser_download_registry(&self) -> BrowserDownloadRegistry { + self.browser_host_state.download_registry() + } + + pub(crate) fn browser_host_network_policy_snapshot(&self) -> BrowserHostNetworkPolicySnapshot { + self.browser_host_state.network_policy_snapshot() } - pub fn set_shared_target_id_allocator(&mut self, allocator: Arc) { - self.shared_target_id_allocator = Some(allocator); + pub(crate) fn apply_browser_host_policy_update(&self, update: BrowserHostPolicyUpdate) { + self.browser_host_state.apply_policy_update(update); } - pub fn set_shared_tab_target_id_allocator(&mut self, allocator: Arc) { - self.shared_tab_target_id_allocator = Some(allocator); + pub fn set_background_event_sender(&mut self, sender: BackgroundEventSender) { + self.scheduler_hooks.set_background_event_sender(sender); } pub fn set_target_host_lifecycle_observer(&mut self, observer: CdpTargetHostLifecycleObserver) { @@ -1426,13 +1555,8 @@ impl CdpConnection { &mut self, sender: moli_core::RendererOutputTransportSender, ) { - self.scheduler_hooks - .set_renderer_publication_sender(sender.clone()); - self.engine - .set_renderer_output_transport_sender(sender.clone()); - for engine in self.retained_background_navigation_engines.values() { - engine.set_renderer_output_transport_sender(sender.clone()); - } + self.browser_host_state + .set_renderer_output_transport_sender(sender); } pub(crate) fn background_event_sender(&self) -> Option { @@ -1446,395 +1570,106 @@ impl CdpConnection { .runtime_inspector_response_ready_sender() } - pub(crate) fn document_navigation_cancellation_handle( - &self, - token: &DocumentNavigationToken, - ) -> Option { - let browser_context_id = self.browser_context_id_for_target(&token.target_id)?; - self.browser_context_by_id(browser_context_id)? - .document_navigation_cancellation_handle(token) - } - - pub(crate) fn arm_background_navigation_completion( - &mut self, - token: &DocumentNavigationToken, - additional_cancellation: Option, - ) -> bool { - let browser_context_id = self - .browser_context_id_for_target(&token.target_id) - .map(str::to_owned); - let Some(browser_context_id) = browser_context_id else { - if let Some(cancellation) = additional_cancellation { - cancellation.cancel(); - } - return false; - }; - self.browser_context_by_id_mut(&browser_context_id) - .is_some_and(|browser_context| { - browser_context.arm_background_navigation_completion(token, additional_cancellation) - }) - } - - pub(crate) fn settle_background_navigation_completion( + pub(crate) fn record_background_navigation_started_scheduler_event( &mut self, token: &DocumentNavigationToken, - ) -> bool { - let browser_context_id = self - .browser_context_id_for_target(&token.target_id) - .map(str::to_owned); - let Some(browser_context_id) = browser_context_id else { - return false; - }; - self.browser_context_by_id_mut(&browser_context_id) - .is_some_and(|browser_context| { - browser_context.settle_background_navigation_completion(token) - }) - } - - pub fn has_inflight_background_navigation(&self) -> bool { - self.browser_contexts() - .any(BrowserContext::has_inflight_background_navigation) - } - - pub fn has_inflight_background_navigation_for_target(&self, target_id: &str) -> bool { - let Some(browser_context_id) = self.browser_context_id_for_target(target_id) else { - return false; - }; - self.browser_context_by_id(browser_context_id) - .is_some_and(|browser_context| { - browser_context.has_inflight_background_navigation_for_target(target_id) - }) - } - - fn target_id_for_session_owner(&self, session_id: Option<&str>) -> Option { - let (browser_context_id, target_id) = self.target_owner_identity_for_session(session_id)?; - target_id.or_else(|| { - self.browser_context_by_id(&browser_context_id) - .and_then(BrowserContext::active_target_id) - .map(str::to_owned) - }) + state: &NavigationDispatchState, + cancellation: BackgroundNavigationCancellation, + ) { + let key = BackgroundNavigationGateKey::for_navigation(token, state); + self.scheduler_state + .push_scheduler_event(CdpSchedulerEvent::BackgroundNavigationStarted { + key, + cancellation, + }); } - pub(crate) fn session_owner_target_has_waiting_for_debugger_session( + pub fn renderer_document_navigation_is_suspended_for_session_owner( &self, session_id: Option<&str>, ) -> bool { - self.target_id_for_session_owner(session_id) - .is_some_and(|target_id| self.target_has_waiting_for_debugger_session(&target_id)) - } - - pub fn background_navigation_target_id_for_event( - &self, - event: &BackgroundProtocolEvent, - ) -> Option { - event - .navigation_gate_target_id() - .filter(|target_id| self.browser_context_id_for_target(target_id).is_some()) - .map(str::to_owned) - .or_else(|| self.target_id_for_session_owner(event.protocol_session_id())) + self.runtime_session_owner_slot(session_id) + .is_ok_and(TargetRuntimeSlot::renderer_document_navigation_is_suspended) } - pub fn has_inflight_background_navigation_for_devtools_context( - &self, - context: &crate::devtools_runtime::DevToolsCommandContext, - ) -> bool { - context - .target_id + pub(crate) fn bind_renderer_document_lifecycle_for_session_owner( + &mut self, + session_id: Option<&str>, + artifacts: moli_core::page::RendererPageCreationArtifacts, + navigation: Option, + frame_id: String, + loader_id: String, + ) -> ( + Option, + Vec, + ) { + if navigation.as_ref().is_some_and(|navigation| { + !self.accepts_committed_document_navigation_for_session_owner(session_id, navigation) + }) { + return (None, Vec::new()); + } + let fact_page = self.target_page_residence_identity_for_session(session_id); + let previous_document_scope = self + .committed_renderer_document_binding_for_session_owner(session_id) + .map(CommittedRendererDocumentBinding::renderer_document_identity); + let Ok(slot) = self.runtime_session_owner_slot_mut(session_id) else { + return (None, Vec::new()); + }; + let ingress = slot + .page_slot_mut() + .bind_renderer_document_lifecycle_with_ingress( + artifacts, navigation, frame_id, loader_id, + ); + let binding = self + .committed_renderer_document_binding_for_session_owner(session_id) + .cloned(); + let current_document_scope = binding .as_ref() - .map(crate::devtools_runtime::DevToolsTargetId::as_str) - .map(str::to_owned) - .or_else(|| { - self.target_id_for_session_owner( - context - .session_id - .as_ref() - .map(crate::devtools_runtime::DevToolsSessionId::as_str), - ) - }) - .is_some_and(|target_id| self.has_inflight_background_navigation_for_target(&target_id)) + .map(CommittedRendererDocumentBinding::renderer_document_identity); + let document_scope_changed = current_document_scope != previous_document_scope; + if document_scope_changed { + self.retire_javascript_dialogs_for_session_owner(session_id); + } + self.record_authoritative_renderer_document_lifecycle_facts( + fact_page.as_ref(), + binding.as_ref(), + ingress.authoritative(), + ); + (binding, ingress.into_visible()) } - pub fn has_pending_document_navigation_for_session_owner( - &self, - session_id: Option<&str>, - ) -> bool { - let Some((browser_context_id, target_id)) = - self.target_owner_identity_for_session(session_id) - else { - return false; - }; - self.browser_context_by_id(&browser_context_id) - .is_some_and(|browser_context| { - browser_context.has_pending_document_navigation_for_target(target_id.as_deref()) - }) - } - - fn document_navigation_state_for_session_owner( - &self, - session_id: Option<&str>, - ) -> DevToolsDocumentNavigationState { - if self.target_owner_identity_for_session(session_id).is_none() { - return DevToolsDocumentNavigationState::Unavailable; - } - if self.has_pending_document_navigation_for_session_owner(session_id) { - return DevToolsDocumentNavigationState::PendingNavigation; - } - // The initial empty Document has a real loader identity before any - // cross-document navigation token exists. Use the same committed - // frame-tree authority as Page/Runtime routing instead of consulting - // only `pending_document_navigation` / `committed_document_navigation`. - // Otherwise a materialized `about:blank` is misclassified as - // `AwaitingCommit`, and ChromeDriver-style pre-command navigation - // waits can never complete. - match self.target_session_owner_frame_tree_loader_id(session_id) { - Some(loader_id) => DevToolsDocumentNavigationState::Committed { loader_id }, - None => DevToolsDocumentNavigationState::AwaitingCommit, - } - } - - /// Resolves Document readiness through the exact target captured by a - /// protocol-neutral command context. A target-id context intentionally - /// uses the owner-route override rather than whichever target is currently - /// active in the browser context. - pub fn devtools_context_document_navigation_state( - &mut self, - context: &DevToolsCommandContext, - ) -> DevToolsDocumentNavigationState { - if let Some(target_id) = context.target_id.as_ref() { - let Some(route) = self.target_session_route_for_target_id(target_id.as_str()) else { - return DevToolsDocumentNavigationState::Unavailable; - }; - let mut route_scope = self.scoped_none_session_owner_route_override(route); - return route_scope - .conn_mut() - .document_navigation_state_for_session_owner(None); - } - self.document_navigation_state_for_session_owner( - context - .session_id - .as_ref() - .map(|session_id| session_id.as_str()), - ) - } - - pub fn renderer_document_navigation_is_suspended_for_session_owner( - &self, - session_id: Option<&str>, - ) -> bool { - self.runtime_session_owner_slot(session_id) - .is_ok_and(TargetRuntimeSlot::renderer_document_navigation_is_suspended) - } - - pub(crate) fn accepts_pending_document_navigation_for_session_owner( - &self, - session_id: Option<&str>, - token: &DocumentNavigationToken, - ) -> bool { - let Some((browser_context_id, target_id)) = - self.target_owner_identity_for_session(session_id) - else { - return false; - }; - if target_id.as_deref() != Some(token.target_id.as_str()) { - return false; - } - self.browser_context_by_id(&browser_context_id) - .is_some_and(|browser_context| { - browser_context.accepts_pending_document_navigation_event(token) - }) - } - - pub(crate) fn ensure_document_accessible_for_session_owner( - &self, - session_id: Option<&str>, - ) -> Result<(), String> { - if self.has_pending_document_navigation_for_session_owner(session_id) { - return Err("Navigation is changing the document".to_owned()); - } - Ok(()) - } - - pub(crate) fn current_document_loader_id_for_session_owner( - &self, - session_id: Option<&str>, - ) -> Option { - self.runtime_session_owner_slot(session_id) - .ok() - .and_then(|slot| slot.current_document_loader_id().map(str::to_owned)) - } - - #[cfg(test)] - pub(crate) fn ingest_renderer_network_output_item_and_prepare_live_delivery_for_session_owner( - &mut self, - session_id: Option<&str>, - source_document: moli_core::RendererDocumentLifecycleIdentity, - item: &moli_core::page::ScriptNetworkOutputItem, - ) -> Option { - self.ingest_renderer_page_network_output_item_and_prepare_live_delivery_for_session_owner( - session_id, - None, - source_document, - item, - ) - } - - pub(crate) fn ingest_renderer_page_network_output_item_and_prepare_live_delivery_for_session_owner( - &mut self, - session_id: Option<&str>, - source_renderer_page: Option, - source_document: moli_core::RendererDocumentLifecycleIdentity, - item: &moli_core::page::ScriptNetworkOutputItem, - ) -> Option { - let primary_session_id = self.runtime_session_owner_primary_session_id(session_id); - let mut request_id_allocator = std::mem::take(&mut self.network_request_id_allocator); - let delivery = self - .runtime_session_owner_slot_mut(session_id) - .ok() - .and_then(|slot| { - slot.ingest_renderer_network_output_item_and_prepare_live_delivery( - source_renderer_page, - source_document, - item, - session_id, - primary_session_id.as_deref(), - None, - &mut request_id_allocator, - ) - }); - self.network_request_id_allocator = request_id_allocator; - delivery - } - - pub(crate) fn loaded_page_mut_for_protocol_access( - &mut self, - session_id: Option<&str>, - ) -> Result<&mut Page, String> { - self.ensure_document_accessible_for_session_owner(session_id)?; - self.loaded_page_mut_for_interruptible_protocol_access(session_id) - } - - /// Returns the exact Page that remains attached while a cross-Document - /// navigation is suspended. - /// - /// Only commands classified as renderer-interruptible at the parsed CDP - /// boundary may use this access path. Ordinary protocol commands must use - /// [`Self::loaded_page_mut_for_protocol_access`] so they wait for the - /// replacement attachment instead of entering the old renderer. - pub(crate) fn loaded_page_mut_for_interruptible_protocol_access( - &mut self, - session_id: Option<&str>, - ) -> Result<&mut Page, String> { - self.runtime_session_owner_slot_mut(session_id)? - .loaded_page_mut() - .ok_or_else(|| "NoDocumentLoaded".to_owned()) - } - - pub(crate) fn start_document_navigation_for_session_owner( - &mut self, - session_id: Option<&str>, - loader_id: String, - ) -> Option { - let (browser_context_id, target_id) = self.target_owner_identity_for_session(session_id)?; - let target_id = target_id?; - self.browser_context_by_id_mut(&browser_context_id)? - .start_document_navigation_for_target(&target_id, loader_id) - } - - pub(crate) fn commit_document_navigation_for_session_owner_if_matches( - &mut self, - session_id: Option<&str>, - token: &DocumentNavigationToken, - ) { - let Some((browser_context_id, target_id)) = - self.target_owner_identity_for_session(session_id) - else { - return; - }; - if target_id.as_deref() != Some(token.target_id.as_str()) { - return; - } - if let Some(browser_context) = self.browser_context_by_id_mut(&browser_context_id) { - browser_context.commit_document_navigation_if_matches(token); - } - } - - pub(crate) fn bind_renderer_document_lifecycle_for_session_owner( - &mut self, - session_id: Option<&str>, - artifacts: moli_core::page::RendererPageCreationArtifacts, - navigation: Option, - frame_id: String, - loader_id: String, - ) -> ( - Option, - Vec, - ) { - let (binding, events, document_scope_changed) = { - let Ok(slot) = self.runtime_session_owner_slot_mut(session_id) else { - return (None, Vec::new()); - }; - let previous_document_scope = slot - .page_slot() - .renderer_document_lifecycle_binding() - .map(CommittedRendererDocumentBinding::renderer_document_identity); - let events = slot - .page_slot_mut() - .bind_renderer_document_lifecycle(artifacts, navigation, frame_id, loader_id); - let binding = slot - .page_slot() - .renderer_document_lifecycle_binding() - .cloned(); - let current_document_scope = binding - .as_ref() - .map(CommittedRendererDocumentBinding::renderer_document_identity); - ( - binding, - events, - current_document_scope != previous_document_scope, - ) - }; - if document_scope_changed { - self.retire_javascript_dialogs_for_session_owner(session_id); - } - (binding, events) - } - - pub(crate) fn ingest_renderer_document_lifecycle_events_for_session_owner( - &mut self, + pub(crate) fn ingest_renderer_document_lifecycle_events_for_session_owner( + &mut self, session_id: Option<&str>, events: Vec, ) -> ( Option, Vec, ) { - let (binding, events, document_scope_changed, document_input_stream_opened) = { - let Ok(slot) = self.runtime_session_owner_slot_mut(session_id) else { - return (None, Vec::new()); - }; - let previous_document_scope = slot - .page_slot() - .renderer_document_lifecycle_binding() - .map(CommittedRendererDocumentBinding::renderer_document_identity); - let events = slot - .page_slot_mut() - .ingest_renderer_document_lifecycle_events(events); - let binding = slot - .page_slot() - .renderer_document_lifecycle_binding() - .cloned(); - let current_document_scope = binding - .as_ref() - .map(CommittedRendererDocumentBinding::renderer_document_identity); - let document_input_stream_opened = binding.as_ref().is_some_and(|binding| { - binding.document_open_replacement_epoch == Some(binding.renderer_epoch) - }); - ( - binding, - events, - current_document_scope != previous_document_scope, - document_input_stream_opened, - ) + let fact_page = self.target_page_residence_identity_for_session(session_id); + let Some(previous_binding) = self + .committed_renderer_document_binding_for_session_owner(session_id) + .cloned() + else { + return (None, Vec::new()); }; + let previous_document_scope = previous_binding.renderer_document_identity(); + let Ok(slot) = self.runtime_session_owner_slot_mut(session_id) else { + return (None, Vec::new()); + }; + let ingress = slot + .page_slot_mut() + .ingest_renderer_document_lifecycle_events_with_ingress(events); + let binding = self + .committed_renderer_document_binding_for_session_owner(session_id) + .cloned(); + let current_document_scope = binding + .as_ref() + .map(CommittedRendererDocumentBinding::renderer_document_identity); + let document_scope_changed = current_document_scope != Some(previous_document_scope); + let document_input_stream_opened = binding.as_ref().is_some_and(|binding| { + binding.document_open_replacement_epoch == Some(binding.renderer_epoch) + }); if document_scope_changed { self.retire_javascript_dialogs_for_session_owner(session_id); } @@ -1844,11 +1679,14 @@ impl CdpConnection { // not defer this state transition to a later diagnostics snapshot: // that would make a later owner turn rediscover and settle output // produced by this renderer turn. - self.with_target_owner_state_for_session_mut(session_id, |owner_state| { - owner_state.mark_initial_empty_document_exited(); - }); + self.mark_target_initial_empty_document_exited_for_session_owner(session_id); } - (binding, events) + self.record_authoritative_renderer_document_lifecycle_facts( + fact_page.as_ref(), + binding.as_ref(), + ingress.authoritative(), + ); + (binding, ingress.into_visible()) } fn retire_javascript_dialogs_for_session_owner(&mut self, session_id: Option<&str>) { @@ -1869,6 +1707,12 @@ impl CdpConnection { session_id: Option<&str>, loader_id: &str, ) -> bool { + if self + .committed_renderer_document_binding_for_session_owner(session_id) + .is_none() + { + return false; + } self.runtime_session_owner_slot_mut(session_id) .is_ok_and(|slot| { slot.page_slot_mut() @@ -1881,6 +1725,7 @@ impl CdpConnection { session_id: Option<&str>, loader_id: &str, ) -> Option> { + self.committed_renderer_document_binding_for_session_owner(session_id)?; self.runtime_session_owner_slot_mut(session_id) .ok()? .page_slot_mut() @@ -1892,6 +1737,12 @@ impl CdpConnection { session_id: Option<&str>, loader_id: &str, ) -> bool { + if self + .committed_renderer_document_binding_for_session_owner(session_id) + .is_none() + { + return false; + } self.runtime_session_owner_slot_mut(session_id) .is_ok_and(|slot| { slot.page_slot_mut() @@ -1907,34 +1758,60 @@ impl CdpConnection { CommittedRendererDocumentBinding, moli_core::page::RendererDocumentLifecycleSnapshot, )> { + let binding = self + .committed_renderer_document_binding_for_session_owner(session_id)? + .clone(); let page_slot = self .runtime_session_owner_slot(session_id) .ok()? .page_slot(); Some(( - page_slot.renderer_document_lifecycle_binding()?.clone(), + binding, page_slot.renderer_document_lifecycle_authoritative_snapshot()?, )) } - pub(crate) fn register_exact_renderer_document_lifecycle_observer_for_session_owner( - &mut self, + pub(crate) fn exact_renderer_document_lifecycle_fact_wait_for_session_owner( + &self, session_id: Option<&str>, expected_binding: Option<&CommittedRendererDocumentBinding>, milestone: moli_core::page::RendererDocumentLifecycleMilestone, - ) -> RendererDocumentLifecycleObserver { + ) -> moli_core::browser_host::BrowserDocumentLifecycleWaitTicket { let Some(expected_binding) = expected_binding else { - return RendererDocumentLifecycleObserver::resolved( - RendererDocumentLifecycleObservation::Unavailable, + return moli_core::browser_host::BrowserDocumentLifecycleWaitTicket::resolved( + None, + None, + milestone, + moli_core::browser_host::BrowserDocumentLifecycleWaitOutcome::Unavailable( + moli_core::browser_host::BrowserDocumentLifecycleWaitUnavailableReason::NoCurrentPage, + ), ); }; - let Ok(slot) = self.runtime_session_owner_slot_mut(session_id) else { - return RendererDocumentLifecycleObserver::resolved( - RendererDocumentLifecycleObservation::Unavailable, + let expected_document = expected_binding.renderer_document_identity(); + if !self + .renderer_document_binding_is_current_for_session_owner(session_id, expected_binding) + { + return moli_core::browser_host::BrowserDocumentLifecycleWaitTicket::resolved( + self.target_page_residence_identity_for_session(session_id), + Some(expected_document), + milestone, + moli_core::browser_host::BrowserDocumentLifecycleWaitOutcome::Superseded, + ); + } + let Some(expected_page) = self.target_page_residence_identity_for_session(session_id) + else { + return moli_core::browser_host::BrowserDocumentLifecycleWaitTicket::resolved( + None, + Some(expected_document), + milestone, + moli_core::browser_host::BrowserDocumentLifecycleWaitOutcome::Unavailable( + moli_core::browser_host::BrowserDocumentLifecycleWaitUnavailableReason::NoCurrentPage, + ), ); }; - slot.page_slot_mut() - .register_exact_renderer_document_lifecycle_observer(expected_binding, milestone) + self.browser_host_state + .navigation_owner() + .capture_document_lifecycle_wait(expected_page, expected_document, milestone) } pub(crate) fn renderer_document_lifecycle_visible_state_for_session_owner( @@ -1944,12 +1821,15 @@ impl CdpConnection { CommittedRendererDocumentBinding, moli_core::page::RendererDocumentLifecycleSnapshot, )> { + let binding = self + .committed_renderer_document_binding_for_session_owner(session_id)? + .clone(); let page_slot = self .runtime_session_owner_slot(session_id) .ok()? .page_slot(); Some(( - page_slot.renderer_document_lifecycle_binding()?.clone(), + binding, page_slot.renderer_document_lifecycle_visible_snapshot()?, )) } @@ -1959,6 +1839,12 @@ impl CdpConnection { session_id: Option<&str>, loader_id: &str, ) -> bool { + if self + .committed_renderer_document_binding_for_session_owner(session_id) + .is_none() + { + return false; + } self.runtime_session_owner_slot_mut(session_id) .is_ok_and(|slot| { slot.page_slot_mut() @@ -1985,6 +1871,12 @@ impl CdpConnection { RootFrameStoppedLoadingSettlementError as SettlementError, }; + if self + .committed_renderer_document_binding_for_session_owner(session_id) + .is_none() + { + return Err(SettlementError::MissingArmedObservation); + } let binding = self .runtime_session_owner_slot_mut(session_id) .ok() @@ -2032,10 +1924,21 @@ impl CdpConnection { { return false; } + if self + .committed_renderer_document_binding_for_session_owner(session_id) + .is_none() + { + return false; + } + let has_pending_document_navigation = + self.has_pending_document_navigation_for_session_owner(session_id); let binding = self .runtime_session_owner_slot_mut(session_id) .ok() - .and_then(|slot| slot.page_slot_mut().take_root_network_idle_binding()); + .and_then(|slot| { + slot.page_slot_mut() + .take_root_network_idle_binding(has_pending_document_navigation) + }); let Some(binding) = binding else { return false; }; @@ -2088,9 +1991,8 @@ impl CdpConnection { )) } - /// Captures the exact target Page currently addressed by a protocol-neutral - /// command context. The identity remains stable across Document replacement - /// within that Page and changes when the Page itself is replaced. + /// Captures the exact Browser Core-owned Page currently addressed by a + /// protocol-neutral command context. pub fn page_residence_identity_for_devtools_context( &mut self, context: &DevToolsCommandContext, @@ -2103,35 +2005,25 @@ impl CdpConnection { .target_page_residence_identity_for_session(session_id.as_deref()) } - pub fn capture_devtools_document_lifecycle_wait_key( - &mut self, - context: &DevToolsCommandContext, + fn capture_document_lifecycle_wait_for_session_owner( + &self, + session_id: Option<&str>, expected_loader_id: &str, milestone: moli_core::page::RendererDocumentLifecycleMilestone, ) -> Option { - let registration = if let Some(target_id) = context.target_id.as_ref() { - let route = self.target_session_route_for_target_id(target_id.as_str())?; - let mut route_scope = self.scoped_none_session_owner_route_override(route); - route_scope - .conn_mut() - .runtime_session_owner_slot_mut(None) - .ok()? - .page_slot_mut() - .register_renderer_document_lifecycle_waiter(milestone, expected_loader_id) - } else { - self.runtime_session_owner_slot_mut( - context - .session_id - .as_ref() - .map(|session_id| session_id.as_str()), - ) - .ok()? - .page_slot_mut() - .register_renderer_document_lifecycle_waiter(milestone, expected_loader_id) - }; - let (registration_id, binding) = registration?; + let binding = self + .committed_renderer_document_binding_for_session_owner(session_id)? + .clone(); + if binding.loader_id != expected_loader_id { + return None; + } + let lifecycle_wait = self.exact_renderer_document_lifecycle_fact_wait_for_session_owner( + session_id, + Some(&binding), + milestone, + ); Some(DevToolsDocumentLifecycleWaitKey { - registration_id, + lifecycle_wait, renderer_document: binding.renderer_document, renderer_epoch: binding.renderer_epoch, milestone, @@ -2140,114 +2032,34 @@ impl CdpConnection { }) } - pub fn devtools_document_lifecycle_wait_state( - &mut self, - context: &DevToolsCommandContext, - key: &DevToolsDocumentLifecycleWaitKey, - ) -> DevToolsDocumentLifecycleWaitState { - if let Some(target_id) = context.target_id.as_ref() { - let Some(route) = self.target_session_route_for_target_id(target_id.as_str()) else { - return DevToolsDocumentLifecycleWaitState::Unavailable; - }; - let mut route_scope = self.scoped_none_session_owner_route_override(route); - return route_scope - .conn_mut() - .runtime_session_owner_slot(None) - .ok() - .map_or(DevToolsDocumentLifecycleWaitState::Unavailable, |slot| { - devtools_document_lifecycle_wait_state_for_slot(slot, key) - }); - } - self.runtime_session_owner_slot( - context - .session_id - .as_ref() - .map(|session_id| session_id.as_str()), - ) - .ok() - .map_or(DevToolsDocumentLifecycleWaitState::Unavailable, |slot| { - devtools_document_lifecycle_wait_state_for_slot(slot, key) - }) - } - - pub fn release_devtools_document_lifecycle_wait_key( + /// Resolves frontend routing once, then captures an exact Browser-owned + /// lifecycle fact wait. Dropping the returned key releases its fact + /// subscribers; no callback is registered in the physical Page slot. + pub fn capture_devtools_document_lifecycle_wait_key( &mut self, context: &DevToolsCommandContext, - key: &DevToolsDocumentLifecycleWaitKey, - ) -> bool { + expected_loader_id: &str, + milestone: moli_core::page::RendererDocumentLifecycleMilestone, + ) -> Option { if let Some(target_id) = context.target_id.as_ref() { - let Some(route) = self.target_session_route_for_target_id(target_id.as_str()) else { - return false; - }; + let route = self.target_session_route_for_target_id(target_id.as_str())?; let mut route_scope = self.scoped_none_session_owner_route_override(route); return route_scope .conn_mut() - .runtime_session_owner_slot_mut(None) - .is_ok_and(|slot| { - slot.page_slot_mut() - .release_renderer_document_lifecycle_waiter( - key.registration_id, - key.renderer_document, - key.renderer_epoch, - &key.frame_id, - &key.loader_id, - ) - }); + .capture_document_lifecycle_wait_for_session_owner( + None, + expected_loader_id, + milestone, + ); } - self.runtime_session_owner_slot_mut( + self.capture_document_lifecycle_wait_for_session_owner( context .session_id .as_ref() .map(|session_id| session_id.as_str()), + expected_loader_id, + milestone, ) - .is_ok_and(|slot| { - slot.page_slot_mut() - .release_renderer_document_lifecycle_waiter( - key.registration_id, - key.renderer_document, - key.renderer_epoch, - &key.frame_id, - &key.loader_id, - ) - }) - } - - pub(crate) fn accepts_document_body_completion_for_session_owner( - &self, - session_id: Option<&str>, - token: &DocumentNavigationToken, - ) -> bool { - let Some((browser_context_id, target_id)) = - self.target_owner_identity_for_session(session_id) - else { - return false; - }; - if target_id.as_deref() != Some(token.target_id.as_str()) { - return false; - } - self.browser_context_by_id(&browser_context_id) - .is_some_and(|browser_context| { - browser_context.accepts_document_body_completion_event(token) - }) - } - - pub(crate) fn clear_pending_document_navigation_for_session_owner_if_loader_matches( - &mut self, - session_id: Option<&str>, - loader_id: &str, - ) { - let Some((browser_context_id, target_id)) = - self.target_owner_identity_for_session(session_id) - else { - return; - }; - if let Some(browser_context) = self.browser_context_by_id_mut(&browser_context_id) { - browser_context.clear_pending_document_navigation_for_target_if_loader_matches( - target_id.as_deref(), - loader_id, - ); - } - self.discard_uncommitted_main_document_resource_for_session_owner(session_id, loader_id); } pub fn take_scheduler_events(&mut self) -> Vec { @@ -2351,44 +2163,91 @@ impl CdpConnection { pub async fn drain_background_navigation_completion_turn_async( &mut self, completion: crate::domains::page::BackgroundNavigationCompletion, - ) -> CdpRendererOwnerTurnOutcome { - let mut command_context = CommandDispatchContext::default(); - let protocol_events = self - .drain_background_navigation_completion_events_with_context( - completion, - &mut command_context, - ) + ) -> ( + CdpRendererOwnerTurnOutcome, + crate::domains::page::BackgroundNavigationTurnDisposition, + ) { + let gate_key = completion.background_navigation_gate_key(); + let (mut command_context, protocol_events, terminal) = self + .drain_background_navigation_completion_events_with_context(completion, gate_key) .await; command_context .protocol_events_mut() .extend(protocol_events); let (protocol_events, renderer_output_boundary, post_renderer_output_events) = command_context.take_renderer_fenced_protocol_events(); - CdpTurnOutcome::new_with_protocol_and_post_response_events( - protocol_events, - command_context.take_post_response_events(), - self.take_scheduler_events(), + ( + CdpTurnOutcome::new_with_protocol_and_post_response_events( + protocol_events, + command_context.take_post_response_events(), + self.take_scheduler_events(), + ) + .with_renderer_output_boundary(renderer_output_boundary, post_renderer_output_events) + .with_renderer_output_predecessor(command_context.take_renderer_output_predecessor()), + if terminal { + crate::domains::page::BackgroundNavigationTurnDisposition::Terminal + } else { + crate::domains::page::BackgroundNavigationTurnDisposition::ParticipantPending + }, ) - .with_renderer_output_boundary(renderer_output_boundary, post_renderer_output_events) - .with_renderer_output_predecessor(command_context.take_renderer_output_predecessor()) } async fn drain_background_navigation_completion_events_with_context( &mut self, completion: crate::domains::page::BackgroundNavigationCompletion, - command_context: &mut CommandDispatchContext, - ) -> Vec { - let completion = match completion { + gate_key: Option, + ) -> (CommandDispatchContext, Vec, bool) { + let ( + mut completed, + mut command_context, + command_id, + command_session_id, + none_session_owner_route, + requested_url, + gate_key, + ) = match completion { crate::domains::page::BackgroundNavigationCompletion::Lifecycle(completion) => { - if !self.settle_background_navigation_completion(completion.navigation_token()) { - tracing::debug!( - token = ?completion.navigation_token(), - "background navigation completion did not match the target-owned request" + let none_session_owner_route = completion.none_session_owner_route(); + let requested_url = completion.requested_url().to_owned(); + let timing_started = + moli_trace::cdp_nav_timing_enabled().then(std::time::Instant::now); + if timing_started.is_some() { + tracing::info!( + target: "moli_cdp_nav_timing", + url = %requested_url, + stage = "background_completion_enqueue_start", + ready_to_enqueue_ms = completion.ready_elapsed_ms(), ); } - completion + // Always materialize stale completions so an outstanding + // Page.navigate receives its terminal abort response. Only + // retention of the detached engine is conditional. + let is_current = completion.is_current_for_connection(self); + let completion = completion.materialize_with_engine_retention(self, is_current); + if let Some(started) = timing_started { + tracing::info!( + target: "moli_cdp_nav_timing", + stage = "background_completion_materialized", + phase_ms = started.elapsed().as_millis(), + ); + } + let command_id = completion.navigate_id(); + let command_session_id = completion.navigate_session_id().map(str::to_owned); + ( + crate::domains::page::CompletedNavigateCommand::materialized(completion), + CommandDispatchContext::default(), + command_id, + command_session_id, + none_session_owner_route, + requested_url, + gate_key, + ) + } + crate::domains::page::BackgroundNavigationCompletion::Participant(completion) => { + completion.into_parts() } crate::domains::page::BackgroundNavigationCompletion::MainDocumentBody(completion) => { + let mut command_context = CommandDispatchContext::default(); let previous_none_session_owner_route = completion.navigate_session_id().is_none().then(|| { self.replace_none_session_owner_route_override( @@ -2399,198 +2258,69 @@ impl CdpConnection { if let Some(previous) = previous_none_session_owner_route { self.replace_none_session_owner_route_override(previous); } - return command_context.take_protocol_events(); + let protocol_events = command_context.take_protocol_events(); + return (command_context, protocol_events, true); } }; - let previous_none_session_owner_route = - completion.navigate_session_id().is_none().then(|| { - self.replace_none_session_owner_route_override( - completion.none_session_owner_route(), - ) + + loop { + let previous_none_session_owner_route = command_session_id.is_none().then(|| { + self.replace_none_session_owner_route_override(none_session_owner_route.clone()) }); - let timing_started = moli_trace::cdp_nav_timing_enabled().then(std::time::Instant::now); - if timing_started.is_some() { - tracing::info!( - target: "moli_cdp_nav_timing", - url = %completion.requested_url(), - stage = "background_completion_enqueue_start", - ready_to_enqueue_ms = completion.ready_elapsed_ms(), - ); - } - // Always complete the materialized navigation so the client receives - // a terminal Page.navigate response (success or abort-error) for the - // outstanding command id. The completion helper is responsible for the - // navigate_id-gated abort emission; the only thing we must avoid here - // is letting the stale background engine displace the live one. - let is_current = completion.is_current_for_connection(self); - let completion = completion.materialize_with_engine_retention(self, is_current); - if let Some(started) = timing_started { - tracing::info!( - target: "moli_cdp_nav_timing", - stage = "background_completion_materialized", - phase_ms = started.elapsed().as_millis(), - ); - } - let protocol_events = self - .drain_materialized_navigation_completion_background_events(completion, command_context) + let step = crate::domains::page::complete_pending_navigate_command( + self, + completed, + &mut command_context, + ) .await; - if let Some(previous) = previous_none_session_owner_route { - self.replace_none_session_owner_route_override(previous); - } - protocol_events - } - - pub(crate) fn replace_navigation_engine(&mut self, engine: NavigationEngine) { - let engine = engine; - self.apply_scheduler_senders_to_navigation_engine(&engine); - drop(self.engine.replace(engine)); - } - - pub(crate) fn apply_scheduler_senders_to_navigation_engine(&self, engine: &NavigationEngine) { - if let Some(sender) = self.scheduler_hooks.renderer_publication_sender() { - engine.set_renderer_output_transport_sender(sender); - } - } - - fn retain_background_navigation_engine( - &mut self, - browser_context_id: String, - target_id: String, - engine: NavigationEngine, - ) -> Result<(), String> { - let owner_access = self - .browser_context_by_id(&browser_context_id) - .ok_or_else(|| { - format!( - "cannot retain navigation engine for missing BrowserContext `{browser_context_id}`" - ) - })? - .renderer_runtime_owner_access(); - if !engine - .browser_context_runtime() - .shares_state_with(&owner_access.runtime()) - { - return Err(format!( - "navigation engine renderer context does not match BrowserContext `{browser_context_id}`" - )); - } - self.apply_scheduler_senders_to_navigation_engine(&engine); - let replaced = self - .retained_background_navigation_engines - .insert((browser_context_id, target_id), engine); - // The replaced wrapper may carry the last ambient (non-Document) - // lease for a retired fetch runtime. Drop it before asking the exact - // BrowserContext root to reap; doing this at the one retained-engine - // transition avoids route-specific finish hooks. - drop(replaced); - owner_access.reap_retired_resource_runtimes(); - Ok(()) - } - - pub(crate) fn forget_retained_navigation_engine_for_target(&mut self, target_id: &str) { - self.retained_background_navigation_engines - .retain(|(_, retained_target_id), _| retained_target_id != target_id); - } - - pub(crate) fn adopt_loaded_navigation_engine_for_session_owner( - &mut self, - session_id: Option<&str>, - engine: NavigationEngine, - ) { - let route = match session_id { - Some(_) => self.session_route(session_id), - None => self.none_session_owner_route_override(), - }; - let Some(route) = route else { - self.replace_navigation_engine(engine); - return; - }; - match route { - CdpSessionRoute::BackgroundTarget { - browser_context_id, - target_id, - } => self - .retain_background_navigation_engine(browser_context_id, target_id, engine) - .expect("background route must retain its exact BrowserContext engine"), - CdpSessionRoute::ActiveTarget { - browser_context_id, - target_id: Some(target_id), - } if self - .browser_context - .as_ref() - .is_none_or(|browser_context| browser_context.id != browser_context_id) => - { - self.retain_background_navigation_engine(browser_context_id, target_id, engine) - .expect( - "inactive active-target route must retain its exact BrowserContext engine", - ) + if let Some(previous) = previous_none_session_owner_route { + self.replace_none_session_owner_route_override(previous); } - CdpSessionRoute::ActiveTarget { - browser_context_id, - target_id: None, - } if self - .browser_context - .as_ref() - .is_none_or(|browser_context| browser_context.id != browser_context_id) => - { - if let Some(target_id) = self - .browser_context_by_id(&browser_context_id) - .and_then(|browser_context| browser_context.active_target_id()) - .map(str::to_owned) - { - self.retain_background_navigation_engine(browser_context_id, target_id, engine) - .expect("inactive active-target route must retain its exact BrowserContext engine"); - } else { - self.replace_navigation_engine(engine); + match step { + crate::domains::page::NavigateCommandCompletion::Complete(plan) => { + let protocol_events = + Self::project_navigation_completion_plan_background_events( + plan, + command_id, + command_session_id.as_deref(), + &mut command_context, + ); + return (command_context, protocol_events, true); + } + crate::domains::page::NavigateCommandCompletion::Pending(pending) => { + let Some(sender) = self + .scheduler_hooks + .background_navigation_completion_sender() + .filter(|sender| !sender.is_closed()) + else { + // This fallback is reachable only in direct tests or + // during frontend teardown. It preserves completion + // correctness without inventing a timer/retry policy. + completed = pending.wait().await; + continue; + }; + tokio::task::spawn_local(async move { + let completed = pending.wait().await; + let completion = + crate::domains::page::BackgroundNavigationParticipantCompletion::new( + completed, + command_context, + command_id, + command_session_id, + none_session_owner_route, + requested_url, + gate_key, + ); + let _ = sender.send( + crate::domains::page::BackgroundNavigationCompletion::participant( + completion, + ), + ); + }); + return (CommandDispatchContext::default(), Vec::new(), false); } } - CdpSessionRoute::AuxiliaryTarget { - browser_context_id, - target_id, - } if self - .browser_context_by_id(&browser_context_id) - .and_then(|browser_context| browser_context.background_target(&target_id)) - .is_some() - || self - .browser_context - .as_ref() - .is_none_or(|browser_context| browser_context.id != browser_context_id) => - { - self.retain_background_navigation_engine(browser_context_id, target_id, engine) - .expect("auxiliary route must retain its exact BrowserContext engine") - } - CdpSessionRoute::Browser - | CdpSessionRoute::TabTarget { .. } - | CdpSessionRoute::ActiveTarget { .. } - | CdpSessionRoute::AuxiliaryTarget { .. } - | CdpSessionRoute::SharedWorkerTarget { .. } - | CdpSessionRoute::DedicatedWorkerTarget { .. } - | CdpSessionRoute::ServiceWorkerTarget { .. } => self.replace_navigation_engine(engine), - } - } - - pub(crate) fn adopt_loaded_navigation_engine_for_page_owner( - &mut self, - owner: &InitialDocumentPageOwner, - engine: NavigationEngine, - ) { - let owner_is_current_active_target = - self.browser_context - .as_ref() - .is_some_and(|browser_context| { - browser_context.id == owner.browser_context_id - && browser_context.active_target_id() == Some(owner.target_id.as_str()) - }); - if owner_is_current_active_target { - self.replace_navigation_engine(engine); - return; } - self.retain_background_navigation_engine( - owner.browser_context_id.clone(), - owner.target_id.clone(), - engine, - ) - .expect("page owner must retain its exact BrowserContext engine"); } pub(crate) fn enqueue_deferred_main_document_load_completion( @@ -2610,96 +2340,15 @@ impl CdpConnection { .allocate_deferred_main_document_load_observation_id(); let completion = { let mut route_scope = owner_scope.enter(self); - admission.bind_lifecycle_observer(route_scope.conn_mut(), observation_id) + admission.bind_fact_wait_ticket(route_scope.conn_mut(), observation_id) }; - let publish_sequence = self - .scheduler_state - .allocate_protocol_work_publish_sequence(); - let work = crate::domains::activity::ProtocolSchedulerWork::main_document_load_owner_action( - publish_sequence, - completion, - ); - self.scheduler_state - .push_scheduler_event(CdpSchedulerEvent::ProtocolWorkPublished { work }); - } - - /// Publishes a top-level navigation already moved into prepared output. - /// - /// The prepared value retains its exact Page residence. The route is - /// captured here, while the output drain is still running under the - /// producer's owner scope. This boundary deliberately performs no - /// navigation: the returned scheduler work is the sole execution - /// authority. - pub(crate) fn publish_prepared_top_level_location_navigation_owner_action( - &mut self, - session_id: Option<&str>, - page_owner: TargetPageResidenceIdentity, - navigation: moli_core::page::RendererDocumentSourcedTopLevelLocationNavigation, - ) { - let action = TopLevelLocationNavigationOwnerAction::from_prepared( - self, session_id, page_owner, navigation, - ); - self.publish_top_level_location_navigation_owner_action(action); - } - - fn publish_top_level_location_navigation_owner_action( - &mut self, - action: TopLevelLocationNavigationOwnerAction, - ) { - let publish_sequence = self - .scheduler_state - .allocate_protocol_work_publish_sequence(); - let work = - crate::domains::activity::ProtocolSchedulerWork::top_level_location_navigation_owner_action( - publish_sequence, - action, - ); - self.scheduler_state - .push_scheduler_event(CdpSchedulerEvent::ProtocolWorkPublished { work }); - } - - pub(crate) fn publish_popup_target_navigation_owner_action( - &mut self, - action: PopupTargetNavigationOwnerAction, - ) { - let publish_sequence = self - .scheduler_state - .allocate_protocol_work_publish_sequence(); - let work = - crate::domains::activity::ProtocolSchedulerWork::popup_target_navigation_owner_action( - publish_sequence, - action, - ); - self.scheduler_state - .push_scheduler_event(CdpSchedulerEvent::ProtocolWorkPublished { work }); - } - - pub(crate) fn publish_popup_target_activation_action( - &mut self, - action: PopupTargetActivationAction, - ) { - let publish_sequence = self - .scheduler_state - .allocate_protocol_work_publish_sequence(); - let work = crate::domains::activity::ProtocolSchedulerWork::popup_target_activation_action( - publish_sequence, - action, - ); - self.scheduler_state - .push_scheduler_event(CdpSchedulerEvent::ProtocolWorkPublished { work }); - } - - pub(crate) fn publish_page_target_termination_owner_action( - &mut self, - action: crate::domains::page::PageTargetTerminationOwnerAction, - ) { let publish_sequence = self .scheduler_state .allocate_protocol_work_publish_sequence(); let work = - crate::domains::activity::ProtocolSchedulerWork::page_target_termination_owner_action( + crate::domains::activity::ProtocolSchedulerWork::main_document_load_fact_projection( publish_sequence, - action, + completion, ); self.scheduler_state .push_scheduler_event(CdpSchedulerEvent::ProtocolWorkPublished { work }); @@ -2753,7 +2402,8 @@ impl CdpConnection { events .into_iter() .filter_map(|event| { - event.into_background_protocol_event_if_current(self.browser_contexts()) + let is_current = self.accepts_pending_document_navigation_token(event.token()); + event.into_background_protocol_event_if_current(is_current) }) .collect() } @@ -2775,6 +2425,7 @@ impl CdpConnection { } } + #[cfg(test)] pub(crate) async fn drain_materialized_navigation_completion_background_events( &mut self, completion: crate::domains::page::MaterializedNavigationCompletion, @@ -2789,17 +2440,29 @@ impl CdpConnection { command_context, ) .await; + Self::project_navigation_completion_plan_background_events( + output.into_plan(), + command_id, + command_session_id.as_deref(), + command_context, + ) + } + + fn project_navigation_completion_plan_background_events( + plan: crate::domains::command_output::CommandOutputPlan, + command_id: Option, + command_session_id: Option<&str>, + command_context: &mut CommandDispatchContext, + ) -> Vec { let ( before_renderer_output, renderer_output_boundary, after_renderer_output, post_response_events, - ) = output - .into_plan() - .into_renderer_fenced_background_and_post_response_events( - command_id, - command_session_id.as_deref(), - ); + ) = plan.into_renderer_fenced_background_and_post_response_events( + command_id, + command_session_id, + ); command_context.append_renderer_fenced_protocol_events( before_renderer_output, renderer_output_boundary, @@ -2835,6 +2498,7 @@ impl CdpConnection { ); } + #[cfg(test)] pub(crate) async fn drain_materialized_navigation_completion_into_buffer( &mut self, out: &mut CommandOutputBuffer, @@ -2852,25 +2516,22 @@ impl CdpConnection { let is_current = completion.is_current_for_connection(self); let (token, state, navigation, engine) = completion.into_parts(); if !is_current { - crate::domains::page::push_superseded_navigation_result(out, &state); + if state.navigate_id.is_some() { + out.push_error_after_messages(-32000, "Navigation aborted"); + } return; } - let navigation_session_id = state.navigate_session_id.clone(); - crate::domains::page::complete_materialized_navigation_into_buffer_async( - self, - out, - token.clone(), - state, - navigation, - command_context, - ) - .await; - if let Some(engine) = engine { - self.adopt_loaded_navigation_engine_for_session_owner( - navigation_session_id.as_deref(), - engine, - ); - } + let committed_owner = + crate::domains::page::complete_materialized_navigation_into_buffer_async( + self, + out, + token.clone(), + state, + navigation, + command_context, + ) + .await; + self.adopt_materialized_navigation_engine(engine, committed_owner); if let Some(started) = timing_started { tracing::info!( target: "moli_cdp_nav_timing", @@ -2880,6 +2541,29 @@ impl CdpConnection { } } + pub(crate) fn adopt_materialized_navigation_engine( + &mut self, + engine: Option, + committed_owner: Option, + ) { + match (engine, committed_owner) { + (Some(engine), Some(owner)) => { + if let Err(error) = + self.adopt_loaded_navigation_engine_for_target_owner(owner, engine) + { + tracing::warn!( + %error, + "materialized navigation engine adoption rejected by Browser Owner" + ); + } + } + (Some(_engine), None) => { + tracing::warn!("materialized navigation engine has no committed Browser owner"); + } + (None, _) => {} + } + } + pub(crate) fn none_session_owner_route_override(&self) -> Option { self.none_session_owner_route_override.clone() } @@ -2906,8 +2590,9 @@ impl CdpConnection { } pub(crate) fn response_body_materialize_limit(&self) -> usize { - self.engine - .fetch_config() + self.browser_host_state + .navigation_owner() + .active_fetch_config() .http_max_response_size() .unwrap_or(body_spool::DEFAULT_BODY_MATERIALIZE_LIMIT) } @@ -2923,15 +2608,42 @@ impl CdpConnection { .map(BrowserContext::moli_memory_diagnostics) .collect::>(); let retained_engine_keys = self - .retained_background_navigation_engines - .keys() - .map(|(browser_context_id, target_id)| { + .browser_host_state + .navigation_owner() + .retained_background_engine_keys() + .map(|key| { + json!({ + "browserContextId": key.browser_context_id(), + "targetId": key.target_id(), + }) + }) + .collect::>(); + let mut browser_owner_initial_empty_documents = self + .browser_host_state + .navigation_owner() + .initial_empty_documents() + .map(|state| { + let creator = state.creator().map(|creator| { + json!({ + "targetId": creator.target_id(), + "securityOrigin": creator.security_origin(), + "secureContextType": creator.secure_context_type(), + }) + }); json!({ - "browserContextId": browser_context_id, - "targetId": target_id, + "targetId": state.target_id(), + "loaderId": state.loader_id(), + "initialUrl": state.initial_url(), + "creator": creator, + "materialized": state.materialized(), + "exited": state.exited(), + "pendingCrossDocumentNavigation": state.pending_cross_document_navigation(), + "isOnInitialEmptyDocument": state.is_on_initial_empty_document(), }) }) .collect::>(); + browser_owner_initial_empty_documents + .sort_by(|left, right| left["targetId"].as_str().cmp(&right["targetId"].as_str())); let browser_context_count = self.browser_contexts().count(); let loaded_document_page_count = self .browser_contexts() @@ -3018,15 +2730,23 @@ impl CdpConnection { shared_worker_pending_service_lane_event_count += shared_worker_diagnostics.pending_service_lane_event_count; } - let retained_background_navigation_engine_count = - self.retained_background_navigation_engines.len(); - let active_renderer_owner_id = self.engine.renderer_owner_id_for_diagnostics(); + let retained_background_navigation_engine_count = self + .browser_host_state + .navigation_owner() + .retained_background_engine_count(); + let active_renderer_owner_id = self + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(); let mut retained_background_navigation_engine_renderer_owner_ids = HashSet::new(); let mut estimated_renderer_owner_ids = HashSet::new(); estimated_renderer_owner_ids.insert(active_renderer_owner_id); estimated_renderer_owner_ids.extend(document_renderer_owner_ids.iter().copied()); - for engine in self.retained_background_navigation_engines.values() { - let renderer_owner_id = engine.renderer_owner_id_for_diagnostics(); + for renderer_owner_id in self + .browser_host_state + .navigation_owner() + .retained_renderer_owner_ids_for_diagnostics() + { if renderer_owner_id != active_renderer_owner_id { retained_background_navigation_engine_renderer_owner_ids.insert(renderer_owner_id); } @@ -3035,10 +2755,16 @@ impl CdpConnection { let retained_background_navigation_engine_renderer_owner_count = retained_background_navigation_engine_renderer_owner_ids.len(); let estimated_renderer_owner_count = estimated_renderer_owner_ids.len(); - let document_isolate_model = self.engine.document_isolate_model_for_diagnostics(); + let document_isolate_model = self + .browser_host_state + .navigation_owner() + .active_document_isolate_model_for_diagnostics(); let estimated_document_isolate_count = loaded_document_page_count + pending_document_page_build_count; - let document_isolate_accounting = self.engine.document_isolate_accounting_for_diagnostics(); + let document_isolate_accounting = self + .browser_host_state + .navigation_owner() + .active_document_isolate_accounting_for_diagnostics(); let document_isolate_accounting = json!({ "scope": "renderer-process", "created": document_isolate_accounting.created, @@ -3051,8 +2777,9 @@ impl CdpConnection { let estimated_live_v8_isolate_count = estimated_document_isolate_count + estimated_worker_isolate_count; let active_navigation_engine_resource_runtime = self - .engine - .resource_request_client() + .browser_host_state + .navigation_owner() + .active_resource_request_client() .map(|client| client.resource_runtime_diagnostics()); let active_navigation_engine_resource_runtime_id = active_navigation_engine_resource_runtime @@ -3060,6 +2787,7 @@ impl CdpConnection { .map(|diagnostics| diagnostics.runtime_id); let active_navigation_engine_memory_cache = active_navigation_engine_resource_runtime.map(|diagnostics| diagnostics.memory_cache); + let browser_host_policy = self.browser_host_policy_snapshot(); json!({ "connection": { "hasActiveBrowserContext": self.browser_context.is_some(), @@ -3067,20 +2795,21 @@ impl CdpConnection { "browserSessionIdCount": self.browser_session_ids.len(), "globalIoStreamCount": self.global_io_streams.len(), "tracing": self.tracing_state.diagnostics(), - "permissionOverrideCount": self.permission_overrides.len(), + "permissionOverrideCount": browser_host_policy.permission_overrides().len(), "retainedBackgroundNavigationEngineCount": retained_background_navigation_engine_count, "retainedBackgroundNavigationEngineKeys": retained_engine_keys, + "browserOwnerInitialEmptyDocuments": browser_owner_initial_empty_documents, "autoAttach": self.auto_attach, "targetDiscoveryEnabled": self.target_discovery_enabled, "targetInfoChangeEventsEnabled": self.target_info_change_events_enabled, "activeNavigationEngine": { - "imageFetchEnabled": self.engine.image_fetch_enabled(), - "optionalResourceFetchMask": self.engine.optional_resource_fetch_mask().bits(), - "subframeLoadingEnabled": self.engine.subframe_loading_enabled(), + "imageFetchEnabled": self.browser_host_state.navigation_owner().active_image_fetch_enabled(), + "optionalResourceFetchMask": self.browser_host_state.navigation_owner().active_optional_resource_fetch_mask().bits(), + "subframeLoadingEnabled": self.browser_host_state.navigation_owner().active_subframe_loading_enabled(), "resourceRuntimeId": active_navigation_engine_resource_runtime_id, "networkMemoryCache": active_navigation_engine_memory_cache, - "browserContextRuntime": self.engine - .browser_context_runtime() + "browserContextRuntime": self.browser_host_state.navigation_owner() + .active_browser_context_runtime() .moli_memory_diagnostics(), }, }, @@ -3153,7 +2882,9 @@ impl CdpConnection { ( loaded_browser_context_count, live_target_browser_context_count, - self.retained_background_navigation_engines.len(), + self.browser_host_state + .navigation_owner() + .retained_background_engine_count(), ) } @@ -3178,8 +2909,24 @@ impl CdpConnection { }; } - let replacement = NavigationEngine::new_with_runtime_config(self.engine.runtime_config()); - self.replace_navigation_engine(replacement); + let replacement = NavigationEngine::new_with_runtime_config( + self.browser_host_state + .navigation_owner() + .active_runtime_config(), + ); + if let Err(error) = self.adopt_navigation_engine_for_current_owner(replacement) { + tracing::warn!( + %error, + "idle NavigationEngine replacement rejected by Browser Owner" + ); + return IdleNavigationEngineReleaseResult { + reset: false, + reason: "engine-owner-diverged", + loaded_browser_context_count, + live_target_browser_context_count, + retained_background_navigation_engine_count, + }; + } IdleNavigationEngineReleaseResult { reset: true, reason: "idle-engine-replaced", @@ -3218,12 +2965,13 @@ impl CdpConnection { } fn apply_global_browser_context_state(&self, browser_context: &mut BrowserContext) { + let policy = self.browser_host_network_policy_snapshot(); browser_context .network_policy - .set_cache_disabled(self.global_cache_disabled); - browser_context.global_extra_headers = self.global_extra_headers.clone(); - browser_context.global_network_conditions = self.global_network_conditions; - browser_context.global_geolocation_override = self.global_geolocation_override.clone(); + .set_cache_disabled(policy.global_cache_disabled()); + browser_context.global_extra_headers = policy.global_extra_headers().to_vec(); + browser_context.global_network_conditions = policy.global_network_conditions(); + browser_context.global_geolocation_override = policy.global_geolocation_override().cloned(); } pub(crate) fn new_ephemeral_browser_context(&self, id: String) -> BrowserContext { @@ -3244,39 +2992,21 @@ impl CdpConnection { .collect() } - pub fn snapshot_profile_backed_cookies(&mut self) -> Option> { - let mut saw_profile_backed_context = false; - let cookies = self - .browser_context - .iter() - .chain(self.inactive_browser_contexts.iter()) - .filter(|context| { - let is_profile_backed = context.is_profile_backed_storage_partition(); - saw_profile_backed_context |= is_profile_backed; - is_profile_backed - }) - .flat_map(BrowserContext::snapshot_cookies) - .collect(); - saw_profile_backed_context.then_some(cookies) - } - // ── ID generators ──────────────────────────────────────────────────────── pub fn gen_bc_id(&mut self) -> String { - self.next_bc_id = self - .next_bc_id - .checked_add(1) - .expect("browser context id space exhausted"); - format!("BID-{}", self.next_bc_id) + format!( + "BID-{}", + self.browser_host_state.allocate_browser_context_sequence() + ) } pub fn gen_user_browser_context_id(&mut self) -> String { loop { - self.next_bc_id = self - .next_bc_id - .checked_add(1) - .expect("browser context id space exhausted"); - let id = format!("user-context-{}", self.next_bc_id); + let id = format!( + "user-context-{}", + self.browser_host_state.allocate_browser_context_sequence() + ); if !self.has_browser_context_id(&id) { return id; } @@ -3295,14 +3025,18 @@ impl CdpConnection { DEFAULT_CDP_TAB_TARGET_ID } + pub(crate) fn derived_tab_target_id(page_target_id: &str) -> String { + format!("TAB-{page_target_id}") + } + pub(crate) fn register_top_level_page_target(&mut self, page_target_id: &str) -> String { let tab_target_id = if page_target_id == self.default_target_id() { self.default_tab_target_id().to_owned() } else { - self.gen_tab_target_id() + Self::derived_tab_target_id(page_target_id) }; self.target_control - .register_tab(tab_target_id.clone(), page_target_id.to_owned()); + .register_top_level_page(page_target_id.to_owned(), tab_target_id.clone()); for target_id in [&tab_target_id, page_target_id] { if let Some(target_info) = self.target_info_for_host_delta(target_id) { self.notify_target_host_lifecycle(CdpTargetHostLifecycleDelta::Created( @@ -3326,18 +3060,21 @@ impl CdpConnection { self.target_control.remove_worker(target_id) } - #[doc(hidden)] - pub fn tab_target_id_for_page_target_id(&self, page_target_id: &str) -> Option<&str> { + pub(crate) fn tab_target_id_for_page_target_id(&self, page_target_id: &str) -> Option<&str> { self.target_control .tab_target_id_for_page_target_id(page_target_id) } + pub(crate) fn page_target_id_for_tab_target_id(&self, tab_target_id: &str) -> Option<&str> { + self.target_control + .page_target_id_for_tab_target_id(tab_target_id) + } + pub(crate) fn primary_page_target_id_for_tab_target_id( &self, tab_target_id: &str, ) -> Option<&str> { - self.target_control - .primary_page_target_id_for_tab_target_id(tab_target_id) + self.page_target_id_for_tab_target_id(tab_target_id) } pub(crate) fn primary_session_id_for_tab_target_id(&self, tab_target_id: &str) -> Option<&str> { @@ -3359,13 +3096,13 @@ impl CdpConnection { self.target_control.remove_tab_session(session_id) } - pub(crate) fn remove_tab_for_page_target( + pub(crate) fn remove_top_level_page_target_by_page_target_id( &mut self, page_target_id: &str, ) -> Option { let closure_plan = self .target_control - .remove_tab_by_page_target_id(page_target_id)?; + .remove_top_level_page_by_page_target_id(page_target_id)?; for target_id in closure_plan.destroyed_target_ids() { self.notify_target_host_lifecycle(CdpTargetHostLifecycleDelta::Destroyed { target_id: target_id.to_owned(), @@ -3379,7 +3116,9 @@ impl CdpConnection { page_target_id: &str, reason: Option<&str>, ) -> TargetEventPlan { - let Some(closure_plan) = self.remove_tab_for_page_target(page_target_id) else { + let Some(closure_plan) = + self.remove_top_level_page_target_by_page_target_id(page_target_id) + else { return TargetEventPlan::default(); }; debug_assert!( @@ -3387,9 +3126,9 @@ impl CdpConnection { .destroyed_target_ids() .any(|target_id| target_id == page_target_id) ); - let target = closure_plan.tab_target(); - let tab_target_id = target.id().to_owned(); - let tab_session_ids = target.session_ids(); + let target = closure_plan.top_level_target(); + let tab_target_id = target.tab_target_id().to_owned(); + let tab_session_ids = target.tab_session_ids(); self.detach_target_closure_cleanup_event_plan( TargetClosureCleanupPlan::new(tab_target_id, reason, tab_session_ids), None, @@ -3400,10 +3139,12 @@ impl CdpConnection { &mut self, page_target_id: &str, ) { - let Some(closure_plan) = self.remove_tab_for_page_target(page_target_id) else { + let Some(closure_plan) = + self.remove_top_level_page_target_by_page_target_id(page_target_id) + else { return; }; - for session_id in closure_plan.tab_target().session_ids() { + for session_id in closure_plan.top_level_target().tab_session_ids() { self.clear_auto_attach_owner(Some(&session_id)); self.rollback_attached_session_without_event(&session_id); } @@ -3446,6 +3187,11 @@ impl CdpConnection { self.tab_target_info_for_page_target_info(&page_target_info) } + #[cfg(test)] + pub(crate) fn tab_target_count(&self) -> usize { + self.target_control.len() + } + pub(crate) fn set_target_discovery_for_owner( &mut self, owner_session_id: Option<&str>, @@ -3549,49 +3295,6 @@ impl CdpConnection { self.target_host_delta_events(deltas) } - pub(crate) fn target_info_changed_event_plan_for_observable_target( - &mut self, - browser_context_id: &str, - target_id: &str, - ) -> TargetEventPlan { - let Some(target_info) = self - .browser_context_by_id(browser_context_id) - .and_then(|browser_context| browser_context.devtools_target_info(target_id)) - else { - return TargetEventPlan::default(); - }; - let tab_target_info = self.tab_target_info_for_page_target_info(&target_info); - self.notify_target_host_lifecycle(CdpTargetHostLifecycleDelta::InfoChanged(target_info)); - if let Some(tab_target_info) = tab_target_info { - self.notify_target_host_lifecycle(CdpTargetHostLifecycleDelta::InfoChanged( - tab_target_info, - )); - } - if !self.has_any_target_info_observer() { - return TargetEventPlan::default(); - } - self.exact_target_info_changed_event_plan_for_target_delta(target_id) - } - - pub(crate) fn target_info_changed_event_plan_for_session_owner( - &mut self, - session_id: Option<&str>, - ) -> TargetEventPlan { - let Some((browser_context_id, Some(target_id))) = - self.target_owner_identity_for_session(session_id) - else { - return TargetEventPlan::default(); - }; - self.target_info_changed_event_plan_for_observable_target(&browser_context_id, &target_id) - } - - pub(crate) fn exact_target_info_changed_event_plan_for_target_delta( - &mut self, - target_id: &str, - ) -> TargetEventPlan { - self.target_host_delta_events([TargetHostDelta::info_changed(target_id.to_owned())]) - } - fn target_host_delta_events( &mut self, deltas: impl IntoIterator, @@ -3637,6 +3340,14 @@ impl CdpConnection { self.prepared_target_host_delta_events(prepared_deltas) } + pub(crate) fn prepared_top_level_target_host_deltas_event_plan( + &mut self, + prepared_deltas: impl IntoIterator, + ) -> TargetEventPlan { + self.prepared_target_host_delta_events(prepared_deltas) + .without_target_lifecycle_automation_sidecars() + } + pub(crate) fn prepare_destroyed_target_host_delta( &self, target_id: &str, @@ -3753,21 +3464,19 @@ impl CdpConnection { &self, target_info: DevToolsTargetInfo, ) -> Vec { - target_destroyed_automation_events( - self.project_page_tab_target_infos_for_destruction(target_info), - ) + target_destroyed_automation_events(self.project_tab_page_target_infos(target_info)) } - fn project_page_tab_target_infos_for_destruction( + fn project_tab_page_target_infos( &self, target_info: DevToolsTargetInfo, ) -> Vec { self.target_control - .project_page_tab_target_infos_for_destruction(target_info) + .project_tab_page_target_infos(target_info) } #[cfg(test)] - pub(crate) fn tab_target_count(&self) -> usize { + pub(crate) fn top_level_target_graph_len(&self) -> usize { self.target_control.len() } @@ -3777,16 +3486,32 @@ impl CdpConnection { } pub fn install_default_browser_target(&mut self) { - if self.browser_context.is_some() || !self.inactive_browser_contexts.is_empty() { + self.debug_assert_browser_context_topology_projection(); + if self.registered_browser_context_count() != 0 { return; } let mut browser_context = self.new_browser_context(self.default_browser_context_id().to_owned()); - browser_context.set_active_target_id(self.default_target_id().to_owned()); + browser_context.stage_active_target_for_browser_context_registration( + self.default_target_id().to_owned(), + ); browser_context.set_target_url("about:blank".to_owned()); - browser_context.begin_active_target_initial_empty_document("about:blank".to_owned()); - self.insert_browser_context(browser_context); + browser_context.mark_active_initial_document_page_build_pending(); + let registration_metadata = BrowserContextRegistrationMetadata::with_active_target_creation( + BrowserTargetCreationMetadata::with_initial_empty_document( + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ), + ); + if let Err(error) = + self.try_insert_browser_context_with_metadata(browser_context, registration_metadata) + { + tracing::warn!( + error = %error, + "default BrowserContext registration projection was rejected" + ); + return; + } let default_target_id = self.default_target_id().to_owned(); self.register_top_level_page_target(&default_target_id); self.notify_target_host_activated(&default_target_id); @@ -3804,61 +3529,28 @@ impl CdpConnection { pub fn gen_target_id(&mut self) -> String { loop { - let id = if let Some(allocator) = self.shared_target_id_allocator.as_ref() { - allocator - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { - current.checked_add(1) - }) - .expect("shared target id space exhausted") - + 1 - } else { - self.next_target_id = self - .next_target_id - .checked_add(1) - .expect("target id space exhausted"); - u64::from(self.next_target_id) - }; + let id = self.browser_host_state.allocate_target_sequence(); let target_id = format!("TID-{id}"); // Target ids supplied while restoring or embedding an existing // target share the same CDP namespace as ids allocated here. // Never let a later worker/page allocation alias such a target: // looking it up would otherwise return the pre-existing target's // kind and state even though the renderer record names a worker. - let target_id_is_live = self.target_control.host_kind(&target_id).is_some() - || self - .browser_context - .iter() - .chain(self.inactive_browser_contexts.iter()) - .any(|context| context.devtools_target_info(&target_id).is_some()); + let target_id_is_live = self + .browser_host_state + .navigation_owner() + .has_target(&target_id) + || self.target_control.host_kind(&target_id).is_some() + || self.browser_contexts().any(|context| { + context.has_shared_worker_target(&target_id) + || context.has_service_worker_target(&target_id) + }); if !target_id_is_live { return target_id; } } } - fn gen_tab_target_id(&mut self) -> String { - loop { - let id = if let Some(allocator) = self.shared_tab_target_id_allocator.as_ref() { - allocator - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { - current.checked_add(1) - }) - .expect("shared tab target id space exhausted") - + 1 - } else { - self.next_tab_target_id = self - .next_tab_target_id - .checked_add(1) - .expect("tab target id space exhausted"); - u64::from(self.next_tab_target_id) - }; - let target_id = format!("TAB-{id}"); - if self.target_control.host_kind(&target_id).is_none() { - return target_id; - } - } - } - pub fn gen_session_id(&mut self) -> String { loop { self.next_session_id = self @@ -3941,138 +3633,63 @@ impl Drop for NoneSessionOwnerRouteOverrideScope<'_> { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BrowserDownloadBehavior { - pub behavior: String, - pub download_path: Option, - pub automation_events_enabled: bool, +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct BrowserDownloadEventSubscriptions { pub webdriver_bidi_events_enabled: bool, - pub browser_context_id: Option, - pub browser_context_overrides: HashMap, + automation_events_enabled: bool, + browser_context_automation_events_enabled: HashMap, browser_event_subscription_generations: HashMap, u64>, next_browser_event_subscription_generation: u64, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BrowserDownloadBehaviorSettings { - pub behavior: String, - pub download_path: Option, - pub automation_events_enabled: bool, -} - -impl Default for BrowserDownloadBehaviorSettings { - fn default() -> Self { - Self { - behavior: "default".to_owned(), - download_path: None, - automation_events_enabled: false, - } - } -} - -impl Default for BrowserDownloadBehavior { - fn default() -> Self { - let default_settings = BrowserDownloadBehaviorSettings::default(); - Self { - behavior: default_settings.behavior, - download_path: default_settings.download_path, - automation_events_enabled: default_settings.automation_events_enabled, - webdriver_bidi_events_enabled: false, - browser_context_id: None, - browser_context_overrides: HashMap::new(), - browser_event_subscription_generations: HashMap::new(), - next_browser_event_subscription_generation: 0, - } - } -} - -impl BrowserDownloadBehavior { - pub(crate) fn set_global( - &mut self, - behavior: String, - download_path: Option, - automation_events_enabled: bool, - ) { - self.behavior = behavior; - self.download_path = download_path; - self.automation_events_enabled = automation_events_enabled; - self.browser_context_id = None; - } - - pub(crate) fn set_global_policy(&mut self, behavior: String, download_path: Option) { - self.behavior = behavior; - self.download_path = download_path; - self.browser_context_id = None; +impl BrowserDownloadEventSubscriptions { + pub(crate) fn enable_webdriver_bidi_events(&mut self) -> bool { + let changed = !self.webdriver_bidi_events_enabled; + self.webdriver_bidi_events_enabled = true; + changed } - pub(crate) fn set_browser_context( - &mut self, - browser_context_id: String, - behavior: String, - download_path: Option, - automation_events_enabled: bool, - ) { - self.browser_context_overrides.insert( - browser_context_id.clone(), - BrowserDownloadBehaviorSettings { - behavior: behavior.clone(), - download_path: download_path.clone(), - automation_events_enabled, - }, - ); - self.browser_context_id = Some(browser_context_id); + pub(crate) fn disable_webdriver_bidi_events(&mut self) -> bool { + let changed = self.webdriver_bidi_events_enabled; + self.webdriver_bidi_events_enabled = false; + changed } - pub(crate) fn set_browser_context_policy( + fn set_automation_events_enabled_for_browser_context( &mut self, - browser_context_id: String, - behavior: String, - download_path: Option, + browser_context_id: Option<&str>, + enabled: bool, ) { - let automation_events_enabled = self - .browser_context_overrides - .get(&browser_context_id) - .is_some_and(|settings| settings.automation_events_enabled); - self.browser_context_overrides.insert( - browser_context_id.clone(), - BrowserDownloadBehaviorSettings { - behavior, - download_path, - automation_events_enabled, - }, - ); - self.browser_context_id = Some(browser_context_id); - } - - pub(crate) fn reset_global(&mut self) { - let default = BrowserDownloadBehaviorSettings::default(); - self.behavior = default.behavior; - self.download_path = default.download_path; - self.automation_events_enabled = default.automation_events_enabled; - self.browser_context_id = None; - } - - pub(crate) fn reset_browser_context(&mut self, browser_context_id: &str) { - self.browser_context_overrides.remove(browser_context_id); - if self.browser_context_id.as_deref() == Some(browser_context_id) { - self.browser_context_id = None; + if let Some(browser_context_id) = browser_context_id { + self.browser_context_automation_events_enabled + .insert(browser_context_id.to_owned(), enabled); + } else { + self.automation_events_enabled = enabled; } } - pub(crate) fn enable_webdriver_bidi_events(&mut self) -> bool { - let changed = !self.webdriver_bidi_events_enabled; - self.webdriver_bidi_events_enabled = true; - changed + fn ensure_automation_event_override_for_browser_context(&mut self, browser_context_id: &str) { + self.browser_context_automation_events_enabled + .entry(browser_context_id.to_owned()) + .or_insert(false); } - pub(crate) fn disable_webdriver_bidi_events(&mut self) -> bool { - let changed = self.webdriver_bidi_events_enabled; - self.webdriver_bidi_events_enabled = false; - changed + fn clear_automation_events_for_browser_context(&mut self, browser_context_id: &str) { + self.browser_context_automation_events_enabled + .remove(browser_context_id); } - pub(crate) fn clear_browser_context(&mut self, browser_context_id: &str) { - self.reset_browser_context(browser_context_id); + fn automation_events_enabled_for_browser_context( + &self, + browser_context_id: Option<&str>, + ) -> bool { + browser_context_id + .and_then(|browser_context_id| { + self.browser_context_automation_events_enabled + .get(browser_context_id) + }) + .copied() + .unwrap_or(self.automation_events_enabled) } pub(crate) fn set_browser_events_enabled_for_session( @@ -4104,7 +3721,7 @@ impl BrowserDownloadBehavior { } #[cfg(test)] - pub(crate) fn browser_event_session_ids(&self) -> Vec> { + fn browser_event_session_ids(&self) -> Vec> { self.browser_event_observers() .into_iter() .map(|(session_id, _)| session_id) @@ -4120,32 +3737,9 @@ impl BrowserDownloadBehavior { .get(&session_id.map(str::to_owned)) .is_some_and(|current| *current == generation) } - - pub(crate) fn effective_for_browser_context( - &self, - browser_context_id: Option<&str>, - ) -> BrowserDownloadBehaviorSettings { - if let Some(browser_context_id) = browser_context_id - && let Some(settings) = self.browser_context_overrides.get(browser_context_id) - { - return settings.clone(); - } - BrowserDownloadBehaviorSettings { - behavior: self.behavior.clone(), - download_path: self.download_path.clone(), - automation_events_enabled: self.automation_events_enabled, - } - } } -#[derive(Debug, Clone, PartialEq)] -pub struct PermissionOverride { - pub permission: serde_json::Value, - pub setting: String, - pub origin: Option, - pub embedded_origin: Option, - pub browser_context_id: Option, -} +pub type PermissionOverride = BrowserPermissionOverride; #[cfg(test)] mod tests; diff --git a/moli-protocol/src/conn/activity_source.rs b/moli-protocol/src/conn/activity_source.rs index f302d30543..63654975db 100644 --- a/moli-protocol/src/conn/activity_source.rs +++ b/moli-protocol/src/conn/activity_source.rs @@ -1,7 +1,4 @@ -use moli_core::{ - page::{CompletedPageCommand, PendingPageCommand, RendererPageDiagnosticsSnapshot}, - runtime::NavigationEngine, -}; +use moli_core::page::{CompletedPageCommand, PendingPageCommand, RendererPageDiagnosticsSnapshot}; use std::time::Instant; use super::{BrowserContext, CdpConnection, CdpSessionRoute, TargetRuntimeSlot}; @@ -46,146 +43,62 @@ fn browser_context_by_id_mut_from_parts<'a>( .find(|bc| bc.id == browser_context_id) } -fn runtime_slot_for_route_mut_from_parts<'a>( - browser_context: &'a mut Option, - inactive_browser_contexts: &'a mut [BrowserContext], - route: &CdpSessionRoute, -) -> Option<&'a mut TargetRuntimeSlot> { - let context = match route { - CdpSessionRoute::Browser => browser_context.as_mut()?, - CdpSessionRoute::ActiveTarget { - browser_context_id, .. - } - | CdpSessionRoute::AuxiliaryTarget { - browser_context_id, .. - } - | CdpSessionRoute::BackgroundTarget { - browser_context_id, .. - } => browser_context_by_id_mut_from_parts( - browser_context, - inactive_browser_contexts, - browser_context_id, - )?, - CdpSessionRoute::TabTarget { .. } - | CdpSessionRoute::SharedWorkerTarget { .. } - | CdpSessionRoute::DedicatedWorkerTarget { .. } - | CdpSessionRoute::ServiceWorkerTarget { .. } => return None, - }; - match route { - CdpSessionRoute::AuxiliaryTarget { target_id, .. } => { - if let Some(index) = context - .background_targets - .iter() - .position(|target| target.is_target(target_id)) - { - return Some(&mut context.background_targets[index].runtime_slot); - } - Some(&mut context.active_target.runtime_slot) - } - CdpSessionRoute::BackgroundTarget { target_id, .. } => { - Some(&mut context.background_target_mut(target_id)?.runtime_slot) - } - CdpSessionRoute::Browser | CdpSessionRoute::ActiveTarget { .. } => { - Some(&mut context.active_target.runtime_slot) - } - CdpSessionRoute::TabTarget { .. } - | CdpSessionRoute::SharedWorkerTarget { .. } - | CdpSessionRoute::DedicatedWorkerTarget { .. } - | CdpSessionRoute::ServiceWorkerTarget { .. } => None, - } -} - impl CdpConnection { - fn activity_source_engine_and_runtime_slot_mut( + fn activity_source_runtime_slot_mut( &mut self, session_id: Option<&str>, - ) -> Option<(&mut NavigationEngine, &mut TargetRuntimeSlot)> { + ) -> Option<&mut TargetRuntimeSlot> { let route = match session_id { Some(_) => self.session_route(session_id)?, None => self .none_session_owner_route_override() .unwrap_or(CdpSessionRoute::Browser), }; - let primary_engine = match &route { - CdpSessionRoute::Browser => true, + let browser_context = match route { + CdpSessionRoute::Browser => self.browser_context.as_mut()?, CdpSessionRoute::ActiveTarget { - browser_context_id, - target_id, - } => self.browser_context.as_ref().is_some_and(|context| { - context.id == *browser_context_id - && target_id - .as_deref() - .is_none_or(|target_id| context.active_target_id() == Some(target_id)) - }), - CdpSessionRoute::AuxiliaryTarget { - browser_context_id, - target_id, - } => self.browser_context.as_ref().is_some_and(|context| { - context.id == *browser_context_id - && context.background_target(target_id).is_none() - && context.active_target_id() == Some(target_id) - }), - CdpSessionRoute::BackgroundTarget { .. } - | CdpSessionRoute::TabTarget { .. } - | CdpSessionRoute::SharedWorkerTarget { .. } - | CdpSessionRoute::DedicatedWorkerTarget { .. } - | CdpSessionRoute::ServiceWorkerTarget { .. } => false, - }; - if primary_engine { - let CdpConnection { - engine, - browser_context, - inactive_browser_contexts, - .. - } = self; - let slot = runtime_slot_for_route_mut_from_parts( - browser_context, - inactive_browser_contexts, - &route, - )?; - return Some((engine, slot)); - } - - let (browser_context_id, target_id) = match &route { - CdpSessionRoute::ActiveTarget { - browser_context_id, - target_id, - } => ( - browser_context_id.clone(), - target_id.clone().or_else(|| { - self.browser_context_by_id(browser_context_id) - .and_then(BrowserContext::active_target_id) - .map(str::to_owned) - })?, - ), + browser_context_id, .. + } => browser_context_by_id_mut_from_parts( + &mut self.browser_context, + &mut self.inactive_browser_contexts, + &browser_context_id, + )?, CdpSessionRoute::AuxiliaryTarget { browser_context_id, target_id, + } => { + let browser_context = browser_context_by_id_mut_from_parts( + &mut self.browser_context, + &mut self.inactive_browser_contexts, + &browser_context_id, + )?; + if let Some(index) = browser_context + .background_targets + .iter() + .position(|target| target.is_target(&target_id)) + { + return Some(&mut browser_context.background_targets[index].runtime_slot); + } + browser_context } - | CdpSessionRoute::BackgroundTarget { + CdpSessionRoute::BackgroundTarget { browser_context_id, target_id, - } => (browser_context_id.clone(), target_id.clone()), - CdpSessionRoute::Browser - | CdpSessionRoute::TabTarget { .. } + } => { + let browser_context = browser_context_by_id_mut_from_parts( + &mut self.browser_context, + &mut self.inactive_browser_contexts, + &browser_context_id, + )?; + let target = browser_context.background_target_mut(&target_id)?; + return Some(&mut target.runtime_slot); + } + CdpSessionRoute::TabTarget { .. } | CdpSessionRoute::SharedWorkerTarget { .. } | CdpSessionRoute::DedicatedWorkerTarget { .. } | CdpSessionRoute::ServiceWorkerTarget { .. } => return None, }; - let CdpConnection { - retained_background_navigation_engines, - browser_context, - inactive_browser_contexts, - .. - } = self; - let engine = - retained_background_navigation_engines.get_mut(&(browser_context_id, target_id))?; - let slot = runtime_slot_for_route_mut_from_parts( - browser_context, - inactive_browser_contexts, - &route, - )?; - Some((engine, slot)) + Some(&mut browser_context.active_target.runtime_slot) } pub(crate) fn start_child_frame_lifecycle_work_for_session_owner( @@ -196,17 +109,17 @@ impl CdpConnection { let storage = self .navigation_load_inputs_for_session_owner(session_id) .resource_storage_handles(); - let Some((engine, slot)) = self.activity_source_engine_and_runtime_slot_mut(session_id) - else { + let browser_host_state = self.browser_host_state.clone(); + let Some(slot) = self.activity_source_runtime_slot_mut(session_id) else { return Err("NoDocumentLoaded".to_owned()); }; let Some(page) = slot.loaded_page() else { return Err("NoDocumentLoaded".to_owned()); }; - let pending = engine - .start_page_child_frame_lifecycle_work_with_storage_best_effort( + let pending = browser_host_state + .start_active_page_child_frame_lifecycle_work( storage.into_navigation_storage(), - page, + &page, timeout, ) .map_err(|error| error.to_string())?; @@ -221,15 +134,15 @@ impl CdpConnection { pending: CompletedChildFrameLifecycleWork, ) -> Result<(bool, moli_core::page::RendererCommandTurnOutput), String> { let session_id = pending.session_id.as_deref(); - let Some((engine, slot)) = self.activity_source_engine_and_runtime_slot_mut(session_id) - else { + let browser_host_state = self.browser_host_state.clone(); + let Some(slot) = self.activity_source_runtime_slot_mut(session_id) else { return Err("NoDocumentLoaded".to_owned()); }; - let Some(page) = slot.loaded_page_mut() else { + let Some(mut page) = slot.loaded_page_mut() else { return Err("NoDocumentLoaded".to_owned()); }; - let completed = engine - .complete_page_child_frame_lifecycle_work_best_effort(page, pending.completion) + let completed = browser_host_state + .complete_active_page_child_frame_lifecycle_work(&mut page, pending.completion) .map_err(|error| error.to_string())?; let _ = slot.ingest_owner_page_observable_output_updates(); Ok(completed) @@ -254,8 +167,7 @@ impl CdpConnection { session_id, trace_started, ); - let Some((_engine, slot)) = self.activity_source_engine_and_runtime_slot_mut(session_id) - else { + let Some(slot) = self.activity_source_runtime_slot_mut(session_id) else { trace_activity_source_stage( "conn_page_diagnostics_snapshot_missing_owner", session_id, @@ -263,7 +175,7 @@ impl CdpConnection { ); return Ok(RendererPageDiagnosticsSnapshot::default()); }; - let Some(page) = slot.loaded_page_mut() else { + let Some(mut page) = slot.loaded_page_mut() else { trace_activity_source_stage( "conn_page_diagnostics_snapshot_missing_page", session_id, diff --git a/moli-protocol/src/conn/background_output.rs b/moli-protocol/src/conn/background_output.rs new file mode 100644 index 0000000000..ae5cfe11cf --- /dev/null +++ b/moli-protocol/src/conn/background_output.rs @@ -0,0 +1,165 @@ +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, error::TryRecvError}; + +use crate::domains::command_output::BrowserNavigateCommandOutcomeDelivery; + +use super::BackgroundProtocolEvent; + +/// Producer endpoint for the migration-period Browser/frontend output FIFO. +/// +/// Existing Browser facts still enter as already projected protocol events. +/// Navigation command completion enters as a protocol-neutral outcome and is +/// projected only when the frontend consumes this FIFO. Both use one physical +/// channel so response-head Network events retain their exact ordering around +/// the early `Page.navigate` response. +#[derive(Clone, Debug)] +pub struct BackgroundEventSender { + sender: UnboundedSender, +} + +/// Consumer endpoint that projects frontend-specific output without owning or +/// advancing Browser execution. +#[derive(Debug)] +pub struct BrowserBackgroundOutputReceiver { + receiver: UnboundedReceiver, +} + +#[derive(Debug)] +enum BrowserBackgroundOutput { + ProtocolEvent(BackgroundProtocolEvent), + NavigateCommandOutcome(BrowserNavigateCommandOutcomeDelivery), +} + +/// The frontend output queue was closed before an item could be admitted. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BackgroundOutputClosed; + +pub fn browser_background_output_channel() +-> (BackgroundEventSender, BrowserBackgroundOutputReceiver) { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + ( + BackgroundEventSender { sender }, + BrowserBackgroundOutputReceiver { receiver }, + ) +} + +impl BackgroundEventSender { + pub fn send(&self, event: BackgroundProtocolEvent) -> Result<(), BackgroundOutputClosed> { + self.sender + .send(BrowserBackgroundOutput::ProtocolEvent(event)) + .map_err(|_| BackgroundOutputClosed) + } + + pub(crate) fn send_browser_navigate_outcome( + &self, + delivery: BrowserNavigateCommandOutcomeDelivery, + ) -> Result<(), BackgroundOutputClosed> { + self.sender + .send(BrowserBackgroundOutput::NavigateCommandOutcome(delivery)) + .map_err(|_| BackgroundOutputClosed) + } + + pub fn is_closed(&self) -> bool { + self.sender.is_closed() + } +} + +impl BrowserBackgroundOutputReceiver { + pub async fn recv(&mut self) -> Option { + self.receiver + .recv() + .await + .map(BrowserBackgroundOutput::into_protocol_event) + } + + pub fn try_recv(&mut self) -> Result { + self.receiver + .try_recv() + .map(BrowserBackgroundOutput::into_protocol_event) + } +} + +impl BrowserBackgroundOutput { + fn into_protocol_event(self) -> BackgroundProtocolEvent { + match self { + Self::ProtocolEvent(event) => event, + Self::NavigateCommandOutcome(delivery) => delivery.into_background_protocol_event(), + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn navigate_outcome_retains_fifo_position_between_protocol_events() { + let (sender, mut receiver) = browser_background_output_channel(); + sender + .send(BackgroundProtocolEvent::immediate(json!({ + "method": "Network.beforeNavigateOutcome", + "params": {"sequence": 1} + }))) + .expect("prefix event should enter output FIFO"); + sender + .send_browser_navigate_outcome(BrowserNavigateCommandOutcomeDelivery::completed( + 42, + Some("SID-nav"), + "https://example.test/", + json!({"frameId": "FRAME-1", "loaderId": "LOADER-1"}), + )) + .expect("navigate outcome should enter output FIFO"); + sender + .send(BackgroundProtocolEvent::immediate(json!({ + "method": "Network.afterNavigateOutcome", + "params": {"sequence": 2} + }))) + .expect("suffix event should enter output FIFO"); + + let outputs = (0..3) + .map(|_| { + receiver + .receiver + .try_recv() + .expect("queued output should remain available") + }) + .collect::>(); + assert!(matches!( + &outputs[0], + BrowserBackgroundOutput::ProtocolEvent(_) + )); + assert!(matches!( + &outputs[1], + BrowserBackgroundOutput::NavigateCommandOutcome(_) + )); + assert!(matches!( + &outputs[2], + BrowserBackgroundOutput::ProtocolEvent(_) + )); + + let messages = outputs + .into_iter() + .map(BrowserBackgroundOutput::into_protocol_event) + .map(BackgroundProtocolEvent::into_protocol_message) + .collect::>(); + assert_eq!( + messages, + vec![ + json!({ + "method": "Network.beforeNavigateOutcome", + "params": {"sequence": 1} + }), + json!({ + "id": 42, + "result": {"frameId": "FRAME-1", "loaderId": "LOADER-1"}, + "sessionId": "SID-nav" + }), + json!({ + "method": "Network.afterNavigateOutcome", + "params": {"sequence": 2} + }), + ] + ); + } +} diff --git a/moli-protocol/src/conn/bidi_channel_work.rs b/moli-protocol/src/conn/bidi_channel_work.rs index 1ac09e83ac..26797a7766 100644 --- a/moli-protocol/src/conn/bidi_channel_work.rs +++ b/moli-protocol/src/conn/bidi_channel_work.rs @@ -22,7 +22,7 @@ impl BidiChannelPageOwner { /// /// Callers using the implicit `None` session must invoke this while the /// target's owner-route override is installed. `owner_scope` freezes that - /// route, while `attachment` freezes the target Page residence and exact + /// route, while `attachment` freezes the target Page generation and exact /// protocol session. pub(crate) fn capture(conn: &CdpConnection, session_id: Option<&str>) -> Option { Some(Self { @@ -35,10 +35,6 @@ impl BidiChannelPageOwner { self.attachment.session_id() } - pub(crate) fn target_id(&self) -> Option<&str> { - self.attachment.page_owner().target_id() - } - pub(crate) fn enter<'a>( &self, conn: &'a mut CdpConnection, @@ -172,23 +168,21 @@ mod tests { let mut browser_context = BrowserContext::new("BID-owner".to_owned()); browser_context.set_active_target_id("TID-owner"); browser_context.attach_active_session("SID-owner".to_owned()); - conn.browser_context = Some(browser_context); - conn.runtime_session_owner_slot_mut(Some("SID-owner")) - .expect("test runtime slot") - .set_page_attachment_id_for_test(1); + conn.insert_browser_context(browser_context); conn } #[test] - fn page_owner_rejects_replacement_attachment() { + fn page_owner_rejects_replacement_generation() { let mut conn = connection_with_page_session(); let owner = BidiChannelPageOwner::capture(&conn, Some("SID-owner")).expect("test Page attachment"); assert!(owner.is_current(&conn)); + let next_generation = owner.attachment.page_owner().loaded_page_generation() + 1; conn.runtime_session_owner_slot_mut(Some("SID-owner")) .expect("test runtime slot") - .replace_page_attachment_id_for_test(); + .set_loaded_page_generation(next_generation); assert!( !owner.is_current(&conn), diff --git a/moli-protocol/src/conn/body_spool.rs b/moli-protocol/src/conn/body_spool.rs index a414a9f395..2adf1a53e6 100644 --- a/moli-protocol/src/conn/body_spool.rs +++ b/moli-protocol/src/conn/body_spool.rs @@ -1,587 +1,5 @@ -use std::{ - fs::{self, File, OpenOptions}, - io::{self, Read, Seek, SeekFrom, Write}, - path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, - time::{SystemTime, UNIX_EPOCH}, +pub(crate) use moli_core::browser_host::{ + BrowserNetworkBody as CapturedBody, BrowserNetworkBodyWriter as CapturedBodyWriter, + DEFAULT_BROWSER_NETWORK_BODY_MATERIALIZE_LIMIT as DEFAULT_BODY_MATERIALIZE_LIMIT, + ensure_browser_network_body_materialize_limit as ensure_materialize_limit, }; - -#[cfg(unix)] -use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; - -use anyhow::{Context, Result, anyhow}; - -const DEFAULT_MEMORY_LIMIT: usize = 1024 * 1024; -pub(crate) const DEFAULT_BODY_MATERIALIZE_LIMIT: usize = 64 * 1024 * 1024; - -static NEXT_SPOOL_ID: AtomicU64 = AtomicU64::new(1); - -#[derive(Debug, Clone)] -pub struct CapturedBody { - inner: Arc, -} - -#[derive(Debug)] -enum CapturedBodyInner { - Memory(Vec), - SharedMemory(Arc<[u8]>), - File { path: PathBuf, len: usize }, - Subresource(moli_core::page::SubresourceResponseBody), -} - -impl Drop for CapturedBodyInner { - fn drop(&mut self) { - if let Self::File { path, .. } = self { - let _ = fs::remove_file(path); - } - } -} - -impl CapturedBody { - pub(crate) fn from_string(body: String) -> Self { - Self::from_bytes(body.into_bytes()) - } - - pub(crate) fn from_bytes(body: Vec) -> Self { - Self { - inner: Arc::new(CapturedBodyInner::Memory(body)), - } - } - - pub(crate) fn from_shared_bytes(body: Arc<[u8]>) -> Self { - Self { - inner: Arc::new(CapturedBodyInner::SharedMemory(body)), - } - } - - pub(crate) fn from_bytes_spooled(body: Vec) -> Self { - if body.len() <= DEFAULT_MEMORY_LIMIT { - return Self::from_bytes(body); - } - let mut writer = CapturedBodyWriter::default(); - if writer.append(&body).is_ok() - && let Ok(captured) = writer.finish() - { - return captured; - } - Self::from_bytes(body) - } - - pub(crate) fn from_optional_renderer_synthetic_response_body( - body: Option, - ) -> Self { - Self::from_bytes(body.map(|body| body.into_body_bytes()).unwrap_or_default()) - } - - pub(crate) fn from_subresource_response_body( - body: &moli_core::page::SubresourceResponseBody, - ) -> Self { - Self { - inner: Arc::new(CapturedBodyInner::Subresource(body.clone())), - } - } - - pub(crate) fn len(&self) -> usize { - match self.inner.as_ref() { - CapturedBodyInner::Memory(bytes) => bytes.len(), - CapturedBodyInner::SharedMemory(bytes) => bytes.len(), - CapturedBodyInner::File { len, .. } => *len, - CapturedBodyInner::Subresource(body) => body.len(), - } - } - - pub(crate) fn materialize_bytes_limited(&self, limit: usize) -> Result> { - ensure_materialize_limit(self.len(), limit)?; - self.materialize_bytes() - } - - pub(crate) fn materialize_bytes(&self) -> Result> { - match self.inner.as_ref() { - CapturedBodyInner::Memory(bytes) => Ok(bytes.clone()), - CapturedBodyInner::SharedMemory(bytes) => Ok(bytes.to_vec()), - CapturedBodyInner::File { path, .. } => { - let mut file = File::open(path).with_context(|| { - anyhow!("failed to open captured response body `{}`", path.display()) - })?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes).with_context(|| { - anyhow!("failed to read captured response body `{}`", path.display()) - })?; - Ok(bytes) - } - CapturedBodyInner::Subresource(body) => body - .materialize_bytes() - .with_context(|| "failed to materialize captured subresource response body"), - } - } - - pub(crate) fn read_range(&self, offset: usize, len: usize) -> Result> { - if len == 0 || offset >= self.len() { - return Ok(Vec::new()); - } - let len = len.min(self.len().saturating_sub(offset)); - match self.inner.as_ref() { - CapturedBodyInner::Memory(bytes) => { - Ok(bytes[offset..offset.saturating_add(len)].to_vec()) - } - CapturedBodyInner::SharedMemory(bytes) => { - Ok(bytes[offset..offset.saturating_add(len)].to_vec()) - } - CapturedBodyInner::File { path, .. } => { - let mut file = File::open(path).with_context(|| { - anyhow!("failed to open captured response body `{}`", path.display()) - })?; - file.seek(SeekFrom::Start(offset as u64)).with_context(|| { - anyhow!("failed to seek captured response body `{}`", path.display()) - })?; - let mut bytes = vec![0; len]; - let read = file.read(&mut bytes).with_context(|| { - anyhow!("failed to read captured response body `{}`", path.display()) - })?; - bytes.truncate(read); - Ok(bytes) - } - CapturedBodyInner::Subresource(body) => body - .read_chunk(offset, len) - .with_context(|| "failed to read captured subresource response body"), - } - } - - #[cfg(test)] - pub(crate) fn materialize_lossy_string(&self) -> Result { - self.materialize_bytes() - .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) - } - - pub(crate) fn chunk_reader(&self, chunk_size: usize) -> Result { - if chunk_size == 0 { - return Err(anyhow!( - "captured response body chunk size must be non-zero" - )); - } - match self.inner.as_ref() { - CapturedBodyInner::Memory(_) | CapturedBodyInner::SharedMemory(_) => { - Ok(CapturedBodyChunkReader { - source: CapturedBodyChunkReaderSource::Memory { - body: self.clone(), - offset: 0, - }, - chunk_size, - }) - } - CapturedBodyInner::File { path, .. } => { - let file = File::open(path).with_context(|| { - anyhow!("failed to open captured response body `{}`", path.display()) - })?; - Ok(CapturedBodyChunkReader { - source: CapturedBodyChunkReaderSource::File { - file, - path: path.clone(), - }, - chunk_size, - }) - } - CapturedBodyInner::Subresource(_) => Ok(CapturedBodyChunkReader { - source: CapturedBodyChunkReaderSource::Subresource { - body: self.clone(), - offset: 0, - }, - chunk_size, - }), - } - } -} - -pub(crate) struct CapturedBodyChunkReader { - source: CapturedBodyChunkReaderSource, - chunk_size: usize, -} - -enum CapturedBodyChunkReaderSource { - Memory { body: CapturedBody, offset: usize }, - File { file: File, path: PathBuf }, - Subresource { body: CapturedBody, offset: usize }, -} - -impl CapturedBodyChunkReader { - pub(crate) fn next_chunk(&mut self) -> Result>> { - match &mut self.source { - CapturedBodyChunkReaderSource::Memory { body, offset } => { - let bytes: &[u8] = match body.inner.as_ref() { - CapturedBodyInner::Memory(bytes) => bytes, - CapturedBodyInner::SharedMemory(bytes) => bytes, - _ => return Ok(None), - }; - if *offset >= bytes.len() { - return Ok(None); - } - let next_offset = (*offset).saturating_add(self.chunk_size).min(bytes.len()); - let chunk = bytes[*offset..next_offset].to_vec(); - *offset = next_offset; - Ok(Some(chunk)) - } - CapturedBodyChunkReaderSource::File { file, path } => { - let mut buffer = vec![0; self.chunk_size]; - let read = file.read(&mut buffer).with_context(|| { - anyhow!("failed to read captured response body `{}`", path.display()) - })?; - if read == 0 { - return Ok(None); - } - buffer.truncate(read); - Ok(Some(buffer)) - } - CapturedBodyChunkReaderSource::Subresource { body, offset } => { - let CapturedBodyInner::Subresource(source) = body.inner.as_ref() else { - return Ok(None); - }; - let chunk = source - .read_chunk(*offset, self.chunk_size) - .with_context(|| "failed to read captured subresource response body")?; - if chunk.is_empty() { - return Ok(None); - } - *offset = offset.saturating_add(chunk.len()); - Ok(Some(chunk)) - } - } - } -} - -pub(crate) fn ensure_materialize_limit(len: usize, limit: usize) -> Result<()> { - if len > limit { - Err(anyhow!( - "response body is {len} bytes, exceeds CDP materialization limit of {limit} bytes" - )) - } else { - Ok(()) - } -} - -impl PartialEq for CapturedBody { - fn eq(&self, other: &Self) -> bool { - self.len() == other.len() && self.materialize_bytes().ok() == other.materialize_bytes().ok() - } -} - -impl Eq for CapturedBody {} - -#[derive(Debug)] -pub(crate) struct CapturedBodyWriter { - memory_limit: usize, - len: usize, - memory: Vec, - file: Option, - path: Option, -} - -impl Default for CapturedBodyWriter { - fn default() -> Self { - Self::new(DEFAULT_MEMORY_LIMIT) - } -} - -impl CapturedBodyWriter { - pub(crate) fn new(memory_limit: usize) -> Self { - Self { - memory_limit, - len: 0, - memory: Vec::new(), - file: None, - path: None, - } - } - - pub(crate) fn append(&mut self, bytes: &[u8]) -> Result<()> { - if bytes.is_empty() { - return Ok(()); - } - if self.file.is_none() && self.len.saturating_add(bytes.len()) <= self.memory_limit { - self.memory.extend_from_slice(bytes); - self.len = self.len.saturating_add(bytes.len()); - return Ok(()); - } - self.ensure_file()?; - if let Some(file) = self.file.as_mut() { - file.write_all(bytes) - .context("failed to append captured response body spool")?; - } - self.len = self.len.saturating_add(bytes.len()); - Ok(()) - } - - pub(crate) fn finish(mut self) -> Result { - self.finish_in_place() - } - - pub(crate) fn finish_in_place(&mut self) -> Result { - if let Some(file) = self.file.as_mut() { - file.flush() - .context("failed to flush captured response body spool")?; - let _ = self.file.take(); - let path = self - .path - .take() - .expect("captured response body file path should be set"); - Ok(CapturedBody { - inner: Arc::new(CapturedBodyInner::File { - path, - len: self.len, - }), - }) - } else { - Ok(CapturedBody::from_bytes(std::mem::take(&mut self.memory))) - } - } - - fn ensure_file(&mut self) -> Result<()> { - if self.file.is_some() { - return Ok(()); - } - let path = unique_spool_path()?; - let mut options = OpenOptions::new(); - options.create_new(true).write(true); - configure_secure_spool_file_options(&mut options); - let file = options.open(&path).with_context(|| { - anyhow!( - "failed to create captured response body spool `{}`", - path.display() - ) - })?; - // Store the file/path before seeding bytes so Drop can clean up the - // already-created temp file if write_all fails. - self.path = Some(path); - self.file = Some(file); - if !self.memory.is_empty() { - self.file - .as_mut() - .expect("captured response body spool file should be set") - .write_all(&self.memory) - .context("failed to seed captured response body spool")?; - self.memory.clear(); - } - Ok(()) - } -} - -impl Drop for CapturedBodyWriter { - fn drop(&mut self) { - let _ = self.file.take(); - if let Some(path) = self.path.take() { - let _ = fs::remove_file(path); - } - } -} - -impl Write for CapturedBodyWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.append(buf).map_err(io::Error::other)?; - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - if let Some(file) = self.file.as_mut() { - file.flush() - } else { - Ok(()) - } - } -} - -fn unique_spool_path() -> Result { - let root = std::env::temp_dir().join("moli-cdp-body-spool"); - create_secure_spool_root(&root)?; - let id = NEXT_SPOOL_ID.fetch_add(1, Ordering::Relaxed); - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - Ok(root.join(format!("body-{}-{id}-{nanos}.bin", std::process::id()))) -} - -#[cfg(unix)] -fn create_secure_spool_root(root: &Path) -> Result<()> { - let mut builder = fs::DirBuilder::new(); - builder.recursive(true).mode(0o700); - builder.create(root).with_context(|| { - anyhow!( - "failed to create captured response body spool root `{}`", - root.display() - ) - })?; - // Existing temp dirs may have been created before this code used a - // restrictive mode. Tighten them every time before publishing spool files. - fs::set_permissions(root, fs::Permissions::from_mode(0o700)).with_context(|| { - anyhow!( - "failed to restrict captured response body spool root `{}`", - root.display() - ) - })?; - Ok(()) -} - -#[cfg(not(unix))] -fn create_secure_spool_root(root: &Path) -> Result<()> { - fs::create_dir_all(root).with_context(|| { - anyhow!( - "failed to create captured response body spool root `{}`", - root.display() - ) - }) -} - -#[cfg(unix)] -fn configure_secure_spool_file_options(options: &mut OpenOptions) { - options.mode(0o600); -} - -#[cfg(not(unix))] -fn configure_secure_spool_file_options(_options: &mut OpenOptions) {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn captured_body_writer_keeps_small_body_in_memory() -> Result<()> { - let mut writer = CapturedBodyWriter::new(16); - writer.append(b"hello")?; - let body = writer.finish()?; - - assert_eq!(body.len(), 5); - assert_eq!(body.materialize_bytes()?, b"hello"); - Ok(()) - } - - #[test] - fn captured_body_writer_spills_large_body_to_file() -> Result<()> { - let mut writer = CapturedBodyWriter::new(4); - writer.append(b"hello")?; - writer.append(b" world")?; - let body = writer.finish()?; - - assert_eq!(body.len(), 11); - assert_eq!(body.materialize_lossy_string()?, "hello world"); - #[cfg(unix)] - if let CapturedBodyInner::File { path, .. } = body.inner.as_ref() { - assert_eq!(fs::metadata(path)?.permissions().mode() & 0o777, 0o600); - assert_eq!( - fs::metadata(path.parent().expect("spool path should have parent"))? - .permissions() - .mode() - & 0o777, - 0o700 - ); - } - Ok(()) - } - - #[test] - fn captured_body_from_bytes_spooled_reads_ranges_without_materializing_all() -> Result<()> { - let body = CapturedBody::from_bytes_spooled(vec![b'a'; DEFAULT_MEMORY_LIMIT + 8]); - - assert_eq!(body.len(), DEFAULT_MEMORY_LIMIT + 8); - assert_eq!(body.read_range(DEFAULT_MEMORY_LIMIT - 2, 6)?, vec![b'a'; 6]); - assert!(body.read_range(body.len(), 16)?.is_empty()); - #[cfg(unix)] - if let CapturedBodyInner::File { path, .. } = body.inner.as_ref() { - assert_eq!(fs::metadata(path)?.permissions().mode() & 0o777, 0o600); - } - Ok(()) - } - - #[test] - fn captured_body_materialization_limit_rejects_large_body_before_read() -> Result<()> { - let body = CapturedBody::from_bytes(b"hello".to_vec()); - assert_eq!(body.materialize_bytes_limited(5)?, b"hello"); - let error = body.materialize_bytes_limited(4).unwrap_err().to_string(); - assert_eq!( - error, - "response body is 5 bytes, exceeds CDP materialization limit of 4 bytes" - ); - Ok(()) - } - - #[test] - fn captured_body_memory_chunk_reader_reads_in_chunks() -> Result<()> { - let body = CapturedBody::from_bytes(b"hello world".to_vec()); - - let mut reader = body.chunk_reader(4)?; - let mut chunks = Vec::new(); - while let Some(chunk) = reader.next_chunk()? { - chunks.push(chunk); - } - - assert_eq!( - chunks, - vec![b"hell".to_vec(), b"o wo".to_vec(), b"rld".to_vec()] - ); - assert_eq!(body.materialize_bytes()?, b"hello world"); - Ok(()) - } - - #[test] - fn captured_body_shared_memory_reads_without_copying_the_backing() -> Result<()> { - let backing: Arc<[u8]> = Arc::from(&b"hello world"[..]); - let body = CapturedBody::from_shared_bytes(backing.clone()); - - let CapturedBodyInner::SharedMemory(stored) = body.inner.as_ref() else { - panic!("shared bytes should retain the shared-memory body kind"); - }; - assert!(Arc::ptr_eq(stored, &backing)); - assert_eq!(body.read_range(6, 5)?, b"world"); - assert_eq!(body.materialize_bytes()?, b"hello world"); - Ok(()) - } - - #[test] - fn captured_body_file_spool_chunk_reader_reads_in_chunks() -> Result<()> { - let mut writer = CapturedBodyWriter::new(4); - writer.append(b"hello world")?; - let body = writer.finish()?; - - let mut reader = body.chunk_reader(5)?; - let mut chunks = Vec::new(); - while let Some(chunk) = reader.next_chunk()? { - chunks.push(chunk); - } - - assert_eq!( - chunks, - vec![b"hello".to_vec(), b" worl".to_vec(), b"d".to_vec()] - ); - Ok(()) - } - - #[test] - fn captured_body_from_subresource_response_body_reads_shared_source() -> Result<()> { - let subresource_body = moli_core::page::SubresourceResponseBody::from_text_and_bytes( - "hello world".to_owned(), - b"hello world".to_vec(), - ); - let body = CapturedBody::from_subresource_response_body(&subresource_body); - - assert!(matches!( - body.inner.as_ref(), - CapturedBodyInner::Subresource(_) - )); - assert_eq!(body.len(), 11); - assert_eq!(body.materialize_bytes()?, b"hello world"); - assert_eq!(body.read_range(6, 5)?, b"world"); - - let mut reader = body.chunk_reader(5)?; - let mut chunks = Vec::new(); - while let Some(chunk) = reader.next_chunk()? { - chunks.push(chunk); - } - - assert_eq!( - chunks, - vec![b"hello".to_vec(), b" worl".to_vec(), b"d".to_vec()] - ); - Ok(()) - } -} diff --git a/moli-protocol/src/conn/browser_context/engine_factory.rs b/moli-protocol/src/conn/browser_context/engine_factory.rs new file mode 100644 index 0000000000..3e2c13a1aa --- /dev/null +++ b/moli-protocol/src/conn/browser_context/engine_factory.rs @@ -0,0 +1,37 @@ +use moli_core::runtime::{ + NavigationEngine, NavigationRuntimeConfig, RendererBrowserContextRuntimeOwnerAccess, +}; + +use super::CdpConnection; + +/// BrowserContext-scoped inputs needed only when Browser Core cannot restore +/// a retained Target engine and asks the migration adapter for a replacement. +pub(super) struct BrowserEngineReplacementInputs { + runtime_config: NavigationRuntimeConfig, +} + +impl BrowserEngineReplacementInputs { + pub(super) fn capture(connection: &CdpConnection) -> Self { + Self { + runtime_config: connection + .browser_host_state + .navigation_owner() + .active_runtime_config(), + } + } + + pub(super) fn create_engine( + self, + renderer_runtime: RendererBrowserContextRuntimeOwnerAccess, + ) -> NavigationEngine { + NavigationEngine::new_with_runtime_config_and_browser_context_access( + self.runtime_config, + renderer_runtime, + ) + .expect("projected BrowserContext renderer owner must remain live") + } + + pub(super) fn create_unbound_engine(self) -> NavigationEngine { + NavigationEngine::new_with_runtime_config(self.runtime_config) + } +} diff --git a/moli-protocol/src/conn/browser_context/fetch_owner.rs b/moli-protocol/src/conn/browser_context/fetch_owner.rs index 32da82a237..3213522b5b 100644 --- a/moli-protocol/src/conn/browser_context/fetch_owner.rs +++ b/moli-protocol/src/conn/browser_context/fetch_owner.rs @@ -948,19 +948,15 @@ impl CdpConnection { &mut self, session_id: Option<&str>, ) -> Result<(String, String), String> { - let mut network_request_id_allocator = - std::mem::take(&mut self.network_request_id_allocator); - let result = self - .runtime_session_owner_slot_mut(session_id) + let mut network_request_id_allocator = self.browser_host_state.network_artifacts(); + self.runtime_session_owner_slot_mut(session_id) .map(|runtime_slot| { runtime_slot .request_id_allocator() .allocate_pending_subresource_fetch_request_ids( &mut network_request_id_allocator, ) - }); - self.network_request_id_allocator = network_request_id_allocator; - result + }) } pub(crate) fn allocate_fetch_navigation_request_id_for_session_owner( diff --git a/moli-protocol/src/conn/browser_context/lifecycle.rs b/moli-protocol/src/conn/browser_context/lifecycle.rs index d74eff540e..fc70872dca 100644 --- a/moli-protocol/src/conn/browser_context/lifecycle.rs +++ b/moli-protocol/src/conn/browser_context/lifecycle.rs @@ -1,8 +1,84 @@ +use super::registry_projection::BrowserContextProjectionError; use super::*; +use moli_core::browser_host::{ + BrowserContextDisposalReservation, BrowserContextHandle, BrowserContextRegistrationMetadata, + BrowserContextRegistryError, +}; +use moli_core::runtime::RendererBrowserContextRuntimeOwner; impl CdpConnection { + #[cfg(test)] + pub(crate) fn adopt_direct_browser_context_fixture_attachments(&mut self) { + // Older protocol tests still construct physical BrowserContexts by + // assigning the public fixture fields directly. Once Browser Core + // became authoritative, adopting only their frontend attachment map + // left those Contexts/Targets unknown to the engine registry. Route a + // wholly unregistered fixture through the production registration + // transaction before installing its legacy session attachments. + if self.registered_browser_context_count() == 0 + && (self.browser_context.is_some() || !self.inactive_browser_contexts.is_empty()) + { + let selected = self.browser_context.take(); + let inactive = std::mem::take(&mut self.inactive_browser_contexts); + if let Some(selected) = selected { + self.try_insert_browser_context(selected) + .expect("direct selected BrowserContext fixture must register in Core"); + } + for browser_context in inactive { + self.try_insert_browser_context(browser_context) + .expect("direct inactive BrowserContext fixture must register in Core"); + } + } + if let Some(browser_context) = self.browser_context.as_mut() { + browser_context.adopt_background_target_fixture_attachments(); + } + for browser_context in &mut self.inactive_browser_contexts { + browser_context.adopt_background_target_fixture_attachments(); + } + } + + pub(crate) fn begin_browser_context_disposal( + &mut self, + browser_context_handle: &BrowserContextHandle, + ) -> Result { + self.browser_host_state + .begin_browser_context_disposal(browser_context_handle) + } + + pub(crate) fn rollback_browser_context_disposal( + &mut self, + reservation: BrowserContextDisposalReservation, + ) -> bool { + self.browser_host_state + .rollback_browser_context_disposal(reservation) + } + + pub(crate) fn try_activate_browser_context_by_id( + &mut self, + browser_context_id: &str, + ) -> Result { + let changed = self.activate_browser_context_projection_by_id(browser_context_id)?; + if changed { + self.apply_active_engine_fetch_overrides(); + self.invalidate_resource_runtime(); + } + Ok(changed) + } + pub fn activate_browser_context_by_id(&mut self, browser_context_id: &str) -> bool { - self.activate_matching_browser_context(|bc| bc.id == browser_context_id) + match self.try_activate_browser_context_by_id(browser_context_id) { + Ok(_) => true, + Err(error) => { + if !error.is_unknown_context() { + tracing::warn!( + browser_context_id, + error = %error, + "BrowserContext activation projection was rejected" + ); + } + false + } + } } pub async fn activate_browser_context_by_id_async(&mut self, browser_context_id: &str) -> bool { @@ -24,13 +100,16 @@ impl CdpConnection { } pub fn activate_browser_context_for_target(&mut self, target_id: &str) -> bool { + let browser_context_id = self + .browser_host_state + .navigation_owner() + .target_browser_context_id(target_id) + .map(str::to_owned); + if let Some(browser_context_id) = browser_context_id { + return self.activate_browser_context_by_id(&browser_context_id); + } self.activate_matching_browser_context(|bc| { - bc.is_active_target(target_id) - || bc - .background_targets - .iter() - .any(|target| target.is_target(target_id)) - || bc.has_shared_worker_target(target_id) + bc.has_shared_worker_target(target_id) || bc.has_dedicated_worker_target(target_id) || bc.has_service_worker_target(target_id) }) @@ -40,7 +119,25 @@ impl CdpConnection { self.activate_browser_context_for_target(target_id) } - pub fn insert_browser_context(&mut self, browser_context: BrowserContext) { + pub(crate) fn try_insert_browser_context( + &mut self, + browser_context: BrowserContext, + ) -> Result<(), BrowserContextProjectionError> { + self.try_insert_browser_context_with_metadata( + browser_context, + BrowserContextRegistrationMetadata::default(), + ) + } + + pub(crate) fn try_insert_browser_context_with_metadata( + &mut self, + mut browser_context: BrowserContext, + registration_metadata: BrowserContextRegistrationMetadata, + ) -> Result<(), BrowserContextProjectionError> { + #[cfg(test)] + let fixture_browser_context_id = browser_context.id.clone(); + browser_context + .adopt_browser_network_artifact_store(self.browser_host_state.network_artifacts()); browser_context .renderer_runtime() .set_service_worker_pause_on_start_for_devtools( @@ -51,155 +148,129 @@ impl CdpConnection { .set_dedicated_worker_pause_on_start_for_devtools( self.dedicated_worker_pause_on_start_for_devtools(), ); - if self.browser_context.is_none() { - let renderer_runtime = browser_context.renderer_runtime_owner_access(); - let next_engine = moli_core::runtime::NavigationEngine:: - new_with_runtime_config_and_browser_context_access( - self.engine.runtime_config(), - renderer_runtime, - ) - .expect("newly inserted BrowserContext owner must be live"); - self.replace_navigation_engine(next_engine); - self.browser_context = Some(browser_context); + let became_active = self.register_browser_context_projection_with_metadata( + browser_context, + registration_metadata, + )?; + #[cfg(test)] + self.browser_context_by_id_mut(&fixture_browser_context_id) + .expect("newly registered test BrowserContext must remain projected") + .adopt_background_target_fixture_attachments(); + if became_active { self.apply_active_engine_fetch_overrides(); - } else { - self.inactive_browser_contexts.push(browser_context); + } + Ok(()) + } + + pub fn insert_browser_context(&mut self, browser_context: BrowserContext) { + let browser_context_id = browser_context.id.clone(); + if let Err(error) = self.try_insert_browser_context(browser_context) { + tracing::warn!( + browser_context_id, + error = %error, + "BrowserContext registration projection was rejected" + ); } } - pub async fn remove_browser_context_by_id_restoring_active_async( + #[cfg(test)] + pub(crate) async fn remove_browser_context_by_id_restoring_active_async( &mut self, browser_context_id: &str, restore_browser_context_id: Option<&str>, - ) -> Option { - if self - .browser_context - .as_ref() - .is_some_and(|bc| bc.id == browser_context_id) - { - let removed = self.browser_context.take(); - if self.browser_context.is_none() && !self.inactive_browser_contexts.is_empty() { - self.promote_inactive_browser_context_index_to_active(0); - } else if self.browser_context.is_none() { - // The active engine only carries weak access to the removed - // context root. Move to a standalone root before returning - // that BrowserContext to a caller that may drop it at once. - let next_engine = moli_core::runtime::NavigationEngine::new_with_runtime_config( - self.engine.runtime_config(), - ); - self.replace_navigation_engine(next_engine); - } - self.forget_retained_navigation_engines_for_browser_context(browser_context_id); - self.invalidate_resource_runtime_async().await; - self.restore_preferred_browser_context_async( - restore_browser_context_id, - browser_context_id, - ) - .await; + ) -> Result { + let removal = self.remove_browser_context_projection_by_id(browser_context_id)?; + let ( + browser_context, + selection_changed, + retired_renderer_page_owners, + mut renderer_runtime_owner, + ) = removal.into_parts(); + let removed_browser_context_id = browser_context.id.clone(); + renderer_runtime_owner.terminate_renderer_producers_for_owner_shutdown(); + if selection_changed { + self.invalidate_resource_runtime(); + } + self.restore_preferred_browser_context_async( + restore_browser_context_id, + browser_context_id, + ) + .await; + if selection_changed { self.apply_active_engine_fetch_overrides(); - return removed; } - - if let Some(index) = self - .inactive_browser_contexts - .iter() - .position(|bc| bc.id == browser_context_id) - { - let removed = self.inactive_browser_contexts.swap_remove(index); - self.forget_retained_navigation_engines_for_browser_context(browser_context_id); - self.restore_preferred_browser_context_async( - restore_browser_context_id, - browser_context_id, - ) - .await; - Some(removed) - } else { - None + for owner in retired_renderer_page_owners { + let _ = owner.close_async().await; } + drop(browser_context); + renderer_runtime_owner.shutdown_network_and_join(); + Ok(removed_browser_context_id) } - fn forget_retained_navigation_engines_for_browser_context(&mut self, browser_context_id: &str) { - self.retained_background_navigation_engines - .retain(|(retained_context_id, _), _| retained_context_id != browser_context_id); - } - - pub(crate) async fn refresh_active_browser_context_loader_async(&mut self) { - self.apply_active_engine_fetch_overrides(); - self.invalidate_resource_runtime_async().await; - } - - fn navigation_engine_for_inactive_browser_context_index( + /// Commits the final physical removal for an exact Browser Host disposal. + /// + /// Unlike the legacy frontend wrapper, this path never restores a + /// Context selection captured before participant waits. Core chooses the + /// successor in this terminal turn and the physical projection follows + /// that current decision. + pub(crate) fn remove_browser_context_for_disposal( &mut self, - index: usize, - ) -> moli_core::runtime::NavigationEngine { - let next_browser_context_id = self.inactive_browser_contexts[index].id.clone(); - let next_renderer_runtime = - self.inactive_browser_contexts[index].renderer_runtime_owner_access(); - let navigation_runtime_config = self.engine.runtime_config(); - let next_active_target_id = self.inactive_browser_contexts[index] - .active_target_id() - .map(str::to_owned); - next_active_target_id - .as_ref() - .and_then(|target_id| { - self.retained_background_navigation_engines - .remove(&(next_browser_context_id.clone(), target_id.clone())) - }) - .unwrap_or_else(|| { - moli_core::runtime::NavigationEngine::new_with_runtime_config_and_browser_context_access( - navigation_runtime_config, - next_renderer_runtime, - ) - .expect("inactive BrowserContext owner must be live") - }) + reservation: &BrowserContextDisposalReservation, + ) -> Result< + ( + BrowserContext, + Vec, + RendererBrowserContextRuntimeOwner, + ), + BrowserContextProjectionError, + > { + let removal = self.remove_browser_context_projection_for_disposal(reservation)?; + let ( + browser_context, + selection_changed, + retired_renderer_page_owners, + renderer_runtime_owner, + ) = removal.into_parts(); + if selection_changed { + self.invalidate_resource_runtime(); + self.apply_active_engine_fetch_overrides(); + } + Ok(( + browser_context, + retired_renderer_page_owners, + renderer_runtime_owner, + )) } - fn promote_inactive_browser_context_index_to_active(&mut self, index: usize) { - let next_engine = self.navigation_engine_for_inactive_browser_context_index(index); - self.replace_navigation_engine(next_engine); - self.browser_context = Some(self.inactive_browser_contexts.swap_remove(index)); + pub(crate) fn refresh_active_browser_context_loader(&mut self) { + self.apply_active_engine_fetch_overrides(); + self.invalidate_resource_runtime(); } fn activate_matching_browser_context(&mut self, mut matches: F) -> bool where F: FnMut(&BrowserContext) -> bool, { - if self + let browser_context_id = if let Some(browser_context) = self .browser_context .as_ref() - .map(&mut matches) - .unwrap_or(false) + .filter(|browser_context| matches(browser_context)) { - return true; - } - - let Some(index) = self.inactive_browser_contexts.iter().position(matches) else { - return false; - }; - let next_engine = self.navigation_engine_for_inactive_browser_context_index(index); - let current_active_engine_key = self.browser_context.as_ref().and_then(|bc| { - let active_target_id = bc.active_target_id()?; - bc.has_loaded_page() - .then(|| (bc.id.clone(), active_target_id.to_owned())) - }); - if let Some((browser_context_id, target_id)) = current_active_engine_key { - self.apply_scheduler_senders_to_navigation_engine(&next_engine); - let active_engine = self.engine.replace(next_engine); - self.retain_background_navigation_engine(browser_context_id, target_id, active_engine) - .expect("inactive BrowserContext must retain its exact active-target engine"); + browser_context.id.clone() } else { - self.replace_navigation_engine(next_engine); - } - - let matched = self.inactive_browser_contexts.swap_remove(index); - if let Some(active) = self.browser_context.replace(matched) { - self.inactive_browser_contexts.push(active); - } - self.apply_active_engine_fetch_overrides(); - self.invalidate_resource_runtime(); - true + let Some(browser_context) = self + .inactive_browser_contexts + .iter() + .find(|browser_context| matches(browser_context)) + else { + return false; + }; + browser_context.id.clone() + }; + self.activate_browser_context_by_id(&browser_context_id) } + #[cfg(test)] async fn restore_preferred_browser_context_async( &mut self, restore_browser_context_id: Option<&str>, @@ -223,3 +294,32 @@ impl CdpConnection { .await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn physically_matched_unregistered_context_is_rejected_without_panicking() { + let mut connection = CdpConnection::new(); + connection.browser_context = Some(BrowserContext::new("physical-only".to_owned())); + + assert!(!connection.activate_matching_browser_context(|_| true)); + + assert_eq!( + connection + .browser_context + .as_ref() + .map(|context| context.id.as_str()), + Some("physical-only") + ); + assert_eq!(connection.registered_browser_context_count(), 0); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_browser_context_id(), + None + ); + } +} diff --git a/moli-protocol/src/conn/browser_context/loaded_page_projection.rs b/moli-protocol/src/conn/browser_context/loaded_page_projection.rs new file mode 100644 index 0000000000..3944299914 --- /dev/null +++ b/moli-protocol/src/conn/browser_context/loaded_page_projection.rs @@ -0,0 +1,192 @@ +use moli_core::{ + browser_host::BrowserPageReplacement, + page::{Page, RendererMainDocumentCommit}, +}; +use url::Url; + +use crate::conn::{ + CdpConnection, LoadedNavigationRendererAttachmentCommit, PreparedLoadedNavigationPageCommit, + RendererPageResidenceIdentity, state::prepare_renderer_call_replacements_for_devtools_sessions, +}; + +use super::target_session_owner::{ + TargetSessionOwnerMut, clear_top_level_target_loaded_document_session_state, +}; + +/// Active/background protocol participant for one loaded Page replacement. +/// +/// Renderer attachment preparation happens before Browser Core mutates its +/// authoritative request/Page/history state. Projection happens immediately +/// after that owner commit and is synchronous; only disposal of the retired +/// physical Page may await. +impl TargetSessionOwnerMut<'_> { + pub(super) fn prepare_loaded_navigation_page_commit( + &mut self, + mut page: Page, + renderer_attachment_commit: LoadedNavigationRendererAttachmentCommit, + ) -> Option> { + match self { + Self::ActiveTarget { + browser_context, .. + } => Some( + browser_context + .prepare_loaded_navigation_page_commit(page, renderer_attachment_commit), + ), + Self::BackgroundTarget { + browser_context, + target_id, + .. + } => { + let retiring_renderer_page = browser_context + .background_target(target_id)? + .runtime_slot() + .loaded_renderer_page_residence(); + let primary_session_id = browser_context + .primary_session_id_for_target(target_id) + .map(str::to_owned); + let (previous_attachment, new_attachment_id) = { + let target = browser_context.background_target_mut(target_id)?; + let previous_attachment = match renderer_attachment_commit { + LoadedNavigationRendererAttachmentCommit::Prepare( + renderer_agent_candidate, + ) => match target + .runtime_slot + .commit_loaded_navigation_renderer_attachment( + &mut page, + renderer_agent_candidate, + ) { + Ok(previous) => previous, + Err(error) => return Some(Err(error.into())), + }, + LoadedNavigationRendererAttachmentCommit::AlreadyCommitted(transaction) => { + if let Err(error) = target + .runtime_slot + .bind_page_to_committed_renderer_agent_candidate( + &mut page, + &transaction, + ) + { + return Some(Err(error.into())); + } + transaction.previous() + } + }; + let new_attachment_id = page + .renderer_agent_attachment_id() + .expect("committed navigation Page must have a renderer attachment"); + (previous_attachment, new_attachment_id) + }; + if let Some(previous_attachment) = previous_attachment + && previous_attachment.id() != new_attachment_id + { + let (primary, auxiliary) = + browser_context.devtools_session_states_for_target_mut(target_id)?; + let replacements = + match prepare_renderer_call_replacements_for_devtools_sessions( + primary_session_id.as_deref(), + primary, + auxiliary, + 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); + } + Some(Ok(PreparedLoadedNavigationPageCommit::new( + page, + retiring_renderer_page, + ))) + } + Self::NoLoadedBrowserContext => None, + } + } + + /// Applies every protocol projection in the same actor turn and returns + /// the retired Page for later asynchronous disposal. + pub(super) fn project_loaded_navigation_page_after_browser_owner_commit( + &mut self, + target_url: &Url, + main_document_commit: &RendererMainDocumentCommit, + replacement: &BrowserPageReplacement, + retiring_renderer_page: Option, + ) -> Option<()> { + let next_url = target_url.to_string(); + let security_origin = main_document_commit.security_origin.clone(); + let secure_context_type = main_document_commit.secure_context_type.clone(); + match self { + Self::ActiveTarget { + browser_context, .. + } => { + browser_context.set_target_url(next_url); + browser_context.set_target_security_origin(security_origin); + browser_context.set_target_secure_context_type(secure_context_type); + browser_context.project_loaded_navigation_page_after_browser_owner_commit( + replacement, + retiring_renderer_page, + ); + Some(()) + } + Self::BackgroundTarget { + browser_context, + target_id, + .. + } => { + { + let target = browser_context.background_target_mut(target_id)?; + target.set_target_url(next_url); + target.set_target_security_origin(security_origin); + target.set_target_secure_context_type(secure_context_type); + } + browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { + owner_state.clear_committed_document_navigation_state(); + }); + clear_top_level_target_loaded_document_session_state(browser_context, target_id); + { + let target = browser_context.background_target_mut(target_id)?; + target.project_loaded_page_after_browser_owner_commit( + replacement, + retiring_renderer_page, + ); + target.runtime_slot.reset_subresource_cursor(); + target.runtime_slot.clear_websocket_artifacts(); + } + Some(()) + } + Self::NoLoadedBrowserContext => None, + } + } +} + +impl CdpConnection { + pub(crate) fn prepare_loaded_navigation_page_for_session_owner( + &mut self, + session_id: Option<&str>, + page: Page, + renderer_attachment_commit: LoadedNavigationRendererAttachmentCommit, + ) -> Option> { + self.target_session_owner_mut(session_id)? + .prepare_loaded_navigation_page_commit(page, renderer_attachment_commit) + } + + pub(crate) fn project_loaded_navigation_page_for_session_owner( + &mut self, + session_id: Option<&str>, + target_url: &Url, + main_document_commit: &RendererMainDocumentCommit, + replacement: &BrowserPageReplacement, + retiring_renderer_page: Option, + ) -> Option<()> { + self.target_session_owner_mut(session_id)? + .project_loaded_navigation_page_after_browser_owner_commit( + target_url, + main_document_commit, + replacement, + retiring_renderer_page, + ) + } +} diff --git a/moli-protocol/src/conn/browser_context/lookup.rs b/moli-protocol/src/conn/browser_context/lookup.rs index 95f924589f..9bf5deb06d 100644 --- a/moli-protocol/src/conn/browser_context/lookup.rs +++ b/moli-protocol/src/conn/browser_context/lookup.rs @@ -74,8 +74,15 @@ impl CdpConnection { } pub fn has_browser_context_id(&self, browser_context_id: &str) -> bool { - self.browser_contexts() - .any(|bc| bc.id == browser_context_id) + self.browser_host_state + .navigation_owner() + .has_browser_context(browser_context_id) + } + + pub(crate) fn registered_browser_context_count(&self) -> usize { + self.browser_host_state + .navigation_owner() + .browser_context_count() } pub(crate) fn browser_context_id_for_target(&self, target_id: &str) -> Option<&str> { diff --git a/moli-protocol/src/conn/browser_context/mod.rs b/moli-protocol/src/conn/browser_context/mod.rs index ab16516f79..77a50c7a4e 100644 --- a/moli-protocol/src/conn/browser_context/mod.rs +++ b/moli-protocol/src/conn/browser_context/mod.rs @@ -1,21 +1,42 @@ -use crate::conn::{BrowserContext, CdpConnection, CdpSessionRoute, TargetPageSessionState}; +use crate::conn::{BrowserContext, CdpConnection, ParkedPageSessionState, TargetPageSessionState}; use crate::domains::audits::SessionOwnerAuditsEnableResult; use crate::domains::log::{SessionOwnerLogControlResult, SessionOwnerLogEnableResult}; use serde_json::Value; mod emulation_owner; +mod engine_factory; mod fetch_owner; mod lifecycle; +mod loaded_page_projection; mod lookup; mod network_owner; mod page_owner; +mod page_residence_projection; +mod page_residence_projection_error; +mod registry_projection; mod runtime_owner; mod session_owner; +mod target_context_projection; +mod target_projection_error; +mod target_registry_projection; mod target_session_owner; +mod target_snapshot_projection; +mod target_termination_projection; +mod target_topology_projection; + +use engine_factory::BrowserEngineReplacementInputs; + +pub(crate) use target_projection_error::TargetProjectionError; +pub(crate) use target_termination_projection::{ + ProjectedActiveTargetClose, ProjectedClosedPageTarget, +}; pub(crate) use emulation_owner::TargetEmulationSessionStateMut; pub(crate) use page_owner::PageLifecycleEventsEnableResult; pub(crate) use runtime_owner::{ SessionOwnerInspectorEnableResult, SessionOwnerRuntimeFrontendEnableResult, }; -pub(crate) use target_session_owner::TargetNavigationLoadInputs; +pub(crate) use session_owner::{CdpSessionRoute, TargetHandlerAccessMode}; +pub(crate) use target_session_owner::{ + ClosedPageTarget, TargetLoadedNavigationCommitState, TargetNavigationLoadInputs, +}; diff --git a/moli-protocol/src/conn/browser_context/network_owner.rs b/moli-protocol/src/conn/browser_context/network_owner.rs index 480b4d9d7c..4b26dafe2e 100644 --- a/moli-protocol/src/conn/browser_context/network_owner.rs +++ b/moli-protocol/src/conn/browser_context/network_owner.rs @@ -1,6 +1,6 @@ +use super::session_owner::CdpSessionRoute; use super::target_session_owner::{TargetSessionOwnerMut, TargetSessionStateMut}; use super::*; -use crate::conn::CdpSessionRoute; use crate::conn::{CapturedBody, TargetRuntimeSlot}; use crate::devtools_runtime::DevToolsNetworkDataType; use crate::domains::network::{ @@ -8,6 +8,7 @@ use crate::domains::network::{ NetworkBacklogPreferredRequestId, PendingNetworkBacklogDeliverySnapshot, TargetNetworkBacklogPreparedDelivery, }; +use moli_core::browser_host::BrowserHostPolicyUpdate; use moli_core::page::PendingPageCommand; impl TargetSessionStateMut<'_> { @@ -529,7 +530,7 @@ impl CdpConnection { &self, request_id: &str, session_id: Option<&str>, - ) -> Option<&CapturedResponseBody> { + ) -> Option { self.browser_contexts().find_map(|browser_context| { browser_context .active_target @@ -559,7 +560,7 @@ impl CdpConnection { &self, request_id: &str, session_id: Option<&str>, - ) -> Option<&CapturedRequestBody> { + ) -> Option { self.browser_contexts().find_map(|browser_context| { browser_context .active_target @@ -672,10 +673,8 @@ impl CdpConnection { primary_session_id: Option<&str>, preferred_request_id: Option>, ) -> Option { - let mut network_request_id_allocator = - std::mem::take(&mut self.network_request_id_allocator); - let result = self - .runtime_session_owner_slot_mut(session_id) + let mut network_request_id_allocator = self.browser_host_state.network_artifacts(); + self.runtime_session_owner_slot_mut(session_id) .ok() .map(|runtime_slot| { runtime_slot.network_backlog_prepared_delivery( @@ -684,9 +683,7 @@ impl CdpConnection { preferred_request_id, &mut network_request_id_allocator, ) - }); - self.network_request_id_allocator = network_request_id_allocator; - result + }) } pub(crate) fn network_request_id_for_subresource_handle_for_session_owner( @@ -694,19 +691,15 @@ impl CdpConnection { session_id: Option<&str>, handle: moli_core::page::SubresourceNetworkRequestHandle, ) -> Option { - let mut network_request_id_allocator = - std::mem::take(&mut self.network_request_id_allocator); - let result = self - .runtime_session_owner_slot_mut(session_id) + let mut network_request_id_allocator = self.browser_host_state.network_artifacts(); + self.runtime_session_owner_slot_mut(session_id) .ok() .map(|runtime_slot| { runtime_slot.network_request_id_for_subresource_handle( handle, &mut network_request_id_allocator, ) - }); - self.network_request_id_allocator = network_request_id_allocator; - result + }) } pub(crate) fn pending_network_backlog_delivery_snapshot_for_session_owner( @@ -716,10 +709,8 @@ impl CdpConnection { primary_session_id: Option<&str>, preferred_request_id: Option>, ) -> Option { - let mut network_request_id_allocator = - std::mem::take(&mut self.network_request_id_allocator); - let result = self - .runtime_session_owner_slot_mut(session_id) + let mut network_request_id_allocator = self.browser_host_state.network_artifacts(); + self.runtime_session_owner_slot_mut(session_id) .ok() .and_then(|runtime_slot| { runtime_slot.pending_network_backlog_delivery_snapshot( @@ -728,9 +719,7 @@ impl CdpConnection { preferred_request_id, &mut network_request_id_allocator, ) - }); - self.network_request_id_allocator = network_request_id_allocator; - result + }) } pub(crate) fn network_event_session_ids_for_session_owner( @@ -783,11 +772,15 @@ impl CdpConnection { } pub(crate) fn set_global_cache_disabled(&mut self, cache_disabled: bool) { - self.global_cache_disabled = cache_disabled; + self.apply_browser_host_policy_update(BrowserHostPolicyUpdate::SetGlobalCacheDisabled( + cache_disabled, + )); } pub(crate) fn set_global_extra_headers(&mut self, extra_headers: Vec<(String, String)>) { - self.global_extra_headers = extra_headers.clone(); + self.apply_browser_host_policy_update(BrowserHostPolicyUpdate::SetGlobalExtraHeaders( + extra_headers.clone(), + )); if let Some(browser_context) = self.browser_context.as_mut() { browser_context.global_extra_headers = extra_headers.clone(); } @@ -877,10 +870,11 @@ impl CdpConnection { session_id: Option<&str>, user_agent: Option, ) -> Result, String> { + let base_identity = self.base_browser_identity(); let browser_identity = user_agent.as_ref().map(|user_agent| { moli_browser_profile::BrowserIdentityProfile::new( user_agent.clone(), - self.base_browser_identity.accept_language(), + base_identity.accept_language(), ) }); self.start_set_browser_identity_override_for_session_owner(session_id, browser_identity) @@ -906,7 +900,11 @@ impl CdpConnection { .clear_browser_identity_override(); } } else { - self.global_browser_identity_override = browser_identity.clone(); + self.apply_browser_host_policy_update( + BrowserHostPolicyUpdate::SetGlobalBrowserIdentityOverride( + browser_identity.clone(), + ), + ); } self.apply_active_engine_fetch_overrides(); return self.start_rebuild_resource_runtime_for_session_owner(session_id); @@ -915,7 +913,9 @@ impl CdpConnection { && self.none_session_owner_route_override().is_none() && self.browser_context.is_none() { - self.global_browser_identity_override = browser_identity.clone(); + self.apply_browser_host_policy_update( + BrowserHostPolicyUpdate::SetGlobalBrowserIdentityOverride(browser_identity.clone()), + ); self.apply_active_engine_fetch_overrides(); return self.start_rebuild_resource_runtime_for_session_owner(session_id); } @@ -948,7 +948,9 @@ impl CdpConnection { if let Some(browser_context) = self.browser_context.as_mut() { browser_context.tls_verify_host_override = Some(enabled); } else { - self.base_tls_verify_host = enabled; + self.apply_browser_host_policy_update( + BrowserHostPolicyUpdate::SetBaseTlsVerifyHost(enabled), + ); } self.apply_active_engine_fetch_overrides(); return self.start_rebuild_resource_runtime_for_session_owner(session_id); @@ -1064,19 +1066,25 @@ fn clear_parked_network_observation_artifacts_if_unobserved( #[cfg(test)] mod tests { use super::*; - use crate::conn::{BackgroundTarget, ParkedPageSessionState}; + use crate::conn::BackgroundTarget; + use crate::conn::state::DevToolsSessionState; fn active_session_state_mut(browser_context: &mut BrowserContext) -> TargetSessionStateMut<'_> { + let (devtools_session_state, network_policy, tls_verify_host_override) = + browser_context.active_frontend_and_policy_state_mut(); TargetSessionStateMut::Active { - devtools_session_state: &mut browser_context.devtools_session_state, - network_policy: &mut browser_context.network_policy, - tls_verify_host_override: &mut browser_context.tls_verify_host_override, + devtools_session_state, + network_policy, + tls_verify_host_override, } } - fn parked_session_state_mut(state: &mut ParkedPageSessionState) -> TargetSessionStateMut<'_> { + fn parked_session_state_mut<'a>( + state: &'a mut ParkedPageSessionState, + devtools_session_state: &'a mut DevToolsSessionState, + ) -> TargetSessionStateMut<'a> { TargetSessionStateMut::Parked { - devtools_session_state: &mut state.devtools_session_state, + devtools_session_state, network_policy: &mut state.network_policy, tls_verify_host_override: &mut state.tls_verify_host_override, } @@ -1092,6 +1100,7 @@ mod tests { Some("SID-background".to_owned()), "https://background.example/".to_owned(), )); + browser_context.adopt_background_target_fixture_attachments(); assert!( browser_context .assign_auxiliary_session_to_target("TID-background", "SID-aux".to_owned()) @@ -1113,6 +1122,7 @@ mod tests { Some("SID-background".to_owned()), "https://background.example/".to_owned(), )); + browser_context.adopt_background_target_fixture_attachments(); conn.browser_context = Some(browser_context); let (active_fetch_id, active_network_request_id) = conn @@ -1191,21 +1201,25 @@ mod tests { ); let mut parked = ParkedPageSessionState::default(); - parked_session_state_mut(&mut parked).set_cache_disabled(true); - parked_session_state_mut(&mut parked).set_bypass_service_worker(true); - let parked_blocked = parked_session_state_mut(&mut parked) + let mut parked_devtools = DevToolsSessionState::default(); + parked_session_state_mut(&mut parked, &mut parked_devtools).set_cache_disabled(true); + parked_session_state_mut(&mut parked, &mut parked_devtools).set_bypass_service_worker(true); + let parked_blocked = parked_session_state_mut(&mut parked, &mut parked_devtools) .set_blocked_url_patterns(vec!["*://parked-blocked.test/*".to_owned()]); - let parked_headers_should_apply = parked_session_state_mut(&mut parked) - .set_extra_http_headers(vec![("X-Test".to_owned(), "parked".to_owned())]); - let parked_user_agent_should_refresh = parked_session_state_mut(&mut parked) - .set_user_agent_override(Some("Moli/Parked".to_owned())); - let parked_offline = parked_session_state_mut(&mut parked).set_emulated_network_conditions( - true, - 50.0, - 2048.0, - 512.0, - Some("cellular4g".to_owned()), - ); + let parked_headers_should_apply = + parked_session_state_mut(&mut parked, &mut parked_devtools) + .set_extra_http_headers(vec![("X-Test".to_owned(), "parked".to_owned())]); + let parked_user_agent_should_refresh = + parked_session_state_mut(&mut parked, &mut parked_devtools) + .set_user_agent_override(Some("Moli/Parked".to_owned())); + let parked_offline = parked_session_state_mut(&mut parked, &mut parked_devtools) + .set_emulated_network_conditions( + true, + 50.0, + 2048.0, + 512.0, + Some("cellular4g".to_owned()), + ); assert!(parked.network_policy.cache_disabled()); assert!(parked.network_policy.bypass_service_worker()); @@ -1359,8 +1373,10 @@ mod tests { #[test] fn network_target_listener_can_be_disabled_after_enable() { - let mut conn = CdpConnection::default(); - conn.browser_context = Some(BrowserContext::new("BID-network".to_owned())); + let mut conn = CdpConnection { + browser_context: Some(BrowserContext::new("BID-network".to_owned())), + ..Default::default() + }; conn.browser_context .as_mut() .expect("browser context") diff --git a/moli-protocol/src/conn/browser_context/page_owner.rs b/moli-protocol/src/conn/browser_context/page_owner.rs index abb9fd1b61..f9cc960c4e 100644 --- a/moli-protocol/src/conn/browser_context/page_owner.rs +++ b/moli-protocol/src/conn/browser_context/page_owner.rs @@ -404,11 +404,13 @@ impl TargetSessionOwnerMut<'_> { } => browser_context .background_target(target_id) .filter(|target| target.has_loaded_page()) - .and_then(|target| { + .and_then(|_target| { let replay_session_id = if *is_auxiliary_target_session { session_id.clone() } else { - target.session_id().map(str::to_owned) + browser_context + .primary_session_id_for_target(target_id) + .map(str::to_owned) }?; Some(PageLifecycleReplayTarget { session_id: replay_session_id, @@ -545,14 +547,7 @@ fn devtools_session_page_domain_enabled(state: &DevToolsSessionState) -> bool { } fn browser_context_has_page_domain_enabled_session(browser_context: &BrowserContext) -> bool { - devtools_session_page_domain_enabled(&browser_context.devtools_session_state) - || browser_context - .auxiliary_devtools_session_states - .values() - .any(devtools_session_page_domain_enabled) - || browser_context - .target_parking - .has_page_domain_enabled_session() + browser_context.has_page_domain_enabled_session() } impl CdpConnection { @@ -639,9 +634,8 @@ impl CdpConnection { body: Option, ) -> bool { if self - .runtime_session_owner_slot(session_id) - .ok() - .and_then(TargetRuntimeSlot::committed_document_loader_id) + .committed_document_loader_id_for_session_owner(session_id) + .as_deref() != Some(loader_id.as_str()) { return false; @@ -663,11 +657,7 @@ impl CdpConnection { &self, session_id: Option<&str>, ) -> Option { - let loader_id = self - .runtime_session_owner_slot(session_id) - .ok()? - .committed_document_loader_id()? - .to_owned(); + let loader_id = self.committed_document_loader_id_for_session_owner(session_id)?; self.target_owner_state_for_session(session_id)? .page_resource_store .main_document_for_loader(&loader_id) @@ -1006,19 +996,23 @@ fn runtime_observable_console_payloads( #[cfg(test)] mod tests { use super::*; - use crate::conn::ParkedPageSessionState; fn active_session_state_mut(browser_context: &mut BrowserContext) -> TargetSessionStateMut<'_> { + let (devtools_session_state, network_policy, tls_verify_host_override) = + browser_context.active_frontend_and_policy_state_mut(); TargetSessionStateMut::Active { - devtools_session_state: &mut browser_context.devtools_session_state, - network_policy: &mut browser_context.network_policy, - tls_verify_host_override: &mut browser_context.tls_verify_host_override, + devtools_session_state, + network_policy, + tls_verify_host_override, } } - fn parked_session_state_mut(state: &mut ParkedPageSessionState) -> TargetSessionStateMut<'_> { + fn parked_session_state_mut<'a>( + state: &'a mut ParkedPageSessionState, + devtools_session_state: &'a mut DevToolsSessionState, + ) -> TargetSessionStateMut<'a> { TargetSessionStateMut::Parked { - devtools_session_state: &mut state.devtools_session_state, + devtools_session_state, network_policy: &mut state.network_policy, tls_verify_host_override: &mut state.tls_verify_host_override, } @@ -1043,95 +1037,84 @@ mod tests { assert!( active - .devtools_session_state + .devtools_session_state() .console_output_session_state .console_enabled ); - assert!(active.devtools_session_state.page_session_state.log_enabled); assert!( active - .devtools_session_state + .devtools_session_state() + .page_session_state + .log_enabled + ); + assert!( + active + .devtools_session_state() .page_session_state .page_file_chooser_opened_event_enabled ); assert!( active - .devtools_session_state + .devtools_session_state() .page_session_state .page_bypass_csp_enabled ); assert_eq!( active - .devtools_session_state + .devtools_session_state() .page_session_state .page_font_families, font_families ); assert!( active - .devtools_session_state + .devtools_session_state() .page_session_state .page_intercept_file_chooser_dialog_enabled ); assert!( active - .devtools_session_state + .devtools_session_state() .page_session_state .performance .enabled() ); let mut parked = ParkedPageSessionState::default(); - parked_session_state_mut(&mut parked).set_console_enabled(true); - parked_session_state_mut(&mut parked).set_log_enabled(true); - parked_session_state_mut(&mut parked).set_page_file_chooser_opened_event_enabled(true); - parked_session_state_mut(&mut parked).set_page_bypass_csp_enabled(true); - parked_session_state_mut(&mut parked).set_page_font_families(font_families.clone()); - parked_session_state_mut(&mut parked).set_page_intercept_file_chooser_dialog_enabled(true); + let mut parked_devtools = DevToolsSessionState::default(); + parked_session_state_mut(&mut parked, &mut parked_devtools).set_console_enabled(true); + parked_session_state_mut(&mut parked, &mut parked_devtools).set_log_enabled(true); + parked_session_state_mut(&mut parked, &mut parked_devtools) + .set_page_file_chooser_opened_event_enabled(true); + parked_session_state_mut(&mut parked, &mut parked_devtools) + .set_page_bypass_csp_enabled(true); + parked_session_state_mut(&mut parked, &mut parked_devtools) + .set_page_font_families(font_families.clone()); + parked_session_state_mut(&mut parked, &mut parked_devtools) + .set_page_intercept_file_chooser_dialog_enabled(true); assert!( - parked_session_state_mut(&mut parked) + parked_session_state_mut(&mut parked, &mut parked_devtools) .enable_performance(PerformanceTimeDomain::TimeTicks) ); + assert!(parked_devtools.console_output_session_state.console_enabled); + assert!(parked_devtools.page_session_state.log_enabled); assert!( - parked - .devtools_session_state - .console_output_session_state - .console_enabled - ); - assert!(parked.devtools_session_state.page_session_state.log_enabled); - assert!( - parked - .devtools_session_state + parked_devtools .page_session_state .page_file_chooser_opened_event_enabled ); - assert!( - parked - .devtools_session_state - .page_session_state - .page_bypass_csp_enabled - ); + assert!(parked_devtools.page_session_state.page_bypass_csp_enabled); assert_eq!( - parked - .devtools_session_state - .page_session_state - .page_font_families, + parked_devtools.page_session_state.page_font_families, font_families ); assert!( - parked - .devtools_session_state + parked_devtools .page_session_state .page_intercept_file_chooser_dialog_enabled ); - assert!( - parked - .devtools_session_state - .page_session_state - .performance - .enabled() - ); + assert!(parked_devtools.page_session_state.performance.enabled()); TargetSessionStateMut::NoLoaded.set_console_enabled(true); TargetSessionStateMut::NoLoaded.set_log_enabled(true); @@ -1187,8 +1170,10 @@ mod tests { #[test] fn file_dialog_opened_target_listener_can_be_disabled_after_enable() { - let mut conn = CdpConnection::default(); - conn.browser_context = Some(BrowserContext::new("BID-file-dialog".to_owned())); + let mut conn = CdpConnection { + browser_context: Some(BrowserContext::new("BID-file-dialog".to_owned())), + ..Default::default() + }; conn.browser_context .as_mut() .expect("browser context") @@ -1199,7 +1184,7 @@ mod tests { conn.browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .page_session_state .page_file_chooser_opened_event_enabled ); @@ -1210,7 +1195,7 @@ mod tests { .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .page_session_state .page_file_chooser_opened_event_enabled ); diff --git a/moli-protocol/src/conn/browser_context/page_residence_projection.rs b/moli-protocol/src/conn/browser_context/page_residence_projection.rs new file mode 100644 index 0000000000..116a2788ac --- /dev/null +++ b/moli-protocol/src/conn/browser_context/page_residence_projection.rs @@ -0,0 +1,197 @@ +use moli_core::{ + browser_host::{BrowserNavigationFailure, BrowserPageOwnerKey, BrowserPageRuntimeOwner}, + page::{Page, RendererPageCreationArtifacts}, +}; +use url::Url; + +use crate::conn::{ + CdpConnection, DocumentNavigationToken, InitialDocumentPageInstallResult, + InitialDocumentPageOwner, +}; + +use super::page_residence_projection_error::PageResidenceProjectionError; + +mod transaction; + +/// Same-turn physical Page participant for Core-owned residence transitions. +/// +/// Every fallible physical check completes before Core mutation. Projection +/// after a successful Core commit uses only the staged payload and the exact +/// shared Page capability proven by the permit. Transaction staging and exact +/// slot restoration live in the focused `transaction` module; this module only +/// orchestrates owner outcomes and asynchronous Page disposal. +impl CdpConnection { + pub(crate) async fn install_initial_loaded_page_for_page_owner_async( + &mut self, + owner: &InitialDocumentPageOwner, + page: Page, + page_creation_artifacts: RendererPageCreationArtifacts, + ) -> Result { + let browser_owner = BrowserPageOwnerKey::new(&owner.browser_context_id, &owner.target_id); + if let Err(error) = self.validate_browser_target_topology_projection() { + let _ = page.close_async().await; + return Err(error.into()); + } + let Some(permit) = self + .browser_host_state + .navigation_owner() + .prepare_initial_document_page_materialization(&browser_owner) + else { + let _ = page.close_async().await; + return Ok(InitialDocumentPageInstallResult::Stale); + }; + let Some(loader_id) = self + .browser_host_state + .navigation_owner() + .target_initial_empty_document(&browser_owner) + .map(|document| document.loader_id()) + else { + let _ = page.close_async().await; + return Ok(InitialDocumentPageInstallResult::Stale); + }; + let staged = match self.stage_physical_page_residence_projection(&permit, true) { + Ok(staged) => staged, + Err(error) => { + let _ = page.close_async().await; + return Err(error); + } + }; + + let mut page_runtime_owner = Some(BrowserPageRuntimeOwner::new(page)); + let mut renderer_page_owner = page_runtime_owner + .as_mut() + .and_then(BrowserPageRuntimeOwner::take_renderer_lifetime_owner); + if renderer_page_owner.is_none() { + staged.restore(self); + if let Some(page) = page_runtime_owner + .take() + .and_then(BrowserPageRuntimeOwner::into_page) + { + let _ = page.close_async().await; + } + return Err(PageResidenceProjectionError::RendererPageOwnerMissing { + browser_context_id: browser_owner.browser_context_id().to_owned(), + target_id: browser_owner.target_id().to_owned(), + }); + } + + let transition = self + .browser_host_state + .commit_initial_document_page_materialization( + permit, + &mut renderer_page_owner, + &mut page_runtime_owner, + ); + let mut transition = match transition { + Ok(transition) => transition, + Err(error) => { + staged.restore(self); + tracing::debug!( + %error, + target_id = browser_owner.target_id(), + "prepared initial Document Page materialization became stale" + ); + if let Some(owner) = renderer_page_owner.take() { + let detached_owner = match page_runtime_owner.as_mut() { + Some(runtime) => runtime.try_restore_renderer_lifetime_owner(owner).err(), + None => Some(owner), + }; + if let Some(owner) = detached_owner { + let _ = owner.close_async().await; + } + } + if let Some(page) = page_runtime_owner + .take() + .and_then(BrowserPageRuntimeOwner::into_page) + { + let _ = page.close_async().await; + } + return Ok(InitialDocumentPageInstallResult::Stale); + } + }; + staged.project_initial_document_after_browser_owner_commit( + self, + &browser_owner, + page_creation_artifacts, + loader_id, + &transition, + ); + if let Some(retired_owner) = transition.take_retired_renderer_page_owner() { + tracing::warn!( + target_id = browser_owner.target_id(), + "initial Document materialization unexpectedly retired a renderer Page owner" + ); + let _ = retired_owner.close_async().await; + } + debug_assert!( + self.assert_target_materialized_initial_empty_document_has_page(&browser_owner) + .is_ok(), + "committed initial Document materialization must install its staged physical Page" + ); + Ok(InitialDocumentPageInstallResult::Installed) + } + + pub(crate) async fn discard_loaded_page_after_failed_navigation_for_session_owner_async( + &mut self, + session_id: Option<&str>, + navigation: &DocumentNavigationToken, + failure: BrowserNavigationFailure, + final_url: &Url, + ) -> Result, PageResidenceProjectionError> { + let Some(owner) = self.target_page_owner_key_for_session(session_id) else { + return Ok(None); + }; + self.validate_browser_target_topology_projection()?; + let projected_failure = failure.clone(); + let Some(permit) = self + .browser_host_state + .navigation_owner() + .prepare_failed_navigation_page_discard(&owner, navigation, failure) + else { + return Ok(None); + }; + let staged = self.stage_physical_page_residence_projection(&permit, false)?; + let transition = self + .browser_host_state + .commit_failed_navigation_page_discard(permit); + let mut transition = match transition { + Ok(transition) => transition, + Err(error) => { + staged.restore(self); + tracing::debug!( + %error, + target_id = owner.target_id(), + "prepared failed-navigation Page discard became stale" + ); + return Ok(None); + } + }; + let browser_fact_projected = match self + .take_navigation_failure_fact(navigation, &projected_failure) + { + Ok(_) => true, + Err(error) => { + tracing::error!( + %error, + target_id = owner.target_id(), + loader_id = navigation.loader_id(), + "failed-navigation Page transition committed without an exact frontend Browser fact" + ); + false + } + }; + staged.project_failed_navigation_after_browser_owner_commit( + self, + &owner, + final_url, + &transition, + ); + if let Some(retired_owner) = transition.take_retired_renderer_page_owner() { + let _ = retired_owner.close_async().await; + } + Ok(browser_fact_projected.then_some(())) + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-protocol/src/conn/browser_context/page_residence_projection/tests.rs b/moli-protocol/src/conn/browser_context/page_residence_projection/tests.rs new file mode 100644 index 0000000000..cf412fe10c --- /dev/null +++ b/moli-protocol/src/conn/browser_context/page_residence_projection/tests.rs @@ -0,0 +1,238 @@ +use moli_core::browser_host::{ + BrowserInitialEmptyDocumentSeed, BrowserNavigationFailure, BrowserPageOwnerKey, + BrowserPageResidenceHandle, +}; +use url::Url; + +use crate::conn::{BrowserContext, CdpConnection, TargetProjectionError}; + +use super::PageResidenceProjectionError; + +#[tokio::test] +async fn physical_residence_divergence_rejects_failed_discard_before_core_commit() { + let mut connection = CdpConnection::default(); + connection.install_default_browser_target(); + let owner = connection + .target_page_owner_key_for_session(None) + .expect("default Target owner"); + let authoritative = connection + .browser_host_state + .navigation_owner() + .page_residence_handle(&owner) + .expect("authoritative Page residence"); + let generation_before = authoritative.generation(); + let had_loaded_page = connection + .browser_context + .as_ref() + .is_some_and(BrowserContext::has_loaded_page); + let navigation = connection + .start_document_navigation_for_session_owner(None, "LOADER-failed".to_owned()) + .expect("default Target should start failed navigation"); + let wrong_residence = BrowserPageResidenceHandle::default(); + connection + .browser_context + .as_mut() + .expect("selected BrowserContext") + .active_target + .runtime_slot + .page_slot_mut() + .replace_page_residence_handle(wrong_residence.clone()); + + let error = connection + .discard_loaded_page_after_failed_navigation_for_session_owner_async( + None, + &navigation, + BrowserNavigationFailure::Network { + error_text: "net::ERR_FAILED".to_owned(), + }, + &Url::parse("https://failed.example/").expect("failure URL"), + ) + .await + .expect_err("wrong physical residence must reject before Core commit"); + + assert!(matches!( + error, + PageResidenceProjectionError::TargetTopology( + TargetProjectionError::PhysicalPageResidenceMismatch { + ref browser_context_id, + ref target_id, + } + ) if browser_context_id == owner.browser_context_id() && target_id == owner.target_id() + )); + assert_eq!( + authoritative.generation(), + generation_before, + "rejected physical projection must not advance Browser Core" + ); + let physical = connection + .browser_context + .as_ref() + .expect("physical BrowserContext must remain installed"); + assert_eq!( + physical.has_loaded_page(), + had_loaded_page, + "rejected projection must preserve physical Page presence" + ); + assert_eq!( + physical.active_target.runtime_slot.page_residence_handle(), + &wrong_residence, + "typed rejection must preserve the exact divergent physical payload" + ); +} + +#[tokio::test] +async fn occupied_initial_document_slot_is_rejected_before_materialization_commit() { + let context_id = "CTX-occupied-initial"; + let target_id = "TID-occupied-initial"; + let mut context = BrowserContext::new(context_id.to_owned()); + context.set_active_target_id(target_id); + context.mark_active_initial_document_page_build_pending(); + let mut connection = CdpConnection::default(); + connection.insert_browser_context(context); + let owner = BrowserPageOwnerKey::new(context_id, target_id); + connection + .register_target_initial_empty_document_for_test( + &owner, + BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("registered Target should accept initial Document metadata"); + let occupied_page = connection + .load_page_via_runtime_async("data:text/html,occupied") + .await + .expect("occupied Page fixture should load"); + connection + .browser_context + .as_mut() + .expect("selected BrowserContext") + .active_target + .runtime_slot + .set_loaded_page_for_test(occupied_page); + let residence = connection + .browser_host_state + .navigation_owner() + .page_residence_handle(&owner) + .expect("authoritative Page residence"); + let generation_before = residence.generation(); + let permit = connection + .browser_host_state + .navigation_owner() + .prepare_initial_document_page_materialization(&owner) + .expect("initial Document should remain materializable in Core"); + + let error = connection + .stage_physical_page_residence_projection(&permit, true) + .err() + .expect("occupied physical slot must reject staging"); + + assert_eq!( + error, + PageResidenceProjectionError::InitialDocumentPageAlreadyPresent { + browser_context_id: context_id.to_owned(), + target_id: target_id.to_owned(), + } + ); + assert_eq!(residence.generation(), generation_before); + assert!( + !connection + .browser_host_state + .navigation_owner() + .target_initial_empty_document(&owner) + .expect("initial Document metadata must remain registered") + .materialized(), + "physical rejection must happen before Core marks the Document materialized" + ); + assert!( + connection + .browser_context + .as_ref() + .is_some_and(BrowserContext::has_loaded_page), + "rejected staging must restore the occupied physical BrowserContext" + ); +} + +#[tokio::test] +async fn background_failed_discard_restores_exact_target_index_after_projection() { + let mut context = BrowserContext::new("CTX-background-discard".to_owned()); + context.set_active_target_id("TID-active"); + context.stage_background_target( + "TID-before".to_owned(), + Some("SID-before".to_owned()), + "about:blank#before".to_owned(), + None, + None, + ); + context.stage_background_target( + "TID-selected".to_owned(), + Some("SID-selected".to_owned()), + "about:blank#selected".to_owned(), + None, + None, + ); + context.stage_background_target( + "TID-after".to_owned(), + Some("SID-after".to_owned()), + "about:blank#after".to_owned(), + None, + None, + ); + let mut connection = CdpConnection::default(); + connection.insert_browser_context(context); + let residence = connection + .target_page_residence_handle_for_session(Some("SID-selected")) + .expect("background Page residence"); + let generation_before = residence.generation(); + let final_url = Url::parse("https://failed.example/background").expect("failure URL"); + let navigation = connection + .start_document_navigation_for_session_owner( + Some("SID-selected"), + "LOADER-background-failed".to_owned(), + ) + .expect("background Target should start failed navigation"); + let admission_sequence = connection + .last_projected_browser_fact_sequence_for_test() + .expect("navigation admission should claim its exact Browser fact"); + + assert_eq!( + connection + .discard_loaded_page_after_failed_navigation_for_session_owner_async( + Some("SID-selected"), + &navigation, + BrowserNavigationFailure::Network { + error_text: "net::ERR_FAILED".to_owned(), + }, + &final_url, + ) + .await, + Ok(Some(())) + ); + + let context = connection + .browser_context + .as_ref() + .expect("selected BrowserContext must be restored"); + assert_eq!( + context + .background_targets + .iter() + .map(|target| target.target_id()) + .collect::>(), + ["TID-before", "TID-selected", "TID-after"], + "same-turn transaction must restore the exact background index" + ); + assert_eq!( + context + .background_target("TID-selected") + .expect("projected background Target") + .target_url(), + final_url.as_str() + ); + assert_eq!(residence.generation(), generation_before + 1); + assert_eq!( + connection.last_projected_browser_fact_sequence_for_test(), + Some(admission_sequence + 1), + "failed Page discard must consume its exact NavigationFailed fact after admission" + ); + connection + .validate_browser_target_topology_projection() + .expect("Core and physical topology must remain exact"); +} diff --git a/moli-protocol/src/conn/browser_context/page_residence_projection/transaction.rs b/moli-protocol/src/conn/browser_context/page_residence_projection/transaction.rs new file mode 100644 index 0000000000..7e014f9bf6 --- /dev/null +++ b/moli-protocol/src/conn/browser_context/page_residence_projection/transaction.rs @@ -0,0 +1,248 @@ +use moli_core::{ + browser_host::{ + BrowserPageOwnerKey, BrowserPageResidenceTransition, BrowserPageResidenceTransitionKind, + BrowserPageResidenceTransitionPermit, + }, + page::RendererPageCreationArtifacts, +}; +use url::Url; + +use crate::conn::{BackgroundTarget, CdpConnection}; + +use super::super::{ + page_residence_projection_error::PageResidenceProjectionError, + target_context_projection::StagedPhysicalBrowserContext, + target_projection_error::TargetProjectionError, + target_session_owner::clear_top_level_target_loaded_document_session_state, +}; + +enum StagedPhysicalPageTarget { + Active, + Background { + index: usize, + target: Box, + }, +} + +/// Exact physical participant removed from Protocol storage before Browser +/// Core commits a Page-residence transition. +/// +/// No callback or await can observe the vacant BrowserContext slot. A Core +/// rejection restores the exact context and background-target indices; a Core +/// success projects through the already-resolved participant without another +/// fallible id lookup. +pub(super) struct StagedPhysicalPageResidenceProjection { + context: StagedPhysicalBrowserContext, + target: StagedPhysicalPageTarget, +} + +impl StagedPhysicalPageResidenceProjection { + pub(super) fn restore(mut self, connection: &mut CdpConnection) { + if let StagedPhysicalPageTarget::Background { index, target } = self.target { + let insert_index = index.min(self.context.browser_context.background_targets.len()); + debug_assert_eq!( + insert_index, index, + "same-turn Page projection must preserve the background Target vector" + ); + self.context + .browser_context + .background_targets + .insert(insert_index, *target); + } + connection.restore_physical_browser_context_after_target_projection(self.context); + } + + pub(super) fn project_initial_document_after_browser_owner_commit( + mut self, + connection: &mut CdpConnection, + owner: &BrowserPageOwnerKey, + page_creation_artifacts: RendererPageCreationArtifacts, + loader_id: String, + transition: &BrowserPageResidenceTransition, + ) { + debug_assert_eq!(transition.owner(), owner); + debug_assert_eq!( + transition.kind(), + BrowserPageResidenceTransitionKind::InitialDocumentMaterialization + ); + let lifecycle_ingress = match &mut self.target { + StagedPhysicalPageTarget::Active => { + self.context + .browser_context + .project_initial_document_page_after_browser_owner_commit(transition); + self.context + .browser_context + .active_target + .runtime_slot + .page_slot_mut() + .bind_renderer_document_lifecycle_with_ingress( + page_creation_artifacts, + None, + owner.target_id().to_owned(), + loader_id, + ) + } + StagedPhysicalPageTarget::Background { target, .. } => { + self.context + .browser_context + .mutate_parked_target_owner_state(owner.target_id(), |owner_state| { + owner_state.clear_committed_document_navigation_state(); + }); + clear_top_level_target_loaded_document_session_state( + &mut self.context.browser_context, + owner.target_id(), + ); + target.project_initial_document_page_after_browser_owner_commit(transition); + target.runtime_slot.reset_subresource_cursor(); + target.runtime_slot.clear_websocket_artifacts(); + target + .runtime_slot + .page_slot_mut() + .bind_renderer_document_lifecycle_with_ingress( + page_creation_artifacts, + None, + owner.target_id().to_owned(), + loader_id, + ) + } + }; + self.restore(connection); + connection.record_authoritative_renderer_document_lifecycle_facts( + Some(transition.current_page()), + None, + lifecycle_ingress.authoritative(), + ); + } + + pub(super) fn project_failed_navigation_after_browser_owner_commit( + mut self, + connection: &mut CdpConnection, + owner: &BrowserPageOwnerKey, + final_url: &Url, + transition: &BrowserPageResidenceTransition, + ) { + debug_assert_eq!(transition.owner(), owner); + debug_assert_eq!( + transition.kind(), + BrowserPageResidenceTransitionKind::FailedNavigationDiscard + ); + let next_url = final_url.to_string(); + let security_origin = final_url.origin().ascii_serialization(); + match &mut self.target { + StagedPhysicalPageTarget::Active => { + self.context.browser_context.set_target_url(next_url); + self.context + .browser_context + .set_target_security_origin(security_origin); + self.context + .browser_context + .active_target + .runtime_slot + .clear_renderer_document_protocol_state(); + self.context + .browser_context + .clear_active_target_runtime_remote_object_tracking(); + self.context + .browser_context + .project_failed_navigation_page_absence_after_browser_owner_commit(transition); + } + StagedPhysicalPageTarget::Background { target, .. } => { + self.context + .browser_context + .mutate_parked_target_owner_state(owner.target_id(), |owner_state| { + owner_state.clear_committed_document_navigation_state(); + }); + clear_top_level_target_loaded_document_session_state( + &mut self.context.browser_context, + owner.target_id(), + ); + target.set_target_url(next_url); + target.set_target_security_origin(security_origin); + target.runtime_slot.clear_renderer_document_protocol_state(); + target + .project_failed_navigation_page_absence_after_browser_owner_commit(transition); + target.runtime_slot.reset_subresource_cursor(); + target.runtime_slot.clear_websocket_artifacts(); + } + } + self.restore(connection); + } +} + +impl CdpConnection { + pub(super) fn stage_physical_page_residence_projection( + &mut self, + permit: &BrowserPageResidenceTransitionPermit, + require_absent_page: bool, + ) -> Result { + let owner = permit.owner(); + let mut context = + self.take_physical_browser_context_for_target_projection(owner.browser_context_id())?; + let background_index = + if context.browser_context.active_target_id() == Some(owner.target_id()) { + None + } else { + let Some(index) = context + .browser_context + .background_targets + .iter() + .position(|target| target.is_target(owner.target_id())) + else { + self.restore_physical_browser_context_after_target_projection(context); + return Err(TargetProjectionError::PhysicalTargetMissing { + browser_context_id: owner.browser_context_id().to_owned(), + target_id: owner.target_id().to_owned(), + } + .into()); + }; + Some(index) + }; + + let (residence_matches, has_loaded_page) = match background_index { + None => { + let runtime_slot = &context.browser_context.active_target.runtime_slot; + ( + runtime_slot + .page_residence_handle() + .is_current(permit.previous_page()), + runtime_slot.has_loaded_page(), + ) + } + Some(index) => { + let target = &context.browser_context.background_targets[index]; + ( + target + .runtime_slot() + .page_residence_handle() + .is_current(permit.previous_page()), + target.has_loaded_page(), + ) + } + }; + if !residence_matches { + self.restore_physical_browser_context_after_target_projection(context); + return Err(PageResidenceProjectionError::PhysicalResidenceMismatch { + browser_context_id: owner.browser_context_id().to_owned(), + target_id: owner.target_id().to_owned(), + }); + } + if require_absent_page && has_loaded_page { + self.restore_physical_browser_context_after_target_projection(context); + return Err( + PageResidenceProjectionError::InitialDocumentPageAlreadyPresent { + browser_context_id: owner.browser_context_id().to_owned(), + target_id: owner.target_id().to_owned(), + }, + ); + } + + let target = match background_index { + None => StagedPhysicalPageTarget::Active, + Some(index) => StagedPhysicalPageTarget::Background { + index, + target: Box::new(context.browser_context.background_targets.remove(index)), + }, + }; + Ok(StagedPhysicalPageResidenceProjection { context, target }) + } +} diff --git a/moli-protocol/src/conn/browser_context/page_residence_projection_error.rs b/moli-protocol/src/conn/browser_context/page_residence_projection_error.rs new file mode 100644 index 0000000000..3f4cea735c --- /dev/null +++ b/moli-protocol/src/conn/browser_context/page_residence_projection_error.rs @@ -0,0 +1,66 @@ +use super::TargetProjectionError; + +/// A physical Page participant was rejected before Browser Core changed the +/// authoritative Page residence. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum PageResidenceProjectionError { + TargetTopology(TargetProjectionError), + PhysicalResidenceMismatch { + browser_context_id: String, + target_id: String, + }, + InitialDocumentPageAlreadyPresent { + browser_context_id: String, + target_id: String, + }, + RendererPageOwnerMissing { + browser_context_id: String, + target_id: String, + }, +} + +impl std::fmt::Display for PageResidenceProjectionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::TargetTopology(error) => error.fmt(formatter), + Self::PhysicalResidenceMismatch { + browser_context_id, + target_id, + } => write!( + formatter, + "physical Page residence for Target {target_id:?} in BrowserContext {browser_context_id:?} does not match the prepared Browser Core permit" + ), + Self::InitialDocumentPageAlreadyPresent { + browser_context_id, + target_id, + } => write!( + formatter, + "initial Document materialization for Target {target_id:?} in BrowserContext {browser_context_id:?} requires an empty physical Page slot" + ), + Self::RendererPageOwnerMissing { + browser_context_id, + target_id, + } => write!( + formatter, + "Page candidate for Target {target_id:?} in BrowserContext {browser_context_id:?} has no renderer lifetime owner" + ), + } + } +} + +impl std::error::Error for PageResidenceProjectionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::TargetTopology(error) => Some(error), + Self::PhysicalResidenceMismatch { .. } + | Self::InitialDocumentPageAlreadyPresent { .. } + | Self::RendererPageOwnerMissing { .. } => None, + } + } +} + +impl From for PageResidenceProjectionError { + fn from(error: TargetProjectionError) -> Self { + Self::TargetTopology(error) + } +} diff --git a/moli-protocol/src/conn/browser_context/registry_projection.rs b/moli-protocol/src/conn/browser_context/registry_projection.rs new file mode 100644 index 0000000000..afe7f08d7d --- /dev/null +++ b/moli-protocol/src/conn/browser_context/registry_projection.rs @@ -0,0 +1,659 @@ +use std::collections::HashSet; + +use moli_core::browser_host::{ + BrowserContextDisposalReservation, BrowserContextId, BrowserContextRegistrationMetadata, + BrowserContextRegistryError, BrowserContextRemovalPermit, BrowserContextRuntimeRegistryError, + BrowserContextSelectionProjection, +}; +use moli_core::page::RendererPageLifetimeOwner; +use moli_core::runtime::RendererBrowserContextRuntimeOwner; + +use crate::devtools_runtime::{DevToolsError, DevToolsErrorKind}; + +use super::{BrowserContext, BrowserEngineReplacementInputs, CdpConnection}; + +pub(super) struct ProjectedBrowserContextRemoval { + pub(super) browser_context: BrowserContext, + pub(super) selection_changed: bool, + retired_renderer_page_owners: Vec, + renderer_runtime_owner: RendererBrowserContextRuntimeOwner, +} + +impl ProjectedBrowserContextRemoval { + pub(super) fn into_parts( + self, + ) -> ( + BrowserContext, + bool, + Vec, + RendererBrowserContextRuntimeOwner, + ) { + ( + self.browser_context, + self.selection_changed, + self.retired_renderer_page_owners, + self.renderer_runtime_owner, + ) + } +} + +/// A Core BrowserContext transaction or its same-turn physical projection was +/// rejected before the two registries could diverge. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum BrowserContextProjectionError { + Core(BrowserContextRegistryError), + Runtime(BrowserContextRuntimeRegistryError), + PhysicalContextCountMismatch { + authoritative: usize, + projected: usize, + }, + PhysicalSelectionMismatch { + authoritative: Option, + projected: Option, + }, + DuplicatePhysicalContext(String), + PhysicalContextMissingFromCore(String), + PhysicalContextMissing(String), + PhysicalRuntimeOwnerMissing(String), + UnexpectedUnchangedActivation(String), +} + +impl BrowserContextProjectionError { + pub(crate) fn is_unknown_context(&self) -> bool { + matches!( + self, + Self::Core(BrowserContextRegistryError::UnknownBrowserContext(_)) + | Self::Runtime(BrowserContextRuntimeRegistryError::Context( + BrowserContextRegistryError::UnknownBrowserContext(_) + )) + ) + } +} + +impl std::fmt::Display for BrowserContextProjectionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Core(error) => error.fmt(formatter), + Self::Runtime(error) => error.fmt(formatter), + Self::PhysicalContextCountMismatch { + authoritative, + projected, + } => write!( + formatter, + "Browser Core has {authoritative} BrowserContexts but the physical projection has {projected}" + ), + Self::PhysicalSelectionMismatch { + authoritative, + projected, + } => write!( + formatter, + "authoritative selected BrowserContext {authoritative:?} does not match physical selection {projected:?}" + ), + Self::DuplicatePhysicalContext(browser_context_id) => write!( + formatter, + "physical BrowserContext projection contains duplicate identity {browser_context_id:?}" + ), + Self::PhysicalContextMissingFromCore(browser_context_id) => write!( + formatter, + "physical BrowserContext {browser_context_id:?} is not registered in Browser Core" + ), + Self::PhysicalContextMissing(browser_context_id) => write!( + formatter, + "Core-known BrowserContext {browser_context_id:?} has no exact physical projection" + ), + Self::PhysicalRuntimeOwnerMissing(browser_context_id) => write!( + formatter, + "new physical BrowserContext {browser_context_id:?} has no renderer runtime owner to register" + ), + Self::UnexpectedUnchangedActivation(browser_context_id) => write!( + formatter, + "Core reported unchanged activation for physically inactive BrowserContext {browser_context_id:?}" + ), + } + } +} + +impl std::error::Error for BrowserContextProjectionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Core(error) => Some(error), + Self::Runtime(error) => Some(error), + _ => None, + } + } +} + +impl From for BrowserContextProjectionError { + fn from(error: BrowserContextRegistryError) -> Self { + Self::Core(error) + } +} + +impl From for BrowserContextProjectionError { + fn from(error: BrowserContextRuntimeRegistryError) -> Self { + match error { + BrowserContextRuntimeRegistryError::Context(error) => Self::Core(error), + error => Self::Runtime(error), + } + } +} + +impl From for DevToolsError { + fn from(error: BrowserContextProjectionError) -> Self { + Self::new( + DevToolsErrorKind::Internal, + format!("BrowserContextProjectionRejected: {error}"), + ) + } +} + +/// Transitional physical projection for Core-owned BrowserContext topology. +/// +/// This module first validates the complete physical identity set. Operations +/// that move or remove an existing payload stage that `Option`/`Vec` change, +/// call Core in the same actor turn, and either publish the matching projection +/// or restore the exact physical slots on typed rejection. It is intentionally +/// separate from command/session lifecycle orchestration so CDP state cannot +/// become part of the authoritative registry transaction. +impl CdpConnection { + pub(super) fn validate_browser_context_topology_projection( + &self, + ) -> Result<(), BrowserContextProjectionError> { + let projected_contexts = self + .browser_contexts() + .map(|browser_context| { + ( + browser_context.id.clone(), + browser_context.browser_context_handle().clone(), + ) + }) + .collect::>(); + let authoritative_count = self.registered_browser_context_count(); + if projected_contexts.len() != authoritative_count { + return Err( + BrowserContextProjectionError::PhysicalContextCountMismatch { + authoritative: authoritative_count, + projected: projected_contexts.len(), + }, + ); + } + let projected_selection = self + .browser_context + .as_ref() + .map(|context| context.id.as_str()); + let authoritative_selection = self + .browser_host_state + .navigation_owner() + .selected_browser_context_id() + .map(str::to_owned); + if projected_selection != authoritative_selection.as_deref() { + return Err(BrowserContextProjectionError::PhysicalSelectionMismatch { + authoritative: authoritative_selection, + projected: projected_selection.map(str::to_owned), + }); + } + + let mut unique_ids = HashSet::with_capacity(projected_contexts.len()); + for (browser_context_id, browser_context_handle) in projected_contexts { + if !unique_ids.insert(browser_context_id.clone()) { + return Err(BrowserContextProjectionError::DuplicatePhysicalContext( + browser_context_id, + )); + } + if !self + .browser_host_state + .navigation_owner() + .has_browser_context(&browser_context_id) + { + return Err( + BrowserContextProjectionError::PhysicalContextMissingFromCore( + browser_context_id, + ), + ); + } + if !self + .browser_host_state + .navigation_owner() + .browser_context_handle_is_current(&browser_context_handle) + { + return Err( + BrowserContextRegistryError::BrowserContextHandleProjectionMismatch( + BrowserContextId::new(browser_context_id), + ) + .into(), + ); + } + } + Ok(()) + } + + pub(crate) fn debug_assert_browser_context_topology_projection(&self) { + let validation = self.validate_browser_context_topology_projection(); + debug_assert!( + validation.is_ok(), + "Browser Core and physical BrowserContext topology diverged: {validation:?}" + ); + #[cfg(debug_assertions)] + self.debug_assert_browser_target_topology_projection(); + } + + pub(super) fn selected_browser_context_projection(&self) -> BrowserContextSelectionProjection { + BrowserContextSelectionProjection::new( + self.browser_context + .as_ref() + .map(|context| context.id.clone()), + self.selected_target_engine_disposition(), + ) + } + + pub(super) fn register_browser_context_projection_with_metadata( + &mut self, + mut browser_context: BrowserContext, + mut registration_metadata: BrowserContextRegistrationMetadata, + ) -> Result { + self.validate_browser_context_topology_projection()?; + let browser_context_id = browser_context.id.clone(); + let browser_context_handle = browser_context.browser_context_handle().clone(); + let target_topology = Self::browser_target_topology_projection(&browser_context); + let registered_target_ids = target_topology + .active_target_id() + .into_iter() + .chain(target_topology.background_target_ids()) + .map(str::to_owned) + .collect::>(); + for target_id in ®istered_target_ids { + if let Some(store) = browser_context.target_session_storage_store(target_id) { + registration_metadata = registration_metadata + .with_target_session_storage_store(target_id.clone(), store); + } + } + let renderer_runtime_owner = browser_context + .take_renderer_runtime_owner_for_host_registration() + .ok_or_else(|| { + BrowserContextProjectionError::PhysicalRuntimeOwnerMissing( + browser_context_id.clone(), + ) + })?; + let projection = self.selected_browser_context_projection(); + let replacement_inputs = BrowserEngineReplacementInputs::capture(self); + let registration = self + .browser_host_state + .register_browser_context_with_runtime( + browser_context_id.clone(), + browser_context_handle, + registration_metadata, + target_topology, + projection, + renderer_runtime_owner, + |renderer_runtime| replacement_inputs.create_engine(renderer_runtime), + ) + .map_err(BrowserContextProjectionError::from)?; + debug_assert_eq!(registration.browser_context_id(), browser_context_id); + + for (target_id, access) in registration.target_session_storage_accesses() { + let bound = browser_context.bind_target_session_storage_access(access.clone()); + debug_assert!( + bound, + "prevalidated physical Target {target_id:?} must accept its exact Core sessionStorage access" + ); + } + + // Context bootstrap/resnapshot is a current-state projection, not a + // delayed replay of creation occurrences. Consume each exact Core + // occurrence now; a later Target.setDiscoverTargets enumerates the + // authoritative live topology independently. + for target_id in registered_target_ids { + let Some(page) = self + .browser_host_state + .navigation_owner() + .capture_page_residence(&browser_context_id, &target_id) + else { + tracing::error!( + browser_context_id, + target_id, + "BrowserContext registration committed a Target without a Page fact identity" + ); + continue; + }; + match self.take_target_created_fact(&page) { + Ok(projection) => tracing::trace!( + browser_fact_sequence = projection.envelope().sequence().get(), + browser_context_id, + target_id, + "consumed bootstrap Target creation fact before discovery resnapshot" + ), + Err(error) => tracing::error!( + %error, + browser_context_id, + target_id, + "BrowserContext Target committed without an exact frontend creation fact" + ), + } + } + + let selected = if registration.is_selected() { + self.browser_context = Some(browser_context); + true + } else { + self.inactive_browser_contexts.push(browser_context); + false + }; + self.debug_assert_browser_context_topology_projection(); + Ok(selected) + } + + pub(super) fn activate_browser_context_projection_by_id( + &mut self, + browser_context_id: &str, + ) -> Result { + self.validate_browser_context_topology_projection()?; + if !self + .browser_host_state + .navigation_owner() + .has_browser_context(browser_context_id) + { + return Err( + BrowserContextRegistryError::UnknownBrowserContext(BrowserContextId::new( + browser_context_id, + )) + .into(), + ); + } + + if self + .browser_context + .as_ref() + .is_some_and(|context| context.id == browser_context_id) + { + let renderer_runtime = self + .browser_context + .as_ref() + .map(BrowserContext::renderer_runtime_owner_access) + .ok_or_else(|| { + BrowserContextProjectionError::PhysicalContextMissing( + browser_context_id.to_owned(), + ) + })?; + let projection = self.selected_browser_context_projection(); + let replacement_inputs = BrowserEngineReplacementInputs::capture(self); + let activation = self + .browser_host_state + .activate_browser_context(browser_context_id, projection, || { + replacement_inputs.create_engine(renderer_runtime) + }) + .map_err(BrowserContextProjectionError::Core)?; + debug_assert!(!activation.changed()); + debug_assert_eq!(activation.browser_context_id(), browser_context_id); + self.debug_assert_browser_context_topology_projection(); + return Ok(false); + } + + let inactive_index = self + .inactive_browser_contexts + .iter() + .position(|context| context.id == browser_context_id) + .ok_or_else(|| { + BrowserContextProjectionError::PhysicalContextMissing(browser_context_id.to_owned()) + })?; + let renderer_runtime = + self.inactive_browser_contexts[inactive_index].renderer_runtime_owner_access(); + let previous_browser_context_id = self + .browser_context + .as_ref() + .map(|context| context.id.clone()) + .ok_or_else(|| { + BrowserContextProjectionError::PhysicalContextMissing("".to_owned()) + })?; + let projection = self.selected_browser_context_projection(); + let replacement_inputs = BrowserEngineReplacementInputs::capture(self); + + // Stage the physical swap before entering Core. The staged values are + // restored at their exact slots when Core rejects the projection, so + // a typed owner error cannot leave Protocol half-switched. + let candidate = self.inactive_browser_contexts.remove(inactive_index); + let Some(previous) = self.browser_context.take() else { + self.inactive_browser_contexts + .insert(inactive_index, candidate); + return Err(BrowserContextProjectionError::PhysicalContextMissing( + previous_browser_context_id, + )); + }; + let activation = self.browser_host_state.activate_browser_context( + browser_context_id, + projection, + || replacement_inputs.create_engine(renderer_runtime), + ); + let activation = match activation { + Ok(activation) => activation, + Err(error) => { + self.browser_context = Some(previous); + self.inactive_browser_contexts + .insert(inactive_index, candidate); + return Err(BrowserContextProjectionError::Core(error)); + } + }; + if !activation.changed() { + self.browser_context = Some(previous); + self.inactive_browser_contexts + .insert(inactive_index, candidate); + return Err( + BrowserContextProjectionError::UnexpectedUnchangedActivation( + browser_context_id.to_owned(), + ), + ); + } + debug_assert_eq!( + activation.previous_browser_context_id(), + previous_browser_context_id + ); + debug_assert_eq!(activation.browser_context_id(), browser_context_id); + self.browser_context = Some(candidate); + self.inactive_browser_contexts.push(previous); + self.debug_assert_browser_context_topology_projection(); + Ok(true) + } + + #[cfg(test)] + pub(super) fn remove_browser_context_projection_by_id( + &mut self, + browser_context_id: &str, + ) -> Result { + self.validate_browser_context_topology_projection()?; + let browser_context_handle = self + .browser_context_by_id(browser_context_id) + .ok_or_else(|| { + BrowserContextProjectionError::PhysicalContextMissing(browser_context_id.to_owned()) + })? + .browser_context_handle() + .clone(); + let permit = self + .browser_host_state + .navigation_owner() + .prepare_browser_context_removal_for_handle(&browser_context_handle) + .map_err(BrowserContextProjectionError::Core)?; + self.remove_browser_context_projection_with_permit(browser_context_id, permit) + } + + /// Removes the exact physical Context already reserved by Browser Host. + /// + /// Selection and successor are intentionally resolved here, in the final + /// owner turn. A frontend selection change that occurred while disposal + /// participants were pending therefore cannot be overwritten by a stale + /// restore decision captured at admission. + pub(super) fn remove_browser_context_projection_for_disposal( + &mut self, + reservation: &BrowserContextDisposalReservation, + ) -> Result { + self.validate_browser_context_topology_projection()?; + let browser_context_id = reservation.browser_context_id(); + let physical_handle = self + .browser_context_by_id(browser_context_id) + .ok_or_else(|| { + BrowserContextProjectionError::PhysicalContextMissing(browser_context_id.to_owned()) + })? + .browser_context_handle(); + if physical_handle != reservation.browser_context_handle() { + return Err(BrowserContextProjectionError::Core( + BrowserContextRegistryError::BrowserContextHandleProjectionMismatch( + BrowserContextId::new(browser_context_id), + ), + )); + } + let permit = self + .browser_host_state + .navigation_owner() + .prepare_browser_context_removal_for_disposal(reservation) + .map_err(BrowserContextProjectionError::Core)?; + self.remove_browser_context_projection_with_permit(browser_context_id, permit) + } + + fn remove_browser_context_projection_with_permit( + &mut self, + browser_context_id: &str, + permit: BrowserContextRemovalPermit, + ) -> Result { + let projection = self.selected_browser_context_projection(); + + if permit.was_selected() { + if self + .browser_context + .as_ref() + .map(|context| context.id.as_str()) + != Some(browser_context_id) + { + return Err(BrowserContextProjectionError::PhysicalSelectionMismatch { + authoritative: Some(browser_context_id.to_owned()), + projected: self + .browser_context + .as_ref() + .map(|context| context.id.clone()), + }); + } + if let Some(successor_browser_context_id) = + permit.successor_browser_context_id().map(str::to_owned) + { + let successor_index = self + .inactive_browser_contexts + .iter() + .position(|context| context.id == successor_browser_context_id) + .ok_or_else(|| { + BrowserContextProjectionError::PhysicalContextMissing( + successor_browser_context_id.clone(), + ) + })?; + let successor = &self.inactive_browser_contexts[successor_index]; + let successor_renderer_runtime = successor.renderer_runtime_owner_access(); + let replacement_inputs = BrowserEngineReplacementInputs::capture(self); + let successor = self.inactive_browser_contexts.remove(successor_index); + let Some(removed) = self.browser_context.take() else { + self.inactive_browser_contexts + .insert(successor_index, successor); + return Err(BrowserContextProjectionError::PhysicalContextMissing( + browser_context_id.to_owned(), + )); + }; + let removal = self + .browser_host_state + .commit_browser_context_removal_with_successor_runtime( + permit, + projection, + || replacement_inputs.create_engine(successor_renderer_runtime), + ); + let (mut removal, renderer_runtime_owner) = match removal { + Ok(removal) => removal, + Err(error) => { + self.browser_context = Some(removed); + self.inactive_browser_contexts + .insert(successor_index, successor); + return Err(error.into()); + } + }; + debug_assert_eq!(removal.browser_context_id(), browser_context_id); + debug_assert_eq!( + removal.selected_browser_context_id(), + Some(successor_browser_context_id.as_str()) + ); + let retired_renderer_page_owners = removal.take_retired_renderer_page_owners(); + + self.browser_context = Some(successor); + self.debug_assert_browser_context_topology_projection(); + return Ok(ProjectedBrowserContextRemoval { + browser_context: removed, + selection_changed: true, + retired_renderer_page_owners, + renderer_runtime_owner, + }); + } + + let Some(browser_context) = self.browser_context.take() else { + return Err(BrowserContextProjectionError::PhysicalContextMissing( + browser_context_id.to_owned(), + )); + }; + let replacement_inputs = BrowserEngineReplacementInputs::capture(self); + let removal = self + .browser_host_state + .commit_browser_context_removal_with_runtime(permit, projection, || { + replacement_inputs.create_unbound_engine() + }); + let (mut removal, renderer_runtime_owner) = match removal { + Ok(removal) => removal, + Err(error) => { + self.browser_context = Some(browser_context); + return Err(error.into()); + } + }; + debug_assert_eq!(removal.browser_context_id(), browser_context_id); + debug_assert_eq!(removal.selected_browser_context_id(), None); + let retired_renderer_page_owners = removal.take_retired_renderer_page_owners(); + self.debug_assert_browser_context_topology_projection(); + return Ok(ProjectedBrowserContextRemoval { + browser_context, + selection_changed: true, + retired_renderer_page_owners, + renderer_runtime_owner, + }); + } + + let inactive_index = self + .inactive_browser_contexts + .iter() + .position(|context| context.id == browser_context_id) + .ok_or_else(|| { + BrowserContextProjectionError::PhysicalContextMissing(browser_context_id.to_owned()) + })?; + let browser_context = self.inactive_browser_contexts.remove(inactive_index); + let replacement_inputs = BrowserEngineReplacementInputs::capture(self); + let removal = self + .browser_host_state + .commit_browser_context_removal_with_runtime(permit, projection, || { + replacement_inputs.create_unbound_engine() + }); + let (mut removal, renderer_runtime_owner) = match removal { + Ok(removal) => removal, + Err(error) => { + self.inactive_browser_contexts + .insert(inactive_index, browser_context); + return Err(error.into()); + } + }; + debug_assert_eq!(removal.browser_context_id(), browser_context_id); + debug_assert_eq!( + removal.selected_browser_context_id(), + self.browser_context + .as_ref() + .map(|context| context.id.as_str()) + ); + let retired_renderer_page_owners = removal.take_retired_renderer_page_owners(); + self.debug_assert_browser_context_topology_projection(); + Ok(ProjectedBrowserContextRemoval { + browser_context, + selection_changed: false, + retired_renderer_page_owners, + renderer_runtime_owner, + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-protocol/src/conn/browser_context/registry_projection/tests.rs b/moli-protocol/src/conn/browser_context/registry_projection/tests.rs new file mode 100644 index 0000000000..84f949ea60 --- /dev/null +++ b/moli-protocol/src/conn/browser_context/registry_projection/tests.rs @@ -0,0 +1,495 @@ +use moli_core::browser_host::{ + BrowserContextHandle, BrowserContextRegistrationMetadata, BrowserContextRegistryError, + BrowserFact, BrowserPageOwnerKey, +}; +use std::sync::Arc; + +use super::{BrowserContextProjectionError, CdpConnection}; +use crate::conn::BrowserContext; + +fn connection_with_two_contexts() -> CdpConnection { + let mut connection = CdpConnection::new(); + let mut first = connection.new_browser_context("context-a".to_owned()); + first.set_active_target_id("target-a"); + connection + .try_insert_browser_context(first) + .expect("first BrowserContext should register"); + let mut second = connection.new_browser_context("context-b".to_owned()); + second.set_active_target_id("target-b"); + connection + .try_insert_browser_context(second) + .expect("second BrowserContext should register"); + connection +} + +fn connection_with_two_empty_contexts() -> CdpConnection { + let mut connection = CdpConnection::new(); + connection + .try_insert_browser_context(BrowserContext::new("context-a".to_owned())) + .expect("first BrowserContext should register"); + connection + .try_insert_browser_context(BrowserContext::new("context-b".to_owned())) + .expect("second BrowserContext should register"); + connection +} + +fn inject_physical_engine_owner_divergence(connection: &mut CdpConnection) -> BrowserPageOwnerKey { + connection + .browser_context + .as_mut() + .expect("selected physical BrowserContext") + .set_active_target_id("physical-only-target"); + BrowserPageOwnerKey::new("context-a", "physical-only-target") +} + +fn assert_original_physical_context_order(connection: &CdpConnection) { + assert_eq!( + connection + .browser_context + .as_ref() + .map(|context| context.id.as_str()), + Some("context-a") + ); + assert_eq!( + connection + .inactive_browser_contexts + .iter() + .map(|context| context.id.as_str()) + .collect::>(), + vec!["context-b"] + ); +} + +#[test] +fn default_target_creation_metadata_commits_with_browser_context_registration() { + let mut connection = CdpConnection::new(); + let owner = BrowserPageOwnerKey::new( + connection.default_browser_context_id(), + connection.default_target_id(), + ); + + connection.install_default_browser_target(); + + let initial_document = connection + .target_initial_empty_document_for_owner(&owner) + .expect("default Target registration must install initial Document metadata"); + assert_eq!(initial_document.initial_url(), "about:blank"); + assert!(initial_document.is_on_initial_empty_document()); + assert_eq!(connection.registered_browser_context_count(), 1); + + let creation = connection + .browser_fact_snapshot_for_test() + .into_iter() + .find(|fact| { + fact.target_id().as_str() == owner.target_id() + && matches!(fact.fact(), BrowserFact::TargetCreated) + }) + .expect("bootstrap Target creation should remain in the Browser journal"); + assert_eq!( + creation.page_residence().browser_context_id(), + owner.browser_context_id() + ); + assert_eq!( + connection.last_projected_browser_fact_sequence_for_test(), + Some(creation.sequence().get()), + "bootstrap projection must consume the occurrence before later discovery resnapshot" + ); +} + +#[test] +fn browser_context_registration_moves_all_target_session_storage_into_core() { + let mut connection = CdpConnection::new(); + let mut browser_context = connection.new_browser_context("context-storage".to_owned()); + browser_context.set_active_target_id("target-storage"); + browser_context.stage_background_target( + "target-background", + None, + "about:blank#background".to_owned(), + None, + None, + ); + let candidate_store = browser_context + .target_session_storage_store("target-storage") + .expect("candidate Target namespace"); + let background_candidate_store = browser_context + .target_session_storage_store("target-background") + .expect("candidate background Target namespace"); + + connection + .try_insert_browser_context(browser_context) + .expect("BrowserContext should register its Target namespace"); + + let browser_context = connection + .browser_context_by_id("context-storage") + .expect("physical BrowserContext projection"); + let projected_store = browser_context + .target_session_storage_store("target-storage") + .expect("projected Target namespace"); + let background_projected_store = browser_context + .target_session_storage_store("target-background") + .expect("projected background Target namespace"); + let authoritative = connection + .browser_host_state + .navigation_owner() + .target_session_storage_access("context-storage", "target-storage") + .expect("Core Target registry must own the namespace association"); + let background_authoritative = connection + .browser_host_state + .navigation_owner() + .target_session_storage_access("context-storage", "target-background") + .expect("Core Target registry must own the background namespace association"); + assert!(authoritative.is_live()); + assert_eq!( + authoritative.target_handle(), + browser_context + .active_target_handle() + .expect("active exact Target handle") + ); + assert!(Arc::ptr_eq(authoritative.store(), &candidate_store)); + assert!(Arc::ptr_eq(&projected_store, authoritative.store())); + assert!(background_authoritative.is_live()); + assert!(Arc::ptr_eq( + background_authoritative.store(), + &background_candidate_store + )); + assert!(Arc::ptr_eq( + &background_projected_store, + background_authoritative.store() + )); +} + +#[test] +fn registration_core_rejection_does_not_publish_candidate_projection() { + let mut connection = connection_with_two_contexts(); + + let error = connection + .register_browser_context_projection_with_metadata( + BrowserContext::new("context-a".to_owned()), + BrowserContextRegistrationMetadata::default(), + ) + .expect_err("duplicate Core BrowserContext must be a typed rejection"); + + assert!(matches!( + error, + BrowserContextProjectionError::Core(BrowserContextRegistryError::DuplicateBrowserContext( + _ + )) + )); + assert_original_physical_context_order(&connection); + assert_eq!(connection.registered_browser_context_count(), 2); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("context-a") + ); +} + +#[test] +fn physical_context_miss_rejects_activation_before_core_mutation() { + let mut connection = connection_with_two_contexts(); + let missing = connection.inactive_browser_contexts.remove(0); + let selected_engine_owner = connection + .browser_host_state + .navigation_owner() + .selected_target_engine_owner() + .cloned(); + + let error = connection + .activate_browser_context_projection_by_id("context-b") + .expect_err("missing physical BrowserContext must be a typed rejection"); + + assert_eq!( + error, + BrowserContextProjectionError::PhysicalContextCountMismatch { + authoritative: 2, + projected: 1, + } + ); + assert_eq!(missing.id, "context-b"); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("context-a") + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + selected_engine_owner.as_ref() + ); + assert_eq!( + connection + .browser_context + .as_ref() + .map(|context| context.id.as_str()), + Some("context-a") + ); + assert!(connection.inactive_browser_contexts.is_empty()); +} + +#[test] +fn activation_core_rejection_restores_exact_physical_slots() { + let mut connection = connection_with_two_empty_contexts(); + let divergent = inject_physical_engine_owner_divergence(&mut connection); + let divergent_renderer_owner = connection + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(); + + let error = connection + .activate_browser_context_projection_by_id("context-b") + .expect_err("engine owner divergence must reject BrowserContext activation"); + + assert!(matches!( + error, + BrowserContextProjectionError::Core(BrowserContextRegistryError::EngineOwnerMismatch(_)) + )); + assert_original_physical_context_order(&connection); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("context-a") + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + None, + "typed rejection must not bind the physical-only owner into Core" + ); + assert_eq!( + connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(divergent.target_id()) + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(), + divergent_renderer_owner + ); +} + +#[test] +fn selected_removal_core_rejection_restores_exact_physical_slots() { + let mut connection = connection_with_two_empty_contexts(); + let divergent = inject_physical_engine_owner_divergence(&mut connection); + let divergent_renderer_owner = connection + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(); + + let error = match connection.remove_browser_context_projection_by_id("context-a") { + Err(error) => error, + Ok(_) => panic!("engine owner divergence must reject BrowserContext removal"), + }; + + assert!(matches!( + error, + BrowserContextProjectionError::Core(BrowserContextRegistryError::EngineOwnerMismatch(_)) + )); + assert_original_physical_context_order(&connection); + assert_eq!(connection.registered_browser_context_count(), 2); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("context-a") + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + None, + "typed rejection must not bind the physical-only owner into Core" + ); + assert_eq!( + connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(divergent.target_id()) + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(), + divergent_renderer_owner + ); +} + +#[test] +fn last_selected_removal_core_rejection_restores_physical_selection() { + let mut connection = CdpConnection::new(); + connection + .try_insert_browser_context(BrowserContext::new("context-a".to_owned())) + .expect("BrowserContext should register"); + let divergent = inject_physical_engine_owner_divergence(&mut connection); + let selected_renderer_owner = connection + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(); + + let error = match connection.remove_browser_context_projection_by_id("context-a") { + Err(error) => error, + Ok(_) => panic!("engine owner divergence must reject last-context removal"), + }; + + assert!(matches!( + error, + BrowserContextProjectionError::Core(BrowserContextRegistryError::EngineOwnerMismatch(_)) + )); + assert_eq!( + connection + .browser_context + .as_ref() + .map(|context| context.id.as_str()), + Some("context-a") + ); + assert_eq!( + connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(divergent.target_id()) + ); + assert!(connection.inactive_browser_contexts.is_empty()); + assert_eq!(connection.registered_browser_context_count(), 1); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("context-a") + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + None + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(), + selected_renderer_owner + ); +} + +#[test] +fn inactive_removal_core_rejection_restores_exact_vector_index() { + let mut connection = connection_with_two_empty_contexts(); + connection + .try_insert_browser_context(BrowserContext::new("context-c".to_owned())) + .expect("third BrowserContext should register"); + let divergent = inject_physical_engine_owner_divergence(&mut connection); + let selected_renderer_owner = connection + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(); + + let error = match connection.remove_browser_context_projection_by_id("context-b") { + Err(error) => error, + Ok(_) => panic!("engine owner divergence must reject inactive-context removal"), + }; + + assert!(matches!( + error, + BrowserContextProjectionError::Core(BrowserContextRegistryError::EngineOwnerMismatch(_)) + )); + assert_eq!( + connection + .browser_context + .as_ref() + .map(|context| context.id.as_str()), + Some("context-a") + ); + assert_eq!( + connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(divergent.target_id()) + ); + assert_eq!( + connection + .inactive_browser_contexts + .iter() + .map(|context| context.id.as_str()) + .collect::>(), + vec!["context-b", "context-c"], + "typed rejection must restore the removed inactive payload at its exact index" + ); + assert_eq!(connection.registered_browser_context_count(), 3); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("context-a") + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + None + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(), + selected_renderer_owner + ); +} + +#[test] +fn same_public_id_wrong_context_handle_rejects_projection_before_core_mutation() { + let mut connection = connection_with_two_empty_contexts(); + let wrong = BrowserContextHandle::staged("context-a"); + let authoritative = connection + .browser_context + .as_mut() + .expect("selected physical BrowserContext") + .replace_browser_context_handle(wrong.clone()); + + let error = connection + .activate_browser_context_projection_by_id("context-b") + .expect_err("same public id must not make the wrong Context instance authoritative"); + + assert_eq!( + error, + BrowserContextProjectionError::Core( + BrowserContextRegistryError::BrowserContextHandleProjectionMismatch( + moli_core::browser_host::BrowserContextId::new("context-a") + ) + ) + ); + assert!(authoritative.is_live()); + assert!(!wrong.is_live()); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("context-a") + ); + assert_original_physical_context_order(&connection); +} diff --git a/moli-protocol/src/conn/browser_context/runtime_owner.rs b/moli-protocol/src/conn/browser_context/runtime_owner.rs index 918aa92b95..c79710324e 100644 --- a/moli-protocol/src/conn/browser_context/runtime_owner.rs +++ b/moli-protocol/src/conn/browser_context/runtime_owner.rs @@ -1,6 +1,6 @@ use super::target_session_owner::{TargetSessionOwnerMut, TargetSessionStateMut}; use super::*; -use crate::CdpRendererOwnerTurnOutcome; +use crate::conn::CdpRendererOwnerTurnOutcome; use moli_core::page::V8InspectorSessionState; use serde_json::json; diff --git a/moli-protocol/src/conn/browser_context/session_owner.rs b/moli-protocol/src/conn/browser_context/session_owner.rs index fc9c3d34d6..d9c00b69b7 100644 --- a/moli-protocol/src/conn/browser_context/session_owner.rs +++ b/moli-protocol/src/conn/browser_context/session_owner.rs @@ -1,4 +1,128 @@ -use crate::conn::{BrowserContext, CdpConnection, CdpSessionRoute, ParkedPageSessionState}; +use super::*; +use crate::conn::{ + ServiceWorkerTargetState, SharedWorkerTargetState, TargetEventPlan, TargetHostDelta, +}; +use crate::devtools_runtime::DevToolsTargetInfo; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TargetHandlerAccessMode { + Browser, + Regular, + AutoAttachOnly, +} + +impl CdpConnection { + /// Projects only frontend-owned attachment state. Browser URL/title + /// changes require an exact `BrowserFact::TargetMetadataChanged` and use + /// the dedicated Target metadata projector instead. + pub(crate) fn frontend_attachment_info_changed_event_plan( + &mut self, + target_id: &str, + ) -> TargetEventPlan { + self.target_host_delta_events([TargetHostDelta::info_changed(target_id.to_owned())]) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CdpSessionRoute { + Browser, + TabTarget { + browser_context_id: String, + tab_target_id: String, + }, + ActiveTarget { + browser_context_id: String, + target_id: Option, + }, + AuxiliaryTarget { + browser_context_id: String, + target_id: String, + }, + BackgroundTarget { + browser_context_id: String, + target_id: String, + }, + SharedWorkerTarget { + browser_context_id: String, + target_id: String, + }, + DedicatedWorkerTarget { + browser_context_id: String, + target_id: String, + }, + ServiceWorkerTarget { + browser_context_id: String, + target_id: String, + }, +} + +impl CdpSessionRoute { + pub(crate) fn browser_context_id(&self) -> Option<&str> { + match self { + Self::Browser => None, + Self::TabTarget { + browser_context_id, .. + } + | Self::ActiveTarget { + browser_context_id, .. + } + | Self::AuxiliaryTarget { + browser_context_id, .. + } + | Self::BackgroundTarget { + browser_context_id, .. + } + | Self::SharedWorkerTarget { + browser_context_id, .. + } + | Self::DedicatedWorkerTarget { + browser_context_id, .. + } + | Self::ServiceWorkerTarget { + browser_context_id, .. + } => Some(browser_context_id), + } + } + + /// Whether Chromium installs a handler for this CDP domain on the routed + /// DevToolsAgentHost. Browser and Tab hosts must not fall through to the + /// currently active Page merely because they share one connection. + pub(crate) fn supports_cdp_domain(&self, domain: &str) -> bool { + match self { + Self::Browser => matches!( + domain, + "Browser" + | "Fetch" + | "IO" + | "Security" + | "Storage" + | "SystemInfo" + | "Target" + | "Tracing" + ), + Self::TabTarget { .. } => matches!(domain, "IO" | "Target" | "Tracing"), + Self::ActiveTarget { .. } + | Self::AuxiliaryTarget { .. } + | Self::BackgroundTarget { .. } + | Self::SharedWorkerTarget { .. } + | Self::DedicatedWorkerTarget { .. } + | Self::ServiceWorkerTarget { .. } => true, + } + } + + pub(crate) fn target_handler_access_mode(&self) -> TargetHandlerAccessMode { + match self { + Self::Browser => TargetHandlerAccessMode::Browser, + Self::TabTarget { .. } + | Self::ActiveTarget { .. } + | Self::AuxiliaryTarget { .. } + | Self::BackgroundTarget { .. } => TargetHandlerAccessMode::Regular, + Self::SharedWorkerTarget { .. } + | Self::DedicatedWorkerTarget { .. } + | Self::ServiceWorkerTarget { .. } => TargetHandlerAccessMode::AutoAttachOnly, + } + } +} pub(super) enum TargetSessionOwner { ActiveTarget { @@ -13,7 +137,1785 @@ pub(super) enum TargetSessionOwner { NoLoadedBrowserContext, } -impl CdpConnection { +impl CdpConnection { + pub(crate) fn target_handler_access_mode( + &self, + session_id: Option<&str>, + ) -> TargetHandlerAccessMode { + let Some(session_id) = session_id else { + return TargetHandlerAccessMode::Browser; + }; + self.session_route(Some(session_id)) + .map(|route| route.target_handler_access_mode()) + .unwrap_or(TargetHandlerAccessMode::Regular) + } + + pub(crate) fn target_handler_may_get_target_info( + &self, + session_id: Option<&str>, + target_id: Option<&str>, + ) -> bool { + if self.target_handler_access_mode(session_id) != TargetHandlerAccessMode::AutoAttachOnly { + return true; + } + let Some(session_id) = session_id else { + return false; + }; + let owner_target_id = self.non_browser_target_id_for_session(Some(session_id)); + let Some(target_id) = target_id.or(owner_target_id.as_deref()) else { + return false; + }; + owner_target_id.as_deref() == Some(target_id) + } + + pub(crate) fn target_handler_may_close_target( + &self, + session_id: Option<&str>, + target_id: &str, + ) -> bool { + if self.target_handler_access_mode(session_id) != TargetHandlerAccessMode::AutoAttachOnly { + return true; + } + let Some(session_id) = session_id else { + return false; + }; + self.non_browser_target_id_for_session(Some(session_id)) + .as_deref() + == Some(target_id) + || self + .target_control + .auto_attached_target_ids_for_owner(Some(session_id)) + .iter() + .any(|attached_target_id| attached_target_id == target_id) + } + + pub(crate) fn session_route(&self, session_id: Option<&str>) -> Option { + let session_id = session_id?; + if self.browser_session_ids.contains(session_id) { + return Some(CdpSessionRoute::Browser); + } + if let Some(tab_target_id) = self.tab_target_id_for_session_id(session_id) + && let Some(browser_context_id) = + self.browser_context_id_for_tab_target_id(tab_target_id) + { + return Some(CdpSessionRoute::TabTarget { + browser_context_id, + tab_target_id: tab_target_id.to_owned(), + }); + } + self.browser_contexts() + .find_map(|bc| browser_context_session_route(bc, session_id)) + } + + pub(crate) fn target_session_route_for_target_id( + &self, + target_id: &str, + ) -> Option { + if self.page_target_id_for_tab_target_id(target_id).is_some() + && let Some(browser_context_id) = self.browser_context_id_for_tab_target_id(target_id) + { + return Some(CdpSessionRoute::TabTarget { + browser_context_id, + tab_target_id: target_id.to_owned(), + }); + } + self.browser_contexts().find_map(|browser_context| { + if browser_context.active_target_id() == Some(target_id) { + return Some(CdpSessionRoute::ActiveTarget { + browser_context_id: browser_context.id.clone(), + target_id: None, + }); + } + if browser_context.background_target(target_id).is_some() { + return Some(CdpSessionRoute::BackgroundTarget { + browser_context_id: browser_context.id.clone(), + target_id: target_id.to_owned(), + }); + } + if browser_context.has_shared_worker_target(target_id) { + return Some(CdpSessionRoute::SharedWorkerTarget { + browser_context_id: browser_context.id.clone(), + target_id: target_id.to_owned(), + }); + } + if browser_context.has_dedicated_worker_target(target_id) { + return Some(CdpSessionRoute::DedicatedWorkerTarget { + browser_context_id: browser_context.id.clone(), + target_id: target_id.to_owned(), + }); + } + browser_context + .has_service_worker_target(target_id) + .then(|| CdpSessionRoute::ServiceWorkerTarget { + browser_context_id: browser_context.id.clone(), + target_id: target_id.to_owned(), + }) + }) + } + + pub fn worker_target_id_for_session(&self, session_id: Option<&str>) -> Option { + match self.session_route(session_id)? { + CdpSessionRoute::SharedWorkerTarget { target_id, .. } + | CdpSessionRoute::DedicatedWorkerTarget { target_id, .. } + | CdpSessionRoute::ServiceWorkerTarget { target_id, .. } => Some(target_id), + _ => None, + } + } + + /// Returns the DevToolsAgentHost owned by a non-browser session. + pub(crate) fn non_browser_target_id_for_session( + &self, + session_id: Option<&str>, + ) -> Option { + match self.session_route(session_id)? { + CdpSessionRoute::TabTarget { + tab_target_id: target_id, + .. + } + | CdpSessionRoute::AuxiliaryTarget { target_id, .. } + | CdpSessionRoute::BackgroundTarget { target_id, .. } + | CdpSessionRoute::SharedWorkerTarget { target_id, .. } + | CdpSessionRoute::DedicatedWorkerTarget { target_id, .. } + | CdpSessionRoute::ServiceWorkerTarget { target_id, .. } + | CdpSessionRoute::ActiveTarget { + target_id: Some(target_id), + .. + } => Some(target_id), + CdpSessionRoute::ActiveTarget { + browser_context_id, + target_id: None, + } => self + .browser_context_by_id(&browser_context_id) + .and_then(BrowserContext::active_target_id) + .map(str::to_owned), + CdpSessionRoute::Browser => None, + } + } + + pub(crate) fn target_session_route_for_child_frame_id( + &self, + frame_id: &str, + ) -> Option { + self.browser_contexts().find_map(|browser_context| { + if browser_context + .active_target + .owner_state + .has_attached_child_frame_id(frame_id) + { + return Some(CdpSessionRoute::ActiveTarget { + browser_context_id: browser_context.id.clone(), + target_id: None, + }); + } + browser_context + .background_targets + .iter() + .find_map(|target| { + browser_context + .parked_target_owner_state(target.target_id()) + .is_some_and(|owner_state| { + owner_state.has_attached_child_frame_id(frame_id) + }) + .then(|| CdpSessionRoute::BackgroundTarget { + browser_context_id: browser_context.id.clone(), + target_id: target.target_id().to_owned(), + }) + }) + }) + } + + pub(crate) fn has_attached_child_frame_id(&self, frame_id: &str) -> bool { + self.browser_contexts() + .any(|browser_context| browser_context.has_attached_child_frame_id(frame_id)) + } + + #[cfg(test)] + pub(crate) fn has_background_target_session(&self, session_id: Option<&str>) -> bool { + self.background_target_route(session_id).is_some() + } + + #[cfg(test)] + pub(crate) fn background_target_id_for_session( + &self, + session_id: Option<&str>, + ) -> Option { + self.background_target_route(session_id) + .map(|(_, target_id)| target_id) + } + + fn background_target_route(&self, session_id: Option<&str>) -> Option<(String, String)> { + match self.session_route(session_id)? { + CdpSessionRoute::BackgroundTarget { + browser_context_id, + target_id, + } => Some((browser_context_id, target_id)), + CdpSessionRoute::AuxiliaryTarget { + browser_context_id, + target_id, + } => self + .browser_context_by_id(&browser_context_id)? + .background_target(&target_id) + .is_some() + .then_some((browser_context_id, target_id)), + CdpSessionRoute::Browser + | CdpSessionRoute::TabTarget { .. } + | CdpSessionRoute::ActiveTarget { .. } + | CdpSessionRoute::SharedWorkerTarget { .. } + | CdpSessionRoute::DedicatedWorkerTarget { .. } + | CdpSessionRoute::ServiceWorkerTarget { .. } => None, + } + } + + pub(crate) fn is_browser_session_id(&self, session_id: Option<&str>) -> bool { + let Some(session_id) = session_id else { + return false; + }; + self.browser_session_ids.contains(session_id) + } + + #[cfg(test)] + pub(crate) fn register_browser_session(&mut self, session_id: String) { + self.browser_session_ids.insert(session_id); + } + + pub(crate) fn remove_browser_session(&mut self, session_id: &str) -> bool { + self.browser_session_ids.remove(session_id) + } + + fn clear_browser_session_owner_state(&mut self, session_id: &str) -> bool { + if !self.remove_browser_session(session_id) { + return false; + } + self.download_event_subscriptions + .set_browser_events_enabled_for_session(Some(session_id), false); + self.cancel_tracing_for_session_owner(Some(session_id)); + self.clear_auto_attach_owner(Some(session_id)); + self.clear_target_discovery_for_owner(Some(session_id)); + self.set_service_worker_pause_on_start_owner(Some(session_id), false); + self.set_dedicated_worker_pause_on_start_owner(Some(session_id), false); + true + } + + pub(crate) fn detach_browser_session_owner_without_event( + &mut self, + session_id: &str, + ) -> Option { + if !self.clear_browser_session_owner_state(session_id) { + return None; + } + let rollback_plan = self.rollback_attached_session_without_event(session_id); + Some(rollback_plan) + } + + pub(crate) fn detach_browser_session_owner_event_plan( + &mut self, + session_id: &str, + ) -> Option { + let owner_session_id = self + .target_control + .attached_session_owner_session_id(session_id) + .map(str::to_owned); + if !self.clear_browser_session_owner_state(session_id) { + return None; + } + let plan = self.target_control.detach_attached_session_event_plan( + session_id, + None, + owner_session_id.as_deref(), + ); + self.clear_detached_target_session_owner_state(session_id); + plan + } + + pub(crate) fn release_root_target_frontend_owner_without_event(&mut self) { + self.download_event_subscriptions + .set_browser_events_enabled_for_session(None, false); + self.cancel_tracing_for_session_owner(None); + self.clear_auto_attach_owner(None); + self.clear_target_discovery_for_owner(None); + self.target_control.remove_owner(None); + } + + pub(crate) fn release_primary_target_session_binding_without_event( + &mut self, + session_id: &str, + ) -> bool { + let released = self + .browser_context + .as_mut() + .is_some_and(|browser_context| { + browser_context + .release_primary_session_binding_preserving_frontend_state(session_id) + }); + if released { + self.rollback_attached_session_without_event(session_id); + } + released + } + + fn sync_auto_attach_flags_from_owners(&mut self) { + self.auto_attach = !self.auto_attach_owner_sessions.is_empty(); + self.auto_attach_wait_for_debugger_on_start = self + .auto_attach_owner_sessions + .values() + .any(|policy| policy.wait_for_debugger_on_start); + } + + pub(crate) fn has_auto_attach_owner(&self, session_id: Option<&str>) -> bool { + self.auto_attach_owner_sessions + .contains_key(&session_id.map(str::to_owned)) + } + + pub(crate) fn auto_attach_owner_count(&self) -> usize { + self.auto_attach_owner_sessions.len() + } + + pub(crate) fn auto_attach_owner_sessions_for_target_type( + &self, + target_type: &str, + ) -> Vec> { + if self.auto_attach_owner_sessions.is_empty() && self.auto_attach { + return super::super::CdpTargetFilter::default_auto_attach() + .matches(target_type) + .then_some(None) + .into_iter() + .collect(); + } + self.auto_attach_owner_sessions + .iter() + .filter(|(_, policy)| policy.target_filter.matches(target_type)) + .map(|(session_id, _)| session_id.clone()) + .collect::>() + } + + pub(crate) fn auto_attach_owner_allows_target_type( + &self, + session_id: Option<&str>, + target_type: &str, + ) -> bool { + self.auto_attach_owner_sessions + .get(&session_id.map(str::to_owned)) + .is_some_and(|policy| policy.target_filter.matches(target_type)) + || (self.auto_attach_owner_sessions.is_empty() + && self.auto_attach + && super::super::CdpTargetFilter::default_auto_attach().matches(target_type)) + } + + pub(crate) fn auto_attach_owner_waits_for_debugger_on_start( + &self, + session_id: Option<&str>, + ) -> bool { + if self.auto_attach_owner_sessions.is_empty() && self.auto_attach { + return self.auto_attach_wait_for_debugger_on_start; + } + self.auto_attach_owner_sessions + .get(&session_id.map(str::to_owned)) + .is_some_and(|policy| policy.wait_for_debugger_on_start) + } + + pub(crate) fn set_auto_attach_owner( + &mut self, + session_id: Option<&str>, + enabled: bool, + wait_for_debugger_on_start: bool, + target_filter: super::super::CdpTargetFilter, + ) { + self.clear_service_worker_auto_attach_related_owner(session_id); + let key = session_id.map(str::to_owned); + if enabled { + self.target_control.ensure_owner(session_id); + self.auto_attach_owner_sessions.insert( + key, + super::super::AutoAttachOwnerPolicy { + wait_for_debugger_on_start, + target_filter, + }, + ); + } else { + self.auto_attach_owner_sessions.remove(&key); + } + self.sync_auto_attach_flags_from_owners(); + } + + pub(crate) fn clear_auto_attach_owner(&mut self, session_id: Option<&str>) { + self.clear_service_worker_auto_attach_related_owner(session_id); + let key = session_id.map(str::to_owned); + self.auto_attach_owner_sessions.remove(&key); + self.sync_auto_attach_flags_from_owners(); + } + + #[cfg(test)] + pub(crate) fn register_auto_attached_session( + &mut self, + session_id: String, + owner_session_id: Option<&str>, + ) { + let target_id = self.target_id_for_auto_attached_session(&session_id); + self.register_auto_attached_session_for_target( + session_id, + owner_session_id, + target_id.as_deref(), + ); + } + + #[cfg(test)] + pub(crate) fn register_auto_attached_session_for_target( + &mut self, + session_id: String, + owner_session_id: Option<&str>, + target_id: Option<&str>, + ) { + let route = self.session_route(Some(&session_id)); + self.target_control.commit_auto_attached_session_for_target( + session_id, + owner_session_id, + target_id, + route, + false, + ); + } + + pub(crate) fn commit_prepared_attach_event_plan( + &mut self, + prepared: crate::conn::PreparedTargetAttach, + ) -> crate::conn::target::TargetEventPlan { + self.commit_prepared_attach_event_plan_with_attached_state_delta(prepared, true) + } + + pub(crate) fn commit_prepared_dedicated_worker_attach_event_plan( + &mut self, + prepared: crate::conn::PreparedTargetAttach, + ) -> crate::conn::target::TargetEventPlan { + self.commit_prepared_attach_event_plan_with_attached_state_delta(prepared, false) + } + + fn commit_prepared_attach_event_plan_with_attached_state_delta( + &mut self, + prepared: crate::conn::PreparedTargetAttach, + emit_attached_state_delta: bool, + ) -> crate::conn::target::TargetEventPlan { + let (target_id, target_info, sessions) = prepared.into_parts(); + let should_emit_attached_state_delta = emit_attached_state_delta && !sessions.is_empty(); + let attached_state_delta_plan = should_emit_attached_state_delta + .then(|| self.frontend_attachment_info_changed_event_plan(&target_id)); + let mut plan = crate::conn::target::TargetEventPlan::default(); + for session in sessions { + let (session_id, owner_session_id, route, auto_attached, waiting_for_debugger) = + session.into_parts(); + if auto_attached { + self.target_control + .ensure_owner(owner_session_id.as_deref()); + } + plan.extend(self.target_control.commit_attached_session_event( + session_id, + owner_session_id.as_deref(), + &target_id, + route, + auto_attached, + waiting_for_debugger, + target_info.clone(), + )); + } + if let Some(attached_state_delta_plan) = attached_state_delta_plan { + plan.extend(attached_state_delta_plan); + } + plan + } + + pub(crate) fn prepare_auto_attach_session_commit( + &self, + session_id: impl Into, + owner_session_id: Option, + waiting_for_debugger: bool, + ) -> crate::conn::TargetAttachSessionCommit { + let session_id = session_id.into(); + let route = self.session_route(Some(&session_id)); + crate::conn::TargetAttachSessionCommit::auto_attached( + session_id, + owner_session_id, + waiting_for_debugger, + ) + .with_route(route) + } + + pub(crate) fn prepare_direct_attach_session_commit( + &self, + session_id: impl Into, + owner_session_id: Option, + waiting_for_debugger: bool, + ) -> crate::conn::TargetAttachSessionCommit { + let session_id = session_id.into(); + let route = self.session_route(Some(&session_id)); + crate::conn::TargetAttachSessionCommit::direct( + session_id, + owner_session_id, + waiting_for_debugger, + ) + .with_route(route) + } + + pub(crate) fn attach_tab_target_session_event_plan( + &mut self, + session_id: String, + owner_session_id: Option<&str>, + tab_target_id: &str, + auxiliary: bool, + ) -> Result { + if !self.assign_session_to_tab_target(tab_target_id, session_id.clone(), auxiliary) { + return Err("UnknownTargetId"); + } + let prepared_session = self.prepare_direct_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + false, + ); + let Some(target_info) = self.tab_target_info(tab_target_id) else { + self.rollback_prepared_attach_session_sync_without_event(&prepared_session); + return Err("UnknownTargetId"); + }; + if prepared_session.route().is_none() { + self.rollback_prepared_attach_session_sync_without_event(&prepared_session); + return Err("InvalidSessionId"); + } + Ok( + self.commit_prepared_attach_event_plan(crate::conn::PreparedTargetAttach::new( + tab_target_id, + target_info, + [prepared_session], + )), + ) + } + + pub(crate) fn attach_shared_worker_target_session_event_plan( + &mut self, + session_id: String, + owner_session_id: Option<&str>, + target_id: &str, + ) -> Result { + let session_id_for_binding = session_id.clone(); + let target_info = { + let Some(bc) = self.browser_context.as_mut() else { + return Err("BrowserContextNotLoaded"); + }; + if !bc.assign_session_to_shared_worker_target(target_id, session_id_for_binding) { + return Err("UnknownTargetId"); + } + bc.devtools_target_info(target_id) + }; + let prepared_session = self.prepare_direct_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + false, + ); + let Some(target_info) = target_info else { + self.rollback_prepared_attach_session_sync_without_event(&prepared_session); + return Err("UnknownTargetId"); + }; + if prepared_session.route().is_none() { + self.rollback_prepared_attach_session_sync_without_event(&prepared_session); + return Err("InvalidSessionId"); + } + Ok( + self.commit_prepared_attach_event_plan(crate::conn::PreparedTargetAttach::new( + target_id, + target_info, + [prepared_session], + )), + ) + } + + pub(crate) fn attach_service_worker_target_session_event_plan( + &mut self, + session_id: String, + owner_session_id: Option<&str>, + target_id: &str, + ) -> Result { + let session_id_for_binding = session_id.clone(); + let target_info = { + let Some(bc) = self.browser_context.as_mut() else { + return Err("BrowserContextNotLoaded"); + }; + if !bc.assign_session_to_service_worker_target(target_id, session_id_for_binding) { + return Err("UnknownTargetId"); + } + bc.devtools_target_info(target_id) + }; + let prepared_session = self.prepare_direct_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + false, + ); + let Some(target_info) = target_info else { + self.rollback_prepared_attach_session_sync_without_event(&prepared_session); + return Err("UnknownTargetId"); + }; + if prepared_session.route().is_none() { + self.rollback_prepared_attach_session_sync_without_event(&prepared_session); + return Err("InvalidSessionId"); + } + Ok( + self.commit_prepared_attach_event_plan(crate::conn::PreparedTargetAttach::new( + target_id, + target_info, + [prepared_session], + )), + ) + } + + pub(crate) fn attach_dedicated_worker_target_session_event_plan( + &mut self, + session_id: String, + owner_session_id: Option<&str>, + target_id: &str, + ) -> Result { + let session_id_for_binding = session_id.clone(); + let target_info = { + let Some(bc) = self.browser_context.as_mut() else { + return Err("BrowserContextNotLoaded"); + }; + if !bc.assign_session_to_dedicated_worker_target(target_id, session_id_for_binding) { + return Err("UnknownTargetId"); + } + bc.devtools_target_info(target_id) + }; + let prepared_session = self.prepare_direct_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + false, + ); + let Some(target_info) = target_info else { + self.rollback_prepared_attach_session_sync_without_event(&prepared_session); + return Err("UnknownTargetId"); + }; + if prepared_session.route().is_none() { + self.rollback_prepared_attach_session_sync_without_event(&prepared_session); + return Err("InvalidSessionId"); + } + Ok(self.commit_prepared_dedicated_worker_attach_event_plan( + crate::conn::PreparedTargetAttach::new(target_id, target_info, [prepared_session]), + )) + } + + pub(crate) fn prepare_auto_attached_tab_session_binding( + &mut self, + tab_target_id: &str, + session_id: String, + owner_session_id: Option<&str>, + ) -> bool { + self.assign_session_to_tab_target(tab_target_id, session_id, owner_session_id.is_some()) + } + + pub(crate) fn prepare_auto_attached_page_session_binding( + &mut self, + target_id: &str, + session_id: String, + ) -> bool { + self.browser_context + .as_mut() + .is_some_and(|bc| bc.assign_auto_attached_session_to_target(target_id, session_id)) + } + + pub(crate) fn prepare_auto_attached_page_session_binding_in_browser_context( + &mut self, + browser_context_id: &str, + target_id: &str, + session_id: String, + ) -> bool { + self.browser_context_by_id_mut(browser_context_id) + .is_some_and(|bc| bc.assign_auto_attached_session_to_target(target_id, session_id)) + } + + pub(crate) fn prepare_auto_attached_shared_worker_session_binding( + &mut self, + target_id: &str, + session_id: String, + ) -> bool { + self.browser_context + .as_mut() + .is_some_and(|bc| bc.assign_session_to_shared_worker_target(target_id, session_id)) + } + + pub(crate) fn prepare_auto_attached_dedicated_worker_session_binding( + &mut self, + target_id: &str, + session_id: String, + ) -> bool { + self.browser_context + .as_mut() + .is_some_and(|bc| bc.assign_session_to_dedicated_worker_target(target_id, session_id)) + } + + pub(crate) fn prepare_auto_attached_service_worker_session_binding( + &mut self, + target_id: &str, + session_id: String, + ) -> bool { + self.browser_context + .as_mut() + .is_some_and(|bc| bc.assign_session_to_service_worker_target(target_id, session_id)) + } + + pub(crate) fn prepare_auto_attached_shared_worker_session_binding_info_in_browser_context( + &mut self, + browser_context_id: &str, + target_id: &str, + session_id: String, + ) -> Option { + let bc = self.browser_context_by_id_mut(browser_context_id)?; + if !bc.assign_session_to_shared_worker_target(target_id, session_id) { + return None; + } + bc.devtools_target_info(target_id) + } + + pub(crate) fn prepare_auto_attached_dedicated_worker_session_binding_info_in_browser_context( + &mut self, + browser_context_id: &str, + target_id: &str, + session_id: String, + ) -> Option { + let bc = self.browser_context_by_id_mut(browser_context_id)?; + if !bc.assign_session_to_dedicated_worker_target(target_id, session_id) { + return None; + } + bc.devtools_target_info(target_id) + } + + pub(crate) fn prepare_auto_attached_service_worker_session_binding_info_in_browser_context( + &mut self, + browser_context_id: &str, + target_id: &str, + session_id: String, + ) -> Option { + let bc = self.browser_context_by_id_mut(browser_context_id)?; + if !bc.assign_session_to_service_worker_target(target_id, session_id) { + return None; + } + bc.devtools_target_info(target_id) + } + + pub(crate) fn prepare_auto_attached_service_worker_session_binding_info( + &mut self, + target_id: &str, + session_id: String, + ) -> Option { + let bc = self.browser_context.as_mut()?; + if !bc.assign_session_to_service_worker_target(target_id, session_id) { + return None; + } + bc.devtools_target_info(target_id) + } + + pub(crate) fn commit_browser_attached_session_event_plan( + &mut self, + session_id: String, + owner_session_id: Option<&str>, + target_id: &str, + target_info: DevToolsTargetInfo, + ) -> crate::conn::target::TargetEventPlan { + self.browser_session_ids.insert(session_id.clone()); + self.target_control.commit_attached_session_event( + session_id, + owner_session_id, + target_id, + Some(CdpSessionRoute::Browser), + false, + false, + target_info, + ) + } + + pub(crate) fn rollback_attached_session_without_event( + &mut self, + session_id: &str, + ) -> crate::conn::target::TargetEventPlan { + let plan = self + .target_control + .rollback_attached_session_without_event(session_id); + for session_id in plan.rolled_back_session_ids() { + self.clear_detached_target_session_owner_state(session_id); + } + plan + } + + pub(crate) fn detach_known_session_event_plan( + &mut self, + target_id: &str, + session_id: &str, + reason: Option<&str>, + parent_session_id: Option<&str>, + ) -> crate::conn::target::TargetEventPlan { + self.detach_known_session_event_plan_with_attached_state_delta( + target_id, + session_id, + reason, + parent_session_id, + true, + ) + } + + fn detach_known_session_event_plan_with_attached_state_delta( + &mut self, + target_id: &str, + session_id: &str, + reason: Option<&str>, + parent_session_id: Option<&str>, + emit_attached_state_delta: bool, + ) -> crate::conn::target::TargetEventPlan { + let attached_state_delta_plan = emit_attached_state_delta + .then(|| self.frontend_attachment_info_changed_event_plan(target_id)); + let mut plan = self.target_control.detach_known_session_event_plan( + target_id, + session_id, + reason, + parent_session_id, + ); + self.clear_detached_target_session_owner_state(session_id); + if let Some(attached_state_delta_plan) = attached_state_delta_plan { + plan.extend(attached_state_delta_plan); + } + plan + } + + pub(crate) fn detach_target_closure_cleanup_event_plan( + &mut self, + cleanup_plan: crate::conn::TargetClosureCleanupPlan, + parent_session_id: Option<&str>, + ) -> crate::conn::target::TargetEventPlan { + let plan = self + .target_control + .detach_target_closure_cleanup_event_plan(cleanup_plan, parent_session_id); + for session in plan.detached_sessions() { + self.clear_detached_target_session_owner_state(session.session_id()); + } + plan + } + + pub(crate) async fn rollback_prepared_attach_session_without_event_async( + &mut self, + prepared: &crate::conn::TargetAttachSessionCommit, + ) -> crate::conn::target::TargetEventPlan { + self.rollback_attached_session_with_cleanup_without_event_async( + crate::conn::TargetAttachRollbackPlan::from_prepared_attach_session(prepared), + ) + .await + } + + pub(crate) fn rollback_prepared_attach_session_sync_without_event( + &mut self, + prepared: &crate::conn::TargetAttachSessionCommit, + ) -> crate::conn::target::TargetEventPlan { + self.rollback_attached_session_with_cleanup_without_event_sync( + crate::conn::TargetAttachRollbackPlan::from_prepared_attach_session(prepared), + ) + } + + fn rollback_attached_session_with_cleanup_without_event_sync( + &mut self, + rollback_plan: crate::conn::TargetAttachRollbackPlan, + ) -> crate::conn::target::TargetEventPlan { + if let Some(cleanup_plan) = rollback_plan.cleanup_plan() { + if matches!( + cleanup_plan.action(), + crate::conn::TargetBindingCleanupAction::ActiveTargetPrimaryAutoAttached + ) { + debug_assert!( + false, + "active target rollback requires async binding cleanup" + ); + } else { + self.execute_target_binding_cleanup_without_event_sync(cleanup_plan); + } + } + self.rollback_attached_session_without_event(rollback_plan.session_id()) + } + + fn execute_target_binding_cleanup_without_event_sync( + &mut self, + cleanup_plan: &crate::conn::TargetBindingCleanupPlan, + ) { + match cleanup_plan.action() { + crate::conn::TargetBindingCleanupAction::BackgroundTargetPrimaryAutoAttached { + .. + } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.clear_background_target_primary_auto_attached_session( + cleanup_plan.session_id(), + ); + } + } + crate::conn::TargetBindingCleanupAction::AuxiliaryTarget { .. } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.remove_auxiliary_session(cleanup_plan.session_id()); + } + } + crate::conn::TargetBindingCleanupAction::TabTarget { .. } => { + self.remove_tab_session(cleanup_plan.session_id()); + } + crate::conn::TargetBindingCleanupAction::SharedWorkerTarget { .. } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.detach_shared_worker_target_session(cleanup_plan.session_id()); + } + } + crate::conn::TargetBindingCleanupAction::DedicatedWorkerTarget { .. } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.detach_dedicated_worker_target_session(cleanup_plan.session_id()); + } + } + crate::conn::TargetBindingCleanupAction::ServiceWorkerTarget { .. } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.detach_service_worker_target_session(cleanup_plan.session_id()); + } + } + crate::conn::TargetBindingCleanupAction::None + | crate::conn::TargetBindingCleanupAction::ActiveTargetPrimaryAutoAttached => {} + } + } + + async fn rollback_attached_session_with_cleanup_without_event_async( + &mut self, + rollback_plan: crate::conn::TargetAttachRollbackPlan, + ) -> crate::conn::target::TargetEventPlan { + let Some(browser_context_id) = rollback_plan.browser_context_id().map(str::to_owned) else { + return self.rollback_attached_session_without_event(rollback_plan.session_id()); + }; + if !self + .activate_browser_context_by_id_async(&browser_context_id) + .await + { + return self.rollback_attached_session_without_event(rollback_plan.session_id()); + } + + if let Some(cleanup_plan) = rollback_plan.cleanup_plan() { + self.execute_target_binding_cleanup_without_event_async(cleanup_plan) + .await; + } + self.rollback_attached_session_without_event(rollback_plan.session_id()) + } + + pub(crate) fn auto_attached_session_detach_plan( + &self, + session_id: &str, + ) -> crate::conn::TargetAutoAttachedSessionDetachPlan { + crate::conn::TargetAutoAttachedSessionDetachPlan::from_session_route( + session_id, + self.session_route(Some(session_id)), + ) + } + + pub(crate) fn rollback_auto_attached_session_detach_plan_without_event( + &mut self, + detach_plan: &crate::conn::TargetAutoAttachedSessionDetachPlan, + ) -> crate::conn::target::TargetEventPlan { + self.rollback_attached_session_without_event(detach_plan.session_id()) + } + + pub(crate) async fn execute_target_binding_cleanup_without_event_async( + &mut self, + cleanup_plan: &crate::conn::TargetBindingCleanupPlan, + ) { + match cleanup_plan.action() { + crate::conn::TargetBindingCleanupAction::ActiveTargetPrimaryAutoAttached => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc + .clear_active_target_primary_auto_attached_session_async() + .await; + } + } + crate::conn::TargetBindingCleanupAction::BackgroundTargetPrimaryAutoAttached { + .. + } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.clear_background_target_primary_auto_attached_session( + cleanup_plan.session_id(), + ); + } + } + crate::conn::TargetBindingCleanupAction::AuxiliaryTarget { .. } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.remove_auxiliary_session(cleanup_plan.session_id()); + } + } + crate::conn::TargetBindingCleanupAction::TabTarget { .. } => { + self.remove_tab_session(cleanup_plan.session_id()); + } + crate::conn::TargetBindingCleanupAction::SharedWorkerTarget { .. } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.detach_shared_worker_target_session(cleanup_plan.session_id()); + } + } + crate::conn::TargetBindingCleanupAction::DedicatedWorkerTarget { .. } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.detach_dedicated_worker_target_session(cleanup_plan.session_id()); + } + } + crate::conn::TargetBindingCleanupAction::ServiceWorkerTarget { .. } => { + if let Some(bc) = self.browser_context.as_mut() { + let _ = bc.detach_service_worker_target_session(cleanup_plan.session_id()); + } + } + crate::conn::TargetBindingCleanupAction::None => {} + } + } + + pub(crate) async fn execute_target_binding_cleanup_for_session_without_event_async( + &mut self, + session_id: &str, + ) -> bool { + let Some(route) = self.session_route(Some(session_id)) else { + return false; + }; + self.cancel_tracing_for_session_owner_async(Some(session_id)) + .await; + let cleanup_plan = crate::conn::TargetBindingCleanupPlan::from_route(session_id, &route); + self.execute_target_binding_cleanup_without_event_async(&cleanup_plan) + .await; + true + } + + pub(crate) async fn detach_session_with_binding_cleanup_event_plan_async( + &mut self, + cleanup_plan: crate::conn::TargetSessionDetachCleanupPlan, + ) -> crate::conn::target::TargetEventPlan { + let session_id = cleanup_plan.session_id().to_owned(); + let target_id = cleanup_plan.target_id().to_owned(); + let reason = cleanup_plan.reason().map(str::to_owned); + let parent_session_id = cleanup_plan + .parent_session_id() + .or_else(|| { + self.target_control + .attached_session_owner_session_id(&session_id) + }) + .map(str::to_owned); + let _ = self + .execute_target_binding_cleanup_for_session_without_event_async(&session_id) + .await; + self.detach_known_session_event_plan( + &target_id, + &session_id, + reason.as_deref(), + parent_session_id.as_deref(), + ) + } + + /// Detaches a SharedWorker/ServiceWorker session whose exact worker + /// target was already removed by BrowserContext disposal. + /// + /// These routes have no renderer-side primary-Page cleanup. Resolving the + /// physical Context from the captured route avoids selecting an inactive + /// Context merely to clear connection-local attachment state. + pub(crate) fn detach_worker_session_with_binding_cleanup_event_plan( + &mut self, + cleanup_plan: crate::conn::TargetSessionDetachCleanupPlan, + ) -> crate::conn::target::TargetEventPlan { + let session_id = cleanup_plan.session_id().to_owned(); + let target_id = cleanup_plan.target_id().to_owned(); + let reason = cleanup_plan.reason().map(str::to_owned); + let route = self.session_route(Some(&session_id)); + let parent_session_id = cleanup_plan + .parent_session_id() + .or_else(|| { + self.target_control + .attached_session_owner_session_id(&session_id) + }) + .map(str::to_owned); + self.cancel_tracing_for_session_owner(Some(&session_id)); + + if let Some(route) = route.as_ref() + && let Some(browser_context_id) = route.browser_context_id() + && let Some(browser_context) = self.browser_context_by_id_mut(browser_context_id) + { + match route { + crate::conn::CdpSessionRoute::SharedWorkerTarget { .. } => { + let _ = browser_context.detach_shared_worker_target_session(&session_id); + } + crate::conn::CdpSessionRoute::ServiceWorkerTarget { .. } => { + let _ = browser_context.detach_service_worker_target_session(&session_id); + } + _ => { + tracing::warn!( + session_id, + ?route, + "worker disposal received a non-worker attachment route" + ); + } + } + } + + self.detach_known_session_event_plan( + &target_id, + &session_id, + reason.as_deref(), + parent_session_id.as_deref(), + ) + } + + pub(crate) async fn detach_dedicated_worker_session_with_binding_cleanup_event_plan_async( + &mut self, + cleanup_plan: crate::conn::TargetSessionDetachCleanupPlan, + ) -> crate::conn::target::TargetEventPlan { + let session_id = cleanup_plan.session_id().to_owned(); + let target_id = cleanup_plan.target_id().to_owned(); + let reason = cleanup_plan.reason().map(str::to_owned); + let parent_session_id = cleanup_plan + .parent_session_id() + .or_else(|| { + self.target_control + .attached_session_owner_session_id(&session_id) + }) + .map(str::to_owned); + let _ = self + .execute_target_binding_cleanup_for_session_without_event_async(&session_id) + .await; + self.detach_known_session_event_plan_with_attached_state_delta( + &target_id, + &session_id, + reason.as_deref(), + parent_session_id.as_deref(), + false, + ) + } + + pub(crate) fn detach_dedicated_worker_session_after_target_removal_event_plan( + &mut self, + cleanup_plan: crate::conn::TargetSessionDetachCleanupPlan, + ) -> crate::conn::target::TargetEventPlan { + let parent_session_id = cleanup_plan + .parent_session_id() + .or_else(|| { + self.target_control + .attached_session_owner_session_id(cleanup_plan.session_id()) + }) + .map(str::to_owned); + self.detach_known_session_event_plan_with_attached_state_delta( + cleanup_plan.target_id(), + cleanup_plan.session_id(), + cleanup_plan.reason(), + parent_session_id.as_deref(), + false, + ) + } + + pub(crate) async fn detach_target_sessions_with_binding_cleanup_event_plan_async( + &mut self, + cleanup_plan: crate::conn::TargetClosureCleanupPlan, + parent_session_id: Option<&str>, + ) -> crate::conn::target::TargetEventPlan { + let target_id = cleanup_plan.target_id().to_owned(); + let reason = cleanup_plan.reason().map(str::to_owned); + let session_ids = cleanup_plan + .session_ids() + .map(str::to_owned) + .collect::>(); + let mut plan = crate::conn::target::TargetEventPlan::default(); + for session_id in session_ids { + if self + .execute_target_binding_cleanup_for_session_without_event_async(&session_id) + .await + { + plan.extend(self.detach_known_session_event_plan( + &target_id, + &session_id, + reason.as_deref(), + parent_session_id, + )); + } + } + plan + } + + pub(crate) async fn detach_active_target_session_binding_event_plan_async( + &mut self, + cleanup_plan: crate::conn::TargetSessionDetachCleanupPlan, + ) -> Result { + let session_id = cleanup_plan.session_id().to_owned(); + let target_id = cleanup_plan.target_id().to_owned(); + let reason = cleanup_plan.reason().map(str::to_owned); + let parent_session_id = cleanup_plan.parent_session_id().map(str::to_owned); + self.clear_active_target_session_binding_for_detach_async() + .await?; + Ok(self.detach_known_session_event_plan( + &target_id, + &session_id, + reason.as_deref(), + parent_session_id.as_deref(), + )) + } + + pub(crate) async fn detach_background_target_session_binding_event_plan_async( + &mut self, + cleanup_plan: crate::conn::TargetSessionDetachCleanupPlan, + ) -> Result, String> { + let session_id = cleanup_plan.session_id().to_owned(); + let target_id = cleanup_plan.target_id().to_owned(); + let reason = cleanup_plan.reason().map(str::to_owned); + let parent_session_id = cleanup_plan.parent_session_id().map(str::to_owned); + let Some(detached_target_id) = self + .clear_background_target_session_binding_for_detach_async(&session_id) + .await? + else { + return Ok(None); + }; + debug_assert_eq!(detached_target_id, target_id); + Ok(Some(self.detach_known_session_event_plan( + &target_id, + &session_id, + reason.as_deref(), + parent_session_id.as_deref(), + ))) + } + + pub(crate) fn background_target_session_detach_cleanup_plans( + &self, + reason: Option<&str>, + parent_session_id: Option<&str>, + ) -> Vec { + self.browser_context + .as_ref() + .map(|bc| { + bc.background_targets + .iter() + .filter_map(|target| { + Some(crate::conn::TargetSessionDetachCleanupPlan::new( + target.target_id().to_owned(), + bc.primary_session_id_for_target(target.target_id())? + .to_owned(), + reason, + parent_session_id, + )) + }) + .collect() + }) + .unwrap_or_default() + } + + pub(crate) async fn clear_active_target_session_binding_for_detach_async( + &mut self, + ) -> Result<(), String> { + let Some(bc) = self.browser_context.as_mut() else { + return Ok(()); + }; + bc.clear_active_target_session_binding_and_scoped_state_async() + .await + } + + pub(crate) async fn clear_background_target_session_binding_for_detach_async( + &mut self, + session_id: &str, + ) -> Result, String> { + let Some(bc) = self.browser_context.as_mut() else { + return Ok(None); + }; + bc.clear_background_target_session_binding_and_scoped_state_async(session_id) + .await + } + + pub(crate) async fn detach_all_shared_worker_target_sessions_event_plan_async( + &mut self, + reason: Option<&str>, + parent_session_id: Option<&str>, + ) -> crate::conn::target::TargetEventPlan { + let target_ids = self + .browser_context + .as_ref() + .map(|bc| { + bc.shared_worker_targets + .values() + .filter(|target| target.has_session()) + .map(|target| target.target_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + let mut plan = crate::conn::target::TargetEventPlan::default(); + for target_id in target_ids { + let session_ids = self + .browser_context + .as_ref() + .and_then(|bc| bc.shared_worker_target(&target_id)) + .map(|target| target.session_ids()) + .unwrap_or_default(); + for session_id in session_ids { + if self + .execute_target_binding_cleanup_for_session_without_event_async(&session_id) + .await + { + plan.extend(self.detach_known_session_event_plan( + &target_id, + &session_id, + reason, + parent_session_id, + )); + } + } + } + plan + } + + pub(crate) async fn detach_all_service_worker_target_sessions_event_plan_async( + &mut self, + reason: Option<&str>, + parent_session_id: Option<&str>, + ) -> crate::conn::target::TargetEventPlan { + let target_ids = self + .browser_context + .as_ref() + .map(|bc| { + bc.service_worker_targets + .values() + .filter(|target| target.has_session()) + .map(|target| target.target_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + let mut plan = crate::conn::target::TargetEventPlan::default(); + for target_id in target_ids { + let session_ids = self + .browser_context + .as_ref() + .and_then(|bc| bc.service_worker_target(&target_id)) + .map(|target| target.session_ids()) + .unwrap_or_default(); + for session_id in session_ids { + if self + .execute_target_binding_cleanup_for_session_without_event_async(&session_id) + .await + { + plan.extend(self.detach_known_session_event_plan( + &target_id, + &session_id, + reason, + parent_session_id, + )); + } + } + } + plan + } + + pub(crate) async fn detach_all_dedicated_worker_target_sessions_event_plan_async( + &mut self, + reason: Option<&str>, + parent_session_id: Option<&str>, + ) -> crate::conn::target::TargetEventPlan { + let target_ids = self + .browser_context + .as_ref() + .map(|bc| { + bc.dedicated_worker_targets + .values() + .filter(|target| target.has_session()) + .map(|target| target.target_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + let mut plan = crate::conn::target::TargetEventPlan::default(); + for target_id in target_ids { + let session_ids = self + .browser_context + .as_ref() + .and_then(|bc| bc.dedicated_worker_target(&target_id)) + .map(|target| target.session_ids()) + .unwrap_or_default(); + for session_id in session_ids { + if self + .execute_target_binding_cleanup_for_session_without_event_async(&session_id) + .await + { + plan.extend(self.detach_known_session_event_plan( + &target_id, + &session_id, + reason, + parent_session_id, + )); + } + } + } + plan + } + + fn clear_detached_target_session_owner_state(&mut self, session_id: &str) { + self.download_event_subscriptions + .set_browser_events_enabled_for_session(Some(session_id), false); + self.cancel_tracing_for_session_owner(Some(session_id)); + self.clear_auto_attach_owner(Some(session_id)); + self.set_service_worker_pause_on_start_owner(Some(session_id), false); + self.target_control.remove_owner(Some(session_id)); + } + + pub(crate) fn attached_sessions_for_target(&self, target_id: &str) -> Vec { + self.target_control.attached_sessions_for_target(target_id) + } + + pub(crate) fn auto_attached_sessions_for_owner( + &self, + owner_session_id: Option<&str>, + ) -> Vec { + self.target_control + .auto_attached_sessions_for_owner(owner_session_id) + } + + pub(crate) fn attached_session_cascade_for_owner( + &self, + owner_session_id: Option<&str>, + ) -> Vec { + self.target_control + .attached_session_cascade_for_owner(owner_session_id) + } + + pub(crate) fn attached_session_cascade_for_root_frontend(&self) -> Vec { + self.target_control + .attached_session_cascade_for_root_frontend() + } + + pub(crate) fn auto_attached_session_cascade_for_owner( + &self, + owner_session_id: Option<&str>, + ) -> Vec { + self.target_control + .auto_attached_session_cascade_for_owner(owner_session_id) + } + + #[cfg(test)] + fn target_id_for_auto_attached_session(&self, session_id: &str) -> Option { + match self.session_route(Some(session_id))? { + CdpSessionRoute::TabTarget { + tab_target_id: target_id, + .. + } + | CdpSessionRoute::BackgroundTarget { target_id, .. } + | CdpSessionRoute::AuxiliaryTarget { target_id, .. } + | CdpSessionRoute::SharedWorkerTarget { target_id, .. } + | CdpSessionRoute::DedicatedWorkerTarget { target_id, .. } + | CdpSessionRoute::ServiceWorkerTarget { target_id, .. } => Some(target_id), + CdpSessionRoute::ActiveTarget { + target_id: Some(target_id), + .. + } => Some(target_id), + CdpSessionRoute::ActiveTarget { + target_id: None, .. + } => self + .target_owner_identity_for_session(Some(session_id)) + .and_then(|(_, target_id)| target_id), + CdpSessionRoute::Browser => None, + } + } + + pub(crate) fn set_service_worker_auto_attach_related_owner( + &mut self, + owner_session_id: Option<&str>, + browser_context_id: &str, + registration_id: u64, + base_version_id: u64, + script_url: String, + scope_url: String, + allow_service_worker_targets: bool, + wait_for_debugger_on_start: bool, + ) { + let owner_key = owner_session_id.map(str::to_owned); + self.service_worker_auto_attach_related_owners + .retain(|owner| { + !(owner.owner_session_id == owner_key + && owner.browser_context_id == browser_context_id + && owner.registration_id == registration_id + && owner.script_url == script_url + && owner.scope_url == scope_url) + }); + self.service_worker_auto_attach_related_owners.push( + super::super::ServiceWorkerAutoAttachRelatedOwner { + owner_session_id: owner_key, + browser_context_id: browser_context_id.to_owned(), + registration_id, + base_version_id, + script_url, + scope_url, + allow_service_worker_targets, + wait_for_debugger_on_start, + }, + ); + self.sync_service_worker_related_pause_on_start_for_devtools(); + } + + pub(crate) fn replace_service_worker_auto_attach_related_owner( + &mut self, + owner_session_id: Option<&str>, + browser_context_id: &str, + registration_id: u64, + base_version_id: u64, + script_url: String, + scope_url: String, + allow_service_worker_targets: bool, + wait_for_debugger_on_start: bool, + ) { + self.clear_auto_attach_owner(owner_session_id); + self.set_service_worker_auto_attach_related_owner( + owner_session_id, + browser_context_id, + registration_id, + base_version_id, + script_url, + scope_url, + allow_service_worker_targets, + wait_for_debugger_on_start, + ); + } + + pub(crate) fn clear_service_worker_auto_attach_related_owner( + &mut self, + owner_session_id: Option<&str>, + ) { + let owner_key = owner_session_id.map(str::to_owned); + self.service_worker_auto_attach_related_owners + .retain(|owner| owner.owner_session_id != owner_key); + self.sync_service_worker_related_pause_on_start_for_devtools(); + } + + pub(crate) fn service_worker_auto_attach_related_owner_sessions_for_target( + &self, + browser_context_id: &str, + registration_id: u64, + version_id: u64, + script_url: &str, + scope_url: &str, + ) -> Vec { + let mut owners = Vec::new(); + for owner in &self.service_worker_auto_attach_related_owners { + if !owner.allow_service_worker_targets + || owner.browser_context_id != browser_context_id + || owner.registration_id != registration_id + || version_id <= owner.base_version_id + || owner.script_url != script_url + || owner.scope_url != scope_url + { + continue; + } + if !owners.iter().any( + |existing: &super::super::ServiceWorkerAutoAttachRelatedOwnerSession| { + existing.owner_session_id == owner.owner_session_id + }, + ) { + owners.push(super::super::ServiceWorkerAutoAttachRelatedOwnerSession { + owner_session_id: owner.owner_session_id.clone(), + wait_for_debugger_on_start: owner.wait_for_debugger_on_start, + }); + } + } + owners + } + + fn sync_service_worker_related_pause_on_start_for_devtools(&self) { + for browser_context in self.browser_contexts() { + let policies = self + .service_worker_auto_attach_related_owners + .iter() + .filter(|owner| { + owner.browser_context_id == browser_context.id + && owner.allow_service_worker_targets + && owner.wait_for_debugger_on_start + }) + .map(|owner| { + ( + owner.registration_id, + owner.base_version_id, + owner.script_url.clone(), + owner.scope_url.clone(), + ) + }) + .collect::>(); + browser_context + .renderer_runtime() + .set_service_worker_related_pause_on_start_policies_for_devtools(policies); + } + } + + pub(crate) fn set_service_worker_pause_on_start_owner( + &mut self, + session_id: Option<&str>, + enabled: bool, + ) -> bool { + let key = session_id.map(str::to_owned); + if enabled { + self.service_worker_pause_on_start_owner_sessions + .insert(key); + } else { + self.service_worker_pause_on_start_owner_sessions + .remove(&key); + } + let pause = self.service_worker_pause_on_start_for_devtools(); + let runtimes = self + .browser_contexts() + .map(BrowserContext::renderer_runtime) + .collect::>(); + for runtime in runtimes { + runtime.set_service_worker_pause_on_start_for_devtools(pause); + } + pause + } + + pub(crate) fn service_worker_pause_on_start_for_devtools(&self) -> bool { + !self.service_worker_pause_on_start_owner_sessions.is_empty() + } + + pub(crate) fn set_dedicated_worker_pause_on_start_owner( + &mut self, + session_id: Option<&str>, + enabled: bool, + ) -> bool { + let key = session_id.map(str::to_owned); + if enabled { + self.dedicated_worker_pause_on_start_owner_sessions + .insert(key); + } else { + self.dedicated_worker_pause_on_start_owner_sessions + .remove(&key); + } + self.dedicated_worker_pause_on_start_for_devtools() + } + + pub(crate) fn dedicated_worker_pause_on_start_for_devtools(&self) -> bool { + !self + .dedicated_worker_pause_on_start_owner_sessions + .is_empty() + } + + #[cfg(test)] + pub(crate) fn service_worker_pause_on_start_owner_count(&self) -> usize { + self.service_worker_pause_on_start_owner_sessions.len() + } + + pub(crate) fn shared_worker_target_for_session( + &self, + session_id: Option<&str>, + ) -> Option<&SharedWorkerTargetState> { + let session_id = session_id?; + match self.session_route(Some(session_id))? { + CdpSessionRoute::SharedWorkerTarget { + browser_context_id, + target_id, + } => self + .browser_context_by_id(&browser_context_id)? + .shared_worker_target(&target_id), + CdpSessionRoute::DedicatedWorkerTarget { + browser_context_id, + target_id, + } => self + .browser_context_by_id(&browser_context_id)? + .dedicated_worker_target(&target_id) + .map(|target| &target.inner), + _ => None, + } + } + + /// Captures the exact renderer worker and protocol attachment addressed by + /// `session_id`. + /// + /// The attachment scope lives with the SharedWorker target's per-session + /// state. Holding this weak identity across a publication-capture boundary does + /// not keep a normally detached session alive and cannot be rebound by a + /// later current-session lookup. + pub(crate) fn shared_worker_protocol_attachment_identity_for_session( + &self, + session_id: Option<&str>, + ) -> Option { + let session_id = session_id?; + let CdpSessionRoute::SharedWorkerTarget { + browser_context_id, + target_id, + } = self.session_route(Some(session_id))? + else { + return None; + }; + self.browser_context_by_id(&browser_context_id)? + .shared_worker_target(&target_id)? + .protocol_attachment_identity(&browser_context_id, session_id) + } + + pub(crate) fn shared_worker_target_for_session_mut( + &mut self, + session_id: Option<&str>, + ) -> Option<&mut SharedWorkerTargetState> { + let session_id = session_id?; + match self.session_route(Some(session_id))? { + CdpSessionRoute::SharedWorkerTarget { + browser_context_id, + target_id, + } => self + .browser_context_by_id_mut(&browser_context_id)? + .shared_worker_target_mut(&target_id), + CdpSessionRoute::DedicatedWorkerTarget { + browser_context_id, + target_id, + } => self + .browser_context_by_id_mut(&browser_context_id)? + .dedicated_worker_target_mut(&target_id) + .map(|target| &mut target.inner), + _ => None, + } + } + + pub(crate) fn dedicated_worker_target_for_session( + &self, + session_id: Option<&str>, + ) -> Option<&crate::conn::DedicatedWorkerTargetState> { + let session_id = session_id?; + let CdpSessionRoute::DedicatedWorkerTarget { + browser_context_id, + target_id, + } = self.session_route(Some(session_id))? + else { + return None; + }; + self.browser_context_by_id(&browser_context_id)? + .dedicated_worker_target(&target_id) + } + + pub(crate) fn dedicated_worker_target_for_session_mut( + &mut self, + session_id: Option<&str>, + ) -> Option<&mut crate::conn::DedicatedWorkerTargetState> { + let session_id = session_id?; + let CdpSessionRoute::DedicatedWorkerTarget { + browser_context_id, + target_id, + } = self.session_route(Some(session_id))? + else { + return None; + }; + self.browser_context_by_id_mut(&browser_context_id)? + .dedicated_worker_target_mut(&target_id) + } + + pub(crate) fn service_worker_target_for_session( + &self, + session_id: Option<&str>, + ) -> Option<&ServiceWorkerTargetState> { + let session_id = session_id?; + let CdpSessionRoute::ServiceWorkerTarget { + browser_context_id, + target_id, + } = self.session_route(Some(session_id))? + else { + return None; + }; + self.browser_context_by_id(&browser_context_id)? + .service_worker_target(&target_id) + } + + pub(crate) fn service_worker_target_for_session_mut( + &mut self, + session_id: Option<&str>, + ) -> Option<&mut ServiceWorkerTargetState> { + let session_id = session_id?; + let CdpSessionRoute::ServiceWorkerTarget { + browser_context_id, + target_id, + } = self.session_route(Some(session_id))? + else { + return None; + }; + self.browser_context_by_id_mut(&browser_context_id)? + .service_worker_target_mut(&target_id) + } + pub(crate) fn with_background_target_session( &mut self, session_id: Option<&str>, @@ -112,3 +2014,54 @@ impl CdpConnection { } } } + +fn browser_context_session_route( + browser_context: &BrowserContext, + session_id: &str, +) -> Option { + if browser_context.active_session_id() == Some(session_id) { + return Some(CdpSessionRoute::ActiveTarget { + browser_context_id: browser_context.id.clone(), + target_id: browser_context.active_target_id().map(str::to_owned), + }); + } + + if let Some(target_id) = browser_context.auxiliary_target_id_for_session(session_id) { + return Some(CdpSessionRoute::AuxiliaryTarget { + browser_context_id: browser_context.id.clone(), + target_id: target_id.to_owned(), + }); + } + + browser_context + .primary_attachment_target_id_for_session(session_id) + .filter(|target_id| !browser_context.is_active_target(target_id)) + .map(|target_id| CdpSessionRoute::BackgroundTarget { + browser_context_id: browser_context.id.clone(), + target_id: target_id.to_owned(), + }) + .or_else(|| { + browser_context + .shared_worker_target_id_for_session(session_id) + .map(|target_id| CdpSessionRoute::SharedWorkerTarget { + browser_context_id: browser_context.id.clone(), + target_id: target_id.to_owned(), + }) + .or_else(|| { + browser_context + .dedicated_worker_target_id_for_session(session_id) + .map(|target_id| CdpSessionRoute::DedicatedWorkerTarget { + browser_context_id: browser_context.id.clone(), + target_id: target_id.to_owned(), + }) + .or_else(|| { + browser_context + .service_worker_target_id_for_session(session_id) + .map(|target_id| CdpSessionRoute::ServiceWorkerTarget { + browser_context_id: browser_context.id.clone(), + target_id: target_id.to_owned(), + }) + }) + }) + }) +} diff --git a/moli-protocol/src/conn/browser_context/target_context_projection.rs b/moli-protocol/src/conn/browser_context/target_context_projection.rs new file mode 100644 index 0000000000..e99fd7fc63 --- /dev/null +++ b/moli-protocol/src/conn/browser_context/target_context_projection.rs @@ -0,0 +1,81 @@ +use super::target_projection_error::TargetProjectionError; +use super::{BrowserContext, CdpConnection}; + +enum PhysicalBrowserContextSlot { + Selected, + Inactive(usize), +} + +pub(super) struct StagedPhysicalBrowserContext { + slot: PhysicalBrowserContextSlot, + pub(super) browser_context: BrowserContext, +} + +/// Stages the physical BrowserContext payload that contains a Target +/// transaction. Core never receives this payload; it is removed only so a +/// rejected same-turn owner call can restore the exact Protocol slot. +impl CdpConnection { + pub(super) fn take_physical_browser_context_for_target_projection( + &mut self, + browser_context_id: &str, + ) -> Result { + if self + .browser_context + .as_ref() + .is_some_and(|context| context.id == browser_context_id) + { + let Some(browser_context) = self.browser_context.take() else { + return Err(TargetProjectionError::PhysicalBrowserContextMissing( + browser_context_id.to_owned(), + )); + }; + return Ok(StagedPhysicalBrowserContext { + slot: PhysicalBrowserContextSlot::Selected, + browser_context, + }); + } + let Some(index) = self + .inactive_browser_contexts + .iter() + .position(|context| context.id == browser_context_id) + else { + return Err(TargetProjectionError::PhysicalBrowserContextMissing( + browser_context_id.to_owned(), + )); + }; + Ok(StagedPhysicalBrowserContext { + slot: PhysicalBrowserContextSlot::Inactive(index), + browser_context: self.inactive_browser_contexts.remove(index), + }) + } + + pub(super) fn restore_physical_browser_context_after_target_projection( + &mut self, + mut staged: StagedPhysicalBrowserContext, + ) { + staged + .browser_context + .adopt_browser_network_artifact_store(self.browser_host_state.network_artifacts()); + match staged.slot { + PhysicalBrowserContextSlot::Selected => { + let displaced = self.browser_context.replace(staged.browser_context); + debug_assert!( + displaced.is_none(), + "same-turn Target projection must leave the selected BrowserContext slot vacant" + ); + if let Some(displaced) = displaced { + self.inactive_browser_contexts.push(displaced); + } + } + PhysicalBrowserContextSlot::Inactive(index) => { + let insert_index = index.min(self.inactive_browser_contexts.len()); + debug_assert_eq!( + insert_index, index, + "same-turn Target projection must preserve the inactive BrowserContext vector" + ); + self.inactive_browser_contexts + .insert(insert_index, staged.browser_context); + } + } + } +} diff --git a/moli-protocol/src/conn/browser_context/target_projection_error.rs b/moli-protocol/src/conn/browser_context/target_projection_error.rs new file mode 100644 index 0000000000..565c51ed51 --- /dev/null +++ b/moli-protocol/src/conn/browser_context/target_projection_error.rs @@ -0,0 +1,161 @@ +use moli_core::browser_host::BrowserTargetRegistryError; + +use crate::devtools_runtime::{DevToolsError, DevToolsErrorKind}; + +/// A Browser Core Target transaction or its same-turn physical projection was +/// rejected before the two registries could diverge. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TargetProjectionError { + Core(BrowserTargetRegistryError), + PhysicalTargetCountMismatch { + authoritative: usize, + projected: usize, + }, + PhysicalContextTargetCountMismatch { + browser_context_id: String, + authoritative: usize, + projected: usize, + }, + PhysicalActiveTargetMismatch { + browser_context_id: String, + authoritative: Option, + projected: Option, + }, + DuplicatePhysicalTarget(String), + PhysicalTargetContextMismatch { + target_id: String, + authoritative: Option, + projected: String, + }, + PhysicalTargetHandleMismatch { + browser_context_id: String, + target_id: String, + }, + PhysicalPageResidenceMismatch { + browser_context_id: String, + target_id: String, + }, + PhysicalBrowserContextHandleMismatch(String), + ForeignBrowserTopLevelTargetSnapshot, + StaleBrowserContextTargetSnapshot(String), + StaleTopLevelTargetSnapshot { + browser_context_id: String, + target_id: String, + }, + PhysicalBrowserContextMissing(String), + PhysicalTargetMissing { + browser_context_id: String, + target_id: String, + }, +} + +impl std::fmt::Display for TargetProjectionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Core(error) => error.fmt(formatter), + Self::PhysicalTargetCountMismatch { + authoritative, + projected, + } => write!( + formatter, + "Browser Core has {authoritative} Targets but the physical projection has {projected}" + ), + Self::PhysicalContextTargetCountMismatch { + browser_context_id, + authoritative, + projected, + } => write!( + formatter, + "BrowserContext {browser_context_id:?} has {authoritative} Core Targets but {projected} physical Targets" + ), + Self::PhysicalActiveTargetMismatch { + browser_context_id, + authoritative, + projected, + } => write!( + formatter, + "BrowserContext {browser_context_id:?} has authoritative active Target {authoritative:?} but physical active Target {projected:?}" + ), + Self::DuplicatePhysicalTarget(target_id) => write!( + formatter, + "physical Target projection contains duplicate identity {target_id:?}" + ), + Self::PhysicalTargetContextMismatch { + target_id, + authoritative, + projected, + } => write!( + formatter, + "physical Target {target_id:?} belongs to BrowserContext {projected:?}, but Browser Core reports {authoritative:?}" + ), + Self::PhysicalTargetHandleMismatch { + browser_context_id, + target_id, + } => write!( + formatter, + "physical Target {target_id:?} in BrowserContext {browser_context_id:?} does not carry the exact live Browser Core handle" + ), + Self::PhysicalPageResidenceMismatch { + browser_context_id, + target_id, + } => write!( + formatter, + "physical Target {target_id:?} in BrowserContext {browser_context_id:?} does not carry the exact Browser Core Page residence" + ), + Self::PhysicalBrowserContextHandleMismatch(browser_context_id) => write!( + formatter, + "physical BrowserContext {browser_context_id:?} does not carry the exact Browser Core handle from the current-state snapshot" + ), + Self::ForeignBrowserTopLevelTargetSnapshot => write!( + formatter, + "top-level Target current-state snapshot belongs to another Browser instance" + ), + Self::StaleBrowserContextTargetSnapshot(browser_context_id) => write!( + formatter, + "BrowserContext current-state snapshot for {browser_context_id:?} is no longer live" + ), + Self::StaleTopLevelTargetSnapshot { + browser_context_id, + target_id, + } => write!( + formatter, + "top-level Target current-state snapshot for {target_id:?} in BrowserContext {browser_context_id:?} is no longer live" + ), + Self::PhysicalBrowserContextMissing(browser_context_id) => write!( + formatter, + "BrowserContext {browser_context_id:?} has no exact physical Target projection" + ), + Self::PhysicalTargetMissing { + browser_context_id, + target_id, + } => write!( + formatter, + "Target {target_id:?} has no physical payload in BrowserContext {browser_context_id:?}" + ), + } + } +} + +impl std::error::Error for TargetProjectionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Core(error) => Some(error), + _ => None, + } + } +} + +impl From for TargetProjectionError { + fn from(error: BrowserTargetRegistryError) -> Self { + Self::Core(error) + } +} + +impl From for DevToolsError { + fn from(error: TargetProjectionError) -> Self { + Self::new( + DevToolsErrorKind::Internal, + format!("TargetProjectionRejected: {error}"), + ) + } +} diff --git a/moli-protocol/src/conn/browser_context/target_registry_projection.rs b/moli-protocol/src/conn/browser_context/target_registry_projection.rs new file mode 100644 index 0000000000..d9cdd8e47b --- /dev/null +++ b/moli-protocol/src/conn/browser_context/target_registry_projection.rs @@ -0,0 +1,413 @@ +use moli_core::browser_host::{ + BrowserPageResidenceHandle, BrowserTargetCreationMetadata, BrowserTargetHandle, + BrowserTargetRegistration, BrowserTargetResidence, BrowserTargetSessionStorageAccess, +}; +use moli_core::page::RendererPageLifetimeOwner; + +use crate::conn::{BackgroundTarget, BrowserTargetCreatedFactProjection}; + +use super::target_projection_error::TargetProjectionError; +use super::{BrowserContext, BrowserEngineReplacementInputs, CdpConnection}; + +#[derive(Debug)] +pub(in crate::conn) struct ProjectedTargetActivation { + synchronize_loaded_page: bool, +} + +/// Physical projection paired with the exact Browser topology occurrence that +/// authorizes a live frontend Target-created notification. +#[derive(Debug)] +pub(crate) struct ProjectedTargetRegistration { + browser_fact: Option, +} + +impl ProjectedTargetRegistration { + pub(crate) fn into_browser_fact(self) -> Option { + self.browser_fact + } +} + +impl ProjectedTargetActivation { + pub(in crate::conn) fn synchronize_loaded_page(&self) -> bool { + self.synchronize_loaded_page + } +} + +/// Same-turn physical projection for Browser Core-owned top-level Target +/// topology. DevTools session synchronization is deliberately not performed +/// here; callers may await it only after this projection has completed. +impl CdpConnection { + #[cfg(test)] + pub(crate) fn register_background_target_projection( + &mut self, + browser_context_id: &str, + target_id: &str, + project: impl FnOnce( + &mut BrowserContext, + BrowserTargetHandle, + BrowserPageResidenceHandle, + BrowserTargetSessionStorageAccess, + ), + ) -> Result<(), TargetProjectionError> { + self.register_background_target_with_creation_metadata_projection( + browser_context_id, + target_id, + BrowserTargetCreationMetadata::default(), + project, + ) + .map(|_| ()) + } + + pub(crate) fn register_background_target_with_creation_metadata_projection( + &mut self, + browser_context_id: &str, + target_id: &str, + creation_metadata: BrowserTargetCreationMetadata, + project: impl FnOnce( + &mut BrowserContext, + BrowserTargetHandle, + BrowserPageResidenceHandle, + BrowserTargetSessionStorageAccess, + ), + ) -> Result { + self.validate_browser_target_topology_projection()?; + let mut staged = + self.take_physical_browser_context_for_target_projection(browser_context_id)?; + let topology = Self::browser_target_topology_projection(&staged.browser_context); + let registration = self + .browser_host_state + .register_background_target_with_creation_metadata( + browser_context_id, + target_id, + creation_metadata, + topology, + ); + let registration = match registration { + Ok(registration) => registration, + Err(error) => { + self.restore_physical_browser_context_after_target_projection(staged); + return Err(TargetProjectionError::Core(error)); + } + }; + debug_assert_eq!( + registration.owner().browser_context_id(), + browser_context_id + ); + debug_assert_eq!(registration.owner().target_id(), target_id); + debug_assert_eq!(registration.residence(), BrowserTargetResidence::Background); + project( + &mut staged.browser_context, + registration.handle().clone(), + registration.page_residence_handle().clone(), + registration.session_storage_access().clone(), + ); + self.restore_physical_browser_context_after_target_projection(staged); + self.debug_assert_browser_target_topology_projection(); + let browser_fact = self.claim_target_created_fact_for_registration(®istration); + Ok(ProjectedTargetRegistration { browser_fact }) + } + + #[cfg(test)] + pub(crate) fn register_active_target_projection( + &mut self, + target_id: &str, + project: impl FnOnce( + &mut BrowserContext, + BrowserTargetHandle, + BrowserPageResidenceHandle, + BrowserTargetSessionStorageAccess, + ), + ) -> Result<(), TargetProjectionError> { + self.register_active_target_with_creation_metadata_projection( + target_id, + BrowserTargetCreationMetadata::default(), + project, + ) + .map(|_| ()) + } + + pub(crate) fn register_active_target_with_creation_metadata_projection( + &mut self, + target_id: &str, + creation_metadata: BrowserTargetCreationMetadata, + project: impl FnOnce( + &mut BrowserContext, + BrowserTargetHandle, + BrowserPageResidenceHandle, + BrowserTargetSessionStorageAccess, + ), + ) -> Result { + self.validate_browser_target_topology_projection()?; + let Some(browser_context) = self.browser_context.as_ref() else { + return Err(TargetProjectionError::PhysicalBrowserContextMissing( + "".to_owned(), + )); + }; + let browser_context_id = browser_context.id.clone(); + let topology = Self::browser_target_topology_projection(browser_context); + let renderer_runtime = browser_context.renderer_runtime_owner_access(); + let selection = self.selected_browser_context_projection(); + let replacement_inputs = BrowserEngineReplacementInputs::capture(self); + let mut staged = + self.take_physical_browser_context_for_target_projection(&browser_context_id)?; + let registration = self + .browser_host_state + .register_active_target_with_creation_metadata( + &browser_context_id, + target_id, + creation_metadata, + topology, + selection, + || replacement_inputs.create_engine(renderer_runtime), + ); + let registration = match registration { + Ok(registration) => registration, + Err(error) => { + self.restore_physical_browser_context_after_target_projection(staged); + return Err(TargetProjectionError::Core(error)); + } + }; + debug_assert_eq!( + registration.owner().browser_context_id(), + browser_context_id + ); + debug_assert_eq!(registration.owner().target_id(), target_id); + debug_assert_eq!(registration.residence(), BrowserTargetResidence::Active); + project( + &mut staged.browser_context, + registration.handle().clone(), + registration.page_residence_handle().clone(), + registration.session_storage_access().clone(), + ); + self.restore_physical_browser_context_after_target_projection(staged); + self.debug_assert_browser_target_topology_projection(); + let browser_fact = self.claim_target_created_fact_for_registration(®istration); + Ok(ProjectedTargetRegistration { browser_fact }) + } + + #[cfg(test)] + pub(crate) fn replace_active_target_projection( + &mut self, + expected_target_id: &str, + replacement_target_id: &str, + project: impl FnOnce( + &mut BrowserContext, + BrowserTargetHandle, + BrowserPageResidenceHandle, + BrowserTargetSessionStorageAccess, + ), + ) -> Result<(), TargetProjectionError> { + self.replace_active_target_with_creation_metadata_projection( + expected_target_id, + replacement_target_id, + BrowserTargetCreationMetadata::default(), + project, + ) + .map(|_| ()) + } + + pub(crate) fn replace_active_target_with_creation_metadata_projection( + &mut self, + expected_target_id: &str, + replacement_target_id: &str, + creation_metadata: BrowserTargetCreationMetadata, + project: impl FnOnce( + &mut BrowserContext, + BrowserTargetHandle, + BrowserPageResidenceHandle, + BrowserTargetSessionStorageAccess, + ), + ) -> Result { + self.validate_browser_target_topology_projection()?; + let Some(browser_context) = self.browser_context.as_ref() else { + return Err(TargetProjectionError::PhysicalBrowserContextMissing( + "".to_owned(), + )); + }; + let browser_context_id = browser_context.id.clone(); + let topology = Self::browser_target_topology_projection(browser_context); + let renderer_runtime = browser_context.renderer_runtime_owner_access(); + let selection = self.selected_browser_context_projection(); + let replacement_inputs = BrowserEngineReplacementInputs::capture(self); + let mut staged = + self.take_physical_browser_context_for_target_projection(&browser_context_id)?; + let registration = self + .browser_host_state + .replace_active_target_with_creation_metadata( + &browser_context_id, + expected_target_id, + replacement_target_id, + creation_metadata, + topology, + selection, + || replacement_inputs.create_engine(renderer_runtime), + ); + let registration = match registration { + Ok(registration) => registration, + Err(error) => { + self.restore_physical_browser_context_after_target_projection(staged); + return Err(TargetProjectionError::Core(error)); + } + }; + debug_assert_eq!( + registration.owner().browser_context_id(), + browser_context_id + ); + debug_assert_eq!(registration.owner().target_id(), replacement_target_id); + debug_assert_eq!(registration.residence(), BrowserTargetResidence::Active); + debug_assert_eq!( + registration.previous_active_target_id(), + Some(expected_target_id) + ); + project( + &mut staged.browser_context, + registration.handle().clone(), + registration.page_residence_handle().clone(), + registration.session_storage_access().clone(), + ); + self.restore_physical_browser_context_after_target_projection(staged); + self.debug_assert_browser_target_topology_projection(); + let browser_fact = self.claim_target_created_fact_for_registration(®istration); + Ok(ProjectedTargetRegistration { browser_fact }) + } + + fn claim_target_created_fact_for_registration( + &mut self, + registration: &BrowserTargetRegistration, + ) -> Option { + match self.take_target_created_fact(registration.page_residence_identity()) { + Ok(projection) => Some(projection), + Err(error) => { + tracing::error!( + %error, + browser_context_id = registration.owner().browser_context_id(), + target_id = registration.owner().target_id(), + "top-level Target registration committed without an exact frontend Browser fact" + ); + None + } + } + } + + pub(in crate::conn) fn activate_target_projection( + &mut self, + target_id: &str, + ) -> Result { + self.validate_browser_target_topology_projection()?; + let Some(browser_context) = self.browser_context.as_ref() else { + return Err(TargetProjectionError::PhysicalBrowserContextMissing( + "".to_owned(), + )); + }; + let browser_context_id = browser_context.id.clone(); + let topology = Self::browser_target_topology_projection(browser_context); + let renderer_runtime = browser_context.renderer_runtime_owner_access(); + let selection = self.selected_browser_context_projection(); + let replacement_inputs = BrowserEngineReplacementInputs::capture(self); + let mut staged = + self.take_physical_browser_context_for_target_projection(&browser_context_id)?; + let physical_target_is_active = staged.browser_context.is_active_target(target_id); + let synchronize_loaded_page = !staged.browser_context.has_pending_javascript_dialog(); + let staged_target = if physical_target_is_active { + None + } else { + let Some(staged_target) = staged + .browser_context + .stage_background_target_slot_projection(target_id) + else { + self.restore_physical_browser_context_after_target_projection(staged); + return Err(TargetProjectionError::PhysicalTargetMissing { + browser_context_id, + target_id: target_id.to_owned(), + }); + }; + Some(staged_target) + }; + + let activation = self.browser_host_state.activate_target( + &browser_context_id, + target_id, + topology, + selection, + || replacement_inputs.create_engine(renderer_runtime), + ); + let activation = match activation { + Ok(activation) => activation, + Err(error) => { + if let Some(staged_target) = staged_target { + staged + .browser_context + .restore_staged_background_target_slot_projection(staged_target); + } + self.restore_physical_browser_context_after_target_projection(staged); + return Err(TargetProjectionError::Core(error)); + } + }; + debug_assert_eq!(activation.owner().browser_context_id(), browser_context_id); + debug_assert_eq!(activation.owner().target_id(), target_id); + + debug_assert_eq!( + activation.changed(), + staged_target.is_some(), + "validated Core and physical Target residence must produce the same activation outcome" + ); + if let Some(staged_target) = staged_target { + staged + .browser_context + .project_staged_background_target_to_active_slot_after_browser_owner_commit( + staged_target, + ); + } + + let synchronize_loaded_page = activation.changed() && synchronize_loaded_page; + self.restore_physical_browser_context_after_target_projection(staged); + self.debug_assert_browser_target_topology_projection(); + Ok(ProjectedTargetActivation { + synchronize_loaded_page, + }) + } + + pub(crate) fn rollback_staged_background_target_projection( + &mut self, + browser_context_id: &str, + target_id: &str, + ) -> Result<(BackgroundTarget, Option), TargetProjectionError> { + self.validate_browser_target_topology_projection()?; + let mut staged = + self.take_physical_browser_context_for_target_projection(browser_context_id)?; + let topology = Self::browser_target_topology_projection(&staged.browser_context); + let Some(index) = staged + .browser_context + .background_targets + .iter() + .position(|target| target.is_target(target_id)) + else { + self.restore_physical_browser_context_after_target_projection(staged); + return Err(TargetProjectionError::PhysicalTargetMissing { + browser_context_id: browser_context_id.to_owned(), + target_id: target_id.to_owned(), + }); + }; + let target = staged.browser_context.background_targets.remove(index); + let retired_renderer_page_owners = self + .browser_host_state + .rollback_staged_background_target(browser_context_id, target_id, topology); + let retired_renderer_page_owners = match retired_renderer_page_owners { + Ok(owners) => owners, + Err(error) => { + staged + .browser_context + .background_targets + .insert(index, target); + self.restore_physical_browser_context_after_target_projection(staged); + return Err(TargetProjectionError::Core(error)); + } + }; + self.restore_physical_browser_context_after_target_projection(staged); + self.debug_assert_browser_target_topology_projection(); + Ok((target, retired_renderer_page_owners)) + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-protocol/src/conn/browser_context/target_registry_projection/tests.rs b/moli-protocol/src/conn/browser_context/target_registry_projection/tests.rs new file mode 100644 index 0000000000..0016baf05d --- /dev/null +++ b/moli-protocol/src/conn/browser_context/target_registry_projection/tests.rs @@ -0,0 +1,551 @@ +use moli_core::browser_host::{BrowserFact, BrowserTargetCreationMetadata}; +use std::sync::Arc; + +use crate::conn::{ + BackgroundTarget, BrowserContext, BrowserTargetCreatedFactProjection, CdpConnection, +}; + +use super::TargetProjectionError; + +fn connection_with_active_target() -> CdpConnection { + let mut connection = CdpConnection::new(); + let mut browser_context = BrowserContext::new("context-1".to_owned()); + browser_context.set_active_target_id("target-a"); + connection.insert_browser_context(browser_context); + connection +} + +fn register_background_target(connection: &mut CdpConnection, target_id: &str) { + let target = BackgroundTarget::with_url( + target_id.to_owned(), + None, + format!("about:blank#{target_id}"), + ); + connection + .register_background_target_projection( + "context-1", + target_id, + move |browser_context, target_handle, page_residence, session_storage_access| { + let mut target = target; + target.replace_target_handle(target_handle); + target.replace_page_residence_handle(page_residence); + target.bind_session_storage_access(session_storage_access); + browser_context.background_targets.push(target); + }, + ) + .expect("background Target projection should register"); +} + +fn register_background_target_and_take_fact( + connection: &mut CdpConnection, + target_id: &str, +) -> BrowserTargetCreatedFactProjection { + let target = BackgroundTarget::with_url( + target_id.to_owned(), + None, + format!("about:blank#{target_id}"), + ); + connection + .register_background_target_with_creation_metadata_projection( + "context-1", + target_id, + BrowserTargetCreationMetadata::default(), + move |browser_context, target_handle, page_residence, session_storage_access| { + let mut target = target; + target.replace_target_handle(target_handle); + target.replace_page_residence_handle(page_residence); + target.bind_session_storage_access(session_storage_access); + browser_context.background_targets.push(target); + }, + ) + .expect("background Target projection should register") + .into_browser_fact() + .expect("top-level Target registration should claim its exact Browser fact") +} + +#[test] +fn projected_target_registration_claims_occurrence_without_discovery_subscription() { + let mut connection = connection_with_active_target(); + assert!(!connection.has_any_target_discovery()); + + let created = register_background_target_and_take_fact(&mut connection, "target-b"); + + assert!(matches!( + created.envelope().fact(), + BrowserFact::TargetCreated + )); + assert_eq!(created.envelope().target_id().as_str(), "target-b"); + assert!(connection.target_created_fact_matches_current_target(&created)); + assert_eq!( + connection.last_projected_browser_fact_sequence_for_test(), + Some(created.envelope().sequence().get()) + ); +} + +#[test] +fn reused_public_target_id_cannot_project_predecessor_creation_fact() { + let mut connection = connection_with_active_target(); + let predecessor = register_background_target_and_take_fact(&mut connection, "target-b"); + + connection + .rollback_staged_background_target_projection("context-1", "target-b") + .expect("staged predecessor should retire"); + let successor = register_background_target_and_take_fact(&mut connection, "target-b"); + + assert!(!connection.target_created_fact_matches_current_target(&predecessor)); + assert!(connection.target_created_fact_matches_current_target(&successor)); + assert!( + !predecessor + .envelope() + .page_residence() + .same_residence_instance(successor.envelope().page_residence()) + ); +} + +#[test] +fn reused_public_target_id_cannot_rebind_predecessor_session_storage() { + let mut connection = connection_with_active_target(); + register_background_target(&mut connection, "target-b"); + let predecessor = connection + .browser_host_state + .navigation_owner() + .target_session_storage_access("context-1", "target-b") + .expect("first Target namespace access"); + let predecessor_store = connection + .browser_context_by_id("context-1") + .and_then(|context| context.background_target("target-b")) + .expect("first physical Target") + .session_storage_store() + .clone(); + assert!(Arc::ptr_eq(predecessor.store(), &predecessor_store)); + + connection + .rollback_staged_background_target_projection("context-1", "target-b") + .expect("first Target should retire"); + register_background_target(&mut connection, "target-b"); + let successor = connection + .browser_host_state + .navigation_owner() + .target_session_storage_access("context-1", "target-b") + .expect("replacement Target namespace access"); + let successor_store = connection + .browser_context_by_id("context-1") + .and_then(|context| context.background_target("target-b")) + .expect("replacement physical Target") + .session_storage_store() + .clone(); + + assert!(!predecessor.is_live()); + assert!(successor.is_live()); + assert_ne!(predecessor.target_handle(), successor.target_handle()); + assert!(!Arc::ptr_eq(predecessor.store(), successor.store())); + assert!(Arc::ptr_eq(successor.store(), &successor_store)); +} + +#[test] +fn registration_and_activation_keep_core_and_physical_topology_exact() { + let mut connection = connection_with_active_target(); + register_background_target(&mut connection, "target-b"); + + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .target_browser_context_id("target-b"), + Some("context-1") + ); + let activation = connection + .activate_target_projection("target-b") + .expect("registered background Target should activate"); + + assert!(activation.synchronize_loaded_page()); + let browser_context = connection + .browser_context + .as_ref() + .expect("selected BrowserContext"); + assert_eq!(browser_context.active_target_id(), Some("target-b")); + assert_eq!(browser_context.background_targets.len(), 1); + assert_eq!( + browser_context.background_targets[0].target_id(), + "target-a" + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .active_target_id_for_browser_context("context-1"), + Some("target-b") + ); + connection + .validate_browser_target_topology_projection() + .expect("Core and physical Target topology should remain exact"); +} + +#[test] +fn active_placeholder_replacement_keeps_one_core_and_physical_target() { + let mut connection = connection_with_active_target(); + let placeholder = connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_handle) + .expect("placeholder handle") + .clone(); + + connection + .replace_active_target_projection( + "target-a", + "target-bootstrap", + |browser_context, target_handle, page_residence, session_storage_access| { + browser_context.bind_new_active_target_registration( + target_handle, + page_residence, + session_storage_access, + ); + }, + ) + .expect("active placeholder projection should be replaced"); + + let browser_context = connection + .browser_context + .as_ref() + .expect("selected BrowserContext"); + assert_eq!(browser_context.active_target_id(), Some("target-bootstrap")); + assert!(browser_context.background_targets.is_empty()); + assert!(placeholder.is_retired()); + assert!( + browser_context + .active_target_handle() + .is_some_and(moli_core::browser_host::BrowserTargetHandle::is_live) + ); + assert!( + !connection + .browser_host_state + .navigation_owner() + .has_target("target-a") + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .target_count(), + 1 + ); + connection + .validate_browser_target_topology_projection() + .expect("Core and physical Target topology should remain exact"); +} + +#[test] +fn staged_background_rollback_removes_core_and_physical_projection_together() { + let mut connection = connection_with_active_target(); + register_background_target(&mut connection, "target-b"); + + let (removed, retired_renderer_page_owner) = connection + .rollback_staged_background_target_projection("context-1", "target-b") + .expect("staged background Target should roll back"); + + assert!(retired_renderer_page_owner.is_none()); + assert_eq!(removed.target_id(), "target-b"); + assert!(removed.target_handle().is_retired()); + assert!( + !connection + .browser_host_state + .navigation_owner() + .has_target("target-b") + ); + assert!( + connection + .browser_context + .as_ref() + .is_some_and(|context| context.background_targets.is_empty()) + ); + connection + .validate_browser_target_topology_projection() + .expect("Core and physical Target topology should remain exact"); +} + +#[test] +fn same_public_id_wrong_target_handle_cannot_authorize_activation() { + let mut connection = connection_with_active_target(); + register_background_target(&mut connection, "target-b"); + let authoritative = connection + .browser_host_state + .navigation_owner() + .target_handle("target-b") + .expect("registered Target handle"); + let wrong_instance = moli_core::browser_host::BrowserTargetHandle::staged("target-b"); + connection + .browser_context + .as_mut() + .and_then(|context| context.background_target_mut("target-b")) + .expect("physical background Target") + .replace_target_handle(wrong_instance.clone()); + + let error = connection + .activate_target_projection("target-b") + .expect_err("wrong Target handle must reject activation"); + assert!(matches!( + error, + TargetProjectionError::PhysicalTargetHandleMismatch { + ref browser_context_id, + ref target_id, + } if browser_context_id == "context-1" && target_id == "target-b" + )); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .active_target_id_for_browser_context("context-1"), + Some("target-a") + ); + assert_eq!( + connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some("target-a") + ); + assert!(authoritative.is_live()); + assert!(!wrong_instance.is_live()); +} + +#[test] +fn same_target_wrong_page_handle_cannot_authorize_activation() { + let mut connection = connection_with_active_target(); + register_background_target(&mut connection, "target-b"); + let owner = moli_core::browser_host::BrowserPageOwnerKey::new("context-1", "target-b"); + let authoritative = connection + .browser_host_state + .navigation_owner() + .page_residence_handle(&owner) + .expect("registered Page residence"); + let wrong_instance = moli_core::browser_host::BrowserPageResidenceHandle::default(); + connection + .browser_context + .as_mut() + .and_then(|context| context.background_target_mut("target-b")) + .expect("physical background Target") + .replace_page_residence_handle(wrong_instance.clone()); + + let error = connection + .activate_target_projection("target-b") + .expect_err("wrong Page residence must reject activation"); + assert!(matches!( + error, + TargetProjectionError::PhysicalPageResidenceMismatch { + ref browser_context_id, + ref target_id, + } if browser_context_id == "context-1" && target_id == "target-b" + )); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .active_target_id_for_browser_context("context-1"), + Some("target-a") + ); + assert_eq!( + connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some("target-a") + ); + assert!( + connection + .browser_host_state + .navigation_owner() + .page_residence_handle_is_current(&owner, &authoritative) + ); + assert!( + !connection + .browser_host_state + .navigation_owner() + .page_residence_handle_is_current(&owner, &wrong_instance) + ); +} + +#[test] +fn idempotent_physical_target_id_write_preserves_core_handle() { + let mut connection = connection_with_active_target(); + let before = connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_handle) + .expect("active Target handle") + .clone(); + + connection + .browser_context + .as_mut() + .expect("selected BrowserContext") + .set_active_target_id("target-a"); + + let after = connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_handle) + .expect("active Target handle"); + assert_eq!(after, &before); + assert!( + connection + .browser_host_state + .navigation_owner() + .target_handle_is_current(after) + ); +} + +#[test] +fn physical_only_target_mutation_is_rejected_instead_of_becoming_authority() { + let mut connection = connection_with_active_target(); + connection + .browser_context + .as_mut() + .expect("selected BrowserContext") + .background_targets + .push(BackgroundTarget::with_url( + "rogue-target".to_owned(), + None, + "about:blank#rogue".to_owned(), + )); + + let target = BackgroundTarget::with_url( + "target-b".to_owned(), + None, + "about:blank#target-b".to_owned(), + ); + let error = connection + .register_background_target_projection( + "context-1", + "target-b", + move |browser_context, target_handle, page_residence, session_storage_access| { + let mut target = target; + target.replace_target_handle(target_handle); + target.replace_page_residence_handle(page_residence); + target.bind_session_storage_access(session_storage_access); + browser_context.background_targets.push(target); + }, + ) + .expect_err("physical-only Target mutation must reject registration"); + + assert!(matches!( + error, + TargetProjectionError::PhysicalContextTargetCountMismatch { + ref browser_context_id, + authoritative: 1, + projected: 2, + } if browser_context_id == "context-1" + )); + assert!( + !connection + .browser_host_state + .navigation_owner() + .has_target("target-b") + ); + assert!( + !connection + .browser_host_state + .navigation_owner() + .has_target("rogue-target") + ); +} + +#[test] +fn core_registration_rejection_restores_the_exact_physical_context_slot() { + let mut connection = connection_with_active_target(); + register_background_target(&mut connection, "target-b"); + let active_handle = connection + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_handle) + .expect("active Target handle") + .clone(); + let background_handle = connection + .browser_context + .as_ref() + .and_then(|context| context.background_target("target-b")) + .expect("background Target") + .target_handle() + .clone(); + + let error = connection + .register_background_target_projection( + "context-1", + "target-b", + |_browser_context, _target_handle, _page_residence, _session_storage_access| {}, + ) + .expect_err("duplicate Target registration must be rejected by Core"); + + assert!(matches!( + error, + TargetProjectionError::Core( + moli_core::browser_host::BrowserTargetRegistryError::DuplicateTarget(ref id) + ) if id.as_str() == "target-b" + )); + let browser_context = connection + .browser_context + .as_ref() + .expect("selected BrowserContext must be restored"); + assert_eq!(browser_context.active_target_handle(), Some(&active_handle)); + assert_eq!(browser_context.background_targets.len(), 1); + assert_eq!( + browser_context.background_targets[0].target_handle(), + &background_handle + ); + assert_eq!( + connection + .browser_host_state + .navigation_owner() + .target_count(), + 2 + ); + connection + .validate_browser_target_topology_projection() + .expect("rejected registration must preserve exact topology"); +} + +#[test] +fn core_registration_rejection_restores_the_exact_inactive_context_index() { + let mut connection = connection_with_active_target(); + register_background_target(&mut connection, "target-b"); + for (browser_context_id, target_id) in [("context-2", "target-c"), ("context-3", "target-d")] { + let mut browser_context = BrowserContext::new(browser_context_id.to_owned()); + browser_context.set_active_target_id(target_id); + connection.insert_browser_context(browser_context); + } + assert_eq!( + connection + .inactive_browser_contexts + .iter() + .map(|context| context.id.as_str()) + .collect::>(), + vec!["context-2", "context-3"] + ); + + let error = connection + .register_background_target_projection( + "context-3", + "target-b", + |_browser_context, _target_handle, _page_residence, _session_storage_access| {}, + ) + .expect_err("duplicate Target registration must be rejected by Core"); + + assert!(matches!( + error, + TargetProjectionError::Core( + moli_core::browser_host::BrowserTargetRegistryError::DuplicateTarget(ref id) + ) if id.as_str() == "target-b" + )); + assert_eq!( + connection + .inactive_browser_contexts + .iter() + .map(|context| context.id.as_str()) + .collect::>(), + vec!["context-2", "context-3"], + "typed Core rejection must restore the removed inactive Context at its original index" + ); + connection + .validate_browser_target_topology_projection() + .expect("rejected registration must preserve every Context Target topology"); +} 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..22af96f6f5 100644 --- a/moli-protocol/src/conn/browser_context/target_session_owner.rs +++ b/moli-protocol/src/conn/browser_context/target_session_owner.rs @@ -2,29 +2,28 @@ use super::session_owner::TargetSessionOwner; use super::*; use crate::conn::state::{ BrowserContextPageStorageHandles, BrowserContextResourceStorageHandles, DevToolsSessionState, - PageNavigationHistoryEntry, RendererMainDocumentCommitSeed, TargetFetchConfig, - TargetNetworkPolicyState, TargetOwnerState, TargetPageAbsenceReason, + RendererMainDocumentCommitSeed, TargetFetchConfig, TargetNetworkPolicyState, TargetOwnerState, TargetPageResidenceIdentity, TargetRuntimeSessionState, TargetRuntimeSlot, - page_bypass_csp_enabled_for_devtools_sessions, - prepare_renderer_call_replacements_for_devtools_sessions, runtime_bindings_for_renderer, + page_bypass_csp_enabled_for_devtools_sessions, runtime_bindings_for_renderer, }; use crate::conn::{ - BackgroundProtocolEvent, ConnectionNetworkRequestIdAllocator, DocumentStartScript, - EmulatedDeviceMetrics, FetchInterceptionPattern, FetchRequestStage, - InitialDocumentPageInstallResult, InitialDocumentPageOwner, IsolatedWorldDefinition, - LoadedNavigationPageCommit, LoadedNavigationRendererAttachmentCommit, NETWORK_ERROR_PAGE_URL, + DocumentStartScript, EmulatedDeviceMetrics, FetchInterceptionPattern, FetchRequestStage, + InitialDocumentPageOwner, IsolatedWorldDefinition, NETWORK_ERROR_PAGE_URL, NetworkErrorPageNavigation, PausedDocumentTransfer, PendingFetchAuthNavigation, PendingFetchNavigation, PendingSubresourceFetchAuthRequest, PendingSubresourceFetchRequest, - PendingSubresourceFetchResponseRequest, RuntimeBindingDefinition, + PendingSubresourceFetchResponseRequest, RuntimeBindingDefinition, TargetEventPlan, }; use crate::devtools_runtime::{DevToolsNetworkInterceptId, DevToolsNetworkResourceType}; use moli_cookie_jar::{StoredCookieQueryReport, StoredCookieSetReport}; +use moli_core::browser_host::BrowserNetworkArtifactStore as ConnectionNetworkRequestIdAllocator; +#[cfg(test)] +use moli_core::browser_host::BrowserPageRuntimeOwner; #[cfg(test)] use moli_core::page::RendererServiceWorkerVersionStatus; use moli_core::page::{ BidiPreloadChannelHandoff, Page, RendererInspectorProtocolConfiguration, - RendererInspectorSessionRestoreSnapshot, RendererMainDocumentCommit, - RendererPageCreationArtifacts, SubresourceResourceType, V8InspectorSessionAttach, + RendererInspectorSessionRestoreSnapshot, RendererMainDocumentCommit, SubresourceResourceType, + V8InspectorSessionAttach, }; use moli_core::runtime::RendererBrowserContextRuntimeOwnerAccess; use moli_fetch::BrowserNavigationRequestKind; @@ -82,6 +81,7 @@ pub(crate) struct ClosedPageTarget { pub(crate) target_id: String, pub(crate) primary_session_id: Option, pub(crate) auxiliary_session_ids: Vec, + pub(super) promoted_target_id: Option, } impl ClosedPageTarget { @@ -92,6 +92,10 @@ impl ClosedPageTarget { .chain(self.auxiliary_session_ids.iter().map(String::as_str)) } + pub(crate) fn promoted_target_id(&self) -> Option<&str> { + self.promoted_target_id.as_deref() + } + pub(crate) fn into_detach_cleanup_plan( self, reason: Option<&str>, @@ -277,27 +281,27 @@ impl TargetNavigationLoadInputs { tls_verify_host_override: browser_context.tls_verify_host_override, navigation_initiator_url: target_navigation_initiator_url( browser_context.target_url(), - browser_context.loaded_page(), + browser_context.loaded_page().as_deref(), ), browser_navigation_kind: BrowserNavigationRequestKind::Navigate, infer_navigation_referrer: true, document_start_scripts: browser_context.document_start_script_descriptors(), runtime_bindings: runtime_bindings_for_renderer( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, + browser_context.devtools_session_state(), + browser_context.auxiliary_devtools_session_states(), ), runtime_inspector_session_restore_snapshots: runtime_inspector_session_restore_snapshots_for_renderer( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, + browser_context.devtools_session_state(), + browser_context.auxiliary_devtools_session_states(), ), extra_http_headers: browser_context.effective_extra_headers(), locale_override: browser_context.effective_active_locale_override_owned(), timezone_override: browser_context.effective_active_timezone_override_owned(), script_execution_disabled: browser_context.script_execution_disabled, bypass_content_security_policy: page_bypass_csp_enabled_for_devtools_sessions( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, + browser_context.devtools_session_state(), + browser_context.auxiliary_devtools_session_states(), ), cpu_throttling_rate: browser_context.cpu_throttling_rate, emulated_media: (&browser_context.emulated_media).into(), @@ -417,26 +421,21 @@ fn target_navigation_initiator_url(target_url: &str, loaded_page: Option<&Page>) url.host_str().is_some().then_some(url) } -fn clear_parked_page_runtime_remote_object_tracking( - state: &mut crate::conn::state::ParkedPageSessionState, -) { - state - .devtools_session_state - .clear_runtime_remote_object_tracking(); - for state in state.auxiliary_devtools_session_states.values_mut() { - state.clear_runtime_remote_object_tracking(); - } -} - -fn clear_parked_page_loaded_document_session_state( - state: &mut crate::conn::state::ParkedPageSessionState, +pub(super) fn clear_top_level_target_loaded_document_session_state( + browser_context: &mut BrowserContext, + target_id: &str, ) { - clear_parked_page_runtime_remote_object_tracking(state); - state - .devtools_session_state + let Some((primary, auxiliary)) = + browser_context.devtools_session_states_for_target_mut(target_id) + else { + return; + }; + primary.clear_runtime_remote_object_tracking(); + primary .page_session_state .clear_loaded_document_context_state(); - for state in state.auxiliary_devtools_session_states.values_mut() { + for state in auxiliary.values_mut() { + state.clear_runtime_remote_object_tracking(); state .page_session_state .clear_loaded_document_context_state(); @@ -518,27 +517,27 @@ impl<'a> TargetSessionOwnerRef<'a> { browser_context, session_id, is_auxiliary_target_session, - } => target_devtools_session_state( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, - *is_auxiliary_target_session, - session_id.as_deref(), - ), + } => { + if *is_auxiliary_target_session { + session_id.as_deref().and_then(|session_id| { + browser_context + .auxiliary_devtools_session_states() + .get(session_id) + }) + } else { + Some(browser_context.devtools_session_state()) + } + } Self::BackgroundTarget { browser_context, target_id, session_id, is_auxiliary_target_session, - } => browser_context - .parked_page_session_state(target_id) - .and_then(|state| { - target_devtools_session_state( - &state.devtools_session_state, - &state.auxiliary_devtools_session_states, - *is_auxiliary_target_session, - session_id.as_deref(), - ) - }), + } => browser_context.devtools_session_state_for_target( + target_id, + *is_auxiliary_target_session, + session_id.as_deref(), + ), Self::NoLoadedBrowserContext => None, } } @@ -553,20 +552,17 @@ impl<'a> TargetSessionOwnerRef<'a> { Self::ActiveTarget { browser_context, .. } => Some(page_bypass_csp_enabled_for_devtools_sessions( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, + browser_context.devtools_session_state(), + browser_context.auxiliary_devtools_session_states(), )), Self::BackgroundTarget { browser_context, target_id, .. } => browser_context - .parked_page_session_state(target_id) - .map(|state| { - page_bypass_csp_enabled_for_devtools_sessions( - &state.devtools_session_state, - &state.auxiliary_devtools_session_states, - ) + .devtools_session_states_for_target(target_id) + .map(|(primary, auxiliary)| { + page_bypass_csp_enabled_for_devtools_sessions(primary, auxiliary) }), Self::NoLoadedBrowserContext => None, } @@ -601,21 +597,16 @@ impl<'a> TargetSessionOwnerRef<'a> { Self::ActiveTarget { browser_context, .. } => runtime_bindings_for_renderer( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, + browser_context.devtools_session_state(), + browser_context.auxiliary_devtools_session_states(), ), Self::BackgroundTarget { browser_context, target_id, .. } => browser_context - .parked_page_session_state(target_id) - .map(|state| { - runtime_bindings_for_renderer( - &state.devtools_session_state, - &state.auxiliary_devtools_session_states, - ) - }) + .devtools_session_states_for_target(target_id) + .map(|(primary, auxiliary)| runtime_bindings_for_renderer(primary, auxiliary)) .unwrap_or_default(), Self::NoLoadedBrowserContext => Vec::new(), } @@ -635,29 +626,6 @@ impl<'a> TargetSessionOwnerRef<'a> { } } - pub(super) fn initial_empty_document_url_if_current(&self) -> Option { - self.target_owner_state()? - .initial_empty_document_url_if_current() - .map(str::to_owned) - } - - pub(super) fn initial_empty_document_storage_key_if_current( - &self, - ) -> Option { - self.target_owner_state()? - .initial_empty_document_storage_key_if_current() - .cloned() - } - - pub(super) fn is_on_initial_empty_document(&self) -> Option { - self.target_owner_state()?.is_on_initial_empty_document() - } - - pub(super) fn initial_empty_document_has_pending_cross_document_navigation(&self) -> bool { - self.target_owner_state() - .is_some_and(TargetOwnerState::initial_empty_document_pending_cross_document_navigation) - } - pub(super) fn aggregate_fetch_config(&self) -> Option { match self { Self::ActiveTarget { @@ -717,8 +685,8 @@ impl<'a> TargetSessionOwnerRef<'a> { target_id, .. } => browser_context - .background_target(target_id) - .and_then(|target| target.session_id().map(str::to_owned)), + .primary_session_id_for_target(target_id) + .map(str::to_owned), Self::NoLoadedBrowserContext => None, } } @@ -782,17 +750,6 @@ impl<'a> TargetSessionOwnerRef<'a> { } } - pub(super) fn frame_tree_loader_id(&self) -> Option { - self.runtime_slot() - .and_then(TargetRuntimeSlot::committed_document_loader_id) - .map(str::to_owned) - .or_else(|| { - self.target_owner_state()? - .initial_empty_document_loader_id_if_current() - .map(str::to_owned) - }) - } - pub(super) fn emulated_device_metrics(&self) -> Option { match self { Self::ActiveTarget { @@ -846,15 +803,30 @@ impl<'a> TargetSessionOwnerRef<'a> { }, )); } - let runtime_bindings = runtime_bindings_for_renderer( - &page_state.devtools_session_state, - &page_state.auxiliary_devtools_session_states, - ); - let runtime_inspector_session_restore_snapshots = - runtime_inspector_session_restore_snapshots_for_renderer( - &page_state.devtools_session_state, - &page_state.auxiliary_devtools_session_states, - ); + let ( + runtime_bindings, + runtime_inspector_session_restore_snapshots, + bypass_content_security_policy, + ) = match browser_context.devtools_session_states_for_target(target_id) { + Some((primary_session_state, auxiliary_session_states)) => ( + runtime_bindings_for_renderer( + primary_session_state, + auxiliary_session_states, + ), + runtime_inspector_session_restore_snapshots_for_renderer( + primary_session_state, + auxiliary_session_states, + ), + page_bypass_csp_enabled_for_devtools_sessions( + primary_session_state, + auxiliary_session_states, + ), + ), + // Frontend projection loss must not stop a Browser-owned + // navigation. It only removes frontend-derived renderer + // configuration from this load snapshot. + None => (Vec::new(), Vec::new(), false), + }; let mut extra_http_headers = page_state.network_policy.extra_headers().to_vec(); let locale_override = page_state .locale_override @@ -884,7 +856,7 @@ impl<'a> TargetSessionOwnerRef<'a> { tls_verify_host_override: page_state.tls_verify_host_override, navigation_initiator_url: target_navigation_initiator_url( target.target_url(), - target.loaded_page(), + target.loaded_page().as_deref(), ), browser_navigation_kind: BrowserNavigationRequestKind::Navigate, infer_navigation_referrer: true, @@ -897,10 +869,7 @@ impl<'a> TargetSessionOwnerRef<'a> { .timezone_override .or_else(|| browser_context.default_timezone_override.clone()), script_execution_disabled: page_state.script_execution_disabled, - bypass_content_security_policy: page_bypass_csp_enabled_for_devtools_sessions( - &page_state.devtools_session_state, - &page_state.auxiliary_devtools_session_states, - ), + bypass_content_security_policy, cpu_throttling_rate: page_state.cpu_throttling_rate, emulated_media: (&page_state.emulated_media).into(), viewport_surface: browser_context @@ -925,30 +894,6 @@ impl<'a> TargetSessionOwnerRef<'a> { } } -fn target_devtools_session_state_mut<'a>( - primary: &'a mut DevToolsSessionState, - auxiliary: &'a mut std::collections::HashMap, - is_auxiliary_target_session: bool, - session_id: Option<&str>, -) -> &'a mut DevToolsSessionState { - if is_auxiliary_target_session && let Some(session_id) = session_id { - return auxiliary.entry(session_id.to_owned()).or_default(); - } - primary -} - -fn target_devtools_session_state<'a>( - primary: &'a DevToolsSessionState, - auxiliary: &'a std::collections::HashMap, - is_auxiliary_target_session: bool, - session_id: Option<&str>, -) -> Option<&'a DevToolsSessionState> { - if is_auxiliary_target_session { - return session_id.and_then(|session_id| auxiliary.get(session_id)); - } - Some(primary) -} - impl TargetSessionStateMut<'_> { pub(super) fn devtools_session_state_mut(&mut self) -> Option<&mut DevToolsSessionState> { match self { @@ -1059,38 +1004,41 @@ impl<'a> TargetSessionOwnerMut<'a> { session_id, is_auxiliary_target_session, .. - } => { - let devtools_session_state = target_devtools_session_state_mut( - &mut browser_context.devtools_session_state, - &mut browser_context.auxiliary_devtools_session_states, - *is_auxiliary_target_session, - session_id.as_deref(), - ); - f(TargetSessionStateMut::Active { - devtools_session_state, - network_policy: &mut browser_context.network_policy, - tls_verify_host_override: &mut browser_context.tls_verify_host_override, - }) - } + } => browser_context.mutate_active_frontend_and_policy_state( + *is_auxiliary_target_session, + session_id.as_deref(), + |state| match state { + Some((devtools_session_state, network_policy, tls_verify_host_override)) => { + f(TargetSessionStateMut::Active { + devtools_session_state, + network_policy, + tls_verify_host_override, + }) + } + None => f(TargetSessionStateMut::NoLoaded), + }, + ), Self::BackgroundTarget { browser_context, target_id, session_id, is_auxiliary_target_session, .. - } => browser_context.mutate_parked_page_session_state(target_id, |state| { - let devtools_session_state = target_devtools_session_state_mut( - &mut state.devtools_session_state, - &mut state.auxiliary_devtools_session_states, - *is_auxiliary_target_session, - session_id.as_deref(), - ); - f(TargetSessionStateMut::Parked { - devtools_session_state, - network_policy: &mut state.network_policy, - tls_verify_host_override: &mut state.tls_verify_host_override, - }) - }), + } => browser_context.mutate_target_frontend_and_policy_state( + target_id, + *is_auxiliary_target_session, + session_id.as_deref(), + |state| match state { + Some((devtools_session_state, network_policy, tls_verify_host_override)) => { + f(TargetSessionStateMut::Parked { + devtools_session_state, + network_policy, + tls_verify_host_override, + }) + } + None => f(TargetSessionStateMut::NoLoaded), + }, + ), Self::NoLoadedBrowserContext => f(TargetSessionStateMut::NoLoaded), } } @@ -1344,155 +1292,6 @@ impl<'a> TargetSessionOwnerMut<'a> { } } - pub(super) async fn mark_target_crashed_async(&mut self) -> Option<()> { - match self { - Self::ActiveTarget { - browser_context, .. - } => { - browser_context.mark_active_target_crashed_async().await; - Some(()) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - browser_context.background_target(target_id)?; - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.target_crash_state.mark_crashed(); - owner_state.navigation_history_state.clear(); - owner_state.clear_loaded_document_context_state(); - }); - browser_context.mutate_parked_page_session_state(target_id, |state| { - clear_parked_page_loaded_document_session_state(state); - }); - let previous = { - let target = browser_context.background_target_mut(target_id)?; - target.runtime_slot.clear_document_navigation_state(); - let previous = target - .runtime_slot - .clear_loaded_page_with_reason(TargetPageAbsenceReason::TargetCrashed); - target.runtime_slot.reset_subresource_cursor(); - target - .runtime_slot - .reset_all_target_scoped_network_artifacts(); - previous - }; - browser_context.replace_parked_fetch_state(target_id.clone(), Default::default()); - browser_context - .replace_parked_network_artifacts(target_id.clone(), Default::default()); - if let Some(page) = previous { - let _ = page.close_async().await; - } - Some(()) - } - Self::NoLoadedBrowserContext => None, - } - } - - pub(super) async fn discard_loaded_page_after_failed_navigation_async( - &mut self, - final_url: &Url, - ) -> Option<()> { - let next_url = final_url.to_string(); - let security_origin = final_url.origin().ascii_serialization(); - match self { - Self::ActiveTarget { - browser_context, .. - } => { - browser_context.set_target_url(next_url); - browser_context.set_target_security_origin(security_origin); - if let Some(target_id) = browser_context.active_target_id_owned() { - browser_context.mark_target_initial_empty_document_exited(&target_id); - } - browser_context - .active_target - .runtime_slot - .clear_document_navigation_state(); - browser_context.clear_active_target_runtime_remote_object_tracking(); - let previous = browser_context - .clear_loaded_page_with_reason(TargetPageAbsenceReason::NavigationFailed); - if let Some(page) = previous { - let _ = page.close_async().await; - } - Some(()) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - browser_context.background_target(target_id)?; - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.mark_initial_empty_document_exited(); - 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 = { - let target = browser_context.background_target_mut(target_id)?; - target.set_target_url(next_url); - target.set_target_security_origin(security_origin); - target.runtime_slot.clear_document_navigation_state(); - let previous = target - .runtime_slot - .clear_loaded_page_with_reason(TargetPageAbsenceReason::NavigationFailed); - target.runtime_slot.reset_subresource_cursor(); - target.runtime_slot.clear_websocket_artifacts(); - previous - }; - if let Some(page) = previous { - let _ = page.close_async().await; - } - Some(()) - } - Self::NoLoadedBrowserContext => None, - } - } - - pub(super) async fn close_page_target_async(&mut self) -> Option { - match self { - Self::ActiveTarget { - browser_context, .. - } => { - let target_id = browser_context.active_target_id_owned()?; - let primary_session_id = browser_context.active_session_id_owned(); - let auxiliary_session_ids = - browser_context.remove_auxiliary_sessions_for_target(&target_id); - browser_context - .close_active_target_after_page_close_async() - .await; - Some(ClosedPageTarget { - target_id, - primary_session_id, - auxiliary_session_ids, - }) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - let mut target = browser_context.remove_background_target(target_id)?; - browser_context.forget_target_opener_references_for_target(target_id); - browser_context.forget_target_window_names_for_target(target_id); - browser_context.forget_target_popup_id_for_target(target_id); - let _ = browser_context.take_parked_target_aux_state(target_id); - let primary_session_id = target.detach_session(); - let auxiliary_session_ids = - browser_context.remove_auxiliary_sessions_for_target(target_id); - target.close_page_async().await; - Some(ClosedPageTarget { - target_id: target_id.clone(), - primary_session_id, - auxiliary_session_ids, - }) - } - Self::NoLoadedBrowserContext => None, - } - } - pub(super) fn runtime_slot_mut(&mut self) -> Option<&mut TargetRuntimeSlot> { match self { Self::ActiveTarget { @@ -1525,331 +1324,52 @@ impl<'a> TargetSessionOwnerMut<'a> { } } - pub(super) fn navigation_history_snapshot( - &mut self, - ) -> Option<(usize, Vec)> { - match self { + pub(super) fn effective_extra_headers_for_target_policy( + &self, + mut headers: Vec<(String, String)>, + ) -> Vec<(String, String)> { + let locale_override = match self { Self::ActiveTarget { browser_context, .. - } => { - let target_url = browser_context.target_url().to_owned(); - let page_snapshot = browser_context - .active_target - .runtime_slot - .loaded_page() - .map(|page| (target_url, page.document_title())); - Some( - browser_context - .active_target - .owner_state - .navigation_history_snapshot(page_snapshot), - ) - } + } => browser_context.effective_active_locale_override(), Self::BackgroundTarget { browser_context, target_id, .. - } => { - let page_snapshot = - browser_context - .background_target(target_id) - .and_then(|target| { - target - .runtime_slot() - .loaded_page() - .map(|page| (target.target_url().to_owned(), page.document_title())) - }); - Some( - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.navigation_history_snapshot(page_snapshot) - }), - ) - } - Self::NoLoadedBrowserContext => None, - } - } - - pub(super) fn apply_renderer_document_title(&mut self, title: String) -> Option { - match self { - Self::ActiveTarget { - browser_context, .. - } => Some( + } => browser_context.locale_override.as_deref().or_else(|| { browser_context - .active_target - .owner_state - .commit_document_title(title), - ), - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => Some( - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.commit_document_title(title) - }), - ), + .parked_page_session_state(target_id) + .and_then(|state| state.locale_override.as_deref()) + .or(browser_context.default_locale_override.as_deref()) + }), Self::NoLoadedBrowserContext => None, - } - } - - pub(super) fn navigation_history_entry_url(&mut self, entry_id: i32) -> Option { - match self { + }; + headers = match self { Self::ActiveTarget { browser_context, .. - } => browser_context.navigation_history_entry_url(entry_id), - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - let page_snapshot = - browser_context - .background_target(target_id) - .and_then(|target| { - target - .runtime_slot() - .loaded_page() - .map(|page| (target.target_url().to_owned(), page.document_title())) - }); - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.navigation_history_entry_url(page_snapshot, entry_id) - }) } - Self::NoLoadedBrowserContext => None, - } + | Self::BackgroundTarget { + browser_context, .. + } => browser_context.merged_extra_headers_for_target_policy(&headers), + Self::NoLoadedBrowserContext => headers, + }; + apply_locale_header(&mut headers, locale_override); + headers } - pub(super) fn reset_navigation_history(&mut self) -> Option { + pub(super) fn prepare_navigation_request( + &mut self, + requested_url: &Url, + referrer: Option<&str>, + is_data_url: bool, + fallback_browser_identity: &moli_browser_profile::BrowserIdentityProfile, + network_request_id_allocator: &mut ConnectionNetworkRequestIdAllocator, + ) -> Option { match self { Self::ActiveTarget { - browser_context, .. - } => { - let target_url = browser_context.target_url().to_owned(); - let page_snapshot = browser_context - .active_target - .runtime_slot - .loaded_page() - .map(|page| (target_url, page.document_title())); - Some( - browser_context - .active_target - .owner_state - .reset_navigation_history(page_snapshot), - ) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - let page_snapshot = - browser_context - .background_target(target_id) - .and_then(|target| { - target - .runtime_slot() - .loaded_page() - .map(|page| (target.target_url().to_owned(), page.document_title())) - }); - Some( - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.reset_navigation_history(page_snapshot) - }), - ) - } - Self::NoLoadedBrowserContext => None, - } - } - - pub(super) fn can_reset_navigation_history(&mut self) -> Option { - match self { - Self::ActiveTarget { - browser_context, .. - } => { - let target_url = browser_context.target_url().to_owned(); - let page_snapshot = browser_context - .active_target - .runtime_slot - .loaded_page() - .map(|page| (target_url, page.document_title())); - Some( - browser_context - .active_target - .owner_state - .can_reset_navigation_history(page_snapshot), - ) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - let page_snapshot = - browser_context - .background_target(target_id) - .and_then(|target| { - target - .runtime_slot() - .loaded_page() - .map(|page| (target.target_url().to_owned(), page.document_title())) - }); - Some( - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.can_reset_navigation_history(page_snapshot) - }), - ) - } - Self::NoLoadedBrowserContext => None, - } - } - - pub(super) fn mark_next_navigation_history_replace_current(&mut self) -> Option<()> { - match self { - Self::ActiveTarget { - browser_context, .. - } => { - browser_context.mark_next_navigation_history_replace_current(); - Some(()) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.mark_next_navigation_history_replace_current(); - }); - Some(()) - } - Self::NoLoadedBrowserContext => None, - } - } - - pub(super) fn mark_next_navigation_history_traverse_to_entry( - &mut self, - entry_id: i32, - ) -> Option<()> { - match self { - Self::ActiveTarget { - browser_context, .. - } => { - browser_context.mark_next_navigation_history_traverse_to_entry(entry_id); - Some(()) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.mark_next_navigation_history_traverse_to_entry(entry_id); - }); - Some(()) - } - Self::NoLoadedBrowserContext => None, - } - } - - pub(super) fn record_same_document_navigation( - &mut self, - url: &Url, - history_update: moli_core::page::SameDocumentHistoryUpdate, - ) -> Option { - let next_url = url.to_string(); - let security_origin = url.origin().ascii_serialization(); - match self { - Self::ActiveTarget { - browser_context, .. - } => { - let frame_id = browser_context.active_target_id_owned()?; - browser_context - .record_same_document_navigation_history(next_url.clone(), history_update); - browser_context.set_target_url(next_url); - browser_context.set_target_security_origin(security_origin); - Some(frame_id) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - let frame_id = target_id.clone(); - let page_snapshot = - browser_context - .background_target(target_id) - .and_then(|target| { - target - .runtime_slot() - .loaded_page() - .map(|page| (target.target_url().to_owned(), page.document_title())) - }); - let title = page_snapshot - .as_ref() - .map(|(_, title)| title.clone()) - .unwrap_or_default(); - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.record_same_document_navigation_history( - page_snapshot, - next_url.clone(), - title, - history_update, - ); - }); - let target = browser_context.background_target_mut(target_id)?; - target.set_target_url(next_url); - target.set_target_security_origin(security_origin); - Some(frame_id) - } - Self::NoLoadedBrowserContext => None, - } - } - - pub(super) fn effective_extra_headers_for_target_policy( - &self, - mut headers: Vec<(String, String)>, - ) -> Vec<(String, String)> { - let locale_override = match self { - Self::ActiveTarget { - browser_context, .. - } => browser_context.effective_active_locale_override(), - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => browser_context.locale_override.as_deref().or_else(|| { - browser_context - .parked_page_session_state(target_id) - .and_then(|state| state.locale_override.as_deref()) - .or(browser_context.default_locale_override.as_deref()) - }), - Self::NoLoadedBrowserContext => None, - }; - headers = match self { - Self::ActiveTarget { - browser_context, .. - } - | Self::BackgroundTarget { - browser_context, .. - } => browser_context.merged_extra_headers_for_target_policy(&headers), - Self::NoLoadedBrowserContext => headers, - }; - apply_locale_header(&mut headers, locale_override); - headers - } - - pub(super) fn prepare_navigation_request( - &mut self, - requested_url: &Url, - referrer: Option<&str>, - is_data_url: bool, - fallback_browser_identity: &moli_browser_profile::BrowserIdentityProfile, - network_request_id_allocator: &mut ConnectionNetworkRequestIdAllocator, - ) -> Option { - match self { - Self::ActiveTarget { - browser_context, - session_id: _, - .. + browser_context, + session_id: _, + .. } => { let frame_id = browser_context .active_target_id_owned() @@ -1955,7 +1475,9 @@ impl<'a> TargetSessionOwnerMut<'a> { .. } => { let target = browser_context.background_target(target_id)?; - let target_session_id = target.session_id().map(str::to_owned); + let target_session_id = browser_context + .primary_session_id_for_target(target_id) + .map(str::to_owned); let inherited_security_origin = target.target_identity().security_origin().to_owned(); let inherited_secure_context_type = @@ -2091,12 +1613,15 @@ impl<'a> TargetSessionOwnerMut<'a> { is_auxiliary_target_session, .. } => { - let devtools_session_state = target_devtools_session_state( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, - *is_auxiliary_target_session, - session_id.as_deref(), - ); + let devtools_session_state = if *is_auxiliary_target_session { + session_id.as_deref().and_then(|session_id| { + browser_context + .auxiliary_devtools_session_states() + .get(session_id) + }) + } else { + Some(browser_context.devtools_session_state()) + }; Some(TargetLoadedNavigationCommitState { browser_context_id: browser_context.id.clone(), runtime_frontend_enabled: devtools_session_state @@ -2108,12 +1633,12 @@ impl<'a> TargetSessionOwnerMut<'a> { ), runtime_inspector_session_restore_snapshots: runtime_inspector_session_restore_snapshots_for_renderer( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, + browser_context.devtools_session_state(), + browser_context.auxiliary_devtools_session_states(), ), stored_runtime_bindings: runtime_bindings_for_renderer( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, + browser_context.devtools_session_state(), + browser_context.auxiliary_devtools_session_states(), ), session_runtime_bindings: devtools_session_state .map(|state| state.runtime_bindings.clone()) @@ -2139,12 +1664,15 @@ impl<'a> TargetSessionOwnerMut<'a> { .parked_page_session_state(target_id) .cloned() .unwrap_or_default(); - let devtools_session_state = target_devtools_session_state( - &page_state.devtools_session_state, - &page_state.auxiliary_devtools_session_states, - *is_auxiliary_target_session, - session_id.as_deref(), - ); + let (primary_session_state, auxiliary_session_states) = + browser_context.devtools_session_states_for_target(target_id)?; + let devtools_session_state = if *is_auxiliary_target_session { + session_id + .as_deref() + .and_then(|session_id| auxiliary_session_states.get(session_id)) + } else { + Some(primary_session_state) + }; let isolated_worlds = browser_context .parked_target_owner_state(target_id) .map(|owner_state| owner_state.isolated_worlds.clone()) @@ -2162,12 +1690,12 @@ impl<'a> TargetSessionOwnerMut<'a> { ), runtime_inspector_session_restore_snapshots: runtime_inspector_session_restore_snapshots_for_renderer( - &page_state.devtools_session_state, - &page_state.auxiliary_devtools_session_states, + primary_session_state, + auxiliary_session_states, ), stored_runtime_bindings: runtime_bindings_for_renderer( - &page_state.devtools_session_state, - &page_state.auxiliary_devtools_session_states, + primary_session_state, + auxiliary_session_states, ), session_runtime_bindings: devtools_session_state .map(|state| state.runtime_bindings.clone()) @@ -2182,6 +1710,7 @@ impl<'a> TargetSessionOwnerMut<'a> { } } + #[cfg(test)] pub(super) fn commit_loaded_navigation_target_identity( &mut self, main_document_commit: &RendererMainDocumentCommit, @@ -2197,9 +1726,6 @@ impl<'a> TargetSessionOwnerMut<'a> { browser_context.set_target_url(next_url); browser_context.set_target_security_origin(security_origin); browser_context.set_target_secure_context_type(secure_context_type); - if let Some(target_id) = browser_context.active_target_id_owned() { - browser_context.mark_target_initial_empty_document_exited(&target_id); - } Some(()) } Self::BackgroundTarget { @@ -2211,180 +1737,11 @@ impl<'a> TargetSessionOwnerMut<'a> { target.set_target_url(next_url); target.set_target_security_origin(security_origin); target.set_target_secure_context_type(secure_context_type); - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.mark_initial_empty_document_exited(); - }); Some(()) } Self::NoLoadedBrowserContext => None, } } - - pub(super) fn clear_pending_navigation_history_update(&mut self) -> Option<()> { - match self { - Self::ActiveTarget { - browser_context, .. - } => { - browser_context.clear_pending_navigation_history_update(); - Some(()) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { - owner_state.clear_pending_navigation_history_update() - }); - Some(()) - } - Self::NoLoadedBrowserContext => None, - } - } - - pub(super) async fn commit_loaded_navigation_page_async( - &mut self, - mut page: Page, - renderer_attachment_commit: LoadedNavigationRendererAttachmentCommit, - 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_page_async( - page, - renderer_attachment_commit, - history_url, - ) - .await, - ) - } - Self::BackgroundTarget { - browser_context, - target_id, - .. - } => { - let committed_document_post_response_continuation = - page.take_committed_document_post_response_continuation(); - let previous_page_owner = browser_context - .background_target(target_id)? - .runtime_slot - .page_attachment_id() - .map(|page_attachment_id| { - TargetPageResidenceIdentity::new( - browser_context.id.clone(), - Some(target_id.clone()), - page_attachment_id, - ) - }); - let primary_session_id = browser_context - .background_target(target_id)? - .session_id() - .map(str::to_owned); - let previous_title = browser_context - .parked_target_owner_state(target_id) - .and_then(|owner_state| { - owner_state.committed_document_title().map(str::to_owned) - }) - .or_else(|| { - browser_context - .background_target(target_id)? - .runtime_slot() - .loaded_page() - .map(Page::document_title) - }); - let committed_document_title = page.document_title(); - let page_snapshot = (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(); - if let Some(previous_title) = previous_title { - owner_state.refresh_current_navigation_history_title(previous_title); - } - owner_state.record_loaded_page_navigation_history(page_snapshot); - owner_state.clear_committed_document_navigation_state(); - owner_state.commit_document_title(committed_document_title); - }); - browser_context.mutate_parked_page_session_state(target_id, |state| { - clear_parked_page_loaded_document_session_state(state); - }); - let (previous_attachment, new_attachment_id) = { - let target = browser_context.background_target_mut(target_id)?; - let previous_attachment = match renderer_attachment_commit { - LoadedNavigationRendererAttachmentCommit::Prepare( - renderer_agent_candidate, - ) => match target - .runtime_slot - .commit_loaded_navigation_renderer_attachment( - &mut page, - renderer_agent_candidate, - ) { - Ok(previous) => previous, - Err(error) => return Some(Err(error.into())), - }, - LoadedNavigationRendererAttachmentCommit::AlreadyCommitted(transaction) => { - if let Err(error) = target - .runtime_slot - .bind_page_to_committed_renderer_agent_candidate( - &mut page, - &transaction, - ) - { - return Some(Err(error.into())); - } - transaction.previous() - } - }; - let new_attachment_id = page - .renderer_agent_attachment_id() - .expect("committed navigation Page must have a renderer attachment"); - (previous_attachment, new_attachment_id) - }; - 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 previous = { - let target = browser_context.background_target_mut(target_id)?; - let previous = target.replace_loaded_page(Some(page)); - target.runtime_slot.reset_subresource_cursor(); - target.runtime_slot.clear_websocket_artifacts(); - previous - }; - let replaced_page_owner = previous.as_ref().and(previous_page_owner); - if let Some(page) = previous { - let _ = page.close_async().await; - } - Some(Ok(LoadedNavigationPageCommit { - replaced_page_owner, - committed_document_post_response_continuation, - })) - } - Self::NoLoadedBrowserContext => None, - } - } } impl CdpConnection { @@ -2404,7 +1761,9 @@ impl CdpConnection { Some(TargetSessionOwnerRef::NoLoadedBrowserContext) | None => { TargetNavigationLoadInputs::no_loaded_browser_context( self.initial_storage_partition.page_storage_handles(), - self.engine.browser_context_owner_access(), + self.browser_host_state + .navigation_owner() + .active_browser_context_owner_access(), ) } Some(owner) => owner.navigation_load_inputs(), @@ -2423,14 +1782,13 @@ impl CdpConnection { referrer: Option<&str>, is_data_url: bool, ) -> Option { - let fallback_browser_identity = self - .global_browser_identity_override - .clone() - .unwrap_or_else(|| self.base_browser_identity.clone()); - let mut network_request_id_allocator = - std::mem::take(&mut self.network_request_id_allocator); - let result = self - .target_session_owner_mut(session_id) + let policy = self.browser_host_network_policy_snapshot(); + let fallback_browser_identity = policy + .global_browser_identity_override() + .cloned() + .unwrap_or_else(|| policy.base_browser_identity().clone()); + let mut network_request_id_allocator = self.browser_host_state.network_artifacts(); + self.target_session_owner_mut(session_id) .and_then(|mut owner| { owner.prepare_navigation_request( requested_url, @@ -2439,9 +1797,7 @@ impl CdpConnection { &fallback_browser_identity, &mut network_request_id_allocator, ) - }); - self.network_request_id_allocator = network_request_id_allocator; - result + }) } pub(crate) fn register_pending_fetch_navigation_request_for_session_owner( @@ -2461,28 +1817,6 @@ impl CdpConnection { .prepare_loaded_navigation_commit() } - pub(crate) fn commit_loaded_navigation_target_identity_for_session_owner( - &mut self, - session_id: Option<&str>, - main_document_commit: &RendererMainDocumentCommit, - target_url: &Url, - ) -> Option<()> { - self.target_session_owner_mut(session_id)? - .commit_loaded_navigation_target_identity(main_document_commit, target_url) - } - - pub(crate) async fn commit_loaded_navigation_page_for_session_owner_async( - &mut self, - session_id: Option<&str>, - page: Page, - renderer_attachment_commit: LoadedNavigationRendererAttachmentCommit, - history_url: &Url, - ) -> Option> { - self.target_session_owner_mut(session_id)? - .commit_loaded_navigation_page_async(page, renderer_attachment_commit, history_url) - .await - } - pub(crate) fn initial_document_page_owner_for_session( &self, session_id: Option<&str>, @@ -2494,307 +1828,11 @@ impl CdpConnection { }) } - pub(crate) async fn install_initial_loaded_page_for_page_owner_async( - &mut self, - owner: &InitialDocumentPageOwner, - page: Page, - page_creation_artifacts: RendererPageCreationArtifacts, - ) -> Result { - let Some(browser_context) = self.browser_context_by_id_mut(&owner.browser_context_id) - else { - let _ = page.close_async().await; - return Ok(InitialDocumentPageInstallResult::Stale); - }; - if !browser_context.can_install_current_initial_empty_document_page(&owner.target_id) { - let _ = page.close_async().await; - return Ok(InitialDocumentPageInstallResult::Stale); - } - if browser_context.active_target_id() == Some(owner.target_id.as_str()) { - let loader_id = browser_context - .active_target - .owner_state - .initial_empty_document_loader_id_if_current() - .map(str::to_owned); - browser_context.mark_target_initial_empty_document_materialized(&owner.target_id); - let previous = browser_context.replace_loaded_page(Some(page)); - if let Some(loader_id) = loader_id { - let _ = browser_context - .active_target - .runtime_slot - .page_slot_mut() - .bind_renderer_document_lifecycle( - page_creation_artifacts, - None, - owner.target_id.clone(), - loader_id, - ); - } - browser_context - .assert_target_materialized_initial_empty_document_has_page(&owner.target_id)?; - if let Some(page) = previous { - let _ = page.close_async().await; - } - return Ok(InitialDocumentPageInstallResult::Installed); - } - - if browser_context - .background_target(&owner.target_id) - .is_none() - { - return Err("TargetNotLoaded".to_owned()); - } - let loader_id = browser_context - .parked_target_owner_state(&owner.target_id) - .and_then(|owner_state| owner_state.initial_empty_document_loader_id_if_current()) - .map(str::to_owned); - browser_context.mutate_parked_target_owner_state(&owner.target_id, |owner_state| { - owner_state.mark_initial_empty_document_materialized(); - owner_state.clear_committed_document_navigation_state(); - }); - browser_context.mutate_parked_page_session_state(&owner.target_id, |state| { - clear_parked_page_loaded_document_session_state(state); - }); - let previous = { - let target = browser_context - .background_target_mut(&owner.target_id) - .ok_or_else(|| "TargetNotLoaded".to_owned())?; - let previous = target.replace_loaded_page(Some(page)); - target.runtime_slot.reset_subresource_cursor(); - target.runtime_slot.clear_websocket_artifacts(); - if let Some(loader_id) = loader_id { - let _ = target - .runtime_slot - .page_slot_mut() - .bind_renderer_document_lifecycle( - page_creation_artifacts, - None, - owner.target_id.clone(), - loader_id, - ); - } - previous - }; - browser_context - .assert_target_materialized_initial_empty_document_has_page(&owner.target_id)?; - if let Some(page) = previous { - let _ = page.close_async().await; - } - Ok(InitialDocumentPageInstallResult::Installed) - } - - pub(crate) fn clear_pending_navigation_history_update_for_session_owner( - &mut self, - session_id: Option<&str>, - ) -> Option<()> { - self.target_session_owner_mut(session_id)? - .clear_pending_navigation_history_update() - } - - pub(crate) async fn mark_target_crashed_for_session_owner_async( - &mut self, - session_id: Option<&str>, - ) -> Option<()> { - let target_id = self - .target_owner_identity_for_session(session_id) - .and_then(|(_, target_id)| target_id); - let result = self - .target_session_owner_mut(session_id)? - .mark_target_crashed_async() - .await; - if result.is_some() - && let Some(target_id) = target_id.as_deref() - { - self.forget_retained_navigation_engine_for_target(target_id); - } - result - } - - pub(crate) async fn discard_loaded_page_after_failed_navigation_for_session_owner_async( - &mut self, - session_id: Option<&str>, - final_url: &Url, - ) -> Option<()> { - let target_id = self - .target_owner_identity_for_session(session_id) - .and_then(|(_, target_id)| target_id); - let result = self - .target_session_owner_mut(session_id)? - .discard_loaded_page_after_failed_navigation_async(final_url) - .await; - if result.is_some() - && let Some(target_id) = target_id.as_deref() - { - self.forget_retained_navigation_engine_for_target(target_id); - } - result - } - - pub(crate) async fn close_page_target_for_session_owner_async( - &mut self, - session_id: Option<&str>, - ) -> Option { - let target_id = self - .target_owner_identity_for_session(session_id) - .and_then(|(_, target_id)| target_id); - let mut collected_network_data_artifacts = self - .target_session_owner_ref(session_id)? - .runtime_slot()? - .collected_network_data_artifacts(); - if let Some(target_id) = target_id.as_deref() - && let Some(browser_context) = self.browser_context.as_mut() - { - collected_network_data_artifacts.extend( - browser_context - .take_parked_network_artifacts(target_id) - .collected_network_data_artifacts(), - ); - } - let closed = self - .target_session_owner_mut(session_id)? - .close_page_target_async() - .await?; - self.record_collected_network_data_artifacts(collected_network_data_artifacts); - self.forget_retained_navigation_engine_for_target(&closed.target_id); - Some(closed) - } - - pub(crate) async fn close_background_page_target_for_target_close_async( - &mut self, - target_id: &str, - out: &mut Vec, - reason: &'static str, - ) -> Option { - let ( - mut target, - mut parked_aux_state, - primary_session_id, - auxiliary_session_ids, - collected_network_data_artifacts, - ) = { - let browser_context = self.browser_context.as_mut()?; - let mut target = browser_context.remove_background_target(target_id)?; - let mut collected_network_data_artifacts = - target.runtime_slot().collected_network_data_artifacts(); - collected_network_data_artifacts.extend( - browser_context - .take_parked_network_artifacts(target_id) - .collected_network_data_artifacts(), - ); - browser_context.forget_target_opener_references_for_target(target_id); - browser_context.forget_target_window_names_for_target(target_id); - browser_context.forget_target_popup_id_for_target(target_id); - let parked_aux_state = browser_context.take_parked_target_aux_state(target_id); - let primary_session_id = target.detach_session(); - let auxiliary_session_ids = - browser_context.remove_auxiliary_sessions_for_target(target_id); - ( - target, - parked_aux_state, - primary_session_id, - auxiliary_session_ids, - collected_network_data_artifacts, - ) - }; - target.close_page_async().await; - self.record_collected_network_data_artifacts(collected_network_data_artifacts); - self.forget_retained_navigation_engine_for_target(target_id); - - let mut affected_sessions = Vec::new(); - if let Some(session_id) = primary_session_id.as_deref() { - affected_sessions.push(session_id); - } - for session_id in &auxiliary_session_ids { - affected_sessions.push(session_id.as_str()); - } - CdpConnection::fail_pending_inspector_awaits_from_page_session_state_for_sessions_background_events_into( - out, - &mut parked_aux_state.page_session_state, - primary_session_id.as_deref(), - &affected_sessions, - reason, - ); - - Some(ClosedPageTarget { - target_id: target_id.to_owned(), - primary_session_id, - auxiliary_session_ids, - }) - } - - pub(crate) async fn close_active_page_target_for_target_close_async( - &mut self, - out: &mut Vec, - ) -> Option { - let ( - target_id, - primary_session_id, - auxiliary_session_ids, - next_active_target_id, - collected_network_data_artifacts, - ) = { - let browser_context = self.browser_context.as_mut()?; - let target_id = browser_context.active_target_id_owned()?; - let primary_session_id = browser_context.active_session_id_owned(); - let auxiliary_session_ids = - browser_context.remove_auxiliary_sessions_for_target(&target_id); - let next_active_target_id = browser_context.last_promotable_background_target_id(); - let collected_network_data_artifacts = browser_context - .active_target - .runtime_slot - .collected_network_data_artifacts(); - browser_context.clear_active_target_session_scoped_state_without_loaded_page(); - browser_context - .reset_active_target_slot_to_empty_async() - .await; - ( - target_id, - primary_session_id, - auxiliary_session_ids, - next_active_target_id, - collected_network_data_artifacts, - ) - }; - self.record_collected_network_data_artifacts(collected_network_data_artifacts); - if let Some(next_active_target_id) = next_active_target_id.as_deref() { - self.handoff_navigation_engine_for_target_activation(next_active_target_id); - } - let promoted_target_id = if let Some(browser_context) = self.browser_context.as_mut() { - browser_context - .promote_last_background_target_to_active_async() - .await - .map(|target| target.target_id().to_owned()) - } else { - None - }; - self.refresh_active_browser_context_loader_async().await; - if let Some(promoted_target_id) = promoted_target_id { - self.notify_target_host_activated(&promoted_target_id); - out.extend( - self.page_screencast_session_ids_for_target(&promoted_target_id) - .into_iter() - .map(|session_id| { - BackgroundProtocolEvent::page_screencast_visibility_changed( - session_id.as_deref(), - true, - ) - }), - ); - } - - Some(ClosedPageTarget { - target_id, - primary_session_id, - auxiliary_session_ids, - }) - } - pub(crate) async fn rollback_incomplete_popup_target_without_event_async( &mut self, browser_context_id: Option<&str>, target_id: &str, ) { - self.rollback_top_level_target_tab_sessions_without_event(target_id); - let browser_context_id = browser_context_id.map(str::to_owned).or_else(|| { self.browser_contexts() .find(|browser_context| { @@ -2803,54 +1841,71 @@ impl CdpConnection { }) .map(|browser_context| browser_context.id.clone()) }); + if let Some(browser_context_id) = browser_context_id.as_deref() + && self + .browser_context_by_id(browser_context_id) + .is_some_and(|browser_context| browser_context.is_active_target(target_id)) + { + tracing::warn!( + browser_context_id, + target_id, + "incomplete popup rollback rejected an active Target" + ); + return; + } + + let rolled_back_target = if let Some(browser_context_id) = browser_context_id.as_deref() { + match self.rollback_staged_background_target_projection(browser_context_id, target_id) { + Ok(target) => Some(target), + Err(error) => { + tracing::warn!( + browser_context_id, + target_id, + error = %error, + "incomplete popup Target rollback projection was rejected" + ); + return; + } + } + } else { + None + }; + + self.rollback_top_level_target_tab_sessions_without_event(target_id); let mut page_session_ids = Vec::new(); if let Some(browser_context_id) = browser_context_id { - let is_active_target = self - .browser_context_by_id(&browser_context_id) - .is_some_and(|browser_context| browser_context.is_active_target(target_id)); - if is_active_target { - if let Some(browser_context) = self.browser_context_by_id_mut(&browser_context_id) { - if let Some((_target_id, session_id)) = browser_context.active_target_identity() - && let Some(session_id) = session_id - { + let close_background_target = { + let Some(browser_context) = self.browser_context_by_id_mut(&browser_context_id) + else { + return; + }; + browser_context.forget_target_opener_references_for_target(target_id); + browser_context.forget_target_window_names_for_target(target_id); + browser_context.forget_target_popup_id_for_target(target_id); + page_session_ids + .extend(browser_context.remove_auxiliary_sessions_for_target(target_id)); + rolled_back_target.map(|(target, renderer_page_owner)| { + let (primary_session_id, remaining_auxiliary_session_ids) = + browser_context.remove_top_level_target_attachment(target.target_handle()); + if let Some(session_id) = primary_session_id { page_session_ids.push(session_id); } - page_session_ids - .extend(browser_context.remove_auxiliary_sessions_for_target(target_id)); - browser_context - .reset_active_target_slot_to_empty_async() - .await; - } - } else { - let close_background_target = { - let Some(browser_context) = self.browser_context_by_id_mut(&browser_context_id) - else { - return; - }; - let target = browser_context.remove_background_target(target_id); - browser_context.forget_target_opener_references_for_target(target_id); - browser_context.forget_target_window_names_for_target(target_id); - browser_context.forget_target_popup_id_for_target(target_id); - page_session_ids - .extend(browser_context.remove_auxiliary_sessions_for_target(target_id)); - target.map(|mut target| { - if let Some(session_id) = target.detach_session() { - page_session_ids.push(session_id); - } - target - }) - }; - if let Some(mut target) = close_background_target { - target.close_page_async().await; + page_session_ids.extend(remaining_auxiliary_session_ids); + (target, renderer_page_owner) + }) + }; + if let Some((mut target, renderer_page_owner)) = close_background_target { + if let Some(owner) = renderer_page_owner { + let _ = owner.close_async().await; } + target.close_page_async().await; } } for session_id in page_session_ids { self.rollback_attached_session_without_event(&session_id); } - self.forget_retained_navigation_engine_for_target(target_id); } pub(crate) fn target_session_owner_aggregate_fetch_config( @@ -2973,6 +2028,60 @@ impl CdpConnection { .mutate_target_owner_state(|owner_state| owner_state.map(f)) } + pub(crate) fn apply_renderer_document_title_for_session_owner( + &mut self, + session_id: Option<&str>, + change: &moli_core::RendererDocumentTitleChanged, + ) -> Option { + let page_owner = self + .target_root_document_protocol_attachment_identity_for_session( + session_id, + change.source_document, + )? + .attachment() + .page_owner() + .clone(); + let core_changed = match self + .browser_host_state + .update_current_document_title(&page_owner, change.title.clone()) + { + Ok(changed) => changed, + Err(error) => { + tracing::error!( + %error, + browser_context_id = page_owner.browser_context_id(), + target_id = page_owner.target_id(), + "renderer Document title could not enter the Browser fact journal" + ); + return None; + } + }; + let physical_changed = self + .with_target_owner_state_for_session_mut(session_id, |owner_state| { + owner_state.commit_document_title(change.title.clone()) + })?; + if core_changed == Some(true) { + match self.take_document_title_target_metadata_changed_fact(&page_owner, &change.title) + { + Ok(projection) => { + return Some(self.project_target_metadata_changed_fact(projection)); + } + Err(error) => tracing::error!( + %error, + browser_context_id = page_owner.browser_context_id(), + target_id = page_owner.target_id(), + "refusing to project renderer title without its exact Browser fact" + ), + } + } + let target_id = page_owner.target_id()?; + Some(if physical_changed { + self.frontend_attachment_info_changed_event_plan(target_id) + } else { + TargetEventPlan::default() + }) + } + pub(crate) fn with_target_devtools_session_state_for_session_mut( &mut self, session_id: Option<&str>, @@ -2989,97 +2098,59 @@ impl CdpConnection { self.target_session_owner_ref(session_id)?.owner_identity() } - /// Captures the exact target-local Page residence currently addressed by - /// `session_id`. + /// Freezes the concrete browser Target addressed by one frontend route. /// - /// The connection actor is mutably borrowed while a renderer output is - /// taken, so callers can capture this identity immediately before starting - /// that Page command and attach it to the returned prepared payload. A - /// target without an installed Page has no current Page residence. - pub(crate) fn target_page_residence_identity_for_session( + /// This key names the target owner, not its current Page. It is suitable + /// for binding a reserved renderer Page before commit, when the old Page + /// residence is still current and the successor does not exist yet. + pub(crate) fn target_page_owner_key_for_session( &self, session_id: Option<&str>, - ) -> Option { + ) -> Option { let (browser_context_id, routed_target_id) = self.target_owner_identity_for_session(session_id)?; - // `None` in a `CdpSessionRoute::ActiveTarget` is a routing shorthand: - // it means "whichever target is currently active". A Page residence - // identity must never retain that mutable shorthand. Freeze the - // concrete target now, otherwise a later `browsingContext.create` - // can make output from the old Page resolve through the new active - // target while preserving the same browser-context id and residence. let target_id = routed_target_id.or_else(|| { self.browser_context_by_id(&browser_context_id) .and_then(|browser_context| browser_context.active_target_id()) .map(str::to_owned) - }); - let page_attachment_id = self - .runtime_session_owner_slot(session_id) - .ok()? - .page_attachment_id()?; - Some(TargetPageResidenceIdentity::new( + })?; + Some(moli_core::browser_host::BrowserPageOwnerKey::new( browser_context_id, target_id, - page_attachment_id, )) } - /// Captures the reserved residence of the Page currently being built for + /// Captures the exact target-local Page residence currently addressed by /// `session_id`. /// - /// Renderer construction can open and publish into its output stream - /// before protocol commits that Page into the target slot. The reservation - /// therefore owns an explicit attachment id before renderer work starts; - /// callers never predict that identity from mutable current-Page state. - pub(crate) fn pending_target_page_residence_identity_for_session( + /// The connection actor is mutably borrowed while a renderer output is + /// taken, so callers can capture this identity immediately before starting + /// that Page command and attach it to the returned prepared payload. + pub(crate) fn target_page_residence_identity_for_session( &self, session_id: Option<&str>, ) -> Option { - let (browser_context_id, routed_target_id) = - self.target_owner_identity_for_session(session_id)?; - let target_id = routed_target_id.or_else(|| { - self.browser_context_by_id(&browser_context_id) - .and_then(|browser_context| browser_context.active_target_id()) - .map(str::to_owned) - }); - let page_attachment_id = self - .runtime_session_owner_slot(session_id) - .ok()? - .pending_page_attachment_id()?; - Some(TargetPageResidenceIdentity::new( - browser_context_id, - target_id, - page_attachment_id, - )) + let owner = self.target_page_owner_key_for_session(session_id)?; + self.browser_host_state + .navigation_owner() + .capture_page_residence(owner.browser_context_id(), owner.target_id()) } - /// Binds a renderer Page reservation to its explicit future target Page - /// attachment and returns that exact residence identity. - pub(crate) fn reserve_target_page_residence_identity_for_session( - &mut self, + #[cfg(test)] + pub(crate) fn target_page_residence_handle_for_session( + &self, session_id: Option<&str>, - renderer_page: crate::conn::RendererPageResidenceIdentity, - ) -> Option { - let (browser_context_id, routed_target_id) = - self.target_owner_identity_for_session(session_id)?; - let target_id = routed_target_id.or_else(|| { - self.browser_context_by_id(&browser_context_id) - .and_then(|browser_context| browser_context.active_target_id()) - .map(str::to_owned) - }); - let page_attachment_id = self - .runtime_session_owner_slot_mut(session_id) - .ok()? - .reserve_renderer_page_attachment(renderer_page); - Some(TargetPageResidenceIdentity::new( - browser_context_id, - target_id, - page_attachment_id, - )) + ) -> Option { + Some( + self.runtime_session_owner_slot(session_id) + .ok()? + .page_residence_handle() + .clone(), + ) } /// Checks that deferred Page-owned work still addresses the same target - /// Page-slot residence from which it was captured. + /// and installed Page generation from which it was captured. pub(crate) fn target_page_residence_identity_is_current_for_session( &self, session_id: Option<&str>, @@ -3090,6 +2161,60 @@ impl CdpConnection { == Some(expected) } + /// Resolves one exact Page residence without consulting frontend/session + /// attachment state. + /// + /// The returned route is a migration adapter for existing target-owner + /// methods. Its selection depends only on authoritative browser context, + /// target, and Page generation state. A DevTools detach/reattach therefore + /// cannot redirect or cancel an already-published browser action. + pub(crate) fn target_page_owner_route_if_current( + &self, + expected: &TargetPageResidenceIdentity, + ) -> Option { + let owner = self + .browser_host_state + .navigation_owner() + .page_owner_key_if_current(expected)?; + self.target_page_owner_route_for_owner(&owner) + } + + /// Resolves the same live Target/Page slot while allowing its Document + /// generation to advance. + /// + /// Browser-level actions such as named auxiliary-target navigation address + /// the browsing context, not the Document that happened to be installed + /// when the action was accepted. The stable slot instance still rejects a + /// removed/recreated Target that reuses the same public id. + pub(crate) fn target_page_owner_route_if_same_slot( + &self, + expected: &TargetPageResidenceIdentity, + ) -> Option { + let owner = self + .browser_host_state + .navigation_owner() + .page_owner_key_for_same_slot(expected)?; + self.target_page_owner_route_for_owner(&owner) + } + + fn target_page_owner_route_for_owner( + &self, + owner: &moli_core::browser_host::BrowserPageOwnerKey, + ) -> Option { + let browser_context = self.browser_context_by_id(owner.browser_context_id())?; + if browser_context.active_target_id() == Some(owner.target_id()) { + return Some(CdpSessionRoute::ActiveTarget { + browser_context_id: owner.browser_context_id().to_owned(), + target_id: Some(owner.target_id().to_owned()), + }); + } + browser_context.background_target(owner.target_id())?; + Some(CdpSessionRoute::BackgroundTarget { + browser_context_id: owner.browser_context_id().to_owned(), + target_id: owner.target_id().to_owned(), + }) + } + /// Captures the lifetime of the concrete Page attachment currently /// addressed by `session_id`. /// @@ -3130,7 +2255,7 @@ impl CdpConnection { self.runtime_session_owner_slot(session_id) .ok()? .loaded_page() - .map(crate::conn::RendererPageResidenceIdentity::from_page) + .map(|page| crate::conn::RendererPageResidenceIdentity::from_page(&page)) } /// Resolves the exact protocol attachment named by one renderer inspector @@ -3168,7 +2293,7 @@ impl CdpConnection { Some(attachment) } - /// Checks both the target Page residence and the session that originally + /// Checks both the target Page generation and the session that originally /// captured an attachment-sensitive output. pub(crate) fn target_page_protocol_attachment_identity_is_current( &self, @@ -3205,9 +2330,7 @@ impl CdpConnection { if !self.target_page_protocol_attachment_identity_is_current(expected.attachment()) { return false; } - self.runtime_session_owner_slot(expected.session_id()) - .ok() - .and_then(|slot| slot.page_slot().renderer_document_lifecycle_binding()) + self.committed_renderer_document_binding_for_session_owner(expected.session_id()) .map(crate::conn::CommittedRendererDocumentBinding::renderer_document_identity) == Some(expected.root_document()) } @@ -3216,10 +2339,7 @@ impl CdpConnection { &self, session_id: Option<&str>, ) -> Option { - self.runtime_session_owner_slot(session_id) - .ok()? - .page_slot() - .renderer_document_lifecycle_binding() + self.committed_renderer_document_binding_for_session_owner(session_id) .map(crate::conn::CommittedRendererDocumentBinding::renderer_document_identity) } @@ -3239,8 +2359,7 @@ impl CdpConnection { browser_context.active_session_id_owned() } else { browser_context - .background_target(target_id)? - .session_id() + .primary_session_id_for_target(target_id) .map(str::to_owned) }; let session_id = primary_session_id @@ -3321,7 +2440,7 @@ impl CdpConnection { .target_session_owner_mut(session_id) .and_then(TargetSessionOwnerMut::into_runtime_slot_mut) .ok_or_else(|| "NoDocumentLoaded".to_owned())?; - if let Some(page) = slot.loaded_page_mut() { + if let Some(mut page) = slot.loaded_page_mut() { page.set_renderer_devtools_command_session_id(renderer_inspector_session_id); } Ok(slot) @@ -3365,8 +2484,8 @@ impl CdpConnection { browser_context.active_session_id_owned() } else { browser_context - .background_target(&target_id) - .and_then(|target| target.session_id().map(str::to_owned)) + .primary_session_id_for_target(&target_id) + .map(str::to_owned) }; let primary_event_session_id = primary_session_id.or_else(|| session_id.map(str::to_owned)); session_ids.push(primary_event_session_id.clone()); @@ -3395,7 +2514,7 @@ impl CdpConnection { /// event produced for `session_id`'s owner. /// /// The returned identities freeze both the capture-time session and the - /// Page-slot residence. A deferred output may later authorize these + /// installed Page generation. A deferred output may later authorize these /// identities, but must never call `page_event_session_ids_for_session_owner` /// again: doing so could route an old Page's historical event through a /// replacement Page or a newly active implicit attachment. @@ -3462,34 +2581,34 @@ impl CdpConnection { &self, session_id: Option<&str>, ) -> Option { - self.target_session_owner_ref(session_id)? - .initial_empty_document_url_if_current() + self.target_initial_empty_document_for_session_owner(session_id) + .filter(|state| state.is_on_initial_empty_document()) + .map(|state| state.initial_url().to_owned()) } pub(crate) fn runtime_session_owner_initial_empty_document_storage_key( &self, session_id: Option<&str>, ) -> Option { - self.target_session_owner_ref(session_id)? - .initial_empty_document_storage_key_if_current() + self.target_initial_empty_document_for_session_owner(session_id) + .filter(|state| state.is_on_initial_empty_document()) + .and_then(|state| state.storage_key().cloned()) } pub(crate) fn runtime_session_owner_record_is_on_initial_empty_document( &self, session_id: Option<&str>, ) -> Option { - self.target_session_owner_ref(session_id)? - .is_on_initial_empty_document() + self.target_initial_empty_document_for_session_owner(session_id) + .map(|state| state.is_on_initial_empty_document()) } pub(crate) fn runtime_session_owner_initial_empty_document_has_pending_cross_document_navigation( &self, session_id: Option<&str>, ) -> bool { - self.target_session_owner_ref(session_id) - .is_some_and(|owner| { - owner.initial_empty_document_has_pending_cross_document_navigation() - }) + self.target_initial_empty_document_for_session_owner(session_id) + .is_some_and(|state| state.pending_cross_document_navigation()) } pub(crate) fn target_session_owner_frame_tree_identity( @@ -3500,93 +2619,24 @@ impl CdpConnection { .frame_tree_identity() } - pub(crate) fn target_session_owner_frame_tree_loader_id( - &self, - session_id: Option<&str>, - ) -> Option { - self.target_session_owner_ref(session_id)? - .frame_tree_loader_id() - } - - pub(crate) fn target_session_owner_emulated_device_metrics( - &self, - session_id: Option<&str>, - ) -> Option { - self.target_session_owner_ref(session_id)? - .emulated_device_metrics() - } - - pub(crate) fn target_session_owner_navigation_history_snapshot( - &mut self, - session_id: Option<&str>, - ) -> Option<(usize, Vec)> { - self.target_session_owner_mut(session_id)? - .navigation_history_snapshot() - } - - pub(crate) fn apply_renderer_document_title_for_session_owner( - &mut self, - session_id: Option<&str>, - change: &moli_core::RendererDocumentTitleChanged, - ) -> Option { - self.target_root_document_protocol_attachment_identity_for_session( - session_id, - change.source_document, - )?; - self.target_session_owner_mut(session_id)? - .apply_renderer_document_title(change.title.clone()) - } - - pub(crate) fn target_session_owner_navigation_history_entry_url( - &mut self, - session_id: Option<&str>, - entry_id: i32, - ) -> Option { - self.target_session_owner_mut(session_id)? - .navigation_history_entry_url(entry_id) - } - - pub(crate) fn reset_navigation_history_for_session_owner( - &mut self, - session_id: Option<&str>, - ) -> Option { - self.target_session_owner_mut(session_id)? - .reset_navigation_history() - } - - pub(crate) fn can_reset_navigation_history_for_session_owner( - &mut self, - session_id: Option<&str>, - ) -> Option { - self.target_session_owner_mut(session_id)? - .can_reset_navigation_history() - } - - pub(crate) fn mark_next_navigation_history_replace_current_for_session_owner( - &mut self, - session_id: Option<&str>, - ) -> Option<()> { - self.target_session_owner_mut(session_id)? - .mark_next_navigation_history_replace_current() - } - - pub(crate) fn mark_next_navigation_history_traverse_to_entry_for_session_owner( - &mut self, + pub(crate) fn target_session_owner_frame_tree_loader_id( + &self, session_id: Option<&str>, - entry_id: i32, - ) -> Option<()> { - self.target_session_owner_mut(session_id)? - .mark_next_navigation_history_traverse_to_entry(entry_id) + ) -> Option { + self.committed_document_loader_id_for_session_owner(session_id) + .or_else(|| { + self.target_initial_empty_document_for_session_owner(session_id) + .filter(|state| state.is_on_initial_empty_document()) + .map(|state| state.loader_id()) + }) } - pub(crate) fn record_same_document_navigation_for_session_owner( - &mut self, + pub(crate) fn target_session_owner_emulated_device_metrics( + &self, session_id: Option<&str>, - url: &Url, - history_update: moli_core::page::SameDocumentHistoryUpdate, - ) -> Option { - self.target_session_owner_mut(session_id)? - .record_same_document_navigation(url, history_update) + ) -> Option { + self.target_session_owner_ref(session_id)? + .emulated_device_metrics() } pub(super) fn target_session_owner_mut( @@ -3680,12 +2730,21 @@ mod tests { moli_browser_profile::DEFAULT_ACCEPT_LANGUAGE, ) } + use crate::conn::state::TargetPageAbsenceReason; use crate::conn::{ - FetchInterceptionPattern, FetchRequestStage, PendingSubresourceFetchOwnerKind, - PendingSubresourceFetchRequest, ServiceWorkerTargetState, + FetchInterceptionPattern, FetchRequestStage, LoadedNavigationRendererAttachmentCommit, + PendingSubresourceFetchOwnerKind, PendingSubresourceFetchRequest, ServiceWorkerTargetState, }; use crate::testing::TestContext; - use moli_core::page::SubresourceResourceType; + use moli_core::{ + browser_host::{ + BrowserContextSelectionProjection, BrowserNavigationHistoryPageSnapshot, + BrowserNavigationOwner, BrowserPageOwnerKey, BrowserSelectedTargetEngineDisposition, + BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }, + page::SubresourceResourceType, + runtime::NavigationEngine, + }; use url::Url; #[test] @@ -3694,7 +2753,7 @@ mod tests { let mut browser_context = BrowserContext::new("BID-session-id".to_owned()); browser_context.set_active_target_id("TID-session-id"); browser_context.attach_active_session("SID-1".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); assert_eq!(conn.gen_session_id(), "SID-2"); assert_eq!( @@ -3707,10 +2766,10 @@ mod tests { fn pending_subresource_fetch(internal_id: u64) -> PendingSubresourceFetchRequest { PendingSubresourceFetchRequest { residence: crate::conn::PendingSubresourceFetchResidence::InstalledPage( - crate::conn::TargetPageResidenceIdentity::new_for_test( + crate::conn::TargetPageResidenceIdentity::new( "BID-target-session-owner".to_owned(), Some("TID-frame".to_owned()), - 1, + 0, ), ), owner_session_id: None, @@ -3786,10 +2845,6 @@ mod tests { let mut browser_context = BrowserContext::new("BID-owner-key".to_owned()); browser_context.set_active_target_id("TID-active".to_owned()); browser_context.attach_active_session("SID-active-primary".to_owned()); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); browser_context .background_targets .push(crate::conn::BackgroundTarget::with_url( @@ -3797,11 +2852,6 @@ mod tests { Some("SID-background-primary".to_owned()), "about:blank#background".to_owned(), )); - browser_context - .background_target_mut("TID-background") - .expect("background target") - .runtime_slot - .set_page_attachment_id_for_test(2); assert!( browser_context .assign_auxiliary_session_to_target("TID-active", "SID-active-aux".to_owned(),) @@ -3812,7 +2862,7 @@ mod tests { "SID-background-aux".to_owned(), ) ); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); assert_eq!( conn.target_renderer_runtime_inspector_session_id_for_session(None), @@ -4008,57 +3058,6 @@ mod tests { ); } - #[test] - fn target_session_owner_mut_snapshots_active_and_parked_navigation_history() { - let mut active = BrowserContext::new("BID-active".to_owned()); - active - .active_target - .owner_state - .record_loaded_page_navigation_history(( - "https://active.example/".to_owned(), - "active".to_owned(), - )); - { - let mut owner = TargetSessionOwnerMut::ActiveTarget { - browser_context: &mut active, - session_id: None, - is_auxiliary_target_session: false, - is_current_active_browser_context: true, - }; - let (current_index, entries) = owner - .navigation_history_snapshot() - .expect("active history should snapshot"); - assert_eq!(current_index, 0); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].url, "https://active.example/"); - } - - let mut parked = BrowserContext::new("BID-parked".to_owned()); - parked.mutate_parked_target_owner_state("TID-parked", |owner_state| { - owner_state.record_loaded_page_navigation_history(( - "https://parked.example/".to_owned(), - "parked".to_owned(), - )); - }); - { - let mut owner = TargetSessionOwnerMut::BackgroundTarget { - browser_context: &mut parked, - target_id: "TID-parked".to_owned(), - session_id: None, - is_auxiliary_target_session: false, - }; - let (current_index, entries) = owner - .navigation_history_snapshot() - .expect("parked history should snapshot"); - assert_eq!(current_index, 0); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].url, "https://parked.example/"); - } - - let mut no_loaded = TargetSessionOwnerMut::NoLoadedBrowserContext; - assert!(no_loaded.navigation_history_snapshot().is_none()); - } - #[test] fn target_session_owner_mut_prepares_active_navigation_request_preflight() { let mut active = BrowserContext::new("BID-active".to_owned()); @@ -4175,6 +3174,7 @@ mod tests { Some("SID-parked".to_owned()), "about:blank".to_owned(), )); + parked.adopt_background_target_fixture_attachments(); parked.mutate_parked_page_session_state("TID-parked", |state| { state.network_enabled = true; state @@ -4283,6 +3283,7 @@ mod tests { Some("SID-parked".to_owned()), "about:blank".to_owned(), )); + parked.adopt_background_target_fixture_attachments(); parked.mutate_parked_page_session_state("TID-parked", |state| { state.network_enabled = true; }); @@ -4371,14 +3372,14 @@ mod tests { }, )); }); - parked.mutate_parked_page_session_state("TID-parked", |state| { - state - .devtools_session_state - .upsert_runtime_binding_definition( - "fromParkedBinding".to_owned(), - Some("utility".to_owned()), - ); - }); + parked.adopt_background_target_fixture_attachments(); + parked + .primary_devtools_session_state_for_target_mut("TID-parked") + .expect("background Target frontend state") + .upsert_runtime_binding_definition( + "fromParkedBinding".to_owned(), + Some("utility".to_owned()), + ); let owner = TargetSessionOwnerRef::BackgroundTarget { browser_context: &parked, @@ -4448,6 +3449,71 @@ mod tests { assert_eq!(inputs.fetch_subresource_interception, (true, None)); } + #[test] + fn background_navigation_survives_missing_frontend_projection() { + let mut parked = BrowserContext::new("BID-parked".to_owned()); + parked + .background_targets + .push(crate::conn::BackgroundTarget::with_url( + "TID-parked".to_owned(), + Some("SID-parked".to_owned()), + "https://parked.example/start".to_owned(), + )); + parked.adopt_background_target_fixture_attachments(); + let removed = parked.take_top_level_target_attachment_for_target("TID-parked"); + assert_eq!(removed.0.as_deref(), Some("SID-parked")); + + let owner = TargetSessionOwnerRef::BackgroundTarget { + browser_context: &parked, + target_id: "TID-parked".to_owned(), + session_id: Some("SID-parked".to_owned()), + is_auxiliary_target_session: false, + }; + let inputs = owner.navigation_load_inputs(); + + assert_eq!(inputs.browser_context_id.as_deref(), Some("BID-parked")); + assert_eq!(inputs.root_frame_id.as_deref(), Some("TID-parked")); + assert!(inputs.runtime_bindings.is_empty()); + assert!( + inputs + .runtime_inspector_session_restore_snapshots + .is_empty() + ); + assert!(!inputs.bypass_content_security_policy); + } + + #[test] + fn missing_auxiliary_frontend_route_does_not_drop_parked_policy_state() { + let mut parked = BrowserContext::new("BID-parked".to_owned()); + parked + .background_targets + .push(crate::conn::BackgroundTarget::with_url( + "TID-parked".to_owned(), + Some("SID-primary".to_owned()), + "about:blank".to_owned(), + )); + parked.adopt_background_target_fixture_attachments(); + parked.mutate_parked_page_session_state("TID-parked", |state| { + state.network_enabled = true; + }); + + let owner = TargetSessionOwnerMut::BackgroundTarget { + browser_context: &mut parked, + target_id: "TID-parked".to_owned(), + session_id: Some("SID-missing-aux".to_owned()), + is_auxiliary_target_session: true, + }; + let resolved = owner.mutate_session_state(|mut state| state.network_policy_mut().is_some()); + + assert!(!resolved); + assert!( + parked + .parked_page_session_state("TID-parked") + .is_some_and(|state| state.network_enabled), + "a stale frontend route must not remove the physical parked policy payload" + ); + } + #[test] fn target_session_owner_mut_prepares_background_navigation_commit_state() { let mut parked = BrowserContext::new("BID-parked".to_owned()); @@ -4458,15 +3524,15 @@ mod tests { Some("SID-parked".to_owned()), "about:blank".to_owned(), )); + parked.adopt_background_target_fixture_attachments(); + { + let state = parked + .primary_devtools_session_state_for_target_mut("TID-parked") + .expect("background Target frontend state"); + state.page_session_state.page_lifecycle_events = true; + state.runtime_session_state.runtime_frontend_enabled = true; + } parked.mutate_parked_page_session_state("TID-parked", |state| { - state - .devtools_session_state - .page_session_state - .page_lifecycle_events = true; - state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled = true; state.fetch_config.configure( Some("SID-parked".to_owned()), false, @@ -4543,52 +3609,6 @@ mod tests { assert_eq!(commit_state.fetch_subresource_config, (true, None)); } - #[test] - fn target_session_owner_mut_clears_background_navigation_history_update() { - let mut parked = BrowserContext::new("BID-parked".to_owned()); - parked - .background_targets - .push(crate::conn::BackgroundTarget::with_url( - "TID-parked".to_owned(), - Some("SID-parked".to_owned()), - "about:blank".to_owned(), - )); - parked.mutate_parked_target_owner_state("TID-parked", |owner_state| { - owner_state.record_loaded_page_navigation_history(( - "https://old.example/".to_owned(), - "old".to_owned(), - )); - owner_state.mark_next_navigation_history_replace_current(); - }); - { - let mut owner = TargetSessionOwnerMut::BackgroundTarget { - browser_context: &mut parked, - target_id: "TID-parked".to_owned(), - session_id: None, - is_auxiliary_target_session: false, - }; - owner - .clear_pending_navigation_history_update() - .expect("background history update should clear"); - } - parked.mutate_parked_target_owner_state("TID-parked", |owner_state| { - owner_state.record_loaded_page_navigation_history(( - "https://new.example/".to_owned(), - "new".to_owned(), - )); - }); - let (_, entries) = parked.mutate_parked_target_owner_state("TID-parked", |owner_state| { - owner_state.navigation_history_snapshot(None) - }); - assert_eq!( - entries - .iter() - .map(|entry| entry.url.as_str()) - .collect::>(), - vec!["https://old.example/", "https://new.example/"] - ); - } - #[tokio::test] async fn target_session_owner_mut_commits_loaded_page_to_background_owner() { let mut ctx = TestContext::new(); @@ -4597,7 +3617,6 @@ mod tests { .load_page_via_runtime_async("data:text/html,background commit") .await .expect("page should load"); - let page_url = page.final_url().clone(); let mut parked = BrowserContext::new("BID-parked".to_owned()); parked .background_targets @@ -4611,7 +3630,59 @@ mod tests { .expect("background target") .page_attachment_id(); - { + let owner_key = BrowserPageOwnerKey::new("BID-parked", "TID-parked"); + let page_residence = parked + .background_target("TID-parked") + .expect("background target") + .runtime_slot() + .page_residence_handle() + .clone(); + let final_url = page.final_url().clone(); + let history_page = BrowserNavigationHistoryPageSnapshot::new( + page.final_url().to_string(), + page.document_title(), + ); + let main_document_commit = RendererMainDocumentCommit { + frame_id: "TID-parked".to_owned(), + loader_id: "LOADER-parked".to_owned(), + url: final_url.to_string(), + unreachable_url: None, + security_origin: final_url.origin().ascii_serialization(), + secure_context_type: "InsecureScheme".to_owned(), + timestamp: 0.0, + }; + let mut browser_owner = BrowserNavigationOwner::new(NavigationEngine::new()); + let target_handle = parked + .background_target("TID-parked") + .expect("background target") + .target_handle() + .clone(); + browser_owner + .register_browser_context( + "BID-parked", + BrowserTargetTopologyProjection::new( + "BID-parked", + None, + vec![BrowserTargetSlotProjection::new( + target_handle, + page_residence.clone(), + )], + ), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("background Target topology should register"); + let navigation = browser_owner + .try_start_document_navigation_with_trace(&owner_key, "LOADER-parked".to_owned(), None) + .expect("parked test Context accepts navigation"); + let permit = browser_owner + .prepare_loaded_page_replacement(&owner_key, &navigation) + .expect("background Page residence should authorize replacement"); + + let prepared = { let mut owner = TargetSessionOwnerMut::BackgroundTarget { browser_context: &mut parked, target_id: "TID-parked".to_owned(), @@ -4619,14 +3690,41 @@ mod tests { is_auxiliary_target_session: false, }; owner - .commit_loaded_navigation_page_async( + .prepare_loaded_navigation_page_commit( page, LoadedNavigationRendererAttachmentCommit::Prepare(None), - &page_url, ) - .await .expect("background page owner should exist") - .expect("background page Inspector binding should activate"); + .expect("background page Inspector binding should prepare") + }; + let retiring_renderer_page = prepared.retiring_renderer_page(); + let mut page_runtime_owner = Some(prepared.into_page_runtime_owner()); + let mut renderer_page_owner = page_runtime_owner + .as_mut() + .and_then(BrowserPageRuntimeOwner::take_renderer_lifetime_owner); + let replacement = browser_owner + .commit_loaded_page_replacement( + permit, + history_page, + &mut renderer_page_owner, + &mut page_runtime_owner, + ) + .expect("exact background replacement should commit"); + { + let mut owner = TargetSessionOwnerMut::BackgroundTarget { + browser_context: &mut parked, + target_id: "TID-parked".to_owned(), + session_id: None, + is_auxiliary_target_session: false, + }; + owner + .project_loaded_navigation_page_after_browser_owner_commit( + &final_url, + &main_document_commit, + &replacement, + retiring_renderer_page, + ) + .expect("prepared background page route should remain available"); } let target = parked @@ -4637,23 +3735,15 @@ mod tests { target.page_attachment_id().is_some() && target.page_attachment_id() != initial_attachment_id ); - let (_, entries) = parked.mutate_parked_target_owner_state("TID-parked", |owner_state| { - owner_state.navigation_history_snapshot(None) - }); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].title, "background commit"); } #[test] - fn target_page_residence_identity_rejects_context_target_and_attachment_collisions() { + fn synthetic_page_residence_cannot_forge_live_slot_capability() { let mut conn = CdpConnection::default(); let mut browser_context = BrowserContext::new("BID-page-residence".to_owned()); browser_context.set_active_target_id("TID-page-residence"); browser_context.attach_active_session("SID-page-residence"); - conn.browser_context = Some(browser_context); - conn.runtime_session_owner_slot_mut(Some("SID-page-residence")) - .expect("active target runtime slot") - .set_page_attachment_id_for_test(41); + conn.insert_browser_context(browser_context); let current = conn .target_page_residence_identity_for_session(Some("SID-page-residence")) @@ -4667,17 +3757,17 @@ mod tests { TargetPageResidenceIdentity::new( "BID-other".to_owned(), Some("TID-page-residence".to_owned()), - current.page_attachment_id(), + current.loaded_page_generation(), ), TargetPageResidenceIdentity::new( "BID-page-residence".to_owned(), Some("TID-other".to_owned()), - current.page_attachment_id(), + current.loaded_page_generation(), ), TargetPageResidenceIdentity::new( "BID-page-residence".to_owned(), Some("TID-page-residence".to_owned()), - crate::conn::TargetPageAttachmentId::allocate(), + current.loaded_page_generation() + 1, ), ] { assert!( @@ -4685,42 +3775,165 @@ mod tests { Some("SID-page-residence"), &stale, ), - "every Page residence identity component must participate in authorization" + "public identity fields cannot replace the live slot instance capability" ); } } #[test] - fn target_without_page_has_only_a_pending_page_residence() { + fn page_residence_owner_route_is_independent_of_frontend_session_churn() { let mut conn = CdpConnection::default(); - let mut browser_context = BrowserContext::new("BID-pending-residence".to_owned()); - browser_context.set_active_target_id("TID-pending-residence"); - browser_context.attach_active_session("SID-pending-residence".to_owned()); - conn.browser_context = Some(browser_context); + let mut browser_context = BrowserContext::new("BID-owner-route".to_owned()); + browser_context.set_active_target_id("TID-owner-route"); + browser_context.attach_active_session("SID-original"); + conn.insert_browser_context(browser_context); + let owner = conn + .target_page_residence_identity_for_session(Some("SID-original")) + .expect("active target should expose its Page residence identity"); + let browser_context = conn.browser_context.as_mut().unwrap(); assert_eq!( - conn.target_page_residence_identity_for_session(Some("SID-pending-residence")), - None, - "an empty target slot must not manufacture a current Page identity" + browser_context.detach_active_session().as_deref(), + Some("SID-original") ); + browser_context.attach_active_session("SID-replacement"); - conn.runtime_session_owner_slot_mut(Some("SID-pending-residence")) - .expect("active target runtime slot") - .start_document_navigation( - "TID-pending-residence".to_owned(), - "LOADER-pending-residence".to_owned(), - ); + assert!( + conn.target_page_residence_identity_for_session(Some("SID-original")) + .is_none(), + "the detached frontend route must no longer resolve" + ); + assert!(matches!( + conn.target_page_owner_route_if_current(&owner), + Some(CdpSessionRoute::ActiveTarget { + ref browser_context_id, + target_id: Some(ref target_id), + }) if browser_context_id == "BID-owner-route" && target_id == "TID-owner-route" + )); + + conn.browser_context + .as_mut() + .unwrap() + .active_target + .runtime_slot + .set_loaded_page_generation(owner.loaded_page_generation() + 1); + assert!( + conn.target_page_owner_route_if_current(&owner).is_none(), + "Page replacement, unlike session replacement, must retire the owner identity" + ); + } + + #[tokio::test] + async fn installed_page_replacement_advances_exact_residence_generation() { + let mut ctx = TestContext::new(); + let first_page = ctx + .conn + .load_page_via_runtime_async("data:text/html,first residence") + .await + .expect("first Page should load"); + let second_page = ctx + .conn + .load_page_via_runtime_async("data:text/html,second residence") + .await + .expect("second Page should load"); + let mut browser_context = BrowserContext::new("BID-page-replacement".to_owned()); + browser_context.set_active_target_id("TID-page-replacement"); + browser_context.attach_active_session("SID-page-replacement"); + browser_context + .active_target + .runtime_slot + .replace_loaded_page(Some(first_page)); + ctx.conn.insert_browser_context(browser_context); + + let first = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-page-replacement")) + .expect("installed first Page should expose its exact residence"); + ctx.conn + .browser_context + .as_mut() + .unwrap() + .active_target + .runtime_slot + .replace_loaded_page(Some(second_page)); + let second = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-page-replacement")) + .expect("installed second Page should expose its exact residence"); assert_eq!( - conn.target_page_residence_identity_for_session(Some("SID-pending-residence")), - None, - "a reservation must not masquerade as the current Page" + second.loaded_page_generation(), + first.loaded_page_generation() + 1 + ); + assert!( + ctx.conn + .target_page_owner_route_if_current(&first) + .is_none() + ); + assert!( + ctx.conn + .target_page_owner_route_if_current(&second) + .is_some() + ); + + ctx.conn + .browser_context + .as_mut() + .unwrap() + .active_target + .runtime_slot + .clear_loaded_page_with_reason(TargetPageAbsenceReason::TargetClosed); + assert!( + ctx.conn + .target_page_owner_route_if_current(&second) + .is_none(), + "retiring the installed Page must invalidate its captured residence" + ); + } + + #[test] + fn background_page_owner_route_survives_auxiliary_session_detach() { + let mut conn = CdpConnection::default(); + let mut browser_context = BrowserContext::new("BID-background-owner-route".to_owned()); + browser_context.set_active_target_id("TID-active"); + browser_context + .background_targets + .push(crate::conn::BackgroundTarget::with_url( + "TID-background".to_owned(), + Some("SID-background-primary".to_owned()), + "about:blank".to_owned(), + )); + assert!(browser_context.assign_auxiliary_session_to_target( + "TID-background", + "SID-background-auxiliary".to_owned(), + )); + conn.insert_browser_context(browser_context); + + let owner = conn + .target_page_residence_identity_for_session(Some("SID-background-auxiliary")) + .expect("auxiliary target attachment should resolve the background Page"); + assert_eq!( + conn.browser_context + .as_mut() + .unwrap() + .remove_auxiliary_session("SID-background-auxiliary") + .as_deref(), + Some("TID-background") ); + assert!( - conn.pending_target_page_residence_identity_for_session(Some("SID-pending-residence")) - .is_some(), - "the future Page attachment should remain explicitly addressable" + conn.target_page_residence_identity_for_session(Some("SID-background-auxiliary")) + .is_none(), + "the detached auxiliary frontend route must no longer resolve" ); + assert!(matches!( + conn.target_page_owner_route_if_current(&owner), + Some(CdpSessionRoute::BackgroundTarget { + ref browser_context_id, + ref target_id, + }) if browser_context_id == "BID-background-owner-route" + && target_id == "TID-background" + )); } #[test] @@ -4728,10 +3941,7 @@ mod tests { let mut conn = CdpConnection::default(); let mut browser_context = BrowserContext::new("BID-implicit-page-residence".to_owned()); browser_context.set_active_target_id("TID-original"); - conn.browser_context = Some(browser_context); - conn.runtime_session_owner_slot_mut(None) - .expect("implicit active runtime slot") - .set_page_attachment_id_for_test(1); + conn.insert_browser_context(browser_context); let original = conn .target_page_residence_identity_for_session(None) @@ -4761,17 +3971,17 @@ mod tests { Some("SID-background".to_owned()), "about:blank".to_owned(), )); + browser_context.adopt_background_target_fixture_attachments(); browser_context - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; - browser_context.mutate_parked_page_session_state("TID-background", |state| { - state - .devtools_session_state - .runtime_session_state - .inspector_enabled = true; - }); - conn.browser_context = Some(browser_context); + browser_context + .primary_devtools_session_state_for_target_mut("TID-background") + .expect("background Target frontend state") + .runtime_session_state + .inspector_enabled = true; + conn.insert_browser_context(browser_context); conn.with_target_owner_state_for_session_mut(Some("SID-active"), |owner_state| { owner_state.target_crash_state.mark_crashed(); @@ -4823,7 +4033,7 @@ mod tests { "TID-page-events", "SID-lifecycle-only".to_owned(), )); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); for session_id in ["SID-primary", "SID-lifecycle-only"] { conn.with_target_devtools_session_state_for_session_mut(Some(session_id), |state| { @@ -4861,9 +4071,9 @@ mod tests { ); } - #[test] - fn runtime_event_attachments_include_every_enabled_session_on_the_exact_page() { - let mut conn = CdpConnection::default(); + #[tokio::test] + async fn runtime_event_attachments_include_every_enabled_session_on_the_exact_page() { + let mut ctx = TestContext::new(); let mut browser_context = BrowserContext::new("BID-runtime-events".to_owned()); browser_context.set_active_target_id("TID-runtime-events".to_owned()); browser_context.attach_active_session("SID-runtime-a".to_owned()); @@ -4877,18 +4087,21 @@ mod tests { "TID-runtime-events", "SID-runtime-disabled".to_owned(), )); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(41); - conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,runtime audience", + Some("SID-runtime-a"), + ) + .await; - conn.with_target_devtools_session_state_for_session_mut(Some("SID-runtime-b"), |state| { - state.runtime_session_state.runtime_frontend_enabled = true - }) - .expect("Runtime-enabled auxiliary session should be mutable"); + ctx.conn + .with_target_devtools_session_state_for_session_mut(Some("SID-runtime-b"), |state| { + state.runtime_session_state.runtime_frontend_enabled = true + }) + .expect("Runtime-enabled auxiliary session should be mutable"); assert_eq!( - conn.runtime_event_protocol_attachments_for_session_owner(Some("SID-runtime-a")) + ctx.conn + .runtime_event_protocol_attachments_for_session_owner(Some("SID-runtime-a")) .expect("the current Page should expose its Runtime audience") .into_iter() .map(|attachment| attachment.session_id().map(str::to_owned)) @@ -4897,12 +4110,14 @@ mod tests { "a disabled primary must not hide the enabled peer attachment" ); - conn.with_target_devtools_session_state_for_session_mut(Some("SID-runtime-a"), |state| { - state.runtime_session_state.runtime_frontend_enabled = true - }) - .expect("Runtime-enabled primary session should be mutable"); + ctx.conn + .with_target_devtools_session_state_for_session_mut(Some("SID-runtime-a"), |state| { + state.runtime_session_state.runtime_frontend_enabled = true + }) + .expect("Runtime-enabled primary session should be mutable"); assert_eq!( - conn.runtime_event_protocol_attachments_for_session_owner(Some("SID-runtime-b")) + ctx.conn + .runtime_event_protocol_attachments_for_session_owner(Some("SID-runtime-b")) .expect("the current Page should expose its Runtime audience") .into_iter() .map(|attachment| attachment.session_id().map(str::to_owned)) @@ -4932,7 +4147,7 @@ mod tests { "TID-service-worker", "SID-service-worker".to_owned(), )); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); assert_eq!( conn.session_route(Some("SID-service-worker")), @@ -4974,9 +4189,12 @@ mod tests { Some("SID-background".to_owned()), "https://background.example/".to_owned(), )); - active.mutate_parked_page_session_state("TID-background", |state| { - state.devtools_session_state.page_session_state.log_enabled = true; - }); + active.adopt_background_target_fixture_attachments(); + active + .primary_devtools_session_state_for_target_mut("TID-background") + .expect("background Target frontend state") + .page_session_state + .log_enabled = true; assert!( active.assign_auxiliary_session_to_target( "TID-background", @@ -4987,19 +4205,23 @@ mod tests { let mut inactive = BrowserContext::new("BID-inactive".to_owned()); inactive.set_active_target_id("TID-inactive".to_owned()); inactive.set_target_url("https://inactive.example/".to_owned()); - inactive - .auxiliary_devtools_session_states - .entry("SID-aux-inactive".to_owned()) - .or_default() - .console_output_session_state - .console_enabled = true; assert!( inactive .assign_auxiliary_session_to_target("TID-inactive", "SID-aux-inactive".to_owned()) ); + assert!( + inactive + .mutate_devtools_session_state_for_target( + "TID-inactive", + true, + Some("SID-aux-inactive"), + |state| state.console_output_session_state.console_enabled = true, + ) + .is_some() + ); - conn.browser_context = Some(active); - conn.inactive_browser_contexts.push(inactive); + conn.insert_browser_context(active); + conn.insert_browser_context(inactive); conn.runtime_session_owner_slot_mut(Some("SID-active")) .expect("active runtime slot should be mutable") @@ -5107,7 +4329,7 @@ mod tests { .is_none(), "a background target with default protocol settings should not allocate parked overrides" ); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let observer = conn .runtime_session_owner_slot(Some("SID-background-dialog")) diff --git a/moli-protocol/src/conn/browser_context/target_snapshot_projection.rs b/moli-protocol/src/conn/browser_context/target_snapshot_projection.rs new file mode 100644 index 0000000000..d71ecb4fd4 --- /dev/null +++ b/moli-protocol/src/conn/browser_context/target_snapshot_projection.rs @@ -0,0 +1,204 @@ +use moli_core::browser_host::{ + BrowserContextTargetSnapshot, BrowserPageOwnerKey, BrowserTargetStateSnapshot, + BrowserTopLevelTargetSnapshot, +}; + +use crate::devtools_runtime::{DevToolsError, DevToolsTargetInfo}; + +use super::{BrowserContext, CdpConnection, TargetProjectionError}; + +/// Frontend projection for Browser Core-owned top-level Target current state. +/// +/// Core decides which exact Context/Target/Page slots exist and their order. +/// This migration boundary joins those identities with physical metadata and +/// renderer-owned worker targets; it cannot add a physical page that Core did +/// not snapshot. +impl CdpConnection { + pub(crate) fn browser_context_target_snapshot_is_current( + &self, + snapshot: &BrowserContextTargetSnapshot, + ) -> bool { + self.browser_host_state + .navigation_owner() + .browser_context_target_snapshot_is_current(snapshot) + } + + pub(crate) fn browser_target_state_snapshot_is_current( + &self, + snapshot: &BrowserTargetStateSnapshot, + ) -> bool { + self.browser_host_state + .navigation_owner() + .browser_target_state_snapshot_is_current(snapshot) + } + + pub(crate) fn capture_browser_top_level_target_snapshot( + &self, + ) -> Result { + self.validate_browser_context_topology_projection() + .map_err(DevToolsError::from)?; + self.validate_browser_target_topology_projection() + .map_err(DevToolsError::from)?; + self.browser_host_state + .navigation_owner() + .snapshot_top_level_targets() + .map_err(TargetProjectionError::Core) + .map_err(DevToolsError::from) + } + + fn physical_browser_context_for_snapshot( + &self, + snapshot: &BrowserContextTargetSnapshot, + ) -> Result<&BrowserContext, TargetProjectionError> { + if !self + .browser_host_state + .navigation_owner() + .browser_context_target_snapshot_is_current(snapshot) + { + return Err(TargetProjectionError::StaleBrowserContextTargetSnapshot( + snapshot.browser_context_id().to_owned(), + )); + } + let browser_context = self + .browser_context_by_id(snapshot.browser_context_id()) + .ok_or_else(|| { + TargetProjectionError::PhysicalBrowserContextMissing( + snapshot.browser_context_id().to_owned(), + ) + })?; + if browser_context.browser_context_handle() != snapshot.browser_context_handle() { + return Err(TargetProjectionError::PhysicalBrowserContextHandleMismatch( + snapshot.browser_context_id().to_owned(), + )); + } + Ok(browser_context) + } + + pub(crate) fn project_top_level_target_snapshot( + &self, + snapshot: &BrowserTargetStateSnapshot, + ) -> Result { + if !self + .browser_host_state + .navigation_owner() + .browser_target_state_snapshot_is_current(snapshot) + { + return Err(DevToolsError::from( + TargetProjectionError::StaleTopLevelTargetSnapshot { + browser_context_id: snapshot.browser_context_id().to_owned(), + target_id: snapshot.target_id().to_owned(), + }, + )); + } + let browser_context = self + .browser_context_by_id(snapshot.browser_context_id()) + .ok_or_else(|| { + DevToolsError::from(TargetProjectionError::PhysicalBrowserContextMissing( + snapshot.browser_context_id().to_owned(), + )) + })?; + if browser_context.browser_context_handle() != snapshot.browser_context_handle() { + return Err(DevToolsError::from( + TargetProjectionError::PhysicalBrowserContextHandleMismatch( + snapshot.browser_context_id().to_owned(), + ), + )); + } + let target_id = snapshot.target_id(); + let physical_slot = if browser_context.active_target_id() == Some(target_id) { + browser_context.active_target_handle().map(|target_handle| { + ( + target_handle, + browser_context + .active_target + .runtime_slot + .page_residence_handle(), + ) + }) + } else { + browser_context.background_target(target_id).map(|target| { + ( + target.target_handle(), + target.runtime_slot().page_residence_handle(), + ) + }) + }; + let Some((physical_target_handle, physical_page_residence)) = physical_slot else { + return Err(DevToolsError::from( + TargetProjectionError::PhysicalTargetMissing { + browser_context_id: snapshot.browser_context_id().to_owned(), + target_id: target_id.to_owned(), + }, + )); + }; + if physical_target_handle != snapshot.target_handle() { + return Err(DevToolsError::from( + TargetProjectionError::PhysicalTargetHandleMismatch { + browser_context_id: snapshot.browser_context_id().to_owned(), + target_id: target_id.to_owned(), + }, + )); + } + let owner = BrowserPageOwnerKey::new(snapshot.browser_context_id(), target_id); + let physical_page = physical_page_residence.identity( + snapshot.browser_context_id().to_owned(), + Some(target_id.to_owned()), + ); + if !self + .browser_host_state + .navigation_owner() + .page_residence_handle_is_current(&owner, physical_page_residence) + || !snapshot + .page_residence() + .same_residence_instance(&physical_page) + { + return Err(DevToolsError::from( + TargetProjectionError::PhysicalPageResidenceMismatch { + browser_context_id: snapshot.browser_context_id().to_owned(), + target_id: target_id.to_owned(), + }, + )); + } + browser_context + .devtools_target_info(target_id) + .ok_or_else(|| { + DevToolsError::from(TargetProjectionError::PhysicalTargetMissing { + browser_context_id: snapshot.browser_context_id().to_owned(), + target_id: target_id.to_owned(), + }) + }) + } + + pub(crate) fn project_devtools_target_infos_from_browser_snapshot( + &self, + snapshot: &BrowserTopLevelTargetSnapshot, + ) -> Result, DevToolsError> { + if !self + .browser_host_state + .navigation_owner() + .browser_top_level_target_snapshot_is_from_current_browser(snapshot) + { + return Err(DevToolsError::from( + TargetProjectionError::ForeignBrowserTopLevelTargetSnapshot, + )); + } + let mut infos = Vec::new(); + for context_snapshot in snapshot.contexts() { + for target_snapshot in context_snapshot.targets() { + let mut target_info = self.project_top_level_target_snapshot(target_snapshot)?; + target_info.moli_popup_id = self + .browser_context_by_id(context_snapshot.browser_context_id()) + .and_then(|context| context.target_popup_id(target_snapshot.target_id())); + infos.push(target_info); + } + let browser_context = self + .physical_browser_context_for_snapshot(context_snapshot) + .map_err(DevToolsError::from)?; + infos.extend(browser_context.devtools_worker_target_infos()); + } + Ok(infos) + } +} + +#[cfg(test)] +mod tests; diff --git a/moli-protocol/src/conn/browser_context/target_snapshot_projection/tests.rs b/moli-protocol/src/conn/browser_context/target_snapshot_projection/tests.rs new file mode 100644 index 0000000000..15d94818e6 --- /dev/null +++ b/moli-protocol/src/conn/browser_context/target_snapshot_projection/tests.rs @@ -0,0 +1,121 @@ +use crate::conn::{BackgroundTarget, BrowserContext, CdpConnection}; +use moli_core::browser_host::BrowserPageResidenceHandle; + +fn connection_with_active_and_background_targets() -> CdpConnection { + let mut connection = CdpConnection::new(); + let mut browser_context = BrowserContext::new("context-1".to_owned()); + browser_context.set_active_target_id("target-active"); + connection.insert_browser_context(browser_context); + let target = BackgroundTarget::with_url( + "target-background".to_owned(), + None, + "about:blank#background".to_owned(), + ); + connection + .register_background_target_projection( + "context-1", + "target-background", + move |browser_context, target_handle, page_residence, session_storage_access| { + let mut target = target; + target.replace_target_handle(target_handle); + target.replace_page_residence_handle(page_residence); + target.bind_session_storage_access(session_storage_access); + browser_context.background_targets.push(target); + }, + ) + .expect("background Target projection should register"); + connection +} + +#[test] +fn frontend_projection_uses_browser_snapshot_order() { + let connection = connection_with_active_and_background_targets(); + let snapshot = connection + .capture_browser_top_level_target_snapshot() + .expect("exact physical state should snapshot"); + let infos = connection + .project_devtools_target_infos_from_browser_snapshot(&snapshot) + .expect("exact snapshot should project"); + let target_ids = infos + .iter() + .filter_map(|info| info.target_id.as_ref().map(|id| id.as_str())) + .collect::>(); + assert_eq!(target_ids, ["target-active", "target-background"]); +} + +#[test] +fn stale_target_snapshot_cannot_project_replacement_payload() { + let mut connection = connection_with_active_and_background_targets(); + let snapshot = connection + .capture_browser_top_level_target_snapshot() + .expect("exact physical state should snapshot"); + let stale = snapshot + .target("target-background") + .expect("background snapshot") + .clone(); + + connection + .rollback_staged_background_target_projection("context-1", "target-background") + .expect("old Target should retire"); + let successor = BackgroundTarget::with_url( + "target-background".to_owned(), + None, + "about:blank#successor".to_owned(), + ); + connection + .register_background_target_projection( + "context-1", + "target-background", + move |browser_context, target_handle, page_residence, session_storage_access| { + let mut target = successor; + target.replace_target_handle(target_handle); + target.replace_page_residence_handle(page_residence); + target.bind_session_storage_access(session_storage_access); + browser_context.background_targets.push(target); + }, + ) + .expect("successor with reused public id should register"); + + let error = connection + .project_top_level_target_snapshot(&stale) + .expect_err("predecessor snapshot must not project successor metadata"); + assert!(error.message.contains("current-state snapshot")); +} + +#[test] +fn exact_snapshot_rejects_wrong_physical_page_slot() { + let mut connection = connection_with_active_and_background_targets(); + let snapshot = connection + .capture_browser_top_level_target_snapshot() + .expect("exact physical state should snapshot"); + let target = snapshot + .target("target-background") + .expect("background snapshot") + .clone(); + connection + .browser_context + .as_mut() + .expect("browser context") + .background_target_mut("target-background") + .expect("physical background Target") + .replace_page_residence_handle(BrowserPageResidenceHandle::default()); + + let error = connection + .project_top_level_target_snapshot(&target) + .expect_err("wrong physical Page slot must not project"); + assert!(error.message.contains("Page residence")); +} + +#[test] +fn empty_snapshot_from_another_browser_cannot_project() { + let source = CdpConnection::new(); + let snapshot = source + .capture_browser_top_level_target_snapshot() + .expect("empty Browser should snapshot"); + let destination = CdpConnection::new(); + + let error = destination + .project_devtools_target_infos_from_browser_snapshot(&snapshot) + .expect_err("foreign empty snapshot must retain Browser provenance"); + assert!(error.message.contains("another Browser instance")); +} diff --git a/moli-protocol/src/conn/browser_context/target_termination_projection.rs b/moli-protocol/src/conn/browser_context/target_termination_projection.rs new file mode 100644 index 0000000000..10f1a0dc8a --- /dev/null +++ b/moli-protocol/src/conn/browser_context/target_termination_projection.rs @@ -0,0 +1,404 @@ +use moli_core::{browser_host::BrowserTargetTermination, page::Page}; + +use crate::conn::{ + BackgroundProtocolEvent, BackgroundTarget, CdpConnection, state::TargetPageAbsenceReason, +}; + +use super::BrowserContext; +use super::target_session_owner::{ + ClosedPageTarget, TargetSessionOwnerMut, clear_top_level_target_loaded_document_session_state, +}; + +pub(crate) struct ProjectedClosedPageTarget { + closed: ClosedPageTarget, + retired_page: Option, +} + +impl ProjectedClosedPageTarget { + pub(crate) fn into_parts(self) -> (ClosedPageTarget, Option) { + (self.closed, self.retired_page) + } +} + +pub(crate) struct ProjectedActiveTargetClose { + closed: ClosedPageTarget, + retired_page: Option, + promoted_target_id: Option, +} + +impl ProjectedActiveTargetClose { + pub(crate) fn into_parts(self) -> (ClosedPageTarget, Option, Option) { + (self.closed, self.retired_page, self.promoted_target_id) + } +} + +impl BrowserContext { + fn project_active_target_page_absence_after_browser_owner_commit( + &mut self, + reason: TargetPageAbsenceReason, + termination: &BrowserTargetTermination, + ) -> Option { + let previous = self + .active_target + .runtime_slot + .project_target_termination_after_browser_owner_commit(reason, termination); + self.ingest_active_target_output_updates(); + self.active_target + .owner_state + .clear_loaded_document_context_state(); + self.clear_active_target_loaded_document_session_state(); + previous + } + + pub(super) fn project_active_target_crash_after_browser_owner_commit( + &mut self, + termination: &BrowserTargetTermination, + ) -> Option { + self.active_target + .owner_state + .target_crash_state + .mark_crashed(); + self.clear_renderer_document_protocol_state_for_active_target(); + let page = self.project_active_target_page_absence_after_browser_owner_commit( + TargetPageAbsenceReason::TargetCrashed, + termination, + ); + self.clear_pending_fetch_state(); + self.clear_session_scoped_network_observation_artifacts(); + page + } + + pub(super) fn project_active_target_after_page_close_browser_owner_commit( + &mut self, + termination: &BrowserTargetTermination, + ) -> Option { + if let Some(target_id) = self.active_target_id_owned() { + self.forget_target_opener_references_for_target(&target_id); + self.forget_target_window_names_for_target(&target_id); + self.forget_target_popup_id_for_target(&target_id); + } + self.clear_active_target_session_scoped_state_fields(); + self.active_target.owner_state.target_crash_state.clear(); + self.clear_active_target_id(); + self.clear_renderer_document_protocol_state_for_active_target(); + self.detach_active_session(); + let page = self.project_active_target_page_absence_after_browser_owner_commit( + TargetPageAbsenceReason::TargetClosed, + termination, + ); + self.active_target.owner_state.clear_page_local_state(); + self.reset_target_identity_to_about_blank(); + self.reset_target_scoped_network_artifacts(); + self.active_target + .owner_state + .clear_observable_output_state(); + self.active_target + .runtime_slot + .request_id_allocator() + .reset_subresource_fetch_request_counter(); + page + } + + pub(super) fn project_active_target_slot_to_empty_after_browser_owner_commit( + &mut self, + termination: &BrowserTargetTermination, + ) -> Option { + self.clear_active_target_session_scoped_state_fields(); + self.active_target.owner_state.target_crash_state.clear(); + if let Some(target_id) = self.active_target_id_owned() { + self.forget_target_opener_references_for_target(&target_id); + self.forget_target_window_names_for_target(&target_id); + self.forget_target_popup_id_for_target(&target_id); + } + self.detach_active_session(); + self.clear_active_target_id(); + self.clear_renderer_document_protocol_state_for_active_target(); + let page = self.project_active_target_page_absence_after_browser_owner_commit( + TargetPageAbsenceReason::TargetClosed, + termination, + ); + self.clear_pending_fetch_state(); + self.active_target.owner_state.clear_page_local_state(); + self.restore_raw_cookie_manager_surface_without_loaded_page_sync(Default::default()); + self.reset_target_identity_to_new_tab(); + self.reset_target_scoped_network_artifacts(); + self.active_target + .owner_state + .clear_observable_output_state(); + self.active_target + .runtime_slot + .request_id_allocator() + .reset_subresource_fetch_request_counter(); + page + } +} + +impl BackgroundTarget { + fn project_target_termination_after_browser_owner_commit( + &mut self, + reason: TargetPageAbsenceReason, + termination: &BrowserTargetTermination, + ) -> Option { + self.runtime_slot + .project_target_termination_after_browser_owner_commit(reason, termination) + } +} + +impl TargetSessionOwnerMut<'_> { + pub(super) fn project_target_crash_after_browser_owner_commit( + &mut self, + termination: &BrowserTargetTermination, + ) -> Option> { + match self { + Self::ActiveTarget { + browser_context, .. + } => Some( + browser_context.project_active_target_crash_after_browser_owner_commit(termination), + ), + Self::BackgroundTarget { + browser_context, + target_id, + .. + } => { + browser_context.background_target(target_id)?; + browser_context.mutate_parked_target_owner_state(target_id, |owner_state| { + owner_state.target_crash_state.mark_crashed(); + owner_state.clear_loaded_document_context_state(); + }); + clear_top_level_target_loaded_document_session_state(browser_context, target_id); + let previous = { + let target = browser_context.background_target_mut(target_id)?; + target.runtime_slot.clear_renderer_document_protocol_state(); + let previous = target.project_target_termination_after_browser_owner_commit( + TargetPageAbsenceReason::TargetCrashed, + termination, + ); + target.runtime_slot.reset_subresource_cursor(); + target + .runtime_slot + .reset_all_target_scoped_network_artifacts(); + previous + }; + browser_context.replace_parked_fetch_state(target_id.clone(), Default::default()); + browser_context + .replace_parked_network_artifacts(target_id.clone(), Default::default()); + Some(previous) + } + Self::NoLoadedBrowserContext => None, + } + } + + pub(super) fn project_page_close_after_browser_owner_commit( + &mut self, + termination: &BrowserTargetTermination, + ) -> Option { + match self { + Self::ActiveTarget { + browser_context, .. + } => { + let target_id = browser_context.active_target_id_owned()?; + let primary_session_id = browser_context.active_session_id_owned(); + let auxiliary_session_ids = + browser_context.remove_auxiliary_sessions_for_target(&target_id); + let retired_page = browser_context + .project_active_target_after_page_close_browser_owner_commit(termination); + Some(ProjectedClosedPageTarget { + closed: ClosedPageTarget { + target_id, + primary_session_id, + auxiliary_session_ids, + promoted_target_id: None, + }, + retired_page, + }) + } + Self::BackgroundTarget { + browser_context, + target_id, + .. + } => { + let primary_session_id = browser_context + .primary_session_id_for_target(target_id) + .map(str::to_owned); + let auxiliary_session_ids = + browser_context.remove_auxiliary_sessions_for_target(target_id); + browser_context.take_top_level_target_attachment_for_target(target_id); + let mut target = browser_context.remove_background_target(target_id)?; + browser_context.forget_target_opener_references_for_target(target_id); + browser_context.forget_target_window_names_for_target(target_id); + browser_context.forget_target_popup_id_for_target(target_id); + let _ = browser_context.take_parked_target_aux_state(target_id); + let retired_page = target.project_target_termination_after_browser_owner_commit( + TargetPageAbsenceReason::TargetClosed, + termination, + ); + Some(ProjectedClosedPageTarget { + closed: ClosedPageTarget { + target_id: target_id.clone(), + primary_session_id, + auxiliary_session_ids, + promoted_target_id: None, + }, + retired_page, + }) + } + Self::NoLoadedBrowserContext => None, + } + } +} + +impl CdpConnection { + pub(crate) fn project_target_crash_for_none_session_owner_after_browser_owner_commit( + &mut self, + termination: &BrowserTargetTermination, + ) -> Option> { + self.target_session_owner_mut(None)? + .project_target_crash_after_browser_owner_commit(termination) + } + + pub(crate) fn project_page_close_for_none_session_owner_after_browser_owner_commit( + &mut self, + termination: &BrowserTargetTermination, + ) -> Option { + let target_id = termination.owner().target_id(); + let mut collected_network_data_artifacts = self + .target_session_owner_ref(None)? + .runtime_slot()? + .collected_network_data_artifacts(); + if let Some(browser_context) = + self.browser_context_by_id_mut(termination.owner().browser_context_id()) + { + collected_network_data_artifacts.extend( + browser_context + .take_parked_network_artifacts(target_id) + .collected_network_data_artifacts(), + ); + } + let projected = self + .target_session_owner_mut(None)? + .project_page_close_after_browser_owner_commit(termination)?; + self.record_collected_network_data_artifacts(collected_network_data_artifacts); + Some(projected) + } + + pub(crate) fn project_background_target_close_after_browser_owner_commit( + &mut self, + termination: &BrowserTargetTermination, + out: &mut Vec, + reason: &'static str, + ) -> Option { + let target_id = termination.owner().target_id(); + let ( + mut target, + primary_session_id, + auxiliary_session_ids, + collected_network_data_artifacts, + ) = { + let browser_context = + self.browser_context_by_id_mut(termination.owner().browser_context_id())?; + let primary_session_id = browser_context + .primary_session_id_for_target(target_id) + .map(str::to_owned); + let auxiliary_session_ids = browser_context.auxiliary_session_ids_for_target(target_id); + let mut affected_sessions = primary_session_id + .as_deref() + .into_iter() + .collect::>(); + affected_sessions.extend(auxiliary_session_ids.iter().map(String::as_str)); + if let Some((primary, auxiliary)) = + browser_context.devtools_session_states_for_target_mut(target_id) + { + CdpConnection::fail_pending_inspector_awaits_from_top_level_session_states_for_sessions_background_events_into( + out, + primary, + auxiliary, + primary_session_id.as_deref(), + &affected_sessions, + reason, + ); + } + browser_context.remove_auxiliary_sessions_for_target(target_id); + browser_context.take_top_level_target_attachment_for_target(target_id); + let target = browser_context.remove_background_target(target_id)?; + let mut collected_network_data_artifacts = + target.runtime_slot().collected_network_data_artifacts(); + collected_network_data_artifacts.extend( + browser_context + .take_parked_network_artifacts(target_id) + .collected_network_data_artifacts(), + ); + browser_context.forget_target_opener_references_for_target(target_id); + browser_context.forget_target_window_names_for_target(target_id); + browser_context.forget_target_popup_id_for_target(target_id); + drop(browser_context.take_parked_target_aux_state(target_id)); + ( + target, + primary_session_id, + auxiliary_session_ids, + collected_network_data_artifacts, + ) + }; + let retired_page = target.project_target_termination_after_browser_owner_commit( + TargetPageAbsenceReason::TargetClosed, + termination, + ); + self.record_collected_network_data_artifacts(collected_network_data_artifacts); + + Some(ProjectedClosedPageTarget { + closed: ClosedPageTarget { + target_id: target_id.to_owned(), + primary_session_id, + auxiliary_session_ids, + promoted_target_id: None, + }, + retired_page, + }) + } + + pub(crate) fn project_active_target_close_after_browser_owner_commit( + &mut self, + termination: &BrowserTargetTermination, + ) -> Option { + let ( + target_id, + primary_session_id, + auxiliary_session_ids, + promoted_target_id, + collected_network_data_artifacts, + retired_page, + ) = { + let browser_context = + self.browser_context_by_id_mut(termination.owner().browser_context_id())?; + let target_id = browser_context.active_target_id_owned()?; + let primary_session_id = browser_context.active_session_id_owned(); + let auxiliary_session_ids = + browser_context.remove_auxiliary_sessions_for_target(&target_id); + let promoted_target_id = browser_context.last_promotable_background_target_id(); + let collected_network_data_artifacts = browser_context + .active_target + .runtime_slot + .collected_network_data_artifacts(); + let retired_page = browser_context + .project_active_target_slot_to_empty_after_browser_owner_commit(termination); + ( + target_id, + primary_session_id, + auxiliary_session_ids, + promoted_target_id, + collected_network_data_artifacts, + retired_page, + ) + }; + self.record_collected_network_data_artifacts(collected_network_data_artifacts); + Some(ProjectedActiveTargetClose { + closed: ClosedPageTarget { + target_id, + primary_session_id, + auxiliary_session_ids, + promoted_target_id: promoted_target_id.clone(), + }, + retired_page, + promoted_target_id, + }) + } +} diff --git a/moli-protocol/src/conn/browser_context/target_topology_projection.rs b/moli-protocol/src/conn/browser_context/target_topology_projection.rs new file mode 100644 index 0000000000..541360cae0 --- /dev/null +++ b/moli-protocol/src/conn/browser_context/target_topology_projection.rs @@ -0,0 +1,151 @@ +use std::collections::HashSet; + +use moli_core::browser_host::{ + BrowserNavigationOwner, BrowserPageOwnerKey, BrowserPageResidenceHandle, BrowserTargetHandle, + BrowserTargetSlotProjection, BrowserTargetTopologyProjection, +}; + +use super::target_projection_error::TargetProjectionError; +use super::{BrowserContext, CdpConnection}; + +/// Exact physical fact projection for the Browser Core-owned top-level Target +/// topology. This module validates identity and capability equality; it never +/// mutates either registry. +impl CdpConnection { + pub(super) fn browser_target_topology_projection( + browser_context: &BrowserContext, + ) -> BrowserTargetTopologyProjection { + BrowserTargetTopologyProjection::new( + browser_context.id.clone(), + browser_context.active_target_handle().map(|target_handle| { + BrowserTargetSlotProjection::new( + target_handle.clone(), + browser_context + .active_target + .runtime_slot + .page_residence_handle() + .clone(), + ) + }), + browser_context.background_targets.iter().map(|target| { + BrowserTargetSlotProjection::new( + target.target_handle().clone(), + target.runtime_slot().page_residence_handle().clone(), + ) + }), + ) + } + + pub(crate) fn validate_browser_target_topology_projection( + &self, + ) -> Result<(), TargetProjectionError> { + let browser_owner = self.browser_host_state.navigation_owner(); + let mut projected_target_ids = HashSet::new(); + let mut projected_target_count = 0; + for browser_context in self.browser_contexts() { + let browser_context_id = browser_context.id.as_str(); + let authoritative_active = + browser_owner.active_target_id_for_browser_context(browser_context_id); + let projected_active = browser_context.active_target_id(); + if authoritative_active != projected_active { + return Err(TargetProjectionError::PhysicalActiveTargetMismatch { + browser_context_id: browser_context_id.to_owned(), + authoritative: authoritative_active.map(str::to_owned), + projected: projected_active.map(str::to_owned), + }); + } + + let physical_count = usize::from(browser_context.has_active_target()) + + browser_context.background_targets.len(); + let authoritative_count = + browser_owner.browser_context_target_count(browser_context_id); + if authoritative_count != physical_count { + return Err(TargetProjectionError::PhysicalContextTargetCountMismatch { + browser_context_id: browser_context_id.to_owned(), + authoritative: authoritative_count, + projected: physical_count, + }); + } + + if let Some(target_handle) = browser_context.active_target_handle() { + Self::validate_physical_target_projection( + &browser_owner, + browser_context_id, + target_handle, + browser_context + .active_target + .runtime_slot + .page_residence_handle(), + )?; + if !projected_target_ids.insert(target_handle.target_id().to_owned()) { + return Err(TargetProjectionError::DuplicatePhysicalTarget( + target_handle.target_id().to_owned(), + )); + } + projected_target_count += 1; + } + for target in &browser_context.background_targets { + Self::validate_physical_target_projection( + &browser_owner, + browser_context_id, + target.target_handle(), + target.runtime_slot().page_residence_handle(), + )?; + if !projected_target_ids.insert(target.target_id().to_owned()) { + return Err(TargetProjectionError::DuplicatePhysicalTarget( + target.target_id().to_owned(), + )); + } + projected_target_count += 1; + } + } + + let authoritative_target_count = browser_owner.target_count(); + if authoritative_target_count != projected_target_count { + return Err(TargetProjectionError::PhysicalTargetCountMismatch { + authoritative: authoritative_target_count, + projected: projected_target_count, + }); + } + Ok(()) + } + + fn validate_physical_target_projection( + browser_owner: &BrowserNavigationOwner, + browser_context_id: &str, + target_handle: &BrowserTargetHandle, + page_residence: &BrowserPageResidenceHandle, + ) -> Result<(), TargetProjectionError> { + let target_id = target_handle.target_id(); + let authoritative_context = browser_owner.target_browser_context_id(target_id); + if authoritative_context != Some(browser_context_id) { + return Err(TargetProjectionError::PhysicalTargetContextMismatch { + target_id: target_id.to_owned(), + authoritative: authoritative_context.map(str::to_owned), + projected: browser_context_id.to_owned(), + }); + } + if !browser_owner.target_handle_is_current(target_handle) { + return Err(TargetProjectionError::PhysicalTargetHandleMismatch { + browser_context_id: browser_context_id.to_owned(), + target_id: target_id.to_owned(), + }); + } + let page_owner = BrowserPageOwnerKey::new(browser_context_id, target_id); + if !browser_owner.page_residence_handle_is_current(&page_owner, page_residence) { + return Err(TargetProjectionError::PhysicalPageResidenceMismatch { + browser_context_id: browser_context_id.to_owned(), + target_id: target_id.to_owned(), + }); + } + Ok(()) + } + + pub(crate) fn debug_assert_browser_target_topology_projection(&self) { + let validation = self.validate_browser_target_topology_projection(); + debug_assert!( + validation.is_ok(), + "Browser Core and physical Target topology diverged: {validation:?}" + ); + } +} diff --git a/moli-protocol/src/conn/browser_document_navigation.rs b/moli-protocol/src/conn/browser_document_navigation.rs new file mode 100644 index 0000000000..9482d6378e --- /dev/null +++ b/moli-protocol/src/conn/browser_document_navigation.rs @@ -0,0 +1,530 @@ +use moli_core::{ + browser_host::{ + BrowserNavigationFailure, BrowserNavigationTraceContext, BrowserNavigationTraceEvent, + BrowserNavigationTraceSource, BrowserPageOwnerKey, + }, + page::ScriptNetworkOutputItem, +}; +use moli_page_types::BrowserActionId; + +use crate::{ + devtools_runtime::DevToolsCommandContext, + domains::network::TargetNetworkBacklogPreparedDelivery, +}; + +use super::{ + CdpConnection, CommittedRendererDocumentBinding, DevToolsDocumentNavigationState, + DocumentNavigationToken, +}; + +/// Migration adapter from frontend/session routing to Browser Core's +/// authoritative cross-document request registry. +/// +/// A CDP session is resolved to a browser Target exactly once. Pending and +/// committed request identity then live in `BrowserNavigationOwner`; protocol +/// slots retain only renderer attachment and lifecycle projections. +impl CdpConnection { + fn document_navigation_owner_for_session( + &self, + session_id: Option<&str>, + ) -> Option { + self.target_page_owner_key_for_session(session_id) + } + + fn document_navigation_owner_for_target_id( + &self, + target_id: &str, + ) -> Option { + let browser_context = self.browser_contexts().find(|browser_context| { + browser_context.is_active_target(target_id) + || browser_context.background_target(target_id).is_some() + })?; + Some(BrowserPageOwnerKey::new( + browser_context.id.clone(), + target_id.to_owned(), + )) + } + + pub(crate) fn prepare_navigation_trace_context_for_session_owner( + &self, + session_id: Option<&str>, + origin: BrowserNavigationTraceSource, + source_document: Option, + ) -> Option { + if !moli_trace::browser_owner_trace_enabled() { + return None; + } + let owner = self.document_navigation_owner_for_session(session_id)?; + let context = self + .browser_host_state + .navigation_owner() + .owner_navigation_trace_context( + &owner, + BrowserActionId::allocate(), + origin, + source_document, + )?; + context.emit(BrowserNavigationTraceEvent::new( + "browser_action_published", + origin, + match origin { + BrowserNavigationTraceSource::FrontendCommand => "frontend-command", + BrowserNavigationTraceSource::RendererIntent => "renderer-output", + BrowserNavigationTraceSource::Network => "network", + BrowserNavigationTraceSource::Lifecycle => "lifecycle", + }, + "browser-owner-inbox", + )); + Some(context) + } + + pub(crate) fn document_navigation_trace_context( + &self, + token: &DocumentNavigationToken, + ) -> Option { + let owner = self.document_navigation_owner_for_target_id(token.target_id())?; + self.browser_host_state + .navigation_owner() + .document_navigation_trace_context(&owner, token) + } + + pub(crate) fn current_page_residence_for_document_navigation( + &self, + token: &DocumentNavigationToken, + ) -> Option { + let owner = self.document_navigation_owner_for_target_id(token.target_id())?; + if !self + .browser_host_state + .navigation_owner() + .accepts_committed_document_navigation(&owner, token) + { + return None; + } + self.browser_host_state + .navigation_owner() + .capture_page_residence(owner.browser_context_id(), owner.target_id()) + } + + pub(crate) fn allocate_frontend_projection_trace_sequence(&mut self) -> Option { + moli_trace::browser_owner_trace_enabled() + .then(|| self.scheduler_state.allocate_frontend_projection_sequence()) + } + + pub(crate) fn accepts_pending_document_navigation_token( + &self, + token: &DocumentNavigationToken, + ) -> bool { + let Some(owner) = self.document_navigation_owner_for_target_id(token.target_id()) else { + return false; + }; + self.browser_host_state + .navigation_owner() + .accepts_pending_document_navigation(&owner, token) + } + + #[cfg(test)] + pub(crate) fn has_pending_document_navigation_for_target_id(&self, target_id: &str) -> bool { + let Some(owner) = self.document_navigation_owner_for_target_id(target_id) else { + return false; + }; + self.browser_host_state + .navigation_owner() + .has_pending_document_navigation(&owner) + } + + pub fn has_pending_document_navigation_for_session_owner( + &self, + session_id: Option<&str>, + ) -> bool { + let Some(owner) = self.document_navigation_owner_for_session(session_id) else { + return false; + }; + self.browser_host_state + .navigation_owner() + .has_pending_document_navigation(&owner) + } + + fn document_navigation_state_for_session_owner( + &self, + session_id: Option<&str>, + ) -> DevToolsDocumentNavigationState { + if self + .document_navigation_owner_for_session(session_id) + .is_none() + { + return DevToolsDocumentNavigationState::Unavailable; + } + if self.has_pending_document_navigation_for_session_owner(session_id) { + return DevToolsDocumentNavigationState::PendingNavigation; + } + // The initial empty Document has a real loader identity before any + // cross-document request exists. Frame-tree lookup combines Browser + // Core's committed request with that protocol bootstrap metadata. + match self.target_session_owner_frame_tree_loader_id(session_id) { + Some(loader_id) => DevToolsDocumentNavigationState::Committed { loader_id }, + None => DevToolsDocumentNavigationState::AwaitingCommit, + } + } + + /// Resolves Document readiness through the exact target captured by a + /// protocol-neutral command context. + pub fn devtools_context_document_navigation_state( + &mut self, + context: &DevToolsCommandContext, + ) -> DevToolsDocumentNavigationState { + if let Some(target_id) = context.target_id.as_ref() { + let Some(route) = self.target_session_route_for_target_id(target_id.as_str()) else { + return DevToolsDocumentNavigationState::Unavailable; + }; + let mut route_scope = self.scoped_none_session_owner_route_override(route); + return route_scope + .conn_mut() + .document_navigation_state_for_session_owner(None); + } + self.document_navigation_state_for_session_owner( + context + .session_id + .as_ref() + .map(|session_id| session_id.as_str()), + ) + } + + pub(crate) fn accepts_pending_document_navigation_for_session_owner( + &self, + session_id: Option<&str>, + token: &DocumentNavigationToken, + ) -> bool { + let Some(owner) = self.document_navigation_owner_for_session(session_id) else { + return false; + }; + owner.target_id() == token.target_id() + && self + .browser_host_state + .navigation_owner() + .accepts_pending_document_navigation(&owner, token) + } + + pub(crate) fn accepts_document_body_completion_for_session_owner( + &self, + session_id: Option<&str>, + token: &DocumentNavigationToken, + ) -> bool { + let Some(owner) = self.document_navigation_owner_for_session(session_id) else { + return false; + }; + owner.target_id() == token.target_id() + && self + .browser_host_state + .navigation_owner() + .accepts_document_body_completion(&owner, token) + } + + pub(crate) fn accepts_committed_document_navigation_for_session_owner( + &self, + session_id: Option<&str>, + token: &DocumentNavigationToken, + ) -> bool { + let Some(owner) = self.document_navigation_owner_for_session(session_id) else { + return false; + }; + owner.target_id() == token.target_id() + && self + .browser_host_state + .navigation_owner() + .accepts_committed_document_navigation(&owner, token) + } + + pub(crate) fn ensure_document_accessible_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Result<(), String> { + if self.has_pending_document_navigation_for_session_owner(session_id) { + return Err("Navigation is changing the document".to_owned()); + } + Ok(()) + } + + pub(crate) fn current_document_loader_id_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Option { + let owner = self.document_navigation_owner_for_session(session_id)?; + self.browser_host_state + .navigation_owner() + .current_document_loader_id(&owner) + } + + pub(crate) fn committed_document_loader_id_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Option { + let owner = self.document_navigation_owner_for_session(session_id)?; + self.browser_host_state + .navigation_owner() + .committed_document_loader_id(&owner) + } + + pub(crate) fn loaded_page_mut_for_protocol_access( + &mut self, + session_id: Option<&str>, + ) -> Result { + self.ensure_document_accessible_for_session_owner(session_id)?; + self.loaded_page_mut_for_interruptible_protocol_access(session_id) + } + + /// Returns the exact Page that remains attached while a cross-Document + /// navigation is suspended. + /// + /// Only commands classified as renderer-interruptible at the parsed CDP + /// boundary may use this access path. Ordinary protocol commands must use + /// [`Self::loaded_page_mut_for_protocol_access`] so they wait for the + /// replacement attachment instead of entering the old renderer. + pub(crate) fn loaded_page_mut_for_interruptible_protocol_access( + &mut self, + session_id: Option<&str>, + ) -> Result { + self.runtime_session_owner_slot_mut(session_id)? + .loaded_page_mut() + .ok_or_else(|| "NoDocumentLoaded".to_owned()) + } + + #[cfg(test)] + pub(crate) fn start_document_navigation_for_session_owner( + &mut self, + session_id: Option<&str>, + loader_id: String, + ) -> Option { + let navigation = self + .start_document_navigation_for_session_owner_with_trace(session_id, loader_id, None)?; + if let Err(error) = self.take_navigation_admission_fact(&navigation) { + tracing::error!( + %error, + target_id = navigation.target_id(), + loader_id = navigation.loader_id(), + "test/embedding navigation admission lost its exact Browser fact" + ); + } + Some(navigation) + } + + pub(crate) fn start_document_navigation_for_session_owner_with_trace( + &mut self, + session_id: Option<&str>, + loader_id: String, + trace: Option, + ) -> Option { + let owner = self.document_navigation_owner_for_session(session_id)?; + // Validate the frontend projection before mutating Browser Core. Once + // accepted, the actor owns both mutations in this turn. + self.runtime_session_owner_slot(session_id).ok()?; + let token = self + .browser_host_state + .try_start_document_navigation_with_trace(&owner, loader_id, trace)?; + self.runtime_session_owner_slot_mut(session_id) + .expect("validated target runtime slot") + .begin_document_navigation_protocol_state(token.clone()); + Some(token) + } + + #[cfg(test)] + pub(crate) fn commit_document_navigation_for_session_owner_if_matches( + &mut self, + session_id: Option<&str>, + token: &DocumentNavigationToken, + ) { + let Some(owner) = self.document_navigation_owner_for_session(session_id) else { + return; + }; + if owner.target_id() == token.target_id() { + let _ = self + .browser_host_state + .commit_document_navigation_if_matches(&owner, token); + } + } + + pub(crate) fn fail_document_navigation_for_session_owner_if_matches( + &mut self, + session_id: Option<&str>, + token: &DocumentNavigationToken, + failure: BrowserNavigationFailure, + ) -> bool { + let Some(owner) = self.document_navigation_owner_for_session(session_id) else { + return false; + }; + if owner.target_id() != token.target_id() + || !self.browser_host_state.fail_document_navigation_if_matches( + &owner, + token, + failure.clone(), + ) + { + return false; + } + match self.take_navigation_failure_fact(token, &failure) { + Ok(_) => true, + Err(error) => { + tracing::error!( + %error, + target_id = token.target_id(), + loader_id = token.loader_id(), + "navigation terminal committed without an exact frontend Browser fact" + ); + false + } + } + } + + pub(crate) fn convert_document_navigation_to_download_for_session_owner_if_matches( + &mut self, + session_id: Option<&str>, + token: &DocumentNavigationToken, + ) -> bool { + let Some(owner) = self.document_navigation_owner_for_session(session_id) else { + return false; + }; + if owner.target_id() != token.target_id() + || !self + .browser_host_state + .convert_document_navigation_to_download_if_matches(&owner, token) + { + return false; + } + match self.take_navigation_download_fact(token) { + Ok(_) => true, + Err(error) => { + tracing::error!( + %error, + target_id = token.target_id(), + loader_id = token.loader_id(), + "download conversion committed without an exact frontend Browser fact" + ); + false + } + } + } + + /// Retires only the protocol/renderer tail projection after Browser Core + /// has already committed the exact navigation terminal. A delayed tail is + /// deliberately unable to clear or otherwise mutate a successor request. + pub(crate) fn clear_document_navigation_protocol_tail_for_session_owner_if_loader_matches( + &mut self, + session_id: Option<&str>, + loader_id: &str, + ) { + let _ = self + .runtime_session_owner_slot_mut(session_id) + .map(|slot| slot.clear_pending_renderer_page_if_loader_matches(loader_id)); + self.discard_uncommitted_main_document_resource_for_session_owner(session_id, loader_id); + } + + /// Returns a lifecycle projection only while its optional navigation token + /// still names Browser Core's exact committed request. + pub(crate) fn committed_renderer_document_binding_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Option<&CommittedRendererDocumentBinding> { + let owner = self.document_navigation_owner_for_session(session_id)?; + let binding = self + .runtime_session_owner_slot(session_id) + .ok()? + .committed_renderer_document_binding()?; + let is_current = binding.navigation.as_ref().is_none_or(|navigation| { + self.browser_host_state + .navigation_owner() + .accepts_committed_document_navigation(&owner, navigation) + }); + is_current.then_some(binding) + } + + pub(crate) fn renderer_document_binding_is_current_for_session_owner( + &self, + session_id: Option<&str>, + binding: &CommittedRendererDocumentBinding, + ) -> bool { + self.committed_renderer_document_binding_for_session_owner(session_id) == Some(binding) + } + + pub(crate) fn ingest_renderer_page_network_output_item_and_prepare_live_delivery_for_session_owner( + &mut self, + session_id: Option<&str>, + source_renderer_page: Option, + source_document: moli_core::RendererDocumentLifecycleIdentity, + item: &ScriptNetworkOutputItem, + ) -> Option { + // The exact renderer Page/Document pair below may name either the + // current Document or a replacement's retained network-only route. + // Requiring the current Browser navigation token here would discard + // the old Document's terminal facts as soon as its successor commits. + let primary_session_id = self.runtime_session_owner_primary_session_id(session_id); + let mut request_id_allocator = self.browser_host_state.network_artifacts(); + self.runtime_session_owner_slot_mut(session_id) + .ok() + .and_then(|slot| { + slot.ingest_renderer_network_output_item_and_prepare_live_delivery( + source_renderer_page, + source_document, + item, + session_id, + primary_session_id.as_deref(), + None, + &mut request_id_allocator, + ) + }) + } + + #[cfg(test)] + pub(crate) fn ingest_renderer_network_output_item_and_prepare_live_delivery_for_session_owner( + &mut self, + session_id: Option<&str>, + source_document: moli_core::RendererDocumentLifecycleIdentity, + item: &ScriptNetworkOutputItem, + ) -> Option { + self.ingest_renderer_page_network_output_item_and_prepare_live_delivery_for_session_owner( + session_id, + None, + source_document, + item, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clearing_protocol_projection_does_not_clear_browser_request_authority() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let token = conn + .start_document_navigation_for_session_owner(None, "LOADER-core-owned".to_owned()) + .expect("default target should start a navigation"); + + conn.runtime_session_owner_slot_mut(None) + .expect("default target runtime slot") + .clear_renderer_document_protocol_state(); + + assert!(conn.has_pending_document_navigation_for_session_owner(None)); + assert!(conn.accepts_pending_document_navigation_for_session_owner(None, &token)); + } + + #[test] + fn delayed_protocol_tail_cannot_clear_a_successor_browser_request() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let first = conn + .start_document_navigation_for_session_owner(None, "LOADER-first".to_owned()) + .expect("first navigation"); + let second = conn + .start_document_navigation_for_session_owner(None, "LOADER-second".to_owned()) + .expect("successor navigation"); + + conn.clear_document_navigation_protocol_tail_for_session_owner_if_loader_matches( + None, + first.loader_id(), + ); + + assert!(!conn.accepts_pending_document_navigation_for_session_owner(None, &first)); + assert!(conn.accepts_pending_document_navigation_for_session_owner(None, &second)); + } +} diff --git a/moli-protocol/src/conn/browser_document_page_builder.rs b/moli-protocol/src/conn/browser_document_page_builder.rs new file mode 100644 index 0000000000..7d867fb2ba --- /dev/null +++ b/moli-protocol/src/conn/browser_document_page_builder.rs @@ -0,0 +1,244 @@ +use moli_core::{ + browser_host::BrowserNavigationOwner, + page::RendererMainDocumentCommit, + runtime::{ + BuiltDocumentPage, CommittedDocumentResourceSource, ExternalRawDocumentBodyStream, + NavigationEngine, PageVmInitStage, PreparedDocumentPage, RendererPageReservationToken, + RendererReplyBoundary, RendererReservedServiceWorkerClient, + }, +}; +use url::Url; + +use super::TargetNavigationLoadInputs; + +/// Protocol-side adapter for document construction. Active work calls the +/// Browser Core owner facade; detached background jobs use their standalone +/// engine. The adapter never extracts an engine from `BrowserNavigationOwner`. +#[allow(clippy::too_many_arguments)] +pub(crate) trait BrowserDocumentPageBuilder { + async fn prepare_protocol_streaming_raw_page( + &mut self, + page_reservation: RendererPageReservationToken, + load_inputs: &TargetNavigationLoadInputs, + requested_url: Url, + final_url: Url, + redirected: bool, + redirect_count: usize, + response_status: u16, + response_headers: Vec<(String, String)>, + raw_body: ExternalRawDocumentBodyStream, + stage: PageVmInitStage, + reply_boundary: RendererReplyBoundary, + resource_source: CommittedDocumentResourceSource, + reserved_service_worker_client: Option, + main_document_commit: Option, + ) -> anyhow::Result; + + async fn prepare_protocol_document_page( + &mut self, + page_reservation: RendererPageReservationToken, + load_inputs: &TargetNavigationLoadInputs, + requested_url: Url, + final_url: Url, + redirected: bool, + redirect_count: usize, + response_status: u16, + response_headers: Vec<(String, String)>, + response_body: String, + resource_source: CommittedDocumentResourceSource, + main_document_commit: Option, + ) -> anyhow::Result; + + async fn build_protocol_html_page( + &mut self, + load_inputs: &TargetNavigationLoadInputs, + requested_url: Url, + final_url: Url, + redirected: bool, + redirect_count: usize, + response_status: u16, + response_headers: Vec<(String, String)>, + response_body: String, + main_document_commit: Option, + ) -> anyhow::Result; +} + +macro_rules! impl_browser_document_page_builder { + ($owner:ty, $prepare_streaming:ident, $prepare_document:ident, $build_html:ident) => { + impl BrowserDocumentPageBuilder for $owner { + async fn prepare_protocol_streaming_raw_page( + &mut self, + page_reservation: RendererPageReservationToken, + load_inputs: &TargetNavigationLoadInputs, + requested_url: Url, + final_url: Url, + redirected: bool, + redirect_count: usize, + response_status: u16, + response_headers: Vec<(String, String)>, + raw_body: ExternalRawDocumentBodyStream, + stage: PageVmInitStage, + reply_boundary: RendererReplyBoundary, + resource_source: CommittedDocumentResourceSource, + reserved_service_worker_client: Option, + main_document_commit: Option, + ) -> anyhow::Result { + let ( + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + ) = load_inputs.fetch_subresource_interception; + self.$prepare_streaming( + page_reservation, + load_inputs.page_storage_handles().into_navigation_storage(), + requested_url, + final_url.clone(), + load_inputs.navigation_initiator_url.clone(), + redirected, + redirect_count, + response_status, + response_headers, + raw_body, + load_inputs.document_start_scripts.clone(), + load_inputs.runtime_bindings.clone(), + load_inputs + .runtime_inspector_session_restore_snapshots + .clone(), + load_inputs.extra_http_headers.clone(), + load_inputs.locale_override.clone(), + load_inputs.timezone_override.clone(), + load_inputs.script_execution_disabled, + load_inputs.bypass_content_security_policy, + load_inputs.cpu_throttling_rate, + load_inputs.emulated_media.clone(), + load_inputs.viewport_surface, + load_inputs.network_offline, + load_inputs.blocked_url_patterns.clone(), + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + stage, + reply_boundary, + load_inputs.root_frame_id.clone(), + resource_source, + reserved_service_worker_client, + main_document_commit, + ) + .await + } + + async fn prepare_protocol_document_page( + &mut self, + page_reservation: RendererPageReservationToken, + load_inputs: &TargetNavigationLoadInputs, + requested_url: Url, + final_url: Url, + redirected: bool, + redirect_count: usize, + response_status: u16, + response_headers: Vec<(String, String)>, + response_body: String, + resource_source: CommittedDocumentResourceSource, + main_document_commit: Option, + ) -> anyhow::Result { + let ( + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + ) = load_inputs.fetch_subresource_interception; + self.$prepare_document( + page_reservation, + load_inputs.page_storage_handles().into_navigation_storage(), + requested_url, + final_url.clone(), + load_inputs.navigation_initiator_url.clone(), + redirected, + redirect_count, + response_status, + response_headers, + response_body, + load_inputs.document_start_scripts.clone(), + load_inputs.runtime_bindings.clone(), + load_inputs + .runtime_inspector_session_restore_snapshots + .clone(), + load_inputs.extra_http_headers.clone(), + load_inputs.locale_override.clone(), + load_inputs.timezone_override.clone(), + load_inputs.script_execution_disabled, + load_inputs.bypass_content_security_policy, + load_inputs.cpu_throttling_rate, + load_inputs.emulated_media.clone(), + load_inputs.viewport_surface, + load_inputs.network_offline, + load_inputs.blocked_url_patterns.clone(), + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + load_inputs.root_frame_id.clone(), + resource_source, + main_document_commit, + ) + .await + } + + async fn build_protocol_html_page( + &mut self, + load_inputs: &TargetNavigationLoadInputs, + requested_url: Url, + final_url: Url, + redirected: bool, + redirect_count: usize, + response_status: u16, + response_headers: Vec<(String, String)>, + response_body: String, + main_document_commit: Option, + ) -> anyhow::Result { + let ( + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + ) = load_inputs.fetch_subresource_interception; + self.$build_html( + load_inputs.page_storage_handles().into_navigation_storage(), + requested_url, + final_url.clone(), + load_inputs.navigation_initiator_url.clone(), + redirected, + redirect_count, + response_status, + response_headers, + response_body, + load_inputs.document_start_scripts.clone(), + load_inputs.runtime_bindings.clone(), + load_inputs + .runtime_inspector_session_restore_snapshots + .clone(), + load_inputs.extra_http_headers.clone(), + load_inputs.locale_override.clone(), + load_inputs.timezone_override.clone(), + load_inputs.script_execution_disabled, + load_inputs.bypass_content_security_policy, + load_inputs.cpu_throttling_rate, + load_inputs.emulated_media.clone(), + load_inputs.viewport_surface, + load_inputs.network_offline, + load_inputs.blocked_url_patterns.clone(), + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + load_inputs.root_frame_id.clone(), + main_document_commit, + ) + .await + } + } + }; +} + +impl_browser_document_page_builder!( + NavigationEngine, + prepare_streaming_raw_page_from_external_body_with_storage_and_inspector_session_restores_async, + prepare_document_page_from_response_with_storage_and_inspector_session_restores_async, + build_html_page_from_response_with_storage_and_inspector_session_restores_async +); +impl_browser_document_page_builder!( + BrowserNavigationOwner, + prepare_active_streaming_raw_page, + prepare_active_document_page_from_response, + build_active_html_page_from_response +); diff --git a/moli-protocol/src/conn/browser_fact_projection.rs b/moli-protocol/src/conn/browser_fact_projection.rs new file mode 100644 index 0000000000..6578b26c15 --- /dev/null +++ b/moli-protocol/src/conn/browser_fact_projection.rs @@ -0,0 +1,1856 @@ +use std::{ + collections::{BTreeMap, VecDeque}, + fmt, + sync::Arc, +}; + +use moli_core::{ + browser_host::{ + BrowserDocumentNavigation, BrowserFact, BrowserFactEnvelope, BrowserFactSequence, + BrowserFactSubscriber, BrowserFactTryReceiveError, BrowserNavigationFailure, + BrowserTargetMetadataTransition, BrowserTargetTermination, BrowserTargetTerminationKind, + PageResidenceIdentity, + }, + page::{ + RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, + RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, + RendererLifecycleEventStamp, + }, +}; + +use super::{CdpConnection, CommittedRendererDocumentBinding, DocumentNavigationToken}; + +// Keep frontend-local pending projection bounded by the same order of +// magnitude as the Browser journal. This is not a second fact journal: it +// retains only facts that have not yet met their frontend projection binding. +// Payload ownership remains in the Browser journal's shared envelopes. +const MAX_PENDING_BROWSER_FACTS: usize = 1_024; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserFactProjectionError { + SubscriberLagged { + skipped: u64, + }, + SubscriberClosed, + NonMonotonicSequence { + previous: BrowserFactSequence, + received: BrowserFactSequence, + }, + WakeSequenceNotObserved { + wake: BrowserFactSequence, + observed: Option, + }, + PendingBrowserFactCapacityExceeded { + capacity: usize, + }, + MissingTargetCreationFact { + browser_context_id: String, + target_id: String, + page: Box, + }, + MissingNavigationTargetMetadataFact { + navigation: BrowserDocumentNavigation, + page: Box, + }, + MissingDocumentTitleMetadataFact { + page: Box, + title: String, + }, + MissingNavigationAdmissionFact { + navigation: BrowserDocumentNavigation, + }, + MissingNavigationFailureFact { + navigation: BrowserDocumentNavigation, + failure: BrowserNavigationFailure, + }, + MissingNavigationDownloadFact { + navigation: BrowserDocumentNavigation, + }, + MissingNavigationCommitFact { + navigation: BrowserDocumentNavigation, + page: PageResidenceIdentity, + }, + MissingTargetTerminationFact { + target_id: String, + previous_page: Box, + terminal_page: Box, + kind: BrowserTargetTerminationKind, + }, + NoCurrentPageForDocumentLifecycle { + document: RendererDocumentLifecycleIdentity, + milestone: RendererDocumentLifecycleMilestone, + stamp: RendererLifecycleEventStamp, + }, + MissingDocumentLifecycleFact { + page: PageResidenceIdentity, + document: RendererDocumentLifecycleIdentity, + milestone: RendererDocumentLifecycleMilestone, + stamp: RendererLifecycleEventStamp, + }, + ConflictingDocumentLifecycleCausalLink { + sequence: BrowserFactSequence, + }, + MismatchedDocumentLifecycleCausalLink { + sequence: BrowserFactSequence, + page: PageResidenceIdentity, + document: RendererDocumentLifecycleIdentity, + }, +} + +impl fmt::Display for BrowserFactProjectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SubscriberLagged { skipped } => { + write!( + formatter, + "Browser fact projector lagged by {skipped} facts" + ) + } + Self::SubscriberClosed => formatter.write_str("Browser fact projector closed"), + Self::NonMonotonicSequence { previous, received } => write!( + formatter, + "Browser fact sequence moved from {} to non-monotonic {}", + previous.get(), + received.get() + ), + Self::WakeSequenceNotObserved { wake, observed } => write!( + formatter, + "Browser fact wake reached sequence {} but the frontend cursor reached only {}", + wake.get(), + observed.map_or(0, BrowserFactSequence::get) + ), + Self::PendingBrowserFactCapacityExceeded { capacity } => write!( + formatter, + "Browser fact projector exceeded its {capacity}-fact pending projection window" + ), + Self::MissingTargetCreationFact { + browser_context_id, + target_id, + page, + } => write!( + formatter, + "Target {target_id:?} in BrowserContext {browser_context_id:?} at Page {:?} has no exact Browser creation fact", + page + ), + Self::MissingNavigationTargetMetadataFact { navigation, page } => write!( + formatter, + "committed navigation {navigation:?} for Page {page:?} has no exact Target metadata Browser fact" + ), + Self::MissingDocumentTitleMetadataFact { page, title } => write!( + formatter, + "renderer title {title:?} for Page {page:?} has no exact Target metadata Browser fact" + ), + Self::MissingNavigationAdmissionFact { navigation } => write!( + formatter, + "accepted navigation {:?} has no exact Browser fact", + navigation + ), + Self::MissingNavigationFailureFact { + navigation, + failure, + } => write!( + formatter, + "failed navigation {:?} with terminal {:?} has no exact Browser fact", + navigation, failure + ), + Self::MissingNavigationDownloadFact { navigation } => write!( + formatter, + "download navigation {:?} has no exact Browser fact", + navigation + ), + Self::MissingNavigationCommitFact { navigation, page } => write!( + formatter, + "committed navigation {:?} for Page {:?} has no exact Browser fact", + navigation, page + ), + Self::MissingTargetTerminationFact { + target_id, + previous_page, + terminal_page, + kind, + } => write!( + formatter, + "Target {target_id:?} {kind:?} from Page {:?} to terminal Page {:?} has no exact Browser fact", + previous_page, terminal_page + ), + Self::NoCurrentPageForDocumentLifecycle { + document, + milestone, + stamp, + } => write!( + formatter, + "protocol-visible renderer lifecycle {:?} at sequence {} for Document {:?} has no current Browser Page", + milestone, stamp.sequence, document + ), + Self::MissingDocumentLifecycleFact { + page, + document, + milestone, + stamp, + } => write!( + formatter, + "protocol-visible renderer lifecycle {:?} at sequence {} for Page {:?}, Document {:?} has no exact Browser fact", + milestone, stamp.sequence, page, document + ), + Self::ConflictingDocumentLifecycleCausalLink { sequence } => write!( + formatter, + "Browser lifecycle fact {} was linked to conflicting frontend bindings", + sequence.get() + ), + Self::MismatchedDocumentLifecycleCausalLink { + sequence, + page, + document, + } => write!( + formatter, + "Browser lifecycle fact {} for Page {:?}, Document {:?} does not match its frozen frontend binding", + sequence.get(), + page, + document + ), + } + } +} + +impl std::error::Error for BrowserFactProjectionError {} + +#[derive(Clone, Debug)] +pub(crate) struct BrowserDocumentLifecycleFactProjection { + envelope: Arc, + binding: CommittedRendererDocumentBinding, +} + +impl BrowserDocumentLifecycleFactProjection { + pub(crate) fn envelope(&self) -> &BrowserFactEnvelope { + self.envelope.as_ref() + } + + pub(crate) fn binding(&self) -> &CommittedRendererDocumentBinding { + &self.binding + } + + pub(crate) fn reached( + &self, + ) -> Option<( + RendererDocumentLifecycleIdentity, + RendererDocumentLifecycleMilestone, + RendererLifecycleEventStamp, + )> { + let BrowserFact::DocumentLifecycleReached { + document, + milestone, + stamp, + } = self.envelope.fact() + else { + return None; + }; + Some((*document, *milestone, *stamp)) + } +} + +#[derive(Debug)] +struct PendingBrowserFact { + envelope: Arc, + document_binding: Option, +} + +/// Exact Browser occurrence authorizing one live top-level Target creation +/// projection. A single fact may fan out to CDP page/tab events and the typed +/// automation sidecar, but it can be claimed only once by this frontend. +#[derive(Clone, Debug)] +pub(crate) struct BrowserTargetCreatedFactProjection { + envelope: Arc, +} + +impl BrowserTargetCreatedFactProjection { + pub(crate) fn envelope(&self) -> &BrowserFactEnvelope { + self.envelope.as_ref() + } + + pub(crate) fn matches_residence_instance(&self, current: &PageResidenceIdentity) -> bool { + self.envelope + .page_residence() + .same_residence_instance(current) + } +} + +/// Exact Browser occurrence authorizing one top-level Target metadata +/// projection. CDP attachment/discovery state is joined only after this fact +/// has been claimed by the frontend cursor. +#[derive(Clone, Debug)] +pub(crate) struct BrowserTargetMetadataFactProjection { + envelope: Arc, +} + +impl BrowserTargetMetadataFactProjection { + pub(crate) fn envelope(&self) -> &BrowserFactEnvelope { + self.envelope.as_ref() + } + + pub(crate) fn transition(&self) -> &BrowserTargetMetadataTransition { + let BrowserFact::TargetMetadataChanged { transition } = self.envelope.fact() else { + unreachable!("Target metadata projection must wrap its claimed fact") + }; + transition + } +} + +/// Exact Browser fact authorizing one committed cross-Document projection. +/// +/// The renderer commit supplies URL/security data later, but it cannot emit a +/// top-level commit unless this occurrence was observed from the Browser +/// journal. +#[derive(Clone, Debug)] +pub(crate) struct BrowserNavigationCommitFactProjection { + committed: Arc, +} + +impl BrowserNavigationCommitFactProjection { + pub(crate) fn sequence(&self) -> BrowserFactSequence { + self.committed.sequence() + } +} + +/// Exact Browser fact authorizing one Target terminal projection. +#[derive(Clone, Debug)] +pub(crate) struct BrowserTargetTerminationFactProjection { + terminal: Arc, +} + +impl BrowserTargetTerminationFactProjection { + pub(crate) fn envelope(&self) -> &BrowserFactEnvelope { + self.terminal.as_ref() + } +} + +/// One CDP/frontend cursor over the protocol-neutral Browser fact journal. +/// +/// The cursor never publishes Browser state and never waits inside a Browser +/// Owner turn. Renderer lifecycle records still release the existing command +/// response visibility barrier, but they cannot authorize a DCL/load event +/// unless this projector observed the matching immutable Browser fact. +pub(crate) struct CdpBrowserFactProjector { + subscriber: Option, + pending_facts: VecDeque, + lifecycle_causal_links: BTreeMap, + last_observed_sequence: Option, + #[cfg(test)] + last_projected_sequence: Option, + terminal_error: Option, +} + +impl CdpBrowserFactProjector { + pub(crate) fn new(subscriber: BrowserFactSubscriber) -> Self { + Self { + subscriber: Some(subscriber), + pending_facts: VecDeque::new(), + lifecycle_causal_links: BTreeMap::new(), + last_observed_sequence: None, + #[cfg(test)] + last_projected_sequence: None, + terminal_error: None, + } + } + + /// Freezes the frontend attachment selected by the renderer ingress for + /// each exact lifecycle fact without advancing this frontend's journal + /// cursor. + /// + /// The Browser fact remains the occurrence authority. This causal link is + /// only the protocol-neutral fact sequence to frontend projection binding + /// join required later to preserve loader/session attribution. A separate + /// application fact wake may therefore advance the cursor before the + /// visible renderer record is projected. + pub(crate) fn register_document_lifecycle_causal_links( + &mut self, + page: &PageResidenceIdentity, + binding: &CommittedRendererDocumentBinding, + published: &[Arc], + ) -> Result<(), BrowserFactProjectionError> { + if let Some(error) = self.terminal_error.as_ref() { + return Err(error.clone()); + } + + for envelope in published { + let BrowserFact::DocumentLifecycleReached { document, .. } = envelope.fact() else { + continue; + }; + if !lifecycle_fact_matches_binding(envelope, Some(page), binding) { + return Err(self.fail( + BrowserFactProjectionError::MismatchedDocumentLifecycleCausalLink { + sequence: envelope.sequence(), + page: page.clone(), + document: *document, + }, + )); + } + let sequence = envelope.sequence(); + if let Some(pending) = self + .pending_facts + .iter_mut() + .find(|pending| pending.envelope.sequence() == sequence) + { + match pending.document_binding.as_ref() { + Some(previous) if previous != binding => { + return Err(self.fail( + BrowserFactProjectionError::ConflictingDocumentLifecycleCausalLink { + sequence, + }, + )); + } + Some(_) => {} + None => pending.document_binding = Some(binding.clone()), + } + continue; + } + if let Some(previous) = self.lifecycle_causal_links.get(&sequence) { + if previous != binding { + return Err(self.fail( + BrowserFactProjectionError::ConflictingDocumentLifecycleCausalLink { + sequence, + }, + )); + } + continue; + } + self.lifecycle_causal_links + .insert(sequence, binding.clone()); + } + + // The Browser journal retains at most this many facts. Causal links + // older than its latest reachable window can no longer be delivered + // without the subscriber first reporting typed lag, so retaining them + // would only form a second unbounded queue. + if let Some(latest) = published.last().map(|envelope| envelope.sequence()) { + let retained_floor = latest + .get() + .saturating_sub((MAX_PENDING_BROWSER_FACTS as u64).saturating_sub(1)); + self.lifecycle_causal_links + .retain(|sequence, _| sequence.get() >= retained_floor); + } + Ok(()) + } + + pub(crate) fn capture_available(&mut self) -> Result<(), BrowserFactProjectionError> { + if let Some(error) = self.terminal_error.as_ref() { + return Err(error.clone()); + } + + loop { + let received = match self.subscriber.as_mut() { + Some(subscriber) => subscriber.try_recv(), + None => return Err(self.fail(BrowserFactProjectionError::SubscriberClosed)), + }; + match received { + Ok(envelope) => self.observe(envelope)?, + Err(BrowserFactTryReceiveError::Empty) => break, + Err(BrowserFactTryReceiveError::Lagged { skipped }) => { + return Err(self.fail(BrowserFactProjectionError::SubscriberLagged { skipped })); + } + Err(BrowserFactTryReceiveError::Closed) => { + return Err(self.fail(BrowserFactProjectionError::SubscriberClosed)); + } + } + } + Ok(()) + } + + pub(crate) fn capture_wake( + &mut self, + through: BrowserFactSequence, + ) -> Result<(), BrowserFactProjectionError> { + self.capture_available()?; + if self + .last_observed_sequence + .is_none_or(|observed| observed < through) + { + return Err( + self.fail(BrowserFactProjectionError::WakeSequenceNotObserved { + wake: through, + observed: self.last_observed_sequence, + }), + ); + } + Ok(()) + } + + /// Claims the unique occurrence fact for one committed top-level Target. + pub(crate) fn take_target_created_fact( + &mut self, + page: &PageResidenceIdentity, + ) -> Result { + self.capture_available()?; + let position = self.pending_facts.iter().position(|pending| { + pending.envelope.browser_context_id().as_str() == page.browser_context_id() + && pending.envelope.target_id().as_str() == page.target_id().unwrap_or_default() + && pending.envelope.page_residence() == page + && matches!(pending.envelope.fact(), BrowserFact::TargetCreated) + }); + let Some(position) = position else { + return Err( + self.fail(BrowserFactProjectionError::MissingTargetCreationFact { + browser_context_id: page.browser_context_id().to_owned(), + target_id: page.target_id().unwrap_or_default().to_owned(), + page: Box::new(page.clone()), + }), + ); + }; + let Some(created) = self.pending_facts.remove(position) else { + return Err( + self.fail(BrowserFactProjectionError::MissingTargetCreationFact { + browser_context_id: page.browser_context_id().to_owned(), + target_id: page.target_id().unwrap_or_default().to_owned(), + page: Box::new(page.clone()), + }), + ); + }; + self.record_projected(created.envelope.sequence()); + Ok(BrowserTargetCreatedFactProjection { + envelope: created.envelope, + }) + } + + pub(crate) fn take_navigation_target_metadata_changed_fact( + &mut self, + navigation: &BrowserDocumentNavigation, + page: &PageResidenceIdentity, + ) -> Result { + self.capture_available()?; + let position = self.pending_facts.iter().position(|pending| { + pending.envelope.page_residence() == page + && matches!( + pending.envelope.fact(), + BrowserFact::TargetMetadataChanged { + transition: observed, + } if observed.navigation() == Some(navigation) + ) + }); + let Some(position) = position else { + return Err(self.fail( + BrowserFactProjectionError::MissingNavigationTargetMetadataFact { + navigation: navigation.clone(), + page: Box::new(page.clone()), + }, + )); + }; + let Some(changed) = self.pending_facts.remove(position) else { + return Err(self.fail( + BrowserFactProjectionError::MissingNavigationTargetMetadataFact { + navigation: navigation.clone(), + page: Box::new(page.clone()), + }, + )); + }; + self.record_projected(changed.envelope.sequence()); + Ok(BrowserTargetMetadataFactProjection { + envelope: changed.envelope, + }) + } + + pub(crate) fn take_document_title_target_metadata_changed_fact( + &mut self, + page: &PageResidenceIdentity, + title: &str, + ) -> Result { + self.capture_available()?; + let position = self.pending_facts.iter().position(|pending| { + pending.envelope.page_residence() == page + && matches!( + pending.envelope.fact(), + BrowserFact::TargetMetadataChanged { transition } + if transition.is_document_title_change() && transition.title() == title + ) + }); + let Some(position) = position else { + return Err(self.fail( + BrowserFactProjectionError::MissingDocumentTitleMetadataFact { + page: Box::new(page.clone()), + title: title.to_owned(), + }, + )); + }; + let Some(changed) = self.pending_facts.remove(position) else { + return Err(self.fail( + BrowserFactProjectionError::MissingDocumentTitleMetadataFact { + page: Box::new(page.clone()), + title: title.to_owned(), + }, + )); + }; + self.record_projected(changed.envelope.sequence()); + Ok(BrowserTargetMetadataFactProjection { + envelope: changed.envelope, + }) + } + + /// Claims the self-contained occurrence produced by one accepted request. + pub(crate) fn take_navigation_admission_fact( + &mut self, + navigation: &BrowserDocumentNavigation, + ) -> Result, BrowserFactProjectionError> { + self.capture_available()?; + let Some(position) = self.pending_facts.iter().position(|pending| { + matches!( + pending.envelope.fact(), + BrowserFact::NavigationAccepted { + navigation: accepted, + .. + } if accepted == navigation + ) + }) else { + return Err( + self.fail(BrowserFactProjectionError::MissingNavigationAdmissionFact { + navigation: navigation.clone(), + }), + ); + }; + let Some(accepted) = self.pending_facts.remove(position) else { + return Err( + self.fail(BrowserFactProjectionError::MissingNavigationAdmissionFact { + navigation: navigation.clone(), + }), + ); + }; + self.record_projected(accepted.envelope.sequence()); + Ok(accepted.envelope) + } + + pub(crate) fn take_navigation_failure_fact( + &mut self, + navigation: &BrowserDocumentNavigation, + failure: &BrowserNavigationFailure, + ) -> Result, BrowserFactProjectionError> { + self.capture_available()?; + let position = self.pending_facts.iter().position(|pending| { + matches!( + pending.envelope.fact(), + BrowserFact::NavigationFailed { + navigation: failed_navigation, + failure: observed_failure, + .. + } if failed_navigation == navigation && observed_failure == failure + ) + }); + let Some(position) = position else { + return Err( + self.fail(BrowserFactProjectionError::MissingNavigationFailureFact { + navigation: navigation.clone(), + failure: failure.clone(), + }), + ); + }; + let Some(failed) = self.pending_facts.remove(position) else { + return Err( + self.fail(BrowserFactProjectionError::MissingNavigationFailureFact { + navigation: navigation.clone(), + failure: failure.clone(), + }), + ); + }; + self.record_projected(failed.envelope.sequence()); + Ok(failed.envelope) + } + + pub(crate) fn take_navigation_download_fact( + &mut self, + navigation: &BrowserDocumentNavigation, + ) -> Result, BrowserFactProjectionError> { + self.capture_available()?; + let position = self.pending_facts.iter().position(|pending| { + matches!( + pending.envelope.fact(), + BrowserFact::NavigationConvertedToDownload { + navigation: downloaded, + } if downloaded == navigation + ) + }); + let Some(position) = position else { + return Err( + self.fail(BrowserFactProjectionError::MissingNavigationDownloadFact { + navigation: navigation.clone(), + }), + ); + }; + let Some(downloaded) = self.pending_facts.remove(position) else { + return Err( + self.fail(BrowserFactProjectionError::MissingNavigationDownloadFact { + navigation: navigation.clone(), + }), + ); + }; + self.record_projected(downloaded.envelope.sequence()); + Ok(downloaded.envelope) + } + + /// Claims the atomic navigation-outcome/Page-topology fact before a + /// renderer commit is allowed to release the existing CDP event shape. + pub(crate) fn take_navigation_commit_fact( + &mut self, + navigation: &BrowserDocumentNavigation, + page: &PageResidenceIdentity, + ) -> Result { + self.capture_available()?; + let position = self.pending_facts.iter().position(|pending| { + pending.envelope.page_residence() == page + && matches!( + pending.envelope.fact(), + BrowserFact::NavigationCommitted { + navigation: committed, + .. + } if committed == navigation + ) + }); + let Some(position) = position else { + return Err( + self.fail(BrowserFactProjectionError::MissingNavigationCommitFact { + navigation: navigation.clone(), + page: page.clone(), + }), + ); + }; + let Some(committed) = self.pending_facts.remove(position) else { + return Err( + self.fail(BrowserFactProjectionError::MissingNavigationCommitFact { + navigation: navigation.clone(), + page: page.clone(), + }), + ); + }; + self.record_projected(committed.envelope.sequence()); + Ok(BrowserNavigationCommitFactProjection { + committed: committed.envelope, + }) + } + + /// Claims one self-contained Target terminal occurrence. + pub(crate) fn take_target_termination_fact( + &mut self, + termination: &BrowserTargetTermination, + ) -> Result { + self.capture_available()?; + let terminal_position = self.pending_facts.iter().position(|pending| { + pending.envelope.target_id().as_str() == termination.owner().target_id() + && pending.envelope.page_residence() == termination.terminal_page() + && match (termination.kind(), pending.envelope.fact()) { + ( + BrowserTargetTerminationKind::Crash, + BrowserFact::TargetCrashed { previous_page, .. }, + ) + | ( + BrowserTargetTerminationKind::Close, + BrowserFact::TargetClosed { previous_page, .. }, + ) => previous_page == termination.previous_page(), + _ => false, + } + }); + let Some(terminal_position) = terminal_position else { + return Err( + self.fail(BrowserFactProjectionError::MissingTargetTerminationFact { + target_id: termination.owner().target_id().to_owned(), + previous_page: Box::new(termination.previous_page().clone()), + terminal_page: Box::new(termination.terminal_page().clone()), + kind: termination.kind(), + }), + ); + }; + let Some(terminal) = self.pending_facts.remove(terminal_position) else { + return Err( + self.fail(BrowserFactProjectionError::MissingTargetTerminationFact { + target_id: termination.owner().target_id().to_owned(), + previous_page: Box::new(termination.previous_page().clone()), + terminal_page: Box::new(termination.terminal_page().clone()), + kind: termination.kind(), + }), + ); + }; + self.record_projected(terminal.envelope.sequence()); + Ok(BrowserTargetTerminationFactProjection { + terminal: terminal.envelope, + }) + } + + pub(crate) fn take_visible_document_lifecycle_facts( + &mut self, + page: Option<&PageResidenceIdentity>, + binding: &CommittedRendererDocumentBinding, + visible_events: &[RendererDocumentLifecycleEvent], + ) -> Result, BrowserFactProjectionError> { + let Some((milestone, stamp)) = visible_events.iter().find_map(|event| { + if event.frame != binding.renderer_frame || event.document != binding.renderer_document + { + return None; + } + let RendererDocumentLifecycleEventKind::Milestone(milestone) = event.kind else { + return None; + }; + Some(( + milestone, + RendererLifecycleEventStamp { + sequence: event.sequence, + timestamp_micros: event.timestamp_micros, + }, + )) + }) else { + return Ok(Vec::new()); + }; + let Some(page) = page else { + return Err(self.fail( + BrowserFactProjectionError::NoCurrentPageForDocumentLifecycle { + document: binding.renderer_document_identity(), + milestone, + stamp, + }, + )); + }; + self.capture_available()?; + + let mut projected = Vec::new(); + for event in visible_events { + if event.frame != binding.renderer_frame || event.document != binding.renderer_document + { + continue; + } + let RendererDocumentLifecycleEventKind::Milestone(milestone) = event.kind else { + continue; + }; + let document = RendererDocumentLifecycleIdentity { + frame: event.frame, + document: event.document, + epoch: event.epoch, + }; + let stamp = RendererLifecycleEventStamp { + sequence: event.sequence, + timestamp_micros: event.timestamp_micros, + }; + let position = self.pending_facts.iter().position(|pending| { + pending.envelope.page_residence() == page + && pending.document_binding.as_ref() == Some(binding) + && matches!( + pending.envelope.fact(), + BrowserFact::DocumentLifecycleReached { + document: fact_document, + milestone: fact_milestone, + stamp: fact_stamp, + } if *fact_document == document + && *fact_milestone == milestone + && *fact_stamp == stamp + ) + }); + let Some(position) = position else { + return Err( + self.fail(BrowserFactProjectionError::MissingDocumentLifecycleFact { + page: page.clone(), + document, + milestone, + stamp, + }), + ); + }; + let requested_sequence = self.pending_facts[position].envelope.sequence(); + let Some(pending) = self.pending_facts.remove(position) else { + return Err( + self.fail(BrowserFactProjectionError::MissingDocumentLifecycleFact { + page: page.clone(), + document, + milestone, + stamp, + }), + ); + }; + // A frontend may begin observing between DCL and load. Chromium's + // Page.enable does not replay Page.domContentEventFired, so a + // later exact load is allowed to cross an earlier lifecycle fact + // that this frontend never selected. Retire those older facts now + // so the bounded queue cannot leak them or project them backwards. + self.pending_facts.retain(|candidate| { + !matches!( + candidate.envelope.fact(), + BrowserFact::DocumentLifecycleReached { .. } + ) || candidate.envelope.page_residence() != page + || candidate.document_binding.as_ref() != Some(binding) + || candidate.envelope.sequence() >= requested_sequence + }); + let Some(frozen_binding) = pending.document_binding else { + return Err( + self.fail(BrowserFactProjectionError::MissingDocumentLifecycleFact { + page: page.clone(), + document, + milestone, + stamp, + }), + ); + }; + self.record_projected(pending.envelope.sequence()); + projected.push(BrowserDocumentLifecycleFactProjection { + envelope: pending.envelope, + binding: frozen_binding, + }); + } + Ok(projected) + } + + fn observe( + &mut self, + envelope: Arc, + ) -> Result<(), BrowserFactProjectionError> { + if let Some(previous) = self.last_observed_sequence + && envelope.sequence() <= previous + { + return Err(self.fail(BrowserFactProjectionError::NonMonotonicSequence { + previous, + received: envelope.sequence(), + })); + } + self.last_observed_sequence = Some(envelope.sequence()); + + // Document termination is consumed by the independent high-level wait + // subscriber and has no CDP event shape. Advancing the common cursor is + // sufficient; retaining it would leak one pending frontend item per + // replacement. + if matches!( + envelope.fact(), + BrowserFact::DocumentLifecycleTerminated { .. } + ) { + return Ok(()); + } + if let Some(retired_page) = retired_page_residence(envelope.fact()) { + self.pending_facts + .retain(|pending| match pending.envelope.fact() { + BrowserFact::TargetCreated => !pending + .envelope + .page_residence() + .same_residence_instance(retired_page), + BrowserFact::TargetMetadataChanged { .. } + | BrowserFact::DocumentLifecycleReached { .. } => { + pending.envelope.page_residence() != retired_page + } + _ => true, + }); + } + if self.pending_facts.len() == MAX_PENDING_BROWSER_FACTS { + return Err(self.fail( + BrowserFactProjectionError::PendingBrowserFactCapacityExceeded { + capacity: MAX_PENDING_BROWSER_FACTS, + }, + )); + } + let document_binding = matches!( + envelope.fact(), + BrowserFact::DocumentLifecycleReached { .. } + ) + .then(|| self.lifecycle_causal_links.remove(&envelope.sequence())) + .flatten(); + self.pending_facts.push_back(PendingBrowserFact { + envelope, + document_binding, + }); + Ok(()) + } + + fn record_projected(&mut self, sequence: BrowserFactSequence) { + #[cfg(test)] + { + self.last_projected_sequence = Some( + self.last_projected_sequence + .map_or(sequence, |previous| previous.max(sequence)), + ); + } + #[cfg(not(test))] + let _ = sequence; + } + + fn fail(&mut self, error: BrowserFactProjectionError) -> BrowserFactProjectionError { + self.subscriber = None; + self.pending_facts.clear(); + self.lifecycle_causal_links.clear(); + self.terminal_error = Some(error.clone()); + error + } + + #[cfg(test)] + pub(crate) fn terminal_error(&self) -> Option<&BrowserFactProjectionError> { + self.terminal_error.as_ref() + } + + #[cfg(test)] + pub(crate) fn pending_document_lifecycle_fact_count(&self) -> usize { + self.pending_facts + .iter() + .filter(|pending| { + matches!( + pending.envelope.fact(), + BrowserFact::DocumentLifecycleReached { .. } + ) + }) + .count() + } + + #[cfg(test)] + pub(crate) fn pending_fact_count(&self) -> usize { + self.pending_facts.len() + } + + #[cfg(test)] + pub(crate) fn last_projected_sequence(&self) -> Option { + self.last_projected_sequence + } +} + +/// Frontend-facing Browser fact boundary for one connection. +/// +/// `CdpConnection` delegates here instead of exposing its journal cursor to +/// Page, Target, BiDi, or scheduler domains. Core publication and frontend +/// projection remain distinct even while both residences are fields of the +/// migration-period connection object. +impl CdpConnection { + pub(crate) fn record_authoritative_renderer_document_lifecycle_facts( + &mut self, + page: Option<&PageResidenceIdentity>, + binding: Option<&CommittedRendererDocumentBinding>, + events: &[RendererDocumentLifecycleEvent], + ) { + let lifecycle_fact_count = events + .iter() + .filter(|event| { + matches!( + event.kind, + RendererDocumentLifecycleEventKind::Milestone(_) + | RendererDocumentLifecycleEventKind::Terminated { .. } + ) + }) + .count(); + if events.is_empty() { + return; + } + let Some(page) = page else { + tracing::warn!( + lifecycle_fact_count, + "accepted renderer lifecycle facts have no exact Browser Page residence" + ); + return; + }; + let published = match self + .browser_host_state + .record_document_lifecycle_facts(page, events) + { + Ok(published) => { + tracing::trace!( + browser_context_id = page.browser_context_id(), + target_id = ?page.target_id(), + page_residence_generation = page.loaded_page_generation(), + fact_count = published.len(), + "published exact renderer lifecycle Browser facts" + ); + published + } + Err(error) => { + tracing::warn!( + %error, + browser_context_id = page.browser_context_id(), + target_id = ?page.target_id(), + page_residence_generation = page.loaded_page_generation(), + lifecycle_fact_count, + "rejected renderer lifecycle Browser facts" + ); + return; + } + }; + let Some(binding) = binding else { + return; + }; + if let Err(error) = self + .browser_fact_projector + .register_document_lifecycle_causal_links(page, binding, &published) + { + tracing::error!( + %error, + browser_context_id = page.browser_context_id(), + target_id = ?page.target_id(), + page_residence_generation = page.loaded_page_generation(), + "frontend Browser fact projector rejected a lifecycle causal link" + ); + } + } + + /// Creates a payload-free wake subscription for the application owner + /// loop. The connection-local projector retains the independent bounded + /// fact cursor; this wake can be dropped or coalesced without changing + /// Browser Owner progress. + pub fn subscribe_browser_fact_wake( + &self, + ) -> moli_core::browser_host::BrowserFactWakeSubscriber { + self.browser_host_state + .navigation_owner() + .subscribe_browser_fact_wake() + } + + /// Advances the one frontend fact cursor through a coalesced application + /// wake. Fact-family projection claims below consume that same cursor's + /// immutable pending window; domains never subscribe to the journal. + pub fn capture_browser_fact_wake( + &mut self, + through: BrowserFactSequence, + ) -> Result<(), BrowserFactProjectionError> { + self.browser_fact_projector.capture_wake(through) + } + + pub(crate) fn take_target_created_fact( + &mut self, + page: &PageResidenceIdentity, + ) -> Result { + self.browser_fact_projector.take_target_created_fact(page) + } + + pub(crate) fn take_navigation_target_metadata_changed_fact( + &mut self, + navigation: &DocumentNavigationToken, + page: &PageResidenceIdentity, + ) -> Result { + self.browser_fact_projector + .take_navigation_target_metadata_changed_fact(navigation, page) + } + + pub(crate) fn take_document_title_target_metadata_changed_fact( + &mut self, + page: &PageResidenceIdentity, + title: &str, + ) -> Result { + self.browser_fact_projector + .take_document_title_target_metadata_changed_fact(page, title) + } + + /// Revalidates that a delayed frontend creation projection still names + /// the same Target Page-slot instance. Initial Document generation changes + /// are allowed; close/reuse of the public Target id is not. + pub(crate) fn target_created_fact_matches_current_target( + &self, + projection: &BrowserTargetCreatedFactProjection, + ) -> bool { + let envelope = projection.envelope(); + self.browser_host_state + .navigation_owner() + .capture_page_residence( + envelope.browser_context_id().as_str(), + envelope.target_id().as_str(), + ) + .is_some_and(|current| projection.matches_residence_instance(¤t)) + } + + pub(crate) fn take_navigation_admission_fact( + &mut self, + navigation: &DocumentNavigationToken, + ) -> Result, BrowserFactProjectionError> { + self.browser_fact_projector + .take_navigation_admission_fact(navigation) + } + + pub(crate) fn take_navigation_failure_fact( + &mut self, + navigation: &DocumentNavigationToken, + failure: &BrowserNavigationFailure, + ) -> Result, BrowserFactProjectionError> { + self.browser_fact_projector + .take_navigation_failure_fact(navigation, failure) + } + + pub(crate) fn take_navigation_download_fact( + &mut self, + navigation: &DocumentNavigationToken, + ) -> Result, BrowserFactProjectionError> { + self.browser_fact_projector + .take_navigation_download_fact(navigation) + } + + pub(crate) fn take_navigation_commit_fact( + &mut self, + navigation: &DocumentNavigationToken, + page: &PageResidenceIdentity, + ) -> Result { + self.browser_fact_projector + .take_navigation_commit_fact(navigation, page) + } + + pub(crate) fn take_target_termination_fact( + &mut self, + termination: &BrowserTargetTermination, + ) -> Result { + self.browser_fact_projector + .take_target_termination_fact(termination) + } + + pub(crate) fn take_visible_renderer_document_lifecycle_facts( + &mut self, + session_id: Option<&str>, + binding: &CommittedRendererDocumentBinding, + events: &[RendererDocumentLifecycleEvent], + ) -> Result, BrowserFactProjectionError> { + let page = self.target_page_residence_identity_for_session(session_id); + self.browser_fact_projector + .take_visible_document_lifecycle_facts(page.as_ref(), binding, events) + } + + #[cfg(test)] + pub(crate) fn browser_fact_snapshot_for_test(&self) -> Vec> { + self.browser_host_state + .navigation_owner() + .browser_fact_snapshot() + } + + #[cfg(test)] + pub(crate) fn last_projected_browser_fact_sequence_for_test(&self) -> Option { + self.browser_fact_projector + .last_projected_sequence() + .map(BrowserFactSequence::get) + } +} + +fn lifecycle_fact_matches_binding( + envelope: &BrowserFactEnvelope, + page: Option<&PageResidenceIdentity>, + binding: &CommittedRendererDocumentBinding, +) -> bool { + page == Some(envelope.page_residence()) + && matches!( + envelope.fact(), + BrowserFact::DocumentLifecycleReached { document, .. } + if document.frame == binding.renderer_frame + && document.document == binding.renderer_document + ) +} + +fn retired_page_residence(fact: &BrowserFact) -> Option<&PageResidenceIdentity> { + match fact { + BrowserFact::NavigationFailed { + previous_page: Some(previous_page), + .. + } + | BrowserFact::NavigationCommitted { previous_page, .. } + | BrowserFact::TargetCrashed { previous_page, .. } + | BrowserFact::TargetClosed { previous_page, .. } => Some(previous_page), + BrowserFact::TargetCreated + | BrowserFact::TargetMetadataChanged { .. } + | BrowserFact::NavigationAccepted { .. } + | BrowserFact::NavigationFailed { + previous_page: None, + .. + } + | BrowserFact::NavigationConvertedToDownload { .. } + | BrowserFact::DocumentLifecycleReached { .. } + | BrowserFact::DocumentLifecycleTerminated { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use moli_core::{ + PageId, + browser_host::{ + BrowserContextSelectionProjection, BrowserNavigationHistoryPageSnapshot, + BrowserNavigationOwner, BrowserPageResidenceHandle, + BrowserSelectedTargetEngineDisposition, BrowserTargetHandle, + BrowserTargetSlotProjection, BrowserTargetTopologyProjection, + }, + page::{ + RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, + RendererDocumentLifecycleMilestone, RendererDocumentToken, RendererFrameToken, + RendererLifecycleEpoch, + }, + runtime::NavigationEngine, + }; + + use super::*; + use crate::conn::{DocumentNavigationToken, TargetPageAttachmentId}; + + fn owner_with_target() -> ( + BrowserNavigationOwner, + moli_core::browser_host::BrowserPageOwnerKey, + PageResidenceIdentity, + ) { + let key = moli_core::browser_host::BrowserPageOwnerKey::new("context-1", "target-1"); + let mut owner = BrowserNavigationOwner::new(NavigationEngine::new()); + owner + .register_browser_context( + key.browser_context_id(), + BrowserTargetTopologyProjection::new( + key.browser_context_id(), + Some(BrowserTargetSlotProjection::new( + BrowserTargetHandle::staged(key.target_id()), + BrowserPageResidenceHandle::default(), + )), + Vec::::new(), + ), + BrowserContextSelectionProjection::new( + None, + BrowserSelectedTargetEngineDisposition::Unbound, + ), + NavigationEngine::new, + ) + .expect("test Target topology should register"); + let page = owner + .capture_page_residence(key.browser_context_id(), key.target_id()) + .expect("test Page residence"); + (owner, key, page) + } + + fn lifecycle_event(sequence: u64) -> RendererDocumentLifecycleEvent { + let page_id = PageId::new_for_testing(71); + RendererDocumentLifecycleEvent { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, 3), + epoch: RendererLifecycleEpoch(2), + sequence, + timestamp_micros: sequence * 10, + kind: RendererDocumentLifecycleEventKind::Milestone( + RendererDocumentLifecycleMilestone::Load, + ), + } + } + + fn binding(event: &RendererDocumentLifecycleEvent) -> CommittedRendererDocumentBinding { + CommittedRendererDocumentBinding { + renderer_frame: event.frame, + renderer_document: event.document, + renderer_epoch: event.epoch, + navigation: Some(DocumentNavigationToken::new("target-1", "loader-1")), + frame_id: "target-1".to_owned(), + loader_id: "loader-1".to_owned(), + page_attachment_id: TargetPageAttachmentId::from_raw_for_test(1), + document_open_replacement_epoch: None, + } + } + + fn projector_after_target_creation( + owner: &BrowserNavigationOwner, + page: &PageResidenceIdentity, + ) -> (CdpBrowserFactProjector, BrowserFactSequence) { + let subscriber = owner.subscribe_browser_facts(); + let mut projector = CdpBrowserFactProjector::new(subscriber); + let created = projector + .take_target_created_fact(page) + .expect("test frontend should claim the exact Target occurrence"); + assert!(matches!( + created.envelope().fact(), + BrowserFact::TargetCreated + )); + assert_eq!(created.envelope().page_residence(), page); + (projector, created.envelope().sequence()) + } + + #[test] + fn target_creation_claim_survives_document_generation_but_rejects_id_reuse() { + let (owner, key, page) = owner_with_target(); + let subscriber = owner.subscribe_browser_facts(); + let mut projector = CdpBrowserFactProjector::new(subscriber); + let created = projector + .take_target_created_fact(&page) + .expect("exact Target creation should be projectable once"); + + let handle = owner + .page_residence_handle(&key) + .expect("registered Target Page handle"); + handle.advance_generation_for_test_fixture(); + let current = owner + .capture_page_residence(key.browser_context_id(), key.target_id()) + .expect("successor Document should remain in the same Page slot"); + assert!(created.matches_residence_instance(¤t)); + + let reused_public_id = BrowserPageResidenceHandle::default().identity( + key.browser_context_id().to_owned(), + Some(key.target_id().to_owned()), + ); + assert!(!created.matches_residence_instance(&reused_public_id)); + assert_eq!(projector.pending_fact_count(), 0); + assert!(matches!( + projector.take_target_created_fact(&page), + Err(BrowserFactProjectionError::MissingTargetCreationFact { .. }) + )); + } + + #[test] + fn exact_fact_waits_for_visibility_and_cannot_project_twice() { + let (mut owner, _, page) = owner_with_target(); + let (mut projector, created_sequence) = projector_after_target_creation(&owner, &page); + let event = lifecycle_event(1); + let binding = binding(&event); + + let published = owner + .record_document_lifecycle_facts(&page, &[event]) + .expect("exact lifecycle fact should publish"); + projector + .register_document_lifecycle_causal_links(&page, &binding, &published) + .expect("renderer ingress should freeze the projection binding"); + projector + .capture_available() + .expect("frontend cursor should capture the fact independently"); + assert_eq!(projector.pending_document_lifecycle_fact_count(), 1); + assert!( + projector + .take_visible_document_lifecycle_facts(Some(&page), &binding, &[]) + .expect("an unreleased fact is not an error") + .is_empty() + ); + + let projected = projector + .take_visible_document_lifecycle_facts(Some(&page), &binding, &[event]) + .expect("the matching visibility record should release the fact"); + assert_eq!(projected.len(), 1); + assert_eq!( + projected[0].envelope().sequence().get(), + created_sequence.get() + 1 + ); + assert_eq!( + projector.last_projected_sequence().map(|value| value.get()), + Some(created_sequence.get() + 1) + ); + assert_eq!(projector.pending_document_lifecycle_fact_count(), 0); + + assert!(matches!( + projector.take_visible_document_lifecycle_facts(Some(&page), &binding, &[event]), + Err(BrowserFactProjectionError::MissingDocumentLifecycleFact { .. }) + )); + } + + #[test] + fn navigation_fact_families_share_one_exact_frontend_cursor() { + let (mut owner, key, page) = owner_with_target(); + let (mut projector, _) = projector_after_target_creation(&owner, &page); + + let first = owner + .try_start_document_navigation_with_trace(&key, "loader-first".to_owned(), None) + .expect("test Context accepts navigation"); + let first_admission = projector + .take_navigation_admission_fact(&first) + .expect("accepted request should project from its Browser fact"); + assert!(matches!( + first_admission.fact(), + BrowserFact::NavigationAccepted { + navigation, + superseded_navigation: None, + } if navigation == &first + )); + + let replacement = owner + .try_start_document_navigation_with_trace(&key, "loader-replacement".to_owned(), None) + .expect("replacement navigation"); + let replacement_admission = projector + .take_navigation_admission_fact(&replacement) + .expect("successor admission should carry its superseded request"); + assert!(matches!( + replacement_admission.fact(), + BrowserFact::NavigationAccepted { + navigation, + superseded_navigation: Some(superseded), + } if navigation == &replacement && superseded == &first + )); + assert_eq!(projector.pending_fact_count(), 0); + + let failure = BrowserNavigationFailure::Canceled { + error_text: "canceled by test".to_owned(), + }; + assert!(owner.fail_document_navigation_if_matches(&key, &replacement, failure.clone(),)); + projector + .take_navigation_failure_fact(&replacement, &failure) + .expect("terminal request should project from its exact failure fact"); + + let download = owner + .try_start_document_navigation_with_trace(&key, "loader-download".to_owned(), None) + .expect("download navigation"); + projector + .take_navigation_admission_fact(&download) + .expect("download request admission"); + assert!(owner.convert_document_navigation_to_download_if_matches(&key, &download)); + projector + .take_navigation_download_fact(&download) + .expect("download terminal should project from its exact fact"); + + let committed = owner + .try_start_document_navigation_with_trace(&key, "loader-committed".to_owned(), None) + .expect("committed navigation"); + projector + .take_navigation_admission_fact(&committed) + .expect("committed request admission"); + let permit = owner + .prepare_loaded_page_replacement(&key, &committed) + .expect("current request prepares replacement"); + let replacement = owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + BrowserNavigationHistoryPageSnapshot::new( + "https://example.test/committed".to_owned(), + "committed".to_owned(), + ), + ) + .expect("exact Page replacement commits"); + let commit_projection = projector + .take_navigation_commit_fact(&committed, replacement.current_page()) + .expect("renderer commit should require the atomic commit fact"); + let metadata_projection = projector + .take_navigation_target_metadata_changed_fact(&committed, replacement.current_page()) + .expect("Target metadata should require the same committed navigation fact batch"); + assert_eq!( + metadata_projection.envelope().sequence().get(), + commit_projection.sequence().get() + 1 + ); + assert_eq!( + metadata_projection.transition().navigation(), + Some(&committed) + ); + assert_eq!( + metadata_projection.transition().url(), + "https://example.test/committed" + ); + assert_eq!(metadata_projection.transition().title(), "committed"); + assert_eq!(projector.pending_fact_count(), 0); + } + + #[test] + fn page_replacement_retires_unprojected_predecessor_lifecycle_facts() { + let (mut owner, key, previous_page) = owner_with_target(); + let (mut projector, _) = projector_after_target_creation(&owner, &previous_page); + let lifecycle = lifecycle_event(1); + let lifecycle_binding = binding(&lifecycle); + + let published = owner + .record_document_lifecycle_facts(&previous_page, &[lifecycle]) + .expect("predecessor lifecycle should publish"); + projector + .register_document_lifecycle_causal_links( + &previous_page, + &lifecycle_binding, + &published, + ) + .expect("predecessor projection binding should freeze"); + projector + .capture_available() + .expect("frontend cursor should retain the unreleased lifecycle fact"); + assert_eq!(projector.pending_document_lifecycle_fact_count(), 1); + + let navigation = owner + .try_start_document_navigation_with_trace(&key, "loader-successor".to_owned(), None) + .expect("successor navigation should start"); + projector + .take_navigation_admission_fact(&navigation) + .expect("successor admission should project"); + let permit = owner + .prepare_loaded_page_replacement(&key, &navigation) + .expect("current navigation should prepare replacement"); + let replacement = owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + BrowserNavigationHistoryPageSnapshot::new( + "https://example.test/successor".to_owned(), + "successor".to_owned(), + ), + ) + .expect("successor Page should commit"); + projector + .take_navigation_commit_fact(&navigation, replacement.current_page()) + .expect("successor commit should project from its exact fact"); + projector + .take_navigation_target_metadata_changed_fact(&navigation, replacement.current_page()) + .expect("successor metadata should project from the same commit batch"); + + assert_eq!(projector.pending_document_lifecycle_fact_count(), 0); + assert_eq!(projector.pending_fact_count(), 0); + } + + #[test] + fn page_replacement_retires_unprojected_predecessor_metadata_fact() { + let (mut owner, key, previous_page) = owner_with_target(); + let (mut projector, _) = projector_after_target_creation(&owner, &previous_page); + + let first = owner + .try_start_document_navigation_with_trace(&key, "loader-first".to_owned(), None) + .expect("first navigation should start"); + projector + .take_navigation_admission_fact(&first) + .expect("first navigation admission should project"); + let first_permit = owner + .prepare_loaded_page_replacement(&key, &first) + .expect("first navigation should prepare replacement"); + let first_replacement = owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + first_permit, + BrowserNavigationHistoryPageSnapshot::new("https://example.test/first", "first"), + ) + .expect("first Page should commit"); + projector + .take_navigation_commit_fact(&first, first_replacement.current_page()) + .expect("first commit fact should project"); + assert_eq!(projector.pending_fact_count(), 1); + + let second = owner + .try_start_document_navigation_with_trace(&key, "loader-second".to_owned(), None) + .expect("second navigation should start"); + projector + .take_navigation_admission_fact(&second) + .expect("second navigation admission should project"); + let second_permit = owner + .prepare_loaded_page_replacement(&key, &second) + .expect("second navigation should prepare replacement"); + let second_replacement = owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + second_permit, + BrowserNavigationHistoryPageSnapshot::new("https://example.test/second", "second"), + ) + .expect("second Page should commit"); + projector + .take_navigation_commit_fact(&second, second_replacement.current_page()) + .expect("second commit fact should project"); + let second_metadata = projector + .take_navigation_target_metadata_changed_fact( + &second, + second_replacement.current_page(), + ) + .expect("second metadata should remain claimable"); + + assert_eq!(second_metadata.transition().navigation(), Some(&second)); + assert_eq!(projector.pending_fact_count(), 0); + } + + #[test] + fn page_replacement_retires_unclaimed_creation_across_document_generation() { + let (mut owner, key, _) = owner_with_target(); + let subscriber = owner.subscribe_browser_facts(); + let mut projector = CdpBrowserFactProjector::new(subscriber); + let handle = owner + .page_residence_handle(&key) + .expect("registered Target Page handle"); + handle.advance_generation_for_test_fixture(); + + let navigation = owner + .try_start_document_navigation_with_trace(&key, "loader-successor".to_owned(), None) + .expect("successor navigation should start"); + projector + .take_navigation_admission_fact(&navigation) + .expect("navigation admission should share the creation cursor"); + assert_eq!( + projector.pending_fact_count(), + 1, + "the unclaimed generation-zero Target occurrence should remain pending" + ); + + let permit = owner + .prepare_loaded_page_replacement(&key, &navigation) + .expect("current navigation should prepare replacement"); + assert_eq!(permit.previous_page().loaded_page_generation(), 1); + let replacement = owner + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + BrowserNavigationHistoryPageSnapshot::new( + "https://example.test/successor".to_owned(), + "successor".to_owned(), + ), + ) + .expect("successor Page should commit"); + projector + .take_navigation_commit_fact(&navigation, replacement.current_page()) + .expect("successor commit should project its exact fact"); + projector + .take_navigation_target_metadata_changed_fact(&navigation, replacement.current_page()) + .expect("successor metadata should project from the same commit batch"); + + assert_eq!(projector.pending_fact_count(), 0); + } + + #[test] + fn target_terminal_carries_its_pending_navigation_on_the_common_cursor() { + let (mut owner, key, page) = owner_with_target(); + let (mut projector, _) = projector_after_target_creation(&owner, &page); + let navigation = owner + .try_start_document_navigation_with_trace(&key, "loader-closing".to_owned(), None) + .expect("closing Target should accept a pending navigation"); + projector + .take_navigation_admission_fact(&navigation) + .expect("pending navigation admission should project"); + let request = owner + .capture_target_termination(&key, BrowserTargetTerminationKind::Close) + .expect("live Target should capture close"); + let permit = owner + .prepare_target_termination(request) + .expect("exact Target close should prepare"); + let termination = owner + .commit_target_termination(permit) + .expect("exact Target close should commit"); + + let projected = projector + .take_target_termination_fact(&termination) + .expect("Target close should consume its exact terminal fact batch"); + + assert!(matches!( + projected.envelope().fact(), + BrowserFact::TargetClosed { + pending_navigation: Some(pending), + .. + } if pending == &navigation + )); + assert_eq!(projector.pending_fact_count(), 0); + } + + #[tokio::test] + async fn application_wake_advances_the_same_frontend_cursor_through_its_committed_tail() { + let (mut owner, _, page) = owner_with_target(); + let mut wake = owner.subscribe_browser_fact_wake(); + let (mut projector, created_sequence) = projector_after_target_creation(&owner, &page); + let event = lifecycle_event(1); + let binding = binding(&event); + + let published = owner + .record_document_lifecycle_facts(&page, &[event]) + .expect("exact lifecycle fact should publish"); + projector + .register_document_lifecycle_causal_links(&page, &binding, &published) + .expect("renderer ingress should freeze the projection binding"); + assert_eq!( + projector.pending_document_lifecycle_fact_count(), + 0, + "registering the causal link must not consume the frontend cursor" + ); + assert_eq!(projector.last_observed_sequence, Some(created_sequence)); + let through = wake + .recv() + .await + .expect("fact publication should wake the application scheduler"); + projector + .capture_wake(through) + .expect("the frontend cursor should reach the coalesced wake sequence"); + + assert_eq!(through.get(), created_sequence.get() + 1); + assert_eq!(projector.pending_document_lifecycle_fact_count(), 1); + let projected = projector + .take_visible_document_lifecycle_facts(Some(&page), &binding, &[event]) + .expect("a wake-captured fact should retain its causal projection binding"); + assert_eq!(projected.len(), 1); + } + + #[test] + fn attachment_replacement_cannot_claim_an_old_lifecycle_fact() { + let (mut owner, _, page) = owner_with_target(); + let (mut projector, _) = projector_after_target_creation(&owner, &page); + let event = lifecycle_event(1); + let old_binding = binding(&event); + + let published = owner + .record_document_lifecycle_facts(&page, &[event]) + .expect("exact lifecycle fact should publish"); + projector + .register_document_lifecycle_causal_links(&page, &old_binding, &published) + .expect("old attachment should freeze the fact mapping"); + projector + .capture_available() + .expect("frontend cursor should advance independently of the visible record"); + let mut replacement_binding = old_binding.clone(); + replacement_binding.page_attachment_id = TargetPageAttachmentId::from_raw_for_test(2); + + assert!(matches!( + projector.take_visible_document_lifecycle_facts( + Some(&page), + &replacement_binding, + &[event], + ), + Err(BrowserFactProjectionError::MissingDocumentLifecycleFact { .. }) + )); + } + + #[test] + fn completed_epoch_uses_the_same_frozen_document_binding() { + let (mut owner, _, page) = owner_with_target(); + let (mut projector, _) = projector_after_target_creation(&owner, &page); + let event = lifecycle_event(1); + let mut active_epoch_binding = binding(&event); + active_epoch_binding.renderer_epoch = RendererLifecycleEpoch(event.epoch.0 + 1); + + let published = owner + .record_document_lifecycle_facts(&page, &[event]) + .expect("completed lifecycle epoch should publish"); + projector + .register_document_lifecycle_causal_links(&page, &active_epoch_binding, &published) + .expect("the root Document binding should retain its completed epoch fact"); + projector + .capture_available() + .expect("frontend cursor should capture the completed epoch fact"); + let projected = projector + .take_visible_document_lifecycle_facts(Some(&page), &active_epoch_binding, &[event]) + .expect("the completed epoch should remain projectable"); + + assert_eq!(projected.len(), 1); + assert_eq!( + projected[0] + .reached() + .map(|(document, _, _)| document.epoch), + Some(event.epoch) + ); + assert_eq!(projected[0].binding(), &active_epoch_binding); + } + + #[test] + fn later_visible_load_retires_an_unselected_earlier_dcl_fact() { + let (mut owner, _, page) = owner_with_target(); + let (mut projector, created_sequence) = projector_after_target_creation(&owner, &page); + let load = lifecycle_event(2); + let dcl = RendererDocumentLifecycleEvent { + sequence: 1, + timestamp_micros: 10, + kind: RendererDocumentLifecycleEventKind::Milestone( + RendererDocumentLifecycleMilestone::DomContentLoaded, + ), + ..load + }; + let binding = binding(&load); + + let published = owner + .record_document_lifecycle_facts(&page, &[dcl, load]) + .expect("ordered lifecycle facts should publish"); + projector + .register_document_lifecycle_causal_links(&page, &binding, &published) + .expect("renderer ingress should freeze both lifecycle links"); + projector + .capture_available() + .expect("frontend cursor should capture both facts independently"); + + let projected = projector + .take_visible_document_lifecycle_facts(Some(&page), &binding, &[load]) + .expect("a frontend may first observe the exact load fact"); + assert_eq!(projected.len(), 1); + assert_eq!( + projected[0].envelope().sequence().get(), + created_sequence.get() + 2 + ); + assert_eq!(projector.pending_document_lifecycle_fact_count(), 0); + assert!(matches!( + projector.take_visible_document_lifecycle_facts(Some(&page), &binding, &[dcl]), + Err(BrowserFactProjectionError::MissingDocumentLifecycleFact { .. }) + )); + } + + #[test] + fn slow_frontend_reports_journal_lag_instead_of_guessing_events() { + let (mut owner, _, page) = owner_with_target(); + let (mut projector, _) = projector_after_target_creation(&owner, &page); + let binding = binding(&lifecycle_event(1)); + for sequence in 1..=1_025 { + let published = owner + .record_document_lifecycle_facts(&page, &[lifecycle_event(sequence)]) + .expect("test fact sequence should remain available"); + projector + .register_document_lifecycle_causal_links(&page, &binding, &published) + .expect("causal-link registration must not backpressure Browser publication"); + } + assert_eq!( + projector.lifecycle_causal_links.len(), + MAX_PENDING_BROWSER_FACTS, + "the causal join must stay bounded with the Browser retention window" + ); + + assert_eq!( + projector.capture_available(), + Err(BrowserFactProjectionError::SubscriberLagged { skipped: 1 }) + ); + assert_eq!( + projector.terminal_error(), + Some(&BrowserFactProjectionError::SubscriberLagged { skipped: 1 }) + ); + assert_eq!(projector.pending_document_lifecycle_fact_count(), 0); + assert!(projector.lifecycle_causal_links.is_empty()); + } +} diff --git a/moli-protocol/src/conn/browser_host_executor_residence.rs b/moli-protocol/src/conn/browser_host_executor_residence.rs new file mode 100644 index 0000000000..cf2a82e3e6 --- /dev/null +++ b/moli-protocol/src/conn/browser_host_executor_residence.rs @@ -0,0 +1,67 @@ +use std::{ + marker::PhantomData, + ops::{Deref, DerefMut}, + rc::Rc, +}; + +use super::CdpConnection; + +/// Application-side residence for the capability to execute Browser Host +/// turns. +/// +/// The Browser Host actor owns turn selection. This non-cloneable companion +/// lives beside that actor in the application owner lane and is required to +/// bind the remaining Protocol projection for one short execution turn. +/// `CdpConnection` deliberately does not contain this authority. +pub struct BrowserHostTurnExecutorOwner { + _local_owner: PhantomData>, +} + +impl BrowserHostTurnExecutorOwner { + pub fn for_application_owner_lane() -> Self { + Self { + _local_owner: PhantomData, + } + } + + /// Binds the migration-period Protocol projection for one exact Host + /// execution turn. + /// + /// The mutable owner borrow prevents two Host executions from using the + /// projection concurrently. Dropping a frontend adapter cannot create or + /// retain this capability. + pub(crate) fn bind_turn<'a>( + &'a mut self, + connection: &'a mut CdpConnection, + ) -> BrowserHostTurnExecution<'a> { + BrowserHostTurnExecution { + connection, + _owner: PhantomData, + } + } +} + +/// One short application-authorized Browser Host execution over the remaining +/// Protocol projection. +/// +/// This adapter is intentionally borrowing and cannot survive a turn or a +/// participant wait. Physical waits remain move-owned by +/// `PendingBrowserHostTurn` and are applied through a newly bound later turn. +pub(crate) struct BrowserHostTurnExecution<'a> { + connection: &'a mut CdpConnection, + _owner: PhantomData<&'a mut BrowserHostTurnExecutorOwner>, +} + +impl Deref for BrowserHostTurnExecution<'_> { + type Target = CdpConnection; + + fn deref(&self) -> &Self::Target { + self.connection + } +} + +impl DerefMut for BrowserHostTurnExecution<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.connection + } +} diff --git a/moli-protocol/src/conn/browser_host_turn_executor.rs b/moli-protocol/src/conn/browser_host_turn_executor.rs new file mode 100644 index 0000000000..6ca71b60ae --- /dev/null +++ b/moli-protocol/src/conn/browser_host_turn_executor.rs @@ -0,0 +1,1268 @@ +#[cfg(test)] +use moli_core::browser_host::PageResidenceIdentity; +use moli_core::browser_host::{ + BrowserContextHandle, BrowserFrontendCommand, BrowserHistoryTraversalDestination, + BrowserHistoryTraversalResult, BrowserHostActor, BrowserHostTurn, BrowserHostTurnExecutor, + BrowserNavigateCommandOutcome, BrowserNavigateCommandResult, BrowserOwnerInput, + BrowserTargetHandle, BrowserTargetId, RendererBrowserIntent, +}; + +use crate::domains::fetch::{ + CompletedPausedNavigationDecisionOwnerTask, PausedNavigationDecisionOwnerTaskStep, + PendingPausedNavigationDecisionOwnerTask, +}; +use crate::domains::page::{ + CompletedPageCommandDispatch, CompletedPageTargetTerminationOwnerTask, + CompletedStopLoadingOwnerTask, CompletedTargetCloseOwnerTask, PageCommandTaskStep, + PageTargetTerminationOwnerTaskStep, PendingPageCommandDispatch, + PendingPageTargetTerminationOwnerTask, PendingStopLoadingOwnerTask, + PendingTargetCloseOwnerTask, StopLoadingOwnerTaskStep, TargetCloseOwnerTaskStep, +}; +use crate::domains::target::{ + BrowserContextDisposalOwnerTaskStep, CompletedBrowserContextDisposalOwnerTask, + PendingBrowserContextDisposalOwnerTask, +}; + +use super::BrowserHostTurnExecutorOwner; +use super::browser_target_engine_handoff::{ + BrowserTargetPromotionStart, CompletedBrowserTargetPromotion, PendingBrowserTargetPromotion, + TargetActivationTransition, +}; +use super::{ + BrowserHostTurnExecution, CdpConnection, CdpRendererOwnerTurnOutcome, CdpTurnOutcome, + CommandDispatchContext, + browser_owner_input::{ + CompletedBrowserOwnerContextDisposalCommand, + CompletedBrowserOwnerInitialTargetNavigationCommand, + CompletedBrowserOwnerNavigationCommand, + CompletedBrowserOwnerPausedNavigationDecisionCommand, + CompletedBrowserOwnerStopLoadingCommand, + }, +}; + +enum BrowserHostPageStepCompletion { + BrowserEffects, + InitialTargetNavigationCommand { + reply: tokio::sync::oneshot::Sender, + }, + FrontendNavigation { + requested_url: String, + history_traversal: Option, + accepted_background_result: Option, + reply: tokio::sync::oneshot::Sender, + }, +} + +enum BrowserHostFrontendNavigationAction { + Navigate { + url: String, + referrer: Option, + }, + Reload { + ignore_cache: bool, + script_to_evaluate_on_load: Option, + }, + HistoryTraversal { + destination: BrowserHistoryTraversalDestination, + }, +} + +/// Result of one short, actor-selected Browser Host turn. +/// +/// Physical/protocol projection remains in `CdpConnection` during migration, +/// but a renderer or network wait is returned as an exact pending operation +/// instead of being awaited while the Browser Host actor is borrowed. +pub struct BrowserHostTurnDispatch { + outcome: CdpRendererOwnerTurnOutcome, + pending: Option, +} + +impl BrowserHostTurnDispatch { + fn complete(outcome: impl Into) -> Self { + Self { + outcome: outcome.into(), + pending: None, + } + } + + fn pending( + outcome: impl Into, + pending: PendingBrowserHostTurn, + ) -> Self { + Self { + outcome: outcome.into(), + pending: Some(pending), + } + } + + pub fn into_parts(self) -> (CdpRendererOwnerTurnOutcome, Option) { + (self.outcome, self.pending) + } +} + +/// Exact participant wait registered by a short Browser Host turn. +/// +/// Waiting consumes the capability. The completion retains the command owner +/// scope and renderer token captured at dispatch time, so a later Page cannot +/// accidentally satisfy an older operation. +#[must_use = "a pending Browser Host turn must be registered for completion"] +pub struct PendingBrowserHostTurn { + participant: PendingBrowserHostTurnParticipant, +} + +enum PendingBrowserHostTurnParticipant { + PageCommand { + pending: PendingPageCommandDispatch, + command_context: CommandDispatchContext, + completion: BrowserHostPageStepCompletion, + }, + PageTermination(PendingPageTargetTerminationOwnerTask), + StopLoading { + pending: PendingStopLoadingOwnerTask, + command_context: CommandDispatchContext, + reply: tokio::sync::oneshot::Sender, + }, + ContextDisposal { + pending: Box, + reply: tokio::sync::oneshot::Sender, + }, + PausedNavigationDecision { + pending: Box, + reply: tokio::sync::oneshot::Sender, + }, + AuxiliaryTargetActivation { + pending: Option>, + transition: TargetActivationTransition, + }, + TargetClose(PendingTargetCloseOwnerTask), +} + +impl PendingBrowserHostTurn { + #[cfg(test)] + pub(crate) fn stop_loading_page_owner_for_test(&self) -> Option<&PageResidenceIdentity> { + match &self.participant { + PendingBrowserHostTurnParticipant::StopLoading { pending, .. } => { + pending.page_owner_for_test() + } + _ => None, + } + } + + #[cfg(test)] + pub(crate) fn paused_navigation_decision_page_owner_for_test( + &self, + ) -> Option<&PageResidenceIdentity> { + match &self.participant { + PendingBrowserHostTurnParticipant::PausedNavigationDecision { pending, .. } => { + Some(pending.page_owner_for_test()) + } + _ => None, + } + } + + pub async fn wait(self) -> CompletedBrowserHostTurn { + let participant = match self.participant { + PendingBrowserHostTurnParticipant::PageCommand { + pending, + command_context, + completion, + } => CompletedBrowserHostTurnParticipant::PageCommand { + completed: pending.wait().await, + command_context, + completion, + }, + PendingBrowserHostTurnParticipant::PageTermination(pending) => { + CompletedBrowserHostTurnParticipant::PageTermination(pending.wait().await) + } + PendingBrowserHostTurnParticipant::StopLoading { + pending, + command_context, + reply, + } => CompletedBrowserHostTurnParticipant::StopLoading { + completed: pending.wait().await, + command_context, + reply, + }, + PendingBrowserHostTurnParticipant::ContextDisposal { pending, reply } => { + CompletedBrowserHostTurnParticipant::ContextDisposal { + completed: Box::new(pending.wait().await), + reply, + } + } + PendingBrowserHostTurnParticipant::PausedNavigationDecision { pending, reply } => { + CompletedBrowserHostTurnParticipant::PausedNavigationDecision { + completed: Box::new(pending.wait().await), + reply, + } + } + PendingBrowserHostTurnParticipant::AuxiliaryTargetActivation { + pending, + transition, + } => CompletedBrowserHostTurnParticipant::AuxiliaryTargetActivation { + completed: match pending { + Some(pending) => Some(pending.wait().await), + None => None, + }, + transition, + }, + PendingBrowserHostTurnParticipant::TargetClose(pending) => { + CompletedBrowserHostTurnParticipant::TargetClose(pending.wait().await) + } + }; + CompletedBrowserHostTurn { participant } + } +} + +/// Move-owned participant completion ready for one later owner-loop turn. +#[must_use = "a completed Browser Host operation must be applied"] +pub struct CompletedBrowserHostTurn { + participant: CompletedBrowserHostTurnParticipant, +} + +enum CompletedBrowserHostTurnParticipant { + PageCommand { + completed: CompletedPageCommandDispatch, + command_context: CommandDispatchContext, + completion: BrowserHostPageStepCompletion, + }, + PageTermination(CompletedPageTargetTerminationOwnerTask), + StopLoading { + completed: CompletedStopLoadingOwnerTask, + command_context: CommandDispatchContext, + reply: tokio::sync::oneshot::Sender, + }, + ContextDisposal { + completed: Box, + reply: tokio::sync::oneshot::Sender, + }, + PausedNavigationDecision { + completed: Box, + reply: tokio::sync::oneshot::Sender, + }, + AuxiliaryTargetActivation { + completed: Option, + transition: TargetActivationTransition, + }, + TargetClose(CompletedTargetCloseOwnerTask), +} + +/// Migration-period physical Page executor for actor-selected Browser Host +/// turns. +/// +/// `CdpConnection` cannot construct [`BrowserHostTurn`], choose mailbox order, +/// or implement the executor capability. The application-owned execution lane +/// binds this short adapter only after Core has selected an exact turn. It +/// returns before any renderer/network participant wait. +impl BrowserHostTurnExecutor for BrowserHostTurnExecution<'_> { + type Output = BrowserHostTurnDispatch; + + fn execute_browser_host_turn(&mut self, turn: BrowserHostTurn) -> Self::Output { + if moli_trace::cdp_runtime_trace_enabled() { + tracing::info!( + target: "moli_cdp_runtime", + stage = "browser_owner_input_start", + kind = ?turn.kind(), + ); + } + match turn.into_input() { + BrowserOwnerInput::FrontendCommand(BrowserFrontendCommand::DisposeBrowserContext( + input, + )) => { + let (command_id, browser_context_handle) = input.into_parts(); + let Some(prepared) = + self.take_prepared_browser_owner_context_disposal_command(command_id) + else { + tracing::error!( + browser_command_id = command_id.get(), + "dropping Browser Host Context-disposal turn without its prepared protocol projection" + ); + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + ); + }; + let (prefix_events, command_context, reply) = prepared.into_parts(); + let step = crate::domains::target::start_browser_context_disposal_owner_task( + self, + browser_context_handle, + prefix_events, + command_context, + ); + self.browser_host_dispatch_from_context_disposal_step(step, reply) + } + BrowserOwnerInput::FrontendCommand(BrowserFrontendCommand::StopLoading(input)) => { + let (command_id, page_owner) = input.into_parts(); + let Some(prepared) = + self.take_prepared_browser_owner_stop_loading_command(command_id) + else { + tracing::error!( + browser_command_id = command_id.get(), + "dropping Browser Host stop-loading turn without its prepared protocol projection" + ); + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + ); + }; + let (command_context, reply) = prepared.into_parts(); + let step = crate::domains::page::start_page_owned_stop_loading_owner_task( + self, + &page_owner, + ); + self.browser_host_dispatch_from_stop_loading_step(step, command_context, reply) + } + BrowserOwnerInput::FrontendCommand( + BrowserFrontendCommand::ResolvePausedNavigation(input), + ) => { + let (command_id, page_owner, decision) = input.into_parts(); + let Some(prepared) = + self.take_prepared_browser_owner_paused_navigation_decision_command(command_id) + else { + tracing::error!( + browser_command_id = command_id.get(), + "dropping Browser Host paused-navigation decision without its prepared protocol sidecar" + ); + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + ); + }; + let (pending_navigation, command_context, reply) = prepared.into_parts(); + let step = + crate::domains::fetch::start_page_owned_paused_navigation_decision_owner_task( + self, + page_owner, + pending_navigation, + decision, + command_context, + ); + self.browser_host_dispatch_from_paused_navigation_decision_step(step, reply) + } + BrowserOwnerInput::FrontendCommand( + BrowserFrontendCommand::EnsureInitialTargetNavigation(input), + ) => { + let (command_id, page_owner, url) = input.into_parts(); + let reply = self + .take_prepared_browser_owner_initial_target_navigation_command(command_id) + .map(|prepared| prepared.into_reply()); + if reply.is_none() { + tracing::error!( + browser_command_id = command_id.get(), + "Browser Host initial-Target prerequisite lost its prepared Protocol continuation" + ); + } + let Some(step) = crate::domains::page::start_page_owned_initial_target_navigation( + self, + &page_owner, + &url, + ) else { + return match reply { + Some(reply) => self + .complete_browser_host_initial_target_navigation_command_projection( + crate::domains::command_output::CommandOutputPlan::default(), + reply, + ), + None => BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + ), + }; + }; + let completion = match reply { + Some(reply) => { + BrowserHostPageStepCompletion::InitialTargetNavigationCommand { reply } + } + None => BrowserHostPageStepCompletion::BrowserEffects, + }; + self.browser_host_dispatch_from_page_step_with_completion( + step, + CommandDispatchContext::default(), + completion, + ) + } + BrowserOwnerInput::FrontendCommand(command) => { + let (command_id, page_owner, action) = match command { + BrowserFrontendCommand::Navigate(input) => { + let (command_id, page_owner, url, referrer) = input.into_parts(); + ( + command_id, + page_owner, + BrowserHostFrontendNavigationAction::Navigate { url, referrer }, + ) + } + BrowserFrontendCommand::Reload(input) => { + let (command_id, page_owner, ignore_cache, script_to_evaluate_on_load) = + input.into_parts(); + ( + command_id, + page_owner, + BrowserHostFrontendNavigationAction::Reload { + ignore_cache, + script_to_evaluate_on_load, + }, + ) + } + BrowserFrontendCommand::TraverseHistory(input) => { + let (command_id, page_owner, destination) = input.into_parts(); + ( + command_id, + page_owner, + BrowserHostFrontendNavigationAction::HistoryTraversal { destination }, + ) + } + BrowserFrontendCommand::StopLoading(_) => { + unreachable!("stop-loading handled by the preceding owner-input arm") + } + BrowserFrontendCommand::ResolvePausedNavigation(_) => unreachable!( + "paused navigation decision handled by the preceding owner-input arm" + ), + BrowserFrontendCommand::EnsureInitialTargetNavigation(_) => unreachable!( + "initial Target prerequisite handled by the preceding owner-input arm" + ), + BrowserFrontendCommand::DisposeBrowserContext(_) => { + tracing::error!( + "Context disposal escaped its dedicated Browser Host input arm" + ); + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + ); + } + }; + let Some(prepared) = + self.take_prepared_browser_owner_navigation_command(command_id) + else { + tracing::error!( + browser_command_id = command_id.get(), + "dropping Browser Host navigation turn without its prepared protocol projection" + ); + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + ); + }; + let ( + frontend_command_id, + frontend_session_id, + allow_background_navigation, + result_payload, + command_context, + reply, + ) = prepared.into_parts(); + // Raw CDP retains a wire command id and completes through its + // response-head early outcome. Only a direct typed wait:none + // command needs the Host acceptance as its immediate result. + let returns_direct_background_result = + allow_background_navigation && frontend_command_id.is_none(); + let accepted_background_target_id = + page_owner.target_id().map(BrowserTargetId::new); + let accepted_background_loader_id = matches!( + &action, + BrowserHostFrontendNavigationAction::Navigate { .. } + | BrowserHostFrontendNavigationAction::Reload { .. } + ) + .then(|| crate::domains::page::LOADER_ID.to_owned()); + let (requested_url, history_traversal, step) = match action { + BrowserHostFrontendNavigationAction::Navigate { url, referrer } => { + let step = crate::domains::page::start_page_owned_frontend_navigate_command( + self, + frontend_command_id, + frontend_session_id.as_deref(), + &page_owner, + &url, + referrer.as_deref(), + allow_background_navigation, + result_payload, + ); + (url, None, step) + } + BrowserHostFrontendNavigationAction::Reload { + ignore_cache, + script_to_evaluate_on_load, + } => { + let (requested_url, step) = + crate::domains::page::start_page_owned_frontend_reload_command( + self, + frontend_command_id, + &page_owner, + ignore_cache, + script_to_evaluate_on_load, + allow_background_navigation, + result_payload, + ); + (requested_url, None, step) + } + BrowserHostFrontendNavigationAction::HistoryTraversal { destination } => { + crate::domains::page::start_page_owned_frontend_history_traversal_command( + self, + frontend_command_id, + &page_owner, + destination, + allow_background_navigation, + result_payload, + ) + } + }; + let accepted_background_result = returns_direct_background_result.then(|| { + BrowserNavigateCommandResult::new( + requested_url.clone(), + accepted_background_target_id, + accepted_background_loader_id, + None, + None, + ) + }); + self.browser_host_dispatch_from_page_step_with_completion( + step, + command_context, + BrowserHostPageStepCompletion::FrontendNavigation { + requested_url, + history_traversal, + accepted_background_result, + reply, + }, + ) + } + BrowserOwnerInput::RendererIntent( + RendererBrowserIntent::TopLevelLocationNavigation(input), + ) => { + let (page_owner, navigation, trace) = input.into_parts(); + let step = crate::domains::page::start_page_owned_top_level_location_navigation( + self, + &page_owner, + navigation, + trace, + ); + self.browser_host_dispatch_from_page_step(step, CommandDispatchContext::default()) + } + BrowserOwnerInput::RendererIntent(RendererBrowserIntent::TopLevelHistoryTraversal( + input, + )) => { + let (page_owner, delta) = input.into_parts(); + let step = crate::domains::page::start_page_owned_top_level_history_traversal( + self, + &page_owner, + delta, + ); + self.browser_host_dispatch_from_page_step(step, CommandDispatchContext::default()) + } + BrowserOwnerInput::InitialTargetNavigation(input) => { + let (page_owner, url) = input.into_parts(); + let Some(step) = crate::domains::page::start_page_owned_initial_target_navigation( + self, + &page_owner, + &url, + ) else { + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + ); + }; + self.browser_host_dispatch_from_page_step_with_completion( + step, + CommandDispatchContext::default(), + BrowserHostPageStepCompletion::BrowserEffects, + ) + } + BrowserOwnerInput::RendererIntent(RendererBrowserIntent::AuxiliaryNavigation( + input, + )) => { + let (page_owner, url, kind) = input.into_parts(); + let Some(step) = crate::domains::page::start_page_owned_auxiliary_navigation( + self, + &page_owner, + &url, + kind, + None, + ) else { + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + ); + }; + self.browser_host_dispatch_from_page_step(step, CommandDispatchContext::default()) + } + BrowserOwnerInput::RendererIntent( + RendererBrowserIntent::AuxiliaryTargetActivation(input), + ) => { + let (browser_context, target, navigation) = input.into_parts(); + let Some(navigation) = navigation else { + let scheduler_events = self.take_scheduler_events(); + return self.start_browser_host_auxiliary_target_activation( + browser_context, + target, + Vec::new(), + scheduler_events, + ); + }; + let (page_owner, url, kind) = navigation.into_parts(); + let Some(step) = crate::domains::page::start_page_owned_auxiliary_navigation( + self, + &page_owner, + &url, + kind, + Some((browser_context, target)), + ) else { + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + ); + }; + self.browser_host_dispatch_from_page_step(step, CommandDispatchContext::default()) + } + BrowserOwnerInput::PageTermination(input) => { + let step = crate::domains::page::start_page_target_termination_owner_task( + self, + input.into_request(), + ); + self.browser_host_dispatch_from_page_termination_step(step) + } + BrowserOwnerInput::TargetTermination(input) => { + let step = + crate::domains::page::start_target_close_owner_task(self, input.into_request()); + self.browser_host_dispatch_from_target_close_step(step) + } + } + } +} + +// Unit tests inside this crate retain their compact direct-actor harness. The +// production library has no `BrowserHostTurnExecutor for CdpConnection` impl; +// application code must own `BrowserHostTurnExecutorOwner` and bind an exact +// short execution adapter. +#[cfg(test)] +impl BrowserHostTurnExecutor for CdpConnection { + type Output = BrowserHostTurnDispatch; + + fn execute_browser_host_turn(&mut self, turn: BrowserHostTurn) -> Self::Output { + let mut owner = BrowserHostTurnExecutorOwner::for_application_owner_lane(); + let mut execution = owner.bind_turn(self); + BrowserHostTurnExecutor::execute_browser_host_turn(&mut execution, turn) + } +} + +impl BrowserHostTurnExecutorOwner { + /// Starts the exact turn already selected by the application-owned Browser + /// Host actor. The application-owned DevTools Host adapter supplies only + /// a borrowed renderer/protocol projection and does not receive the turn + /// selection capability itself. + pub fn start_next_turn( + &mut self, + actor: &mut BrowserHostActor, + host_adapter: &mut crate::DevToolsHostAdapter, + ) -> Option { + let mut execution = host_adapter.bind_browser_host_turn(self); + actor.complete_next_turn(&mut execution) + } + + /// Applies one move-owned participant completion as a distinct later Host + /// turn. No execution adapter survives the participant wait. + pub async fn complete_turn( + &mut self, + host_adapter: &mut crate::DevToolsHostAdapter, + completed: CompletedBrowserHostTurn, + ) -> BrowserHostTurnDispatch { + host_adapter + .bind_browser_host_turn(self) + .complete_browser_host_turn(completed) + .await + } +} + +impl BrowserHostTurnExecution<'_> { + /// Applies one exact participant completion as a later physical owner turn. + /// + /// This remains an async migration adapter because some completion paths + /// still perform Page commit work in Protocol. The Browser Host mailbox is + /// no longer borrowed while the participant itself is pending. + pub async fn complete_browser_host_turn( + &mut self, + completed: CompletedBrowserHostTurn, + ) -> BrowserHostTurnDispatch { + let CompletedBrowserHostTurn { participant } = completed; + match participant { + CompletedBrowserHostTurnParticipant::PageCommand { + completed, + command_context, + completion, + } => { + self.complete_browser_host_page_command_turn(completed, command_context, completion) + .await + } + CompletedBrowserHostTurnParticipant::PageTermination(completed) => { + BrowserHostTurnDispatch::complete( + crate::domains::page::complete_page_target_termination_owner_task( + self, completed, + ), + ) + } + CompletedBrowserHostTurnParticipant::StopLoading { + completed, + command_context, + reply, + } => { + let step = crate::domains::page::complete_page_owned_stop_loading_owner_task( + self, completed, + ) + .await; + self.browser_host_dispatch_from_stop_loading_step(step, command_context, reply) + } + CompletedBrowserHostTurnParticipant::ContextDisposal { completed, reply } => { + let step = crate::domains::target::complete_browser_context_disposal_owner_task( + self, *completed, + ) + .await; + self.browser_host_dispatch_from_context_disposal_step(step, reply) + } + CompletedBrowserHostTurnParticipant::PausedNavigationDecision { completed, reply } => { + let step = crate::domains::fetch::complete_page_owned_paused_navigation_decision_owner_task( + self, + *completed, + ) + .await; + self.browser_host_dispatch_from_paused_navigation_decision_step(step, reply) + } + CompletedBrowserHostTurnParticipant::AuxiliaryTargetActivation { + completed, + transition, + } => { + let activation_succeeded = match completed { + Some(completed) => match self + .finish_promote_background_target_to_active_for_connection(completed) + { + Ok(true) => true, + Ok(false) => false, + Err(error) => { + tracing::debug!( + %error, + "popup Target activation synchronization could not complete" + ); + false + } + }, + None => true, + }; + let protocol_events = if activation_succeeded { + self.complete_staged_target_activation_async(&transition) + .await + .into_protocol_events() + } else { + Vec::new() + }; + BrowserHostTurnDispatch::complete(CdpTurnOutcome::new_with_protocol_events( + protocol_events, + self.take_scheduler_events(), + )) + } + CompletedBrowserHostTurnParticipant::TargetClose(completed) => { + let step = crate::domains::page::complete_target_close_owner_task(self, completed); + self.browser_host_dispatch_from_target_close_step(step) + } + } + } + + fn start_browser_host_auxiliary_target_activation( + &mut self, + browser_context_handle: BrowserContextHandle, + target_handle: BrowserTargetHandle, + prefix_protocol_events: Vec, + prefix_scheduler_events: Vec, + ) -> BrowserHostTurnDispatch { + let browser_context_id = browser_context_handle.browser_context_id().to_owned(); + let target_id = target_handle.target_id().to_owned(); + let core_is_current_and_selected = { + let browser_host_state = self.browser_host_state(); + let owner = browser_host_state.navigation_owner(); + owner.selected_browser_context_id() == Some(browser_context_id.as_str()) + && owner.browser_context_handle_is_current(&browser_context_handle) + && owner.target_handle_is_current(&target_handle) + }; + let physical_is_current = self + .browser_context + .as_ref() + .is_some_and(|browser_context| { + browser_context.browser_context_handle() == &browser_context_handle + && browser_context.top_level_target_handle(&target_id) == Some(&target_handle) + && (browser_context.is_active_target(&target_id) + || browser_context.background_target(&target_id).is_some()) + }); + if !core_is_current_and_selected || !physical_is_current { + tracing::debug!( + browser_context_id, + target_id, + "dropping popup activation after its exact selected Target retired or moved" + ); + return BrowserHostTurnDispatch::complete(CdpTurnOutcome::new_with_protocol_events( + prefix_protocol_events, + prefix_scheduler_events, + )); + } + + let previous_active_target_id = self + .browser_context + .as_ref() + .and_then(|browser_context| browser_context.active_target_id_owned()); + let transition = + TargetActivationTransition::new(target_id.clone(), previous_active_target_id); + let pending = + match self.start_promote_background_target_to_active_for_connection(&target_id) { + Ok(BrowserTargetPromotionStart::Complete(true)) => None, + Ok(BrowserTargetPromotionStart::Pending(pending)) => Some(pending), + Ok(BrowserTargetPromotionStart::Complete(false)) => { + tracing::debug!( + browser_context_id, + target_id, + "dropping popup activation after its Target lost foreground eligibility" + ); + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + prefix_protocol_events, + prefix_scheduler_events, + ), + ); + } + Err(error) => { + tracing::debug!( + %error, + browser_context_id, + target_id, + "popup Target could not begin activation" + ); + return BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events( + prefix_protocol_events, + prefix_scheduler_events, + ), + ); + } + }; + BrowserHostTurnDispatch::pending( + CdpTurnOutcome::new_with_protocol_events( + prefix_protocol_events, + prefix_scheduler_events, + ), + PendingBrowserHostTurn { + participant: PendingBrowserHostTurnParticipant::AuxiliaryTargetActivation { + pending, + transition, + }, + }, + ) + } +} + +impl CdpConnection { + #[cfg(test)] + pub async fn complete_browser_host_turn( + &mut self, + completed: CompletedBrowserHostTurn, + ) -> BrowserHostTurnDispatch { + let mut owner = BrowserHostTurnExecutorOwner::for_application_owner_lane(); + owner + .bind_turn(self) + .complete_browser_host_turn(completed) + .await + } +} + +impl BrowserHostTurnExecution<'_> { + async fn complete_browser_host_page_command_turn( + &mut self, + completed: CompletedPageCommandDispatch, + mut command_context: CommandDispatchContext, + mut completion: BrowserHostPageStepCompletion, + ) -> BrowserHostTurnDispatch { + if completed.renderer_accepted_same_document_history_traversal() == Some(false) + && let BrowserHostPageStepCompletion::FrontendNavigation { + history_traversal, .. + } = &mut completion + && matches!( + history_traversal, + Some(BrowserHistoryTraversalResult::SameDocument) + ) + { + *history_traversal = Some(BrowserHistoryTraversalResult::CrossDocument); + } + let step = crate::domains::page::complete_pending_page_command( + self, + completed, + &mut command_context, + ) + .await; + self.browser_host_dispatch_from_page_step_with_completion(step, command_context, completion) + } +} + +impl CdpConnection { + #[cfg(test)] + pub(crate) async fn finish_browser_host_turn_for_test( + &mut self, + mut dispatch: BrowserHostTurnDispatch, + ) -> CdpRendererOwnerTurnOutcome { + let mut protocol_events = Vec::new(); + let mut post_renderer_output_events = Vec::new(); + let mut renderer_output_boundary = None; + let mut post_response_events = Vec::new(); + let mut scheduler_events = Vec::new(); + let mut renderer_output_predecessor = None; + loop { + let (outcome, pending) = dispatch.into_parts(); + let ( + events, + post_boundary_events, + boundary, + outcome_post_response_events, + nested_scheduler_events, + predecessor, + ) = outcome.into_renderer_owner_turn_parts(); + if renderer_output_boundary.is_some() { + assert!( + boundary.is_none(), + "one Browser Host action cannot introduce multiple renderer boundaries" + ); + post_renderer_output_events.extend(events); + post_renderer_output_events.extend(post_boundary_events); + } else { + protocol_events.extend(events); + if let Some(boundary) = boundary { + renderer_output_boundary = Some(boundary); + post_renderer_output_events.extend(post_boundary_events); + } else { + assert!( + post_boundary_events.is_empty(), + "post-renderer output requires an exact boundary" + ); + } + } + post_response_events.extend(outcome_post_response_events); + scheduler_events.extend(nested_scheduler_events); + if let Some(predecessor) = predecessor { + predecessor.merge_into_same_stream_tail(&mut renderer_output_predecessor); + } + let Some(pending) = pending else { + return CdpTurnOutcome::new_with_protocol_and_post_response_events( + protocol_events, + post_response_events, + scheduler_events, + ) + .with_renderer_output_boundary( + renderer_output_boundary, + post_renderer_output_events, + ) + .with_renderer_output_predecessor(renderer_output_predecessor); + }; + dispatch = self.complete_browser_host_turn(pending.wait().await).await; + } + } + + fn browser_host_dispatch_from_page_step( + &mut self, + step: PageCommandTaskStep, + command_context: CommandDispatchContext, + ) -> BrowserHostTurnDispatch { + self.browser_host_dispatch_from_page_step_with_completion( + step, + command_context, + BrowserHostPageStepCompletion::BrowserEffects, + ) + } + + fn browser_host_dispatch_from_page_step_with_completion( + &mut self, + step: PageCommandTaskStep, + command_context: CommandDispatchContext, + completion: BrowserHostPageStepCompletion, + ) -> BrowserHostTurnDispatch { + let scheduler_events = self.take_scheduler_events(); + match step { + PageCommandTaskStep::Complete(plan) => match completion { + BrowserHostPageStepCompletion::BrowserEffects => { + let (_, protocol_events) = plan.into_command_status_and_background_events(); + BrowserHostTurnDispatch::complete(CdpTurnOutcome::new_with_protocol_events( + protocol_events, + scheduler_events, + )) + } + BrowserHostPageStepCompletion::InitialTargetNavigationCommand { reply } => self + .complete_browser_host_initial_target_navigation_command_projection( + plan, reply, + ), + BrowserHostPageStepCompletion::FrontendNavigation { + requested_url, + history_traversal, + accepted_background_result, + reply, + } => { + let (mut outcome, projection) = + plan.into_browser_navigate_outcome_and_projection(requested_url.as_str()); + if outcome.is_none() + && let Some(result) = accepted_background_result + { + // wait:none reaches its response boundary when this + // actor-selected start has admitted the detached + // navigation. DCL/load remain later Browser facts. + outcome = Some(BrowserNavigateCommandOutcome::Completed(result)); + } + if let Some(history_traversal) = history_traversal { + outcome = outcome + .map(|outcome| outcome.with_history_traversal(history_traversal)); + } + let completed = CompletedBrowserOwnerNavigationCommand::new( + outcome, + projection, + command_context, + ); + match reply.send(completed) { + Ok(()) => BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events(Vec::new(), scheduler_events), + ), + Err(completed) => { + let (_, projection, command_context) = completed.into_parts(); + BrowserHostTurnDispatch::complete( + self.settle_detached_command_projection( + command_context, + projection.into_browser_effects_plan(), + scheduler_events, + ), + ) + } + } + } + }, + PageCommandTaskStep::Pending(pending) => BrowserHostTurnDispatch::pending( + CdpTurnOutcome::new_with_protocol_events(Vec::new(), scheduler_events), + PendingBrowserHostTurn { + participant: PendingBrowserHostTurnParticipant::PageCommand { + pending, + command_context, + completion, + }, + }, + ), + } + } + + fn browser_host_dispatch_from_page_termination_step( + &mut self, + step: PageTargetTerminationOwnerTaskStep, + ) -> BrowserHostTurnDispatch { + match step { + PageTargetTerminationOwnerTaskStep::Complete(outcome) => { + BrowserHostTurnDispatch::complete(outcome) + } + PageTargetTerminationOwnerTaskStep::Pending(pending) => { + BrowserHostTurnDispatch::pending( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + PendingBrowserHostTurn { + participant: PendingBrowserHostTurnParticipant::PageTermination(*pending), + }, + ) + } + } + } + + fn complete_browser_host_initial_target_navigation_command_projection( + &mut self, + plan: crate::domains::command_output::CommandOutputPlan, + reply: tokio::sync::oneshot::Sender, + ) -> BrowserHostTurnDispatch { + let scheduler_events = self.take_scheduler_events(); + let completed = CompletedBrowserOwnerInitialTargetNavigationCommand::new(plan); + match reply.send(completed) { + Ok(()) => BrowserHostTurnDispatch::complete(CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + scheduler_events, + )), + Err(completed) => { + BrowserHostTurnDispatch::complete(self.settle_detached_command_projection( + CommandDispatchContext::default(), + completed.into_plan(), + scheduler_events, + )) + } + } + } + + fn browser_host_dispatch_from_target_close_step( + &mut self, + step: TargetCloseOwnerTaskStep, + ) -> BrowserHostTurnDispatch { + match step { + TargetCloseOwnerTaskStep::Complete(outcome) => { + BrowserHostTurnDispatch::complete(outcome) + } + TargetCloseOwnerTaskStep::Pending(pending) => BrowserHostTurnDispatch::pending( + CdpTurnOutcome::new_with_protocol_events(Vec::new(), self.take_scheduler_events()), + PendingBrowserHostTurn { + participant: PendingBrowserHostTurnParticipant::TargetClose(*pending), + }, + ), + } + } + + fn browser_host_dispatch_from_context_disposal_step( + &mut self, + step: BrowserContextDisposalOwnerTaskStep, + reply: tokio::sync::oneshot::Sender, + ) -> BrowserHostTurnDispatch { + match step { + BrowserContextDisposalOwnerTaskStep::Pending(pending) => { + BrowserHostTurnDispatch::pending( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + PendingBrowserHostTurn { + participant: PendingBrowserHostTurnParticipant::ContextDisposal { + pending, + reply, + }, + }, + ) + } + BrowserContextDisposalOwnerTaskStep::Complete(output) => { + let (plan, command_context) = output.into_parts(); + let scheduler_events = self.take_scheduler_events(); + let completed = + CompletedBrowserOwnerContextDisposalCommand::new(plan, command_context); + match reply.send(completed) { + Ok(()) => BrowserHostTurnDispatch::complete( + CdpTurnOutcome::new_with_protocol_events(Vec::new(), scheduler_events), + ), + Err(completed) => { + let (plan, command_context) = completed.into_parts(); + BrowserHostTurnDispatch::complete(self.settle_detached_command_projection( + command_context, + plan, + scheduler_events, + )) + } + } + } + } + } + + fn browser_host_dispatch_from_stop_loading_step( + &mut self, + step: StopLoadingOwnerTaskStep, + command_context: CommandDispatchContext, + reply: tokio::sync::oneshot::Sender, + ) -> BrowserHostTurnDispatch { + match step { + StopLoadingOwnerTaskStep::Complete(plan) => { + self.complete_browser_host_stop_loading_projection(plan, command_context, reply) + } + StopLoadingOwnerTaskStep::Pending(pending) => BrowserHostTurnDispatch::pending( + CdpTurnOutcome::new_with_protocol_events(Vec::new(), self.take_scheduler_events()), + PendingBrowserHostTurn { + participant: PendingBrowserHostTurnParticipant::StopLoading { + pending, + command_context, + reply, + }, + }, + ), + } + } + + fn complete_browser_host_stop_loading_projection( + &mut self, + plan: crate::domains::command_output::CommandOutputPlan, + command_context: CommandDispatchContext, + reply: tokio::sync::oneshot::Sender, + ) -> BrowserHostTurnDispatch { + let scheduler_events = self.take_scheduler_events(); + let completed = CompletedBrowserOwnerStopLoadingCommand::new(plan, command_context); + match reply.send(completed) { + Ok(()) => BrowserHostTurnDispatch::complete(CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + scheduler_events, + )), + Err(completed) => { + let (plan, command_context) = completed.into_parts(); + BrowserHostTurnDispatch::complete(self.settle_detached_command_projection( + command_context, + plan, + scheduler_events, + )) + } + } + } + + fn browser_host_dispatch_from_paused_navigation_decision_step( + &mut self, + step: PausedNavigationDecisionOwnerTaskStep, + reply: tokio::sync::oneshot::Sender, + ) -> BrowserHostTurnDispatch { + match step { + PausedNavigationDecisionOwnerTaskStep::Complete(output) => { + let (plan, command_context) = output.into_parts(); + self.complete_browser_host_paused_navigation_decision_projection( + plan, + command_context, + reply, + ) + } + PausedNavigationDecisionOwnerTaskStep::Pending(pending) => { + BrowserHostTurnDispatch::pending( + CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + self.take_scheduler_events(), + ), + PendingBrowserHostTurn { + participant: PendingBrowserHostTurnParticipant::PausedNavigationDecision { + pending, + reply, + }, + }, + ) + } + } + } + + fn complete_browser_host_paused_navigation_decision_projection( + &mut self, + plan: crate::domains::command_output::CommandOutputPlan, + command_context: CommandDispatchContext, + reply: tokio::sync::oneshot::Sender, + ) -> BrowserHostTurnDispatch { + let scheduler_events = self.take_scheduler_events(); + let completed = + CompletedBrowserOwnerPausedNavigationDecisionCommand::new(plan, command_context); + match reply.send(completed) { + Ok(()) => BrowserHostTurnDispatch::complete(CdpTurnOutcome::new_with_protocol_events( + Vec::new(), + scheduler_events, + )), + Err(completed) => { + let (plan, command_context) = completed.into_parts(); + BrowserHostTurnDispatch::complete(self.settle_detached_command_projection( + command_context, + plan, + scheduler_events, + )) + } + } + } +} diff --git a/moli-protocol/src/conn/browser_initial_document.rs b/moli-protocol/src/conn/browser_initial_document.rs new file mode 100644 index 0000000000..757a7de51c --- /dev/null +++ b/moli-protocol/src/conn/browser_initial_document.rs @@ -0,0 +1,109 @@ +#[cfg(test)] +use moli_core::browser_host::{BrowserInitialEmptyDocumentSeed, BrowserTargetRegistryError}; +use moli_core::browser_host::{BrowserInitialEmptyDocumentSnapshot, BrowserPageOwnerKey}; + +use super::CdpConnection; + +/// Projection adapters from frontend/session routing to Browser Core's +/// initial-empty-Document authority. +/// +/// Production creation metadata is installed by the Core Target or +/// BrowserContext registration transaction. The physical BrowserContext is +/// consulted here only for Page presence; metadata, loader identity, and +/// lifecycle flags are never mirrored into protocol parking state. +impl CdpConnection { + #[cfg(test)] + pub(crate) fn register_target_initial_empty_document_for_test( + &mut self, + owner: &BrowserPageOwnerKey, + seed: BrowserInitialEmptyDocumentSeed, + ) -> Result<(), BrowserTargetRegistryError> { + self.browser_host_state + .register_target_initial_empty_document(owner, seed) + } + + pub(crate) fn target_initial_empty_document_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Option { + let owner = self.target_page_owner_key_for_session(session_id)?; + self.browser_host_state + .navigation_owner() + .target_initial_empty_document(&owner) + } + + #[cfg(test)] + pub(crate) fn target_initial_empty_document_for_owner( + &self, + owner: &BrowserPageOwnerKey, + ) -> Option { + self.browser_host_state + .navigation_owner() + .target_initial_empty_document(owner) + } + + pub(crate) fn mark_target_initial_empty_document_exited_for_session_owner( + &mut self, + session_id: Option<&str>, + ) -> bool { + let Some(owner) = self.target_page_owner_key_for_session(session_id) else { + return false; + }; + self.browser_host_state + .mark_target_initial_empty_document_exited(&owner); + true + } + + pub(crate) fn can_install_target_initial_empty_document_page( + &self, + owner: &BrowserPageOwnerKey, + ) -> bool { + if !self + .browser_host_state + .navigation_owner() + .can_install_target_initial_empty_document_page(owner) + { + return false; + } + let Some(browser_context) = self.browser_context_by_id(owner.browser_context_id()) else { + return false; + }; + if browser_context.active_target_id() == Some(owner.target_id()) { + return !browser_context.has_loaded_page(); + } + browser_context + .background_target(owner.target_id()) + .is_some_and(|target| !target.has_loaded_page()) + } + + pub(crate) fn assert_target_materialized_initial_empty_document_has_page( + &self, + owner: &BrowserPageOwnerKey, + ) -> Result<(), String> { + let materialized_current = self + .browser_host_state + .navigation_owner() + .target_initial_empty_document(owner) + .is_some_and(|state| state.is_on_initial_empty_document() && state.materialized()); + if !materialized_current { + return Ok(()); + } + let has_loaded_page = self + .browser_context_by_id(owner.browser_context_id()) + .is_some_and(|browser_context| { + if browser_context.active_target_id() == Some(owner.target_id()) { + return browser_context.has_loaded_page(); + } + browser_context + .background_target(owner.target_id()) + .is_some_and(|target| target.has_loaded_page()) + }); + if has_loaded_page { + return Ok(()); + } + Err(format!( + "TargetInitialEmptyDocumentMissingPage: target {} has materialized current initial empty document without loaded Page", + owner.target_id() + )) + } +} diff --git a/moli-protocol/src/conn/browser_navigation_history.rs b/moli-protocol/src/conn/browser_navigation_history.rs new file mode 100644 index 0000000000..72e24c70b0 --- /dev/null +++ b/moli-protocol/src/conn/browser_navigation_history.rs @@ -0,0 +1,557 @@ +use moli_core::{ + browser_host::{ + BrowserExactHistoryTraversalResolutionError, BrowserHistoryTraversalDestination, + BrowserHistoryTraversalResolution, BrowserHistoryTraversalResolutionError, + BrowserNavigationHistoryPageSnapshot, BrowserNavigationHistorySeed, BrowserPageOwnerKey, + BrowserSameDocumentNavigationCommitError, + }, + page::SameDocumentHistoryUpdate, +}; +use url::Url; + +use super::{ + BackgroundTarget, BrowserContext, CdpConnection, PageNavigationHistoryEntry, + TargetPageResidenceIdentity, +}; + +enum PhysicalSameDocumentTargetIdentityProjection<'a> { + Active(&'a mut BrowserContext), + Background(&'a mut BackgroundTarget), +} + +impl PhysicalSameDocumentTargetIdentityProjection<'_> { + fn commit(self, url: &Url) { + let next_url = url.to_string(); + let security_origin = url.origin().ascii_serialization(); + match self { + Self::Active(browser_context) => { + browser_context.set_target_url(next_url); + browser_context.set_target_security_origin(security_origin); + } + Self::Background(target) => { + target.set_target_url(next_url); + target.set_target_security_origin(security_origin); + } + } + } +} + +/// A same-Document renderer fact must commit in Browser Core before Protocol +/// projects physical Target metadata or frontend events. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum SameDocumentNavigationProjectionError { + PageResidenceRouteMismatch { + expected: Box, + current: Option>, + }, + PhysicalBrowserContextUnavailable { + browser_context_id: String, + }, + PhysicalTargetUnavailable { + browser_context_id: String, + target_id: String, + }, + BrowserHistory(BrowserSameDocumentNavigationCommitError), +} + +impl std::fmt::Display for SameDocumentNavigationProjectionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PageResidenceRouteMismatch { expected, current } => write!( + formatter, + "same-Document navigation route no longer addresses Page {expected:?}; current={current:?}" + ), + Self::PhysicalBrowserContextUnavailable { browser_context_id } => write!( + formatter, + "physical BrowserContext {browser_context_id:?} is unavailable for same-Document navigation" + ), + Self::PhysicalTargetUnavailable { + browser_context_id, + target_id, + } => write!( + formatter, + "physical Target {target_id:?} in BrowserContext {browser_context_id:?} is unavailable for same-Document navigation" + ), + Self::BrowserHistory(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for SameDocumentNavigationProjectionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::BrowserHistory(error) => Some(error), + _ => None, + } + } +} + +impl From for SameDocumentNavigationProjectionError { + fn from(error: BrowserSameDocumentNavigationCommitError) -> Self { + Self::BrowserHistory(error) + } +} + +/// Migration adapter from frontend/session routing to Browser Core history. +/// +/// Session ids are resolved once into a protocol-neutral target key. The core +/// registry remains the sole owner of entries, cursor, allocators, and pending +/// replace/traversal state. +impl CdpConnection { + fn navigation_history_loaded_page_snapshot_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Option { + let page = self + .runtime_session_owner_slot(session_id) + .ok()? + .loaded_page()?; + Some(BrowserNavigationHistoryPageSnapshot::new( + page.final_url().to_string(), + page.document_title(), + )) + } + + fn navigation_history_same_document_page_snapshot_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Option { + let title = self + .runtime_session_owner_slot(session_id) + .ok()? + .loaded_page()? + .document_title(); + Some(BrowserNavigationHistoryPageSnapshot::new( + self.runtime_session_owner_target_url(session_id)?, + title, + )) + } + + fn navigation_history_query_seed_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Option { + self.navigation_history_loaded_page_snapshot_for_session_owner(session_id) + .map(BrowserNavigationHistorySeed::page_snapshot) + } + + fn navigation_history_same_document_seed_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Option { + self.navigation_history_same_document_page_snapshot_for_session_owner(session_id) + .map(BrowserNavigationHistorySeed::page_snapshot) + } + + fn navigation_history_owner_for_session( + &self, + session_id: Option<&str>, + ) -> Option { + self.target_page_owner_key_for_session(session_id) + } + + pub(crate) fn target_session_owner_navigation_history_snapshot( + &mut self, + session_id: Option<&str>, + ) -> Option<(usize, Vec)> { + let owner = self.navigation_history_owner_for_session(session_id)?; + let seed = self.navigation_history_query_seed_for_session_owner(session_id); + Some( + self.browser_host_state + .navigation_history_snapshot(&owner, seed), + ) + } + + pub(crate) fn resolve_navigation_history_traversal_for_session_owner( + &mut self, + session_id: Option<&str>, + destination: BrowserHistoryTraversalDestination, + ) -> Option> + { + let owner = self.navigation_history_owner_for_session(session_id)?; + let seed = self.navigation_history_query_seed_for_session_owner(session_id); + Some( + self.browser_host_state + .resolve_navigation_history_traversal(&owner, seed, destination), + ) + } + + pub(crate) fn resolve_exact_navigation_history_traversal_for_session_owner( + &mut self, + session_id: Option<&str>, + expected_page: &TargetPageResidenceIdentity, + destination: BrowserHistoryTraversalDestination, + ) -> Result + { + let seed = self.navigation_history_query_seed_for_session_owner(session_id); + self.browser_host_state + .resolve_exact_navigation_history_traversal(expected_page, seed, destination) + } + + pub(crate) fn reset_navigation_history_for_session_owner( + &mut self, + session_id: Option<&str>, + ) -> Option { + let owner = self.navigation_history_owner_for_session(session_id)?; + let seed = self.navigation_history_query_seed_for_session_owner(session_id); + Some( + self.browser_host_state + .reset_navigation_history(&owner, seed), + ) + } + + pub(crate) fn can_reset_navigation_history_for_session_owner( + &mut self, + session_id: Option<&str>, + ) -> Option { + let owner = self.navigation_history_owner_for_session(session_id)?; + let seed = self.navigation_history_query_seed_for_session_owner(session_id); + Some( + self.browser_host_state + .can_reset_navigation_history(&owner, seed), + ) + } + + pub(crate) fn mark_next_navigation_history_replace_current_for_session_owner( + &mut self, + session_id: Option<&str>, + ) -> Option<()> { + let owner = self.navigation_history_owner_for_session(session_id)?; + self.browser_host_state + .mark_next_navigation_history_replace_current(&owner); + Some(()) + } + + pub(crate) fn mark_next_navigation_history_replace_initial_empty_document_for_owner( + &mut self, + owner: &BrowserPageOwnerKey, + ) { + self.browser_host_state + .mark_next_navigation_history_replace_initial_empty_document(owner); + } + + pub(crate) fn mark_next_navigation_history_traverse_to_entry_for_session_owner( + &mut self, + session_id: Option<&str>, + entry_id: i32, + ) -> Option<()> { + let owner = self.navigation_history_owner_for_session(session_id)?; + self.browser_host_state + .mark_next_navigation_history_traverse_to_entry(&owner, entry_id); + Some(()) + } + + pub(crate) fn clear_pending_navigation_history_update_for_session_owner( + &mut self, + session_id: Option<&str>, + ) -> Option<()> { + let owner = self.navigation_history_owner_for_session(session_id)?; + self.browser_host_state + .clear_pending_navigation_history_update(&owner); + Some(()) + } + + pub(crate) fn record_same_document_navigation_for_session_owner( + &mut self, + session_id: Option<&str>, + expected_page: &TargetPageResidenceIdentity, + url: &Url, + history_update: SameDocumentHistoryUpdate, + ) -> Result { + let current_page = self.target_page_residence_identity_for_session(session_id); + if current_page.as_ref() != Some(expected_page) { + return Err( + SameDocumentNavigationProjectionError::PageResidenceRouteMismatch { + expected: Box::new(expected_page.clone()), + current: current_page.map(Box::new), + }, + ); + } + let browser_context_id = expected_page.browser_context_id().to_owned(); + let Some(target_id) = expected_page.target_id().map(str::to_owned) else { + return Err( + SameDocumentNavigationProjectionError::PhysicalTargetUnavailable { + browser_context_id, + target_id: "".to_owned(), + }, + ); + }; + let seed = self.navigation_history_same_document_seed_for_session_owner(session_id); + let title = self + .runtime_session_owner_slot(session_id) + .ok() + .and_then(|slot| slot.loaded_page()) + .map(|page| page.document_title()) + .unwrap_or_default(); + + // Split the connection's Browser Owner and physical Target fields so + // the exact physical participant remains mutably borrowed across the + // Core commit. No lookup or fallible participant remains after Core + // accepts the history update. + let browser_host_state = self.browser_host_state.clone(); + let browser_context = if self + .browser_context + .as_ref() + .is_some_and(|context| context.id == browser_context_id) + { + self.browser_context.as_mut() + } else { + self.inactive_browser_contexts + .iter_mut() + .find(|context| context.id == browser_context_id) + } + .ok_or_else(|| { + SameDocumentNavigationProjectionError::PhysicalBrowserContextUnavailable { + browser_context_id: browser_context_id.clone(), + } + })?; + let physical_target = if browser_context.is_active_target(&target_id) { + PhysicalSameDocumentTargetIdentityProjection::Active(browser_context) + } else { + let target = browser_context + .background_target_mut(&target_id) + .ok_or_else(|| { + SameDocumentNavigationProjectionError::PhysicalTargetUnavailable { + browser_context_id: browser_context_id.clone(), + target_id: target_id.clone(), + } + })?; + PhysicalSameDocumentTargetIdentityProjection::Background(target) + }; + + browser_host_state.commit_same_document_navigation_history( + expected_page, + seed, + url.to_string(), + title, + history_update, + )?; + physical_target.commit(url); + Ok(target_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_document_history_is_seeded_by_core_through_target_adapter() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + + let (current_index, entries) = conn + .target_session_owner_navigation_history_snapshot(None) + .expect("default target should expose navigation history"); + + assert_eq!(current_index, 0); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, 1); + assert_eq!(entries[0].url, "about:blank"); + assert_eq!(entries[0].transition_type, "auto_toplevel"); + } + + #[test] + fn rejected_same_document_history_does_not_project_physical_target_identity() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let expected_page = conn + .target_page_residence_identity_for_session(None) + .expect("default target should expose its exact Page residence"); + let before_history = conn + .target_session_owner_navigation_history_snapshot(None) + .expect("default target should expose navigation history"); + let before_url = conn + .browser_context + .as_ref() + .expect("default BrowserContext") + .target_url() + .to_owned(); + let before_security_origin = conn + .browser_context + .as_ref() + .expect("default BrowserContext") + .target_security_origin() + .to_owned(); + + let error = conn + .record_same_document_navigation_for_session_owner( + None, + &expected_page, + &Url::parse("https://example.test/missing").expect("valid test URL"), + SameDocumentHistoryUpdate::Traverse { delta: -1 }, + ) + .expect_err("out-of-range traversal must reject the joint projection"); + + assert!(matches!( + error, + SameDocumentNavigationProjectionError::BrowserHistory( + BrowserSameDocumentNavigationCommitError::History( + moli_core::browser_host::BrowserSameDocumentHistoryUpdateError::TraversalTargetOutsideHistory { + current_index: 0, + delta: -1, + entry_count: 1, + } + ) + ) + )); + let browser_context = conn + .browser_context + .as_ref() + .expect("default BrowserContext"); + assert_eq!(browser_context.target_url(), before_url); + assert_eq!( + browser_context.target_security_origin(), + before_security_origin + ); + assert_eq!( + conn.target_session_owner_navigation_history_snapshot(None), + Some(before_history) + ); + } + + #[test] + fn protocol_page_slot_cleanup_does_not_own_or_clear_target_history() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let before = conn + .target_session_owner_navigation_history_snapshot(None) + .expect("default target should expose navigation history"); + + conn.browser_context + .as_mut() + .expect("default browser context") + .active_target + .owner_state + .clear_page_local_state(); + + assert_eq!( + conn.target_session_owner_navigation_history_snapshot(None), + Some(before) + ); + } + + #[test] + fn protocol_adapter_clears_only_the_pending_history_update() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let key = conn + .target_page_owner_key_for_session(None) + .expect("default target owner"); + let _ = conn + .target_session_owner_navigation_history_snapshot(None) + .expect("seed initial history"); + conn.browser_host_state + .record_loaded_page_navigation_history( + &key, + BrowserNavigationHistoryPageSnapshot::new("https://old.example/", "old"), + ); + conn.mark_next_navigation_history_replace_current_for_session_owner(None) + .expect("mark pending replace"); + conn.clear_pending_navigation_history_update_for_session_owner(None) + .expect("clear pending replace"); + conn.browser_host_state + .record_loaded_page_navigation_history( + &key, + BrowserNavigationHistoryPageSnapshot::new("https://new.example/", "new"), + ); + + let (_, entries) = conn + .target_session_owner_navigation_history_snapshot(None) + .expect("history snapshot"); + assert_eq!( + entries + .iter() + .map(|entry| entry.url.as_str()) + .collect::>(), + vec![ + "about:blank", + "https://old.example/", + "https://new.example/" + ] + ); + } + + #[tokio::test] + async fn crashed_target_history_does_not_reseed_from_protocol_metadata() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let _ = conn + .target_session_owner_navigation_history_snapshot(None) + .expect("seed initial history"); + + let termination = conn + .capture_browser_target_termination_for_session_owner( + None, + crate::conn::BrowserTargetTerminationProjectionKind::Crash, + ) + .expect("default target should capture crash"); + let mut events = Vec::new(); + assert!(matches!( + conn.commit_browser_target_termination_async( + termination, + crate::conn::BrowserTargetTerminationProjectionKind::Crash, + &mut events, + "Page crashed", + ) + .await, + Some(crate::conn::BrowserTargetTerminationProjection::Crashed { .. }) + )); + + let (_, entries) = conn + .target_session_owner_navigation_history_snapshot(None) + .expect("crashed target remains addressable"); + assert!(entries.is_empty()); + } + + #[tokio::test] + async fn failed_navigation_page_discard_preserves_target_history() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let before = conn + .target_session_owner_navigation_history_snapshot(None) + .expect("seed initial history"); + let previous_page = conn + .target_page_residence_identity_for_session(None) + .expect("default target should expose its loaded Page residence"); + let navigation = conn + .start_document_navigation_for_session_owner(None, "LOADER-failed".to_owned()) + .expect("default Target should start failed navigation"); + + conn.discard_loaded_page_after_failed_navigation_for_session_owner_async( + None, + &navigation, + moli_core::browser_host::BrowserNavigationFailure::Network { + error_text: "net::ERR_FAILED".to_owned(), + }, + &Url::parse("https://failed.example/").expect("failure URL"), + ) + .await + .expect("default target should discard failed Page"); + + assert_eq!( + conn.target_session_owner_navigation_history_snapshot(None), + Some(before) + ); + let absent_page = conn + .target_page_residence_identity_for_session(None) + .expect("failed navigation keeps the Target Page slot registered"); + assert_eq!( + absent_page.loaded_page_generation(), + previous_page.loaded_page_generation() + 1, + "Core must advance the failed-navigation Page generation exactly once" + ); + assert!( + conn.target_page_owner_route_if_current(&previous_page) + .is_none(), + "failed navigation must invalidate work captured from the discarded Page" + ); + assert!( + conn.runtime_session_owner_slot(None) + .expect("default Target remains routable") + .loaded_page() + .is_none(), + "Protocol must synchronously project Page absence after the Core commit" + ); + } +} diff --git a/moli-protocol/src/conn/browser_owner_input.rs b/moli-protocol/src/conn/browser_owner_input.rs new file mode 100644 index 0000000000..8072629a5a --- /dev/null +++ b/moli-protocol/src/conn/browser_owner_input.rs @@ -0,0 +1,1433 @@ +use moli_core::browser_host::{ + BrowserAuxiliaryNavigationKind, BrowserCommandId, BrowserContextHandle, + BrowserHistoryTraversalDestination, BrowserHostHandle, BrowserNavigateCommandOutcome, + BrowserNavigationTraceEvent, BrowserNavigationTraceSource, BrowserOwnerInput, + BrowserOwnerInputKind, BrowserPausedNavigationDecision, +}; +use serde_json::Value; +use tokio::sync::oneshot; + +use super::{ + CdpConnection, CommandDispatchContext, PausedDocumentTransfer, PendingFetchAuthNavigation, + PendingFetchNavigation, TargetPageResidenceIdentity, +}; + +use crate::domains::command_output::{BrowserNavigateCommandProjection, CommandOutputPlan}; + +pub(crate) struct PreparedBrowserOwnerNavigationCommand { + command_id: Option, + command_session_id: Option, + allow_background_navigation: bool, + result_payload: Value, + command_context: CommandDispatchContext, + reply: oneshot::Sender, +} + +impl PreparedBrowserOwnerNavigationCommand { + pub(crate) fn into_parts( + self, + ) -> ( + Option, + Option, + bool, + Value, + CommandDispatchContext, + oneshot::Sender, + ) { + ( + self.command_id, + self.command_session_id, + self.allow_background_navigation, + self.result_payload, + self.command_context, + self.reply, + ) + } +} + +pub(crate) struct CompletedBrowserOwnerNavigationCommand { + outcome: Option, + projection: BrowserNavigateCommandProjection, + command_context: CommandDispatchContext, +} + +impl CompletedBrowserOwnerNavigationCommand { + pub(crate) fn new( + outcome: Option, + projection: BrowserNavigateCommandProjection, + command_context: CommandDispatchContext, + ) -> Self { + Self { + outcome, + projection, + command_context, + } + } + + pub(crate) fn into_parts( + self, + ) -> ( + Option, + BrowserNavigateCommandProjection, + CommandDispatchContext, + ) { + (self.outcome, self.projection, self.command_context) + } +} + +pub(crate) struct PendingBrowserOwnerNavigationCommand { + reply: oneshot::Receiver, +} + +/// Protocol-owned continuation for a frontend command that depends on the +/// Target's initial creation URL being committed first. +/// +/// Core carries only the opaque `BrowserCommandId`; the enclosing +/// `Page.createIsolatedWorld` task and its CDP route never enter Browser Host. +pub(crate) struct PreparedBrowserOwnerInitialTargetNavigationCommand { + reply: oneshot::Sender, +} + +impl PreparedBrowserOwnerInitialTargetNavigationCommand { + pub(crate) fn into_reply( + self, + ) -> oneshot::Sender { + self.reply + } +} + +pub(crate) struct CompletedBrowserOwnerInitialTargetNavigationCommand { + plan: CommandOutputPlan, +} + +impl CompletedBrowserOwnerInitialTargetNavigationCommand { + pub(crate) fn new(plan: CommandOutputPlan) -> Self { + Self { plan } + } + + pub(crate) fn into_plan(self) -> CommandOutputPlan { + self.plan + } +} + +pub(crate) struct PendingBrowserOwnerInitialTargetNavigationCommand { + reply: oneshot::Receiver, +} + +pub(crate) struct PreparedBrowserOwnerStopLoadingCommand { + command_context: CommandDispatchContext, + reply: oneshot::Sender, +} + +impl PreparedBrowserOwnerStopLoadingCommand { + pub(crate) fn into_parts( + self, + ) -> ( + CommandDispatchContext, + oneshot::Sender, + ) { + (self.command_context, self.reply) + } +} + +pub(crate) struct CompletedBrowserOwnerStopLoadingCommand { + plan: CommandOutputPlan, + command_context: CommandDispatchContext, +} + +impl CompletedBrowserOwnerStopLoadingCommand { + pub(crate) fn new(plan: CommandOutputPlan, command_context: CommandDispatchContext) -> Self { + Self { + plan, + command_context, + } + } + + pub(crate) fn into_parts(self) -> (CommandOutputPlan, CommandDispatchContext) { + (self.plan, self.command_context) + } +} + +pub(crate) struct PendingBrowserOwnerStopLoadingCommand { + reply: oneshot::Receiver, +} + +pub(crate) struct PreparedBrowserOwnerContextDisposalCommand { + prefix_events: Vec, + command_context: CommandDispatchContext, + reply: oneshot::Sender, +} + +impl PreparedBrowserOwnerContextDisposalCommand { + pub(crate) fn into_parts( + self, + ) -> ( + Vec, + CommandDispatchContext, + oneshot::Sender, + ) { + (self.prefix_events, self.command_context, self.reply) + } +} + +pub(crate) struct CompletedBrowserOwnerContextDisposalCommand { + plan: CommandOutputPlan, + command_context: CommandDispatchContext, +} + +impl CompletedBrowserOwnerContextDisposalCommand { + pub(crate) fn new(plan: CommandOutputPlan, command_context: CommandDispatchContext) -> Self { + Self { + plan, + command_context, + } + } + + pub(crate) fn into_parts(self) -> (CommandOutputPlan, CommandDispatchContext) { + (self.plan, self.command_context) + } +} + +pub(crate) struct PendingBrowserOwnerContextDisposalCommand { + reply: oneshot::Receiver, +} + +pub(crate) struct PreparedBrowserOwnerPausedNavigationDecisionCommand { + pending_navigation: BrowserOwnerPausedNavigationSidecar, + command_context: CommandDispatchContext, + reply: oneshot::Sender, +} + +/// Protocol-owned payload paired with one Core paused-navigation decision. +/// +/// Fetch request ids, response bodies, auth response bodies and frontend +/// routing remain in this sidecar. Browser Host serializes only the exact Page +/// and neutral decision; the selected turn later consumes the matching sidecar +/// variant. +pub(crate) enum BrowserOwnerPausedNavigationSidecar { + Request(PendingFetchNavigation), + Response(Box), + Auth(PendingFetchAuthNavigation), +} + +impl PreparedBrowserOwnerPausedNavigationDecisionCommand { + pub(crate) fn into_parts( + self, + ) -> ( + BrowserOwnerPausedNavigationSidecar, + CommandDispatchContext, + oneshot::Sender, + ) { + (self.pending_navigation, self.command_context, self.reply) + } +} + +pub(crate) struct CompletedBrowserOwnerPausedNavigationDecisionCommand { + plan: CommandOutputPlan, + command_context: CommandDispatchContext, +} + +impl CompletedBrowserOwnerPausedNavigationDecisionCommand { + pub(crate) fn new(plan: CommandOutputPlan, command_context: CommandDispatchContext) -> Self { + Self { + plan, + command_context, + } + } + + pub(crate) fn into_parts(self) -> (CommandOutputPlan, CommandDispatchContext) { + (self.plan, self.command_context) + } +} + +pub(crate) struct PendingBrowserOwnerPausedNavigationDecisionCommand { + reply: oneshot::Receiver, +} + +pub(crate) enum PreparedBrowserOwnerCommand { + Navigation(PreparedBrowserOwnerNavigationCommand), + StopLoading(PreparedBrowserOwnerStopLoadingCommand), + ContextDisposal(PreparedBrowserOwnerContextDisposalCommand), + InitialTargetNavigation(PreparedBrowserOwnerInitialTargetNavigationCommand), + PausedNavigationDecision(Box), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PreparedBrowserOwnerCommandKind { + Navigation, + StopLoading, + ContextDisposal, + InitialTargetNavigation, + PausedNavigationDecision, +} + +impl PreparedBrowserOwnerCommand { + fn kind(&self) -> PreparedBrowserOwnerCommandKind { + match self { + Self::Navigation(_) => PreparedBrowserOwnerCommandKind::Navigation, + Self::StopLoading(_) => PreparedBrowserOwnerCommandKind::StopLoading, + Self::ContextDisposal(_) => PreparedBrowserOwnerCommandKind::ContextDisposal, + Self::InitialTargetNavigation(_) => { + PreparedBrowserOwnerCommandKind::InitialTargetNavigation + } + Self::PausedNavigationDecision(_) => { + PreparedBrowserOwnerCommandKind::PausedNavigationDecision + } + } + } +} + +impl PendingBrowserOwnerPausedNavigationDecisionCommand { + pub(crate) async fn wait( + self, + ) -> Result { + self.reply + .await + .map_err(|_| "BrowserOwnerPausedNavigationDecisionCompletionCanceled".to_owned()) + } +} + +pub(crate) struct BrowserOwnerPausedNavigationDecisionPublicationFailure { + error: BrowserOwnerInputPublicationError, + pending_navigation: Option>, +} + +impl BrowserOwnerPausedNavigationDecisionPublicationFailure { + pub(crate) fn into_parts( + self, + ) -> ( + BrowserOwnerInputPublicationError, + Option, + ) { + (self.error, self.pending_navigation.map(|pending| *pending)) + } +} + +impl PendingBrowserOwnerStopLoadingCommand { + pub(crate) async fn wait(self) -> Result { + self.reply + .await + .map_err(|_| "BrowserOwnerStopLoadingCompletionCanceled".to_owned()) + } +} + +impl PendingBrowserOwnerContextDisposalCommand { + pub(crate) async fn wait(self) -> Result { + self.reply + .await + .map_err(|_| "BrowserOwnerContextDisposalCompletionCanceled".to_owned()) + } +} + +impl PendingBrowserOwnerNavigationCommand { + pub(crate) async fn wait(self) -> Result { + self.reply + .await + .map_err(|_| "BrowserOwnerNavigationCompletionCanceled".to_owned()) + } +} + +impl PendingBrowserOwnerInitialTargetNavigationCommand { + pub(crate) async fn wait( + self, + ) -> Result { + self.reply + .await + .map_err(|_| "BrowserOwnerInitialTargetNavigationCompletionCanceled".to_owned()) + } +} + +/// Why a protocol-neutral input could not enter Browser Host. +/// +/// This is an application-composition failure, not a protocol scheduler event; +/// callers may diagnose it but must not recreate a fallback owner queue. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BrowserOwnerInputPublicationError { + HostNotInstalled { kind: BrowserOwnerInputKind }, + HostStopped { kind: BrowserOwnerInputKind }, +} + +impl std::fmt::Display for BrowserOwnerInputPublicationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::HostNotInstalled { kind } => { + write!(formatter, "Browser Host is not installed for {kind:?}") + } + Self::HostStopped { kind } => { + write!(formatter, "Browser Host stopped before accepting {kind:?}") + } + } + } +} + +impl std::error::Error for BrowserOwnerInputPublicationError {} + +impl CdpConnection { + fn allocate_browser_owner_command_id(&mut self) -> BrowserCommandId { + loop { + let command_id = self.browser_host_state.allocate_browser_command_id(); + if !self + .prepared_browser_owner_commands + .contains_key(&command_id) + { + return command_id; + } + } + } + + fn take_prepared_browser_owner_command( + &mut self, + command_id: BrowserCommandId, + expected_kind: PreparedBrowserOwnerCommandKind, + ) -> Option { + let prepared = self.prepared_browser_owner_commands.remove(&command_id)?; + let actual_kind = prepared.kind(); + if actual_kind == expected_kind { + return Some(prepared); + } + + let previous = self + .prepared_browser_owner_commands + .insert(command_id, prepared); + debug_assert!(previous.is_none()); + tracing::error!( + browser_command_id = command_id.get(), + ?expected_kind, + ?actual_kind, + "prepared Browser Owner command kind did not match selected Host input" + ); + None + } + + /// Publishes one frontend-originated top-level navigation. + /// + /// CDP response payload and completion routing remain in Protocol. Core + /// receives only an opaque correlation id, exact Page residence and + /// protocol-neutral navigation parameters. Same-Document classification + /// happens only after the actor selects this exact Page. There is + /// deliberately no direct-execution fallback when Browser Host is + /// unavailable. + pub(crate) fn publish_browser_owner_navigate_command( + &mut self, + frontend_command_id: Option, + frontend_session_id: Option<&str>, + page_owner: TargetPageResidenceIdentity, + url: String, + referrer: Option, + allow_background_navigation: bool, + result_payload: Value, + command_context: CommandDispatchContext, + ) -> Result { + let command_id = self.allocate_browser_owner_command_id(); + let input = BrowserOwnerInput::frontend_navigate(command_id, page_owner, url, referrer); + self.publish_browser_owner_frontend_navigation_command( + command_id, + frontend_command_id, + frontend_session_id, + input, + allow_background_navigation, + result_payload, + command_context, + ) + } + + /// Publishes one frontend-originated top-level reload. + /// + /// The exact Page, not its mutable URL, crosses the frontend boundary. + /// Browser Owner resolves the current URL and starts the reload only after + /// selecting this command from its FIFO mailbox. + pub(crate) fn publish_browser_owner_reload_command( + &mut self, + frontend_command_id: Option, + frontend_session_id: Option<&str>, + page_owner: TargetPageResidenceIdentity, + ignore_cache: bool, + script_to_evaluate_on_load: Option, + allow_background_navigation: bool, + result_payload: Value, + command_context: CommandDispatchContext, + ) -> Result { + let command_id = self.allocate_browser_owner_command_id(); + let input = BrowserOwnerInput::frontend_reload( + command_id, + page_owner, + ignore_cache, + script_to_evaluate_on_load, + ); + self.publish_browser_owner_frontend_navigation_command( + command_id, + frontend_command_id, + frontend_session_id, + input, + allow_background_navigation, + result_payload, + command_context, + ) + } + + /// Publishes one frontend-originated joint session-history traversal. + /// + /// The frontend contributes only a browser destination and exact Page. + /// Browser Core resolves a relative delta against its current cursor, then + /// resolves the URL and Document-sequence relationship after this input + /// wins its Browser Host turn. + pub(crate) fn publish_browser_owner_history_traversal_command( + &mut self, + frontend_command_id: Option, + frontend_session_id: Option<&str>, + page_owner: TargetPageResidenceIdentity, + destination: BrowserHistoryTraversalDestination, + allow_background_navigation: bool, + result_payload: Value, + command_context: CommandDispatchContext, + ) -> Result { + let command_id = self.allocate_browser_owner_command_id(); + let input = + BrowserOwnerInput::frontend_history_traversal(command_id, page_owner, destination); + self.publish_browser_owner_frontend_navigation_command( + command_id, + frontend_command_id, + frontend_session_id, + input, + allow_background_navigation, + result_payload, + command_context, + ) + } + + fn publish_browser_owner_frontend_navigation_command( + &mut self, + command_id: BrowserCommandId, + frontend_command_id: Option, + frontend_session_id: Option<&str>, + input: BrowserOwnerInput, + allow_background_navigation: bool, + result_payload: Value, + command_context: CommandDispatchContext, + ) -> Result { + let (reply, pending_reply) = oneshot::channel(); + self.prepared_browser_owner_commands.insert( + command_id, + PreparedBrowserOwnerCommand::Navigation(PreparedBrowserOwnerNavigationCommand { + // Direct WebDriver commands do not always have a wire command + // id. A foreground Page participant still needs an opaque id + // so the shared navigation pipeline emits its terminal result + // plan. The BrowserCommandId is correlation only and never + // enters Core execution semantics or frontend projection. + command_id: frontend_command_id + .or_else(|| (!allow_background_navigation).then_some(command_id.get())), + command_session_id: frontend_session_id.map(str::to_owned), + allow_background_navigation, + result_payload, + command_context, + reply, + }), + ); + let kind = input.kind(); + let publication = if let Some(browser_host) = self.browser_host_handle.as_ref() { + browser_host.publish(input).map_err(|error| { + BrowserOwnerInputPublicationError::HostStopped { kind: error.kind() } + }) + } else { + Err(BrowserOwnerInputPublicationError::HostNotInstalled { kind }) + }; + if let Err(error) = publication { + let _ = self.take_prepared_browser_owner_navigation_command(command_id); + return Err(error); + } + if moli_trace::cdp_runtime_trace_enabled() { + tracing::info!( + target: "moli_cdp_runtime", + stage = "browser_owner_input_published", + kind = ?kind, + browser_command_id = command_id.get(), + ); + } + Ok(PendingBrowserOwnerNavigationCommand { + reply: pending_reply, + }) + } + + pub(crate) fn take_prepared_browser_owner_navigation_command( + &mut self, + command_id: BrowserCommandId, + ) -> Option { + let prepared = self.take_prepared_browser_owner_command( + command_id, + PreparedBrowserOwnerCommandKind::Navigation, + )?; + let PreparedBrowserOwnerCommand::Navigation(prepared) = prepared else { + unreachable!("prepared command kind was validated") + }; + Some(prepared) + } + + /// Publishes one target-slot-scoped stop-loading command. + /// + /// Frontend correlation remains in the outer Page command task. Browser + /// Core receives only an opaque command id and the captured Page-slot + /// capability; the Host resolves the slot's current Document after FIFO + /// selection and never falls back to direct Protocol execution. + pub(crate) fn publish_browser_owner_stop_loading_command( + &mut self, + page_owner: TargetPageResidenceIdentity, + command_context: CommandDispatchContext, + ) -> Result { + let command_id = self.allocate_browser_owner_command_id(); + let input = BrowserOwnerInput::frontend_stop_loading(command_id, page_owner); + let (reply, pending_reply) = oneshot::channel(); + self.prepared_browser_owner_commands.insert( + command_id, + PreparedBrowserOwnerCommand::StopLoading(PreparedBrowserOwnerStopLoadingCommand { + command_context, + reply, + }), + ); + let kind = input.kind(); + let publication = if let Some(browser_host) = self.browser_host_handle.as_ref() { + browser_host.publish(input).map_err(|error| { + BrowserOwnerInputPublicationError::HostStopped { kind: error.kind() } + }) + } else { + Err(BrowserOwnerInputPublicationError::HostNotInstalled { kind }) + }; + if let Err(error) = publication { + let _ = self.take_prepared_browser_owner_stop_loading_command(command_id); + return Err(error); + } + if moli_trace::cdp_runtime_trace_enabled() { + tracing::info!( + target: "moli_cdp_runtime", + stage = "browser_owner_input_published", + kind = ?kind, + browser_command_id = command_id.get(), + ); + } + Ok(PendingBrowserOwnerStopLoadingCommand { + reply: pending_reply, + }) + } + + pub(crate) fn take_prepared_browser_owner_stop_loading_command( + &mut self, + command_id: BrowserCommandId, + ) -> Option { + let prepared = self.take_prepared_browser_owner_command( + command_id, + PreparedBrowserOwnerCommandKind::StopLoading, + )?; + let PreparedBrowserOwnerCommand::StopLoading(prepared) = prepared else { + unreachable!("prepared command kind was validated") + }; + Some(prepared) + } + + /// Publishes disposal of one exact BrowserContext instance. + /// + /// Protocol event routing and the completion channel remain in this + /// sidecar. Browser Host receives only the opaque command id and Core + /// capability, so frontend disconnect cannot cancel accepted cleanup. + pub(crate) fn publish_browser_owner_context_disposal_command( + &mut self, + browser_context_handle: BrowserContextHandle, + prefix_events: Vec, + command_context: CommandDispatchContext, + ) -> Result { + let command_id = self.allocate_browser_owner_command_id(); + let input = + BrowserOwnerInput::frontend_dispose_browser_context(command_id, browser_context_handle); + let (reply, pending_reply) = oneshot::channel(); + self.prepared_browser_owner_commands.insert( + command_id, + PreparedBrowserOwnerCommand::ContextDisposal( + PreparedBrowserOwnerContextDisposalCommand { + prefix_events, + command_context, + reply, + }, + ), + ); + let kind = input.kind(); + let publication = if let Some(browser_host) = self.browser_host_handle.as_ref() { + browser_host.publish(input).map_err(|error| { + BrowserOwnerInputPublicationError::HostStopped { kind: error.kind() } + }) + } else { + Err(BrowserOwnerInputPublicationError::HostNotInstalled { kind }) + }; + if let Err(error) = publication { + let _ = self.take_prepared_browser_owner_context_disposal_command(command_id); + return Err(error); + } + if moli_trace::cdp_runtime_trace_enabled() { + tracing::info!( + target: "moli_cdp_runtime", + stage = "browser_owner_input_published", + kind = ?kind, + browser_command_id = command_id.get(), + ); + } + Ok(PendingBrowserOwnerContextDisposalCommand { + reply: pending_reply, + }) + } + + pub(crate) fn take_prepared_browser_owner_context_disposal_command( + &mut self, + command_id: BrowserCommandId, + ) -> Option { + let prepared = self.take_prepared_browser_owner_command( + command_id, + PreparedBrowserOwnerCommandKind::ContextDisposal, + )?; + let PreparedBrowserOwnerCommand::ContextDisposal(prepared) = prepared else { + unreachable!("prepared command kind was validated") + }; + Some(prepared) + } + + /// Publishes one exact Page-scoped decision for a paused top-level + /// navigation. + /// + /// The move-owned paused request remains in the Protocol sidecar. A failed + /// publication returns it to the caller so the request can be restored; + /// this boundary never falls back to direct frontend execution. + pub(crate) fn publish_browser_owner_paused_navigation_decision( + &mut self, + page_owner: TargetPageResidenceIdentity, + pending_navigation: PendingFetchNavigation, + decision: BrowserPausedNavigationDecision, + command_context: CommandDispatchContext, + ) -> Result< + PendingBrowserOwnerPausedNavigationDecisionCommand, + BrowserOwnerPausedNavigationDecisionPublicationFailure, + > { + self.publish_browser_owner_paused_navigation_decision_with_sidecar( + page_owner, + BrowserOwnerPausedNavigationSidecar::Request(pending_navigation), + decision, + command_context, + ) + } + + pub(crate) fn publish_browser_owner_paused_navigation_auth_decision( + &mut self, + page_owner: TargetPageResidenceIdentity, + pending_navigation: PendingFetchAuthNavigation, + decision: BrowserPausedNavigationDecision, + command_context: CommandDispatchContext, + ) -> Result< + PendingBrowserOwnerPausedNavigationDecisionCommand, + BrowserOwnerPausedNavigationDecisionPublicationFailure, + > { + self.publish_browser_owner_paused_navigation_decision_with_sidecar( + page_owner, + BrowserOwnerPausedNavigationSidecar::Auth(pending_navigation), + decision, + command_context, + ) + } + + pub(crate) fn publish_browser_owner_paused_navigation_response_decision( + &mut self, + page_owner: TargetPageResidenceIdentity, + pending_response: PausedDocumentTransfer, + decision: BrowserPausedNavigationDecision, + command_context: CommandDispatchContext, + ) -> Result< + PendingBrowserOwnerPausedNavigationDecisionCommand, + BrowserOwnerPausedNavigationDecisionPublicationFailure, + > { + self.publish_browser_owner_paused_navigation_decision_with_sidecar( + page_owner, + BrowserOwnerPausedNavigationSidecar::Response(Box::new(pending_response)), + decision, + command_context, + ) + } + + fn publish_browser_owner_paused_navigation_decision_with_sidecar( + &mut self, + page_owner: TargetPageResidenceIdentity, + pending_navigation: BrowserOwnerPausedNavigationSidecar, + decision: BrowserPausedNavigationDecision, + command_context: CommandDispatchContext, + ) -> Result< + PendingBrowserOwnerPausedNavigationDecisionCommand, + BrowserOwnerPausedNavigationDecisionPublicationFailure, + > { + let command_id = self.allocate_browser_owner_command_id(); + let input = BrowserOwnerInput::frontend_paused_navigation_decision( + command_id, page_owner, decision, + ); + let (reply, pending_reply) = oneshot::channel(); + self.prepared_browser_owner_commands.insert( + command_id, + PreparedBrowserOwnerCommand::PausedNavigationDecision(Box::new( + PreparedBrowserOwnerPausedNavigationDecisionCommand { + pending_navigation, + command_context, + reply, + }, + )), + ); + let kind = input.kind(); + let publication = if let Some(browser_host) = self.browser_host_handle.as_ref() { + browser_host.publish(input).map_err(|error| { + BrowserOwnerInputPublicationError::HostStopped { kind: error.kind() } + }) + } else { + Err(BrowserOwnerInputPublicationError::HostNotInstalled { kind }) + }; + if let Err(error) = publication { + let pending_navigation = self + .take_prepared_browser_owner_paused_navigation_decision_command(command_id) + .map(|prepared| prepared.into_parts().0); + if pending_navigation.is_none() { + tracing::error!( + browser_command_id = command_id.get(), + "paused navigation decision sidecar disappeared during failed publication" + ); + } + return Err(BrowserOwnerPausedNavigationDecisionPublicationFailure { + error, + pending_navigation: pending_navigation.map(Box::new), + }); + } + if moli_trace::cdp_runtime_trace_enabled() { + tracing::info!( + target: "moli_cdp_runtime", + stage = "browser_owner_input_published", + kind = ?kind, + browser_command_id = command_id.get(), + ); + } + Ok(PendingBrowserOwnerPausedNavigationDecisionCommand { + reply: pending_reply, + }) + } + + pub(crate) fn take_prepared_browser_owner_paused_navigation_decision_command( + &mut self, + command_id: BrowserCommandId, + ) -> Option { + let prepared = self.take_prepared_browser_owner_command( + command_id, + PreparedBrowserOwnerCommandKind::PausedNavigationDecision, + )?; + let PreparedBrowserOwnerCommand::PausedNavigationDecision(prepared) = prepared else { + unreachable!("prepared command kind was validated") + }; + Some(*prepared) + } + + /// Publishes an already-prepared protocol-neutral Browser action. + /// + /// Callers may enforce an exact renderer/protocol predecessor before this + /// boundary, but they cannot execute the action directly if Host is absent. + pub(crate) fn publish_browser_owner_input( + &self, + input: BrowserOwnerInput, + ) -> Result<(), BrowserOwnerInputPublicationError> { + let kind = input.kind(); + let publication = if let Some(browser_host) = self.browser_host_handle.as_ref() { + browser_host.publish(input).map_err(|error| { + BrowserOwnerInputPublicationError::HostStopped { kind: error.kind() } + }) + } else { + Err(BrowserOwnerInputPublicationError::HostNotInstalled { kind }) + }; + if publication.is_ok() && moli_trace::cdp_runtime_trace_enabled() { + tracing::info!( + target: "moli_cdp_runtime", + stage = "browser_owner_input_published", + kind = ?kind, + ); + } + publication + } + + /// Publishes navigation for an already-resolved auxiliary Target. + /// + /// `Ok(false)` means the Target no longer owns a live Page at capture + /// time. A successful publication freezes Core's exact Page residence; + /// later replacement is rejected by the Host executor instead of being + /// followed through a mutable target/session route. + pub(crate) fn publish_renderer_auxiliary_navigation( + &self, + browser_context_id: &str, + target_id: &str, + url: String, + kind: BrowserAuxiliaryNavigationKind, + ) -> Result { + let Some(page_owner) = self + .browser_host_state + .navigation_owner() + .capture_page_residence(browser_context_id, target_id) + else { + return Ok(false); + }; + self.publish_browser_owner_input(BrowserOwnerInput::renderer_auxiliary_navigation( + page_owner, url, kind, + ))?; + Ok(true) + } + + /// Publishes the creation URL replacement for the exact Target addressed + /// by one current route. + /// + /// The initial-empty-Document predicate is owned by Core and is checked + /// both here (to avoid no-op mailbox traffic) and again after Browser Host + /// selection. No frontend session, command id, or response route enters + /// the input itself. + pub(crate) fn publish_initial_target_navigation_for_session_owner( + &self, + session_id: Option<&str>, + ) -> Result { + let Some(url) = self.runtime_session_owner_target_url(session_id) else { + return Ok(false); + }; + let Some(page_owner) = self.target_page_residence_identity_for_session(session_id) else { + return Ok(false); + }; + if !self + .browser_host_state + .navigation_owner() + .accepts_initial_target_navigation(&page_owner, &url) + { + return Ok(false); + } + self.publish_browser_owner_input(BrowserOwnerInput::initial_target_navigation( + page_owner, url, + ))?; + Ok(true) + } + + /// Publishes the initial-Target navigation required by one dependent + /// frontend command. + /// + /// The Page command waits only on the move-owned reply. Browser Host owns + /// selection, network/renderer participants and terminal projection. A + /// no-op result means the exact Page no longer needs this transition; the + /// dependent command re-resolves its current renderer residence next. + pub(crate) fn publish_initial_target_navigation_prerequisite_for_session_owner( + &mut self, + session_id: Option<&str>, + ) -> Result< + Option, + BrowserOwnerInputPublicationError, + > { + let Some(url) = self.runtime_session_owner_target_url(session_id) else { + return Ok(None); + }; + let Some(page_owner) = self.target_page_residence_identity_for_session(session_id) else { + return Ok(None); + }; + if !self + .browser_host_state + .navigation_owner() + .accepts_initial_target_navigation(&page_owner, &url) + { + return Ok(None); + } + + let command_id = self.allocate_browser_owner_command_id(); + let input = BrowserOwnerInput::frontend_ensure_initial_target_navigation( + command_id, page_owner, url, + ); + let (reply, pending_reply) = oneshot::channel(); + self.prepared_browser_owner_commands.insert( + command_id, + PreparedBrowserOwnerCommand::InitialTargetNavigation( + PreparedBrowserOwnerInitialTargetNavigationCommand { reply }, + ), + ); + let kind = input.kind(); + let publication = if let Some(browser_host) = self.browser_host_handle.as_ref() { + browser_host.publish(input).map_err(|error| { + BrowserOwnerInputPublicationError::HostStopped { kind: error.kind() } + }) + } else { + Err(BrowserOwnerInputPublicationError::HostNotInstalled { kind }) + }; + if let Err(error) = publication { + let _ = self.take_prepared_browser_owner_initial_target_navigation_command(command_id); + return Err(error); + } + if moli_trace::cdp_runtime_trace_enabled() { + tracing::info!( + target: "moli_cdp_runtime", + stage = "browser_owner_input_published", + kind = ?kind, + browser_command_id = command_id.get(), + ); + } + Ok(Some(PendingBrowserOwnerInitialTargetNavigationCommand { + reply: pending_reply, + })) + } + + pub(crate) fn take_prepared_browser_owner_initial_target_navigation_command( + &mut self, + command_id: BrowserCommandId, + ) -> Option { + let prepared = self.take_prepared_browser_owner_command( + command_id, + PreparedBrowserOwnerCommandKind::InitialTargetNavigation, + )?; + let PreparedBrowserOwnerCommand::InitialTargetNavigation(prepared) = prepared else { + unreachable!("prepared command kind was validated") + }; + Some(prepared) + } + + /// Revalidates a selected initial-Target input against Core authority. + pub(crate) fn browser_owner_accepts_initial_target_navigation( + &self, + page_owner: &TargetPageResidenceIdentity, + url: &str, + ) -> bool { + self.browser_host_state + .navigation_owner() + .accepts_initial_target_navigation(page_owner, url) + } + + /// Installs the application-owned Browser Host endpoint before renderer + /// output can be admitted. + /// + /// Rebinding is a migration-time construction error: inputs already sent + /// to the previous actor cannot be transferred by Protocol. Production + /// composition installs exactly once; the debug assertion keeps fixtures + /// honest without turning external divergence into a release panic. + pub fn install_browser_host_handle(&mut self, handle: BrowserHostHandle) { + debug_assert!( + self.browser_host_handle.is_none(), + "one CdpConnection cannot publish to multiple Browser Host actors" + ); + if self.browser_host_handle.is_some() { + tracing::error!("ignoring duplicate Browser Host handle installation"); + return; + } + self.browser_host_handle = Some(handle); + } + + #[doc(hidden)] + #[cfg(feature = "test-support")] + pub fn browser_host_handle_for_test_support(&self) -> Option { + self.browser_host_handle.clone() + } + + /// Publishes a top-level navigation input already moved into prepared + /// output directly to Browser Host. + /// + /// The prepared value retains its exact Page residence. This boundary + /// deliberately performs no navigation: only the actor receiver may select + /// the input on a later owner turn. + pub(crate) fn publish_prepared_top_level_location_navigation_input( + &mut self, + page_owner: TargetPageResidenceIdentity, + navigation: moli_core::page::RendererDocumentSourcedTopLevelLocationNavigation, + ) -> Result<(), BrowserOwnerInputPublicationError> { + let trace = self + .browser_host_state + .navigation_owner() + .renderer_navigation_trace_context( + &page_owner, + navigation.browser_action_id(), + navigation.source_document(), + ); + let trace_for_publication = trace.clone(); + let input = BrowserOwnerInput::renderer_top_level_location_navigation( + page_owner, navigation, trace, + ); + let kind = input.kind(); + let publication = if let Some(browser_host) = self.browser_host_handle.as_ref() { + browser_host.publish(input).map_err(|error| { + BrowserOwnerInputPublicationError::HostStopped { kind: error.kind() } + }) + } else { + Err(BrowserOwnerInputPublicationError::HostNotInstalled { kind }) + }; + if let Err(error) = publication { + if let Some(trace) = trace_for_publication.as_ref() { + trace.emit(BrowserNavigationTraceEvent::new( + "browser_owner_rejected", + BrowserNavigationTraceSource::RendererIntent, + "renderer-output", + "browser-host-unavailable", + )); + } + return Err(error); + } + if moli_trace::cdp_runtime_trace_enabled() { + tracing::info!( + target: "moli_cdp_runtime", + stage = "browser_owner_input_published", + kind = ?kind, + ); + } + if let Some(trace) = trace_for_publication.as_ref() { + trace.emit(BrowserNavigationTraceEvent::new( + "browser_action_published", + BrowserNavigationTraceSource::RendererIntent, + "renderer-output", + "browser-owner-queue", + )); + } + Ok(()) + } + + /// Publishes one exact renderer history traversal already moved into + /// prepared output. + /// + /// The input contains no session or resolved history entry. Only Browser + /// Host may resolve the delta and start the resulting traversal on a later + /// owner turn; Host absence never recreates a Protocol scheduler fallback. + pub(crate) fn publish_prepared_top_level_history_traversal_input( + &self, + page_owner: TargetPageResidenceIdentity, + delta: i64, + ) -> Result<(), BrowserOwnerInputPublicationError> { + self.publish_browser_owner_input(BrowserOwnerInput::renderer_top_level_history_traversal( + page_owner, delta, + )) + } +} + +#[cfg(test)] +mod tests { + use moli_core::{ + PageId, + browser_host::{ + BrowserHostActor, BrowserHostTurn, BrowserHostTurnExecutor, BrowserOwnerInput, + BrowserOwnerInputKind, PageResidenceIdentity, RendererBrowserIntent, + }, + page::{ + RendererDocumentLifecycleIdentity, RendererDocumentSourcedTopLevelLocationNavigation, + RendererDocumentToken, RendererFrameToken, RendererLifecycleEpoch, + }, + }; + + use super::*; + + struct RecordingTurnExecutor; + + impl BrowserHostTurnExecutor for RecordingTurnExecutor { + type Output = BrowserOwnerInput; + + fn execute_browser_host_turn(&mut self, turn: BrowserHostTurn) -> Self::Output { + turn.into_input() + } + } + + fn renderer_navigation(generation: u64) -> RendererDocumentSourcedTopLevelLocationNavigation { + let page_id = PageId::new_for_testing(31); + RendererDocumentSourcedTopLevelLocationNavigation::new( + RendererDocumentLifecycleIdentity { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, generation), + epoch: RendererLifecycleEpoch(generation), + }, + format!("https://example.test/{generation}"), + ) + } + + fn page(generation: u64) -> PageResidenceIdentity { + PageResidenceIdentity::new( + "context-browser-host-publication".to_owned(), + Some("target-browser-host-publication".to_owned()), + generation, + ) + } + + #[test] + fn stop_loading_requires_a_live_host_without_direct_protocol_fallback() { + let mut conn = CdpConnection::new(); + + let error = match conn + .publish_browser_owner_stop_loading_command(page(1), CommandDispatchContext::default()) + { + Ok(_) => panic!("an unbound Browser Host must reject stop-loading publication"), + Err(error) => error, + }; + assert!(matches!( + error, + BrowserOwnerInputPublicationError::HostNotInstalled { + kind: BrowserOwnerInputKind::FrontendStopLoading, + } + )); + assert!(conn.prepared_browser_owner_commands.is_empty()); + assert!( + conn.take_scheduler_events().is_empty(), + "stop-loading rejection must not recreate a Protocol scheduler fallback" + ); + } + + #[tokio::test] + async fn renderer_intent_uses_host_handle_without_protocol_scheduler_fallback() { + let mut conn = CdpConnection::new(); + + assert!(matches!( + conn.publish_prepared_top_level_location_navigation_input( + page(1), + renderer_navigation(1), + ), + Err(BrowserOwnerInputPublicationError::HostNotInstalled { + kind: BrowserOwnerInputKind::RendererTopLevelLocationNavigation, + }) + )); + assert!( + conn.take_scheduler_events().is_empty(), + "an unbound Browser Host must not recreate the removed Protocol scheduler envelope" + ); + + let (mut actor, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + conn.publish_prepared_top_level_location_navigation_input(page(2), renderer_navigation(2)) + .expect("bound Browser Host should accept renderer intent"); + + let input = actor + .complete_next_turn(&mut RecordingTurnExecutor) + .expect("Browser Host actor should own the published input"); + assert_eq!( + input.kind(), + BrowserOwnerInputKind::RendererTopLevelLocationNavigation + ); + let BrowserOwnerInput::RendererIntent(RendererBrowserIntent::TopLevelLocationNavigation( + input, + )) = input + else { + panic!("expected renderer navigation input"); + }; + assert_eq!(input.page_owner(), &page(2)); + assert!(conn.take_scheduler_events().is_empty()); + } + + #[tokio::test] + async fn renderer_history_intent_uses_host_handle_without_protocol_scheduler_fallback() { + let mut conn = CdpConnection::new(); + + assert!(matches!( + conn.publish_prepared_top_level_history_traversal_input(page(3), -1), + Err(BrowserOwnerInputPublicationError::HostNotInstalled { + kind: BrowserOwnerInputKind::RendererTopLevelHistoryTraversal, + }) + )); + assert!(conn.take_scheduler_events().is_empty()); + + let (mut actor, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + conn.publish_prepared_top_level_history_traversal_input(page(4), 2) + .expect("bound Browser Host should accept renderer history intent"); + + let input = actor + .complete_next_turn(&mut RecordingTurnExecutor) + .expect("Browser Host actor should own the renderer history input"); + assert_eq!( + input.kind(), + BrowserOwnerInputKind::RendererTopLevelHistoryTraversal + ); + let BrowserOwnerInput::RendererIntent(RendererBrowserIntent::TopLevelHistoryTraversal( + input, + )) = input + else { + panic!("expected renderer history traversal input"); + }; + assert_eq!(input.page_owner(), &page(4)); + assert_eq!(input.delta(), 2); + assert!(conn.take_scheduler_events().is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn initial_target_navigation_starts_only_after_browser_host_selection() { + let mut conn = CdpConnection::new(); + conn.install_default_browser_target(); + let url = "data:text/html,
owner initial target
"; + conn.browser_context + .as_mut() + .expect("default BrowserContext") + .set_target_url(url.to_owned()); + + assert!(matches!( + conn.publish_initial_target_navigation_for_session_owner(None), + Err(BrowserOwnerInputPublicationError::HostNotInstalled { + kind: BrowserOwnerInputKind::InitialTargetNavigation, + }) + )); + assert!( + !conn.has_pending_document_navigation_for_session_owner(None), + "publication failure must not start navigation through a direct fallback" + ); + + let (mut actor, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + assert_eq!( + conn.publish_initial_target_navigation_for_session_owner(None), + Ok(true) + ); + assert_eq!(actor.ready_len(), 1); + assert!( + !conn.has_pending_document_navigation_for_session_owner(None), + "mailbox publication alone must not execute the browser action" + ); + assert!(conn.take_scheduler_events().is_empty()); + + let dispatch = actor + .complete_next_turn(&mut conn) + .expect("Browser Host should select initial Target input"); + let outcome = conn.finish_browser_host_turn_for_test(dispatch).await; + let (_events, scheduler_events, _) = outcome.into_protocol_event_parts(); + assert!( + scheduler_events.iter().all(|event| !matches!( + event, + crate::conn::CdpSchedulerEvent::ProtocolWorkPublished { work } + if work.kind() + != crate::domains::activity::ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection + )), + "the actor-selected action may still hand its loaded-Document tail to the existing compatibility participant, but must not publish unrelated frontend work: {scheduler_events:?}" + ); + assert!( + conn.runtime_session_owner_slot(None) + .ok() + .and_then(|slot| slot.loaded_page()) + .is_some_and(|page| page.final_url().as_str() == url) + ); + assert!( + conn.target_initial_empty_document_for_session_owner(None) + .is_some_and(|document| document.exited()), + "the actor-selected replacement must exit the exact initial empty Document" + ); + } + + #[test] + fn initial_target_prerequisite_rejects_missing_host_without_direct_fallback() { + let mut conn = CdpConnection::new(); + conn.install_default_browser_target(); + conn.browser_context + .as_mut() + .expect("default BrowserContext") + .set_target_url("https://example.test/prerequisite".to_owned()); + + let error = + match conn.publish_initial_target_navigation_prerequisite_for_session_owner(None) { + Ok(_) => panic!("an unbound Browser Host must reject the prerequisite"), + Err(error) => error, + }; + assert!(matches!( + error, + BrowserOwnerInputPublicationError::HostNotInstalled { + kind: BrowserOwnerInputKind::FrontendEnsureInitialTargetNavigation, + } + )); + assert!(conn.prepared_browser_owner_commands.is_empty()); + assert!(!conn.has_pending_document_navigation_for_session_owner(None)); + assert!(conn.take_scheduler_events().is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn initial_target_prerequisite_reply_waits_for_browser_host_terminal() { + let mut conn = CdpConnection::new(); + conn.install_default_browser_target(); + let url = "data:text/html,
owner prerequisite
"; + conn.browser_context + .as_mut() + .expect("default BrowserContext") + .set_target_url(url.to_owned()); + let (mut actor, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + + let pending = conn + .publish_initial_target_navigation_prerequisite_for_session_owner(None) + .expect("live Browser Host should accept the prerequisite") + .expect("the creation URL should require initial navigation"); + assert_eq!(actor.ready_len(), 1); + assert!( + !conn.has_pending_document_navigation_for_session_owner(None), + "frontend publication must not start the navigation" + ); + + let mut completion = Box::pin(pending.wait()); + tokio::select! { + biased; + _ = &mut completion => { + panic!("the dependent command must not resume before Browser Host selection"); + } + _ = std::future::ready(()) => {} + } + + let dispatch = actor + .complete_next_turn(&mut conn) + .expect("Browser Host should select the prerequisite"); + let outcome = conn.finish_browser_host_turn_for_test(dispatch).await; + let completed = completion + .await + .expect("the Host terminal turn should resolve the prerequisite"); + assert!( + completed + .into_plan() + .command_status() + .is_none_or(|status| status.is_ok()) + ); + assert!( + outcome.into_protocol_event_parts().0.is_empty(), + "navigation projection should remain paired with the dependent command sidecar" + ); + assert!( + conn.runtime_session_owner_slot(None) + .ok() + .and_then(|slot| slot.loaded_page()) + .is_some_and(|page| page.final_url().as_str() == url), + "the prerequisite must resolve only after the requested initial URL commits" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn dropped_initial_target_prerequisite_wait_does_not_cancel_browser_action() { + let mut conn = CdpConnection::new(); + conn.install_default_browser_target(); + let url = "data:text/html,
detached prerequisite
"; + conn.browser_context + .as_mut() + .expect("default BrowserContext") + .set_target_url(url.to_owned()); + let (mut actor, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + + let pending = conn + .publish_initial_target_navigation_prerequisite_for_session_owner(None) + .expect("live Browser Host should accept the prerequisite") + .expect("the creation URL should require initial navigation"); + drop(pending); + + let dispatch = actor + .complete_next_turn(&mut conn) + .expect("Browser Host should retain the accepted action"); + let outcome = conn.finish_browser_host_turn_for_test(dispatch).await; + let ( + before_renderer_output, + post_renderer_output, + renderer_output_boundary, + post_response_events, + _, + _, + ) = outcome.into_renderer_owner_turn_parts(); + assert!( + !before_renderer_output.is_empty() + || !post_renderer_output.is_empty() + || !post_response_events.is_empty(), + "a detached dependent command must leave browser-visible navigation effects" + ); + assert!( + renderer_output_boundary.is_some(), + "detached projection must preserve the exact renderer insertion boundary" + ); + assert!( + conn.runtime_session_owner_slot(None) + .ok() + .and_then(|slot| slot.loaded_page()) + .is_some_and(|page| page.final_url().as_str() == url), + "dropping the frontend wait must not cancel the Browser action" + ); + assert!(conn.prepared_browser_owner_commands.is_empty()); + } +} diff --git a/moli-protocol/src/conn/browser_page_replacement.rs b/moli-protocol/src/conn/browser_page_replacement.rs new file mode 100644 index 0000000000..d812e3911e --- /dev/null +++ b/moli-protocol/src/conn/browser_page_replacement.rs @@ -0,0 +1,565 @@ +use moli_core::{ + browser_host::{ + BrowserNavigationHistoryPageSnapshot, BrowserPageReplacement, BrowserPageRuntimeOwner, + }, + page::{Page, RendererMainDocumentCommit, RendererPageLifetimeOwner}, +}; +use url::Url; + +use super::{CdpConnection, DocumentNavigationToken, LoadedNavigationRendererAttachmentCommit}; + +/// Synchronous Browser/physical replacement result before retired Page +/// disposal has necessarily completed. +pub(crate) enum LoadedPageReplacementStart { + Pending(PendingLoadedPageReplacement), + Ready(LoadedPageReplacementOutcome), +} + +/// Move-owned wait for exactly one Page that no longer has physical +/// residence. +/// +/// The Browser Core request/Page/history mutation and the protocol Page-slot +/// projection have already either committed or been rejected before this +/// value is created. Waiting therefore needs no `CdpConnection` access and +/// cannot change which Document is current. +pub(crate) struct PendingLoadedPageReplacement { + page: Option, + renderer_page_owner: Option, + outcome: LoadedPageReplacementOutcome, +} + +pub(crate) enum LoadedPageReplacementOutcome { + Committed(Box), + Rejected, + Failed(anyhow::Error), +} + +impl PendingLoadedPageReplacement { + fn new(page: Page, outcome: LoadedPageReplacementOutcome) -> Self { + Self { + page: Some(page), + renderer_page_owner: None, + outcome, + } + } + + fn with_retired_renderer_page( + page: Option, + renderer_page_owner: Option, + outcome: LoadedPageReplacementOutcome, + ) -> Self { + Self { + page, + renderer_page_owner, + outcome, + } + } + + pub(crate) fn committed_owner(&self) -> Option<&moli_core::browser_host::BrowserPageOwnerKey> { + self.outcome.committed_owner() + } + + pub(crate) async fn wait(self) -> LoadedPageReplacementOutcome { + if let Some(owner) = self.renderer_page_owner { + let _ = owner.close_async().await; + } + if let Some(page) = self.page { + let _ = page.close_async().await; + } + self.outcome + } +} + +impl LoadedPageReplacementOutcome { + pub(crate) fn committed_owner(&self) -> Option<&moli_core::browser_host::BrowserPageOwnerKey> { + match self { + Self::Committed(replacement) => Some(replacement.owner()), + Self::Rejected | Self::Failed(_) => None, + } + } + + #[cfg(test)] + fn into_legacy_result(self) -> Option> { + match self { + Self::Committed(replacement) => Some(Ok(*replacement)), + Self::Rejected => None, + Self::Failed(error) => Some(Err(error)), + } + } +} + +/// Migration adapter for an authoritative Browser Core Page replacement. +/// +/// CDP/session routing selects the browser Target once. Browser Core then +/// authorizes and commits request identity, Page generation, and joint +/// history. Renderer attachment and physical `Page` storage remain protocol +/// participants during this migration, but they cannot decide whether the +/// navigation request is current. +impl CdpConnection { + pub(crate) fn start_loaded_page_replacement_for_session_owner( + &mut self, + session_id: Option<&str>, + navigation: &DocumentNavigationToken, + page: Page, + target_url: &Url, + main_document_commit: &RendererMainDocumentCommit, + renderer_attachment_commit: LoadedNavigationRendererAttachmentCommit, + ) -> LoadedPageReplacementStart { + let Some(owner) = self.target_page_owner_key_for_session(session_id) else { + return self.reject_loaded_page_replacement( + session_id, + navigation, + page, + renderer_attachment_commit, + "Browser Core replacement owner is no longer routed", + ); + }; + let history_page = BrowserNavigationHistoryPageSnapshot::new( + target_url.to_string(), + page.document_title(), + ); + let Some(permit) = self + .browser_host_state + .navigation_owner() + .prepare_loaded_page_replacement(&owner, navigation) + else { + return self.reject_loaded_page_replacement( + session_id, + navigation, + page, + renderer_attachment_commit, + "Browser Core replacement permit is stale", + ); + }; + + let prepared = match self.prepare_loaded_navigation_page_for_session_owner( + session_id, + page, + renderer_attachment_commit, + ) { + None => { + return LoadedPageReplacementStart::Ready(LoadedPageReplacementOutcome::Rejected); + } + Some(Ok(prepared)) => prepared, + Some(Err(error)) => { + return LoadedPageReplacementStart::Ready(LoadedPageReplacementOutcome::Failed( + error, + )); + } + }; + let retiring_renderer_page = prepared.retiring_renderer_page(); + let mut page_runtime_owner = Some(prepared.into_page_runtime_owner()); + let mut renderer_page_owner = page_runtime_owner + .as_mut() + .and_then(BrowserPageRuntimeOwner::take_renderer_lifetime_owner); + if renderer_page_owner.is_none() { + let page = page_runtime_owner + .take() + .and_then(BrowserPageRuntimeOwner::into_page); + let outcome = LoadedPageReplacementOutcome::Failed(anyhow::anyhow!( + "loaded Page candidate has no renderer lifetime owner" + )); + return match page { + Some(page) => LoadedPageReplacementStart::Pending( + PendingLoadedPageReplacement::new(page, outcome), + ), + None => LoadedPageReplacementStart::Ready(outcome), + }; + } + + // From here through the physical Page projection there is deliberately + // no await or external callback. The connection actor therefore cannot + // expose a core successor while protocol storage still names the old + // Page. + let mut replacement = match self.browser_host_state.commit_loaded_page_replacement( + permit, + history_page, + &mut renderer_page_owner, + &mut page_runtime_owner, + ) { + Ok(replacement) => replacement, + Err(error) => { + tracing::debug!( + %error, + session_id, + loader_id = navigation.loader_id(), + "prepared Browser Core Page replacement became stale before commit" + ); + let detached_owner = match (renderer_page_owner.take(), page_runtime_owner.as_mut()) + { + (Some(owner), Some(runtime)) => { + runtime.try_restore_renderer_lifetime_owner(owner).err() + } + (owner, None) => owner, + (None, _) => None, + }; + let page = page_runtime_owner + .take() + .and_then(BrowserPageRuntimeOwner::into_page); + return LoadedPageReplacementStart::Pending( + PendingLoadedPageReplacement::with_retired_renderer_page( + page, + detached_owner, + LoadedPageReplacementOutcome::Rejected, + ), + ); + } + }; + if self + .project_loaded_navigation_page_for_session_owner( + session_id, + target_url, + main_document_commit, + &replacement, + retiring_renderer_page, + ) + .is_none() + { + tracing::error!( + session_id, + loader_id = navigation.loader_id(), + "committed Browser Page replacement lost its same-turn frontend projection route" + ); + } + + let retired_renderer_page_owner = replacement.take_retired_renderer_page_owner(); + + let outcome = LoadedPageReplacementOutcome::Committed(Box::new(replacement)); + match retired_renderer_page_owner { + None => LoadedPageReplacementStart::Ready(outcome), + renderer_page_owner => LoadedPageReplacementStart::Pending( + PendingLoadedPageReplacement::with_retired_renderer_page( + None, + renderer_page_owner, + outcome, + ), + ), + } + } + + #[cfg(test)] + pub(crate) async fn commit_loaded_page_replacement_for_session_owner_async( + &mut self, + session_id: Option<&str>, + navigation: &DocumentNavigationToken, + page: Page, + target_url: &Url, + main_document_commit: &RendererMainDocumentCommit, + renderer_attachment_commit: LoadedNavigationRendererAttachmentCommit, + ) -> Option> { + let outcome = match self.start_loaded_page_replacement_for_session_owner( + session_id, + navigation, + page, + target_url, + main_document_commit, + renderer_attachment_commit, + ) { + LoadedPageReplacementStart::Pending(pending) => pending.wait().await, + LoadedPageReplacementStart::Ready(outcome) => outcome, + }; + outcome.into_legacy_result() + } + + fn reject_loaded_page_replacement( + &mut self, + session_id: Option<&str>, + navigation: &DocumentNavigationToken, + page: Page, + renderer_attachment_commit: LoadedNavigationRendererAttachmentCommit, + reason: &'static str, + ) -> LoadedPageReplacementStart { + if let LoadedNavigationRendererAttachmentCommit::AlreadyCommitted(transaction) = + renderer_attachment_commit + && let Err(error) = self.rollback_committed_renderer_agent_candidate_for_session_owner( + session_id, + transaction, + ) + { + tracing::warn!( + %error, + session_id, + loader_id = navigation.loader_id(), + reason, + "failed to roll back renderer attachment for stale Browser Core replacement" + ); + } + LoadedPageReplacementStart::Pending(PendingLoadedPageReplacement::new( + page, + LoadedPageReplacementOutcome::Rejected, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conn::state::RendererPageResidenceIdentity; + use moli_core::browser_host::BrowserFact; + + fn main_document_commit(url: &Url, loader_id: &str) -> RendererMainDocumentCommit { + RendererMainDocumentCommit { + frame_id: "TID-1".to_owned(), + loader_id: loader_id.to_owned(), + url: url.to_string(), + unreachable_url: None, + security_origin: url.origin().ascii_serialization(), + secure_context_type: "InsecureScheme".to_owned(), + timestamp: 0.0, + } + } + + #[tokio::test] + async fn adapter_commits_request_history_and_exactly_one_page_generation() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let residence = conn + .target_page_residence_handle_for_session(None) + .expect("default target Page residence"); + let previous_page = conn + .load_page_via_runtime_async("data:text/html,previous") + .await + .expect("previous Page should load"); + conn.browser_context + .as_mut() + .expect("default BrowserContext") + .set_loaded_page_async(previous_page) + .await; + let previous_generation = residence.generation(); + let navigation = conn + .start_document_navigation_for_session_owner(None, "LOADER-replacement".to_owned()) + .expect("default target should start navigation"); + let page = conn + .load_page_via_runtime_async("data:text/html,replacement") + .await + .expect("replacement Page should load"); + let replacement_renderer_page_id = page.page_id(); + let final_url = page.final_url().clone(); + let document_commit = main_document_commit(&final_url, navigation.loader_id()); + + let replacement_start = conn.start_loaded_page_replacement_for_session_owner( + None, + &navigation, + page, + &final_url, + &document_commit, + LoadedNavigationRendererAttachmentCommit::Prepare(None), + ); + + assert_eq!( + residence.generation(), + previous_generation + 1, + "Browser and physical replacement must commit before Page disposal waits" + ); + assert!(conn.accepts_committed_document_navigation_for_session_owner(None, &navigation)); + let commit_facts = conn.browser_fact_snapshot_for_test(); + let commit_fact = commit_facts + .iter() + .find(|envelope| { + matches!( + envelope.fact(), + BrowserFact::NavigationCommitted { + navigation: fact_navigation, + .. + } if fact_navigation == &navigation + ) + }) + .expect( + "Core commit must publish NavigationCommitted before predecessor disposal waits", + ); + assert_eq!( + commit_fact.page_residence().loaded_page_generation(), + previous_generation + 1 + ); + + let replacement_outcome = match replacement_start { + LoadedPageReplacementStart::Pending(pending) => pending.wait().await, + LoadedPageReplacementStart::Ready(outcome) => outcome, + }; + let replacement = match replacement_outcome { + LoadedPageReplacementOutcome::Committed(replacement) => replacement, + LoadedPageReplacementOutcome::Rejected => panic!("exact replacement was rejected"), + LoadedPageReplacementOutcome::Failed(error) => { + panic!("replacement preparation failed: {error}") + } + }; + + assert_eq!( + replacement.current_page().loaded_page_generation(), + previous_generation + 1 + ); + assert!(residence.is_current(replacement.current_page())); + let owner = conn + .target_page_owner_key_for_session(None) + .expect("replacement Target owner"); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .renderer_page_id_for_owner(&owner), + Some(replacement_renderer_page_id), + "loaded replacement must transfer its renderer lifetime into Browser Core" + ); + assert_eq!(commit_fact.page_residence(), replacement.current_page()); + assert!(matches!( + commit_fact.fact(), + BrowserFact::NavigationCommitted { + previous_page, + navigation: fact_navigation, + } if previous_page == replacement.previous_page() + && fact_navigation == replacement.navigation() + )); + assert!(!conn.has_pending_document_navigation_for_session_owner(None)); + assert_eq!( + conn.runtime_session_owner_target_url(None).as_deref(), + Some(final_url.as_str()) + ); + let (_, history) = conn + .target_session_owner_navigation_history_snapshot(None) + .expect("replacement should record joint history"); + assert_eq!( + history.last().map(|entry| entry.url.as_str()), + Some(final_url.as_str()) + ); + + let predecessor_access = conn + .browser_context + .as_ref() + .expect("default BrowserContext") + .active_target + .runtime_slot + .loaded_page_runtime_access_for_test() + .expect("committed replacement runtime access"); + let predecessor_lease = predecessor_access + .checkout_page() + .expect("committed replacement Page can be checked out"); + let predecessor_renderer_page = + RendererPageResidenceIdentity::from_page(&predecessor_lease); + assert_eq!(predecessor_lease.page_id(), replacement_renderer_page_id); + assert!( + predecessor_access.is_live(), + "checking out the payload must not change Core Page residence" + ); + assert!( + predecessor_access.checkout_page().is_none(), + "one mutable Page payload cannot have two concurrent leases" + ); + assert_eq!( + conn.browser_context.as_ref().and_then(|context| context + .active_target + .runtime_slot + .loaded_renderer_page_residence()), + Some(predecessor_renderer_page), + "stable renderer identity must remain readable while the Page payload is checked out" + ); + + let next_navigation = conn + .start_document_navigation_for_session_owner(None, "LOADER-next".to_owned()) + .expect("successor navigation should start"); + let next_page = conn + .load_page_via_runtime_async("data:text/html,next replacement") + .await + .expect("next replacement Page should load"); + let next_page_id = next_page.page_id(); + let next_url = next_page.final_url().clone(); + let next_commit = main_document_commit(&next_url, next_navigation.loader_id()); + let next_pending = match conn.start_loaded_page_replacement_for_session_owner( + None, + &next_navigation, + next_page, + &next_url, + &next_commit, + LoadedNavigationRendererAttachmentCommit::Prepare(None), + ) { + LoadedPageReplacementStart::Pending(pending) => pending, + LoadedPageReplacementStart::Ready(_) => { + panic!("successor replacement must retire its renderer Page owner") + } + }; + + assert!( + !predecessor_access.is_live(), + "Core replacement must invalidate stale frontend access in the commit turn" + ); + assert!( + predecessor_access.checkout_page().is_none(), + "a stale access cannot concurrently reacquire its checked-out Page" + ); + drop(predecessor_lease); + assert!( + predecessor_access.checkout_page().is_none(), + "a late lease drop must not restore the predecessor Page after replacement" + ); + + assert!(matches!( + next_pending.wait().await, + LoadedPageReplacementOutcome::Committed(_) + )); + assert_eq!( + conn.browser_context + .as_ref() + .and_then(|context| context.loaded_page()) + .map(|page| page.page_id()), + Some(next_page_id), + "the successor frontend projection must resolve the Core-owned Page payload" + ); + } + + #[tokio::test] + async fn stale_core_replacement_rolls_back_precommitted_renderer_attachment() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let navigation_a = conn + .start_document_navigation_for_session_owner(None, "LOADER-A".to_owned()) + .expect("navigation A should start"); + let mut page = conn + .load_page_via_runtime_async("data:text/html,stale A") + .await + .expect("candidate Page A should load"); + let final_url = page.final_url().clone(); + let main_document_commit = main_document_commit(&final_url, navigation_a.loader_id()); + let renderer_page = RendererPageResidenceIdentity::from_page(&page); + let candidate = conn + .prepare_renderer_agent_candidate_for_session_owner(None, &navigation_a, &mut page) + .expect("renderer candidate A should prepare"); + let transaction = conn + .commit_renderer_agent_candidate_for_session_owner(None, candidate, renderer_page) + .expect("renderer candidate A should precommit"); + let previous_attachment_id = transaction.previous().map(|attachment| attachment.id()); + + let navigation_b = conn + .start_document_navigation_for_session_owner(None, "LOADER-B".to_owned()) + .expect("navigation B should supersede Browser Core request A"); + assert!( + conn.commit_loaded_page_replacement_for_session_owner_async( + None, + &navigation_a, + page, + &final_url, + &main_document_commit, + LoadedNavigationRendererAttachmentCommit::AlreadyCommitted(transaction), + ) + .await + .is_none(), + "stale request A must not replace the current Page" + ); + + assert_eq!( + conn.current_renderer_agent_attachment_id_for_session_owner(None), + previous_attachment_id, + "stale precommitted attachment A must roll back to its exact predecessor" + ); + assert!(conn.accepts_pending_document_navigation_for_session_owner(None, &navigation_b)); + assert!( + conn.browser_fact_snapshot_for_test() + .iter() + .all(|envelope| !matches!( + envelope.fact(), + BrowserFact::NavigationCommitted { + navigation: fact_navigation, + .. + } if fact_navigation == &navigation_a + )), + "stale request A must not publish a navigation commit fact" + ); + } +} diff --git a/moli-protocol/src/conn/browser_target_engine_handoff.rs b/moli-protocol/src/conn/browser_target_engine_handoff.rs new file mode 100644 index 0000000000..a959e5109b --- /dev/null +++ b/moli-protocol/src/conn/browser_target_engine_handoff.rs @@ -0,0 +1,964 @@ +use moli_core::{ + browser_host::{ + BrowserPageOwnerKey, BrowserSelectedTargetEngineDisposition, + BrowserTargetEngineAdoptionError, BrowserTargetEngineResidence, PageResidenceIdentity, + }, + page::{ + CompletedDevToolsIoCommandDispatch, CompletedPageCommand, PendingDevToolsIoCommandDispatch, + PendingPageCommand, + }, + runtime::NavigationEngine, +}; + +use crate::devtools_runtime::{DevToolsError, DevToolsErrorKind}; + +#[cfg(test)] +use moli_core::browser_host::BrowserTargetRegistryError; + +use super::{BackgroundProtocolEvent, CdpConnection, TargetProjectionError}; + +/// The stable Target identities on both sides of one foreground selection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TargetActivationTransition { + promoted_target_id: String, + previous_active_target_id: Option, +} + +impl TargetActivationTransition { + pub(crate) fn new( + promoted_target_id: impl Into, + previous_active_target_id: Option, + ) -> Self { + Self { + promoted_target_id: promoted_target_id.into(), + previous_active_target_id, + } + } + + fn promoted_target_id(&self) -> &str { + &self.promoted_target_id + } + + fn demoted_target_id(&self) -> Option<&str> { + self.previous_active_target_id + .as_deref() + .filter(|target_id| *target_id != self.promoted_target_id()) + } + + fn changed_active_target(&self) -> bool { + self.previous_active_target_id.as_deref() != Some(self.promoted_target_id()) + } +} + +#[derive(Debug)] +pub(crate) struct CompletedTargetActivation { + protocol_events: Vec, +} + +impl CompletedTargetActivation { + fn new(protocol_events: Vec) -> Self { + Self { protocol_events } + } + + pub(crate) fn into_protocol_events(self) -> Vec { + self.protocol_events + } +} + +#[derive(Debug)] +pub(crate) enum BrowserTargetPromotionError { + Projection(TargetProjectionError), + BrowserContextNotLoaded, + BrowserContextLostAfterProjection, + Synchronization(String), +} + +pub(crate) enum BrowserTargetPromotionStart { + Complete(bool), + Pending(Box), +} + +pub(crate) struct PendingBrowserTargetPromotion { + page_owner: PageResidenceIdentity, + script_execution_disabled: PendingDevToolsIoCommandDispatch, + commands: Vec<(BrowserTargetPromotionPageCommandKind, PendingPageCommand)>, + start_error: Option, +} + +pub(crate) struct CompletedBrowserTargetPromotion { + page_owner: PageResidenceIdentity, + script_execution_disabled: Result, + commands: Vec<( + BrowserTargetPromotionPageCommandKind, + Result, + )>, + start_error: Option, +} + +#[derive(Clone, Copy, Debug)] +enum BrowserTargetPromotionPageCommandKind { + ExtraHttpHeaders, + NetworkOffline, + BypassServiceWorker, + BlockedUrlPatterns, + CpuThrottlingRate, + FetchSubresourceInterception, + SurfaceOverride, +} + +impl PendingBrowserTargetPromotion { + pub(crate) async fn wait(self) -> CompletedBrowserTargetPromotion { + let script_execution_disabled = self + .script_execution_disabled + .wait() + .await + .map_err(|error| error.to_string()); + let mut commands = Vec::with_capacity(self.commands.len()); + for (kind, pending) in self.commands { + commands.push(( + kind, + pending.wait().await.map_err(|error| error.to_string()), + )); + } + CompletedBrowserTargetPromotion { + page_owner: self.page_owner, + script_execution_disabled, + commands, + start_error: self.start_error, + } + } +} + +impl std::fmt::Display for BrowserTargetPromotionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Projection(error) => write!(formatter, "TargetProjectionRejected: {error}"), + Self::BrowserContextNotLoaded => formatter.write_str("BrowserContextNotLoaded"), + Self::BrowserContextLostAfterProjection => { + formatter.write_str("BrowserContextLostAfterTargetProjection") + } + Self::Synchronization(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for BrowserTargetPromotionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Projection(error) => Some(error), + _ => None, + } + } +} + +impl From for BrowserTargetPromotionError { + fn from(error: TargetProjectionError) -> Self { + Self::Projection(error) + } +} + +impl From for DevToolsError { + fn from(error: BrowserTargetPromotionError) -> Self { + Self::new(DevToolsErrorKind::Internal, error.to_string()) + } +} + +/// Migration adapter between the physical Target/Page projection and Browser +/// Core's target-keyed NavigationEngine registry. +/// +/// This module may inspect protocol-resident active/background Page slots to +/// choose `Retain` versus `Discard`, but every engine exchange is committed by +/// Browser Core with protocol-neutral `{context, target}` identity. It must not +/// consult CDP session attachment when authorizing a handoff. +impl CdpConnection { + /// Completes renderer-surface updates for a foreground transition that was + /// already committed synchronously by Browser Core and its physical + /// projection. + pub(crate) async fn complete_staged_target_activation_async( + &mut self, + transition: &TargetActivationTransition, + ) -> CompletedTargetActivation { + let Some(demoted_target_id) = transition.demoted_target_id() else { + return CompletedTargetActivation::new(Vec::new()); + }; + let protocol_events = self + .page_screencast_session_ids_for_target(demoted_target_id) + .into_iter() + .map(|session_id| { + BackgroundProtocolEvent::page_screencast_visibility_changed( + session_id.as_deref(), + false, + ) + }) + .collect(); + if let Err(error) = self + .apply_parked_target_surface_overrides_async(demoted_target_id) + .await + { + tracing::warn!( + target_id = demoted_target_id, + promoted_target_id = transition.promoted_target_id(), + %error, + "failed to update Page visibility after target activation" + ); + } + CompletedTargetActivation::new(protocol_events) + } + + /// Completes a foreground transition initiated for an already resident + /// Target. Unlike target creation, both sides may already have active + /// screencasts, so the promoted surface must also become visible. + async fn complete_target_activation_async( + &mut self, + transition: &TargetActivationTransition, + ) -> CompletedTargetActivation { + let mut protocol_events = self + .complete_staged_target_activation_async(transition) + .await + .into_protocol_events(); + if transition.changed_active_target() { + protocol_events.extend( + self.page_screencast_session_ids_for_target(transition.promoted_target_id()) + .into_iter() + .map(|session_id| { + BackgroundProtocolEvent::page_screencast_visibility_changed( + session_id.as_deref(), + true, + ) + }), + ); + } + CompletedTargetActivation::new(protocol_events) + } + + pub(crate) fn page_screencast_session_ids_for_target( + &mut self, + target_id: &str, + ) -> Vec> { + let Some(route) = self.target_session_route_for_target_id(target_id) else { + return Vec::new(); + }; + let mut route_scope = self.scoped_none_session_owner_route_override(route); + let conn = route_scope.conn_mut(); + conn.page_event_session_ids_for_session_owner(None) + .into_iter() + .filter(|session_id| { + conn.target_page_session_state_for_session(session_id.as_deref()) + .is_some_and(|state| state.page_screencast.is_active()) + }) + .collect() + } + + pub(super) fn selected_target_engine_disposition( + &self, + ) -> BrowserSelectedTargetEngineDisposition { + let Some(browser_context) = self.browser_context.as_ref() else { + return BrowserSelectedTargetEngineDisposition::Unbound; + }; + let Some(target_id) = browser_context.active_target_id() else { + return BrowserSelectedTargetEngineDisposition::Unbound; + }; + let owner = BrowserPageOwnerKey::new(browser_context.id.clone(), target_id.to_owned()); + if browser_context.has_loaded_page() { + BrowserSelectedTargetEngineDisposition::Retain(owner) + } else { + BrowserSelectedTargetEngineDisposition::Discard(owner) + } + } + + fn selected_target_engine_owner_key(&self) -> Option { + let browser_context = self.browser_context.as_ref()?; + Some(BrowserPageOwnerKey::new( + browser_context.id.clone(), + browser_context.active_target_id()?.to_owned(), + )) + } + + /// Transitional helper for engine-producing operations that do not yet + /// carry a neutral Target key. Browser Core resolves its authoritative + /// selected Target (or exact unbound state) and commits the adoption. + pub(crate) fn adopt_navigation_engine_for_current_owner( + &mut self, + engine: NavigationEngine, + ) -> Result, BrowserTargetEngineAdoptionError> { + let projected_owner = self.selected_target_engine_owner_key(); + self.browser_host_state + .adopt_selected_target_engine_or_unbound(projected_owner, engine) + } + + pub(crate) fn apply_scheduler_senders_to_navigation_engine(&self, engine: &NavigationEngine) { + self.browser_host_state + .navigation_owner() + .configure_detached_engine(engine); + } + + #[cfg(test)] + pub(crate) fn navigation_engine_with_user_agent_marker_for_test( + &self, + marker: &str, + ) -> NavigationEngine { + let mut fetch_config = self + .browser_host_state + .navigation_owner() + .active_fetch_config() + .clone(); + fetch_config.set_user_agent(marker); + self.browser_host_state + .navigation_owner() + .new_engine_sharing_active_renderer_owner(fetch_config) + .expect("test navigation engine should share the active renderer owner") + } + + #[cfg(test)] + pub(crate) fn active_navigation_engine_user_agent_for_test(&self) -> String { + self.browser_host_state + .navigation_owner() + .active_fetch_config() + .user_agent() + .to_owned() + } + + pub(crate) fn retain_background_navigation_engine( + &mut self, + browser_context_id: String, + target_id: String, + engine: NavigationEngine, + ) -> Result<(), String> { + let owner_access = self + .browser_context_by_id(&browser_context_id) + .ok_or_else(|| { + format!( + "cannot retain navigation engine for missing BrowserContext `{browser_context_id}`" + ) + })? + .renderer_runtime_owner_access(); + if !engine + .browser_context_runtime() + .shares_state_with(&owner_access.runtime()) + { + return Err(format!( + "navigation engine renderer context does not match BrowserContext `{browser_context_id}`" + )); + } + self.browser_host_state + .adopt_target_engine( + BrowserPageOwnerKey::new(browser_context_id, target_id), + BrowserTargetEngineResidence::Retained, + engine, + ) + .map_err(|error| error.to_string())?; + owner_access.reap_retired_resource_runtimes(); + Ok(()) + } + + pub(crate) fn adopt_loaded_navigation_engine_for_target_owner( + &mut self, + owner: BrowserPageOwnerKey, + engine: NavigationEngine, + ) -> Result { + self.browser_host_state + .adopt_registered_target_engine(owner, engine) + } + + /// Session routing is retained only as a test/migration input adapter. It + /// resolves once to neutral browser identity; the core operation neither + /// sees nor stores the frontend session. + #[cfg(test)] + pub(crate) fn adopt_loaded_navigation_engine_for_session_owner( + &mut self, + session_id: Option<&str>, + engine: NavigationEngine, + ) { + let Some(owner) = self.target_page_owner_key_for_session(session_id) else { + self.adopt_navigation_engine_for_current_owner(engine) + .expect("test current engine owner should remain exact"); + return; + }; + self.adopt_loaded_navigation_engine_for_target_owner(owner, engine) + .expect("test navigation engine owner must remain physically resident"); + } + + pub(crate) async fn promote_background_target_to_active_for_connection_async( + &mut self, + target_id: &str, + ) -> Result, BrowserTargetPromotionError> { + let previous_active_target_id = self + .browser_context + .as_ref() + .and_then(|browser_context| browser_context.active_target_id_owned()); + let transition = TargetActivationTransition::new(target_id, previous_active_target_id); + let promoted = match self + .start_promote_background_target_to_active_for_connection(target_id)? + { + BrowserTargetPromotionStart::Complete(promoted) => Ok(promoted), + BrowserTargetPromotionStart::Pending(pending) => { + self.finish_promote_background_target_to_active_for_connection(pending.wait().await) + } + }?; + if !promoted { + return Ok(None); + } + Ok(Some( + self.complete_target_activation_async(&transition).await, + )) + } + + pub(crate) fn start_promote_background_target_to_active_for_connection( + &mut self, + target_id: &str, + ) -> Result { + let Some(browser_context) = self.browser_context.as_ref() else { + return Err(BrowserTargetPromotionError::BrowserContextNotLoaded); + }; + if browser_context.active_target_id() == Some(target_id) { + return Ok(BrowserTargetPromotionStart::Complete(true)); + } + if browser_context.background_target(target_id).is_none() { + return Ok(BrowserTargetPromotionStart::Complete(false)); + } + let projected = self.activate_target_projection(target_id)?; + self.notify_target_host_activated(target_id); + if projected.synchronize_loaded_page() + && let Some(pending) = self.start_active_target_promotion_page_synchronization()? + { + return Ok(BrowserTargetPromotionStart::Pending(Box::new(pending))); + } + self.refresh_active_browser_context_loader(); + Ok(BrowserTargetPromotionStart::Complete(true)) + } + + pub(crate) fn finish_promote_background_target_to_active_for_connection( + &mut self, + completed: CompletedBrowserTargetPromotion, + ) -> Result { + let promoted_context_is_selected = self + .browser_host_state + .navigation_owner() + .selected_browser_context_id() + == Some(completed.page_owner.browser_context_id()); + let owner_route = self + .target_page_owner_route_if_current(&completed.page_owner) + .ok_or_else(|| { + BrowserTargetPromotionError::Synchronization( + "promoted Target Page was replaced during state synchronization".to_owned(), + ) + })?; + let mut route_scope = self.scoped_none_session_owner_route_override(owner_route); + let conn = route_scope.conn_mut(); + let mut page = conn + .runtime_session_owner_slot_mut(None) + .map_err(BrowserTargetPromotionError::Synchronization)? + .loaded_page_mut() + .ok_or_else(|| { + BrowserTargetPromotionError::Synchronization( + "promoted Target lost its loaded Page during state synchronization".to_owned(), + ) + })?; + let mut first_error = completed.start_error; + match completed.script_execution_disabled { + Ok(CompletedDevToolsIoCommandDispatch::Dispatched) + | Ok(CompletedDevToolsIoCommandDispatch::SessionResponse { + response_succeeded: true, + .. + }) => {} + Ok(CompletedDevToolsIoCommandDispatch::SessionResponse { + response_succeeded: false, + .. + }) => { + if first_error.is_none() { + first_error = Some( + "promoted Target script-execution policy IO command returned an error" + .to_owned(), + ); + } + } + Err(error) => { + if first_error.is_none() { + first_error = Some(format!( + "promoted Target script-execution policy IO command failed: {error}" + )); + } + } + } + for (kind, completion) in completed.commands { + let completion = match completion { + Ok(completion) => completion, + Err(error) => { + if first_error.is_none() { + first_error = Some(format!( + "promoted Target {kind:?} command failed before apply: {error}" + )); + } + continue; + } + }; + let result = match kind { + BrowserTargetPromotionPageCommandKind::ExtraHttpHeaders => { + page.finish_set_extra_http_headers(completion) + } + BrowserTargetPromotionPageCommandKind::NetworkOffline => { + page.finish_set_network_offline(completion) + } + BrowserTargetPromotionPageCommandKind::BypassServiceWorker => { + page.finish_set_bypass_service_worker(completion) + } + BrowserTargetPromotionPageCommandKind::BlockedUrlPatterns => { + page.finish_set_blocked_url_patterns(completion) + } + BrowserTargetPromotionPageCommandKind::CpuThrottlingRate => { + page.finish_set_cpu_throttling_rate(completion) + } + BrowserTargetPromotionPageCommandKind::FetchSubresourceInterception => { + page.finish_set_fetch_subresource_interception(completion) + } + BrowserTargetPromotionPageCommandKind::SurfaceOverride => { + page.finish_run_page_surface_override_script(completion) + } + }; + if let Err(error) = result + && first_error.is_none() + { + first_error = Some(format!( + "failed to finish promoted Target {kind:?}: {error}" + )); + } + } + drop(route_scope); + if let Some(error) = first_error { + return Err(BrowserTargetPromotionError::Synchronization(error)); + } + if promoted_context_is_selected { + self.refresh_active_browser_context_loader(); + } + Ok(true) + } + + fn start_active_target_promotion_page_synchronization( + &mut self, + ) -> Result, BrowserTargetPromotionError> { + let Some(browser_context) = self.browser_context.as_ref() else { + return Err(BrowserTargetPromotionError::BrowserContextLostAfterProjection); + }; + let Some(target_id) = browser_context.active_target_id() else { + return Err(BrowserTargetPromotionError::BrowserContextLostAfterProjection); + }; + if !browser_context.active_target.runtime_slot.has_loaded_page() { + return Ok(None); + } + let page_owner = self + .browser_host_state + .navigation_owner() + .capture_page_residence(&browser_context.id, target_id) + .ok_or_else(|| { + BrowserTargetPromotionError::Synchronization( + "promoted Target has no exact Page residence".to_owned(), + ) + })?; + + let browser_context = self + .browser_context + .as_mut() + .ok_or(BrowserTargetPromotionError::BrowserContextLostAfterProjection)?; + let effective_headers = browser_context.effective_extra_headers(); + let network_offline = browser_context.network_policy.network_offline(); + let bypass_service_worker = browser_context.network_policy.bypass_service_worker(); + let blocked_url_patterns = browser_context + .network_policy + .blocked_url_patterns() + .to_vec(); + let script_execution_disabled = browser_context.script_execution_disabled; + let cpu_throttling_rate = browser_context.cpu_throttling_rate; + let (fetch_subresource_enabled, fetch_subresource_resource_type) = browser_context + .active_target + .fetch_owner + .subresource_interception_config(); + let surface_override = browser_context + .generated_surface_override_script_for_active_target() + .map(|script| script.source); + let page = browser_context + .active_target + .runtime_slot + .loaded_page_mut() + .ok_or_else(|| { + BrowserTargetPromotionError::Synchronization( + "promoted Target has no loaded Page to synchronize".to_owned(), + ) + })?; + let script_execution_disabled = + page.start_set_script_execution_disabled_from_io(script_execution_disabled); + let mut pending = Vec::with_capacity(6 + usize::from(surface_override.is_some())); + macro_rules! start_promotion_command { + ($kind:expr, $command:expr) => { + match $command { + Ok(command) => pending.push(($kind, command)), + Err(error) => { + let error = format!("failed to start promoted Target {:?}: {error}", $kind); + if pending.is_empty() { + return Err(BrowserTargetPromotionError::Synchronization(error)); + } + return Ok(Some(PendingBrowserTargetPromotion { + page_owner, + script_execution_disabled, + commands: pending, + start_error: Some(error), + })); + } + } + }; + } + start_promotion_command!( + BrowserTargetPromotionPageCommandKind::ExtraHttpHeaders, + page.start_set_extra_http_headers(&effective_headers) + ); + start_promotion_command!( + BrowserTargetPromotionPageCommandKind::NetworkOffline, + page.start_set_network_offline(network_offline) + ); + start_promotion_command!( + BrowserTargetPromotionPageCommandKind::BypassServiceWorker, + page.start_set_bypass_service_worker(bypass_service_worker) + ); + start_promotion_command!( + BrowserTargetPromotionPageCommandKind::BlockedUrlPatterns, + page.start_set_blocked_url_patterns(&blocked_url_patterns) + ); + start_promotion_command!( + BrowserTargetPromotionPageCommandKind::CpuThrottlingRate, + page.start_set_cpu_throttling_rate(cpu_throttling_rate) + ); + start_promotion_command!( + BrowserTargetPromotionPageCommandKind::FetchSubresourceInterception, + page.start_set_fetch_subresource_interception( + fetch_subresource_enabled, + fetch_subresource_resource_type, + ) + ); + if let Some(source) = surface_override { + start_promotion_command!( + BrowserTargetPromotionPageCommandKind::SurfaceOverride, + page.start_run_page_surface_override_script(&source) + ); + } + Ok(Some(PendingBrowserTargetPromotion { + page_owner, + script_execution_disabled, + commands: pending, + start_error: None, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conn::BackgroundTarget; + + #[test] + fn exact_engine_adoption_does_not_require_frontend_session() { + let mut conn = CdpConnection::new(); + let mut context = conn.new_browser_context("context-1".to_owned()); + context.set_active_target_id("target-a"); + conn.insert_browser_context(context); + let owner = BrowserPageOwnerKey::new("context-1", "target-a"); + + assert_eq!( + conn.adopt_loaded_navigation_engine_for_target_owner( + owner.clone(), + NavigationEngine::new() + ), + Ok(BrowserTargetEngineResidence::Selected) + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + Some(&owner) + ); + } + + #[test] + fn physical_background_residence_parks_engine_by_target_not_session() { + let mut conn = CdpConnection::new(); + let mut context = conn.new_browser_context("context-1".to_owned()); + context.set_active_target_id("target-a"); + context.background_targets.push(BackgroundTarget::with_url( + "target-b".to_owned(), + None, + "about:blank".to_owned(), + )); + conn.insert_browser_context(context); + let background = BrowserPageOwnerKey::new("context-1", "target-b"); + + assert_eq!( + conn.adopt_loaded_navigation_engine_for_target_owner( + background.clone(), + NavigationEngine::new() + ), + Ok(BrowserTargetEngineResidence::Retained) + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .retained_background_engine_keys() + .collect::>(), + vec![&background] + ); + } + + #[test] + fn physical_selected_target_divergence_rejects_engine_without_rebinding_core() { + let mut conn = CdpConnection::new(); + let mut context = conn.new_browser_context("context-1".to_owned()); + context.set_active_target_id("target-a"); + conn.insert_browser_context(context); + let target_a = BrowserPageOwnerKey::new("context-1", "target-a"); + let target_b = BrowserPageOwnerKey::new("context-1", "target-b"); + let selected_renderer_owner = conn + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(); + conn.browser_context + .as_mut() + .expect("physical BrowserContext") + .set_active_target_id("target-b"); + + let error = conn + .adopt_navigation_engine_for_current_owner( + NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics(), + ) + .expect_err("physical/Core selected Target divergence must reject adoption"); + + assert_eq!( + error, + BrowserTargetEngineAdoptionError::SelectedTargetProjectionMismatch { + authoritative: Some(target_a.clone()), + projected: Some(target_b), + } + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + Some(&target_a) + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(), + selected_renderer_owner, + "rejected projection must preserve the selected engine" + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .retained_background_engine_count(), + 0 + ); + } + + #[test] + fn unknown_loaded_target_rejects_engine_without_mutating_registry() { + let mut conn = CdpConnection::new(); + let selected_renderer_owner = conn + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(); + let missing = BrowserPageOwnerKey::new("missing-context", "missing-target"); + + let error = conn + .adopt_loaded_navigation_engine_for_target_owner( + missing.clone(), + NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics(), + ) + .expect_err("unknown exact Target must reject engine adoption"); + + assert_eq!( + error, + BrowserTargetEngineAdoptionError::Target(BrowserTargetRegistryError::UnknownTarget( + moli_core::browser_host::BrowserTargetId::new("missing-target") + )) + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + None + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(), + selected_renderer_owner + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .retained_background_engine_count(), + 0 + ); + } + + #[test] + fn topology_rejects_engine_owner_divergence_before_idle_reset() { + let mut conn = CdpConnection::new(); + let divergent = BrowserPageOwnerKey::new("missing-context", "missing-target"); + let selected_renderer_owner = conn + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(); + let error = conn + .browser_host_state + .adopt_target_engine( + divergent.clone(), + BrowserTargetEngineResidence::Selected, + NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics(), + ) + .expect_err("selected engine identity must come from Browser topology"); + assert_eq!(error.selected(), None); + assert_eq!(error.requested(), Some(&divergent)); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(), + selected_renderer_owner, + "rejected injection must preserve the unbound engine" + ); + + let result = conn.release_idle_navigation_engine_memory_if_idle(); + + assert!(result.reset); + assert_eq!(result.reason, "idle-engine-replaced"); + assert_eq!(result.loaded_browser_context_count, 0); + assert_eq!(result.live_target_browser_context_count, 0); + assert_eq!(result.retained_background_navigation_engine_count, 0); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + None + ); + } + + #[tokio::test] + async fn loaded_target_handoff_parks_and_restores_exact_core_engine_owner() { + let mut conn = CdpConnection::new(); + let mut context = conn.new_browser_context("context-1".to_owned()); + context.set_active_target_id("target-a"); + conn.insert_browser_context(context); + let page_a = conn + .load_page_via_runtime_async("data:text/html,A") + .await + .expect("target A Page should load"); + conn.browser_context + .as_mut() + .expect("active context") + .replace_loaded_page(Some(page_a)); + let background_target = + BackgroundTarget::with_url("target-b".to_owned(), None, "about:blank".to_owned()); + conn.register_background_target_projection( + "context-1", + "target-b", + move |context, target_handle, page_residence, session_storage_access| { + let mut background_target = background_target; + background_target.replace_target_handle(target_handle); + background_target.replace_page_residence_handle(page_residence); + background_target.bind_session_storage_access(session_storage_access); + context.background_targets.push(background_target); + }, + ) + .expect("background Target projection should register"); + let target_a = BrowserPageOwnerKey::new("context-1", "target-a"); + let target_b = BrowserPageOwnerKey::new("context-1", "target-b"); + assert_eq!( + conn.adopt_loaded_navigation_engine_for_target_owner( + target_b.clone(), + NavigationEngine::new() + ), + Ok(BrowserTargetEngineResidence::Retained) + ); + + assert!( + conn.promote_background_target_to_active_for_connection_async("target-b") + .await + .expect("target B promotion should run") + .is_some() + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + Some(&target_b) + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .retained_background_engine_keys() + .collect::>(), + vec![&target_a] + ); + + assert!( + conn.promote_background_target_to_active_for_connection_async("target-a") + .await + .expect("target A promotion should run") + .is_some() + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + Some(&target_a) + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .retained_background_engine_count(), + 0 + ); + } + + #[tokio::test] + async fn unknown_target_promotion_does_not_rebind_the_selected_core_engine() { + let mut conn = CdpConnection::new(); + let mut context = conn.new_browser_context("context-1".to_owned()); + context.set_active_target_id("target-a"); + conn.insert_browser_context(context); + let target_a = BrowserPageOwnerKey::new("context-1", "target-a"); + assert_eq!( + conn.adopt_loaded_navigation_engine_for_target_owner( + target_a.clone(), + NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics(), + ), + Ok(BrowserTargetEngineResidence::Selected) + ); + let renderer_owner = conn + .browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(); + + assert!( + conn.promote_background_target_to_active_for_connection_async("missing-target") + .await + .expect("unknown Target lookup should not fail") + .is_none() + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_target_engine_owner(), + Some(&target_a) + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .active_renderer_owner_id_for_diagnostics(), + renderer_owner + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .retained_background_engine_count(), + 0 + ); + } +} diff --git a/moli-protocol/src/conn/browser_target_metadata_projection.rs b/moli-protocol/src/conn/browser_target_metadata_projection.rs new file mode 100644 index 0000000000..18f3969e93 --- /dev/null +++ b/moli-protocol/src/conn/browser_target_metadata_projection.rs @@ -0,0 +1,212 @@ +use super::{ + CdpConnection, CdpTargetHostLifecycleDelta, PreparedTargetHostDelta, TargetEventPlan, + browser_fact_projection::BrowserTargetMetadataFactProjection, +}; + +impl CdpConnection { + /// Projects one already-claimed Browser metadata occurrence into the + /// current frontend's Target graph. + /// + /// The fact freezes Browser URL/title values and exact Page identity. + /// Attached state, discovery/reporting state and the tab facade remain + /// frontend-local and are joined only at this boundary. + pub(crate) fn project_target_metadata_changed_fact( + &mut self, + projection: BrowserTargetMetadataFactProjection, + ) -> TargetEventPlan { + let envelope = projection.envelope(); + let page = envelope.page_residence(); + let target_id = envelope.target_id().as_str(); + let snapshot = match self.capture_browser_top_level_target_snapshot() { + Ok(snapshot) => snapshot, + Err(error) => { + tracing::error!( + ?error, + browser_context_id = page.browser_context_id(), + target_id, + "cannot project Target metadata fact from divergent Browser topology" + ); + return TargetEventPlan::default(); + } + }; + let Some(target_snapshot) = snapshot + .context(page.browser_context_id()) + .and_then(|context| context.target(target_id)) + .cloned() + else { + tracing::debug!( + browser_context_id = page.browser_context_id(), + target_id, + "dropping Target metadata fact after its Target retired" + ); + return TargetEventPlan::default(); + }; + if target_snapshot.page_residence() != page { + tracing::debug!( + browser_context_id = page.browser_context_id(), + target_id, + fact_page_generation = page.loaded_page_generation(), + current_page_generation = target_snapshot.page_residence().loaded_page_generation(), + "dropping Target metadata fact after its exact Page was replaced" + ); + return TargetEventPlan::default(); + } + let mut target_info = match self.project_top_level_target_snapshot(&target_snapshot) { + Ok(target_info) => target_info, + Err(error) => { + tracing::error!( + ?error, + browser_context_id = page.browser_context_id(), + target_id, + "cannot join Target metadata fact with its physical projection" + ); + return TargetEventPlan::default(); + } + }; + target_info.url = projection.transition().url().to_owned(); + target_info.title = projection.transition().title().to_owned(); + + let tab_target_info = self.tab_target_info_for_page_target_info(&target_info); + self.notify_target_host_lifecycle(CdpTargetHostLifecycleDelta::InfoChanged( + target_info.clone(), + )); + if let Some(tab_target_info) = tab_target_info { + self.notify_target_host_lifecycle(CdpTargetHostLifecycleDelta::InfoChanged( + tab_target_info, + )); + } + if !self.has_any_target_info_observer() { + return TargetEventPlan::default(); + } + self.prepared_target_host_delta_event_plan(PreparedTargetHostDelta::info_changed( + target_id.to_owned(), + Some(target_info), + )) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use moli_core::browser_host::BrowserNavigationHistoryPageSnapshot; + use parking_lot::Mutex; + + use super::*; + use crate::conn::{CdpTargetHostLifecycleDelta, CdpTargetHostLifecycleObserver}; + + fn committed_metadata_projection( + conn: &mut CdpConnection, + url: &str, + title: &str, + ) -> BrowserTargetMetadataFactProjection { + conn.install_default_browser_target(); + let owner = conn + .target_page_owner_key_for_session(None) + .expect("default Target owner"); + let navigation = conn + .start_document_navigation_for_session_owner(None, "LID-metadata".to_owned()) + .expect("default Target should accept navigation"); + let permit = conn + .browser_host_state + .navigation_owner() + .prepare_loaded_page_replacement(&owner, &navigation) + .expect("current navigation should prepare replacement"); + let replacement = conn + .browser_host_state + .commit_loaded_page_replacement_without_renderer_owner_for_testing( + permit, + BrowserNavigationHistoryPageSnapshot::new(url, title), + ) + .expect("current navigation should commit replacement"); + conn.take_navigation_commit_fact(&navigation, replacement.current_page()) + .expect("frontend should claim the exact commit fact"); + conn.take_navigation_target_metadata_changed_fact(&navigation, replacement.current_page()) + .expect("frontend should claim the exact committed metadata fact") + } + + #[test] + fn metadata_fact_freezes_browser_values_before_frontend_projection() { + let mut conn = CdpConnection::default(); + let expected_url = "https://example.test/frozen"; + let expected_title = "Frozen title"; + let projection = committed_metadata_projection(&mut conn, expected_url, expected_title); + conn.browser_context + .as_mut() + .expect("default BrowserContext") + .set_target_url("https://example.test/drift".to_owned()); + let expected_page_target_id = conn.default_target_id().to_owned(); + let expected_tab_target_id = conn.default_tab_target_id().to_owned(); + + let observed_metadata = Arc::new(Mutex::new(Vec::new())); + let callback_metadata = Arc::clone(&observed_metadata); + conn.set_target_host_lifecycle_observer(CdpTargetHostLifecycleObserver::new( + move |delta| { + if let CdpTargetHostLifecycleDelta::InfoChanged(target_info) = delta { + callback_metadata.lock().push(( + target_info + .target_id + .map(|target_id| target_id.as_str().to_owned()), + target_info.url, + target_info.title, + )); + } + }, + )); + let plan = conn.project_target_metadata_changed_fact(projection); + + assert!(plan.into_iter().next().is_none()); + assert_eq!( + *observed_metadata.lock(), + vec![ + ( + Some(expected_page_target_id), + expected_url.to_owned(), + expected_title.to_owned(), + ), + ( + Some(expected_tab_target_id), + expected_url.to_owned(), + expected_title.to_owned(), + ), + ], + "frontend projection must use immutable Browser fact values, not later physical drift", + ); + } + + #[test] + fn metadata_fact_stale_drops_after_exact_document_replacement() { + let mut conn = CdpConnection::default(); + let projection = + committed_metadata_projection(&mut conn, "https://example.test/stale", "Stale title"); + conn.target_page_residence_handle_for_session(None) + .expect("default Page residence handle") + .advance_generation_for_test_fixture(); + + let observed = Arc::new(Mutex::new(0_u32)); + let callback_count = Arc::clone(&observed); + conn.set_target_host_lifecycle_observer(CdpTargetHostLifecycleObserver::new(move |_| { + *callback_count.lock() += 1; + })); + let plan = conn.project_target_metadata_changed_fact(projection); + + assert!(plan.into_iter().next().is_none()); + assert_eq!(*observed.lock(), 0); + } + + #[test] + fn frontend_attachment_delta_does_not_publish_browser_metadata_fact() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let before = conn.browser_fact_snapshot_for_test(); + + let target_id = conn.default_target_id().to_owned(); + let _ = conn.frontend_attachment_info_changed_event_plan(&target_id); + + assert_eq!(conn.browser_fact_snapshot_for_test(), before); + assert!(before.iter().all(|envelope| !matches!( + envelope.fact(), + moli_core::browser_host::BrowserFact::TargetMetadataChanged { .. } + ))); + } +} diff --git a/moli-protocol/src/conn/browser_target_termination.rs b/moli-protocol/src/conn/browser_target_termination.rs new file mode 100644 index 0000000000..ba86da38f3 --- /dev/null +++ b/moli-protocol/src/conn/browser_target_termination.rs @@ -0,0 +1,1268 @@ +use moli_core::{ + browser_host::{ + BrowserPageOwnerKey, BrowserTargetResidence, BrowserTargetTerminationKind, + BrowserTargetTerminationRequest, + }, + page::{Page, RendererPageLifetimeOwner}, +}; + +use super::{ + BackgroundProtocolEvent, CdpConnection, ClosedPageTarget, ProjectedActiveTargetClose, + ProjectedClosedPageTarget, + browser_fact_projection::BrowserTargetTerminationFactProjection, + browser_target_engine_handoff::{ + BrowserTargetPromotionStart, CompletedBrowserTargetPromotion, PendingBrowserTargetPromotion, + }, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum BrowserTargetTerminationProjectionKind { + Crash, + PageClose, + TargetClose, +} + +impl BrowserTargetTerminationProjectionKind { + fn browser_kind(self) -> BrowserTargetTerminationKind { + match self { + Self::Crash => BrowserTargetTerminationKind::Crash, + Self::PageClose | Self::TargetClose => BrowserTargetTerminationKind::Close, + } + } +} + +pub(crate) enum BrowserTargetTerminationProjection { + Crashed { + inspector_session_ids: Vec>, + browser_fact: Option, + }, + Closed { + closed: ClosedPageTarget, + browser_fact: Option, + }, +} + +enum TargetTerminationAsyncCleanup { + Crash { + inspector_session_ids: Vec>, + retired_page: Option, + retired_renderer_page_owner: Option, + browser_fact: Option, + }, + Close { + projected: ProjectedClosedPageTarget, + retired_renderer_page_owner: Option, + browser_fact: Option, + }, + ActiveTargetClose { + projected: ProjectedActiveTargetClose, + retired_renderer_page_owner: Option, + browser_fact: Option, + }, +} + +struct RetiredRendererPageCleanup { + page: Option, + owner: Option, +} + +impl RetiredRendererPageCleanup { + fn new(page: Option, owner: Option) -> Self { + Self { page, owner } + } + + fn is_empty(&self) -> bool { + self.page.is_none() && self.owner.is_none() + } + + async fn wait(self) { + if let Some(owner) = self.owner { + let _ = owner.close_async().await; + } + if let Some(page) = self.page { + let _ = page.close_async().await; + } + } +} + +/// Result of synchronously committing and projecting one Page-originated +/// Browser termination turn. +/// +/// Core authority and physical Page absence are already committed when this +/// value is returned. Only destruction of the retired renderer Page may remain +/// as a move-owned participant. +pub(crate) enum BrowserPageTargetTerminationStart { + Complete(BrowserTargetTerminationProjection), + Pending(PendingBrowserPageTargetTermination), +} + +/// Move-owned disposal participant for an actor-selected Page termination. +pub(crate) struct PendingBrowserPageTargetTermination { + projection: BrowserTargetTerminationProjection, + cleanup: RetiredRendererPageCleanup, +} + +impl PendingBrowserPageTargetTermination { + pub(crate) async fn wait(self) -> BrowserTargetTerminationProjection { + self.cleanup.wait().await; + self.projection + } +} + +/// Result of committing one exact `Target.closeTarget` in Browser Host. +pub(crate) enum BrowserTargetCloseStart { + Complete(BrowserTargetTerminationProjection), + Pending(Box), +} + +pub(crate) struct PendingBrowserTargetClose { + stage: PendingBrowserTargetCloseStage, +} + +enum PendingBrowserTargetCloseStage { + RetiredPage { + cleanup: RetiredRendererPageCleanup, + continuation: BrowserTargetCloseContinuation, + }, + Promotion { + pending: PendingBrowserTargetPromotion, + projection: BrowserTargetTerminationProjection, + }, +} + +pub(crate) struct CompletedBrowserTargetClose { + stage: CompletedBrowserTargetCloseStage, +} + +enum CompletedBrowserTargetCloseStage { + RetiredPage(BrowserTargetCloseContinuation), + Promotion { + completed: CompletedBrowserTargetPromotion, + projection: BrowserTargetTerminationProjection, + }, +} + +struct BrowserTargetCloseContinuation { + projection: BrowserTargetTerminationProjection, + owner_browser_context_id: String, + promoted_target_id: Option, + synchronize_promoted_target: bool, +} + +impl PendingBrowserTargetClose { + pub(crate) async fn wait(self) -> CompletedBrowserTargetClose { + let stage = match self.stage { + PendingBrowserTargetCloseStage::RetiredPage { + cleanup, + continuation, + } => { + cleanup.wait().await; + CompletedBrowserTargetCloseStage::RetiredPage(continuation) + } + PendingBrowserTargetCloseStage::Promotion { + pending, + projection, + } => CompletedBrowserTargetCloseStage::Promotion { + completed: pending.wait().await, + projection, + }, + }; + CompletedBrowserTargetClose { stage } + } +} + +impl CdpConnection { + pub(crate) fn capture_browser_target_termination_for_owner( + &self, + owner: &BrowserPageOwnerKey, + kind: BrowserTargetTerminationProjectionKind, + ) -> Option { + self.browser_host_state + .navigation_owner() + .capture_target_termination(owner, kind.browser_kind()) + } + + pub(crate) fn capture_browser_target_termination_for_session_owner( + &self, + session_id: Option<&str>, + kind: BrowserTargetTerminationProjectionKind, + ) -> Option { + let owner = self.target_page_owner_key_for_session(session_id)?; + self.browser_host_state + .navigation_owner() + .capture_target_termination(&owner, kind.browser_kind()) + } + + pub(crate) fn capture_browser_target_termination_for_target( + &self, + target_id: &str, + kind: BrowserTargetTerminationProjectionKind, + ) -> Option { + let browser_context = self.browser_context.as_ref()?; + if !browser_context.is_active_target(target_id) + && browser_context.background_target(target_id).is_none() + { + return None; + } + let owner = BrowserPageOwnerKey::new(browser_context.id.clone(), target_id.to_owned()); + self.browser_host_state + .navigation_owner() + .capture_target_termination(&owner, kind.browser_kind()) + } + + /// Starts one actor-selected Page.crash/Page.close transition without + /// crossing an async boundary. + /// + /// This is deliberately narrower than explicit Target.closeTarget: the + /// latter may first require an asynchronous BrowserContext engine handoff. + /// Page-originated input already carries an exact route and can therefore + /// commit Core authority and project physical absence in one short owner + /// turn. Retired renderer destruction is returned as a participant. + pub(crate) fn start_browser_page_target_termination( + &mut self, + request: BrowserTargetTerminationRequest, + projection_kind: BrowserTargetTerminationProjectionKind, + out: &mut Vec, + close_reason: &'static str, + ) -> Option { + if !matches!( + projection_kind, + BrowserTargetTerminationProjectionKind::Crash + | BrowserTargetTerminationProjectionKind::PageClose + ) || request.kind() != projection_kind.browser_kind() + { + return None; + } + + let cleanup = self.commit_browser_target_termination_projection( + request, + projection_kind, + out, + close_reason, + )?; + self.debug_assert_browser_target_topology_projection(); + let (projection, retired_cleanup) = match cleanup { + TargetTerminationAsyncCleanup::Crash { + inspector_session_ids, + retired_page, + retired_renderer_page_owner, + browser_fact, + } => ( + BrowserTargetTerminationProjection::Crashed { + inspector_session_ids, + browser_fact, + }, + RetiredRendererPageCleanup::new(retired_page, retired_renderer_page_owner), + ), + TargetTerminationAsyncCleanup::Close { + projected, + retired_renderer_page_owner, + browser_fact, + } => { + let (closed, retired_page) = projected.into_parts(); + ( + BrowserTargetTerminationProjection::Closed { + closed, + browser_fact, + }, + RetiredRendererPageCleanup::new(retired_page, retired_renderer_page_owner), + ) + } + TargetTerminationAsyncCleanup::ActiveTargetClose { .. } => { + tracing::error!( + ?projection_kind, + "Page termination unexpectedly requested Target.closeTarget projection" + ); + return None; + } + }; + + Some(if retired_cleanup.is_empty() { + BrowserPageTargetTerminationStart::Complete(projection) + } else { + BrowserPageTargetTerminationStart::Pending(PendingBrowserPageTargetTermination { + projection, + cleanup: retired_cleanup, + }) + }) + } + + /// Starts an exact top-level Target close in one short owner turn. + /// + /// Context selection, Core authority and matching physical absence commit + /// synchronously. Retired Page disposal and promoted-Page renderer state + /// synchronization are returned as move-owned participants. + pub(crate) fn start_browser_target_close( + &mut self, + request: BrowserTargetTerminationRequest, + out: &mut Vec, + close_reason: &'static str, + ) -> Option { + if request.kind() != BrowserTargetTerminationKind::Close { + return None; + } + let owner_browser_context_id = request.owner().browser_context_id().to_owned(); + let restore_browser_context_id = self + .browser_context + .as_ref() + .map(|browser_context| browser_context.id.clone()); + let activated_for_target_close = + restore_browser_context_id.as_deref() != Some(owner_browser_context_id.as_str()); + if activated_for_target_close + && !self.activate_browser_context_by_id(&owner_browser_context_id) + { + return None; + } + + let cleanup = self.commit_browser_target_termination_projection( + request, + BrowserTargetTerminationProjectionKind::TargetClose, + out, + close_reason, + ); + let Some(cleanup) = cleanup else { + self.restore_target_termination_browser_context( + activated_for_target_close + .then_some(restore_browser_context_id.as_deref()) + .flatten(), + ); + return None; + }; + self.debug_assert_browser_target_topology_projection(); + let (closed, retired_cleanup, promoted_target_id, browser_fact) = match cleanup { + TargetTerminationAsyncCleanup::Close { + projected, + retired_renderer_page_owner, + browser_fact, + } => { + let (closed, retired_page) = projected.into_parts(); + ( + closed, + RetiredRendererPageCleanup::new(retired_page, retired_renderer_page_owner), + None, + browser_fact, + ) + } + TargetTerminationAsyncCleanup::ActiveTargetClose { + projected, + retired_renderer_page_owner, + browser_fact, + } => { + let (closed, retired_page, promoted_target_id) = projected.into_parts(); + ( + closed, + RetiredRendererPageCleanup::new(retired_page, retired_renderer_page_owner), + promoted_target_id, + browser_fact, + ) + } + TargetTerminationAsyncCleanup::Crash { .. } => { + tracing::error!("Target.closeTarget unexpectedly produced a crash projection"); + self.restore_target_termination_browser_context( + activated_for_target_close + .then_some(restore_browser_context_id.as_deref()) + .flatten(), + ); + return None; + } + }; + let continuation = BrowserTargetCloseContinuation { + projection: BrowserTargetTerminationProjection::Closed { + closed, + browser_fact, + }, + owner_browser_context_id, + promoted_target_id, + synchronize_promoted_target: true, + }; + Some(if !retired_cleanup.is_empty() { + self.restore_target_termination_browser_context( + activated_for_target_close + .then_some(restore_browser_context_id.as_deref()) + .flatten(), + ); + BrowserTargetCloseStart::Pending(Box::new(PendingBrowserTargetClose { + stage: PendingBrowserTargetCloseStage::RetiredPage { + cleanup: retired_cleanup, + continuation, + }, + })) + } else { + self.continue_browser_target_close_after_page_disposal( + continuation, + activated_for_target_close + .then_some(restore_browser_context_id) + .flatten(), + ) + }) + } + + /// Starts one exact Target close as part of whole-Context disposal. + /// + /// The Context may be physically inactive and is already reserved against + /// new owner work. Projection therefore uses the exact Page route without + /// changing global Context selection. If closing its active Target + /// promotes a retained target, that target is deliberately not + /// synchronized with frontend settings because the same disposal chain + /// will close it next. + pub(crate) fn start_browser_target_close_for_context_disposal( + &mut self, + request: BrowserTargetTerminationRequest, + out: &mut Vec, + close_reason: &'static str, + ) -> Option { + if request.kind() != BrowserTargetTerminationKind::Close { + return None; + } + let owner_browser_context_id = request.owner().browser_context_id().to_owned(); + let cleanup = self.commit_browser_target_termination_projection( + request, + BrowserTargetTerminationProjectionKind::TargetClose, + out, + close_reason, + )?; + self.debug_assert_browser_target_topology_projection(); + let (closed, retired_cleanup, promoted_target_id, browser_fact) = match cleanup { + TargetTerminationAsyncCleanup::Close { + projected, + retired_renderer_page_owner, + browser_fact, + } => { + let (closed, retired_page) = projected.into_parts(); + ( + closed, + RetiredRendererPageCleanup::new(retired_page, retired_renderer_page_owner), + None, + browser_fact, + ) + } + TargetTerminationAsyncCleanup::ActiveTargetClose { + projected, + retired_renderer_page_owner, + browser_fact, + } => { + let (closed, retired_page, promoted_target_id) = projected.into_parts(); + ( + closed, + RetiredRendererPageCleanup::new(retired_page, retired_renderer_page_owner), + promoted_target_id, + browser_fact, + ) + } + TargetTerminationAsyncCleanup::Crash { .. } => { + tracing::error!( + "BrowserContext disposal Target close unexpectedly produced a crash projection" + ); + return None; + } + }; + let continuation = BrowserTargetCloseContinuation { + projection: BrowserTargetTerminationProjection::Closed { + closed, + browser_fact, + }, + owner_browser_context_id, + promoted_target_id, + synchronize_promoted_target: false, + }; + Some(if retired_cleanup.is_empty() { + self.continue_browser_target_close_after_page_disposal(continuation, None) + } else { + BrowserTargetCloseStart::Pending(Box::new(PendingBrowserTargetClose { + stage: PendingBrowserTargetCloseStage::RetiredPage { + cleanup: retired_cleanup, + continuation, + }, + })) + }) + } + + pub(crate) fn continue_browser_target_close( + &mut self, + completed: CompletedBrowserTargetClose, + ) -> BrowserTargetCloseStart { + match completed.stage { + CompletedBrowserTargetCloseStage::RetiredPage(continuation) => { + self.continue_browser_target_close_after_page_disposal(continuation, None) + } + CompletedBrowserTargetCloseStage::Promotion { + completed, + projection, + } => { + if let Err(error) = + self.finish_promote_background_target_to_active_for_connection(completed) + { + tracing::warn!( + %error, + "failed to synchronize the promoted Target after Target.closeTarget" + ); + } + BrowserTargetCloseStart::Complete(projection) + } + } + } + + fn continue_browser_target_close_after_page_disposal( + &mut self, + continuation: BrowserTargetCloseContinuation, + restore_browser_context_id: Option, + ) -> BrowserTargetCloseStart { + let BrowserTargetCloseContinuation { + projection, + owner_browser_context_id, + promoted_target_id, + synchronize_promoted_target, + } = continuation; + if !synchronize_promoted_target { + return BrowserTargetCloseStart::Complete(projection); + } + let selected_browser_context_id = self + .browser_context + .as_ref() + .map(|browser_context| browser_context.id.clone()); + let activated_for_continuation = + selected_browser_context_id.as_deref() != Some(owner_browser_context_id.as_str()); + let restore_browser_context_id = restore_browser_context_id.or_else(|| { + activated_for_continuation + .then_some(selected_browser_context_id) + .flatten() + }); + if activated_for_continuation + && !self.activate_browser_context_by_id(&owner_browser_context_id) + { + return BrowserTargetCloseStart::Complete(projection); + } + + if let Some(promoted_target_id) = promoted_target_id.as_deref() { + match self.start_promote_background_target_to_active_for_connection(promoted_target_id) + { + Ok(BrowserTargetPromotionStart::Complete(_)) => {} + Ok(BrowserTargetPromotionStart::Pending(pending)) => { + self.restore_target_termination_browser_context( + restore_browser_context_id.as_deref(), + ); + return BrowserTargetCloseStart::Pending(Box::new(PendingBrowserTargetClose { + stage: PendingBrowserTargetCloseStage::Promotion { + pending: *pending, + projection, + }, + })); + } + Err(error) => { + tracing::warn!( + %error, + "failed to promote a retained Target after Target.closeTarget" + ); + } + } + } else { + self.refresh_active_browser_context_loader(); + } + self.restore_target_termination_browser_context(restore_browser_context_id.as_deref()); + BrowserTargetCloseStart::Complete(projection) + } + + /// Commits Browser Core authority before synchronously projecting the + /// physical Target/Page absence. Only retired Page disposal and optional + /// active-target promotion may await afterward. + #[cfg(test)] + pub(crate) async fn commit_browser_target_termination_async( + &mut self, + request: BrowserTargetTerminationRequest, + projection_kind: BrowserTargetTerminationProjectionKind, + out: &mut Vec, + close_reason: &'static str, + ) -> Option { + if projection_kind == BrowserTargetTerminationProjectionKind::TargetClose { + let mut step = self.start_browser_target_close(request, out, close_reason)?; + loop { + match step { + BrowserTargetCloseStart::Complete(projection) => return Some(projection), + BrowserTargetCloseStart::Pending(pending) => { + step = self.continue_browser_target_close(pending.wait().await); + } + } + } + } + match self.start_browser_page_target_termination( + request, + projection_kind, + out, + close_reason, + )? { + BrowserPageTargetTerminationStart::Complete(projection) => Some(projection), + BrowserPageTargetTerminationStart::Pending(pending) => Some(pending.wait().await), + } + } + + fn restore_target_termination_browser_context( + &mut self, + restore_browser_context_id: Option<&str>, + ) { + if let Some(restore_browser_context_id) = restore_browser_context_id + && self.has_browser_context_id(restore_browser_context_id) + { + let _ = self.activate_browser_context_by_id(restore_browser_context_id); + } + } + + fn commit_browser_target_termination_projection( + &mut self, + request: BrowserTargetTerminationRequest, + projection_kind: BrowserTargetTerminationProjectionKind, + out: &mut Vec, + close_reason: &'static str, + ) -> Option { + let owner_browser_context_id = request.owner().browser_context_id().to_owned(); + let owner_target_id = request.owner().target_id().to_owned(); + let owner_route = self.target_page_owner_route_if_current(request.page())?; + let permit = self + .browser_host_state + .navigation_owner() + .prepare_target_termination(request)?; + + let mut route_scope = self.scoped_none_session_owner_route_override(owner_route); + let conn = route_scope.conn_mut(); + let inspector_session_ids = (projection_kind + == BrowserTargetTerminationProjectionKind::Crash) + .then(|| conn.page_event_session_ids_for_session_owner(None)); + + // There is deliberately no await, frontend flush, or callback between + // this authoritative commit and the matching physical projection. + let mut termination = match conn.browser_host_state.commit_target_termination(permit) { + Ok(termination) => termination, + Err(error) => { + tracing::warn!( + target_id = owner_target_id, + browser_context_id = owner_browser_context_id, + %error, + "Browser Owner rejected a prepared Target termination" + ); + return None; + } + }; + let browser_fact = match conn.take_target_termination_fact(&termination) { + Ok(projection) => Some(projection), + Err(error) => { + tracing::error!( + %error, + target_id = owner_target_id, + browser_context_id = owner_browser_context_id, + "Target terminal committed without an exact frontend Browser fact" + ); + None + } + }; + if let Some(inspector_session_ids) = inspector_session_ids.as_ref() { + for inspector_session_id in inspector_session_ids { + let _ = conn.with_target_devtools_session_state_for_session_mut( + inspector_session_id.as_deref(), + |state| { + state + .runtime_session_state + .record_inspector_target_crashed(); + }, + ); + } + } + Some(match projection_kind { + BrowserTargetTerminationProjectionKind::Crash => { + let retired_page = conn + .project_target_crash_for_none_session_owner_after_browser_owner_commit( + &termination, + ) + .expect("prepared crash route must remain available after owner commit"); + TargetTerminationAsyncCleanup::Crash { + inspector_session_ids: inspector_session_ids + .expect("crash projection must capture inspector sessions"), + retired_page, + retired_renderer_page_owner: termination.take_retired_renderer_page_owner(), + browser_fact, + } + } + BrowserTargetTerminationProjectionKind::PageClose => { + let projected = conn + .project_page_close_for_none_session_owner_after_browser_owner_commit( + &termination, + ) + .expect("prepared Page.close route must remain available after owner commit"); + TargetTerminationAsyncCleanup::Close { + projected, + retired_renderer_page_owner: termination.take_retired_renderer_page_owner(), + browser_fact, + } + } + BrowserTargetTerminationProjectionKind::TargetClose => { + match termination + .closed_target_residence() + .expect("Target.close must commit a Core Target topology removal") + { + BrowserTargetResidence::Active => { + let projected = conn + .project_active_target_close_after_browser_owner_commit(&termination) + .expect( + "prepared active Target.close route must remain available after owner commit", + ); + TargetTerminationAsyncCleanup::ActiveTargetClose { + projected, + retired_renderer_page_owner: termination + .take_retired_renderer_page_owner(), + browser_fact, + } + } + BrowserTargetResidence::Background => { + let projected = conn + .project_background_target_close_after_browser_owner_commit( + &termination, + out, + close_reason, + ) + .expect( + "prepared background Target.close route must remain available after owner commit", + ); + TargetTerminationAsyncCleanup::Close { + projected, + retired_renderer_page_owner: termination + .take_retired_renderer_page_owner(), + browser_fact, + } + } + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conn::{BrowserContext, LoadedNavigationRendererAttachmentCommit}; + use moli_core::browser_host::{BrowserFact, BrowserHostActor, BrowserOwnerInput}; + use moli_core::page::RendererMainDocumentCommit; + + #[tokio::test] + async fn page_close_uses_exact_browser_owner_after_frontend_session_replacement() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let target_id = conn + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id) + .expect("default target") + .to_owned(); + conn.browser_context + .as_mut() + .expect("browser context") + .attach_active_session("SID-old".to_owned()); + let page_residence = conn + .target_page_residence_handle_for_session(Some("SID-old")) + .expect("active target residence"); + let previous_generation = page_residence.generation(); + let request = conn + .capture_browser_target_termination_for_session_owner( + Some("SID-old"), + BrowserTargetTerminationProjectionKind::PageClose, + ) + .expect("live Page.close should capture exact Browser owner"); + let previous_page = request.page().clone(); + + let browser_context = conn.browser_context.as_mut().expect("browser context"); + assert_eq!( + browser_context.detach_active_session().as_deref(), + Some("SID-old") + ); + browser_context.attach_active_session("SID-new".to_owned()); + + let mut events = Vec::new(); + let projection = conn + .commit_browser_target_termination_async( + request, + BrowserTargetTerminationProjectionKind::PageClose, + &mut events, + "Page closed", + ) + .await + .expect("session replacement must not cancel Browser Target close"); + let BrowserTargetTerminationProjection::Closed { + closed, + browser_fact, + } = projection + else { + panic!("Page.close must project a closed Target"); + }; + assert!(browser_fact.is_some()); + + assert_eq!(closed.target_id, target_id); + assert_eq!(closed.primary_session_id.as_deref(), Some("SID-new")); + assert_eq!(page_residence.generation(), previous_generation + 1); + assert!( + conn.browser_context + .as_ref() + .is_some_and(|browser_context| !browser_context.has_active_target()) + ); + let fact = conn + .browser_fact_snapshot_for_test() + .into_iter() + .find(|fact| matches!(fact.fact(), BrowserFact::TargetClosed { .. })) + .expect("Page.close production path should publish TargetClosed"); + assert_eq!( + fact.fact(), + &BrowserFact::TargetClosed { + previous_page, + pending_navigation: None, + } + ); + assert_eq!( + fact.page_residence().loaded_page_generation(), + previous_generation + 1 + ); + } + + #[tokio::test] + async fn crash_advances_one_generation_and_rejects_second_terminal_capture() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let page_residence = conn + .target_page_residence_handle_for_session(None) + .expect("default target residence"); + let previous_generation = page_residence.generation(); + let request = conn + .capture_browser_target_termination_for_session_owner( + None, + BrowserTargetTerminationProjectionKind::Crash, + ) + .expect("live Page.crash should capture exact Browser owner"); + let previous_page = request.page().clone(); + let mut events = Vec::new(); + + let projection = conn + .commit_browser_target_termination_async( + request, + BrowserTargetTerminationProjectionKind::Crash, + &mut events, + "Page crashed", + ) + .await + .expect("exact crash should project"); + let BrowserTargetTerminationProjection::Crashed { browser_fact, .. } = projection else { + panic!("Page.crash must project a crashed Target"); + }; + assert!(browser_fact.is_some()); + + assert_eq!(page_residence.generation(), previous_generation + 1); + let fact = conn + .browser_fact_snapshot_for_test() + .into_iter() + .find(|fact| matches!(fact.fact(), BrowserFact::TargetCrashed { .. })) + .expect("Page.crash production path should publish TargetCrashed"); + assert_eq!( + fact.fact(), + &BrowserFact::TargetCrashed { + previous_page, + pending_navigation: None, + } + ); + assert_eq!( + fact.page_residence().loaded_page_generation(), + previous_generation + 1 + ); + assert!( + conn.capture_browser_target_termination_for_session_owner( + None, + BrowserTargetTerminationProjectionKind::Crash, + ) + .is_none(), + "crashed Target must not commit another terminal transition" + ); + } + + #[tokio::test] + async fn crashed_target_remains_closable_through_browser_owner_transaction() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let target_id = conn + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id) + .expect("default target") + .to_owned(); + let page_residence = conn + .target_page_residence_handle_for_session(None) + .expect("default target residence"); + let crash = conn + .capture_browser_target_termination_for_session_owner( + None, + BrowserTargetTerminationProjectionKind::Crash, + ) + .expect("live Page should capture crash"); + let mut events = Vec::new(); + assert!(matches!( + conn.commit_browser_target_termination_async( + crash, + BrowserTargetTerminationProjectionKind::Crash, + &mut events, + "Page crashed", + ) + .await, + Some(BrowserTargetTerminationProjection::Crashed { .. }) + )); + + let close = conn + .capture_browser_target_termination_for_target( + &target_id, + BrowserTargetTerminationProjectionKind::TargetClose, + ) + .expect("crashed Target should remain closable"); + let projection = conn + .commit_browser_target_termination_async( + close, + BrowserTargetTerminationProjectionKind::TargetClose, + &mut events, + "Target closed", + ) + .await + .expect("crashed Target close should commit"); + let BrowserTargetTerminationProjection::Closed { + closed, + browser_fact, + } = projection + else { + panic!("Target.closeTarget must project a closed crashed Target"); + }; + assert!(browser_fact.is_some()); + + assert_eq!(closed.target_id, target_id); + assert_eq!(page_residence.generation(), 2); + assert!( + conn.browser_context + .as_ref() + .is_some_and(|browser_context| !browser_context.has_active_target()) + ); + } + + #[tokio::test] + async fn replaced_page_makes_delayed_page_close_stale_without_closing_target() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let page_residence = conn + .target_page_residence_handle_for_session(None) + .expect("default target residence"); + let request = conn + .capture_browser_target_termination_for_session_owner( + None, + BrowserTargetTerminationProjectionKind::PageClose, + ) + .expect("live Page.close should capture exact Browser owner"); + page_residence.advance_generation_for_test_fixture(); + let mut events = Vec::new(); + + assert!( + conn.commit_browser_target_termination_async( + request, + BrowserTargetTerminationProjectionKind::PageClose, + &mut events, + "Page closed", + ) + .await + .is_none() + ); + assert!( + conn.browser_context + .as_ref() + .is_some_and(BrowserContext::has_active_target) + ); + } + + #[tokio::test] + async fn browser_host_drops_queued_page_close_after_page_generation_replacement() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let target_id = conn + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id) + .expect("default target") + .to_owned(); + let page_residence = conn + .target_page_residence_handle_for_session(None) + .expect("default target residence"); + let request = conn + .capture_browser_target_termination_for_session_owner( + None, + BrowserTargetTerminationProjectionKind::PageClose, + ) + .expect("live Page.close should capture exact Browser owner"); + let (mut browser_host, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + conn.publish_browser_owner_input(BrowserOwnerInput::page_termination(request)) + .expect("live Browser Host should accept Page.close"); + + page_residence.advance_generation_for_test_fixture(); + let dispatch = browser_host + .complete_next_turn(&mut conn) + .expect("queued Page.close Browser Host turn"); + let outcome = conn.finish_browser_host_turn_for_test(dispatch).await; + let (events, scheduler_events, predecessor) = outcome.into_protocol_event_parts(); + + assert!(events.is_empty()); + assert!(scheduler_events.is_empty()); + assert!(predecessor.is_none()); + assert_eq!( + conn.browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(target_id.as_str()), + "a queued old-Page close must not terminate its replacement" + ); + } + + #[tokio::test] + async fn target_close_commits_only_after_browser_host_selects_its_turn() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let target_id = conn + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id) + .expect("default target") + .to_owned(); + let request = conn + .capture_browser_target_termination_for_target( + &target_id, + BrowserTargetTerminationProjectionKind::TargetClose, + ) + .expect("live Target should capture exact close authority"); + let (mut browser_host, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + conn.publish_browser_owner_input(BrowserOwnerInput::target_termination(request)) + .expect("live Browser Host should accept Target.closeTarget"); + + assert_eq!(browser_host.ready_len(), 1); + assert_eq!( + conn.browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(target_id.as_str()), + "publishing an input cannot execute Target.closeTarget" + ); + + let dispatch = browser_host + .complete_next_turn(&mut conn) + .expect("queued Target.closeTarget Browser Host turn"); + let outcome = conn.finish_browser_host_turn_for_test(dispatch).await; + let (_, _, predecessor) = outcome.into_protocol_event_parts(); + + assert!(predecessor.is_none()); + assert!( + conn.browser_context + .as_ref() + .is_some_and(|browser_context| !browser_context.has_active_target()), + "only the actor-selected turn may commit Target retirement" + ); + } + + #[tokio::test] + async fn target_close_participants_do_not_hold_temporary_context_selection() { + let mut conn = CdpConnection::default(); + for (browser_context_id, target_id) in [ + ("context-a", "target-a"), + ("context-b", "target-b"), + ("context-c", "target-c"), + ] { + let mut browser_context = conn.new_browser_context(browser_context_id.to_owned()); + browser_context.set_active_target_id(target_id.to_owned()); + conn.try_insert_browser_context(browser_context) + .expect("test BrowserContext should register"); + } + + assert!(conn.activate_browser_context_by_id("context-b")); + let navigation = conn + .start_document_navigation_for_session_owner(None, "LOADER-target-b".to_owned()) + .expect("target B navigation should start"); + let page = conn + .load_page_via_runtime_async("data:text/html,target-b") + .await + .expect("target B Page should load"); + let final_url = page.final_url().clone(); + let document_commit = RendererMainDocumentCommit { + frame_id: "target-b".to_owned(), + loader_id: navigation.loader_id().to_owned(), + url: final_url.to_string(), + unreachable_url: None, + security_origin: final_url.origin().ascii_serialization(), + secure_context_type: "InsecureScheme".to_owned(), + timestamp: 0.0, + }; + conn.commit_loaded_page_replacement_for_session_owner_async( + None, + &navigation, + page, + &final_url, + &document_commit, + LoadedNavigationRendererAttachmentCommit::Prepare(None), + ) + .await + .expect("target B replacement should produce an outcome") + .expect("target B replacement should commit"); + let request = conn + .capture_browser_target_termination_for_target( + "target-b", + BrowserTargetTerminationProjectionKind::TargetClose, + ) + .expect("loaded target B should capture exact close authority"); + let previous_page = request.page().clone(); + assert!(conn.activate_browser_context_by_id("context-a")); + + let mut events = Vec::new(); + let BrowserTargetCloseStart::Pending(pending) = conn + .start_browser_target_close(request, &mut events, "Target closed") + .expect("target B close should commit") + else { + panic!("retired target B Page should become a participant"); + }; + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("context-a"), + "starting a participant must restore the pre-turn selection" + ); + let fact = conn + .browser_fact_snapshot_for_test() + .into_iter() + .find(|fact| { + fact.target_id().as_str() == "target-b" + && matches!(fact.fact(), BrowserFact::TargetClosed { .. }) + }) + .expect("Core TargetClosed fact must precede retired Page disposal"); + assert_eq!( + fact.fact(), + &BrowserFact::TargetClosed { + previous_page, + pending_navigation: None, + } + ); + + assert!(conn.activate_browser_context_by_id("context-c")); + let completed = pending.wait().await; + assert!(matches!( + conn.continue_browser_target_close(completed), + BrowserTargetCloseStart::Complete(_) + )); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("context-c"), + "an old completion must preserve a newer BrowserContext selection" + ); + assert_eq!( + conn.browser_context + .as_ref() + .map(|browser_context| browser_context.id.as_str()), + Some("context-c") + ); + } + + #[tokio::test] + async fn browser_host_drops_queued_target_close_after_page_generation_replacement() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let target_id = conn + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id) + .expect("default target") + .to_owned(); + let page_residence = conn + .target_page_residence_handle_for_session(None) + .expect("default target residence"); + let request = conn + .capture_browser_target_termination_for_target( + &target_id, + BrowserTargetTerminationProjectionKind::TargetClose, + ) + .expect("live Target should capture exact close authority"); + let (mut browser_host, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + conn.publish_browser_owner_input(BrowserOwnerInput::target_termination(request)) + .expect("live Browser Host should accept Target.closeTarget"); + + page_residence.advance_generation_for_test_fixture(); + let dispatch = browser_host + .complete_next_turn(&mut conn) + .expect("queued Target.closeTarget Browser Host turn"); + let outcome = conn.finish_browser_host_turn_for_test(dispatch).await; + let (events, scheduler_events, predecessor) = outcome.into_protocol_event_parts(); + + assert!(events.is_empty()); + assert!(scheduler_events.is_empty()); + assert!(predecessor.is_none()); + assert_eq!( + conn.browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(target_id.as_str()), + "a queued old-Page Target close must not terminate its replacement" + ); + } + + #[tokio::test] + async fn background_target_close_survives_primary_session_replacement() { + let mut conn = CdpConnection::default(); + let mut browser_context = BrowserContext::new("BID-termination".to_owned()); + browser_context.set_active_target_id("TID-active".to_owned()); + browser_context.stage_background_target( + "TID-background".to_owned(), + Some("SID-old".to_owned()), + "about:blank".to_owned(), + Some("about:blank".to_owned()), + None, + ); + conn.insert_browser_context(browser_context); + let request = conn + .capture_browser_target_termination_for_session_owner( + Some("SID-old"), + BrowserTargetTerminationProjectionKind::TargetClose, + ) + .expect("background target should capture close"); + let browser_context = conn.browser_context.as_mut().expect("browser context"); + assert_eq!( + browser_context + .replace_primary_session_for_target("TID-background", None) + .as_deref(), + Some("SID-old") + ); + browser_context + .replace_primary_session_for_target("TID-background", Some("SID-new".to_owned())); + let mut events = Vec::new(); + + let projection = conn + .commit_browser_target_termination_async( + request, + BrowserTargetTerminationProjectionKind::TargetClose, + &mut events, + "Target closed", + ) + .await + .expect("session replacement must not cancel background Target close"); + let BrowserTargetTerminationProjection::Closed { + closed, + browser_fact, + } = projection + else { + panic!("Target.closeTarget must project a closed Target"); + }; + assert!(browser_fact.is_some()); + + assert_eq!(closed.target_id, "TID-background"); + assert_eq!(closed.primary_session_id.as_deref(), Some("SID-new")); + let browser_context = conn.browser_context.as_ref().expect("browser context"); + assert!(browser_context.is_active_target("TID-active")); + assert!( + browser_context + .background_target("TID-background") + .is_none() + ); + } +} diff --git a/moli-protocol/src/conn/cookie_owner.rs b/moli-protocol/src/conn/cookie_owner.rs index e3d7c1b871..3712fd2ce4 100644 --- a/moli-protocol/src/conn/cookie_owner.rs +++ b/moli-protocol/src/conn/cookie_owner.rs @@ -554,7 +554,7 @@ impl BrowserContext { pub(crate) async fn document_cookie_owner_snapshot_async( &mut self, ) -> Option { - let page = self.active_target.runtime_slot.loaded_page_mut()?; + let mut page = self.active_target.runtime_slot.loaded_page_mut()?; page.document_cookie_owner_snapshot_async().await.ok() } diff --git a/moli-protocol/src/conn/devtools_command.rs b/moli-protocol/src/conn/devtools_command.rs index 16e4bd3248..0f10c89c57 100644 --- a/moli-protocol/src/conn/devtools_command.rs +++ b/moli-protocol/src/conn/devtools_command.rs @@ -59,19 +59,32 @@ pub struct DevToolsCommandDispatchOutcome { result: Result, scheduler_events: Vec, protocol_events: Vec, + post_renderer_output_events: Vec, + renderer_output_boundary: Option, + post_response_events: Vec, renderer_output_predecessor: Option, } impl DevToolsCommandDispatchOutcome { - pub(crate) fn new_with_protocol_events( + pub(crate) fn new_with_renderer_fenced_protocol_events( result: Result, scheduler_events: Vec, protocol_events: Vec, + renderer_output_boundary: Option, + post_renderer_output_events: Vec, + post_response_events: Vec, ) -> Self { + debug_assert!( + renderer_output_boundary.is_some() || post_renderer_output_events.is_empty(), + "post-renderer DevTools output requires an exact insertion boundary" + ); Self { result, scheduler_events, protocol_events, + post_renderer_output_events, + renderer_output_boundary, + post_response_events, renderer_output_predecessor: None, } } @@ -105,6 +118,12 @@ impl DevToolsCommandDispatchOutcome { self.protocol_events.is_empty(), "command output with protocol events requires complete consumption" ); + assert!( + self.renderer_output_boundary.is_none() + && self.post_renderer_output_events.is_empty() + && self.post_response_events.is_empty(), + "command output with ordered protocol segments requires complete consumption" + ); assert!( self.renderer_output_predecessor.is_none(), "command output with a renderer cursor requires complete consumption" @@ -126,6 +145,12 @@ impl DevToolsCommandDispatchOutcome { self.renderer_output_predecessor.is_none(), "command output with a renderer cursor requires complete consumption" ); + assert!( + self.renderer_output_boundary.is_none() + && self.post_renderer_output_events.is_empty() + && self.post_response_events.is_empty(), + "command output with ordered protocol segments requires complete consumption" + ); (self.result, self.scheduler_events, self.protocol_events) } @@ -138,11 +163,41 @@ impl DevToolsCommandDispatchOutcome { Vec, Vec, Option, + ) { + assert!( + self.renderer_output_boundary.is_none() + && self.post_renderer_output_events.is_empty() + && self.post_response_events.is_empty(), + "renderer-fenced command output requires fenced complete consumption" + ); + ( + self.result, + self.scheduler_events, + self.protocol_events, + self.renderer_output_predecessor, + ) + } + + /// Consumes the full typed command transaction without flattening an + /// independently transported renderer insertion boundary. + pub fn into_fenced_complete_parts( + self, + ) -> ( + Result, + Vec, + Vec, + Option, + Vec, + Vec, + Option, ) { ( self.result, self.scheduler_events, self.protocol_events, + self.renderer_output_boundary, + self.post_renderer_output_events, + self.post_response_events, self.renderer_output_predecessor, ) } @@ -356,22 +411,55 @@ impl CdpConnection { &mut self, command_context: DevToolsCommandContext, result: Result, - mut protocol_events: Vec, + protocol_events: Vec, renderer_output_predecessor: Option, ) -> DevToolsCommandDispatchOutcome { let mut dispatch_context = CommandDispatchContext::default(); + dispatch_context + .protocol_events_mut() + .extend(protocol_events); + if let Some(predecessor) = renderer_output_predecessor { + dispatch_context.set_renderer_output_predecessor(predecessor); + } + self.finish_devtools_command_dispatch_with_projection_context( + command_context, + result, + dispatch_context, + ) + .await + } + + pub(crate) async fn finish_devtools_command_dispatch_with_projection_context( + &mut self, + command_context: DevToolsCommandContext, + result: Result, + mut dispatch_context: CommandDispatchContext, + ) -> DevToolsCommandDispatchOutcome { + let mut local_context = CommandDispatchContext::default(); + let mut local_events = Vec::new(); Box::pin(self.project_protocol_local_outputs_for_direct_command( &command_context, - &mut dispatch_context, - &mut protocol_events, + &mut local_context, + &mut local_events, )) .await; - protocol_events.extend(dispatch_context.take_post_response_events()); + dispatch_context.protocol_events_mut().extend(local_events); + dispatch_context.extend_post_response_events(local_context.take_post_response_events()); + if let Some(predecessor) = local_context.take_renderer_output_predecessor() { + dispatch_context.set_renderer_output_predecessor(predecessor); + } + let (protocol_events, renderer_output_boundary, post_renderer_output_events) = + dispatch_context.take_renderer_fenced_protocol_events(); + let post_response_events = dispatch_context.take_post_response_events(); + let renderer_output_predecessor = dispatch_context.take_renderer_output_predecessor(); let scheduler_events = self.take_scheduler_events(); - DevToolsCommandDispatchOutcome::new_with_protocol_events( + DevToolsCommandDispatchOutcome::new_with_renderer_fenced_protocol_events( result, scheduler_events, protocol_events, + renderer_output_boundary, + post_renderer_output_events, + post_response_events, ) .with_renderer_output_predecessor(renderer_output_predecessor) } diff --git a/moli-protocol/src/conn/dispatch.rs b/moli-protocol/src/conn/dispatch.rs index 9fb85296f7..7b01512e9f 100644 --- a/moli-protocol/src/conn/dispatch.rs +++ b/moli-protocol/src/conn/dispatch.rs @@ -215,6 +215,28 @@ impl PendingCdpCommandDispatch { } } + /// Whether this exact pending command owns a Browser navigation whose raw + /// renderer commit publication must remain parked until the command's + /// renderer insertion boundary is consumed. + pub fn holds_navigation_renderer_publication_gate(&self) -> bool { + match &self.inner { + PendingCdpCommandDispatchKind::Fetch(pending) => { + pending.holds_navigation_renderer_publication_gate() + } + _ => false, + } + } + + #[cfg(test)] + pub(crate) fn owns_runtime_protocol_message_normalization_for_test(&self) -> bool { + match &self.inner { + PendingCdpCommandDispatchKind::Runtime(pending) => { + pending.owns_protocol_message_normalization_for_test() + } + _ => false, + } + } + pub fn runtime_deferred_reply_page_owner_access_allowed(&self) -> bool { match &self.inner { PendingCdpCommandDispatchKind::Runtime(pending) => { @@ -490,38 +512,43 @@ impl CdpConnection { } } - /// Compatibility/test entry point for direct command dispatch. + /// Parses one raw command into the caller-owned output context. /// - /// The WebSocket scheduler must use `start_parsed_command_dispatch_with_context` - /// so pending command continuations keep the same command-local output - /// buffers and response-flush gate across await points. - #[cfg(test)] - pub(crate) fn start_command_dispatch(&mut self, raw: &str) -> CdpCommandTaskStep { - let command = match ParsedCdpCommand::parse_str(raw.to_owned()) { + /// Compatibility direct dispatch and legacy `Target.sendMessageToTarget` + /// share this parse-error boundary. The WebSocket scheduler already parses + /// at ingress and uses `start_parsed_command_dispatch_with_context`. + pub(crate) fn start_raw_command_dispatch_with_context( + &mut self, + raw: impl Into, + command_context: &mut CommandDispatchContext, + ) -> (Option, CdpCommandTaskStep) { + let command = match ParsedCdpCommand::parse_str(raw) { Ok(command) => command, Err(error) => { - let mut command_context = CommandDispatchContext::default(); - return self.complete_with_output_plan( - &mut command_context, - CommandOutputPlan::error_without_session( - error.response_code(), - error.response_message(), - ), - error.command_id(), + return ( None, + self.complete_with_output_plan( + command_context, + CommandOutputPlan::error_without_session( + error.response_code(), + error.response_message(), + ), + error.command_id(), + None, + ), ); } }; - self.start_parsed_command_dispatch(&command) + let output_session_id = command.command_output_session_id().map(str::to_owned); + let step = self.start_parsed_command_dispatch_with_context(&command, command_context); + (output_session_id, step) } #[cfg(test)] - pub(crate) fn start_parsed_command_dispatch( - &mut self, - command: &ParsedCdpCommand, - ) -> CdpCommandTaskStep { + pub(crate) fn start_command_dispatch(&mut self, raw: &str) -> CdpCommandTaskStep { let mut command_context = CommandDispatchContext::default(); - self.start_parsed_command_dispatch_with_context(command, &mut command_context) + self.start_raw_command_dispatch_with_context(raw.to_owned(), &mut command_context) + .1 } pub fn start_parsed_command_dispatch_with_context( @@ -726,15 +753,17 @@ impl CdpConnection { }, ), "Target" => { - crate::domains::target::try_start_target_command_dispatch(self, &cmd).map(|step| { - match step { - crate::domains::target::TargetCommandTaskStep::Pending(pending) => { - self.pending_step(PendingCdpCommandDispatchKind::Target(pending)) - } - crate::domains::target::TargetCommandTaskStep::Complete(plan) => { - self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id) - } + crate::domains::target::try_start_target_command_dispatch( + self, + &cmd, + command_context, + ) + .map(|step| match step { + crate::domains::target::TargetCommandTaskStep::Pending(pending) => { + self.pending_step(PendingCdpCommandDispatchKind::Target(pending)) } + crate::domains::target::TargetCommandTaskStep::Complete(plan) => self + .complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id), }) } "Tracing" => Some( @@ -764,7 +793,12 @@ impl CdpConnection { }) } "Page" => { - crate::domains::page::try_start_page_command_dispatch(self, &cmd).map(|step| { + crate::domains::page::try_start_page_command_dispatch( + self, + &cmd, + command_context, + ) + .map(|step| { match step { crate::domains::page::PageCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Page(pending)) @@ -1325,17 +1359,6 @@ impl CdpConnection { .await } - pub(crate) async fn process_message_with_command_reply_turn_outcome_async( - &mut self, - raw: &str, - ) -> CdpRendererOwnerTurnOutcome { - self.process_message_with_terminal_response_delivery_async( - raw, - Some(moli_page_types::RendererInspectorResponseDelivery::CommandReply), - ) - .await - } - async fn process_message_with_terminal_response_delivery_async( &mut self, raw: &str, @@ -1371,6 +1394,7 @@ impl CdpConnection { /// events cannot be dropped accidentally. #[cfg(test)] pub async fn process_message_messages_only_for_test(&mut self, raw: &str) -> Vec { + self.adopt_direct_browser_context_fixture_attachments(); let outcome = self .process_message_through_command_dispatch_async(raw, None) .await; @@ -1382,61 +1406,27 @@ impl CdpConnection { raw: &str, response_delivery_override: Option, ) -> CdpRendererOwnerTurnOutcome { - let command = ParsedCdpCommand::parse_str(raw.to_owned()); - let output_session_id = command - .as_ref() - .ok() - .and_then(ParsedCdpCommand::command_output_session_id) - .map(str::to_owned); - let mut protocol_events = Vec::new(); - let mut post_renderer_output_events = Vec::new(); - let mut renderer_output_boundary = None; let mut scheduler_events = Vec::new(); - let mut renderer_output_predecessor: Option = None; let mut command_context = CommandDispatchContext::default(); if let Some(response_delivery) = response_delivery_override { command_context.set_terminal_response_delivery_override(response_delivery); } - let mut step = match command.as_ref() { - Ok(command) => { - self.start_parsed_command_dispatch_with_context(command, &mut command_context) - } - Err(error) => self.complete_with_output_plan( - &mut command_context, - CommandOutputPlan::error_without_session( - error.response_code(), - error.response_message(), - ), - error.command_id(), - None, - ), - }; + let (output_session_id, mut step) = + self.start_raw_command_dispatch_with_context(raw.to_owned(), &mut command_context); loop { match step { CdpCommandTaskStep::Complete(outcome) => { - let ( - mut complete_protocol_events, - mut complete_post_renderer_output_events, - complete_renderer_output_boundary, - mut complete_post_response_events, - mut complete_events, - complete_renderer_output_predecessor, - ) = outcome.into_renderer_owner_turn_parts(); - assert!( - renderer_output_boundary.is_none(), - "one direct command cannot complete multiple renderer insertion boundaries" - ); - renderer_output_boundary = complete_renderer_output_boundary; - protocol_events.append(&mut complete_protocol_events); - post_renderer_output_events.append(&mut complete_post_renderer_output_events); - post_renderer_output_events.append(&mut complete_post_response_events); - scheduler_events.append(&mut complete_events); - if let Some(predecessor) = complete_renderer_output_predecessor { - predecessor.merge_into_same_stream_tail(&mut renderer_output_predecessor); - } - break; + return self + .settle_direct_command_turn_outcome( + output_session_id.as_deref(), + command_context, + scheduler_events, + outcome, + ) + .await; } - CdpCommandTaskStep::Pending(pending) => { + CdpCommandTaskStep::Pending(mut pending) => { + scheduler_events.extend(pending.take_scheduler_events()); let completed = Box::pin(pending.wait()).await; step = Box::pin(self.complete_pending_command_dispatch_with_context( completed, @@ -1446,10 +1436,35 @@ impl CdpConnection { } } } + } + + /// Settles protocol-local projection around one completed direct command. + /// + /// Compatibility adapters may expose a pending participant to the + /// application actor and call this only after that participant completes. + /// Sharing this boundary preserves the renderer insertion and scheduler + /// event ordering of the older direct-drain helper. + pub(crate) async fn settle_direct_command_turn_outcome( + &mut self, + output_session_id: Option<&str>, + mut command_context: CommandDispatchContext, + mut scheduler_events: Vec, + outcome: CdpRendererOwnerTurnOutcome, + ) -> CdpRendererOwnerTurnOutcome { + let ( + protocol_events, + mut post_renderer_output_events, + renderer_output_boundary, + mut outcome_post_response_events, + mut complete_scheduler_events, + renderer_output_predecessor, + ) = outcome.into_renderer_owner_turn_parts(); + post_renderer_output_events.append(&mut outcome_post_response_events); + scheduler_events.append(&mut complete_scheduler_events); Box::pin( crate::domains::activity::project_protocol_local_command_outputs( self, - output_session_id.as_deref(), + output_session_id, &mut command_context, ), ) @@ -1479,6 +1494,41 @@ impl CdpConnection { .with_renderer_output_boundary(renderer_output_boundary, post_renderer_output_events) .with_renderer_output_predecessor(renderer_output_predecessor) } + + /// Projects only browser-visible side effects from a command whose + /// frontend completion receiver disappeared. + /// + /// Browser Host has already completed the action and must not lose its + /// events or renderer ordering fences. The abandoned command response is + /// removed before the remaining projection enters the application loop. + pub(crate) fn settle_detached_command_projection( + &mut self, + mut command_context: CommandDispatchContext, + plan: CommandOutputPlan, + scheduler_events: Vec, + ) -> CdpRendererOwnerTurnOutcome { + let mut protocol_events = Vec::new(); + append_command_output_plan( + &mut protocol_events, + &mut command_context, + plan.into_composite_command_prefix(), + None, + None, + ); + let (protocol_events, renderer_output_boundary, post_renderer_output_events) = + command_context.take_renderer_fenced_protocol_events(); + let post_response_events = command_context.take_post_response_events(); + self.record_tracing_protocol_events(&protocol_events); + self.record_tracing_protocol_events(&post_renderer_output_events); + self.record_tracing_protocol_events(&post_response_events); + CdpTurnOutcome::new_with_protocol_and_post_response_events( + protocol_events, + post_response_events, + scheduler_events, + ) + .with_renderer_output_boundary(renderer_output_boundary, post_renderer_output_events) + .with_renderer_output_predecessor(command_context.take_renderer_output_predecessor()) + } } /// A minimal "startup" context - used before a real browser context exists. diff --git a/moli-protocol/src/conn/dispatch_tests.rs b/moli-protocol/src/conn/dispatch_tests.rs index ae65412c7a..56016d3051 100644 --- a/moli-protocol/src/conn/dispatch_tests.rs +++ b/moli-protocol/src/conn/dispatch_tests.rs @@ -109,6 +109,67 @@ async fn evaluate_string_through_renderer_fence_for_test( .to_owned() } +async fn evaluate_owned_object_handle_for_test( + ctx: &mut crate::testing::TestContext, + context: DevToolsCommandContext, + expression: &str, +) -> DevToolsRemoteHandleId { + let result = execute_direct_devtools_command_through_renderer_fence_for_test( + ctx, + DevToolsCommand::EvaluateScript(DevToolsEvaluateScriptCommand { + context, + realm_id: None, + world_name: None, + expression: expression.to_owned(), + await_promise: false, + user_gesture: false, + webdriver_bidi_file_prompt_handler: None, + result_ownership: DevToolsResultOwnership::Root, + preserve_remote_metadata: false, + materialize_bidi_script_result: false, + serialization_options: None, + }), + ) + .await; + expect_script_value_result( + result.expect("owned object evaluation should succeed"), + "expected owned object result", + ) + .handle + .expect("root-owned object result should expose a handle") +} + +async fn create_active_bidi_target_context_for_test( + ctx: &mut crate::testing::TestContext, + session_id: &str, +) -> DevToolsCommandContext { + let context = DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverBidi, + session_id: Some(DevToolsSessionId::from(session_id)), + target_id: None, + browser_context_id: None, + }; + let create_result = execute_direct_devtools_command_through_renderer_fence_for_test( + ctx, + DevToolsCommand::CreateTarget(DevToolsCreateTargetCommand { + context: context.clone(), + url: "about:blank".to_owned(), + browser_context_id: None, + activate: true, + }), + ) + .await; + let DevToolsCommandResult::CreateTarget(create_result) = + create_result.expect("create target should succeed") + else { + panic!("expected create target result"); + }; + DevToolsCommandContext { + target_id: Some(create_result.target_id), + ..context + } +} + async fn create_target_in_browser_context_through_renderer_fence_for_test( ctx: &mut crate::testing::TestContext, context: &DevToolsCommandContext, @@ -385,10 +446,11 @@ async fn devtools_browser_context_create_uses_ephemeral_storage_partition() { "profile-key", "profile-value" )); - let initial_storage_partition = CdpInitialStoragePartition::from_storage_partition( - vec![stored_cookie_for_dispatch_test("sid", "seeded")], - &initial_storage_owner, - ); + initial_storage_owner + .import_cookies([stored_cookie_for_dispatch_test("sid", "seeded")]) + .expect("seed Browser-owned cookies"); + let initial_storage_partition = + CdpInitialStoragePartition::from_storage_partition(&initial_storage_owner); let mut conn = CdpConnection::new_with_initial_storage_partition(initial_storage_partition); let context = DevToolsCommandContext { protocol: DevToolsProtocol::WebDriverBidi, @@ -431,7 +493,6 @@ async fn devtools_browser_context_create_uses_ephemeral_storage_partition() { .get_item("https://example.com", "profile-key"), None ); - assert!(conn.snapshot_profile_backed_cookies().is_none()); } #[tokio::test] @@ -618,7 +679,7 @@ async fn devtools_script_navigation_exact_cursor_rejects_replaced_page_owner_act .conn_mut() .runtime_session_owner_slot_mut(None) .expect("created target runtime slot") - .replace_page_attachment_id_for_test(); + .clear_loaded_page_for_test_fixture(); } let sent_start = ctx.sent.len(); @@ -694,6 +755,7 @@ async fn bidi_fetch_control_resolves_background_request_owner() { "https://example.test/background".to_owned(), )); conn.browser_context = Some(browser_context); + conn.adopt_direct_browser_context_fixture_attachments(); conn.register_pending_fetch_navigation_request_for_session_owner( Some("SID-background"), @@ -718,6 +780,7 @@ async fn bidi_fetch_control_resolves_background_request_owner() { request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }, request_cookie_report: None, intercept_response: false, @@ -1172,19 +1235,12 @@ async fn devtools_runtime_call_function_popup_activity_drains_from_protocol_neut let mut browser_context = BrowserContext::new("BID-neutral-popup".to_owned()); browser_context.set_active_target_id("TID-neutral-popup-opener".to_owned()); browser_context.attach_active_session("SID-neutral-popup-opener"); - ctx.conn.browser_context = Some(browser_context); - let page = ctx - .conn - .load_page_via_runtime_async("data:text/html,

neutral popup opener

") - .await - .expect("page should load"); - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .active_target - .runtime_slot - .set_loaded_page_for_test(page); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,

neutral popup opener

", + Some("SID-neutral-popup-opener"), + ) + .await; let context = DevToolsCommandContext { protocol: DevToolsProtocol::WebDriverBidi, @@ -1320,6 +1376,7 @@ async fn devtools_create_target_rejects_unknown_reference_target() { #[tokio::test] async fn devtools_command_preserves_target_create_typed_sidecar() { let mut conn = CdpConnection::new(); + conn.enable_webdriver_bidi_target_lifecycle_projection(); conn.set_root_target_discovery_enabled(true); let context = DevToolsCommandContext { protocol: DevToolsProtocol::WebDriverBidi, @@ -1328,7 +1385,7 @@ async fn devtools_command_preserves_target_create_typed_sidecar() { browser_context_id: None, }; - let (result, scheduler_events, mut protocol_events) = conn + let (result, scheduler_events, protocol_events) = conn .execute_devtools_command(DevToolsCommand::CreateTarget(DevToolsCreateTargetCommand { context: context.clone(), url: "about:blank".to_owned(), @@ -1340,12 +1397,77 @@ async fn devtools_command_preserves_target_create_typed_sidecar() { assert!(scheduler_events.is_empty()); result.expect("create target should succeed"); - assert_eq!(protocol_events.len(), 1); - let (_message, automation_event) = protocol_events.remove(0).into_parts(); - let Some(AutomationEvent::TargetCreated(event)) = automation_event else { - panic!("expected targetCreated typed sidecar"); + let parts = protocol_events + .into_iter() + .map(BackgroundProtocolEvent::into_parts) + .collect::>(); + let target_created_messages = parts + .iter() + .filter(|(message, _)| message["method"] == json!("Target.targetCreated")) + .collect::>(); + assert_eq!(target_created_messages.len(), 1); + assert!( + target_created_messages[0].1.is_none(), + "CDP discovery notification must not duplicate the neutral lifecycle sidecar" + ); + let created = parts + .into_iter() + .filter_map(|(_, automation_event)| { + let Some(AutomationEvent::TargetCreated(event)) = automation_event else { + return None; + }; + Some(event) + }) + .collect::>(); + assert_eq!(created.len(), 1); + assert_eq!(created[0].target_id.as_str(), "TID-1"); +} + +#[tokio::test] +async fn devtools_command_preserves_target_create_typed_sidecar_without_discovery() { + let mut conn = CdpConnection::new(); + conn.enable_webdriver_bidi_target_lifecycle_projection(); + conn.insert_browser_context(BrowserContext::new("user-context-exact".to_owned())); + let context = DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverBidi, + session_id: Some(DevToolsSessionId::from("bidi-session-1")), + target_id: None, + browser_context_id: None, }; - assert_eq!(event.target_id.as_str(), "TID-1"); + + let (result, scheduler_events, protocol_events) = conn + .execute_devtools_command(DevToolsCommand::CreateTarget(DevToolsCreateTargetCommand { + context, + url: "about:blank".to_owned(), + browser_context_id: Some(DevToolsBrowserContextId::from("user-context-exact")), + activate: false, + })) + .await + .into_parts_with_protocol_events(); + + assert!(scheduler_events.is_empty()); + result.expect("create target should succeed"); + let created = protocol_events + .into_iter() + .filter_map(|event| { + let (_message, automation_event) = event.into_parts(); + let Some(AutomationEvent::TargetCreated(event)) = automation_event else { + return None; + }; + Some(event) + }) + .collect::>(); + assert_eq!(created.len(), 1, "one exact TargetCreated fact is required"); + assert_eq!(created[0].target_id.as_str(), "TID-1"); + assert_eq!(created[0].url, "about:blank"); + assert_eq!( + created[0].browser_context_id.as_ref().map(|id| id.as_str()), + Some("user-context-exact") + ); + assert!( + created[0].target_info.is_some(), + "created lifecycle must carry the committed Target snapshot" + ); } #[tokio::test] @@ -1353,8 +1475,11 @@ async fn devtools_command_preserves_target_close_detached_typed_sidecar() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-close-sidecar".to_owned()); browser_context.set_active_target_id("TID-close-sidecar".to_owned()); + browser_context.set_target_url("https://example.test/exact-close".to_owned()); browser_context.attach_active_session("SID-close-sidecar".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); + conn.enable_webdriver_bidi_target_lifecycle_projection(); + let mut ctx = crate::testing::TestContext::from_conn(conn); let context = DevToolsCommandContext { protocol: DevToolsProtocol::WebDriverBidi, @@ -1362,7 +1487,8 @@ async fn devtools_command_preserves_target_close_detached_typed_sidecar() { target_id: Some(DevToolsTargetId::from("TID-close-sidecar")), browser_context_id: None, }; - let (result, scheduler_events, protocol_events) = conn + let (result, scheduler_events, mut protocol_events) = ctx + .conn .execute_devtools_command_with_protocol_events(DevToolsCommand::CloseTarget( DevToolsCloseTargetCommand { context, @@ -1379,9 +1505,20 @@ async fn devtools_command_preserves_target_close_detached_typed_sidecar() { success: true, }) ); + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (owner_events, owner_scheduler_events, predecessor) = outcome.into_protocol_event_parts(); + assert!(owner_scheduler_events.is_empty()); + assert!(predecessor.is_none()); + protocol_events.extend(owner_events); let mut saw_detached = false; + let mut destroyed = Vec::new(); for event in protocol_events { let (message, automation_event) = event.into_parts(); + if let Some(AutomationEvent::TargetDestroyed(event)) = automation_event.as_ref() { + destroyed.push(event.clone()); + } if message["method"] != json!("Target.detachedFromTarget") { continue; } @@ -1397,6 +1534,62 @@ async fn devtools_command_preserves_target_close_detached_typed_sidecar() { saw_detached, "close target should emit TargetDetached sidecar" ); + assert_eq!( + destroyed.len(), + 1, + "one exact TargetDestroyed fact is required" + ); + assert_eq!(destroyed[0].target_id.as_str(), "TID-close-sidecar"); + assert_eq!( + destroyed[0] + .browser_context_id + .as_ref() + .map(|id| id.as_str()), + Some("BID-close-sidecar") + ); + assert_eq!(destroyed[0].url, "https://example.test/exact-close"); + assert!( + destroyed[0].target_info.is_some(), + "destroyed lifecycle must retain the pre-close Target snapshot" + ); +} + +#[tokio::test] +async fn devtools_target_close_rejects_when_browser_host_is_not_installed() { + let mut conn = CdpConnection::new(); + let mut browser_context = BrowserContext::new("BID-close-no-host".to_owned()); + browser_context.set_active_target_id("TID-close-no-host".to_owned()); + conn.insert_browser_context(browser_context); + let context = DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverBidi, + session_id: Some(DevToolsSessionId::from("bidi-session-no-host")), + target_id: Some(DevToolsTargetId::from("TID-close-no-host")), + browser_context_id: None, + }; + + let (result, scheduler_events) = conn + .execute_devtools_command(DevToolsCommand::CloseTarget(DevToolsCloseTargetCommand { + context, + target_id: DevToolsTargetId::from("TID-close-no-host"), + })) + .await + .into_parts(); + + let error = result.expect_err("Target.closeTarget requires a live Browser Host"); + assert_eq!(error.kind, DevToolsErrorKind::Internal); + assert!( + error + .message + .contains("BrowserHostTargetCloseAdmissionFailed") + ); + assert!(scheduler_events.is_empty()); + assert_eq!( + conn.browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some("TID-close-no-host"), + "rejected admission must leave the Target live" + ); } #[tokio::test] @@ -1450,7 +1643,7 @@ async fn devtools_command_preserves_remove_browser_context_detached_typed_sideca #[tokio::test] async fn devtools_command_executes_target_pending_activate_and_close() { - let mut conn = CdpConnection::new(); + let mut ctx = crate::testing::TestContext::from_conn(CdpConnection::new()); let context = DevToolsCommandContext { protocol: DevToolsProtocol::WebDriverBidi, session_id: Some(DevToolsSessionId::from("bidi-session-1")), @@ -1458,7 +1651,8 @@ async fn devtools_command_executes_target_pending_activate_and_close() { browser_context_id: None, }; for _ in 0..2 { - let (result, _) = conn + let (result, _) = ctx + .conn .execute_devtools_command(DevToolsCommand::CreateTarget(DevToolsCreateTargetCommand { context: context.clone(), url: "about:blank".to_owned(), @@ -1470,7 +1664,8 @@ async fn devtools_command_executes_target_pending_activate_and_close() { result.expect("create target should succeed"); } - let (activate_result, _) = conn + let (activate_result, _) = ctx + .conn .execute_devtools_command(DevToolsCommand::ActivateTarget( DevToolsActivateTargetCommand { context: context.clone(), @@ -1484,7 +1679,8 @@ async fn devtools_command_executes_target_pending_activate_and_close() { DevToolsCommandResult::Empty ); - let (close_result, _) = conn + let (close_result, _) = ctx + .conn .execute_devtools_command(DevToolsCommand::CloseTarget(DevToolsCloseTargetCommand { context: context.clone(), target_id: DevToolsTargetId::from("TID-2"), @@ -1497,8 +1693,15 @@ async fn devtools_command_executes_target_pending_activate_and_close() { panic!("expected close target result"); }; assert!(close_result.success); + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (_, scheduler_events, predecessor) = outcome.into_protocol_event_parts(); + assert!(scheduler_events.is_empty()); + assert!(predecessor.is_none()); - let (remaining_result, _) = conn + let (remaining_result, _) = ctx + .conn .execute_devtools_command(DevToolsCommand::GetTargets(DevToolsGetTargetsCommand { context, root: Some(DevToolsTargetId::from("TID-2")), @@ -2272,6 +2475,28 @@ async fn initial_document_page_ensure_completion_uses_captured_owner() { .expect("completion should install on captured owner"); conn.replace_none_session_owner_route_override(previous_route); + let background_dcl_facts = conn + .browser_fact_snapshot_for_test() + .into_iter() + .filter(|envelope| { + envelope.target_id().as_str() == second_target_id.as_str() + && envelope.page_residence().target_id() == Some(second_target_id.as_str()) + && matches!( + envelope.fact(), + moli_core::browser_host::BrowserFact::DocumentLifecycleReached { + milestone: + moli_core::page::RendererDocumentLifecycleMilestone::DomContentLoaded, + .. + } + ) + }) + .collect::>(); + assert_eq!( + background_dcl_facts.len(), + 1, + "initial background Page projection must publish its exact DCL fact without an ambient session route" + ); + let browser_context = conn.browser_context.as_ref().expect("browser context"); assert_eq!( browser_context.active_target_id(), @@ -2323,6 +2548,9 @@ async fn target_lifecycle_ensure_installs_initial_about_blank_page_for_active_ta .has_loaded_page(), "immediate target create path should still only stage owner metadata before lifecycle ensure" ); + let pending_page = conn + .target_page_residence_identity_for_session(None) + .expect("staged Target should expose its Core-owned Page residence"); let pending = conn .start_initial_document_page_ensure_for_session_owner(None) @@ -2353,6 +2581,19 @@ async fn target_lifecycle_ensure_installs_initial_about_blank_page_for_active_ta .has_loaded_page(), "ensure should install loaded page on active target" ); + let materialized_page = conn + .target_page_residence_identity_for_session(None) + .expect("materialized initial Page should expose its successor residence"); + assert_eq!( + materialized_page.loaded_page_generation(), + pending_page.loaded_page_generation() + 1, + "Core must advance the initial Page generation exactly once" + ); + assert!( + conn.target_page_owner_route_if_current(&pending_page) + .is_none(), + "initial Page materialization must invalidate work captured from the vacant generation" + ); assert!( conn.start_initial_document_page_ensure_for_session_owner(None) @@ -2398,15 +2639,6 @@ async fn stale_initial_document_page_build_does_not_overwrite_committed_page() { .load_page_via_runtime_async(real_page_url) .await .expect("real navigation page should build"); - conn.commit_loaded_navigation_page_for_session_owner_async( - None, - real_page, - crate::conn::LoadedNavigationRendererAttachmentCommit::Prepare(None), - &parsed_real_page_url, - ) - .await - .expect("real navigation page owner should exist") - .expect("real navigation page Inspector binding should activate"); let real_page_commit = moli_core::page::RendererMainDocumentCommit { frame_id: "TID-1".to_owned(), loader_id: "LOADER-real-page".to_owned(), @@ -2416,13 +2648,21 @@ async fn stale_initial_document_page_build_does_not_overwrite_committed_page() { secure_context_type: "InsecureScheme".to_owned(), timestamp: 0.0, }; - conn.commit_loaded_navigation_target_identity_for_session_owner( + let navigation = conn + .start_document_navigation_for_session_owner(None, "LOADER-real-page".to_owned()) + .expect("real navigation should start"); + conn.commit_loaded_page_replacement_for_session_owner_async( None, - &real_page_commit, + &navigation, + real_page, &parsed_real_page_url, + &real_page_commit, + crate::conn::LoadedNavigationRendererAttachmentCommit::Prepare(None), ) - .expect("real navigation identity should commit"); - let attachment_after_real_page = conn + .await + .expect("real navigation page owner should exist") + .expect("real navigation page Inspector binding should activate"); + let generation_after_real_page = conn .browser_context .as_ref() .expect("browser context") @@ -2482,7 +2722,7 @@ async fn stale_initial_document_page_build_does_not_overwrite_committed_page() { .browser_context .as_ref() .and_then(|context| context.loaded_page()) - .and_then(moli_core::page::Page::renderer_agent_attachment_id) + .and_then(|page| page.renderer_agent_attachment_id()) .expect("loaded page should have a renderer attachment"); let stale_attachment_id = moli_core::page::RendererAgentAttachmentId::allocate(); let attachment_test_frontend_id = 8_101; @@ -2555,7 +2795,7 @@ async fn stale_initial_document_page_build_does_not_overwrite_committed_page() { let browser_context = conn.browser_context.as_ref().expect("browser context"); assert_eq!( browser_context.page_attachment_id(), - attachment_after_real_page, + generation_after_real_page, "discarding stale initial document build must not replace the current page" ); assert_eq!( @@ -2567,10 +2807,8 @@ async fn stale_initial_document_page_build_does_not_overwrite_committed_page() { parsed_real_page_url.as_str(), "current page should remain the committed navigation page" ); - let initial = browser_context - .active_target - .owner_state - .initial_empty_document_state() + let initial = conn + .target_initial_empty_document_for_session_owner(None) .expect("initial empty document state should remain recorded"); assert!( initial.exited(), @@ -2770,7 +3008,7 @@ async fn devtools_get_realms_succeeds_when_page_loaded_before_session_attach() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-late-realms".to_owned()); browser_context.set_active_target_id("TID-late-realms".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,late-realms") @@ -5366,7 +5604,7 @@ async fn devtools_command_low_backend_node_refs_miss_without_backend_binding() { assert_eq!(error.kind, DevToolsErrorKind::NoSuchNode); assert_eq!(error.message, "Could not find node with given id"); - let page = conn + let mut page = conn .browser_context .as_mut() .expect("browser context") @@ -5534,12 +5772,12 @@ async fn devtools_command_executes_input_key_command_without_cdp_sidecar() { 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", + "protocol-neutral navigation load fact projection", ) .await; ctx.sent.clear(); - let (key_result, _scheduler_events, protocol_events) = ctx + let (key_result, scheduler_events, protocol_events) = ctx .conn .execute_devtools_command(DevToolsCommand::DispatchKeyEvent( DevToolsDispatchKeyEventCommand { @@ -5563,6 +5801,10 @@ async fn devtools_command_executes_input_key_command_without_cdp_sidecar() { protocol_events.is_empty(), "direct input key dispatch must not emit CDP-shaped sidecar messages: {protocol_events:?}" ); + assert!( + scheduler_events.is_empty(), + "direct input key dispatch must not publish scheduler sidecar work: {scheduler_events:?}" + ); let value_result = ctx .execute_devtools_command_through_renderer_fence_for_test(DevToolsCommand::EvaluateScript( @@ -5719,38 +5961,31 @@ async fn devtools_command_executes_storage_cookie_commands_for_webdriver_context } #[tokio::test] -async fn devtools_storage_cookie_commands_scope_to_target_browser_context() { - let mut conn = CdpConnection::new(); - let mut default_context = BrowserContext::new("BID-default".to_owned()); - default_context.set_active_target_id("TID-default".to_owned()); - let mut custom_context = BrowserContext::new("BID-custom".to_owned()); - custom_context.set_active_target_id("TID-custom".to_owned()); - conn.browser_context = Some(default_context); - conn.inactive_browser_contexts.push(custom_context); - - let base_context = DevToolsCommandContext { +async fn profile_cookie_commands_are_live_across_frontend_connections() { + let storage_partition = StoragePartitionState::open(None).expect("memory partition"); + let mut writer = CdpConnection::new_with_initial_storage_partition( + CdpInitialStoragePartition::from_storage_partition(&storage_partition), + ); + let mut reader = CdpConnection::new_with_initial_storage_partition( + CdpInitialStoragePartition::from_storage_partition(&storage_partition), + ); + writer.install_default_browser_target(); + reader.install_default_browser_target(); + let context = DevToolsCommandContext { protocol: DevToolsProtocol::WebDriverBidi, - session_id: Some(DevToolsSessionId::from("bidi-session-1")), + session_id: Some(DevToolsSessionId::from("shared-profile-frontend")), target_id: None, browser_context_id: None, }; - let default_target_context = DevToolsCommandContext { - target_id: Some(DevToolsTargetId::from("TID-default")), - ..base_context.clone() - }; - let custom_target_context = DevToolsCommandContext { - target_id: Some(DevToolsTargetId::from("TID-custom")), - ..base_context - }; - let cookie_url = "https://example.com/path".to_owned(); + let cookie_url = "https://shared.example/path".to_owned(); - let (set_result, _) = conn + let (set_result, _) = writer .execute_devtools_command(DevToolsCommand::SetCookies(DevToolsSetCookiesCommand { - context: custom_target_context.clone(), + context: context.clone(), browser_context_id: None, cookies: vec![DevToolsCookieParam { - name: "targetScoped".to_owned(), - value: "custom".to_owned(), + name: "sid".to_owned(), + value: "live".to_owned(), url: Some(cookie_url.clone()), domain: None, path: Some("/".to_owned()), @@ -5767,55 +6002,164 @@ async fn devtools_storage_cookie_commands_scope_to_target_browser_context() { })) .await .into_parts(); - let DevToolsCommandResult::SetCookies(set_result) = - set_result.expect("custom target setCookies should succeed") - else { - panic!("expected set cookies result"); - }; - assert!(set_result.success); + assert!(matches!( + set_result.expect("writer should set shared cookie"), + DevToolsCommandResult::SetCookies(_) + )); - let (custom_get, _) = conn + let (reader_result, _) = reader .execute_devtools_command(DevToolsCommand::GetCookies(DevToolsGetCookiesCommand { - context: custom_target_context, + context: context.clone(), browser_context_id: None, urls: Some(vec![cookie_url.clone()]), filter: None, })) .await .into_parts(); - let DevToolsCommandResult::GetCookies(custom_get) = - custom_get.expect("custom target getCookies should succeed") + let DevToolsCommandResult::GetCookies(reader_result) = + reader_result.expect("reader should observe live shared cookie") else { panic!("expected get cookies result"); }; - assert_eq!(custom_get.cookies.len(), 1); - assert_eq!(custom_get.cookies[0]["name"], json!("targetScoped")); - assert_eq!(custom_get.cookies[0]["value"], json!("custom")); + assert_eq!(reader_result.cookies.len(), 1); + assert_eq!(reader_result.cookies[0]["name"], json!("sid")); + assert_eq!(reader_result.cookies[0]["value"], json!("live")); - let (default_get, _) = conn + let (delete_result, _) = reader + .execute_devtools_command(DevToolsCommand::DeleteCookies( + DevToolsDeleteCookiesCommand { + context: context.clone(), + browser_context_id: None, + name: Some("sid".to_owned()), + url: Some(cookie_url.clone()), + domain: None, + path: None, + partition_key: None, + filter: None, + }, + )) + .await + .into_parts(); + assert!(matches!( + delete_result.expect("reader should delete shared cookie"), + DevToolsCommandResult::DeleteCookies(_) + )); + + let (writer_result, _) = writer .execute_devtools_command(DevToolsCommand::GetCookies(DevToolsGetCookiesCommand { - context: default_target_context, + context, browser_context_id: None, urls: Some(vec![cookie_url]), filter: None, })) .await .into_parts(); - let DevToolsCommandResult::GetCookies(default_get) = - default_get.expect("default target getCookies should succeed") + let DevToolsCommandResult::GetCookies(writer_result) = + writer_result.expect("writer should observe live shared deletion") else { panic!("expected get cookies result"); }; - assert!(default_get.cookies.is_empty()); + assert!(writer_result.cookies.is_empty()); } #[tokio::test] -async fn devtools_command_executes_navigation_history_and_traverse() { +async fn devtools_storage_cookie_commands_scope_to_target_browser_context() { let mut conn = CdpConnection::new(); - let context = DevToolsCommandContext { - protocol: DevToolsProtocol::WebDriverClassic, - session_id: Some(DevToolsSessionId::from("classic-session-1")), - target_id: None, + let mut default_context = BrowserContext::new("BID-default".to_owned()); + default_context.set_active_target_id("TID-default".to_owned()); + let mut custom_context = BrowserContext::new("BID-custom".to_owned()); + custom_context.set_active_target_id("TID-custom".to_owned()); + conn.browser_context = Some(default_context); + conn.inactive_browser_contexts.push(custom_context); + + let base_context = DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverBidi, + session_id: Some(DevToolsSessionId::from("bidi-session-1")), + target_id: None, + browser_context_id: None, + }; + let default_target_context = DevToolsCommandContext { + target_id: Some(DevToolsTargetId::from("TID-default")), + ..base_context.clone() + }; + let custom_target_context = DevToolsCommandContext { + target_id: Some(DevToolsTargetId::from("TID-custom")), + ..base_context + }; + let cookie_url = "https://example.com/path".to_owned(); + + let (set_result, _) = conn + .execute_devtools_command(DevToolsCommand::SetCookies(DevToolsSetCookiesCommand { + context: custom_target_context.clone(), + browser_context_id: None, + cookies: vec![DevToolsCookieParam { + name: "targetScoped".to_owned(), + value: "custom".to_owned(), + url: Some(cookie_url.clone()), + domain: None, + path: Some("/".to_owned()), + secure: Some(true), + http_only: false, + same_site: Some("Lax".to_owned()), + priority: None, + source_scheme: None, + source_port: None, + partition_key: None, + partition_key_opaque: None, + expires: None, + }], + })) + .await + .into_parts(); + let DevToolsCommandResult::SetCookies(set_result) = + set_result.expect("custom target setCookies should succeed") + else { + panic!("expected set cookies result"); + }; + assert!(set_result.success); + + let (custom_get, _) = conn + .execute_devtools_command(DevToolsCommand::GetCookies(DevToolsGetCookiesCommand { + context: custom_target_context, + browser_context_id: None, + urls: Some(vec![cookie_url.clone()]), + filter: None, + })) + .await + .into_parts(); + let DevToolsCommandResult::GetCookies(custom_get) = + custom_get.expect("custom target getCookies should succeed") + else { + panic!("expected get cookies result"); + }; + assert_eq!(custom_get.cookies.len(), 1); + assert_eq!(custom_get.cookies[0]["name"], json!("targetScoped")); + assert_eq!(custom_get.cookies[0]["value"], json!("custom")); + + let (default_get, _) = conn + .execute_devtools_command(DevToolsCommand::GetCookies(DevToolsGetCookiesCommand { + context: default_target_context, + browser_context_id: None, + urls: Some(vec![cookie_url]), + filter: None, + })) + .await + .into_parts(); + let DevToolsCommandResult::GetCookies(default_get) = + default_get.expect("default target getCookies should succeed") + else { + panic!("expected get cookies result"); + }; + assert!(default_get.cookies.is_empty()); +} + +#[tokio::test] +async fn devtools_command_executes_navigation_history_and_traverse() { + let mut conn = CdpConnection::new(); + let context = DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverClassic, + session_id: Some(DevToolsSessionId::from("classic-session-1")), + target_id: None, browser_context_id: None, }; let (create_result, _) = conn @@ -6450,6 +6794,243 @@ async fn devtools_runtime_call_function_channel_does_not_emit_direct_script_mess ); } +#[tokio::test] +async fn devtools_release_objects_exposes_each_known_handle_as_an_owned_participant() { + let mut ctx = crate::testing::TestContext::from_conn(CdpConnection::new()); + let target_context = + create_active_bidi_target_context_for_test(&mut ctx, "bidi-release-participants").await; + let target_id = target_context + .target_id + .clone() + .expect("created target context id"); + let first_handle = evaluate_owned_object_handle_for_test( + &mut ctx, + target_context.clone(), + "({owner: 'first'})", + ) + .await; + let second_handle = evaluate_owned_object_handle_for_test( + &mut ctx, + target_context.clone(), + "({owner: 'second'})", + ) + .await; + + let step = ctx + .conn + .start_devtools_runtime_command_dispatch(DevToolsCommand::ReleaseObjects( + DevToolsReleaseObjectsCommand { + context: target_context, + realm_id: None, + world_name: None, + handles: vec![ + DevToolsRemoteHandleId::from("unknown-release-handle"), + first_handle.clone(), + second_handle.clone(), + first_handle.clone(), + ], + }, + )) + .await; + let first_pending = match step { + DevToolsRuntimeCommandTaskStep::Pending(pending) => *pending, + DevToolsRuntimeCommandTaskStep::Complete(_) => { + panic!("the first known handle must become an owned Runtime participant") + } + }; + assert!(first_pending.owns_release_objects_sequence_for_test()); + let first_command_id = first_pending.internal_command_id(); + + let step = ctx + .conn + .complete_devtools_runtime_command_dispatch(first_pending.wait().await) + .await; + let second_pending = match step { + DevToolsRuntimeCommandTaskStep::Pending(pending) => *pending, + DevToolsRuntimeCommandTaskStep::Complete(_) => { + panic!("the second known handle must remain a separate Runtime participant") + } + }; + assert!(second_pending.owns_release_objects_sequence_for_test()); + assert_ne!(first_command_id, second_pending.internal_command_id()); + + let step = ctx + .conn + .complete_devtools_runtime_command_dispatch(second_pending.wait().await) + .await; + let outcome = match step { + DevToolsRuntimeCommandTaskStep::Complete(outcome) => *outcome, + DevToolsRuntimeCommandTaskStep::Pending(_) => { + panic!("duplicate and unknown handles must not create extra participants") + } + }; + let (result, scheduler_events, protocol_events, renderer_output_predecessor) = + outcome.into_complete_parts(); + if let Some(predecessor) = renderer_output_predecessor { + ctx.route_direct_command_renderer_predecessor_for_test(predecessor) + .await; + } + ctx.route_direct_command_output_for_test(protocol_events, scheduler_events) + .await; + assert_eq!( + result.expect("releaseObjects should succeed"), + DevToolsCommandResult::Empty + ); + + let route = ctx + .conn + .target_session_route_for_target_id(target_id.as_str()) + .expect("created target route"); + let mut route_scope = ctx.conn.scoped_none_session_owner_route_override(route); + assert!( + !route_scope + .conn_mut() + .runtime_remote_object_id_known_for_session_owner(None, first_handle.as_str()) + ); + assert!( + !route_scope + .conn_mut() + .runtime_remote_object_id_known_for_session_owner(None, second_handle.as_str()) + ); +} + +#[tokio::test] +async fn stale_release_objects_participant_does_not_enter_replacement_page() { + let mut ctx = crate::testing::TestContext::from_conn(CdpConnection::new()); + let target_context = + create_active_bidi_target_context_for_test(&mut ctx, "bidi-release-stale").await; + let target_id = target_context + .target_id + .clone() + .expect("created target context id"); + let first_handle = evaluate_owned_object_handle_for_test( + &mut ctx, + target_context.clone(), + "({owner: 'old-first'})", + ) + .await; + let second_handle = evaluate_owned_object_handle_for_test( + &mut ctx, + target_context.clone(), + "({owner: 'old-second'})", + ) + .await; + let route = ctx + .conn + .target_session_route_for_target_id(target_id.as_str()) + .expect("created target route"); + let (old_attachment, old_second_realm) = { + let mut route_scope = ctx + .conn + .scoped_none_session_owner_route_override(route.clone()); + let owner = route_scope.conn_mut(); + ( + owner + .current_renderer_agent_attachment_id_for_session_owner(None) + .expect("old Page renderer attachment"), + owner + .runtime_remote_object_realm_for_session_owner(None, second_handle.as_str()) + .expect("second handle realm"), + ) + }; + + let step = ctx + .conn + .start_devtools_runtime_command_dispatch(DevToolsCommand::ReleaseObjects( + DevToolsReleaseObjectsCommand { + context: target_context, + realm_id: None, + world_name: None, + handles: vec![first_handle, second_handle.clone()], + }, + )) + .await; + let pending = match step { + DevToolsRuntimeCommandTaskStep::Pending(pending) => *pending, + DevToolsRuntimeCommandTaskStep::Complete(_) => { + panic!("the first handle must become a Runtime participant") + } + }; + assert!(pending.owns_release_objects_sequence_for_test()); + let completed = pending.wait().await; + + let replacement = ctx + .conn + .load_page_via_runtime_async("data:text/html,replacement") + .await + .expect("replacement Page should load"); + { + let mut route_scope = ctx + .conn + .scoped_none_session_owner_route_override(route.clone()); + let owner = route_scope.conn_mut(); + let old_page = owner + .runtime_session_owner_slot_mut(None) + .expect("target runtime slot") + .clear_loaded_page_for_test_fixture(); + drop(old_page); + owner + .runtime_session_owner_slot_mut(None) + .expect("target runtime slot") + .set_loaded_page_for_test(replacement); + owner.unregister_runtime_remote_object_ids_for_session_owner( + None, + &[second_handle.as_str().to_owned()], + ); + owner.register_runtime_remote_object_ids_for_session_owner_with_realm( + None, + vec![second_handle.as_str().to_owned()], + &old_second_realm, + ); + } + let replacement_attachment = { + let mut route_scope = ctx + .conn + .scoped_none_session_owner_route_override(route.clone()); + route_scope + .conn_mut() + .current_renderer_agent_attachment_id_for_session_owner(None) + .expect("replacement Page renderer attachment") + }; + assert_ne!(old_attachment, replacement_attachment); + + let step = ctx + .conn + .complete_devtools_runtime_command_dispatch(completed) + .await; + let outcome = match step { + DevToolsRuntimeCommandTaskStep::Complete(outcome) => *outcome, + DevToolsRuntimeCommandTaskStep::Pending(_) => { + panic!("a stale first handle must terminate the outer release command") + } + }; + let (result, scheduler_events, protocol_events, renderer_output_predecessor) = + outcome.into_complete_parts(); + if let Some(predecessor) = renderer_output_predecessor { + ctx.route_direct_command_renderer_predecessor_for_test(predecessor) + .await; + } + ctx.route_direct_command_output_for_test(protocol_events, scheduler_events) + .await; + let error = result.expect_err("stale releaseObjects should fail"); + assert!( + error.message.contains("destroyed by navigation"), + "unexpected stale release error: {error:?}" + ); + + let mut route_scope = ctx.conn.scoped_none_session_owner_route_override(route); + let owner = route_scope.conn_mut(); + assert_eq!( + owner.current_renderer_agent_attachment_id_for_session_owner(None), + Some(replacement_attachment) + ); + assert!( + owner.runtime_remote_object_id_known_for_session_owner(None, second_handle.as_str()), + "the stale outer command must not release a successor-owned matching handle" + ); + assert!(!owner.has_pending_inspector_awaits()); +} + #[tokio::test] async fn devtools_command_executes_script_evaluate_and_call_function() { let mut ctx = crate::testing::TestContext::from_conn(CdpConnection::new()); @@ -6948,7 +7529,7 @@ async fn command_dispatch_completes_live_browser_permission_without_legacy_fallb let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-browser-permission-live".to_owned()); browser_context.set_active_target_id("TID-browser-permission-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

browser permission

") .await @@ -6981,7 +7562,12 @@ async fn command_dispatch_completes_live_browser_permission_without_legacy_fallb complete_messages(step), vec![json!({ "id": 28, "result": {} })] ); - assert_eq!(conn.permission_overrides.len(), 1); + assert_eq!( + conn.browser_host_policy_snapshot() + .permission_overrides() + .len(), + 1 + ); } #[tokio::test] @@ -7147,7 +7733,7 @@ fn command_dispatch_completes_additional_page_sync_commands_without_legacy_fallb let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-page-sync".to_owned()); browser_context.set_active_target_id("TID-page-sync"); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let download_raw = serde_json::to_string(&json!({ "id": 411, @@ -7160,10 +7746,8 @@ fn command_dispatch_completes_additional_page_sync_commands_without_legacy_fallb complete_messages(step), vec![json!({ "id": 411, "result": {} })] ); - let settings = conn - .download_behavior - .effective_for_browser_context(Some("BID-page-sync")); - assert_eq!(settings.behavior, "allow"); + let settings = conn.effective_browser_download_policy(Some("BID-page-sync")); + assert_eq!(settings.behavior().as_str(), "allow"); let metrics_raw = serde_json::to_string(&json!({ "id": 412, @@ -7318,19 +7902,12 @@ async fn command_dispatch_completes_live_page_preload_without_legacy_fallback() let mut browser_context = BrowserContext::new("BID-page-preload-live".to_owned()); browser_context.set_active_target_id("TID-page-preload-live".to_owned()); browser_context.attach_active_session("SID-page-preload-live"); - ctx.conn.browser_context = Some(browser_context); - let page = ctx - .conn - .load_page_via_runtime_async("data:text/html,

preload

") - .await - .expect("page should load"); - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .active_target - .runtime_slot - .set_loaded_page_for_test(page); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,

preload

", + Some("SID-page-preload-live"), + ) + .await; let add_raw = serde_json::to_string(&json!({ "id": 42, @@ -7352,7 +7929,9 @@ async fn command_dispatch_completes_live_page_preload_without_legacy_fallback() .conn .complete_pending_command_dispatch(add_pending.wait().await) .await; - let (add_messages, _) = ctx.complete_command_task_step_for_test(add_step).await; + let (add_messages, add_scheduler_events) = + ctx.complete_command_task_step_for_test(add_step).await; + assert!(add_scheduler_events.is_empty()); assert_eq!(add_messages.len(), 1); assert_eq!(add_messages[0]["id"], json!(42)); assert_eq!(add_messages[0]["sessionId"], json!("SID-page-preload-live")); @@ -7394,9 +7973,8 @@ async fn command_dispatch_completes_live_page_preload_without_legacy_fallback() .conn .complete_pending_command_dispatch(remove_pending.wait().await) .await; - let (remove_messages, _) = ctx.complete_command_task_step_for_test(remove_step).await; assert_eq!( - remove_messages, + ctx.complete_command_task_step_for_test(remove_step).await.0, vec![json!({ "id": 43, "sessionId": "SID-page-preload-live", @@ -7431,9 +8009,10 @@ async fn command_dispatch_completes_live_page_preload_without_legacy_fallback() panic!("live Page.createIsolatedWorld should use explicit pending page dispatch") } }; - let (create_world_messages, _) = ctx + let create_world_messages = ctx .complete_command_task_step_for_test(CdpCommandTaskStep::Pending(create_world_pending)) - .await; + .await + .0; assert_eq!(create_world_messages.len(), 1); assert_eq!(create_world_messages[0]["id"], json!(44)); assert_eq!( @@ -7483,7 +8062,7 @@ async fn command_dispatch_completes_target_activate_without_legacy_fallback() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-target-activate".to_owned()); browser_context.set_active_target_id("TID-target-activate".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let raw = serde_json::to_string(&json!({ "id": 56, @@ -7508,7 +8087,7 @@ async fn command_dispatch_completes_target_set_auto_attach_without_legacy_fallba let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-target-auto-attach".to_owned()); browser_context.set_active_target_id("TID-target-auto-attach".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let raw = serde_json::to_string(&json!({ "id": 57, @@ -7542,7 +8121,7 @@ async fn command_dispatch_completes_page_bring_to_front_without_legacy_fallback( let mut browser_context = BrowserContext::new("BID-page-bring".to_owned()); browser_context.set_active_target_id("TID-page-bring".to_owned()); browser_context.attach_active_session("SID-page-bring".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let raw = serde_json::to_string(&json!({ "id": 5701, @@ -7572,7 +8151,7 @@ async fn command_dispatch_completes_target_detach_without_legacy_fallback() { let mut browser_context = BrowserContext::new("BID-target-detach".to_owned()); browser_context.set_active_target_id("TID-target-detach".to_owned()); browser_context.attach_active_session("SID-target-detach".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let raw = serde_json::to_string(&json!({ "id": 58, @@ -7615,21 +8194,20 @@ async fn command_dispatch_completes_target_close_without_legacy_fallback() { let mut browser_context = BrowserContext::new("BID-target-close".to_owned()); browser_context.set_active_target_id("TID-target-close".to_owned()); browser_context.attach_active_session("SID-target-close".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); - let raw = serde_json::to_string(&json!({ + let mut ctx = crate::testing::TestContext::from_conn(conn); + let command = json!({ "id": 59, "method": "Target.closeTarget", "params": { "targetId": "TID-target-close" } - })) - .unwrap(); - let pending = match conn.start_command_dispatch(&raw) { - CdpCommandTaskStep::Pending(pending) => pending, - CdpCommandTaskStep::Complete(_) => { - panic!("Target.closeTarget should use the Target pending dispatcher") - } - }; - let messages = complete_command_task_for_test(&mut conn, *pending).await; + }); + let scheduler_events = ctx.process_command_only_async(&command).await; + assert!(scheduler_events.is_empty()); + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + assert_eq!(ctx.sent.len(), 1, "frontend response must route first"); + ctx.complete_one_ready_scheduler_input_for_test().await; + let messages = ctx.sent; assert_eq!(messages.len(), 3); assert_eq!( messages[0], @@ -7650,7 +8228,8 @@ async fn command_dispatch_completes_target_close_without_legacy_fallback() { }) ); assert!( - conn.browser_context + ctx.conn + .browser_context .as_ref() .expect("browser context should remain loaded") .active_target_identity() @@ -7665,7 +8244,10 @@ async fn command_dispatch_completes_target_dispose_browser_context_without_legac let mut browser_context = BrowserContext::new("BID-target-dispose".to_owned()); browser_context.set_active_target_id("TID-target-dispose".to_owned()); browser_context.attach_active_session("SID-target-dispose".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); + let (mut browser_host, browser_host_handle) = + moli_core::browser_host::BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(browser_host_handle); let raw = serde_json::to_string(&json!({ "id": 60, @@ -7679,6 +8261,18 @@ async fn command_dispatch_completes_target_dispose_browser_context_without_legac panic!("Target.disposeBrowserContext should use the Target pending dispatcher") } }; + assert!( + conn.has_browser_context_id("BID-target-dispose"), + "publishing the command cannot mutate BrowserContext topology before Host selection" + ); + let dispatch = browser_host + .complete_next_turn(&mut conn) + .expect("queued Context disposal Browser Host turn"); + let host_outcome = conn.finish_browser_host_turn_for_test(dispatch).await; + assert!( + host_outcome.into_parts().0.is_empty(), + "the owner turn must send its result through the prepared command sidecar" + ); let messages = complete_command_task_for_test(&mut conn, *pending).await; assert_eq!(messages.len(), 3); assert_eq!(messages[0], json!({ "id": 60, "result": {} })); @@ -7702,13 +8296,115 @@ async fn command_dispatch_completes_target_dispose_browser_context_without_legac ); } +#[tokio::test(flavor = "multi_thread")] +async fn queued_context_disposal_cannot_remove_recreated_same_id_context() { + let mut conn = CdpConnection::new(); + let mut original = BrowserContext::new("BID-context-aba".to_owned()); + original.set_active_target_id("TID-context-aba-old".to_owned()); + conn.insert_browser_context(original); + let original_handle = conn + .browser_context_by_id("BID-context-aba") + .expect("original Context") + .browser_context_handle() + .clone(); + let (mut browser_host, browser_host_handle) = + moli_core::browser_host::BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(browser_host_handle); + + let raw = serde_json::to_string(&json!({ + "id": 61, + "method": "Target.disposeBrowserContext", + "params": { "browserContextId": "BID-context-aba" } + })) + .unwrap(); + let pending = match conn.start_command_dispatch(&raw) { + CdpCommandTaskStep::Pending(pending) => pending, + CdpCommandTaskStep::Complete(_) => panic!("Context disposal should enter Browser Host"), + }; + + let removed = conn + .remove_browser_context_by_id_restoring_active_async("BID-context-aba", None) + .await + .expect("remove original Context before queued owner selection"); + drop(removed); + assert!(original_handle.is_retired()); + let mut replacement = BrowserContext::new("BID-context-aba".to_owned()); + replacement.set_active_target_id("TID-context-aba-new".to_owned()); + conn.insert_browser_context(replacement); + + let dispatch = browser_host + .complete_next_turn(&mut conn) + .expect("queued stale Context disposal turn"); + let _ = conn.finish_browser_host_turn_for_test(dispatch).await; + let messages = complete_command_task_for_test(&mut conn, *pending).await; + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["id"], json!(61)); + assert_eq!(messages[0]["error"]["code"], json!(-32000)); + assert!( + conn.browser_context_by_id("BID-context-aba") + .is_some_and(|context| context.active_target_id() == Some("TID-context-aba-new")), + "an old exact handle must not authorize disposal of the recreated Context" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn accepted_context_disposal_finishes_after_frontend_reply_is_dropped() { + let mut conn = CdpConnection::new(); + let mut browser_context = BrowserContext::new("BID-dispose-detached".to_owned()); + browser_context.set_active_target_id("TID-dispose-detached".to_owned()); + browser_context.attach_active_session("SID-dispose-detached".to_owned()); + conn.insert_browser_context(browser_context); + let retired_runtime_access = conn + .browser_context + .as_ref() + .expect("registered Context projection") + .renderer_runtime_owner_access(); + let (mut browser_host, browser_host_handle) = + moli_core::browser_host::BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(browser_host_handle); + + let raw = serde_json::to_string(&json!({ + "id": 62, + "method": "Target.disposeBrowserContext", + "params": { "browserContextId": "BID-dispose-detached" } + })) + .unwrap(); + let pending = match conn.start_command_dispatch(&raw) { + CdpCommandTaskStep::Pending(pending) => pending, + CdpCommandTaskStep::Complete(_) => panic!("Context disposal should enter Browser Host"), + }; + drop(pending); + + let dispatch = browser_host + .complete_next_turn(&mut conn) + .expect("accepted Context disposal owner turn"); + let outcome = conn.finish_browser_host_turn_for_test(dispatch).await; + let messages = outcome.into_parts().0; + assert!( + messages + .iter() + .any(|message| message["method"] == json!("Inspector.detached")), + "detached frontend effects must be settled through background output" + ); + assert!( + !conn.has_browser_context_id("BID-dispose-detached"), + "dropping only the frontend reply cannot cancel accepted Browser cleanup" + ); + assert!( + retired_runtime_access + .current_browser_resource_runtime() + .is_err(), + "the detached Browser cleanup participant must retire the Context network root" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn command_dispatch_completes_target_send_message_without_legacy_fallback() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-target-send".to_owned()); browser_context.set_active_target_id("TID-target-send".to_owned()); browser_context.attach_active_session("SID-target-send".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let nested = serde_json::to_string(&json!({ "id": 6101, @@ -7802,7 +8498,7 @@ async fn command_dispatch_completes_live_storage_set_cookies_without_legacy_fall let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-storage-live".to_owned()); browser_context.set_active_target_id("TID-storage-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

storage

") .await @@ -7850,7 +8546,7 @@ async fn command_dispatch_completes_live_network_extra_headers_without_legacy_fa let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-network-live".to_owned()); browser_context.set_active_target_id("TID-network-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

network

") .await @@ -7887,7 +8583,7 @@ async fn command_dispatch_completes_live_network_blocked_urls_without_legacy_fal let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-network-blocked-live".to_owned()); browser_context.set_active_target_id("TID-network-blocked-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

network blocked

") .await @@ -7924,7 +8620,7 @@ async fn command_dispatch_completes_live_network_set_cookie_without_legacy_fallb let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-network-cookie-live".to_owned()); browser_context.set_active_target_id("TID-network-cookie-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

network cookie

") .await @@ -7969,7 +8665,7 @@ async fn command_dispatch_completes_live_network_emulation_without_legacy_fallba let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-network-emulated-live".to_owned()); browser_context.set_active_target_id("TID-network-emulated-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

network emulated

") .await @@ -8012,7 +8708,7 @@ async fn command_dispatch_completes_live_network_user_agent_without_legacy_fallb let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-network-ua-live".to_owned()); browser_context.set_active_target_id("TID-network-ua-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

network ua

") .await @@ -8049,7 +8745,7 @@ async fn command_dispatch_completes_live_emulation_user_agent_without_legacy_fal let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-emulation-ua-live".to_owned()); browser_context.set_active_target_id("TID-emulation-ua-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

emulation ua

") .await @@ -8261,7 +8957,7 @@ async fn command_dispatch_completes_live_emulation_locale_without_legacy_fallbac let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-emulation-locale-live".to_owned()); browser_context.set_active_target_id("TID-emulation-locale-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

emulation locale

") .await @@ -8298,7 +8994,7 @@ async fn command_dispatch_completes_live_security_tls_without_legacy_fallback() let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-security-tls-live".to_owned()); browser_context.set_active_target_id("TID-security-tls-live".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

security tls

") .await @@ -8421,19 +9117,9 @@ async fn command_dispatch_completes_live_fetch_enable_without_legacy_fallback() let mut ctx = crate::testing::TestContext::new(); let mut browser_context = BrowserContext::new("BID-fetch-live".to_owned()); browser_context.set_active_target_id("TID-fetch-live".to_owned()); - ctx.conn.browser_context = Some(browser_context); - let page = ctx - .conn - .load_page_via_runtime_async("data:text/html,

fetch

") - .await - .expect("page should load"); - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .active_target - .runtime_slot - .set_loaded_page_for_test(page); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner("data:text/html,

fetch

", None) + .await; let raw = serde_json::to_string(&json!({ "id": 69, @@ -8451,8 +9137,10 @@ async fn command_dispatch_completes_live_fetch_enable_without_legacy_fallback() }; let completed = pending.wait().await; let step = ctx.conn.complete_pending_command_dispatch(completed).await; - let (messages, _) = ctx.complete_command_task_step_for_test(step).await; - assert_eq!(messages, vec![json!({ "id": 69, "result": {} })]); + assert_eq!( + ctx.complete_command_task_step_for_test(step).await.0, + vec![json!({ "id": 69, "result": {} })] + ); } #[tokio::test] @@ -8660,19 +9348,9 @@ async fn command_dispatch_completes_live_fetch_disable_without_legacy_fallback() .active_target .fetch_owner .configure(None, true, Vec::new()); - ctx.conn.browser_context = Some(browser_context); - let page = ctx - .conn - .load_page_via_runtime_async("data:text/html,

fetch disable

") - .await - .expect("page should load"); - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .active_target - .runtime_slot - .set_loaded_page_for_test(page); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner("data:text/html,

fetch disable

", None) + .await; let raw = serde_json::to_string(&json!({ "id": 6901, @@ -8687,8 +9365,10 @@ async fn command_dispatch_completes_live_fetch_disable_without_legacy_fallback() }; let completed = pending.wait().await; let step = ctx.conn.complete_pending_command_dispatch(completed).await; - let (messages, _) = ctx.complete_command_task_step_for_test(step).await; - assert_eq!(messages, vec![json!({ "id": 6901, "result": {} })]); + assert_eq!( + ctx.complete_command_task_step_for_test(step).await.0, + vec![json!({ "id": 6901, "result": {} })] + ); assert!( !ctx.conn .browser_context diff --git a/moli-protocol/src/conn/download_event_projection.rs b/moli-protocol/src/conn/download_event_projection.rs new file mode 100644 index 0000000000..29012d7a2b --- /dev/null +++ b/moli-protocol/src/conn/download_event_projection.rs @@ -0,0 +1,198 @@ +use std::sync::Arc; + +use parking_lot::Mutex; + +use super::{BackgroundEventSender, BackgroundProtocolEvent, CommandResponseFlushContext}; + +/// A bounded frontend projection gate for one accepted download action. +/// +/// Download network and artifact work must not wait for a DevTools command +/// response. Before that response enters the output sequence, this gate keeps +/// only the fixed start prefix and the latest progress batch. Releasing the +/// response boundary publishes those batches in order; subsequent progress is +/// forwarded directly. A slow or disconnected frontend therefore cannot +/// block Browser progress or make pre-response buffering grow with body size. +#[derive(Clone)] +pub(super) struct DownloadBackgroundEventProjection { + inner: Arc>, +} + +struct DownloadBackgroundEventProjectionInner { + sender: BackgroundEventSender, + state: DownloadBackgroundEventProjectionState, +} + +enum DownloadBackgroundEventProjectionState { + Waiting { + start_events: Vec, + latest_progress_events: Vec, + }, + Released, + Canceled, +} + +impl DownloadBackgroundEventProjection { + pub(super) fn new( + sender: BackgroundEventSender, + response_flush: &CommandResponseFlushContext, + ) -> Self { + let projection = Self { + inner: Arc::new(Mutex::new(DownloadBackgroundEventProjectionInner { + sender, + state: DownloadBackgroundEventProjectionState::Waiting { + start_events: Vec::new(), + latest_progress_events: Vec::new(), + }, + })), + }; + let Some(mut receiver) = response_flush.receiver() else { + projection.release(); + return projection; + }; + if *receiver.borrow() { + projection.release(); + return projection; + } + let waiter = projection.clone(); + tokio::spawn(async move { + while !*receiver.borrow() { + if receiver.changed().await.is_err() { + waiter.cancel(); + return; + } + } + waiter.release(); + }); + projection + } + + /// Retains one finite ordered prefix, such as will-begin plus the initial + /// in-progress observation. This method is deliberately separate from + /// progress replacement so terminal progress cannot overtake will-begin. + pub(super) fn emit_start_events(&self, events: Vec) { + if events.is_empty() { + return; + } + let mut inner = self.inner.lock(); + match &mut inner.state { + DownloadBackgroundEventProjectionState::Waiting { start_events, .. } => { + start_events.extend(events); + } + DownloadBackgroundEventProjectionState::Released => { + send_events(&inner.sender, events); + } + DownloadBackgroundEventProjectionState::Canceled => {} + } + } + + /// Coalesces progress while the command response is pending. Each batch is + /// already fanned out to the finite observer set, so retaining one batch is + /// bounded independently of the number of downloaded chunks. + pub(super) fn emit_latest_progress_events(&self, events: Vec) { + if events.is_empty() { + return; + } + let mut inner = self.inner.lock(); + match &mut inner.state { + DownloadBackgroundEventProjectionState::Waiting { + latest_progress_events, + .. + } => { + *latest_progress_events = events; + } + DownloadBackgroundEventProjectionState::Released => { + send_events(&inner.sender, events); + } + DownloadBackgroundEventProjectionState::Canceled => {} + } + } + + fn release(&self) { + let mut inner = self.inner.lock(); + let DownloadBackgroundEventProjectionState::Waiting { + start_events, + latest_progress_events, + } = std::mem::replace( + &mut inner.state, + DownloadBackgroundEventProjectionState::Released, + ) + else { + return; + }; + + // Keep the lock while sending. UnboundedSender::send is synchronous + // and non-blocking, and serialization with concurrent action progress + // prevents a post-release terminal event from overtaking this prefix. + send_events(&inner.sender, start_events); + send_events(&inner.sender, latest_progress_events); + } + + fn cancel(&self) { + let mut inner = self.inner.lock(); + if matches!( + &inner.state, + DownloadBackgroundEventProjectionState::Waiting { .. } + ) { + inner.state = DownloadBackgroundEventProjectionState::Canceled; + } + } +} + +fn send_events(sender: &BackgroundEventSender, events: Vec) { + for event in events { + let _ = sender.send(event); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use tokio::sync::mpsc::error::TryRecvError; + + use crate::conn::{BackgroundProtocolEvent, CdpConnection, browser_background_output_channel}; + + use super::DownloadBackgroundEventProjection; + + fn sequenced_event(sequence: u64) -> BackgroundProtocolEvent { + BackgroundProtocolEvent::immediate(json!({ + "method": "Browser.downloadProgress", + "params": { "sequence": sequence } + })) + } + + #[tokio::test] + async fn response_gate_keeps_start_and_only_latest_progress_in_order() { + let mut conn = CdpConnection::new(); + let (permit, response_flush) = conn.begin_command_response_flush_permit(); + let (sender, mut receiver) = browser_background_output_channel(); + let projection = DownloadBackgroundEventProjection::new(sender, &response_flush); + + projection.emit_start_events(vec![sequenced_event(1), sequenced_event(2)]); + projection.emit_latest_progress_events(vec![sequenced_event(3)]); + projection.emit_latest_progress_events(vec![sequenced_event(4)]); + + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); + + permit.finish(); + + let sequences = tokio::time::timeout(std::time::Duration::from_secs(2), async { + let mut sequences = Vec::new(); + for _ in 0..3 { + sequences.push( + receiver + .recv() + .await + .expect("released download event") + .into_protocol_message()["params"]["sequence"] + .as_u64() + .expect("numeric sequence"), + ); + } + sequences + }) + .await + .expect("response release should wake download projection"); + assert_eq!(sequences, [1, 2, 4]); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); + } +} diff --git a/moli-protocol/src/conn/downloads.rs b/moli-protocol/src/conn/downloads.rs index a94886cb7c..73c473d539 100644 --- a/moli-protocol/src/conn/downloads.rs +++ b/moli-protocol/src/conn/downloads.rs @@ -1,180 +1,39 @@ use std::{ - collections::HashMap, fs, path::{Path, PathBuf}, - str::FromStr, - sync::Arc, }; use http::HeaderName; +use moli_core::browser_host::{ + BrowserDownloadArtifactOutcome, BrowserDownloadBehavior, BrowserDownloadCancelOutcome, + BrowserDownloadPolicy, BrowserDownloadRegistry, +}; use moli_core::network::ResourceRequestClient; use moli_core::page::RendererPendingDownloadActivation; use moli_fetch::{FetchCancelHandle, Request}; use moli_header_field::{split_outside_quoted_strings, unquote_parameter_value}; use moli_web_mime::response_headers_indicate_attachment_download; -use parking_lot::Mutex; use sanitize_filename::Options; use tokio::io::AsyncWriteExt; use url::Url; use super::{ - BackgroundProtocolEvent, BrowserDownloadBehaviorSettings, CdpConnection, - CommandDispatchContext, CompletedDownloadBody, CompletedDownloadBodyArtifact, - NavigationDispatchState, output::BackgroundEventSender, + BackgroundEventSender, BackgroundProtocolEvent, CdpConnection, CommandDispatchContext, + CompletedDownloadBody, CompletedDownloadBodyArtifact, NavigationDispatchState, + download_event_projection::DownloadBackgroundEventProjection, }; -#[derive(Clone, Default)] -pub(crate) struct SharedDownloadRegistry { - inner: Arc>>, -} - -#[derive(Debug, Clone)] -struct DownloadRecord { - state: DownloadLifecycle, - artifact_path: Option, -} - -#[derive(Debug, Clone)] -enum DownloadLifecycle { - Active(FetchCancelHandle), - Completed, - Canceled, -} - -impl SharedDownloadRegistry { - fn insert_active(&self, guid: String, cancel_handle: FetchCancelHandle) { - self.with_mut(|downloads| { - downloads.insert( - guid, - DownloadRecord { - state: DownloadLifecycle::Active(cancel_handle), - artifact_path: None, - }, - ); - }); - } - - fn mark_completed(&self, guid: &str, artifact_path: PathBuf) { - self.with_mut(|downloads| match downloads.get_mut(guid) { - Some(record) => { - record.state = DownloadLifecycle::Completed; - record.artifact_path = Some(artifact_path); - } - None => { - downloads.insert( - guid.to_owned(), - DownloadRecord { - state: DownloadLifecycle::Completed, - artifact_path: Some(artifact_path), - }, - ); - } - }); - } - - fn mark_canceled(&self, guid: &str) { - self.with_mut(|downloads| match downloads.get_mut(guid) { - Some(record) => { - record.state = DownloadLifecycle::Canceled; - } - None => { - downloads.insert( - guid.to_owned(), - DownloadRecord { - state: DownloadLifecycle::Canceled, - artifact_path: None, - }, - ); - } - }); - } - - fn cancel(&self, guid: &str) -> CancelDownloadOutcome { - self.with_mut(|downloads| match downloads.get_mut(guid) { - Some(DownloadRecord { - state: DownloadLifecycle::Active(cancel_handle), - .. - }) => { - cancel_handle.cancel(); - CancelDownloadOutcome::Handled - } - Some(_) => CancelDownloadOutcome::AlreadyTerminal, - None => CancelDownloadOutcome::NotFound, - }) - } - - fn open_artifact(&self, guid: &str) -> OpenDownloadArtifactOutcome { - self.with_mut(|downloads| match downloads.get(guid) { - Some(DownloadRecord { - state: DownloadLifecycle::Completed, - artifact_path: Some(path), - }) => OpenDownloadArtifactOutcome::Ready(path.clone()), - Some(DownloadRecord { - state: DownloadLifecycle::Active(_), - .. - }) => OpenDownloadArtifactOutcome::InProgress, - Some(_) => OpenDownloadArtifactOutcome::NotAvailable, - None => OpenDownloadArtifactOutcome::NotFound, - }) - } - - fn with_mut(&self, f: impl FnOnce(&mut HashMap) -> T) -> T { - let mut downloads = self.inner.lock(); - f(&mut downloads) - } -} - -enum CancelDownloadOutcome { - Handled, - AlreadyTerminal, - NotFound, -} - -enum OpenDownloadArtifactOutcome { - Ready(PathBuf), - InProgress, - NotAvailable, - NotFound, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, strum::EnumString)] -#[strum(serialize_all = "camelCase")] -enum DownloadBehavior { - Default, - Deny, - Allow, - AllowAndName, -} - -impl DownloadBehavior { - fn parse(value: &str) -> Option { - Self::from_str(value).ok() - } - - fn allows_download(self) -> bool { - matches!(self, Self::Allow | Self::AllowAndName) - } - - fn names_artifact_by_guid(self) -> bool { - self == Self::AllowAndName - } - - fn is_canceled_without_download(self) -> bool { - matches!(self, Self::Default | Self::Deny) - } -} - struct PreparedDownloadActivation { frame_id: String, request: Request, loader: ResourceRequestClient, download_root: String, guid: String, - behavior: String, + behavior: BrowserDownloadBehavior, event_route: DownloadEventRoute, suggested_filename_hint: Option, cancel_handle: FetchCancelHandle, - registry: SharedDownloadRegistry, + registry: BrowserDownloadRegistry, } struct PreparedNavigationDownload { @@ -184,9 +43,9 @@ struct PreparedNavigationDownload { response_body: CompletedDownloadBody, download_root: String, guid: String, - behavior: String, + behavior: BrowserDownloadBehavior, event_route: DownloadEventRoute, - registry: SharedDownloadRegistry, + registry: BrowserDownloadRegistry, } struct PendingDownloadOwnerContext { @@ -288,14 +147,12 @@ impl CdpConnection { return Ok(()); }; if allow_background_events && let Some(sender) = self.background_event_sender() { - let response_flush = command_context.response_flush().receiver(); + let projection = DownloadBackgroundEventProjection::new( + sender, + command_context.response_flush(), + ); tokio::spawn(async move { - if !wait_for_command_response_flush(response_flush).await { - return; - } - for event in complete_navigation_download_async(prepared).await { - let _ = sender.send(event); - } + complete_navigation_download_into_background_async(prepared, projection).await; }); return Ok(()); } @@ -313,46 +170,44 @@ impl CdpConnection { prepared .registry - .insert_active(prepared.guid.clone(), prepared.cancel_handle.clone()); + .register_active(prepared.guid.clone(), prepared.cancel_handle.clone()); if allow_background_events && let Some(sender) = self.background_event_sender() { let emit_early_start = can_emit_early_download_start(&prepared); - let response_flush = command_context.response_flush().receiver(); + let projection = DownloadBackgroundEventProjection::new( + sender.clone(), + command_context.response_flush(), + ); if emit_early_start { let events = start_download_events(&prepared); - if response_flush.is_some() { + if command_context.response_flush().is_active() { command_context.extend_post_response_events(events); } else { send_background_download_events(&sender, events); } } tokio::spawn(async move { - if !wait_for_command_response_flush(response_flush).await { - return; - } - let events = if emit_early_start { + let delivery = if emit_early_start { complete_download_activation_async( prepared, - DownloadActivationStartEvents::AlreadyEmitted(sender.clone()), + DownloadActivationEventDelivery::StartAlreadyScheduled(projection), ) .await } else { complete_download_activation_async( prepared, - DownloadActivationStartEvents::SendToBackground(sender.clone()), + DownloadActivationEventDelivery::Background(projection), ) .await }; - for event in events { - let _ = sender.send(event); - } + debug_assert!(delivery.is_empty()); }); return Ok(()); } for event in complete_download_activation_async( prepared, - DownloadActivationStartEvents::ReturnToCaller, + DownloadActivationEventDelivery::ReturnToCaller(Vec::new()), ) .await { @@ -376,14 +231,10 @@ impl CdpConnection { }; if let Some(sender) = self.background_event_sender() { - let response_flush = command_context.response_flush().receiver(); + let projection = + DownloadBackgroundEventProjection::new(sender, command_context.response_flush()); tokio::spawn(async move { - if !wait_for_command_response_flush(response_flush).await { - return; - } - for event in complete_navigation_download_async(prepared).await { - let _ = sender.send(event); - } + complete_navigation_download_into_background_async(prepared, projection).await; }); return Ok(()); } @@ -409,7 +260,7 @@ impl CdpConnection { return Ok(None); }; - let Some(download_root) = settings.download_path.clone() else { + let Some(download_root) = settings.download_path().map(str::to_owned) else { return Ok(None); }; @@ -424,6 +275,10 @@ impl CdpConnection { let loader = self.ensure_resource_request_client()?.clone(); let guid = generate_download_guid()?; let cancel_handle = FetchCancelHandle::new(); + let automation_events_enabled = self + .automation_download_events_enabled_for_browser_context(Some( + &owner.browser_context_id, + )); Ok(Some(PreparedDownloadActivation { frame_id: owner.frame_id, @@ -431,11 +286,11 @@ impl CdpConnection { loader, download_root, guid, - behavior: settings.behavior, - event_route: self.download_event_route(session_id, settings.automation_events_enabled), + behavior: settings.behavior(), + event_route: self.download_event_route(session_id, automation_events_enabled), suggested_filename_hint: activation.suggested_filename, cancel_handle, - registry: self.download_registry.clone(), + registry: self.browser_download_registry(), })) } @@ -447,15 +302,16 @@ impl CdpConnection { let Some(owner) = self.pending_download_owner_context(session_id) else { return Ok(None); }; - let settings = self - .download_behavior - .effective_for_browser_context(Some(&owner.browser_context_id)); - if !DownloadBehavior::parse(settings.behavior.as_str()) - .is_some_and(DownloadBehavior::is_canceled_without_download) - { + let settings = self.effective_browser_download_policy(Some(&owner.browser_context_id)); + if !settings.behavior().is_canceled_without_download() { return Ok(None); } - let event_route = self.download_event_route(session_id, settings.automation_events_enabled); + let event_route = self.download_event_route( + session_id, + self.automation_download_events_enabled_for_browser_context(Some( + &owner.browser_context_id, + )), + ); if !event_route.has_observers() { return Ok(Some(Vec::new())); } @@ -509,7 +365,7 @@ impl CdpConnection { let Some(response) = activation.response else { return Ok(None); }; - let Some(download_root) = settings.download_path.clone() else { + let Some(download_root) = settings.download_path().map(str::to_owned) else { return Ok(None); }; @@ -518,6 +374,10 @@ impl CdpConnection { .map_err(|error| format!("invalid download url: {error}"))?; let response_headers = response.headers; let response_body = CompletedDownloadBody::Buffered(response.body); + let automation_events_enabled = self + .automation_download_events_enabled_for_browser_context(Some( + &owner.browser_context_id, + )); Ok(Some(PreparedNavigationDownload { frame_id: owner.frame_id, @@ -526,9 +386,9 @@ impl CdpConnection { response_body, download_root, guid: generate_download_guid()?, - behavior: settings.behavior, - event_route: self.download_event_route(session_id, settings.automation_events_enabled), - registry: self.download_registry.clone(), + behavior: settings.behavior(), + event_route: self.download_event_route(session_id, automation_events_enabled), + registry: self.browser_download_registry(), })) } @@ -552,11 +412,13 @@ impl CdpConnection { return Ok(None); }; - let Some(download_root) = settings.download_path.clone() else { + let Some(download_root) = settings.download_path().map(str::to_owned) else { return Ok(None); }; let (response_body, response_headers) = body_artifact.into_parts(); + let automation_events_enabled = self + .automation_download_events_enabled_for_browser_context(owner_context_id.as_deref()); Ok(Some(PreparedNavigationDownload { // Navigation may complete after another frontend has changed the active target. @@ -567,22 +429,20 @@ impl CdpConnection { response_body, download_root, guid: generate_download_guid()?, - behavior: settings.behavior, - event_route: self.download_event_route( - state.session_id.as_deref(), - settings.automation_events_enabled, - ), - registry: self.download_registry.clone(), + behavior: settings.behavior(), + event_route: self + .download_event_route(state.session_id.as_deref(), automation_events_enabled), + registry: self.browser_download_registry(), })) } pub(crate) fn cancel_download(&self, guid: &str) -> Result<(), String> { - match self.download_registry.cancel(guid) { - CancelDownloadOutcome::Handled => Ok(()), - CancelDownloadOutcome::AlreadyTerminal => { + match self.browser_download_registry().cancel(guid) { + BrowserDownloadCancelOutcome::Handled => Ok(()), + BrowserDownloadCancelOutcome::AlreadyTerminal => { Err("Download item is no longer active".to_owned()) } - CancelDownloadOutcome::NotFound => { + BrowserDownloadCancelOutcome::NotFound => { Err("No download item found for the given GUID".to_owned()) } } @@ -592,15 +452,15 @@ impl CdpConnection { &self, guid: &str, ) -> Result, String>>, String> { - let artifact_path = match self.download_registry.open_artifact(guid) { - OpenDownloadArtifactOutcome::Ready(path) => path, - OpenDownloadArtifactOutcome::InProgress => { + let artifact_path = match self.browser_download_registry().artifact(guid) { + BrowserDownloadArtifactOutcome::Ready(path) => path, + BrowserDownloadArtifactOutcome::InProgress => { return Err("Download item is not completed yet".to_owned()); } - OpenDownloadArtifactOutcome::NotAvailable => { + BrowserDownloadArtifactOutcome::NotAvailable => { return Err("Download item has no readable artifact".to_owned()); } - OpenDownloadArtifactOutcome::NotFound => { + BrowserDownloadArtifactOutcome::NotFound => { return Err("No download item found for the given GUID".to_owned()); } }; @@ -651,13 +511,9 @@ impl CdpConnection { fn effective_download_behavior_for_browser_context( &self, browser_context_id: Option<&str>, - ) -> Option { - let settings = self - .download_behavior - .effective_for_browser_context(browser_context_id); - if !DownloadBehavior::parse(settings.behavior.as_str()) - .is_some_and(DownloadBehavior::allows_download) - { + ) -> Option { + let settings = self.effective_browser_download_policy(browser_context_id); + if !settings.behavior().allows_download() { return None; } Some(settings) @@ -683,7 +539,7 @@ impl CdpConnection { .collect(); DownloadEventRoute { browser_observers: self - .download_behavior + .download_event_subscriptions .browser_event_observers() .into_iter() .map( @@ -694,31 +550,103 @@ impl CdpConnection { ) .collect(), automation_events_enabled: automation_events_enabled - || self.download_behavior.webdriver_bidi_events_enabled, + || self + .download_event_subscriptions + .webdriver_bidi_events_enabled, page_observers, } } } -enum DownloadActivationStartEvents { - AlreadyEmitted(BackgroundEventSender), - ReturnToCaller, - SendToBackground(BackgroundEventSender), +#[cfg(test)] +impl CdpConnection { + pub(crate) fn test_insert_completed_download(&self, guid: &str, artifact_path: PathBuf) { + self.browser_download_registry() + .record_completed(guid, artifact_path); + } +} + +enum DownloadActivationEventDelivery { + StartAlreadyScheduled(DownloadBackgroundEventProjection), + ReturnToCaller(Vec), + Background(DownloadBackgroundEventProjection), } -impl DownloadActivationStartEvents { - fn progress_sender(&self) -> Option<&BackgroundEventSender> { +impl DownloadActivationEventDelivery { + fn emit_failed_before_response(&mut self, prepared: &PreparedDownloadActivation) { + let terminal = terminal_download_events(prepared, None, None, true); + match self { + Self::StartAlreadyScheduled(projection) => { + projection.emit_latest_progress_events(terminal); + } + Self::ReturnToCaller(events) => { + let suggested_filename = pending_download_filename(prepared); + events.extend(deferred_start_download_events( + prepared, + prepared.request.url.as_str(), + &suggested_filename, + )); + events.extend(terminal); + } + Self::Background(projection) => { + let suggested_filename = pending_download_filename(prepared); + projection.emit_start_events(deferred_start_download_events( + prepared, + prepared.request.url.as_str(), + &suggested_filename, + )); + projection.emit_latest_progress_events(terminal); + } + } + } + + fn emit_start( + &mut self, + prepared: &PreparedDownloadActivation, + final_url: &Url, + suggested_filename: &str, + ) { match self { - Self::AlreadyEmitted(sender) => Some(sender), - Self::SendToBackground(sender) => Some(sender), - Self::ReturnToCaller => None, + Self::StartAlreadyScheduled(_) => {} + Self::ReturnToCaller(events) => { + events.extend(deferred_start_download_events( + prepared, + final_url.as_str(), + suggested_filename, + )); + } + Self::Background(projection) => { + let mut events = deferred_start_download_events( + prepared, + final_url.as_str(), + suggested_filename, + ); + events.extend(in_progress_download_events(prepared)); + projection.emit_start_events(events); + } + } + } + + fn emit_progress(&mut self, events: Vec) { + match self { + Self::StartAlreadyScheduled(projection) | Self::Background(projection) => { + projection.emit_latest_progress_events(events); + } + Self::ReturnToCaller(collected) => collected.extend(events), + } + } + + fn into_caller_events(self) -> Vec { + match self { + Self::ReturnToCaller(events) => events, + Self::StartAlreadyScheduled(_) | Self::Background(_) => Vec::new(), } } } async fn complete_download_activation_async( prepared: PreparedDownloadActivation, - start_events: DownloadActivationStartEvents, + mut delivery: DownloadActivationEventDelivery, ) -> Vec { // Active downloads can be arbitrarily large, so stream chunks directly into // the artifact instead of materializing a RawResponse body in memory. @@ -729,8 +657,9 @@ async fn complete_download_activation_async( { Ok(response) => response, Err(_) => { - prepared.registry.mark_canceled(&prepared.guid); - return download_activation_failed_before_response_events(&prepared, &start_events); + prepared.registry.record_canceled(&prepared.guid); + delivery.emit_failed_before_response(&prepared); + return delivery.into_caller_events(); } }; @@ -738,35 +667,26 @@ async fn complete_download_activation_async( .or_else(|| prepared.suggested_filename_hint.clone()) .or_else(|| filename_from_url(&response.final_url)) .unwrap_or_else(|| pending_download_filename(&prepared)); - let artifact_name = artifact_file_name( - prepared.behavior.as_str(), - &prepared.guid, - &suggested_filename, - ); + let artifact_name = artifact_file_name(prepared.behavior, &prepared.guid, &suggested_filename); let artifact_path = Path::new(&prepared.download_root).join(&artifact_name); let partial_path = partial_artifact_path(&artifact_path); let expected_total_bytes = content_length_from_headers(&response.headers); - let mut events = emit_download_activation_start( - &prepared, - &start_events, - &response.final_url, - &suggested_filename, - ); + delivery.emit_start(&prepared, &response.final_url, &suggested_filename); if let Err(_error) = tokio::fs::create_dir_all(&prepared.download_root).await { - prepared.registry.mark_canceled(&prepared.guid); + prepared.registry.record_canceled(&prepared.guid); let _ = response.finish().await; - events.extend(terminal_download_events(&prepared, None, None, true)); - return events; + delivery.emit_progress(terminal_download_events(&prepared, None, None, true)); + return delivery.into_caller_events(); } let mut file = match tokio::fs::File::create(&partial_path).await { Ok(file) => file, Err(_error) => { - prepared.registry.mark_canceled(&prepared.guid); + prepared.registry.record_canceled(&prepared.guid); let _ = response.finish().await; - events.extend(terminal_download_events(&prepared, None, None, true)); - return events; + delivery.emit_progress(terminal_download_events(&prepared, None, None, true)); + return delivery.into_caller_events(); } }; @@ -775,36 +695,34 @@ async fn complete_download_activation_async( total_bytes = total_bytes.saturating_add(chunk.len() as u64); if file.write_all(&chunk).await.is_err() { prepared.cancel_handle.cancel(); - prepared.registry.mark_canceled(&prepared.guid); + prepared.registry.record_canceled(&prepared.guid); let _ = response.finish().await; let _ = tokio::fs::remove_file(&partial_path).await; - events.extend(terminal_download_events( + delivery.emit_progress(terminal_download_events( &prepared, Some(total_bytes), None, true, )); - return events; - } - let progress_events = - progress_download_events(&prepared, total_bytes, expected_total_bytes); - if let Some(sender) = start_events.progress_sender() { - send_background_download_events(sender, progress_events); - } else { - events.extend(progress_events); + return delivery.into_caller_events(); } + delivery.emit_progress(progress_download_events( + &prepared, + total_bytes, + expected_total_bytes, + )); } if response.finish().await.is_err() || file.flush().await.is_err() { - prepared.registry.mark_canceled(&prepared.guid); + prepared.registry.record_canceled(&prepared.guid); let _ = tokio::fs::remove_file(&partial_path).await; - events.extend(terminal_download_events( + delivery.emit_progress(terminal_download_events( &prepared, Some(total_bytes), None, true, )); - return events; + return delivery.into_caller_events(); } drop(file); @@ -812,70 +730,27 @@ async fn complete_download_activation_async( .await .is_err() { - prepared.registry.mark_canceled(&prepared.guid); + prepared.registry.record_canceled(&prepared.guid); let _ = tokio::fs::remove_file(&partial_path).await; - events.extend(terminal_download_events( + delivery.emit_progress(terminal_download_events( &prepared, Some(total_bytes), None, true, )); - return events; + return delivery.into_caller_events(); } prepared .registry - .mark_completed(&prepared.guid, artifact_path.clone()); - events.extend(terminal_download_events( + .record_completed(&prepared.guid, artifact_path.clone()); + delivery.emit_progress(terminal_download_events( &prepared, Some(total_bytes), Some(artifact_path), false, )); - events -} - -fn download_activation_failed_before_response_events( - prepared: &PreparedDownloadActivation, - start_events: &DownloadActivationStartEvents, -) -> Vec { - match start_events { - DownloadActivationStartEvents::AlreadyEmitted(_) => { - terminal_download_events(prepared, None, None, true) - } - DownloadActivationStartEvents::ReturnToCaller - | DownloadActivationStartEvents::SendToBackground(_) => { - let suggested_filename = pending_download_filename(prepared); - let mut events = deferred_start_download_events( - prepared, - prepared.request.url.as_str(), - &suggested_filename, - ); - events.extend(terminal_download_events(prepared, None, None, true)); - events - } - } -} - -fn emit_download_activation_start( - prepared: &PreparedDownloadActivation, - start_events: &DownloadActivationStartEvents, - final_url: &Url, - suggested_filename: &str, -) -> Vec { - match start_events { - DownloadActivationStartEvents::AlreadyEmitted(_) => Vec::new(), - DownloadActivationStartEvents::ReturnToCaller => { - deferred_start_download_events(prepared, final_url.as_str(), suggested_filename) - } - DownloadActivationStartEvents::SendToBackground(sender) => { - let mut events = - deferred_start_download_events(prepared, final_url.as_str(), suggested_filename); - events.extend(in_progress_download_events(prepared)); - send_background_download_events(sender, events); - Vec::new() - } - } + delivery.into_caller_events() } fn send_background_download_events( @@ -887,31 +762,13 @@ fn send_background_download_events( } } -async fn wait_for_command_response_flush( - mut receiver: Option>, -) -> bool { - let Some(receiver) = receiver.as_mut() else { - return true; - }; - while !*receiver.borrow() { - if receiver.changed().await.is_err() { - return false; - } - } - true -} - async fn complete_navigation_download_async( mut prepared: PreparedNavigationDownload, ) -> Vec { let suggested_filename = filename_from_headers(&prepared.response_headers) .or_else(|| filename_from_url(&prepared.response_url)) .unwrap_or_else(|| "download".to_owned()); - let artifact_name = artifact_file_name( - prepared.behavior.as_str(), - &prepared.guid, - &suggested_filename, - ); + let artifact_name = artifact_file_name(prepared.behavior, &prepared.guid, &suggested_filename); let artifact_path = Path::new(&prepared.download_root).join(&artifact_name); let mut events = build_navigation_download_will_begin_event(&prepared, &suggested_filename); @@ -931,7 +788,7 @@ async fn complete_navigation_download_async( Ok(total_bytes) => { prepared .registry - .mark_completed(&prepared.guid, artifact_path.clone()); + .record_completed(&prepared.guid, artifact_path.clone()); events.extend(build_navigation_download_terminal_event( &prepared, Some(total_bytes), @@ -940,7 +797,7 @@ async fn complete_navigation_download_async( )); } Err(_) => { - prepared.registry.mark_canceled(&prepared.guid); + prepared.registry.record_canceled(&prepared.guid); events.extend(build_navigation_download_terminal_event( &prepared, None, None, true, )); @@ -950,6 +807,14 @@ async fn complete_navigation_download_async( events } +async fn complete_navigation_download_into_background_async( + prepared: PreparedNavigationDownload, + projection: DownloadBackgroundEventProjection, +) { + let events = complete_navigation_download_async(prepared).await; + projection.emit_start_events(events); +} + async fn write_navigation_download_body_async( download_root: &str, artifact_path: &Path, @@ -1334,8 +1199,12 @@ fn format_download_guid(mut bytes: [u8; 16]) -> String { ) } -fn artifact_file_name(behavior: &str, guid: &str, suggested_filename: &str) -> String { - if DownloadBehavior::parse(behavior).is_some_and(DownloadBehavior::names_artifact_by_guid) { +fn artifact_file_name( + behavior: BrowserDownloadBehavior, + guid: &str, + suggested_filename: &str, +) -> String { + if behavior.names_artifact_by_guid() { return guid.to_owned(); } sanitize_filename(suggested_filename) @@ -1494,56 +1363,80 @@ fn header_name_is(candidate: &str, expected: &HeaderName) -> bool { mod tests { use std::{ path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; + use moli_core::page::RendererPendingDownloadActivation; use moli_fetch::FetchCancelHandle; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::oneshot, + }; + use url::Url; use crate::{ - conn::{BackgroundProtocolEvent, CdpConnection}, + conn::{ + BackgroundProtocolEvent, BrowserContext, CdpConnection, CommandDispatchContext, + CompletedDownloadBody, browser_background_output_channel, + download_event_projection::DownloadBackgroundEventProjection, + }, devtools_runtime::AutomationEvent, }; use super::{ - BrowserDownloadObserver, DownloadBehavior, DownloadEventRoute, DownloadLifecycle, - DownloadRecord, OpenDownloadArtifactOutcome, PageDownloadObserver, SharedDownloadRegistry, - artifact_file_name, content_length_from_headers, download_progress_event, - download_progress_events, download_will_begin_event, download_will_begin_events, - filename_from_content_disposition, format_download_guid, generate_download_guid, - partial_artifact_path, response_headers_indicate_download, sanitize_filename, + BrowserDownloadArtifactOutcome, BrowserDownloadBehavior, BrowserDownloadCancelOutcome, + BrowserDownloadObserver, BrowserDownloadRegistry, DownloadEventRoute, PageDownloadObserver, + PreparedNavigationDownload, artifact_file_name, + complete_navigation_download_into_background_async, content_length_from_headers, + download_progress_event, download_progress_events, download_will_begin_event, + download_will_begin_events, filename_from_content_disposition, format_download_guid, + generate_download_guid, partial_artifact_path, response_headers_indicate_download, + sanitize_filename, }; + fn unique_download_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "moli-cdp-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after Unix epoch") + .as_nanos() + )) + } + #[test] fn download_behavior_parses_cdp_tokens_with_existing_case_sensitivity() { assert_eq!( - DownloadBehavior::parse("default"), - Some(DownloadBehavior::Default) + BrowserDownloadBehavior::parse("default"), + Some(BrowserDownloadBehavior::Default) ); assert_eq!( - DownloadBehavior::parse("deny"), - Some(DownloadBehavior::Deny) + BrowserDownloadBehavior::parse("deny"), + Some(BrowserDownloadBehavior::Deny) ); assert_eq!( - DownloadBehavior::parse("allow"), - Some(DownloadBehavior::Allow) + BrowserDownloadBehavior::parse("allow"), + Some(BrowserDownloadBehavior::Allow) ); assert_eq!( - DownloadBehavior::parse("allowAndName"), - Some(DownloadBehavior::AllowAndName) + BrowserDownloadBehavior::parse("allowAndName"), + Some(BrowserDownloadBehavior::AllowAndName) ); - assert_eq!(DownloadBehavior::parse("allowandname"), None); - assert_eq!(DownloadBehavior::parse("unknown"), None); + assert_eq!(BrowserDownloadBehavior::parse("allowandname"), None); + assert_eq!(BrowserDownloadBehavior::parse("unknown"), None); } #[test] fn download_behavior_helpers_preserve_allow_and_naming_policy() { - assert!(!DownloadBehavior::Default.allows_download()); - assert!(!DownloadBehavior::Deny.allows_download()); - assert!(DownloadBehavior::Allow.allows_download()); - assert!(DownloadBehavior::AllowAndName.allows_download()); + assert!(!BrowserDownloadBehavior::Default.allows_download()); + assert!(!BrowserDownloadBehavior::Deny.allows_download()); + assert!(BrowserDownloadBehavior::Allow.allows_download()); + assert!(BrowserDownloadBehavior::AllowAndName.allows_download()); - assert!(!DownloadBehavior::Allow.names_artifact_by_guid()); - assert!(DownloadBehavior::AllowAndName.names_artifact_by_guid()); + assert!(!BrowserDownloadBehavior::Allow.names_artifact_by_guid()); + assert!(BrowserDownloadBehavior::AllowAndName.names_artifact_by_guid()); } #[test] @@ -1716,9 +1609,8 @@ mod tests { #[test] fn stale_browser_observer_does_not_suppress_automation_download_event() { let mut conn = CdpConnection::new(); - conn.download_behavior - .set_browser_events_enabled_for_session(Some("SID-browser"), true); - let generation = conn.download_behavior.browser_event_observers()[0].1; + conn.set_browser_download_events_enabled_for_session(Some("SID-browser"), true); + let generation = conn.download_event_subscriptions.browser_event_observers()[0].1; let route = DownloadEventRoute { browser_observers: vec![BrowserDownloadObserver { session_id: Some("SID-browser".to_owned()), @@ -1738,8 +1630,7 @@ mod tests { assert!(events[0].route_is_current(&conn)); assert!(events[1].route_is_current(&conn)); - conn.download_behavior - .set_browser_events_enabled_for_session(Some("SID-browser"), false); + conn.set_browser_download_events_enabled_for_session(Some("SID-browser"), false); assert!(!events[0].route_is_current(&conn)); assert!( @@ -1822,15 +1713,15 @@ mod tests { #[test] fn artifact_file_name_uses_guid_only_for_allow_and_name_behavior() { assert_eq!( - artifact_file_name("allowAndName", "GUID-1", "../report.txt"), + artifact_file_name( + BrowserDownloadBehavior::AllowAndName, + "GUID-1", + "../report.txt", + ), "GUID-1" ); assert_eq!( - artifact_file_name("allow", "GUID-1", "../report.txt"), - "report.txt" - ); - assert_eq!( - artifact_file_name("unknown", "GUID-1", "../report.txt"), + artifact_file_name(BrowserDownloadBehavior::Allow, "GUID-1", "../report.txt"), "report.txt" ); } @@ -1845,14 +1736,7 @@ mod tests { #[tokio::test] async fn finalize_download_artifact_preserves_existing_artifact_when_rename_fails() { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be after Unix epoch") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "moli-cdp-download-finalize-{}-{nonce}", - std::process::id() - )); + let root = unique_download_root("download-finalize"); tokio::fs::create_dir_all(&root) .await .expect("test temp dir should be created"); @@ -1876,6 +1760,180 @@ mod tests { let _ = tokio::fs::remove_dir_all(&root).await; } + #[tokio::test] + async fn navigation_download_completes_while_response_projection_is_held() { + let root = unique_download_root("navigation-owner-progress"); + let registry = BrowserDownloadRegistry::default(); + let prepared = PreparedNavigationDownload { + frame_id: "FRAME-download".to_owned(), + response_url: Url::parse("https://example.test/report.txt").expect("download URL"), + response_headers: vec![( + "Content-Disposition".to_owned(), + "attachment; filename=report.txt".to_owned(), + )], + response_body: CompletedDownloadBody::Buffered(b"download-body".to_vec()), + download_root: root.to_string_lossy().into_owned(), + guid: "GUID-navigation".to_owned(), + behavior: BrowserDownloadBehavior::AllowAndName, + event_route: DownloadEventRoute { + browser_observers: vec![BrowserDownloadObserver { + session_id: None, + subscription_generation: 1, + }], + automation_events_enabled: false, + page_observers: Vec::new(), + }, + registry: registry.clone(), + }; + let mut conn = CdpConnection::new(); + let (permit, response_flush) = conn.begin_command_response_flush_permit(); + let (sender, mut receiver) = browser_background_output_channel(); + let projection = DownloadBackgroundEventProjection::new(sender, &response_flush); + + tokio::time::timeout( + Duration::from_secs(2), + complete_navigation_download_into_background_async(prepared, projection), + ) + .await + .expect("download action must not wait for the frontend response boundary"); + + assert!(matches!( + registry.artifact("GUID-navigation"), + BrowserDownloadArtifactOutcome::Ready(path) if path == root.join("GUID-navigation") + )); + assert_eq!( + tokio::fs::read(root.join("GUID-navigation")) + .await + .expect("completed artifact"), + b"download-body" + ); + assert!(matches!( + receiver.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + + permit.finish(); + + let messages = tokio::time::timeout(Duration::from_secs(2), async { + let mut messages = Vec::new(); + for _ in 0..3 { + messages.push( + receiver + .recv() + .await + .expect("released download observation") + .into_protocol_message(), + ); + } + messages + }) + .await + .expect("response release should wake navigation download projection"); + assert_eq!(messages[0]["method"], "Browser.downloadWillBegin"); + assert_eq!(messages[1]["params"]["state"], "inProgress"); + assert_eq!(messages[2]["params"]["state"], "completed"); + + let _ = tokio::fs::remove_dir_all(root).await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn pending_download_request_starts_before_command_response_flush() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("download test listener"); + let url = format!("http://{}/report.bin", listener.local_addr().unwrap()); + let (request_started_tx, request_started_rx) = oneshot::channel(); + let (release_response_tx, release_response_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("download request"); + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request).await.expect("HTTP request bytes"); + let _ = request_started_tx.send(()); + let _ = release_response_rx.await; + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=report.bin\r\nContent-Length: 13\r\nConnection: close\r\n\r\ndownload-body", + ) + .await + .expect("HTTP download response"); + }); + + let root = unique_download_root("pending-owner-start"); + let mut conn = CdpConnection::new(); + let mut browser_context = BrowserContext::new("BID-download".to_owned()); + browser_context.set_active_target_id("FRAME-download"); + browser_context.attach_active_session("SID-download"); + conn.insert_browser_context(browser_context); + conn.apply_browser_download_policy_update( + moli_core::browser_host::BrowserDownloadPolicyUpdate::SetGlobal { + behavior: BrowserDownloadBehavior::AllowAndName, + download_path: Some(root.to_string_lossy().into_owned()), + }, + ); + conn.set_automation_download_events_enabled_for_browser_context(None, true); + conn.set_browser_download_events_enabled_for_session(None, true); + let (sender, mut receiver) = browser_background_output_channel(); + conn.set_background_event_sender(sender); + let (permit, response_flush) = conn.begin_command_response_flush_permit(); + let mut command_context = CommandDispatchContext::new(response_flush); + let mut inline_events = Vec::new(); + + conn.handle_pending_download_activation_background_events_async( + &mut inline_events, + Some("SID-download"), + RendererPendingDownloadActivation { + url, + suggested_filename: None, + response: None, + }, + &mut command_context, + ) + .await + .expect("accepted background download"); + assert!(inline_events.is_empty()); + + tokio::time::timeout(Duration::from_secs(2), request_started_rx) + .await + .expect("download network action must start while response flush is held") + .expect("download server should report request start"); + assert!(matches!( + receiver.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + + let _ = release_response_tx.send(()); + permit.finish(); + + let terminal = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let message = receiver + .recv() + .await + .expect("download projection remains connected") + .into_protocol_message(); + if message["method"] == "Browser.downloadProgress" + && message["params"]["state"] == "completed" + { + break message; + } + } + }) + .await + .expect("download should complete after its server responds"); + let guid = terminal["params"]["guid"] + .as_str() + .expect("terminal download GUID"); + assert_eq!( + tokio::fs::read(root.join(guid)) + .await + .expect("completed pending artifact"), + b"download-body" + ); + + server.await.expect("download test server"); + let _ = tokio::fs::remove_dir_all(root).await; + } + #[test] fn content_length_from_headers_parses_case_insensitive_header_name() { assert_eq!( @@ -1963,90 +2021,71 @@ mod tests { #[test] fn cancel_reports_completed_download_as_already_terminal() { - let registry = SharedDownloadRegistry::default(); - registry.insert_active("DOWNLOAD-1".to_owned(), FetchCancelHandle::new()); - registry.mark_completed("DOWNLOAD-1", PathBuf::from("/tmp/download")); + let registry = BrowserDownloadRegistry::default(); + registry.register_active("DOWNLOAD-1".to_owned(), FetchCancelHandle::new()); + registry.record_completed("DOWNLOAD-1", PathBuf::from("/tmp/download")); assert!(matches!( registry.cancel("DOWNLOAD-1"), - super::CancelDownloadOutcome::AlreadyTerminal + BrowserDownloadCancelOutcome::AlreadyTerminal )); } #[test] fn cancel_reports_canceled_download_as_already_terminal() { - let registry = SharedDownloadRegistry::default(); - registry.insert_active("DOWNLOAD-2".to_owned(), FetchCancelHandle::new()); - registry.mark_canceled("DOWNLOAD-2"); + let registry = BrowserDownloadRegistry::default(); + registry.register_active("DOWNLOAD-2".to_owned(), FetchCancelHandle::new()); + registry.record_canceled("DOWNLOAD-2"); assert!(matches!( registry.cancel("DOWNLOAD-2"), - super::CancelDownloadOutcome::AlreadyTerminal + BrowserDownloadCancelOutcome::AlreadyTerminal )); } #[test] fn cancel_does_not_mutate_completed_download_record() { - let registry = SharedDownloadRegistry::default(); - registry.with_mut(|downloads| { - downloads.insert( - "DOWNLOAD-3".to_owned(), - DownloadRecord { - state: DownloadLifecycle::Completed, - artifact_path: Some(PathBuf::from("/tmp/download")), - }, - ); - }); + let registry = BrowserDownloadRegistry::default(); + let artifact_path = PathBuf::from("/tmp/download"); + registry.record_completed("DOWNLOAD-3", artifact_path.clone()); let _ = registry.cancel("DOWNLOAD-3"); - registry.with_mut(|downloads| { - let record = downloads - .get("DOWNLOAD-3") - .expect("completed download should remain present"); - assert!(matches!(record.state, DownloadLifecycle::Completed)); - assert_eq!(record.artifact_path, Some(PathBuf::from("/tmp/download"))); - }); + assert_eq!( + registry.artifact("DOWNLOAD-3"), + BrowserDownloadArtifactOutcome::Ready(artifact_path) + ); } #[test] fn open_artifact_reports_active_download_as_in_progress() { - let registry = SharedDownloadRegistry::default(); - registry.insert_active("DOWNLOAD-5".to_owned(), FetchCancelHandle::new()); + let registry = BrowserDownloadRegistry::default(); + registry.register_active("DOWNLOAD-5".to_owned(), FetchCancelHandle::new()); assert!(matches!( - registry.open_artifact("DOWNLOAD-5"), - OpenDownloadArtifactOutcome::InProgress + registry.artifact("DOWNLOAD-5"), + BrowserDownloadArtifactOutcome::InProgress )); } #[test] fn open_artifact_returns_completed_artifact_path() { - let registry = SharedDownloadRegistry::default(); + let registry = BrowserDownloadRegistry::default(); let artifact_path = PathBuf::from("/tmp/download"); - registry.with_mut(|downloads| { - downloads.insert( - "DOWNLOAD-6".to_owned(), - DownloadRecord { - state: DownloadLifecycle::Completed, - artifact_path: Some(artifact_path.clone()), - }, - ); - }); + registry.record_completed("DOWNLOAD-6", artifact_path.clone()); assert!(matches!( - registry.open_artifact("DOWNLOAD-6"), - OpenDownloadArtifactOutcome::Ready(path) if path == artifact_path + registry.artifact("DOWNLOAD-6"), + BrowserDownloadArtifactOutcome::Ready(path) if path == artifact_path )); } #[test] fn connection_cancel_download_rejects_already_terminal_guid() { let conn = CdpConnection::new(); - conn.download_registry - .insert_active("DOWNLOAD-4".to_owned(), FetchCancelHandle::new()); - conn.download_registry - .mark_completed("DOWNLOAD-4", PathBuf::from("/tmp/download")); + let registry = conn.browser_download_registry(); + registry.register_active("DOWNLOAD-4".to_owned(), FetchCancelHandle::new()); + registry.record_completed("DOWNLOAD-4", PathBuf::from("/tmp/download")); assert_eq!( conn.cancel_download("DOWNLOAD-4"), diff --git a/moli-protocol/src/conn/fetch_support.rs b/moli-protocol/src/conn/fetch_support.rs index c0fa9e6d25..d69646476e 100644 --- a/moli-protocol/src/conn/fetch_support.rs +++ b/moli-protocol/src/conn/fetch_support.rs @@ -811,10 +811,94 @@ impl PausedDocumentTransfer { &self.fetch_request_id } + pub(crate) fn navigation(&self) -> &NavigationDispatchState { + match &self.state { + PausedDocumentTransferState::Pending { navigation, .. } + | PausedDocumentTransferState::ActiveBodyStream { + stream: ActiveDocumentBodyStreamState { navigation, .. }, + .. + } => navigation, + } + } + pub(crate) fn is_pending(&self) -> bool { matches!(self.state, PausedDocumentTransferState::Pending { .. }) } + pub(crate) fn into_pending_navigation_parts( + self, + ) -> Result< + ( + Option, + NavigationDispatchState, + DocumentBodySource, + ), + Box, + > { + let Self { + fetch_request_id, + state, + } = self; + match state { + PausedDocumentTransferState::Pending { + document_navigation_token, + navigation, + body, + } => Ok((document_navigation_token, navigation, body)), + state => Err(Box::new(Self { + fetch_request_id, + state, + })), + } + } + + pub(crate) fn into_synthetic_navigation_parts( + self, + ) -> ( + Option, + NavigationDispatchState, + Url, + Option, + MainDocumentBodyProgressSource, + ) { + match self.state { + PausedDocumentTransferState::Pending { + document_navigation_token, + navigation, + body, + } => { + let final_url = navigation.requested_url.clone(); + let request_cookie_report = body.request_cookie_report().cloned(); + let body_progress_source = body.body_progress_source(); + ( + document_navigation_token, + navigation, + final_url, + request_cookie_report, + body_progress_source, + ) + } + PausedDocumentTransferState::ActiveBodyStream { stream, .. } => { + let ActiveDocumentBodyStreamState { + document_navigation_token, + navigation, + response, + body_progress_source, + .. + } = stream; + let final_url = response.final_url.clone(); + let request_cookie_report = response.request_cookie_report.clone(); + ( + document_navigation_token, + navigation, + final_url, + request_cookie_report, + body_progress_source, + ) + } + } + } + #[cfg(test)] pub(crate) fn prepared_renderer_agent_token( &self, @@ -2056,7 +2140,7 @@ impl CdpConnection { intercept_response: bool, handle_auth_requests: bool, ) -> Result { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2088,7 +2172,7 @@ impl CdpConnection { internal_id: u64, auth: SubresourceAuthCredentials, ) -> Result { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2112,7 +2196,7 @@ impl CdpConnection { internal_id: u64, error_text: String, ) -> Result, String> { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2136,7 +2220,7 @@ impl CdpConnection { internal_id: u64, error_text: String, ) -> Result, String> { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2170,7 +2254,7 @@ impl CdpConnection { response_headers: Vec<(String, String)>, response_body: moli_core::page::RendererSyntheticResponseBody, ) -> Result<(), String> { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2206,7 +2290,7 @@ impl CdpConnection { response_code: Option, response_headers: Option>, ) -> Result<(), String> { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2238,7 +2322,7 @@ impl CdpConnection { internal_id: u64, error_text: String, ) -> Result, String> { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2272,7 +2356,7 @@ impl CdpConnection { response_headers: Vec<(String, String)>, response_body: moli_core::page::RendererSyntheticResponseBody, ) -> Result<(), String> { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2301,7 +2385,7 @@ impl CdpConnection { socket_id: u64, data: String, ) -> Result<(), String> { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2325,7 +2409,7 @@ impl CdpConnection { socket_id: u64, data: Vec, ) -> Result<(), String> { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; @@ -2353,7 +2437,7 @@ impl CdpConnection { code: Option, reason: String, ) -> Result<(), String> { - let page = self + let mut page = self .runtime_session_owner_slot_mut(session_id)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned())?; diff --git a/moli-protocol/src/conn/inspector_route.rs b/moli-protocol/src/conn/inspector_route.rs index 7d73698308..2b770d46a2 100644 --- a/moli-protocol/src/conn/inspector_route.rs +++ b/moli-protocol/src/conn/inspector_route.rs @@ -4,8 +4,9 @@ use moli_core::page::{ }; use super::state::{ - CommittedRendererAgentAttachment, FinishedRendererDocumentNavigation, - PreparedRendererAgentAttachment, RendererAgentAttachment, RendererPageResidenceIdentity, + CommittedRendererAgentAttachment, DevToolsRendererChannelError, + FinishedRendererDocumentNavigation, PreparedRendererAgentAttachment, RendererAgentAttachment, + RendererPageResidenceIdentity, }; use super::{CdpConnection, DocumentNavigationToken}; @@ -53,6 +54,7 @@ impl CdpConnection { agent_token: RendererDevToolsAgentToken, ) -> Result { self.validate_navigation_target_owner(session_id, token)?; + self.ensure_pending_renderer_navigation_for_session_owner(session_id, token)?; self.runtime_session_owner_slot(session_id)? .prepare_renderer_agent_candidate_token(token, agent_token) .map_err(|error| error.to_string()) @@ -153,17 +155,41 @@ impl CdpConnection { renderer_page: RendererPageResidenceIdentity, ) -> Result { self.validate_navigation_target_owner(session_id, candidate.navigation())?; + // The renderer channel owns attachment-lease diagnostics such as + // supersession. Validate that projection without mutating it before + // Browser Core makes the authoritative request-identity decision. + self.runtime_session_owner_slot(session_id)? + .validate_renderer_agent_candidate_commit(&candidate) + .map_err(|error| error.to_string())?; + self.ensure_pending_renderer_navigation_for_session_owner( + session_id, + candidate.navigation(), + )?; let transaction = self .runtime_session_owner_slot_mut(session_id)? .commit_renderer_agent_candidate_transaction(candidate, renderer_page) .map_err(|error| error.to_string())?; let page_owner = self - .pending_target_page_residence_identity_for_session(session_id) + .target_page_owner_key_for_session(session_id) .ok_or_else(|| "NavigationTargetOwnerMissing".to_owned())?; - self.bind_renderer_page_output_owner(renderer_page, page_owner); + self.bind_renderer_page_output_target(renderer_page, page_owner); Ok(transaction) } + fn ensure_pending_renderer_navigation_for_session_owner( + &self, + session_id: Option<&str>, + token: &DocumentNavigationToken, + ) -> Result<(), String> { + if self.accepts_pending_document_navigation_for_session_owner(session_id, token) { + return Ok(()); + } + if self.has_pending_document_navigation_for_session_owner(session_id) { + return Err(DevToolsRendererChannelError::SupersededNavigation.to_string()); + } + Err("renderer channel navigation request is no longer pending".to_owned()) + } + pub(crate) fn rollback_committed_renderer_agent_candidate_for_session_owner( &mut self, session_id: Option<&str>, @@ -197,7 +223,7 @@ impl CdpConnection { tracing::debug!( %error, session_id, - loader_id = token.loader_id, + loader_id = token.loader_id(), "renderer channel rejected navigation completion" ); None @@ -232,7 +258,7 @@ impl CdpConnection { let (_, target_id) = self .target_owner_identity_for_session(session_id) .ok_or_else(|| "NoDocumentLoaded".to_owned())?; - if target_id.as_deref() != Some(token.target_id.as_str()) { + if target_id.as_deref() != Some(token.target_id()) { return Err("renderer channel navigation target owner mismatch".to_owned()); } Ok(()) @@ -284,8 +310,10 @@ mod tests { "SID-background-aux".to_owned(), ) ); - let mut conn = CdpConnection::default(); - conn.browser_context = Some(browser_context); + let conn = CdpConnection { + browser_context: Some(browser_context), + ..Default::default() + }; let filtered = conn.filter_renderer_inspector_batches_for_target_owner( Some("SID-active-primary"), @@ -348,7 +376,7 @@ mod tests { .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .inspector_session_state .v8_state, Some(accepted_state.clone()) @@ -373,7 +401,7 @@ mod tests { .browser_context .as_ref() .expect("browser context") - .auxiliary_devtools_session_states["SID-state-aux"] + .auxiliary_devtools_session_states()["SID-state-aux"] .inspector_session_state .v8_state, Some(auxiliary_state), @@ -422,7 +450,7 @@ mod tests { .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .inspector_session_state .v8_state, Some(route_completed_state) @@ -452,7 +480,7 @@ mod tests { .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .inspector_session_state .v8_state, Some(response_state), diff --git a/moli-protocol/src/conn/output.rs b/moli-protocol/src/conn/output.rs index 95e1f8410b..b47c96e4c5 100644 --- a/moli-protocol/src/conn/output.rs +++ b/moli-protocol/src/conn/output.rs @@ -9,10 +9,11 @@ use crate::devtools_runtime::{ }; use moli_core::{ RendererRuntimeInspectorAsyncCompletion, + browser_host::BrowserNavigateCommandOutcome, page::{ - RendererAgentAttachmentId, RendererDocumentToken, RendererLifecycleEpoch, - RendererRuntimeCommandOutput, RendererRuntimeInspectorMessage, WebSocketFrameDirection, - WebSocketFrameOpcode, + RendererAgentAttachmentId, RendererDocumentLifecycleMilestone, RendererDocumentToken, + RendererLifecycleEpoch, RendererRuntimeCommandOutput, RendererRuntimeInspectorMessage, + WebSocketFrameDirection, WebSocketFrameOpcode, }, }; use moli_page_types::{ @@ -21,16 +22,11 @@ use moli_page_types::{ use serde_json::{Value, json}; use tokio::sync::mpsc::UnboundedSender; -use super::{ - CdpConnection, DevToolsDocumentLifecycleWaitKey, - state::{BrowserContext, DocumentNavigationToken}, -}; +use super::{CdpConnection, DevToolsDocumentLifecycleWaitKey, state::DocumentNavigationToken}; mod delivery_route; use delivery_route::ProtocolDeliveryRoute; -pub type BackgroundEventSender = UnboundedSender; - #[derive(Debug, Clone, PartialEq)] pub struct RuntimeInspectorResponseReady { frontend_command_id: FrontendCommandId, @@ -563,6 +559,7 @@ pub struct BackgroundCommandResponseEvent { command_id: Option, session_id: Option, response: BackgroundCommandResponse, + browser_navigate_outcome: Option>, } #[derive(Debug, Clone, PartialEq)] @@ -834,6 +831,7 @@ pub struct BackgroundTargetInfoChangedEvent { pub struct BackgroundTargetCreatedEvent { session_id: Option, event: TargetLifecycleEvent, + automation_sidecar: bool, } #[derive(Debug, Clone, PartialEq)] @@ -850,6 +848,7 @@ pub struct BackgroundTargetDetachedEvent { pub struct BackgroundTargetDestroyedEvent { session_id: Option, event: TargetLifecycleEvent, + automation_sidecar: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -880,61 +879,6 @@ impl ProtocolDeliveryEnvelope { self.route.wire_session_id() } - pub(crate) fn navigation_gate_target_id(&self) -> Option<&str> { - if let Some(target_id) = self.route.navigation_gate_target_id() { - return Some(target_id); - } - let BackgroundProtocolEventPayload::Protocol(event) = &self.payload else { - return None; - }; - match event.automation_event.as_deref()? { - AutomationEvent::NetworkBeforeRequestSent(event) - | AutomationEvent::NetworkResponseStarted(event) - | AutomationEvent::NetworkResponseCompleted(event) - | AutomationEvent::NetworkFetchError(event) - | AutomationEvent::NetworkAuthRequired(event) - | AutomationEvent::RequestPaused(event) => Some(event.target_id.as_str()), - AutomationEvent::TargetCreated(event) | AutomationEvent::TargetDestroyed(event) => { - Some(event.target_id.as_str()) - } - AutomationEvent::TargetAttached(event) => Some(event.target_id.as_str()), - AutomationEvent::TargetDetached(event) => Some(event.target_id.as_str()), - AutomationEvent::NavigationFrame(event) => Some(event.target_id.as_str()), - AutomationEvent::NavigationStarted(event) - | AutomationEvent::DomContentLoaded(event) - | AutomationEvent::Load(event) => Some(event.target_id.as_str()), - AutomationEvent::PageLifecycle(event) => Some(event.target_id.as_str()), - AutomationEvent::SameDocumentNavigation(event) => Some(event.target_id.as_str()), - AutomationEvent::UserPromptClosed(event) => { - event.target_id.as_ref().map(|target_id| target_id.as_str()) - } - AutomationEvent::LogEntryAdded(event) => { - event.target_id.as_ref().map(|target_id| target_id.as_str()) - } - AutomationEvent::RuntimeConsoleApiCalled(event) => { - event.target_id.as_ref().map(|target_id| target_id.as_str()) - } - AutomationEvent::RuntimeExecutionContextCreated(event) - | AutomationEvent::RuntimeExecutionContextDestroyed(event) => { - event.target_id.as_ref().map(|target_id| target_id.as_str()) - } - AutomationEvent::RuntimeExecutionContextsCleared(event) => { - event.target_id.as_ref().map(|target_id| target_id.as_str()) - } - AutomationEvent::ScriptMessage(event) => { - event.target_id.as_ref().map(|target_id| target_id.as_str()) - } - AutomationEvent::ScriptException(event) => { - event.target_id.as_ref().map(|target_id| target_id.as_str()) - } - AutomationEvent::PageJavaScriptDialogOpening(_) - | AutomationEvent::PageFileChooserOpened(_) - | AutomationEvent::BrowserDownloadWillBegin(_) - | AutomationEvent::BrowserDownloadProgress(_) - | AutomationEvent::DomSetChildNodes(_) => None, - } - } - pub fn route_is_current(&self, conn: &CdpConnection) -> bool { self.route.is_current(conn) } @@ -1089,6 +1033,7 @@ impl ProtocolDeliveryEnvelope { command_id, session_id: session_id.map(str::to_owned), response: BackgroundCommandResponse::Success { result }, + browser_navigate_outcome: None, })) } @@ -1107,9 +1052,45 @@ impl ProtocolDeliveryEnvelope { message, data, }, + browser_navigate_outcome: None, })) } + /// Retains the protocol-neutral Browser navigation result beside its wire + /// response. Frontends may consume this sidecar, but it is deliberately + /// absent from JSON serialization and cannot affect Browser progress. + pub(crate) fn bind_browser_navigate_command_outcome( + mut self, + outcome: BrowserNavigateCommandOutcome, + ) -> Self { + if !self.attach_browser_navigate_command_outcome(outcome) { + tracing::error!( + "Browser navigation outcome cannot bind to a non-response protocol event" + ); + } + self + } + + pub(crate) fn attach_browser_navigate_command_outcome( + &mut self, + outcome: BrowserNavigateCommandOutcome, + ) -> bool { + let BackgroundProtocolEventPayload::CommandResponse(event) = &mut self.payload else { + return false; + }; + event.browser_navigate_outcome = Some(Box::new(outcome)); + true + } + + pub fn browser_navigate_command_outcome( + &self, + ) -> Option<(Option, &BrowserNavigateCommandOutcome)> { + let BackgroundProtocolEventPayload::CommandResponse(event) = &self.payload else { + return None; + }; + Some((event.command_id, event.browser_navigate_outcome.as_deref()?)) + } + pub(crate) fn command_response_payload_ref( &self, ) -> Option<( @@ -1512,15 +1493,17 @@ impl ProtocolDeliveryEnvelope { })) } - pub(crate) fn page_dom_content_loaded( + pub(crate) fn page_dom_content_loaded_for_renderer_document( session_id: Option<&str>, event: NavigationLifecycleEvent, + renderer_document: RendererDocumentToken, + renderer_epoch: RendererLifecycleEpoch, ) -> Self { Self::wrap_page_dom_content_loaded(Box::new(BackgroundNavigationLifecycleEvent { session_id: session_id.map(str::to_owned), event, - renderer_document: None, - renderer_epoch: None, + renderer_document: Some(renderer_document), + renderer_epoch: Some(renderer_epoch), })) } @@ -1828,6 +1811,7 @@ impl ProtocolDeliveryEnvelope { Self::wrap_target_created_event(Box::new(BackgroundTargetCreatedEvent { session_id: session_id.map(str::to_owned), event, + automation_sidecar: true, })) } @@ -1843,9 +1827,26 @@ impl ProtocolDeliveryEnvelope { Self::wrap_target_destroyed_event(Box::new(BackgroundTargetDestroyedEvent { session_id: session_id.map(str::to_owned), event, + automation_sidecar: true, })) } + /// Keeps a concrete CDP Target notification on its frozen delivery route + /// while reserving the protocol-neutral lifecycle occurrence for one + /// separate exact Browser-fact sidecar. + pub(crate) fn without_target_lifecycle_automation_sidecar(mut self) -> Self { + match &mut self.payload { + BackgroundProtocolEventPayload::TargetCreated(event) => { + event.automation_sidecar = false; + } + BackgroundProtocolEventPayload::TargetDestroyed(event) => { + event.automation_sidecar = false; + } + _ => {} + } + self + } + pub(crate) fn target_crashed( session_id: Option<&str>, target_id: impl Into, @@ -2745,9 +2746,20 @@ impl ProtocolDeliveryEnvelope { } } - pub fn matches_document_load_wait_key(&self, key: &DevToolsDocumentLifecycleWaitKey) -> bool { - let BackgroundProtocolEventPayload::PageLoad(event) = &self.payload else { - return false; + pub fn matches_document_lifecycle_wait_key( + &self, + key: &DevToolsDocumentLifecycleWaitKey, + ) -> bool { + let event = match (key.milestone, &self.payload) { + ( + RendererDocumentLifecycleMilestone::DomContentLoaded, + BackgroundProtocolEventPayload::PageDomContentLoaded(event), + ) + | ( + RendererDocumentLifecycleMilestone::Load, + BackgroundProtocolEventPayload::PageLoad(event), + ) => event, + _ => return false, }; event.renderer_document == Some(key.renderer_document) && event.renderer_epoch == Some(key.renderer_epoch) @@ -3936,7 +3948,9 @@ impl BackgroundTargetInfoChangedEvent { impl BackgroundTargetCreatedEvent { fn into_protocol_message(self) -> Value { - let Self { session_id, event } = self; + let Self { + session_id, event, .. + } = self; let target_info = event.target_info.unwrap_or_else(|| DevToolsTargetInfo { target_id: Some(event.target_id), kind: event.kind, @@ -3958,7 +3972,10 @@ impl BackgroundTargetCreatedEvent { fn into_parts(self) -> (Value, Option) { let message = self.clone().into_protocol_message(); - (message, Some(AutomationEvent::TargetCreated(self.event))) + let automation_event = self + .automation_sidecar + .then(|| AutomationEvent::TargetCreated(self.event)); + (message, automation_event) } } @@ -4016,7 +4033,10 @@ impl BackgroundTargetDestroyedEvent { fn into_parts(self) -> (Value, Option) { let message = self.clone().into_protocol_message(); - (message, Some(AutomationEvent::TargetDestroyed(self.event))) + let automation_event = self + .automation_sidecar + .then(|| AutomationEvent::TargetDestroyed(self.event)); + (message, automation_event) } } @@ -4299,13 +4319,14 @@ impl NavigationBackgroundEvent { Self { token, event } } - pub(crate) fn into_background_protocol_event_if_current<'a>( + pub(crate) fn token(&self) -> &DocumentNavigationToken { + &self.token + } + + pub(crate) fn into_background_protocol_event_if_current( self, - browser_contexts: impl IntoIterator, + is_current: bool, ) -> Option { - let is_current = browser_contexts.into_iter().any(|browser_context| { - browser_context.accepts_pending_document_navigation_event(&self.token) - }); if !is_current { return None; } @@ -4313,11 +4334,8 @@ impl NavigationBackgroundEvent { } #[cfg(test)] - pub(crate) fn into_protocol_message_if_current<'a>( - self, - browser_contexts: impl IntoIterator, - ) -> Option { - self.into_background_protocol_event_if_current(browser_contexts) + pub(crate) fn into_protocol_message_if_current(self, is_current: bool) -> Option { + self.into_background_protocol_event_if_current(is_current) .map(BackgroundProtocolEvent::into_protocol_message) } } @@ -4442,9 +4460,8 @@ mod tests { #[test] fn browser_download_route_guard_rejects_detached_and_reenabled_subscription() { let mut conn = CdpConnection::new(); - conn.download_behavior - .set_browser_events_enabled_for_session(Some("SID-browser"), true); - let first_generation = conn.download_behavior.browser_event_observers()[0].1; + conn.set_browser_download_events_enabled_for_session(Some("SID-browser"), true); + let first_generation = conn.download_event_subscriptions.browser_event_observers()[0].1; let event = BackgroundProtocolEvent::browser_download_progress( Some("SID-browser"), Some(first_generation), @@ -4456,13 +4473,11 @@ mod tests { ); assert!(event.route_is_current(&conn)); - conn.download_behavior - .set_browser_events_enabled_for_session(Some("SID-browser"), false); + conn.set_browser_download_events_enabled_for_session(Some("SID-browser"), false); assert!(!event.route_is_current(&conn)); - conn.download_behavior - .set_browser_events_enabled_for_session(Some("SID-browser"), true); - let second_generation = conn.download_behavior.browser_event_observers()[0].1; + conn.set_browser_download_events_enabled_for_session(Some("SID-browser"), true); + let second_generation = conn.download_event_subscriptions.browser_event_observers()[0].1; assert_ne!(second_generation, first_generation); assert!( !event.route_is_current(&conn), @@ -4481,7 +4496,7 @@ mod tests { } #[test] - fn renderer_page_load_matches_wait_key_by_document_and_epoch() { + fn renderer_document_lifecycle_matches_wait_key_by_milestone_document_and_epoch() { let page_id = PageId::new_for_testing(902); let document = RendererDocumentToken::new_for_testing(page_id, 3); let event = NavigationLifecycleEvent { @@ -4494,25 +4509,51 @@ mod tests { }; let output = BackgroundProtocolEvent::page_load_for_renderer_document( None, - event, + event.clone(), document, RendererLifecycleEpoch(4), ); - let matching = DevToolsDocumentLifecycleWaitKey { - registration_id: crate::conn::state::RendererDocumentLifecycleWaiterId::new_for_test(1), - renderer_document: document, - renderer_epoch: RendererLifecycleEpoch(4), - milestone: RendererDocumentLifecycleMilestone::Load, - frame_id: "FRAME".to_owned(), - loader_id: "LOADER".to_owned(), - }; - let restarted_epoch = DevToolsDocumentLifecycleWaitKey { - renderer_epoch: RendererLifecycleEpoch(5), - ..matching.clone() - }; + let matching = DevToolsDocumentLifecycleWaitKey::resolved_for_event_match_test( + document, + RendererLifecycleEpoch(4), + RendererDocumentLifecycleMilestone::Load, + "FRAME", + "LOADER", + ); + let restarted_epoch = DevToolsDocumentLifecycleWaitKey::resolved_for_event_match_test( + document, + RendererLifecycleEpoch(5), + RendererDocumentLifecycleMilestone::Load, + "FRAME", + "LOADER", + ); - assert!(output.matches_document_load_wait_key(&matching)); - assert!(!output.matches_document_load_wait_key(&restarted_epoch)); + assert!(output.matches_document_lifecycle_wait_key(&matching)); + assert!(!output.matches_document_lifecycle_wait_key(&restarted_epoch)); + + let dcl_key = DevToolsDocumentLifecycleWaitKey::resolved_for_event_match_test( + document, + RendererLifecycleEpoch(4), + RendererDocumentLifecycleMilestone::DomContentLoaded, + "FRAME", + "LOADER", + ); + let dcl_restarted_epoch = DevToolsDocumentLifecycleWaitKey::resolved_for_event_match_test( + document, + RendererLifecycleEpoch(5), + RendererDocumentLifecycleMilestone::DomContentLoaded, + "FRAME", + "LOADER", + ); + let dcl = BackgroundProtocolEvent::page_dom_content_loaded_for_renderer_document( + None, + event, + document, + RendererLifecycleEpoch(4), + ); + assert!(dcl.matches_document_lifecycle_wait_key(&dcl_key)); + assert!(!dcl.matches_document_lifecycle_wait_key(&dcl_restarted_epoch)); + assert!(!output.matches_document_lifecycle_wait_key(&dcl_key)); } #[test] @@ -4520,8 +4561,12 @@ mod tests { let mut browser_context = BrowserContext::new("CTX-nav".to_owned()); browser_context.set_active_target_id("TID-nav"); browser_context.attach_active_session("SID-nav"); - let token = browser_context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) + let mut conn = CdpConnection { + browser_context: Some(browser_context), + ..Default::default() + }; + let token = conn + .start_document_navigation_for_session_owner(Some("SID-nav"), "LOADER-1".to_owned()) .expect("active target should produce navigation token"); let message = build_event( "Page.frameStartedLoading", @@ -4530,9 +4575,10 @@ mod tests { ); let event = NavigationBackgroundEvent::protocol_message(token, message.clone()); + let is_current = conn.accepts_pending_document_navigation_token(event.token()); assert_eq!( - event.into_protocol_message_if_current(std::iter::once(&browser_context)), + event.into_protocol_message_if_current(is_current), Some(message) ); } @@ -4542,8 +4588,12 @@ mod tests { let mut browser_context = BrowserContext::new("CTX-nav".to_owned()); browser_context.set_active_target_id("TID-nav"); browser_context.attach_active_session("SID-nav"); - let token = browser_context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) + let mut conn = CdpConnection { + browser_context: Some(browser_context), + ..Default::default() + }; + let token = conn + .start_document_navigation_for_session_owner(Some("SID-nav"), "LOADER-1".to_owned()) .expect("active target should produce navigation token"); let message = build_event( "Page.frameStartedNavigating", @@ -4574,8 +4624,9 @@ mod tests { automation_event.clone(), ), ); + let is_current = conn.accepts_pending_document_navigation_token(event.token()); let background_event = event - .into_background_protocol_event_if_current(std::iter::once(&browser_context)) + .into_background_protocol_event_if_current(is_current) .expect("current navigation event should materialize"); let (actual_message, actual_automation_event) = background_event.into_parts(); @@ -5624,11 +5675,15 @@ mod tests { fn navigation_background_event_drops_stale_token() { let mut browser_context = BrowserContext::new("CTX-nav".to_owned()); browser_context.set_active_target_id("TID-nav"); - let stale = browser_context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) + let mut conn = CdpConnection { + browser_context: Some(browser_context), + ..Default::default() + }; + let stale = conn + .start_document_navigation_for_session_owner(None, "LOADER-1".to_owned()) .expect("active target should produce stale token"); - let current = browser_context - .start_document_navigation_for_active_target("LOADER-2".to_owned()) + let current = conn + .start_document_navigation_for_session_owner(None, "LOADER-2".to_owned()) .expect("active target should produce current token"); let message = build_event( "Page.frameStoppedLoading", @@ -5638,13 +5693,16 @@ mod tests { let stale_event = NavigationBackgroundEvent::protocol_message(stale, message.clone()); let current_event = NavigationBackgroundEvent::protocol_message(current, message.clone()); + let stale_is_current = conn.accepts_pending_document_navigation_token(stale_event.token()); + let current_is_current = + conn.accepts_pending_document_navigation_token(current_event.token()); assert_eq!( - stale_event.into_protocol_message_if_current(std::iter::once(&browser_context)), + stale_event.into_protocol_message_if_current(stale_is_current), None ); assert_eq!( - current_event.into_protocol_message_if_current(std::iter::once(&browser_context)), + current_event.into_protocol_message_if_current(current_is_current), Some(message) ); } diff --git a/moli-protocol/src/conn/output/delivery_route.rs b/moli-protocol/src/conn/output/delivery_route.rs index 87be715f8c..ffa8a40642 100644 --- a/moli-protocol/src/conn/output/delivery_route.rs +++ b/moli-protocol/src/conn/output/delivery_route.rs @@ -64,16 +64,6 @@ impl ProtocolDeliveryRoute { self.wire_session.as_deref() } - pub(super) fn navigation_gate_target_id(&self) -> Option<&str> { - self.capabilities - .as_deref()? - .root_document - .as_ref()? - .attachment() - .page_owner() - .target_id() - } - pub(super) fn ensure_wire_session_id(&mut self, session_id: &str) { match self.wire_session.as_deref() { Some(existing) => assert_eq!( @@ -157,7 +147,7 @@ impl ProtocolDeliveryRoute { } if let Some(subscription) = capabilities.browser_download_subscription.as_ref() && !conn - .download_behavior + .download_event_subscriptions .browser_event_subscription_is_current( subscription.session_id.as_deref(), subscription.generation, diff --git a/moli-protocol/src/conn/page_state.rs b/moli-protocol/src/conn/page_state.rs index 8f9b9fa90e..4c79dea44f 100644 --- a/moli-protocol/src/conn/page_state.rs +++ b/moli-protocol/src/conn/page_state.rs @@ -3,4 +3,6 @@ mod loaded; mod parked; mod surfaces; -pub(crate) use loaded::{LoadedNavigationPageCommit, LoadedNavigationRendererAttachmentCommit}; +pub(crate) use loaded::{ + LoadedNavigationRendererAttachmentCommit, PreparedLoadedNavigationPageCommit, +}; diff --git a/moli-protocol/src/conn/page_state/fetch_state.rs b/moli-protocol/src/conn/page_state/fetch_state.rs index 227eb9b692..7bb79ba11e 100644 --- a/moli-protocol/src/conn/page_state/fetch_state.rs +++ b/moli-protocol/src/conn/page_state/fetch_state.rs @@ -1,10 +1,11 @@ use super::super::{ - BackgroundTarget, BrowserContext, ConnectionNetworkRequestIdAllocator, PausedDocumentTransfer, - PendingFetchAuthNavigation, PendingFetchNavigation, PendingSubresourceFetchAuthRequest, - PendingSubresourceFetchRequest, PendingSubresourceFetchResponseRequest, + BackgroundTarget, BrowserContext, PausedDocumentTransfer, PendingFetchAuthNavigation, + PendingFetchNavigation, PendingSubresourceFetchAuthRequest, PendingSubresourceFetchRequest, + PendingSubresourceFetchResponseRequest, }; #[cfg(test)] use super::super::{DocumentBodySource, DocumentNavigationToken, NavigationDispatchState}; +use moli_core::browser_host::BrowserNetworkArtifactStore as ConnectionNetworkRequestIdAllocator; fn document_navigation_loader_id(sequence: u64) -> String { format!("LID-{sequence:010}") @@ -108,14 +109,6 @@ impl BrowserContext { .register_in_flight_subresource_fetch_request(request_id, pending); } - #[cfg(test)] - pub(crate) fn allocate_subresource_network_request_id(&mut self) -> String { - self.active_target - .runtime_slot - .request_id_allocator() - .allocate_network_request_id() - } - #[cfg(test)] pub(crate) fn record_captured_response_body( &mut self, @@ -155,7 +148,7 @@ impl BrowserContext { pub(crate) fn captured_response_body( &self, request_id: &str, - ) -> Option<&crate::domains::network::CapturedResponseBody> { + ) -> Option { self.active_target .runtime_slot .captured_response_body(request_id) @@ -261,20 +254,6 @@ impl BrowserContext { .captured_response_bodies_empty() } - #[cfg(test)] - pub(crate) fn set_next_network_request_sequence_for_test(&mut self, sequence: u64) { - self.active_target - .runtime_slot - .set_next_network_request_sequence_for_test(sequence); - } - - #[cfg(test)] - pub(crate) fn next_network_request_sequence_for_test(&self) -> u64 { - self.active_target - .runtime_slot - .next_network_request_sequence_for_test() - } - #[cfg(test)] pub(crate) fn io_streams_empty_for_test(&self) -> bool { self.active_target.runtime_slot.io_streams_empty_for_test() @@ -320,7 +299,7 @@ impl BrowserContext { } let mut allocator = self.active_target.runtime_slot.request_id_allocator(); let document_loader_id = - document_navigation_loader_id(network_request_id_allocator.allocate_sequence()); + document_navigation_loader_id(network_request_id_allocator.allocate_request_sequence()); let document_request_id = observes_document_request.then(|| document_loader_id.clone()); let fetch_navigation_request_id = needs_fetch_navigation_request_id .then(|| allocator.allocate_fetch_navigation_request_id()); @@ -345,7 +324,7 @@ impl BackgroundTarget { } let mut allocator = self.runtime_slot.request_id_allocator(); let document_loader_id = - document_navigation_loader_id(network_request_id_allocator.allocate_sequence()); + document_navigation_loader_id(network_request_id_allocator.allocate_request_sequence()); let document_request_id = observes_document_request.then(|| document_loader_id.clone()); let fetch_navigation_request_id = needs_fetch_navigation_request_id .then(|| allocator.allocate_fetch_navigation_request_id()); @@ -369,10 +348,10 @@ mod tests { fn pending_subresource_fetch(internal_id: u64) -> PendingSubresourceFetchRequest { PendingSubresourceFetchRequest { residence: crate::conn::PendingSubresourceFetchResidence::InstalledPage( - crate::conn::TargetPageResidenceIdentity::new_for_test( + crate::conn::TargetPageResidenceIdentity::new( "BID-fetch-state".to_owned(), Some("TID-1".to_owned()), - 1, + 0, ), ), owner_session_id: None, @@ -409,6 +388,7 @@ mod tests { request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, } } @@ -547,33 +527,16 @@ mod tests { } #[test] - fn network_and_io_stream_ids_cross_u32_max_without_reuse() { + fn io_stream_ids_cross_u32_max_without_reuse() { let mut bc = BrowserContext::new("BID-1".to_owned()); - bc.set_next_network_request_sequence_for_test(u32::MAX as u64); - assert_eq!( - bc.allocate_subresource_network_request_id(), - "REQ-4294967296" - ); - assert_eq!(bc.next_network_request_sequence_for_test(), 4_294_967_296); - bc.set_next_io_stream_sequence_for_test(u32::MAX as u64); assert_eq!(bc.allocate_io_stream_handle(), "STREAM-4294967296"); assert_eq!(bc.next_io_stream_sequence_for_test(), 4_294_967_296); } #[test] - fn network_and_io_stream_id_allocators_fail_at_u64_exhaustion() { - let mut bc = BrowserContext::new("BID-1".to_owned()); - bc.set_next_network_request_sequence_for_test(u64::MAX); - assert!( - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _ = bc.allocate_subresource_network_request_id(); - })) - .is_err(), - "network request ids must not silently wrap after u64::MAX" - ); - + fn io_stream_id_allocator_fails_at_u64_exhaustion() { let mut bc = BrowserContext::new("BID-2".to_owned()); bc.set_next_io_stream_sequence_for_test(u64::MAX); assert!( diff --git a/moli-protocol/src/conn/page_state/loaded.rs b/moli-protocol/src/conn/page_state/loaded.rs index 4a6703e755..de34744f60 100644 --- a/moli-protocol/src/conn/page_state/loaded.rs +++ b/moli-protocol/src/conn/page_state/loaded.rs @@ -1,30 +1,58 @@ +#[cfg(test)] +use super::super::state::TargetPageAbsenceReason; use super::super::state::{ - CommittedRendererAgentAttachment, PreparedRendererAgentAttachment, TargetPageAbsenceReason, - TargetPageAttachmentId, prepare_renderer_call_replacements_for_devtools_sessions, - runtime_bindings_for_renderer, + CommittedRendererAgentAttachment, PreparedRendererAgentAttachment, TargetPageAttachmentId, + prepare_renderer_call_replacements_for_devtools_sessions, runtime_bindings_for_renderer, +}; +use super::super::{ + BackgroundTarget, BrowserContext, RendererPageResidenceIdentity, TargetRuntimeSlot, +}; +use moli_core::{ + browser_host::{ + BrowserPageReplacement, BrowserPageResidenceTransition, BrowserPageRuntimeLease, + BrowserPageRuntimeOwner, + }, + page::Page, }; -use super::super::{BackgroundTarget, BrowserContext, TargetRuntimeSlot}; -use crate::conn::TargetPageResidenceIdentity; -use moli_core::page::{Page, RendererPageCommandPostResponseContinuation}; -use url::Url; - -pub(crate) struct LoadedNavigationPageCommit { - pub(crate) replaced_page_owner: Option, - pub(crate) committed_document_post_response_continuation: - Option, -} pub(crate) enum LoadedNavigationRendererAttachmentCommit { Prepare(Option), AlreadyCommitted(CommittedRendererAgentAttachment), } +/// Renderer/protocol participant state prepared before Browser Core commits +/// an authoritative Page replacement. +pub(crate) struct PreparedLoadedNavigationPageCommit { + page: Page, + retiring_renderer_page: Option, +} + +impl PreparedLoadedNavigationPageCommit { + pub(crate) fn new( + page: Page, + retiring_renderer_page: Option, + ) -> Self { + Self { + page, + retiring_renderer_page, + } + } + + pub(crate) fn retiring_renderer_page(&self) -> Option { + self.retiring_renderer_page + } + + pub(crate) fn into_page_runtime_owner(self) -> BrowserPageRuntimeOwner { + BrowserPageRuntimeOwner::new(self.page) + } +} + impl BrowserContext { async fn close_page_best_effort(page: Page) { let _ = page.close_async().await; } - pub(crate) fn loaded_page(&self) -> Option<&Page> { + pub(crate) fn loaded_page(&self) -> Option { self.active_target.runtime_slot.loaded_page() } @@ -36,25 +64,31 @@ impl BrowserContext { self.active_target.runtime_slot.page_attachment_id() } - fn clear_active_target_session_scoped_state_fields(&mut self) { - let retained_runtime_bindings = runtime_bindings_for_renderer( - &self.devtools_session_state, - &self.auxiliary_devtools_session_states, - ); - self.devtools_session_state - .page_session_state - .page_lifecycle_events = false; - self.devtools_session_state = Default::default(); - self.auxiliary_devtools_session_states.clear(); - self.devtools_session_state.runtime_bindings = retained_runtime_bindings; - self.devtools_session_state.page_session_state.log_enabled = false; - self.devtools_session_state - .console_output_session_state - .console_enabled = false; - self.devtools_session_state - .page_session_state - .performance - .disable(); + pub(crate) fn clear_active_target_session_scoped_state_fields(&mut self) { + { + let (primary, auxiliary) = self.devtools_session_states_mut(); + let retained_runtime_bindings = runtime_bindings_for_renderer(primary, auxiliary); + primary.page_session_state.page_lifecycle_events = false; + *primary = Default::default(); + // Auxiliary map membership is the exact attachment route. Reset + // session-scoped contents without deleting those still-live + // routes; detach owns structural removal. + for state in auxiliary.values_mut() { + *state = Default::default(); + } + primary.runtime_bindings = retained_runtime_bindings; + primary.page_session_state.log_enabled = false; + primary.console_output_session_state.console_enabled = false; + primary.page_session_state.performance.disable(); + primary.page_session_state.page_bypass_csp_enabled = false; + primary.page_session_state.page_font_families.clear(); + primary + .page_session_state + .page_file_chooser_opened_event_enabled = false; + primary + .page_session_state + .page_intercept_file_chooser_dialog_enabled = false; + } self.active_target .runtime_slot .disable_primary_network_events(); @@ -69,19 +103,6 @@ impl BrowserContext { self.emulated_media = Default::default(); self.emulated_device_metrics = None; self.cpu_throttling_rate = 1.0; - self.devtools_session_state - .page_session_state - .page_bypass_csp_enabled = false; - self.devtools_session_state - .page_session_state - .page_font_families - .clear(); - self.devtools_session_state - .page_session_state - .page_file_chooser_opened_event_enabled = false; - self.devtools_session_state - .page_session_state - .page_intercept_file_chooser_dialog_enabled = false; self.touch_emulation_enabled = false; self.emit_touch_events_for_mouse = false; self.focus_emulation_enabled = false; @@ -99,11 +120,12 @@ impl BrowserContext { .clear_observable_output_state(); } - fn clear_active_target_loaded_document_session_state(&mut self) { - self.devtools_session_state + pub(crate) fn clear_active_target_loaded_document_session_state(&mut self) { + let (primary, auxiliary) = self.devtools_session_states_mut(); + primary .page_session_state .clear_loaded_document_context_state(); - for state in self.auxiliary_devtools_session_states.values_mut() { + for state in auxiliary.values_mut() { state .page_session_state .clear_loaded_document_context_state(); @@ -111,13 +133,14 @@ impl BrowserContext { } pub(crate) fn clear_active_target_runtime_remote_object_tracking(&mut self) { - self.devtools_session_state - .clear_runtime_remote_object_tracking(); - for state in self.auxiliary_devtools_session_states.values_mut() { + let (primary, auxiliary) = self.devtools_session_states_mut(); + primary.clear_runtime_remote_object_tracking(); + for state in auxiliary.values_mut() { state.clear_runtime_remote_object_tracking(); } } + #[cfg(test)] pub(crate) fn replace_loaded_page(&mut self, page: Option) -> Option { let previous = self.active_target.runtime_slot.replace_loaded_page(page); self.ingest_active_target_output_updates(); @@ -128,6 +151,7 @@ impl BrowserContext { previous } + #[cfg(test)] pub(crate) fn clear_loaded_page_with_reason( &mut self, reason: TargetPageAbsenceReason, @@ -144,71 +168,6 @@ impl BrowserContext { previous } - pub(crate) fn mark_next_navigation_history_replace_current(&mut self) { - self.active_target - .owner_state - .mark_next_navigation_history_replace_current(); - } - - pub(crate) fn mark_next_navigation_history_traverse_to_entry(&mut self, entry_id: i32) { - self.active_target - .owner_state - .mark_next_navigation_history_traverse_to_entry(entry_id); - } - - pub(crate) fn clear_pending_navigation_history_update(&mut self) { - self.active_target - .owner_state - .clear_pending_navigation_history_update(); - } - - pub(crate) fn navigation_history_entry_url(&mut self, entry_id: i32) -> Option { - let target_url = self.target_url().to_owned(); - let page_snapshot = self - .loaded_page() - .map(|page| (target_url, page.document_title())); - self.active_target - .owner_state - .navigation_history_entry_url(page_snapshot, entry_id) - } - - fn record_loaded_page_navigation_history(&mut self, page: &Page, history_url: &Url) { - let previous_title = self - .active_target - .owner_state - .committed_document_title() - .map(str::to_owned) - .or_else(|| self.loaded_page().map(Page::document_title)); - if let Some(previous_title) = previous_title { - self.active_target - .owner_state - .refresh_current_navigation_history_title(previous_title); - } - self.active_target - .owner_state - .record_loaded_page_navigation_history(( - history_url.to_string(), - page.document_title(), - )); - } - - pub(crate) fn record_same_document_navigation_history( - &mut self, - url: String, - history_update: moli_core::page::SameDocumentHistoryUpdate, - ) { - let page_snapshot = self - .loaded_page() - .map(|page| (self.target_url().to_owned(), page.document_title())); - let title = page_snapshot - .as_ref() - .map(|(_, title)| title.clone()) - .unwrap_or_default(); - self.active_target - .owner_state - .record_same_document_navigation_history(page_snapshot, url, title, history_update); - } - #[cfg(test)] pub(crate) async fn set_loaded_page_async(&mut self, mut page: Page) { // BrowserContext owns document-cookie facade overrides for the active @@ -233,8 +192,12 @@ impl BrowserContext { .ingest_owner_page_observable_output_updates() } + #[cfg(test)] async fn close_loaded_page_async(&mut self) -> bool { - let page = self.clear_loaded_page_with_reason(TargetPageAbsenceReason::TargetClosed); + let page = self + .active_target + .runtime_slot + .retire_page_projection_after_browser_owner_forget(); let had_page = page.is_some(); if let Some(page) = page { Self::close_page_best_effort(page).await; @@ -242,25 +205,15 @@ impl BrowserContext { had_page } - pub(crate) async fn commit_loaded_navigation_page_async( + pub(crate) fn prepare_loaded_navigation_page_commit( &mut self, mut page: Page, renderer_attachment_commit: LoadedNavigationRendererAttachmentCommit, - history_url: &Url, - ) -> anyhow::Result { - let committed_document_post_response_continuation = - page.take_committed_document_post_response_continuation(); - let previous_page_owner = - self.active_target - .runtime_slot - .page_attachment_id() - .map(|page_attachment_id| { - TargetPageResidenceIdentity::new( - self.id.clone(), - self.active_target_id_owned(), - page_attachment_id, - ) - }); + ) -> anyhow::Result { + let retiring_renderer_page = self + .active_target + .runtime_slot + .loaded_renderer_page_residence(); let primary_session_id = self.active_session_id_owned(); let previous_attachment = match renderer_attachment_commit { LoadedNavigationRendererAttachmentCommit::Prepare(renderer_agent_candidate) => self @@ -283,10 +236,11 @@ impl BrowserContext { if let Some(previous_attachment) = previous_attachment && previous_attachment.id() != new_attachment_id { + let (primary, auxiliary) = self.devtools_session_states_mut(); 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, + primary, + auxiliary, previous_attachment.id(), new_attachment_id, )?; @@ -294,26 +248,61 @@ impl BrowserContext { .runtime_slot .install_pending_renderer_call_replacements(replacements); } - let committed_document_title = page.document_title(); - self.record_loaded_page_navigation_history(&page, history_url); - let previous = self.replace_loaded_page(Some(page)); + Ok(PreparedLoadedNavigationPageCommit::new( + page, + retiring_renderer_page, + )) + } + + pub(crate) fn project_loaded_navigation_page_after_browser_owner_commit( + &mut self, + replacement: &BrowserPageReplacement, + retiring_renderer_page: Option, + ) { + self.active_target + .runtime_slot + .project_loaded_page_after_browser_owner_commit(replacement, retiring_renderer_page); + self.ingest_active_target_output_updates(); + self.active_target + .owner_state + .clear_loaded_document_context_state(); + self.clear_active_target_loaded_document_session_state(); self.reset_subresource_network_cursor(); self.clear_websocket_network_artifacts(); self.active_target .owner_state .clear_committed_document_navigation_state(); + self.clear_active_target_runtime_remote_object_tracking(); + } + + pub(crate) fn project_initial_document_page_after_browser_owner_commit( + &mut self, + transition: &BrowserPageResidenceTransition, + ) { + self.active_target + .runtime_slot + .project_initial_document_page_after_browser_owner_commit(transition); + self.ingest_active_target_output_updates(); self.active_target .owner_state - .commit_document_title(committed_document_title); - self.clear_active_target_runtime_remote_object_tracking(); - let replaced_page_owner = previous.as_ref().and(previous_page_owner); - if let Some(page) = previous { - Self::close_page_best_effort(page).await; - } - Ok(LoadedNavigationPageCommit { - replaced_page_owner, - committed_document_post_response_continuation, - }) + .clear_loaded_document_context_state(); + self.clear_active_target_loaded_document_session_state(); + } + + pub(crate) fn project_failed_navigation_page_absence_after_browser_owner_commit( + &mut self, + transition: &BrowserPageResidenceTransition, + ) -> Option { + let previous = self + .active_target + .runtime_slot + .project_failed_navigation_page_absence_after_browser_owner_commit(transition); + self.ingest_active_target_output_updates(); + self.active_target + .owner_state + .clear_loaded_document_context_state(); + self.clear_active_target_loaded_document_session_state(); + previous } pub(crate) async fn clear_active_target_session_scoped_state_async( @@ -321,7 +310,7 @@ impl BrowserContext { ) -> Result<(), String> { self.clear_active_target_session_scoped_state_fields(); let emulated_media: moli_core::page::EmulatedMediaOverrides = (&self.emulated_media).into(); - if let Some(page) = self.active_target.runtime_slot.loaded_page_mut() { + if let Some(mut page) = self.active_target.runtime_slot.loaded_page_mut() { page.set_extra_http_headers_async(&[]) .await .map_err(|error| format!("failed to clear page extra headers: {error}"))?; @@ -354,48 +343,7 @@ impl BrowserContext { self.clear_active_target_session_scoped_state_fields(); } - pub(crate) async fn mark_active_target_crashed_async(&mut self) { - self.active_target - .owner_state - .target_crash_state - .mark_crashed(); - self.clear_document_navigation_state_for_active_target(); - let page = self.clear_loaded_page_with_reason(TargetPageAbsenceReason::TargetCrashed); - if let Some(page) = page { - Self::close_page_best_effort(page).await; - } - self.clear_pending_fetch_state(); - self.active_target - .owner_state - .navigation_history_state - .clear(); - self.clear_session_scoped_network_observation_artifacts(); - } - - pub(crate) async fn close_active_target_after_page_close_async(&mut self) { - if let Some(target_id) = self.active_target_id_owned() { - self.forget_target_opener_references_for_target(&target_id); - self.forget_target_window_names_for_target(&target_id); - self.forget_target_popup_id_for_target(&target_id); - } - self.clear_active_target_session_scoped_state_fields(); - self.active_target.owner_state.target_crash_state.clear(); - self.clear_active_target_id(); - self.clear_document_navigation_state_for_active_target(); - self.detach_active_session(); - self.close_loaded_page_async().await; - self.active_target.owner_state.clear_page_local_state(); - self.reset_target_identity_to_about_blank(); - self.reset_target_scoped_network_artifacts(); - self.active_target - .owner_state - .clear_observable_output_state(); - self.active_target - .runtime_slot - .request_id_allocator() - .reset_subresource_fetch_request_counter(); - } - + #[cfg(test)] pub(crate) async fn reset_active_target_slot_to_empty_async(&mut self) { self.clear_active_target_session_scoped_state_fields(); self.active_target.owner_state.target_crash_state.clear(); @@ -406,7 +354,7 @@ impl BrowserContext { } self.detach_active_session(); self.clear_active_target_id(); - self.clear_document_navigation_state_for_active_target(); + self.clear_renderer_document_protocol_state_for_active_target(); self.close_loaded_page_async().await; self.clear_pending_fetch_state(); self.active_target.owner_state.clear_page_local_state(); @@ -423,11 +371,31 @@ impl BrowserContext { .reset_subresource_fetch_request_counter(); } - pub(crate) async fn close_all_pages_async(&mut self) { - self.close_loaded_page_async().await; + /// Transfers any residual renderer Pages after authoritative whole- + /// Context removal. + /// + /// A correct disposal chain has already closed every registered Target, + /// so this is normally empty. Returning concrete Pages lets Browser Host + /// expose real cleanup participants if physical projection drift left a + /// residual Page, without awaiting an always-ready compatibility drain. + pub(crate) fn take_residual_pages_for_browser_context_disposal(&mut self) -> Vec { + let mut pages = Vec::new(); + if let Some(page) = self + .active_target + .runtime_slot + .retire_page_projection_after_browser_owner_forget() + { + pages.push(page); + } for target in &mut self.background_targets { - target.close_page_async().await; + if let Some(page) = target + .runtime_slot + .retire_page_projection_after_browser_owner_forget() + { + pages.push(page); + } } + pages } } @@ -457,11 +425,11 @@ impl BackgroundTarget { &self.runtime_slot } - pub(crate) fn loaded_page(&self) -> Option<&Page> { + pub(crate) fn loaded_page(&self) -> Option { self.runtime_slot.loaded_page() } - pub(crate) fn loaded_page_mut(&mut self) -> Option<&mut Page> { + pub(crate) fn loaded_page_mut(&mut self) -> Option { self.runtime_slot.loaded_page_mut() } @@ -469,6 +437,7 @@ impl BackgroundTarget { self.loaded_page().is_some() } + #[cfg(test)] pub(crate) fn replace_loaded_page(&mut self, page: Option) -> Option { let previous = self.runtime_slot.replace_loaded_page(page); self.runtime_slot @@ -476,6 +445,39 @@ impl BackgroundTarget { previous } + pub(crate) fn project_loaded_page_after_browser_owner_commit( + &mut self, + replacement: &BrowserPageReplacement, + retiring_renderer_page: Option, + ) { + self.runtime_slot + .project_loaded_page_after_browser_owner_commit(replacement, retiring_renderer_page); + self.runtime_slot + .ingest_owner_page_observable_output_updates(); + } + + pub(crate) fn project_initial_document_page_after_browser_owner_commit( + &mut self, + transition: &BrowserPageResidenceTransition, + ) { + self.runtime_slot + .project_initial_document_page_after_browser_owner_commit(transition); + self.runtime_slot + .ingest_owner_page_observable_output_updates(); + } + + pub(crate) fn project_failed_navigation_page_absence_after_browser_owner_commit( + &mut self, + transition: &BrowserPageResidenceTransition, + ) -> Option { + let previous = self + .runtime_slot + .project_failed_navigation_page_absence_after_browser_owner_commit(transition); + self.runtime_slot + .ingest_owner_page_observable_output_updates(); + previous + } + #[cfg(test)] pub(crate) fn page_attachment_id(&self) -> Option { self.runtime_slot.page_attachment_id() @@ -484,7 +486,7 @@ impl BackgroundTarget { pub(crate) async fn close_page_async(&mut self) { if let Some(page) = self .runtime_slot - .clear_loaded_page_with_reason(TargetPageAbsenceReason::TargetClosed) + .retire_page_projection_after_browser_owner_forget() { BrowserContext::close_page_best_effort(page).await; } diff --git a/moli-protocol/src/conn/page_state/parked.rs b/moli-protocol/src/conn/page_state/parked.rs index 03de3cbe53..50a52de97f 100644 --- a/moli-protocol/src/conn/page_state/parked.rs +++ b/moli-protocol/src/conn/page_state/parked.rs @@ -1,6 +1,7 @@ use super::super::cookie_manager_surface::BrowserContextCookieManagerSurfaceSnapshot; use super::super::state::{ - TargetPageAbsenceReason, TargetSessionStorageNamespace, runtime_bindings_for_renderer, + StagedBackgroundTargetSlot, TargetPageAbsenceReason, TargetSessionStorageNamespace, + runtime_bindings_for_renderer, }; use super::super::{ BackgroundTarget, BrowserContext, DedicatedWorkerTargetState, IsolatedWorldDefinition, @@ -8,11 +9,13 @@ use super::super::{ ParkedTargetOwnerState, ServiceWorkerTargetState, SharedWorkerTargetState, TargetIdentityState, TargetInitialEmptyDocumentCreator, TargetSlotState, }; -use crate::conn::state::{DevToolsSessionState, TargetFetchConfig, TargetNetworkPolicyState}; +use crate::conn::state::{TargetFetchConfig, TargetNetworkPolicyState}; use crate::devtools_runtime::{ DevToolsBrowserContextId, DevToolsTargetId, DevToolsTargetInfo, DevToolsTargetKind, }; -use moli_core::network::SharedWebStorageStore; +use moli_core::browser_host::{ + BrowserPageResidenceHandle, BrowserTargetHandle, BrowserTargetSessionStorageAccess, +}; impl BrowserContext { pub fn remove_background_target(&mut self, target_id: &str) -> Option { @@ -23,51 +26,69 @@ impl BrowserContext { Some(self.background_targets.swap_remove(index)) } + #[cfg(test)] pub(crate) fn stage_background_target( &mut self, - target_id: String, + target_handle: impl Into, session_id: Option, url: String, - initial_empty_document_url: Option, + _initial_empty_document_url: Option, creator: Option, ) { let session_storage_namespace = self.deep_cloned_session_storage_namespace_for_creator(creator.as_ref()); self.stage_background_target_with_session_storage_namespace( - target_id, + target_handle.into(), + BrowserPageResidenceHandle::default(), session_id, url, - initial_empty_document_url, creator, - None, session_storage_namespace, + None, + ); + } + + pub(crate) fn stage_background_target_with_browser_handles( + &mut self, + target_handle: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, + session_storage_access: BrowserTargetSessionStorageAccess, + session_id: Option, + url: String, + creator: Option, + ) { + self.stage_background_target_with_session_storage_namespace( + target_handle, + page_residence, + session_id, + url, + creator, + None, + Some(session_storage_access), ); } pub(crate) fn stage_popup_background_target( &mut self, - target_id: String, + target_handle: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, + session_storage_access: BrowserTargetSessionStorageAccess, session_id: Option, url: String, - initial_empty_document_url: Option, creator: Option, - session_storage_store: Option, - initial_empty_document_storage_key: Option, ) { - let session_storage_namespace = session_storage_store - .map(TargetSessionStorageNamespace::from_store) - .or_else(|| self.deep_cloned_session_storage_namespace_for_creator(creator.as_ref())); self.stage_background_target_with_session_storage_namespace( - target_id, + target_handle, + page_residence, session_id, url, - initial_empty_document_url, creator, - initial_empty_document_storage_key, - session_storage_namespace, + None, + Some(session_storage_access), ); } + #[cfg(test)] fn deep_cloned_session_storage_namespace_for_creator( &self, creator: Option<&TargetInitialEmptyDocumentCreator>, @@ -83,49 +104,97 @@ impl BrowserContext { fn stage_background_target_with_session_storage_namespace( &mut self, - target_id: String, + target_handle: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, session_id: Option, url: String, - initial_empty_document_url: Option, creator: Option, - initial_empty_document_storage_key: Option, session_storage_namespace: Option, + session_storage_access: Option, ) { let target_identity = background_target_identity_for_initial_url(&url, creator.as_ref()); - self.mutate_parked_target_owner_state(&target_id, |owner_state| { - owner_state.begin_initial_empty_document( - target_id.clone(), - initial_empty_document_url.unwrap_or_else(|| url.clone()), - creator, - initial_empty_document_storage_key, - ); - }); - let mut target = BackgroundTarget::with_identity(target_id, session_id, target_identity); + let mut target = + BackgroundTarget::with_identity(target_handle.clone(), page_residence, target_identity); if let Some(namespace) = session_storage_namespace { target.replace_session_storage_namespace(namespace); } + if let Some(access) = session_storage_access { + target.bind_session_storage_access(access); + } + self.register_top_level_target_attachment(target_handle, session_id); self.background_targets.push(target); } + #[cfg(test)] pub(crate) fn stage_active_target_demoting_current( &mut self, - target_id: String, + target_handle: impl Into, + session_id: Option, + url: String, + _initial_empty_document_url: Option, + ) { + self.stage_active_target_demoting_current_with_optional_registration( + target_handle.into(), + BrowserPageResidenceHandle::default(), + None, + session_id, + url, + ); + } + + pub(crate) fn stage_active_target_demoting_current_with_browser_handles( + &mut self, + target_handle: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, + session_storage_access: BrowserTargetSessionStorageAccess, session_id: Option, url: String, - initial_empty_document_url: Option, ) { + self.stage_active_target_demoting_current_with_optional_registration( + target_handle, + page_residence, + Some(session_storage_access), + session_id, + url, + ); + } + + fn stage_active_target_demoting_current_with_optional_registration( + &mut self, + target_handle: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, + session_storage_access: Option, + session_id: Option, + url: String, + ) { + // Root frontend bindings are browser-context defaults for successor + // Documents. The predecessor exact Target keeps its own copy when it + // becomes background, while the newly created active Target receives + // the same cleaned pre-document configuration. + let inherited_runtime_bindings = runtime_bindings_for_renderer( + self.devtools_session_state(), + self.auxiliary_devtools_session_states(), + ); let previous_active_slot = self.take_active_target_slot_state(); - self.set_active_target_id(target_id.clone()); + if let Some(session_storage_access) = session_storage_access { + self.bind_new_active_target_registration( + target_handle, + page_residence, + session_storage_access, + ); + } else { + self.bind_new_active_target_handles(target_handle, page_residence); + } self.replace_active_session(session_id); self.replace_target_identity(TargetIdentityState::with_url(url.clone())); self.clear_active_target_session_scoped_state_without_loaded_page(); + if let Some(target_id) = self.active_target_id_owned() { + let _ = + self.mutate_devtools_session_state_for_target(&target_id, false, None, |state| { + state.runtime_bindings = inherited_runtime_bindings + }); + } self.active_target.owner_state.clear_page_local_state(); - self.active_target.owner_state.begin_initial_empty_document( - target_id, - initial_empty_document_url.unwrap_or(url), - None, - None, - ); self.reset_target_scoped_network_artifacts(); self.active_target .owner_state @@ -245,37 +314,12 @@ impl BrowserContext { } } - pub(crate) fn update_target_url(&mut self, target_id: &str, url: String) -> bool { - if self.is_active_target(target_id) { - self.set_target_url(url); - self.active_target.owner_state.target_crash_state.clear(); - return true; - } - if let Some(target) = self - .background_targets - .iter_mut() - .find(|target| target.is_target(target_id)) - { - target.set_target_url(url); - return true; - } - false - } - pub(crate) fn assign_session_to_target(&mut self, target_id: &str, session_id: String) -> bool { - if self.is_active_target(target_id) { - self.attach_active_session(session_id); - true - } else if let Some(target) = self - .background_targets - .iter_mut() - .find(|target| target.is_target(target_id)) - { - target.attach_session(session_id); - true - } else { - false + if self.top_level_target_handle(target_id).is_none() { + return false; } + self.replace_primary_session_for_target(target_id, Some(session_id)); + true } pub(crate) fn assign_auto_attached_session_to_target( @@ -283,26 +327,13 @@ impl BrowserContext { target_id: &str, session_id: String, ) -> bool { - if self.is_active_target(target_id) { - if self.has_active_session() { - self.assign_auxiliary_session_to_target(target_id, session_id) - } else { - self.attach_active_session(session_id); - true - } - } else if let Some(target) = self - .background_targets - .iter_mut() - .find(|target| target.is_target(target_id)) - { - if target.has_session() { - self.assign_auxiliary_session_to_target(target_id, session_id) - } else { - target.attach_session(session_id); - true - } - } else { + if self.top_level_target_handle(target_id).is_none() { false + } else if self.primary_session_id_for_target(target_id).is_some() { + self.assign_auxiliary_session_to_target(target_id, session_id) + } else { + self.replace_primary_session_for_target(target_id, Some(session_id)); + true } } @@ -311,42 +342,23 @@ impl BrowserContext { target_id: &str, session_id: String, ) -> bool { - if self.is_active_target(target_id) - || self - .background_targets - .iter() - .any(|target| target.is_target(target_id)) - { - self.auxiliary_target_sessions - .insert(session_id, target_id.to_owned()); - true - } else { - false - } + self.attach_auxiliary_session_for_target(target_id, session_id) } pub(crate) fn auxiliary_target_id_for_session(&self, session_id: &str) -> Option<&str> { - self.auxiliary_target_sessions - .get(session_id) - .map(String::as_str) + self.auxiliary_attachment_target_id_for_session(session_id) } pub(crate) fn auxiliary_session_ids_for_target(&self, target_id: &str) -> Vec { - let mut session_ids = self - .auxiliary_target_sessions - .iter() - .filter(|&(_session_id, session_target_id)| session_target_id == target_id) - .map(|(session_id, _session_target_id)| session_id.clone()) - .collect::>(); - session_ids.sort(); - session_ids + self.auxiliary_attachment_session_ids_for_target(target_id) } pub(crate) fn devtools_session_ids_for_target(&self, target_id: &str) -> Vec { - let mut session_ids = if self.is_active_target(target_id) { - self.active_session_id_owned().into_iter().collect() - } else if let Some(target) = self.background_target(target_id) { - target.session_id().map(str::to_owned).into_iter().collect() + let mut session_ids = if self.top_level_target_handle(target_id).is_some() { + self.primary_session_id_for_target(target_id) + .map(str::to_owned) + .into_iter() + .collect() } else if let Some(target) = self.shared_worker_target(target_id) { target.session_ids() } else if let Some(target) = self.service_worker_target(target_id) { @@ -367,15 +379,13 @@ impl BrowserContext { self.active_target .fetch_owner .remove_fetch_session(Some(session_id)); - self.auxiliary_devtools_session_states.remove(session_id); - let target_id = self.auxiliary_target_sessions.remove(session_id)?; + let target_id = self.remove_auxiliary_attachment_session(session_id)?; if let Some(target) = self.background_target_mut(&target_id) { target .runtime_slot .remove_auxiliary_network_session(session_id); } self.mutate_parked_page_session_state(&target_id, |state| { - state.auxiliary_devtools_session_states.remove(session_id); state.fetch_config.remove_fetch_session(Some(session_id)); }); if self @@ -391,12 +401,7 @@ impl BrowserContext { } pub(crate) fn remove_auxiliary_sessions_for_target(&mut self, target_id: &str) -> Vec { - let session_ids = self - .auxiliary_target_sessions - .iter() - .filter(|&(_session_id, session_target_id)| session_target_id == target_id) - .map(|(session_id, _session_target_id)| session_id.clone()) - .collect::>(); + let session_ids = self.auxiliary_session_ids_for_target(target_id); for session_id in &session_ids { let _ = self.remove_auxiliary_session(session_id); } @@ -408,52 +413,48 @@ impl BrowserContext { session_id: &str, ) -> Result, String> { let Some(target_id) = self - .background_targets - .iter_mut() - .find(|target| target.is_session(session_id)) - .map(|target| { - target.detach_session(); - let target_id = target.target_id().to_owned(); - target.runtime_slot.disable_primary_network_events(); - target - .runtime_slot - .clear_session_scoped_network_observation_artifacts(); - target - .runtime_slot - .request_id_allocator() - .reset_fetch_navigation_request_counter(); - target - .runtime_slot - .request_id_allocator() - .reset_subresource_fetch_request_counter(); - target_id - }) + .primary_attachment_target_id_for_session(session_id) + .filter(|target_id| !self.is_active_target(target_id)) + .map(str::to_owned) else { return Ok(None); }; + self.replace_primary_session_for_target(&target_id, None); + let Some(()) = self.background_target_mut(&target_id).map(|target| { + target.runtime_slot.disable_primary_network_events(); + target + .runtime_slot + .clear_session_scoped_network_observation_artifacts(); + target + .runtime_slot + .request_id_allocator() + .reset_fetch_navigation_request_counter(); + target + .runtime_slot + .request_id_allocator() + .reset_subresource_fetch_request_counter(); + }) else { + return Ok(None); + }; let retained_runtime_bindings = self - .parked_page_session_state(&target_id) - .map(|state| { - runtime_bindings_for_renderer( - &state.devtools_session_state, - &state.auxiliary_devtools_session_states, - ) - }) + .devtools_session_states_for_target(&target_id) + .map(|(primary, auxiliary)| runtime_bindings_for_renderer(primary, auxiliary)) .unwrap_or_default(); - let mut cleared_page_session_state = ParkedPageSessionState::default(); + let cleared_page_session_state = ParkedPageSessionState::default(); let cleared_emulated_media: moli_core::page::EmulatedMediaOverrides = (&cleared_page_session_state.emulated_media).into(); - cleared_page_session_state - .devtools_session_state - .runtime_bindings = retained_runtime_bindings; + self.reset_primary_devtools_session_state_for_target(&target_id); + self.mutate_devtools_session_state_for_target(&target_id, false, None, |state| { + state.runtime_bindings = retained_runtime_bindings + }); self.replace_parked_page_session_state(target_id.clone(), cleared_page_session_state); self.replace_parked_fetch_state(target_id.clone(), Default::default()); self.mutate_parked_network_artifacts(&target_id, |artifacts| { artifacts.remove_session_observation_cursor(Some(session_id)); }); - if let Some(page) = self + if let Some(mut page) = self .background_target_mut(&target_id) .and_then(|target| target.runtime_slot.loaded_page_mut()) { @@ -487,32 +488,29 @@ impl BrowserContext { session_id: &str, ) -> Option { let target_id = self - .background_targets - .iter_mut() - .find(|target| target.is_session(session_id)) - .map(|target| { - target.detach_session(); - let target_id = target.target_id().to_owned(); - target.runtime_slot.disable_primary_network_events(); - target - .runtime_slot - .clear_session_scoped_network_observation_artifacts(); - target - .runtime_slot - .request_id_allocator() - .reset_fetch_navigation_request_counter(); - target - .runtime_slot - .request_id_allocator() - .reset_subresource_fetch_request_counter(); - target_id - })?; + .primary_attachment_target_id_for_session(session_id) + .filter(|target_id| !self.is_active_target(target_id)) + .map(str::to_owned)?; + self.replace_primary_session_for_target(&target_id, None); + let target = self.background_target_mut(&target_id)?; + target.runtime_slot.disable_primary_network_events(); + target + .runtime_slot + .clear_session_scoped_network_observation_artifacts(); + target + .runtime_slot + .request_id_allocator() + .reset_fetch_navigation_request_counter(); + target + .runtime_slot + .request_id_allocator() + .reset_subresource_fetch_request_counter(); self.mutate_parked_page_session_state(&target_id, |state| { - state.devtools_session_state = DevToolsSessionState::default(); state.network_enabled = false; state.network_policy = TargetNetworkPolicyState::default(); state.fetch_config = TargetFetchConfig::default(); }); + self.reset_primary_devtools_session_state_for_target(&target_id); self.mutate_parked_network_artifacts(&target_id, |artifacts| { artifacts.remove_session_observation_cursor(Some(session_id)); }); @@ -640,15 +638,11 @@ impl BrowserContext { &mut self, ) -> Result<(), String> { let target_id = self.active_target_id_owned(); - let auxiliary_inspector_session_ids = self - .auxiliary_target_sessions - .iter() - .filter(|(_session_id, session_target_id)| { - target_id.as_deref() == Some(session_target_id.as_str()) - }) - .map(|(session_id, _session_target_id)| session_id.clone()) - .collect::>(); - if let Some(page) = self.active_target.runtime_slot.loaded_page_mut() { + let auxiliary_inspector_session_ids = target_id + .as_deref() + .map(|target_id| self.auxiliary_session_ids_for_target(target_id)) + .unwrap_or_default(); + if let Some(mut page) = self.active_target.runtime_slot.loaded_page_mut() { for session_id in &auxiliary_inspector_session_ids { page.detach_runtime_inspector_session_async(Some(session_id)) .await @@ -666,7 +660,7 @@ impl BrowserContext { .await?; self.detach_active_session(); for session_id in auxiliary_inspector_session_ids { - self.auxiliary_target_sessions.remove(&session_id); + self.remove_auxiliary_attachment_session(&session_id); self.active_target .runtime_slot .remove_auxiliary_network_session(&session_id); @@ -698,15 +692,18 @@ impl BrowserContext { self.detach_active_session(); return true; } - let Some(target) = self - .background_targets - .iter_mut() - .find(|target| target.is_session(session_id)) + let Some(target_id) = self + .primary_attachment_target_id_for_session(session_id) + .filter(|target_id| !self.is_active_target(target_id)) + .map(str::to_owned) else { return false; }; + self.replace_primary_session_for_target(&target_id, None); + let Some(target) = self.background_target_mut(&target_id) else { + return false; + }; target.runtime_slot.disable_primary_network_events(); - target.detach_session(); true } @@ -728,14 +725,6 @@ impl BrowserContext { .await } - pub(crate) async fn promote_last_background_target_to_active_async( - &mut self, - ) -> Option { - let promoted = self.last_promotable_background_target_id()?; - self.promote_selected_background_target_to_active_async(promoted) - .await - } - pub(crate) fn last_promotable_background_target_id(&self) -> Option { self.background_targets .iter() @@ -748,6 +737,7 @@ impl BrowserContext { }) } + #[cfg(test)] async fn promote_selected_background_target_to_active_async( &mut self, promoted: String, @@ -771,71 +761,122 @@ impl BrowserContext { Some(target) } + #[cfg(test)] pub(crate) async fn promote_background_target_to_active_slot_async( &mut self, target_id: &str, ) -> Result { + let Some(synchronize_loaded_page) = + self.project_background_target_to_active_slot_after_browser_owner_commit(target_id) + else { + return Ok(false); + }; + if synchronize_loaded_page { + self.synchronize_active_target_after_topology_projection_async() + .await?; + } + Ok(true) + } + + /// Applies only the physical active/background payload swap after Browser + /// Core has committed the matching Target topology and engine handoff. + /// + /// This operation is synchronous and infallible for an exact projection; + /// renderer/DevTools state synchronization happens afterward so no + /// authoritative/physical topology mismatch crosses an await. + #[cfg(test)] + pub(in crate::conn) fn project_background_target_to_active_slot_after_browser_owner_commit( + &mut self, + target_id: &str, + ) -> Option { if self.is_active_target(target_id) { - return Ok(true); + return Some(false); } let synchronize_loaded_page = !self.has_pending_javascript_dialog(); - let Some(promoted_slot) = self.take_background_target_slot(target_id) else { - return Ok(false); - }; + let promoted_slot = self.take_background_target_slot(target_id)?; let previous_active = self.take_active_target_slot_state(); let (mut target, aux_state) = promoted_slot.into_parts(); std::mem::swap( &mut self.active_target.runtime_slot, &mut target.runtime_slot, ); - if synchronize_loaded_page { - self.clear_active_target_session_scoped_state_async() - .await?; - } else { - self.clear_active_target_session_scoped_state_without_loaded_page(); - } + self.clear_active_target_session_scoped_state_without_loaded_page(); self.active_target.owner_state.target_crash_state.clear(); - Box::pin(self.apply_promoted_background_target_state_async( + self.apply_promoted_background_target_state_without_loaded_page_sync( &mut target, aux_state, - synchronize_loaded_page, - )) - .await?; + ); if let Some(previous_active_slot) = previous_active { - Box::pin( - self.park_demoted_active_target_async( - *previous_active_slot, - synchronize_loaded_page, - ), - ) - .await; + self.replace_background_target_slot(*previous_active_slot); } - Ok(true) + Some(synchronize_loaded_page) } - async fn park_demoted_active_target_async( + /// Removes one exact physical background Target before entering the Core + /// topology transaction. Rejection can restore the payload at the same + /// index; success consumes it without another fallible Target lookup. + pub(in crate::conn) fn stage_background_target_slot_projection( &mut self, - slot: TargetSlotState, - synchronize_loaded_page: bool, + target_id: &str, + ) -> Option { + let index = self + .background_targets + .iter() + .position(|target| target.is_target(target_id))?; + let mut target = self.background_targets.remove(index); + let mut aux_state = self.take_parked_target_aux_state(target.target_id()); + aux_state + .network_artifacts + .drain_from_background_target(&mut target); + Some(StagedBackgroundTargetSlot::new( + index, + TargetSlotState::new(target, aux_state), + )) + } + + pub(in crate::conn) fn restore_staged_background_target_slot_projection( + &mut self, + staged: StagedBackgroundTargetSlot, ) { - let (mut target, aux_state) = slot.into_parts(); - if synchronize_loaded_page { - let target_id = target.target_id().to_owned(); - if let Err(error) = self - .apply_parked_surface_overrides_to_loaded_page_async( - &mut target.runtime_slot, - &aux_state.page_session_state, - ) - .await - { - tracing::warn!( - target_id, - %error, - "failed to update demoted page visibility" - ); - } + let (index, slot) = staged.into_parts(); + self.replace_background_target_slot_at(index, slot); + } + + /// Publishes the physical half of an already accepted Core activation. + /// The promoted payload was staged before the Core call, so this path has + /// no lookup or recoverable failure after the authoritative commit. + pub(in crate::conn) fn project_staged_background_target_to_active_slot_after_browser_owner_commit( + &mut self, + staged: StagedBackgroundTargetSlot, + ) { + let (_, promoted_slot) = staged.into_parts(); + let previous_active = self.take_active_target_slot_state(); + let (mut target, aux_state) = promoted_slot.into_parts(); + std::mem::swap( + &mut self.active_target.runtime_slot, + &mut target.runtime_slot, + ); + self.clear_active_target_session_scoped_state_without_loaded_page(); + self.active_target.owner_state.target_crash_state.clear(); + self.apply_promoted_background_target_state_without_loaded_page_sync( + &mut target, + aux_state, + ); + if let Some(previous_active_slot) = previous_active { + self.replace_background_target_slot(*previous_active_slot); } - self.replace_background_target_slot(TargetSlotState::new(target, aux_state)); + } + + #[cfg(test)] + pub(in crate::conn) async fn synchronize_active_target_after_topology_projection_async( + &mut self, + ) -> Result<(), String> { + self.synchronize_page_session_state_to_loaded_page_async() + .await?; + #[cfg(test)] + self.synchronize_raw_cookie_manager_surface_to_loaded_page_async() + .await; + Ok(()) } #[cfg(test)] @@ -864,6 +905,7 @@ impl BrowserContext { Ok(true) } + #[cfg(test)] pub(crate) fn take_background_target_slot( &mut self, target_id: &str, @@ -875,6 +917,7 @@ impl BrowserContext { self.take_background_target_slot_by_index(index) } + #[cfg(test)] fn take_background_target_slot_by_index(&mut self, index: usize) -> Option { if index >= self.background_targets.len() { return None; @@ -888,6 +931,11 @@ impl BrowserContext { } fn replace_background_target_slot(&mut self, slot: TargetSlotState) { + let index = self.background_targets.len(); + self.replace_background_target_slot_at(index, slot); + } + + fn replace_background_target_slot_at(&mut self, index: usize, slot: TargetSlotState) { let target_id = slot.target_id().to_owned(); let (mut target, mut aux_state) = slot.into_parts(); target @@ -897,24 +945,31 @@ impl BrowserContext { .network_artifacts .drain_into_background_target(&mut target); self.replace_parked_target_aux_state(target_id, aux_state); - self.background_targets.push(target); + let insert_index = index.min(self.background_targets.len()); + debug_assert_eq!( + insert_index, index, + "staged background Target slot must restore within the unchanged vector" + ); + self.background_targets.insert(insert_index, target); } fn take_active_target_slot_state(&mut self) -> Option> { let aux_state = self.snapshot_active_target_aux_state(); let session_storage_namespace = std::mem::take(&mut self.active_target.session_storage_namespace); - self.active_target_id_owned().map(|target_id| { - let runtime_slot = std::mem::take(&mut self.active_target.runtime_slot); - Box::new(TargetSlotState::from_active_snapshot( - target_id, - self.active_session_id_owned(), - self.target_identity().clone(), - runtime_slot, - session_storage_namespace, - aux_state, - )) - }) + self.active_target + .target_handle + .take() + .map(|target_handle| { + let runtime_slot = std::mem::take(&mut self.active_target.runtime_slot); + Box::new(TargetSlotState::from_active_snapshot( + target_handle, + self.target_identity().clone(), + runtime_slot, + session_storage_namespace, + aux_state, + )) + }) } pub fn take_parked_isolated_worlds(&mut self, target_id: &str) -> Vec { @@ -1038,75 +1093,10 @@ impl BrowserContext { result } - pub(crate) fn begin_active_target_initial_empty_document(&mut self, initial_url: String) { - self.begin_active_target_initial_empty_document_with_storage_key(initial_url, None); - } - - pub(crate) fn begin_active_target_initial_empty_document_with_storage_key( - &mut self, - initial_url: String, - storage_key: Option, - ) { - let Some(target_id) = self.active_target_id_owned() else { - return; - }; + pub(crate) fn mark_active_initial_document_page_build_pending(&mut self) { self.active_target .runtime_slot .mark_loaded_page_absent(TargetPageAbsenceReason::InitialDocumentPageBuildPending); - self.active_target.owner_state.begin_initial_empty_document( - target_id, - initial_url, - None, - storage_key, - ); - } - - pub(crate) fn mark_target_initial_empty_document_materialized(&mut self, target_id: &str) { - self.mutate_target_owner_state_by_target_id(target_id, |owner_state| { - owner_state.mark_initial_empty_document_materialized(); - }); - } - - pub(crate) fn mark_target_initial_url_replaces_empty_document(&mut self, target_id: &str) { - self.mutate_target_owner_state_by_target_id(target_id, |owner_state| { - owner_state.mark_next_navigation_history_replace_initial_empty_document(); - }); - } - - pub(crate) fn mark_target_initial_empty_document_pending_cross_document_navigation( - &mut self, - target_id: &str, - ) { - self.mutate_target_owner_state_by_target_id(target_id, |owner_state| { - owner_state.mark_initial_empty_document_pending_cross_document_navigation(); - }); - } - - pub(crate) fn clear_target_initial_empty_document_pending_cross_document_navigation( - &mut self, - target_id: &str, - ) { - self.mutate_target_owner_state_by_target_id(target_id, |owner_state| { - owner_state.clear_initial_empty_document_pending_cross_document_navigation(); - }); - } - - pub(crate) fn mark_target_initial_empty_document_exited(&mut self, target_id: &str) { - self.mutate_target_owner_state_by_target_id(target_id, |owner_state| { - owner_state.mark_initial_empty_document_exited(); - }); - } - - fn mutate_target_owner_state_by_target_id( - &mut self, - target_id: &str, - mutate: impl FnOnce(&mut ParkedTargetOwnerState) -> T, - ) -> Option { - if self.is_active_target(target_id) { - return Some(mutate(&mut self.active_target.owner_state)); - } - self.background_target(target_id)?; - Some(self.mutate_parked_target_owner_state(target_id, mutate)) } fn take_parked_target_owner_state(&mut self, target_id: &str) -> ParkedTargetOwnerState { @@ -1172,6 +1162,7 @@ impl BrowserContext { } } + #[cfg(test)] pub(crate) async fn restore_active_target_aux_state_async( &mut self, state: ParkedTargetAuxState, @@ -1199,15 +1190,28 @@ impl BrowserContext { self.restore_target_owner_state(state.target_owner_state); } + #[cfg(test)] pub(crate) async fn apply_promoted_background_target_state_async( &mut self, promoted: &mut BackgroundTarget, state: ParkedTargetAuxState, synchronize_loaded_page: bool, ) -> Result<(), String> { - let has_pending_dialog = state.has_pending_javascript_dialog(); - self.set_active_target_id(promoted.target_id()); - self.replace_active_session(promoted.session_id().map(str::to_owned)); + let has_pending_dialog = self + .devtools_session_states_for_target(promoted.target_id()) + .is_some_and(|(primary, auxiliary)| { + !primary + .page_session_state + .javascript_dialog_state + .is_empty() + || auxiliary.values().any(|session| { + !session + .page_session_state + .javascript_dialog_state + .is_empty() + }) + }); + self.set_active_target_handle(promoted.target_handle().clone()); self.replace_target_identity(promoted.target_identity().clone()); self.active_target.session_storage_namespace = promoted.take_session_storage_namespace(); if has_pending_dialog || !synchronize_loaded_page { @@ -1218,10 +1222,19 @@ impl BrowserContext { } } + fn apply_promoted_background_target_state_without_loaded_page_sync( + &mut self, + promoted: &mut BackgroundTarget, + state: ParkedTargetAuxState, + ) { + self.set_active_target_handle(promoted.target_handle().clone()); + self.replace_target_identity(promoted.target_identity().clone()); + self.active_target.session_storage_namespace = promoted.take_session_storage_namespace(); + self.restore_active_target_aux_state_without_loaded_page_sync(state); + } + fn snapshot_page_session_state(&self) -> ParkedPageSessionState { ParkedPageSessionState { - devtools_session_state: self.devtools_session_state.clone(), - auxiliary_devtools_session_states: self.auxiliary_devtools_session_states.clone(), network_enabled: self .active_target .runtime_slot @@ -1246,11 +1259,18 @@ impl BrowserContext { } } + #[cfg(test)] async fn restore_page_session_state_async( &mut self, state: ParkedPageSessionState, ) -> Result<(), String> { self.restore_page_session_state_without_loaded_page_sync(state); + self.synchronize_page_session_state_to_loaded_page_async() + .await + } + + #[cfg(test)] + async fn synchronize_page_session_state_to_loaded_page_async(&mut self) -> Result<(), String> { let effective_headers = self.effective_extra_headers(); let network_offline = self.network_policy.network_offline(); let bypass_service_worker = self.network_policy.bypass_service_worker(); @@ -1261,7 +1281,7 @@ impl BrowserContext { .active_target .fetch_owner .subresource_interception_config(); - if let Some(page) = self.active_target.runtime_slot.loaded_page_mut() { + if let Some(mut page) = self.active_target.runtime_slot.loaded_page_mut() { page.set_extra_http_headers_async(&effective_headers) .await .map_err(|error| format!("failed to restore page extra headers: {error}"))?; @@ -1299,8 +1319,6 @@ impl BrowserContext { &mut self, state: ParkedPageSessionState, ) { - self.devtools_session_state = state.devtools_session_state; - self.auxiliary_devtools_session_states = state.auxiliary_devtools_session_states; self.active_target .runtime_slot .set_primary_network_events_enabled(state.network_enabled); @@ -1420,7 +1438,9 @@ impl BrowserContext { } let target = self.background_target(target_id)?; - let attached = target.has_session() + let attached = self + .primary_session_id_for_target(target.target_id()) + .is_some() || !self .auxiliary_session_ids_for_target(target.target_id()) .is_empty(); @@ -1470,6 +1490,12 @@ impl BrowserContext { .iter() .filter_map(|target| self.devtools_target_info(target.target_id())), ); + infos.extend(self.devtools_worker_target_infos()); + infos + } + + pub(crate) fn devtools_worker_target_infos(&self) -> Vec { + let mut infos = Vec::new(); infos.extend( self.shared_worker_targets .values() @@ -1609,19 +1635,19 @@ impl BrowserContext { if expected.browser_context_id() != self.id { return false; } - let current_attachment = match expected.target_id() { + let current_residence = match expected.target_id() { Some(target_id) if self.is_active_target(target_id) => { - self.active_target.runtime_slot.page_attachment_id() + Some(self.active_target.runtime_slot.page_residence_handle()) } Some(target_id) => self .background_target(target_id) - .and_then(|target| target.runtime_slot.page_attachment_id()), + .map(|target| target.runtime_slot.page_residence_handle()), None if self.active_target_id().is_none() => { - self.active_target.runtime_slot.page_attachment_id() + Some(self.active_target.runtime_slot.page_residence_handle()) } None => None, }; - current_attachment == Some(expected.page_attachment_id()) + current_residence.is_some_and(|residence| residence.is_current(expected)) } pub(crate) fn dedicated_worker_target_id_for_renderer_instance( @@ -2120,7 +2146,7 @@ mod tests { } #[test] - fn background_target_slot_take_and_replace_moves_aux_state_together() { + fn background_target_slot_take_and_replace_leaves_frontend_state_in_exact_registry() { let mut context = BrowserContext::new("BC-1".to_owned()); context.stage_background_target( "TID-bg".to_owned(), @@ -2137,19 +2163,11 @@ mod tests { grant_universal_access: true, }], ); - context.replace_parked_page_session_state( - "TID-bg".to_owned(), - ParkedPageSessionState { - devtools_session_state: DevToolsSessionState { - runtime_session_state: TargetRuntimeSessionState { - runtime_frontend_enabled: true, - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }, - ); + context + .primary_devtools_session_state_for_target_mut("TID-bg") + .expect("background Target frontend state") + .runtime_session_state + .runtime_frontend_enabled = true; let slot = context .take_background_target_slot("TID-bg") @@ -2161,7 +2179,7 @@ mod tests { context.take_parked_document_start_script_counter("TID-bg"), 0 ); - assert_eq!(slot.target().target_id, "TID-bg"); + assert_eq!(slot.target().target_id(), "TID-bg"); assert_eq!( slot.aux_state() .target_owner_state @@ -2174,11 +2192,12 @@ mod tests { "utility" ); assert!( - slot.aux_state() - .page_session_state - .devtools_session_state + context + .primary_devtools_session_state_for_exact_target(slot.target().target_handle()) + .expect("staged exact Target frontend state") .runtime_session_state - .runtime_frontend_enabled + .runtime_frontend_enabled, + "staging the physical slot must not move frontend state into its aux payload" ); context.replace_background_target_slot(slot); @@ -2187,11 +2206,8 @@ mod tests { assert_eq!(context.background_targets[0].target_id(), "TID-bg"); assert!( context - .parked_page_session_state("TID-bg") - .is_some_and(|state| state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled) + .primary_devtools_session_state_for_target("TID-bg") + .is_some_and(|state| state.runtime_session_state.runtime_frontend_enabled) ); assert_eq!( context.take_parked_document_start_script_counter("TID-bg"), @@ -2254,11 +2270,11 @@ mod tests { .runtime_slot .set_page_attachment_id_for_test(42); context - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; context - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; context @@ -2292,8 +2308,11 @@ mod tests { .take_active_target_slot_state() .expect("active target slot should be present"); - assert_eq!(slot.target().target_id, "TID-active"); - assert_eq!(slot.target().session_id(), Some("SID-active")); + assert_eq!(slot.target().target_id(), "TID-active"); + assert_eq!( + context.primary_session_id_for_exact_target(slot.target().target_handle()), + Some("SID-active") + ); assert_eq!(slot.target().target_url(), "https://active.test/"); assert_eq!( slot.target().target_identity().security_origin(), @@ -2316,17 +2335,16 @@ mod tests { .len(), 1 ); + let staged_frontend_state = context + .primary_devtools_session_state_for_exact_target(slot.target().target_handle()) + .expect("staged exact Target frontend state"); assert!( - slot.aux_state() - .page_session_state - .devtools_session_state + staged_frontend_state .runtime_session_state .runtime_frontend_enabled ); assert!( - slot.aux_state() - .page_session_state - .devtools_session_state + staged_frontend_state .runtime_session_state .inspector_enabled ); @@ -2361,38 +2379,38 @@ mod tests { context.attach_active_session("SID-demote".to_owned()); context.set_target_url(active_page.final_url().as_str().to_owned()); context - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; context - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; context - .devtools_session_state + .devtools_session_state_mut() .console_output_session_state .console_enabled = true; context - .devtools_session_state + .devtools_session_state_mut() .page_session_state .log_enabled = true; assert!( context - .devtools_session_state + .devtools_session_state_mut() .page_session_state .performance .enable(PerformanceTimeDomain::ThreadTicks) ); context - .devtools_session_state + .devtools_session_state_mut() .page_session_state .page_lifecycle_events = true; context - .devtools_session_state + .devtools_session_state_mut() .page_session_state .page_file_chooser_opened_event_enabled = true; context - .devtools_session_state + .devtools_session_state_mut() .page_session_state .page_intercept_file_chooser_dialog_enabled = true; context @@ -2447,7 +2465,10 @@ mod tests { assert_eq!(context.background_targets.len(), 1); let parked = &context.background_targets[0]; assert_eq!(parked.target_id(), "TID-demote"); - assert_eq!(parked.session_id(), Some("SID-demote")); + assert_eq!( + context.primary_session_id_for_target("TID-demote"), + Some("SID-demote") + ); assert_eq!( parked.target_url(), "data:text/html,demote-active" @@ -2477,55 +2498,44 @@ mod tests { ); assert!( context - .parked_page_session_state("TID-demote") - .is_some_and(|state| state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled), - "session-scoped Runtime.enable state should be parked with the target" - ); - let parked_page_session_state = context - .parked_page_session_state("TID-demote") - .expect("demoted target should retain parked page session state"); + .primary_devtools_session_state_for_target("TID-demote") + .is_some_and(|state| state.runtime_session_state.runtime_frontend_enabled), + "session-scoped Runtime.enable state should remain keyed by the exact Target" + ); + let frontend_session_state = context + .primary_devtools_session_state_for_target("TID-demote") + .expect("demoted target should retain exact frontend state"); assert!( - parked_page_session_state - .devtools_session_state + frontend_session_state .runtime_session_state .runtime_frontend_enabled, - "Runtime.enable state should move with the demoted target" + "Runtime.enable state should remain with the exact Target" ); assert!( - parked_page_session_state - .devtools_session_state + frontend_session_state .runtime_session_state .inspector_enabled, - "Inspector.enable state should move with the demoted target" + "Inspector.enable state should remain with the exact Target" ); assert!( - parked_page_session_state - .devtools_session_state + frontend_session_state .console_output_session_state .console_enabled, - "Console.enable state should move with the demoted target" + "Console.enable state should remain with the exact Target" ); assert!( - parked_page_session_state - .devtools_session_state - .page_session_state - .log_enabled, - "Log.enable state should move with the demoted target" + frontend_session_state.page_session_state.log_enabled, + "Log.enable state should remain with the exact Target" ); assert!( - parked_page_session_state - .devtools_session_state + frontend_session_state .page_session_state .performance .enabled(), "Performance.enable state should move with the demoted target" ); assert_eq!( - parked_page_session_state - .devtools_session_state + frontend_session_state .page_session_state .performance .time_domain(), @@ -2533,22 +2543,19 @@ mod tests { "Performance time domain should move with the demoted target" ); assert!( - parked_page_session_state - .devtools_session_state + frontend_session_state .page_session_state .page_lifecycle_events, "Page lifecycle listener state should move with the demoted target" ); assert!( - parked_page_session_state - .devtools_session_state + frontend_session_state .page_session_state .page_file_chooser_opened_event_enabled, "file chooser opened listener state should move with the demoted target" ); assert!( - parked_page_session_state - .devtools_session_state + frontend_session_state .page_session_state .page_intercept_file_chooser_dialog_enabled, "file chooser interception state should move with the demoted target" @@ -2611,7 +2618,7 @@ mod tests { assert_eq!(context.background_targets.len(), 1); assert_eq!(context.background_targets[0].target_id(), "TID-existing-bg"); assert_eq!( - context.background_targets[0].session_id(), + context.primary_session_id_for_target("TID-existing-bg"), Some("SID-existing-bg") ); } @@ -2621,7 +2628,7 @@ mod tests { let mut context = BrowserContext::new("BC-demote-pending".to_owned()); context.set_active_target_id("TID-old-active"); context.set_target_url("about:blank#old".to_owned()); - context.begin_active_target_initial_empty_document("about:blank#old".to_owned()); + context.mark_active_initial_document_page_build_pending(); context.stage_active_target_demoting_current( "TID-new-active".to_owned(), @@ -2774,6 +2781,14 @@ mod tests { None, None, ); + assert!(context.assign_auxiliary_session_to_target( + "TID-active-route", + "SID-active-route-aux".to_owned() + )); + assert!(context.assign_auxiliary_session_to_target( + "TID-background-route", + "SID-background-route-aux".to_owned() + )); context .background_target_mut("TID-background-route") .expect("background target") @@ -2810,6 +2825,19 @@ mod tests { .map(|attachment| attachment.id()), Some(active_attachment.id()) ); + assert_eq!(context.active_session_id(), Some("SID-background-route")); + assert_eq!( + context.primary_session_id_for_target("TID-active-route"), + Some("SID-active-route") + ); + assert_eq!( + context.auxiliary_target_id_for_session("SID-background-route-aux"), + Some("TID-background-route") + ); + assert_eq!( + context.auxiliary_target_id_for_session("SID-active-route-aux"), + Some("TID-active-route") + ); } #[tokio::test] @@ -2846,13 +2874,9 @@ mod tests { devtools_session_state .console_output_session_state .console_enabled = true; - context.replace_parked_page_session_state( - "TID-bg".to_owned(), - ParkedPageSessionState { - devtools_session_state, - ..Default::default() - }, - ); + *context + .primary_devtools_session_state_for_target_mut("TID-bg") + .expect("background Target frontend state") = devtools_session_state; let mut network_artifacts = ParkedNetworkArtifacts::default(); network_artifacts.set_session_observation_cursor_at_counts(None, 4, 5); context.replace_parked_network_artifacts("TID-bg".to_owned(), network_artifacts); @@ -2867,13 +2891,13 @@ mod tests { assert_eq!(context.active_target_id(), Some("TID-bg")); assert!( context - .devtools_session_state + .devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); assert!( context - .devtools_session_state + .devtools_session_state() .runtime_session_state .inspector_enabled ); @@ -2895,32 +2919,32 @@ mod tests { ); assert!( context - .devtools_session_state + .devtools_session_state() .page_session_state .page_lifecycle_events ); assert!( context - .devtools_session_state + .devtools_session_state() .page_session_state .log_enabled ); assert!( context - .devtools_session_state + .devtools_session_state() .console_output_session_state .console_enabled ); assert!( context - .devtools_session_state + .devtools_session_state() .page_session_state .performance .enabled() ); assert_eq!( context - .devtools_session_state + .devtools_session_state() .page_session_state .performance .time_domain(), @@ -2928,13 +2952,13 @@ mod tests { ); assert!( context - .devtools_session_state + .devtools_session_state() .page_session_state .page_file_chooser_opened_event_enabled ); assert!( context - .devtools_session_state + .devtools_session_state() .page_session_state .page_intercept_file_chooser_dialog_enabled ); diff --git a/moli-protocol/src/conn/page_state/surfaces.rs b/moli-protocol/src/conn/page_state/surfaces.rs index 8b89d29643..0110c43dea 100644 --- a/moli-protocol/src/conn/page_state/surfaces.rs +++ b/moli-protocol/src/conn/page_state/surfaces.rs @@ -1,7 +1,7 @@ use super::super::cookie_manager_surface::BrowserContextCookieManagerSurfaceSnapshot; use super::super::{ BrowserContext, DocumentStartScript, EmulatedDeviceMetrics, EmulatedGeolocationOverrideState, - EmulatedNetworkConditions, EmulatedViewportSurface, ParkedPageSessionState, TargetRuntimeSlot, + EmulatedNetworkConditions, EmulatedViewportSurface, ParkedPageSessionState, viewport_surface_install_script, }; #[cfg(test)] @@ -117,9 +117,9 @@ impl BrowserContext { if !mutate(&mut self.document_cookie_manager_surface) { return false; } - if let Some(page) = self.active_target.runtime_slot.loaded_page_mut() { + if let Some(mut page) = self.active_target.runtime_slot.loaded_page_mut() { self.document_cookie_manager_surface - .apply_to_page_async(page) + .apply_to_page_async(&mut page) .await; } true @@ -131,15 +131,16 @@ impl BrowserContext { self.document_cookie_manager_surface.snapshot() } + #[cfg(test)] pub(crate) async fn restore_raw_cookie_manager_surface_async( &mut self, snapshot: BrowserContextCookieManagerSurfaceSnapshot, ) { self.restore_raw_cookie_manager_surface_without_loaded_page_sync(snapshot); #[cfg(test)] - if let Some(page) = self.active_target.runtime_slot.loaded_page_mut() { + if let Some(mut page) = self.active_target.runtime_slot.loaded_page_mut() { self.document_cookie_manager_surface - .apply_to_page_async(page) + .apply_to_page_async(&mut page) .await; } } @@ -154,6 +155,17 @@ impl BrowserContext { ); } + #[cfg(test)] + pub(in crate::conn) async fn synchronize_raw_cookie_manager_surface_to_loaded_page_async( + &mut self, + ) { + if let Some(mut page) = self.active_target.runtime_slot.loaded_page_mut() { + self.document_cookie_manager_surface + .apply_to_page_async(&mut page) + .await; + } + } + pub fn document_start_script_descriptors(&self) -> Vec { let mut scripts = Vec::new(); if let Some(script) = self.generated_surface_override_script() { @@ -358,13 +370,6 @@ impl BrowserContext { default_state = ParkedPageSessionState::default(); &default_state }; - self.generated_surface_override_script_for_parked_state(state) - } - - fn generated_surface_override_script_for_parked_state( - &self, - state: &ParkedPageSessionState, - ) -> Option { Self::generated_surface_override_script_from_inputs(&SurfaceOverrideInputs::from_parked( state, self.default_network_conditions @@ -376,31 +381,24 @@ impl BrowserContext { )) } - pub(crate) async fn apply_parked_surface_overrides_to_loaded_page_async( - &self, - runtime_slot: &mut TargetRuntimeSlot, - state: &ParkedPageSessionState, - ) -> Result<(), String> { - let Some(script) = self.generated_surface_override_script_for_parked_state(state) else { - return Ok(()); - }; - let Some(page) = runtime_slot.loaded_page_mut() else { - return Ok(()); - }; - page.run_page_surface_override_script_async(&script.source) - .await - .map_err(|error| format!("failed to hide demoted page surface: {error}")) - } - pub(crate) async fn apply_parked_target_surface_overrides_async( &mut self, target_id: &str, ) -> Result { + // A modal JavaScript dialog blocks script execution in its renderer. + // Target selection itself must still complete (WebDriver and CDP both + // allow switching away while the dialog remains owned by this Page), + // so do not synchronously inject the hidden-surface shim into a + // prompted target. Its parked state remains authoritative and the + // active surface is synchronized if the target is selected again. + if self.has_pending_javascript_dialog() { + return Ok(false); + } let Some(script) = self.generated_surface_override_script_for_parked_target(target_id) else { return Ok(false); }; - let Some(page) = self + let Some(mut page) = self .background_target_mut(target_id) .and_then(|target| target.runtime_slot.loaded_page_mut()) else { @@ -622,7 +620,7 @@ impl BrowserContext { let Some(script) = self.generated_surface_override_script() else { return Ok(()); }; - let Some(page) = self.active_target.runtime_slot.loaded_page_mut() else { + let Some(mut page) = self.active_target.runtime_slot.loaded_page_mut() else { return Ok(()); }; page.run_page_surface_override_script_async(&script.source) diff --git a/moli-protocol/src/conn/popup_activation_work.rs b/moli-protocol/src/conn/popup_activation_work.rs index 09382ff896..2bd44f04a1 100644 --- a/moli-protocol/src/conn/popup_activation_work.rs +++ b/moli-protocol/src/conn/popup_activation_work.rs @@ -1,17 +1,28 @@ -use super::{CdpConnection, CdpSessionRoute, CommandOwnerScope}; +use moli_core::browser_host::{ + BrowserAuxiliaryNavigationKind, BrowserContextHandle, BrowserTargetHandle, + PageResidenceIdentity, +}; -/// Foreground activation for one already-created auxiliary target. +use super::{CdpConnection, CdpSchedulerEvent}; + +/// Delayed foreground selection for one exact auxiliary Target instance. /// -/// Target creation and navigation have separate lifetimes. In particular, a -/// target waiting for `Runtime.runIfWaitingForDebugger` must still become the -/// selected target immediately. This move-only action freezes the exact -/// target identity while allowing that target to move between background and -/// active residence before the action completes. +/// Creation is projected before the opener command response. Activation can +/// replace the opener renderer, so the scheduler retains this capability +/// until the ordinary client-turn predecessor has completed. Stable Core +/// handles prevent a retired Context or Target id from being retargeted. #[derive(Debug)] pub(crate) struct PopupTargetActivationAction { - owner_scope: CommandOwnerScope, - browser_context_id: String, - target_id: String, + browser_context: BrowserContextHandle, + target: BrowserTargetHandle, + navigation: Option, +} + +#[derive(Debug)] +struct PopupTargetActivationNavigation { + page_owner: PageResidenceIdentity, + url: String, + kind: BrowserAuxiliaryNavigationKind, } impl PopupTargetActivationAction { @@ -20,29 +31,79 @@ impl PopupTargetActivationAction { browser_context_id: &str, target_id: &str, ) -> Option { - let route = conn.target_session_route_for_target_id(target_id)?; - (route.browser_context_id() == Some(browser_context_id)).then(|| Self { - 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(), + let browser_context = conn.browser_context_by_id(browser_context_id)?; + let browser_context_handle = browser_context.browser_context_handle().clone(); + let target_handle = browser_context.top_level_target_handle(target_id)?.clone(); + let browser_host_state = conn.browser_host_state(); + let owner = browser_host_state.navigation_owner(); + let context_selected = owner.selected_browser_context_id() == Some(browser_context_id); + let context_current = owner.browser_context_handle_is_current(&browser_context_handle); + let target_current = owner.target_handle_is_current(&target_handle); + (context_selected && context_current && target_current).then_some(Self { + browser_context: browser_context_handle, + target: target_handle, + navigation: None, }) } + pub(crate) fn capture_after_navigation( + conn: &CdpConnection, + browser_context_id: &str, + target_id: &str, + url: String, + kind: BrowserAuxiliaryNavigationKind, + ) -> Option { + let mut action = Self::capture(conn, browser_context_id, target_id)?; + let page_owner = conn + .browser_host_state() + .navigation_owner() + .capture_page_residence(browser_context_id, target_id)?; + action.navigation = Some(PopupTargetActivationNavigation { + page_owner, + url, + kind, + }); + Some(action) + } + pub(crate) fn browser_context_id(&self) -> &str { - &self.browser_context_id + self.browser_context.browser_context_id() } pub(crate) fn target_id(&self) -> &str { - &self.target_id + self.target.target_id() + } + + pub(crate) fn into_browser_owner_input(self) -> moli_core::browser_host::BrowserOwnerInput { + match self.navigation { + Some(navigation) => moli_core::browser_host::BrowserOwnerInput::renderer_auxiliary_navigation_and_target_activation( + navigation.page_owner, + navigation.url, + navigation.kind, + self.browser_context, + self.target, + ), + None => moli_core::browser_host::BrowserOwnerInput::renderer_auxiliary_target_activation( + self.browser_context, + self.target, + ), + } } +} - pub(crate) fn into_parts(self) -> (CommandOwnerScope, String, String) { - (self.owner_scope, self.browser_context_id, self.target_id) +impl CdpConnection { + pub(crate) fn publish_popup_target_activation_action( + &mut self, + action: PopupTargetActivationAction, + ) { + let publish_sequence = self + .scheduler_state + .allocate_protocol_work_publish_sequence(); + let work = crate::domains::activity::ProtocolSchedulerWork::popup_target_activation_action( + publish_sequence, + action, + ); + self.scheduler_state + .push_scheduler_event(CdpSchedulerEvent::ProtocolWorkPublished { work }); } } diff --git a/moli-protocol/src/conn/popup_navigation_work.rs b/moli-protocol/src/conn/popup_navigation_work.rs deleted file mode 100644 index 0f6fe283a7..0000000000 --- a/moli-protocol/src/conn/popup_navigation_work.rs +++ /dev/null @@ -1,90 +0,0 @@ -use super::{CdpConnection, CdpSessionRoute, CommandOwnerScope}; - -/// 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, - 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, - }) - } - - pub(crate) fn browser_context_id(&self) -> &str { - &self.browser_context_id - } - - pub(crate) fn target_id(&self) -> &str { - &self.target_id - } - - pub(crate) fn url(&self) -> &str { - &self.url - } - - 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, - ) - } -} diff --git a/moli-protocol/src/conn/protocol_output.rs b/moli-protocol/src/conn/protocol_output.rs index 83ad78797e..8497a2ede4 100644 --- a/moli-protocol/src/conn/protocol_output.rs +++ b/moli-protocol/src/conn/protocol_output.rs @@ -67,16 +67,14 @@ impl CdpConnection { } } - pub(crate) fn bind_renderer_page_output_owner( + pub(crate) fn bind_renderer_page_output_target( &mut self, renderer_page: super::RendererPageResidenceIdentity, - page_owner: super::TargetPageResidenceIdentity, + page_owner: moli_core::browser_host::BrowserPageOwnerKey, ) { let owner = crate::domains::activity::RendererPublicationOwner::PageTarget { - browser_context_id: page_owner.browser_context_id().to_owned(), - target_id: page_owner.target_id().map(str::to_owned), - renderer_page, page_owner, + renderer_page, }; self.scheduler_state.renderer_output_ingress.bind_owner( moli_core::RendererOutputResidenceIdentity::Page { @@ -167,7 +165,7 @@ impl CdpConnection { Vec::new(), ) } - ReadyProtocolSchedulerWork::MainDocumentLoadOwnerAction(completion) => { + ReadyProtocolSchedulerWork::MainDocumentLoadFactProjection(completion) => { self.complete_deferred_main_document_load_completion_for_scheduler( super::CompletedDeferredMainDocumentLoadCompletion::new(*completion), ) @@ -185,40 +183,10 @@ impl CdpConnection { self.take_scheduler_events(), ) } - ReadyProtocolSchedulerWork::TopLevelLocationNavigationOwnerAction(action) => { - let (owner_scope, page_owner, navigation) = action.into_parts(); - let session_id = owner_scope.session_id().map(str::to_owned); - let mut route_scope = owner_scope.enter(self); - let mut protocol_events = Vec::new(); - crate::domains::page::navigate_page_owned_top_level_location_background_events_async( - route_scope.conn_mut(), - &mut protocol_events, - session_id.as_deref(), - &page_owner, - navigation, - ) - .await; - CdpTurnOutcome::new_with_protocol_events( - protocol_events, - route_scope.conn_mut().take_scheduler_events(), - ) - } - ReadyProtocolSchedulerWork::PopupTargetNavigationOwnerAction(action) => { - crate::domains::target::complete_popup_target_navigation_owner_action_async( - self, action, - ) - .await - } ReadyProtocolSchedulerWork::PopupTargetActivationAction(action) => { crate::domains::target::complete_popup_target_activation_action_async(self, action) .await } - ReadyProtocolSchedulerWork::PageTargetTerminationOwnerAction(action) => { - crate::domains::page::complete_page_target_termination_owner_action_async( - self, action, - ) - .await - } } } diff --git a/moli-protocol/src/conn/renderer_command_turn.rs b/moli-protocol/src/conn/renderer_command_turn.rs index 62fe50ab16..c195c1223b 100644 --- a/moli-protocol/src/conn/renderer_command_turn.rs +++ b/moli-protocol/src/conn/renderer_command_turn.rs @@ -22,7 +22,7 @@ impl CdpConnection { return completion.into_output(); } - if let Ok(page) = self.loaded_page_mut_for_interruptible_protocol_access(session_id) { + if let Ok(mut page) = self.loaded_page_mut_for_interruptible_protocol_access(session_id) { return page.finish_page_command_turn(completion); } diff --git a/moli-protocol/src/conn/resource_runtime_support.rs b/moli-protocol/src/conn/resource_runtime_support.rs index 81cd5c15c6..9b97bd1456 100644 --- a/moli-protocol/src/conn/resource_runtime_support.rs +++ b/moli-protocol/src/conn/resource_runtime_support.rs @@ -10,11 +10,8 @@ use super::{ impl CdpConnection { pub(crate) fn invalidate_resource_runtime(&mut self) { - self.engine.reset_resource_runtime_without_loaded_page(); - } - - pub(crate) async fn invalidate_resource_runtime_async(&mut self) { - self.engine.reset_resource_runtime_async(None).await; + self.browser_host_state + .reset_active_resource_runtime_without_loaded_page(); } pub(crate) fn resource_storage_handles(&self) -> BrowserContextResourceStorageHandles { @@ -29,11 +26,12 @@ impl CdpConnection { ) -> Result { self.apply_active_engine_fetch_overrides(); let storage = self.resource_storage_handles(); - self.engine - .ensure_resource_runtime_ready_for_navigation_storage(storage.into_navigation_storage()) + self.browser_host_state + .ensure_active_resource_runtime_ready(storage.into_navigation_storage()) .map_err(|error| format!("failed to initialize resource runtime: {error}"))?; - self.engine - .resource_request_client() + self.browser_host_state + .navigation_owner() + .active_resource_request_client() .ok_or_else(|| "resource request client unavailable".to_owned()) } @@ -62,11 +60,12 @@ impl CdpConnection { } self.apply_navigation_load_input_engine_fetch_overrides(load_inputs); let storage = load_inputs.resource_storage_handles(); - self.engine - .ensure_resource_runtime_ready_for_navigation_storage(storage.into_navigation_storage()) + self.browser_host_state + .ensure_active_resource_runtime_ready(storage.into_navigation_storage()) .map_err(|error| format!("failed to initialize resource runtime: {error}"))?; - self.engine - .resource_request_client() + self.browser_host_state + .navigation_owner() + .active_resource_request_client() .ok_or_else(|| "resource request client unavailable".to_owned()) } @@ -76,17 +75,17 @@ impl CdpConnection { ) -> bool { let Some(browser_context_id) = load_inputs.browser_context_id.as_deref() else { return self - .engine - .browser_context_runtime() - .shares_state_with(&load_inputs.renderer_runtime.runtime()); + .browser_host_state + .navigation_owner() + .active_runtime_shares_state_with(&load_inputs.renderer_runtime); }; self.browser_context.as_ref().is_some_and(|context| { context.id == browser_context_id && context.active_target_id() == load_inputs.root_frame_id.as_deref() && self - .engine - .browser_context_runtime() - .shares_state_with(&load_inputs.renderer_runtime.runtime()) + .browser_host_state + .navigation_owner() + .active_runtime_shares_state_with(&load_inputs.renderer_runtime) }) } @@ -94,57 +93,62 @@ impl CdpConnection { &mut self, load_inputs: &TargetNavigationLoadInputs, ) { + let policy = self.browser_host_network_policy_snapshot(); let browser_identity = load_inputs .browser_identity_override .clone() - .or_else(|| self.global_browser_identity_override.clone()) - .unwrap_or_else(|| self.base_browser_identity.clone()); + .or_else(|| policy.global_browser_identity_override().cloned()) + .unwrap_or_else(|| policy.base_browser_identity().clone()); let http_proxy = load_inputs .http_proxy_override .clone() - .or_else(|| self.base_http_proxy.clone()); + .or_else(|| policy.base_http_proxy().map(str::to_owned)); let http_no_proxy = load_inputs .http_no_proxy_override .clone() - .or_else(|| self.base_http_no_proxy.clone()); + .or_else(|| policy.base_http_no_proxy().map(str::to_owned)); let tls_verify_host = load_inputs .tls_verify_host_override - .unwrap_or(self.base_tls_verify_host); - self.engine.set_browser_identity_override(browser_identity); - self.engine.set_http_proxy_override(http_proxy); - self.engine.set_http_no_proxy_override(http_no_proxy); - self.engine.set_tls_verify_host(tls_verify_host); - self.engine - .set_bypass_service_worker(load_inputs.bypass_service_worker); + .unwrap_or(policy.base_tls_verify_host()); + self.browser_host_state.configure_active_fetch( + moli_core::browser_host::BrowserPageFetchConfiguration { + browser_identity, + http_proxy, + http_no_proxy, + tls_verify_host, + bypass_service_worker: load_inputs.bypass_service_worker, + }, + ); } pub(crate) fn build_registered_browser_resource_runtime_for_navigation_load_inputs( &self, load_inputs: &TargetNavigationLoadInputs, ) -> Result { + let policy = self.browser_host_network_policy_snapshot(); let mut fetch_config = self.fetch_config().clone(); let browser_identity = load_inputs .browser_identity_override .clone() - .or_else(|| self.global_browser_identity_override.clone()) - .unwrap_or_else(|| self.base_browser_identity.clone()); + .or_else(|| policy.global_browser_identity_override().cloned()) + .unwrap_or_else(|| policy.base_browser_identity().clone()); fetch_config.set_browser_identity(browser_identity); fetch_config.set_http_proxy( load_inputs .http_proxy_override .clone() - .or_else(|| self.base_http_proxy.clone()), + .or_else(|| policy.base_http_proxy().map(str::to_owned)), ); fetch_config.set_http_no_proxy( load_inputs .http_no_proxy_override .clone() - .or_else(|| self.base_http_no_proxy.clone()), + .or_else(|| policy.base_http_no_proxy().map(str::to_owned)), ); fetch_config.set_tls_verify_host( load_inputs .tls_verify_host_override - .unwrap_or(self.base_tls_verify_host), + .unwrap_or(policy.base_tls_verify_host()), ); let storage = load_inputs.resource_storage_handles(); load_inputs @@ -162,40 +166,62 @@ impl CdpConnection { ) -> Result { self.apply_active_engine_fetch_overrides(); let storage = self.resource_storage_handles(); - self.engine - .ensure_cookie_store_for_navigation_storage(storage.into_navigation_storage()) + self.browser_host_state + .ensure_active_cookie_store(storage.into_navigation_storage()) .map_err(|error| format!("failed to initialize loader: {error}")) } #[cfg(test)] pub(crate) async fn reset_resource_runtime_async(&mut self) { - let page = self - .browser_context - .as_mut() - .and_then(|bc| bc.active_target.runtime_slot.loaded_page_mut()); - self.engine.reset_resource_runtime_async(page).await; + let mut engine = self + .browser_host_state + .navigation_owner() + .clone_active_navigation_engine(); + { + let mut page = self + .browser_context + .as_mut() + .and_then(|bc| bc.active_target.runtime_slot.loaded_page_mut()); + engine + .reset_resource_runtime_async(page.as_deref_mut()) + .await; + } + self.adopt_navigation_engine_for_current_owner(engine) + .expect("test resource-runtime reset must preserve the current engine owner"); } pub(crate) async fn rebuild_resource_runtime_for_loaded_page_async(&mut self) { let storage = self.resource_storage_handles(); + let mut engine = self + .browser_host_state + .navigation_owner() + .clone_active_navigation_engine(); let rebuild_result = { - let page = self + let mut page = self .browser_context .as_mut() .and_then(|bc| bc.active_target.runtime_slot.loaded_page_mut()); - self.engine + engine .rebuild_resource_runtime_for_page_with_storage_async( storage.into_navigation_storage(), - page, + page.as_deref_mut(), ) .await }; if rebuild_result.is_err() { - let page = self + let mut page = self .browser_context .as_mut() .and_then(|bc| bc.active_target.runtime_slot.loaded_page_mut()); - self.engine.reset_resource_runtime_async(page).await; + engine + .reset_resource_runtime_async(page.as_deref_mut()) + .await; + } + if let Err(error) = self.adopt_navigation_engine_for_current_owner(engine) { + tracing::warn!( + %error, + "resource-runtime rebuild engine adoption rejected by Browser Owner" + ); } } @@ -207,10 +233,8 @@ impl CdpConnection { let storage = load_inputs.resource_storage_handles(); let request_client = if self.navigation_load_inputs_use_primary_engine(&load_inputs) { self.apply_navigation_load_input_engine_fetch_overrides(&load_inputs); - self.engine - .rebuild_resource_request_client_for_navigation_storage( - storage.into_navigation_storage(), - ) + self.browser_host_state + .rebuild_active_resource_request_client(storage.into_navigation_storage()) .map_err(|error| format!("failed to rebuild resource runtime: {error}"))? } else { let mut engine = self.background_navigation_engine_for_load_inputs(&load_inputs); @@ -242,7 +266,7 @@ impl CdpConnection { session_id: Option<&str>, completion: CompletedPageCommand, ) -> Result<(), String> { - let Some(page) = self.resource_runtime_apply_page_for_session_owner(session_id) else { + let Some(mut page) = self.resource_runtime_apply_page_for_session_owner(session_id) else { return Ok(()); }; page.finish_replace_browser_resource_runtime(completion) @@ -252,7 +276,7 @@ impl CdpConnection { fn resource_runtime_apply_page_for_session_owner( &mut self, session_id: Option<&str>, - ) -> Option<&mut moli_core::page::Page> { + ) -> Option { if matches!( self.session_route(session_id), Some(super::CdpSessionRoute::Browser) diff --git a/moli-protocol/src/conn/runtime_eval.rs b/moli-protocol/src/conn/runtime_eval.rs index a6a8b33e12..48b935b261 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::{HashMap, VecDeque, hash_map::Entry}, + future::Future, + pin::Pin, +}; use moli_page_types::{FrontendCommandId, RendererCallId, RendererInspectorResponseDelivery}; use moli_protocol_cdp::CdpRendererCommandReplayDispatch; @@ -23,8 +27,7 @@ use moli_core::{ use crate::conn::state::{ DevToolsSessionState, PreparedRendererCallTermination, SessionRendererCallReplay, - SessionRendererCallTermination, devtools_sessions_have_pending_inspector_awaits, - drain_pending_inspector_awaits_for_devtools_sessions, + SessionRendererCallTermination, drain_pending_inspector_awaits_for_devtools_sessions, }; use crate::domains::command_output::protocol_message_background_event; use crate::domains::runtime_context_events::{ @@ -35,6 +38,12 @@ use crate::domains::runtime_context_events::{ use super::*; +mod normalization; +pub(crate) use normalization::{ + CompletedRuntimeProtocolMessageNormalization, PendingRuntimeProtocolMessageNormalization, + RuntimeProtocolMessageCompletionStep, +}; + type RuntimeInspectorResponseReceiver = RuntimeInspectorAsyncCompletionReceiver; const SHARED_WORKER_RUNTIME_REMOTE_OBJECT_CLEANUP_COMMAND_ID_BASE: u64 = 900_600_000; @@ -230,22 +239,19 @@ enum BidiChannelListenerRoute { Event(BackgroundProtocolEvent), } -fn unregister_runtime_remote_object_group_from_parked_page_session_state( - page_session_state: &mut ParkedPageSessionState, +fn unregister_runtime_remote_object_group_from_top_level_session_states( + primary: &mut DevToolsSessionState, + auxiliary: &mut HashMap, session_id: Option<&str>, object_group: &str, ) { if let Some(session_id) = session_id - && let Some(state) = page_session_state - .auxiliary_devtools_session_states - .get_mut(session_id) + && let Some(state) = auxiliary.get_mut(session_id) { state.unregister_runtime_remote_object_group(object_group); return; } - page_session_state - .devtools_session_state - .unregister_runtime_remote_object_group(object_group); + primary.unregister_runtime_remote_object_group(object_group); } #[derive(Debug)] @@ -454,6 +460,94 @@ pub struct CompletedRuntimeProtocolMessageDispatch { response_route: RuntimeProtocolResponseRoute, } +/// One exact-Page renderer wait needed to start or clean up a BiDi channel +/// listener. +/// +/// The pending Page dispatch (or deferred Inspector response) is owned by this +/// value. Waiting therefore never borrows [`CdpConnection`]; the captured Page +/// owner is revalidated only when the completion is applied. +pub(crate) struct PendingBidiChannelOwnerAction { + owner: BidiChannelPageOwner, + command_id: u64, + action: PendingBidiChannelOwnerActionKind, + wait: PendingBidiChannelOwnerActionWait, +} + +enum PendingBidiChannelOwnerActionWait { + Page(PendingRuntimeProtocolMessageDispatch), + Normalize { + pending: Box, + renderer_response_rx: Option, + }, + DeferredResponse(RuntimeInspectorResponseReceiver), +} + +pub(crate) struct CompletedBidiChannelOwnerAction { + owner: BidiChannelPageOwner, + command_id: u64, + action: PendingBidiChannelOwnerActionKind, + completion: CompletedBidiChannelOwnerActionWait, +} + +enum CompletedBidiChannelOwnerActionWait { + Page(Result), + Normalize { + completed: Box, + renderer_response_rx: Option, + }, + DeferredResponse(RuntimeInspectorResponseReady), +} + +#[derive(Debug)] +enum PendingBidiChannelOwnerActionKind { + StartListener { object_group: String }, + ReleaseObjectGroup { object_group: String }, +} + +pub(crate) enum BidiChannelOwnerActionStep { + Pending(Box), + Complete(Vec), +} + +/// One move-owned renderer Inspector replay wait after a Document replacement. +/// +/// The pending Page dispatch and the remainder of the exact replay batch live +/// here instead of behind `&mut CdpConnection`. Browser Host can therefore +/// keep servicing owner inputs while the replacement renderer executes this +/// command. +pub(crate) struct PendingRendererCallReplayBatch { + pending: PendingRendererCallReplayDispatch, + frontend_session_id: Option, + correlation: RendererCommandCorrelation, + response_delivery: RendererInspectorResponseDelivery, + response_sender: RendererRuntimeInspectorResponseSender, + remaining: VecDeque, + new_attachment_id: RendererAgentAttachmentId, +} + +/// Move-owned completion of one renderer Inspector replay dispatch. +pub(crate) struct CompletedRendererCallReplayBatch { + completed: CompletedRendererCallReplayDispatch, + frontend_session_id: Option, + correlation: RendererCommandCorrelation, + response_delivery: RendererInspectorResponseDelivery, + response_sender: RendererRuntimeInspectorResponseSender, + remaining: VecDeque, + new_attachment_id: RendererAgentAttachmentId, +} + +enum PendingRendererCallReplayDispatch { + Inspector(PendingRuntimeProtocolMessageDispatch), + PerformanceGetMetrics(moli_core::page::PendingDevToolsIoCommandDispatch), + SetScriptExecutionDisabled(moli_core::page::PendingDevToolsIoCommandDispatch), +} + +enum CompletedRendererCallReplayDispatch { + Inspector(Result), + PerformanceGetMetrics(Result), + SetScriptExecutionDisabled(Result), +} + pub struct CompletedSharedWorkerRuntimeProtocolMessageDispatch { session_id: Option, messages: Vec, @@ -544,6 +638,7 @@ struct RuntimeProtocolMessagePageRoute { browser_context_id: String, target_id: Option, renderer_agent_attachment_id: RendererAgentAttachmentId, + inspector_session_id: Option, } fn collect_moli_diagnostics_pending_snapshots( @@ -609,6 +704,69 @@ impl PendingRuntimeProtocolMessageDispatch { } } +impl PendingBidiChannelOwnerAction { + pub(crate) async fn wait(self) -> CompletedBidiChannelOwnerAction { + let completion = match self.wait { + PendingBidiChannelOwnerActionWait::Page(pending) => { + CompletedBidiChannelOwnerActionWait::Page(pending.wait().await) + } + PendingBidiChannelOwnerActionWait::Normalize { + pending, + renderer_response_rx, + } => CompletedBidiChannelOwnerActionWait::Normalize { + completed: Box::new((*pending).wait().await), + renderer_response_rx, + }, + PendingBidiChannelOwnerActionWait::DeferredResponse(response_rx) => { + CompletedBidiChannelOwnerActionWait::DeferredResponse( + RuntimeInspectorResponseReady::new( + self.command_id, + self.owner.session_id(), + response_rx + .await + .map_err(|_| "RuntimeInspectorResponseCanceled".to_owned()), + ), + ) + } + }; + CompletedBidiChannelOwnerAction { + owner: self.owner, + command_id: self.command_id, + action: self.action, + completion, + } + } +} + +impl PendingRendererCallReplayBatch { + pub(crate) async fn wait(self) -> CompletedRendererCallReplayBatch { + let completed = match self.pending { + PendingRendererCallReplayDispatch::Inspector(pending) => { + CompletedRendererCallReplayDispatch::Inspector(pending.wait().await) + } + PendingRendererCallReplayDispatch::PerformanceGetMetrics(pending) => { + CompletedRendererCallReplayDispatch::PerformanceGetMetrics( + pending.wait().await.map_err(|error| error.to_string()), + ) + } + PendingRendererCallReplayDispatch::SetScriptExecutionDisabled(pending) => { + CompletedRendererCallReplayDispatch::SetScriptExecutionDisabled( + pending.wait().await.map_err(|error| error.to_string()), + ) + } + }; + CompletedRendererCallReplayBatch { + completed, + frontend_session_id: self.frontend_session_id, + correlation: self.correlation, + response_delivery: self.response_delivery, + response_sender: self.response_sender, + remaining: self.remaining, + new_attachment_id: self.new_attachment_id, + } + } +} + impl PendingSharedWorkerRuntimeProtocolMessageDispatch { pub async fn wait(self) -> Result { let messages = self.pending.await?; @@ -1627,12 +1785,7 @@ impl CdpConnection { return true; } self.browser_contexts().any(|browser_context| { - devtools_sessions_have_pending_inspector_awaits( - &browser_context.devtools_session_state, - &browser_context.auxiliary_devtools_session_states, - ) || browser_context - .target_parking - .has_pending_inspector_awaits() + browser_context.page_target_pending_inspector_await_count_for_diagnostics() > 0 || browser_context .shared_worker_targets .values() @@ -1670,21 +1823,21 @@ impl CdpConnection { .is_some_and(DevToolsSessionState::has_pending_inspector_awaits) } - pub(crate) fn fail_pending_inspector_awaits_from_page_session_state_for_sessions_background_events_into( + pub(crate) fn fail_pending_inspector_awaits_from_top_level_session_states_for_sessions_background_events_into( out: &mut Vec, - page_session_state: &mut ParkedPageSessionState, + primary: &mut DevToolsSessionState, + auxiliary: &mut HashMap, primary_session_id: Option<&str>, session_ids: &[&str], reason: &'static str, ) { - for (cdp_id, entry) in drain_pending_inspector_awaits_for_devtools_sessions( - &mut page_session_state.devtools_session_state, - &mut page_session_state.auxiliary_devtools_session_states, - session_ids, - ) { + for (cdp_id, entry) in + drain_pending_inspector_awaits_for_devtools_sessions(primary, auxiliary, session_ids) + { if let Some(listener) = entry.bidi_channel_listener() { - unregister_runtime_remote_object_group_from_parked_page_session_state( - page_session_state, + unregister_runtime_remote_object_group_from_top_level_session_states( + primary, + auxiliary, entry.session_id(), listener.channel_object_group(), ); @@ -1700,9 +1853,7 @@ impl CdpConnection { if let Some(primary_session_id) = primary_session_id && session_ids.contains(&primary_session_id) { - let terminated = page_session_state - .devtools_session_state - .terminate_all_renderer_calls(reason); + let terminated = primary.terminate_all_renderer_calls(reason); push_terminated_renderer_call_error_background_events( out, terminated, @@ -1711,10 +1862,7 @@ impl CdpConnection { ); } for session_id in session_ids { - let Some(state) = page_session_state - .auxiliary_devtools_session_states - .get_mut(*session_id) - else { + let Some(state) = auxiliary.get_mut(*session_id) else { continue; }; let terminated = state.terminate_all_renderer_calls(reason); @@ -2420,79 +2568,33 @@ impl CdpConnection { object_id: &str, ) -> bool { for browser_context in self.browser_contexts() { - let active_is_current_owner = matches!( - owner, + let current_page_owner = match owner { RuntimeRemoteObjectOwnerIdentity::Page { browser_context_id, target_id, - .. - } if browser_context_id == &browser_context.id - && target_id + devtools_session_id, + } if browser_context_id == &browser_context.id => Some(( + target_id .as_deref() - .is_none_or(|target_id| browser_context.is_active_target(target_id)) - ); - if active_is_current_owner { - let current_devtools_session_id = match owner { - RuntimeRemoteObjectOwnerIdentity::Page { - devtools_session_id, - .. - } => devtools_session_id.as_deref(), - RuntimeRemoteObjectOwnerIdentity::SharedWorker { .. } - | RuntimeRemoteObjectOwnerIdentity::DedicatedWorker { .. } - | RuntimeRemoteObjectOwnerIdentity::ServiceWorker { .. } => None, - }; - if current_devtools_session_id.is_some() - && browser_context - .devtools_session_state - .has_runtime_remote_object_id(object_id) - { + .or_else(|| browser_context.active_target_id()), + devtools_session_id.as_deref(), + )), + _ => None, + }; + if let Some((Some(target_id), devtools_session_id)) = current_page_owner { + if browser_context.runtime_remote_object_id_known_for_different_top_level_owner( + target_id, + devtools_session_id, + object_id, + ) { return true; } - for (session_id, state) in &browser_context.auxiliary_devtools_session_states { - if Some(session_id.as_str()) != current_devtools_session_id - && state.has_runtime_remote_object_id(object_id) - { - return true; - } - } - } else if browser_context - .devtools_session_state - .has_runtime_remote_object_id(object_id) - || browser_context - .auxiliary_devtools_session_states - .values() - .any(|state| state.has_runtime_remote_object_id(object_id)) + } else if current_page_owner.is_none() + && browser_context.has_runtime_remote_object_id_in_top_level_target(object_id) { return true; } - let parked_has_different_owner = match owner { - RuntimeRemoteObjectOwnerIdentity::Page { - browser_context_id, - target_id, - devtools_session_id, - } if browser_context_id == &browser_context.id => match target_id.as_deref() { - Some(target_id) if !browser_context.is_active_target(target_id) => { - browser_context - .target_parking - .runtime_remote_object_id_known_for_different_page_owner( - target_id, - devtools_session_id.as_deref(), - object_id, - ) - } - Some(_) | None => browser_context - .target_parking - .has_runtime_remote_object_id(object_id), - }, - _ => browser_context - .target_parking - .has_runtime_remote_object_id(object_id), - }; - if parked_has_different_owner { - return true; - } - for target in browser_context.shared_worker_targets.values() { let shared_worker_is_current_owner = matches!( owner, @@ -2741,6 +2843,14 @@ impl CdpConnection { page_owner_access_allowed: bool, ordered_events: &mut Vec, ) -> bool { + if !page_owner_access_allowed { + return self.route_normalized_renderer_runtime_command_output_into( + output, + current_cmd_id, + current_session_id, + ordered_events, + ); + } if let Some(attachment_id) = output.renderer_agent_attachment_id() && !self.renderer_agent_attachment_is_current_for_session_owner( current_session_id, @@ -2765,9 +2875,7 @@ impl CdpConnection { // Pause-loop responses are already concrete V8 protocol output. // Context and DOM-node normalization can enqueue Page commands, // so preserve the raw response until that owner can run again. - if page_owner_access_allowed - && let RendererRuntimeInspectorMessage::Protocol(message) = &mut message - { + if let RendererRuntimeInspectorMessage::Protocol(message) = &mut message { let mut message_value = message.value_mut(); self.normalize_runtime_event_context_ids_for_session_owner_async( current_session_id, @@ -2796,7 +2904,53 @@ impl CdpConnection { current_seen } - pub(crate) async fn route_renderer_command_turn_output_into( + /// Routes Runtime output whose exact-Page normalization has already been + /// completed by a move-owned participant. + pub(crate) fn route_normalized_renderer_runtime_command_output_into( + &mut self, + output: RendererRuntimeCommandOutput, + current_cmd_id: Option, + current_session_id: Option<&str>, + ordered_events: &mut Vec, + ) -> bool { + if let Some(attachment_id) = output.renderer_agent_attachment_id() + && !self.renderer_agent_attachment_is_current_for_session_owner( + current_session_id, + attachment_id, + ) + { + tracing::debug!( + ?attachment_id, + session_id = current_session_id, + "dropping normalized renderer command output from a stale attachment" + ); + return false; + } + if output.renderer_agent_attachment_id().is_some() + && let Some(state) = output.v8_state_update().cloned() + { + let _ = + self.merge_v8_inspector_session_state_for_session_owner(current_session_id, state); + } + let mut current_seen = false; + for message in output.into_messages() { + let mut response_events = Vec::new(); + let mut background_events = Vec::new(); + current_seen |= self + .route_renderer_runtime_inspector_messages_with_background_events_into( + vec![message], + current_cmd_id, + current_session_id, + &mut response_events, + &mut background_events, + ); + ordered_events.extend(response_events); + ordered_events.extend(background_events); + } + current_seen + } + + pub(crate) fn route_normalized_renderer_command_turn_output_into( &mut self, output: RendererCommandTurnOutput, current_cmd_id: Option, @@ -2815,13 +2969,12 @@ impl CdpConnection { return (false, renderer_output_predecessor); }; ( - self.route_renderer_runtime_command_output_into( + self.route_normalized_renderer_runtime_command_output_into( output, current_cmd_id, current_session_id, ordered_events, - ) - .await, + ), renderer_output_predecessor, ) } @@ -3406,31 +3559,15 @@ impl CdpConnection { .await; } - async fn normalize_node_remote_objects_for_runtime_protocol_message_route_async( - &mut self, - session_id: Option<&str>, - route: &RuntimeProtocolMessagePageRoute, - payload: &mut Value, - ) { - let inspector_session_id = - self.target_renderer_runtime_inspector_session_id_for_session(session_id); - let include_whitespace = - crate::domains::dom::dom_agent_includes_whitespace_for_session(self, session_id); - self.normalize_node_remote_objects_by_page_lookup_async( - inspector_session_id.as_deref(), - include_whitespace, - payload, - |connection| connection.runtime_protocol_message_started_page_mut(route), - ) - .await; - } - async fn normalize_node_remote_objects_by_page_lookup_async( &mut self, inspector_session_id: Option<&str>, include_whitespace: bool, payload: &mut Value, - mut page_lookup: impl for<'a> FnMut(&'a mut Self) -> Result<&'a mut Page, String>, + mut page_lookup: impl FnMut( + &mut Self, + ) + -> Result, ) { let mut paths = Vec::new(); collect_remote_object_paths(payload, "", &mut paths); @@ -3469,7 +3606,7 @@ impl CdpConnection { let is_node = match pending { Ok(pending) => match pending.wait().await { Ok(completion) => page_lookup(self) - .and_then(|page| { + .and_then(|mut page| { page.finish_document_node_snapshot_for_object_id(completion) .map_err(|error| error.to_string()) }) @@ -3545,7 +3682,7 @@ impl CdpConnection { .wait() .await .map_err(|error| format!("resolve runtime node snapshot failed: {error}"))?; - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.finish_document_node_snapshot_for_object_id(completion) .map_err(|error| format!("resolve runtime node snapshot failed: {error}")) } @@ -3566,7 +3703,7 @@ impl CdpConnection { .wait() .await .map_err(|error| format!("resolve backend node snapshot failed: {error}"))?; - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.finish_document_node_snapshot_for_backend_node_id(completion) .map_err(|error| format!("resolve backend node snapshot failed: {error}")) } @@ -3592,7 +3729,7 @@ impl CdpConnection { .wait() .await .map_err(|error| format!("register BiDi node binding failed: {error}"))?; - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.finish_register_document_bidi_node_binding(completion) .map_err(|error| format!("register BiDi node binding failed: {error}")) } @@ -3613,7 +3750,7 @@ impl CdpConnection { .wait() .await .map_err(|error| format!("resolve BiDi node binding failed: {error}"))?; - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.finish_document_bidi_node_binding(completion) .map_err(|error| format!("resolve BiDi node binding failed: {error}")) } @@ -3637,7 +3774,7 @@ impl CdpConnection { .wait() .await .map_err(|error| format!("resolve BiDi node shared id failed: {error}"))?; - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.finish_document_bidi_node_shared_id_for_backend_node_id(completion) .map_err(|error| format!("resolve BiDi node shared id failed: {error}")) } @@ -3665,7 +3802,7 @@ impl CdpConnection { .wait() .await .map_err(|error| format!("resolve runtime object for backend node failed: {error}"))?; - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; let result = page .finish_resolve_runtime_object_for_backend_node_id(completion) .map_err(|error| format!("resolve runtime object for backend node failed: {error}"))?; @@ -3728,7 +3865,7 @@ impl CdpConnection { await_promise: bool, ) -> Result { let payload = { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.evaluate_runtime_expression_without_navigation_follow_with_await_async( expression, await_promise, @@ -3772,7 +3909,7 @@ impl CdpConnection { completed: CompletedRuntimeEnableEventsDispatch, ) -> Result { let session_id = completed.session_id.as_deref(); - let page = self.runtime_protocol_message_started_page_mut(&completed.route)?; + let mut page = self.runtime_protocol_message_started_page_mut(&completed.route)?; let output = page .finish_runtime_enable_output(completed.completion) .map_err(|error| format!("runtime enable event replay failed: {error}"))?; @@ -3810,14 +3947,14 @@ impl CdpConnection { fn runtime_session_owner_page_mut( &mut self, session_id: Option<&str>, - ) -> Result<&mut Page, String> { + ) -> Result { self.loaded_page_mut_for_protocol_access(session_id) } fn runtime_session_owner_page_mut_for_interruptible_control( &mut self, session_id: Option<&str>, - ) -> Result<&mut Page, String> { + ) -> Result { self.loaded_page_mut_for_interruptible_protocol_access(session_id) } @@ -3837,6 +3974,8 @@ impl CdpConnection { browser_context_id, target_id, renderer_agent_attachment_id, + inspector_session_id: self + .target_renderer_runtime_inspector_session_id_for_session(session_id), }) } @@ -3872,7 +4011,7 @@ impl CdpConnection { fn runtime_protocol_message_started_page_mut( &mut self, route: &RuntimeProtocolMessagePageRoute, - ) -> Result<&mut Page, String> { + ) -> Result { self.runtime_protocol_message_started_slot_mut(route)? .loaded_page_mut() .ok_or_else(|| "NoDocumentLoaded".to_owned()) @@ -3883,7 +4022,7 @@ impl CdpConnection { route: &RuntimeProtocolMessagePageRoute, completion: moli_core::page::CompletedPageCommand, ) -> Result { - let output = if let Ok(page) = self.runtime_protocol_message_started_page_mut(route) { + let output = if let Ok(mut page) = self.runtime_protocol_message_started_page_mut(route) { page.finish_runtime_protocol_message_command_turn(completion) } else { // Completion means the renderer owner has already committed the @@ -4276,7 +4415,7 @@ impl CdpConnection { let mut failed_page_snapshot_count = 0; for completed in completed.completed { - let Some(page) = self + let Some(mut page) = self .browser_context_by_id_mut(&completed.browser_context_id) .and_then(|browser_context| { if let Some(target_id) = completed.target_id.as_deref() { @@ -4407,7 +4546,7 @@ impl CdpConnection { let inspector_session_id = self.target_renderer_runtime_inspector_session_id_for_session(session_id); let mut messages = { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.dispatch_runtime_protocol_message_for_inspector_session_async( inspector_session_id, raw_json, @@ -4541,6 +4680,9 @@ impl CdpConnection { raw_json: String, inspector_route: RendererInspectorCommandRoute, ) -> Result { + if inspector_route == RendererInspectorCommandRoute::MainThread { + self.ensure_document_accessible_for_session_owner(session_id)?; + } let route = self.runtime_protocol_message_page_route_for_session_owner(session_id)?; let raw_json = self.rewrite_runtime_inspector_command_for_session_owner(session_id, &raw_json, None)?; @@ -4609,6 +4751,9 @@ impl CdpConnection { command_id: u64, inspector_route: RendererInspectorCommandRoute, ) -> Result { + if inspector_route == RendererInspectorCommandRoute::MainThread { + self.ensure_document_accessible_for_session_owner(session_id)?; + } let route = self.runtime_protocol_message_page_route_for_session_owner(session_id)?; let (correlation, raw_json, response_sender, response_route) = self .prepare_renderer_call_for_session_owner( @@ -4665,6 +4810,7 @@ impl CdpConnection { action: &str, raw_json: String, ) -> Result { + self.ensure_document_accessible_for_session_owner(session_id)?; let route = self.runtime_protocol_message_page_route_for_session_owner(session_id)?; let raw_json = self.rewrite_runtime_inspector_command_for_session_owner(session_id, &raw_json, None)?; @@ -4735,97 +4881,66 @@ impl CdpConnection { }) } + /// Test-fixture drain for callers which do not run the production command + /// participant loop. Production paths use the start/complete pair below. + #[cfg(test)] pub(crate) async fn complete_runtime_protocol_message_for_session_owner_async( &mut self, completed: CompletedRuntimeProtocolMessageDispatch, ) -> Result, String> { - let timing_started = moli_trace::cdp_nav_timing_enabled().then(std::time::Instant::now); - let completion = match completed.completion { - moli_core::page::CompletedRuntimeInspectorCommandDispatch::Owner(completion) => { - *completion - } - moli_core::page::CompletedRuntimeInspectorCommandDispatch::OwnerSessionResponse { - completion, - .. - } => *completion, - moli_core::page::CompletedRuntimeInspectorCommandDispatch::Inspector - | moli_core::page::CompletedRuntimeInspectorCommandDispatch::InspectorSessionResponse { - .. - } - | moli_core::page::CompletedRuntimeInspectorCommandDispatch::OwnerSessionErrorSettled( - _, - ) => { - return Ok(None); + let mut step = self.start_runtime_protocol_message_completion(completed); + loop { + match step { + RuntimeProtocolMessageCompletionStep::Pending(pending) => { + step = self + .complete_runtime_protocol_message_normalization((*pending).wait().await); + } + RuntimeProtocolMessageCompletionStep::Complete(result) => return *result, } - }; - let mut output = - self.consume_runtime_protocol_message_completion(&completed.route, completion)?; - if let Some(started) = timing_started { - tracing::info!( - target: "moli_cdp_nav_timing", - stage = "runtime_inspector_page_dispatch_done", - output_messages = output - .runtime_inspector_output() - .map_or(0, |messages| messages.len()), - elapsed_ms = started.elapsed().as_millis(), - ); - } - self.ingest_runtime_protocol_message_started_route_output_updates(&completed.route); - if let Some(started) = timing_started { - tracing::info!( - target: "moli_cdp_nav_timing", - stage = "runtime_inspector_output_ingested", - elapsed_ms = started.elapsed().as_millis(), - ); - } - let runtime_messages = output.runtime_inspector_output_mut().ok_or_else(|| { - "runtime inspector dispatch completed with a non-Runtime renderer reply".to_owned() - })?; - runtime_messages - .bind_renderer_agent_attachment(completed.route.renderer_agent_attachment_id); - let runtime_messages = runtime_messages.messages_mut(); - for runtime_message in runtime_messages.iter_mut() { - let RendererRuntimeInspectorMessage::Protocol(message) = runtime_message else { - continue; - }; - let mut message_value = message.value_mut(); - self.normalize_runtime_event_context_ids_for_session_owner_async( - completed.session_id.as_deref(), - &mut message_value, - ) - .await; - self.normalize_node_remote_objects_for_runtime_protocol_message_route_async( - completed.session_id.as_deref(), - &completed.route, - &mut message_value, - ) - .await; - } - self.restore_frontend_command_ids_in_runtime_messages( - completed.session_id.as_deref(), - Some(completed.route.renderer_agent_attachment_id), - runtime_messages, - ); - if let Some(started) = timing_started { - tracing::info!( - target: "moli_cdp_nav_timing", - stage = "runtime_inspector_command_output_ready", - output_messages = output - .runtime_inspector_output() - .map_or(0, |messages| messages.len()), - elapsed_ms = started.elapsed().as_millis(), - ); } - Ok(Some(output)) } - pub(crate) async fn replay_prepared_renderer_calls_after_navigation_async( + pub(crate) fn start_runtime_protocol_message_completion( + &mut self, + completed: CompletedRuntimeProtocolMessageDispatch, + ) -> RuntimeProtocolMessageCompletionStep { + normalization::start_runtime_protocol_message_completion(self, completed) + } + + pub(crate) fn complete_runtime_protocol_message_normalization( + &mut self, + completed: CompletedRuntimeProtocolMessageNormalization, + ) -> RuntimeProtocolMessageCompletionStep { + normalization::complete_runtime_protocol_message_normalization(self, completed) + } + + /// Starts the first viable command in an exact renderer replay batch. + /// + /// Invalid or already-stale entries receive their terminal frontend error + /// synchronously. A returned participant owns the Page dispatch future and + /// every not-yet-started replay, so waiting for it never borrows this + /// connection. + pub(crate) fn start_prepared_renderer_calls_after_navigation( &mut self, replays: Vec, new_attachment_id: RendererAgentAttachmentId, - ) -> Result, String> { + ) -> ( + Vec, + Option, + ) { + self.start_next_prepared_renderer_call_after_navigation(replays.into(), new_attachment_id) + } + + fn start_next_prepared_renderer_call_after_navigation( + &mut self, + mut remaining: VecDeque, + new_attachment_id: RendererAgentAttachmentId, + ) -> ( + Vec, + Option, + ) { let mut events = Vec::new(); - for replay in replays { + while let Some(replay) = remaining.pop_front() { let frontend_session_id = replay.frontend_session_id().map(str::to_owned); let renderer_inspector_session_id = replay.renderer_inspector_session_id().map(str::to_owned); @@ -4879,17 +4994,24 @@ impl CdpConnection { ) .map_err(|error| error.to_string()) }); - let completion = match pending { - Ok(pending) => pending.wait().await.map_err(|error| error.to_string()), - Err(error) => Err(error), - }; - match completion { - Ok( - moli_core::page::CompletedDevToolsIoCommandDispatch::SessionResponse { - .. - }, - ) - | Ok(moli_core::page::CompletedDevToolsIoCommandDispatch::Dispatched) => {} + match pending { + Ok(pending) => { + return ( + events, + Some(PendingRendererCallReplayBatch { + pending: + PendingRendererCallReplayDispatch::PerformanceGetMetrics( + pending, + ), + frontend_session_id, + correlation, + response_delivery, + response_sender, + remaining, + new_attachment_id, + }), + ); + } Err(error) => { self.settle_renderer_replacement_error( &mut events, @@ -4899,9 +5021,9 @@ impl CdpConnection { correlation, &format!("Performance replay dispatch failed: {error}"), ); + continue; } } - continue; } RendererCommandReplay::SetScriptExecutionDisabled { disabled } => { debug_assert_eq!( @@ -4919,17 +5041,23 @@ impl CdpConnection { ) .map_err(|error| error.to_string()) }); - let completion = match pending { - Ok(pending) => pending.wait().await.map_err(|error| error.to_string()), - Err(error) => Err(error), - }; - match completion { - Ok( - moli_core::page::CompletedDevToolsIoCommandDispatch::SessionResponse { - .. - }, - ) - | Ok(moli_core::page::CompletedDevToolsIoCommandDispatch::Dispatched) => {} + match pending { + Ok(pending) => { + return ( + events, + Some(PendingRendererCallReplayBatch { + pending: PendingRendererCallReplayDispatch::SetScriptExecutionDisabled( + pending, + ), + frontend_session_id, + correlation, + response_delivery, + response_sender, + remaining, + new_attachment_id, + }), + ); + } Err(error) => { self.settle_renderer_replacement_error( &mut events, @@ -4939,9 +5067,9 @@ impl CdpConnection { correlation, &format!("Emulation replay dispatch failed: {error}"), ); + continue; } } - continue; } }; let raw_json = match self.rewrite_runtime_inspector_command_for_session_owner( @@ -5039,8 +5167,45 @@ impl CdpConnection { continue; } }; - let completed = match pending.wait().await { - Ok(completed) => completed, + return ( + events, + Some(PendingRendererCallReplayBatch { + pending: PendingRendererCallReplayDispatch::Inspector(pending), + frontend_session_id, + correlation, + response_delivery, + response_sender, + remaining, + new_attachment_id, + }), + ); + } + (events, None) + } + + /// Applies one completed replay and starts the next exact replay, if any. + /// + /// Output normalization still runs in this apply turn; only the renderer + /// Inspector dispatch wait is detached by this slice. + pub(crate) async fn complete_prepared_renderer_call_after_navigation( + &mut self, + completed: CompletedRendererCallReplayBatch, + ) -> ( + Vec, + Option, + ) { + let CompletedRendererCallReplayBatch { + completed, + frontend_session_id, + correlation, + response_delivery, + response_sender, + remaining, + new_attachment_id, + } = completed; + let mut events = Vec::new(); + match completed { + CompletedRendererCallReplayDispatch::Inspector(completed) => match completed { Err(error) => { self.settle_renderer_replacement_error( &mut events, @@ -5050,75 +5215,134 @@ impl CdpConnection { correlation, &error, ); - continue; - } - }; - let completion = match completed.completion { - moli_core::page::CompletedRuntimeInspectorCommandDispatch::Owner(completion) => { - *completion - } - moli_core::page::CompletedRuntimeInspectorCommandDispatch::Inspector => { - continue; - } - moli_core::page::CompletedRuntimeInspectorCommandDispatch::InspectorSessionResponse { - .. - } => { - continue; - } - moli_core::page::CompletedRuntimeInspectorCommandDispatch::OwnerSessionErrorSettled( - _, - ) => { - continue; } - moli_core::page::CompletedRuntimeInspectorCommandDispatch::OwnerSessionResponse { - completion, - .. - } => *completion, - }; - let mut command_turn_output = match self - .consume_runtime_protocol_message_completion(&completed.route, completion) - { - Ok(output) => output, - Err(error) => { - send_renderer_replacement_error( - &response_sender, - correlation, - &format!("runtime inspector replay dispatch failed: {error}"), - ); - continue; + Ok(completed) => { + let completion = match completed.completion { + moli_core::page::CompletedRuntimeInspectorCommandDispatch::Owner( + completion, + ) + | moli_core::page::CompletedRuntimeInspectorCommandDispatch::OwnerSessionResponse { + completion, + .. + } => Some(*completion), + moli_core::page::CompletedRuntimeInspectorCommandDispatch::Inspector + | moli_core::page::CompletedRuntimeInspectorCommandDispatch::InspectorSessionResponse { + .. + } + | moli_core::page::CompletedRuntimeInspectorCommandDispatch::OwnerSessionErrorSettled( + _, + ) => None, + }; + if let Some(completion) = completion { + match self.consume_runtime_protocol_message_completion( + &completed.route, + completion, + ) { + Err(error) => self.settle_renderer_replacement_error( + &mut events, + frontend_session_id.as_deref(), + response_delivery, + &response_sender, + correlation, + &format!("runtime inspector replay dispatch failed: {error}"), + ), + Ok(mut command_turn_output) => { + command_turn_output + .bind_renderer_agent_attachment(new_attachment_id); + self.ingest_runtime_protocol_message_started_route_output_updates( + &completed.route, + ); + let mut command = CommandDispatchContext::default(); + let completion = command + .consume_renderer_command_turn_output(command_turn_output); + events.extend(command.take_protocol_events()); + events.extend(command.take_post_response_events()); + match completion.into_runtime_inspector_output() { + None => self.settle_renderer_replacement_error( + &mut events, + frontend_session_id.as_deref(), + response_delivery, + &response_sender, + correlation, + "runtime inspector replay completed with a non-Runtime renderer reply", + ), + Some(output) + if output + .protocol_response( + correlation.renderer_call_id().get(), + ) + .is_some() => + { + let _ = response_sender.send_output(output); + } + Some(output) => { + let _ = self + .route_renderer_runtime_command_output_into( + output, + None, + completed.session_id.as_deref(), + &mut events, + ) + .await; + } + } + } + } + } } - }; - command_turn_output.bind_renderer_agent_attachment(new_attachment_id); - self.ingest_runtime_protocol_message_started_route_output_updates(&completed.route); - let mut command = CommandDispatchContext::default(); - let completion = command.consume_renderer_command_turn_output(command_turn_output); - events.extend(command.take_protocol_events()); - events.extend(command.take_post_response_events()); - let Some(output) = completion.into_runtime_inspector_output() else { - send_renderer_replacement_error( + }, + CompletedRendererCallReplayDispatch::PerformanceGetMetrics(completed) => { + self.settle_completed_io_renderer_replay( + &mut events, + frontend_session_id.as_deref(), + response_delivery, &response_sender, correlation, - "runtime inspector replay completed with a non-Runtime renderer reply", + completed, + "Performance", ); - continue; - }; - if output - .protocol_response(correlation.renderer_call_id().get()) - .is_some() - { - let _ = response_sender.send_output(output); - continue; } - let _ = self - .route_renderer_runtime_command_output_into( - output, - None, - frontend_session_id.as_deref(), + CompletedRendererCallReplayDispatch::SetScriptExecutionDisabled(completed) => { + self.settle_completed_io_renderer_replay( &mut events, - ) - .await; + frontend_session_id.as_deref(), + response_delivery, + &response_sender, + correlation, + completed, + "Emulation", + ); + } + } + let (start_events, pending) = + self.start_next_prepared_renderer_call_after_navigation(remaining, new_attachment_id); + events.extend(start_events); + (events, pending) + } + + #[allow(clippy::too_many_arguments)] + fn settle_completed_io_renderer_replay( + &mut self, + events: &mut Vec, + frontend_session_id: Option<&str>, + response_delivery: RendererInspectorResponseDelivery, + response_sender: &RendererRuntimeInspectorResponseSender, + correlation: RendererCommandCorrelation, + completed: Result, + action: &str, + ) { + match completed { + Ok(moli_core::page::CompletedDevToolsIoCommandDispatch::SessionResponse { .. }) + | Ok(moli_core::page::CompletedDevToolsIoCommandDispatch::Dispatched) => {} + Err(error) => self.settle_renderer_replacement_error( + events, + frontend_session_id, + response_delivery, + response_sender, + correlation, + &format!("{action} replay dispatch failed: {error}"), + ), } - Ok(events) } fn settle_renderer_replacement_error( @@ -5246,57 +5470,104 @@ impl CdpConnection { Ok(messages) } - /// Completes one concrete BiDi channel owner action under its frozen Page - /// route. - /// - /// Stale work is consumed without entering a replacement runtime. The old - /// Page or detached session owns any renderer-side cleanup; applying an - /// object-group release to the new attachment would be the more dangerous - /// outcome because group names belong to the producing runtime. - pub(crate) async fn complete_bidi_channel_owner_action_with_background_events_async( + /// Starts one concrete BiDi channel owner action under its frozen Page + /// route. Any renderer wait is returned as a move-owned participant. + pub(crate) fn start_bidi_channel_owner_action( &mut self, action: BidiChannelOwnerAction, - background_events: &mut Vec, - ) { + ) -> BidiChannelOwnerActionStep { let (owner, body) = action.into_parts(); - let mut route_scope = owner.enter(self); - if !owner.is_current(route_scope.conn_mut()) { - tracing::debug!( - session_id = owner.session_id(), - action = ?body, - "discarding stale BiDi channel owner action" - ); - return; + { + let mut route_scope = owner.enter(self); + if !owner.is_current(route_scope.conn_mut()) { + tracing::debug!( + session_id = owner.session_id(), + action = ?body, + "discarding stale BiDi channel owner action" + ); + return BidiChannelOwnerActionStep::Complete(Vec::new()); + } } - let session_id = owner.session_id().map(str::to_owned); match body { BidiChannelOwnerActionBody::StartListener(listener) => { - route_scope - .conn_mut() - .start_bidi_channel_listener_once_for_session_owner_with_background_events_async( - session_id.as_deref(), - BidiChannelListenerResidence::from_boxed(owner, listener), - background_events, - ) - .await; + self.start_bidi_channel_listener_owner_action(owner, listener) } BidiChannelOwnerActionBody::ReleaseObjectGroup(object_group) => { - route_scope - .conn_mut() - .release_bidi_channel_object_group_for_session_owner_best_effort_async( - session_id.as_deref(), - &object_group, - ) - .await; + self.start_bidi_channel_object_group_release_owner_action(owner, object_group) } } } - pub(crate) async fn release_bidi_channel_object_group_for_session_owner_best_effort_async( + fn start_bidi_channel_listener_owner_action( &mut self, - session_id: Option<&str>, - object_group: &str, - ) { + owner: BidiChannelPageOwner, + listener: Box, + ) -> BidiChannelOwnerActionStep { + let session_id = owner.session_id().map(str::to_owned); + let object_group = listener.channel_object_group().to_owned(); + let channel = listener.properties().channel.clone(); + let pending = { + let mut route_scope = owner.enter(self); + let conn = route_scope.conn_mut(); + if conn.runtime_inspector_response_ready_sender().is_none() { + None + } else { + let command_id = conn.next_internal_runtime_command_id(); + let raw_json = bidi_channel_listener_call_function_json(command_id, &listener); + match RendererCommandDescriptor::from_synthesized_payload(raw_json) { + Err(error) => { + tracing::debug!( + %error, + %channel, + "failed to prepare BiDi channel listener" + ); + None + } + Ok(descriptor) => { + match conn.start_runtime_protocol_message_with_context_resolution_for_session_owner_with_deferred_response( + session_id.as_deref(), + "callFunctionOn", + descriptor, + command_id, + ) { + Ok(pending) => { + conn.register_pending_bidi_channel_listener( + command_id, + session_id.as_deref(), + BidiChannelListenerResidence::from_boxed(owner.clone(), listener), + ); + Some((command_id, pending)) + } + Err(error) => { + tracing::debug!( + %error, + %channel, + "failed to start BiDi channel listener" + ); + None + } + } + } + } + } + }; + let Some((command_id, pending)) = pending else { + return self.start_bidi_channel_object_group_release_owner_action(owner, object_group); + }; + BidiChannelOwnerActionStep::Pending(Box::new(PendingBidiChannelOwnerAction { + owner, + command_id, + action: PendingBidiChannelOwnerActionKind::StartListener { object_group }, + wait: PendingBidiChannelOwnerActionWait::Page(pending), + })) + } + + fn start_bidi_channel_object_group_release_owner_action( + &mut self, + owner: BidiChannelPageOwner, + object_group: String, + ) -> BidiChannelOwnerActionStep { + let session_id = owner.session_id().map(str::to_owned); let command_id = self.next_internal_runtime_command_id(); let raw_json = json!({ "id": command_id, @@ -5306,301 +5577,396 @@ impl CdpConnection { .to_string(); let descriptor = match RendererCommandDescriptor::from_synthesized_payload(raw_json) { Ok(descriptor) => descriptor, - Err(error) => { - tracing::debug!(%error, object_group, "failed to prepare BiDi object group release"); - self.unregister_runtime_remote_object_group_for_session_owner( - session_id, - object_group, - ); - return; - } - }; - let pending = match self - .start_runtime_protocol_message_with_context_resolution_for_session_owner_with_deferred_response( - session_id, - "releaseObjectGroup", - descriptor, - command_id, - ) { - Ok(pending) => pending, Err(error) => { tracing::debug!( %error, - object_group, - "failed to start BiDi channel object group release" - ); - self.unregister_runtime_remote_object_group_for_session_owner( - session_id, - object_group, + %object_group, + "failed to prepare BiDi object group release" ); - return; + let mut route_scope = owner.enter(self); + route_scope + .conn_mut() + .unregister_runtime_remote_object_group_for_session_owner( + session_id.as_deref(), + &object_group, + ); + return BidiChannelOwnerActionStep::Complete(Vec::new()); } }; - let mut completed = match pending.wait().await { - Ok(completed) => completed, + let pending = { + let mut route_scope = owner.enter(self); + route_scope + .conn_mut() + .start_runtime_protocol_message_with_context_resolution_for_session_owner_with_deferred_response( + session_id.as_deref(), + "releaseObjectGroup", + descriptor, + command_id, + ) + }; + let pending = match pending { + Ok(pending) => pending, Err(error) => { - self.forget_pending_inspector_await(command_id, session_id); tracing::debug!( %error, - object_group, - "BiDi channel object group release dispatch failed" - ); - self.unregister_runtime_remote_object_group_for_session_owner( - session_id, - object_group, + %object_group, + "failed to start BiDi channel object group release" ); - return; + let mut route_scope = owner.enter(self); + route_scope + .conn_mut() + .unregister_runtime_remote_object_group_for_session_owner( + session_id.as_deref(), + &object_group, + ); + return BidiChannelOwnerActionStep::Complete(Vec::new()); } }; - let mut renderer_response_rx = completed.take_deferred_response_receiver(); - let messages = match self - .complete_runtime_protocol_message_for_session_owner_async(completed) - .await + BidiChannelOwnerActionStep::Pending(Box::new(PendingBidiChannelOwnerAction { + owner, + command_id, + action: PendingBidiChannelOwnerActionKind::ReleaseObjectGroup { object_group }, + wait: PendingBidiChannelOwnerActionWait::Page(pending), + })) + } + + /// Applies one completed BiDi channel participant and either starts its + /// next exact wait or returns the listener events made visible by it. + pub(crate) fn complete_bidi_channel_owner_action( + &mut self, + completed: CompletedBidiChannelOwnerAction, + ) -> BidiChannelOwnerActionStep { + let CompletedBidiChannelOwnerAction { + owner, + command_id, + action, + completion, + } = completed; + let session_id = owner.session_id().map(str::to_owned); { - Ok(messages) => messages, - Err(error) => { - self.forget_pending_inspector_await(command_id, session_id); + let mut route_scope = owner.enter(self); + if !owner.is_current(route_scope.conn_mut()) { tracing::debug!( - %error, - object_group, - "BiDi channel object group release completion failed" + session_id = owner.session_id(), + ?action, + command_id, + "discarding stale BiDi channel participant completion" ); - self.unregister_runtime_remote_object_group_for_session_owner( - session_id, - object_group, + return BidiChannelOwnerActionStep::Complete(Vec::new()); + } + } + match completion { + CompletedBidiChannelOwnerActionWait::Page(completed) => { + let mut completed = match completed { + Ok(completed) => completed, + Err(error) => { + return self.finish_failed_bidi_channel_owner_action( + owner, command_id, action, "dispatch", error, + ); + } + }; + let renderer_response_rx = completed.take_deferred_response_receiver(); + let step = { + let mut route_scope = owner.enter(self); + route_scope + .conn_mut() + .start_runtime_protocol_message_completion(completed) + }; + self.continue_bidi_channel_owner_action_normalization( + owner, + command_id, + action, + renderer_response_rx, + step, + ) + } + CompletedBidiChannelOwnerActionWait::Normalize { + completed, + renderer_response_rx, + } => { + let step = { + let mut route_scope = owner.enter(self); + route_scope + .conn_mut() + .complete_runtime_protocol_message_normalization(*completed) + }; + self.continue_bidi_channel_owner_action_normalization( + owner, + command_id, + action, + renderer_response_rx, + step, + ) + } + CompletedBidiChannelOwnerActionWait::DeferredResponse(response) => { + let PendingBidiChannelOwnerActionKind::ReleaseObjectGroup { object_group } = action + else { + tracing::warn!( + command_id, + "BiDi listener startup unexpectedly waited for its message response" + ); + return BidiChannelOwnerActionStep::Complete(Vec::new()); + }; + let mut release_events = Vec::new(); + let mut route_scope = owner.enter(self); + let conn = route_scope.conn_mut(); + if let Some(response) = conn.resolve_runtime_inspector_response_ready(response) { + let (_, output, renderer_output_predecessor) = + response.into_renderer_command_output(); + if renderer_output_predecessor.is_some() { + tracing::warn!( + command_id, + "discarding an unexpected object-group cleanup output predecessor" + ); + } + // This receiver belongs exclusively to the internal + // Runtime.releaseObjectGroup response. That method cannot + // carry execution-context ids or RemoteObjects, so the + // Page-dispatch participant above is the final output + // normalization boundary for this owner action. + if !conn.route_normalized_renderer_runtime_command_output_into( + output, + Some(command_id), + session_id.as_deref(), + &mut release_events, + ) { + tracing::debug!( + command_id, + "internal object group release deferred inspector response was not routed as current command" + ); + } + } + conn.unregister_runtime_remote_object_group_for_session_owner( + session_id.as_deref(), + &object_group, ); - return; + BidiChannelOwnerActionStep::Complete(Vec::new()) } - }; - let mut release_events = Vec::new(); - let mut release_post_response_events = Vec::new(); - let response_flush = CommandResponseFlushContext::default(); - let release_response_seen = if let Some(messages) = messages { - self.route_renderer_command_turn_output_into( - messages, - Some(command_id), - session_id, - &response_flush, - &mut release_events, - &mut release_post_response_events, - ) - .await - .0 + } + } + + fn continue_bidi_channel_owner_action_normalization( + &mut self, + owner: BidiChannelPageOwner, + command_id: u64, + action: PendingBidiChannelOwnerActionKind, + renderer_response_rx: Option, + step: RuntimeProtocolMessageCompletionStep, + ) -> BidiChannelOwnerActionStep { + match step { + RuntimeProtocolMessageCompletionStep::Pending(pending) => { + BidiChannelOwnerActionStep::Pending(Box::new(PendingBidiChannelOwnerAction { + owner, + command_id, + action, + wait: PendingBidiChannelOwnerActionWait::Normalize { + pending, + renderer_response_rx, + }, + })) + } + RuntimeProtocolMessageCompletionStep::Complete(result) => match *result { + Ok(output) => self.finish_bidi_channel_owner_action_output( + owner, + command_id, + action, + renderer_response_rx, + output, + ), + Err(error) => self.finish_failed_bidi_channel_owner_action( + owner, + command_id, + action, + "normalization", + error, + ), + }, + } + } + + fn finish_bidi_channel_owner_action_output( + &mut self, + owner: BidiChannelPageOwner, + command_id: u64, + action: PendingBidiChannelOwnerActionKind, + mut renderer_response_rx: Option, + output: Option, + ) -> BidiChannelOwnerActionStep { + let session_id = owner.session_id().map(str::to_owned); + let mut events = Vec::new(); + let mut post_response_events = Vec::new(); + let response_seen = if let Some(output) = output { + let response_flush = CommandResponseFlushContext::default(); + let mut route_scope = owner.enter(self); + route_scope + .conn_mut() + .route_normalized_renderer_command_turn_output_into( + output, + Some(command_id), + session_id.as_deref(), + &response_flush, + &mut events, + &mut post_response_events, + ) + .0 } else { false }; - if !release_response_seen { - tracing::debug!( - command_id, - "internal object group release inspector response was not routed as current command" - ); - } - if release_response_seen { + if response_seen { renderer_response_rx.take(); } - release_events.extend(release_post_response_events); - if let Some(renderer_response_rx) = renderer_response_rx { - let response = RuntimeInspectorResponseReady::new( - command_id, - session_id, - renderer_response_rx - .await - .map_err(|_| "RuntimeInspectorResponseCanceled".to_owned()), - ); - if let Some(response) = self.resolve_runtime_inspector_response_ready(response) { - let (_, output, renderer_output_predecessor) = - response.into_renderer_command_output(); - assert!( - renderer_output_predecessor.is_none(), - "internal object-group cleanup cannot discard a concrete output cursor" - ); - let release_response_seen = self - .route_renderer_runtime_command_output_into( - output, - Some(command_id), - session_id, - &mut release_events, - ) - .await; - if !release_response_seen { + events.extend(post_response_events); + match action { + PendingBidiChannelOwnerActionKind::StartListener { object_group } => { + let non_listener_response_count = events + .iter() + .filter(|event| event.protocol_message_id().is_some()) + .count(); + if non_listener_response_count > 0 { tracing::debug!( - command_id, - "internal object group release deferred inspector response was not routed as current command" + messages = non_listener_response_count, + "BiDi channel listener produced non-listener protocol messages on background route" ); } + let background_events = events + .into_iter() + .filter(|event| event.protocol_message_id().is_none()) + .collect(); + if let Some(renderer_response_rx) = renderer_response_rx { + let enqueued = { + let mut route_scope = owner.enter(self); + route_scope + .conn_mut() + .start_or_enqueue_registered_runtime_inspector_response_ready( + command_id, + session_id.as_deref(), + renderer_response_rx, + ) + }; + if !enqueued { + let mut route_scope = owner.enter(self); + let conn = route_scope.conn_mut(); + let _ = conn.remove_pending_inspector_await_for_cancellation( + command_id, + session_id.as_deref(), + ); + drop(route_scope); + tracing::debug!( + %object_group, + "BiDi channel listener started without scheduler runtime response hook" + ); + return self.start_bidi_channel_object_group_release_owner_action( + owner, + object_group, + ); + } + } + BidiChannelOwnerActionStep::Complete(background_events) + } + PendingBidiChannelOwnerActionKind::ReleaseObjectGroup { object_group } => { + if let Some(renderer_response_rx) = renderer_response_rx { + return BidiChannelOwnerActionStep::Pending(Box::new( + PendingBidiChannelOwnerAction { + owner, + command_id, + action: PendingBidiChannelOwnerActionKind::ReleaseObjectGroup { + object_group, + }, + wait: PendingBidiChannelOwnerActionWait::DeferredResponse( + renderer_response_rx, + ), + }, + )); + } + let mut route_scope = owner.enter(self); + route_scope + .conn_mut() + .unregister_runtime_remote_object_group_for_session_owner( + session_id.as_deref(), + &object_group, + ); + BidiChannelOwnerActionStep::Complete(Vec::new()) } } - self.unregister_runtime_remote_object_group_for_session_owner(session_id, object_group); } - async fn start_bidi_channel_listener_once_for_session_owner_with_background_events_async( + fn finish_failed_bidi_channel_owner_action( &mut self, - session_id: Option<&str>, - residence: BidiChannelListenerResidence, - background_events: &mut Vec, - ) { - let listener = residence.listener(); - if self.runtime_inspector_response_ready_sender().is_none() { - self.release_bidi_channel_object_group_for_session_owner_best_effort_async( - session_id, - listener.channel_object_group(), - ) - .await; - return; - } - let command_id = self.next_internal_runtime_command_id(); - let raw_json = bidi_channel_listener_call_function_json(command_id, listener); - let descriptor = match RendererCommandDescriptor::from_synthesized_payload(raw_json) { - Ok(descriptor) => descriptor, - Err(error) => { - tracing::debug!(%error, "failed to prepare BiDi channel listener command"); - self.release_bidi_channel_object_group_for_session_owner_best_effort_async( - session_id, - listener.channel_object_group(), - ) - .await; - return; + owner: BidiChannelPageOwner, + command_id: u64, + action: PendingBidiChannelOwnerActionKind, + stage: &'static str, + error: String, + ) -> BidiChannelOwnerActionStep { + let session_id = owner.session_id().map(str::to_owned); + match action { + PendingBidiChannelOwnerActionKind::StartListener { object_group } => { + { + let mut route_scope = owner.enter(self); + let _ = route_scope + .conn_mut() + .remove_pending_inspector_await_for_cancellation( + command_id, + session_id.as_deref(), + ); + } + tracing::debug!(%error, stage, "BiDi channel listener participant failed"); + self.start_bidi_channel_object_group_release_owner_action(owner, object_group) } - }; - let pending = match self - .start_runtime_protocol_message_with_context_resolution_for_session_owner_with_deferred_response( - session_id, - "callFunctionOn", - descriptor, - command_id, - ) { - Ok(pending) => pending, - Err(error) => { + PendingBidiChannelOwnerActionKind::ReleaseObjectGroup { object_group } => { + let mut route_scope = owner.enter(self); + let conn = route_scope.conn_mut(); + conn.forget_pending_inspector_await(command_id, session_id.as_deref()); + conn.unregister_runtime_remote_object_group_for_session_owner( + session_id.as_deref(), + &object_group, + ); tracing::debug!( %error, - channel = %listener.properties().channel, - "failed to start BiDi channel listener" + stage, + %object_group, + "BiDi channel object group release participant failed" ); - self.release_bidi_channel_object_group_for_session_owner_best_effort_async( - session_id, - listener.channel_object_group(), - ) - .await; - return; + BidiChannelOwnerActionStep::Complete(Vec::new()) } - }; - self.register_pending_bidi_channel_listener(command_id, session_id, residence); - let mut completed = match pending.wait().await { - Ok(completed) => completed, - Err(error) => { - let object_group = self - .remove_pending_inspector_await_for_cancellation(command_id, session_id) - .and_then(|entry| { - entry - .bidi_channel_listener() - .map(|listener| listener.channel_object_group().to_owned()) - }); - tracing::debug!(%error, "BiDi channel listener dispatch failed"); - if let Some(object_group) = object_group { - self.release_bidi_channel_object_group_for_session_owner_best_effort_async( - session_id, - &object_group, - ) - .await; + } + } + + /// Compatibility drain for protocol scheduler callers that have not yet + /// exposed the BiDi action participant at their own owner boundary. + pub(crate) async fn complete_bidi_channel_owner_action_with_background_events_async( + &mut self, + action: BidiChannelOwnerAction, + background_events: &mut Vec, + ) { + let mut step = self.start_bidi_channel_owner_action(action); + loop { + match step { + BidiChannelOwnerActionStep::Pending(pending) => { + step = self.complete_bidi_channel_owner_action((*pending).wait().await); } - return; - } - }; - let mut renderer_response_rx = completed.take_deferred_response_receiver(); - let messages = match self - .complete_runtime_protocol_message_for_session_owner_async(completed) - .await - { - Ok(messages) => messages, - Err(error) => { - let object_group = self - .remove_pending_inspector_await_for_cancellation(command_id, session_id) - .and_then(|entry| { - entry - .bidi_channel_listener() - .map(|listener| listener.channel_object_group().to_owned()) - }); - tracing::debug!(%error, "BiDi channel listener completion failed"); - if let Some(object_group) = object_group { - self.release_bidi_channel_object_group_for_session_owner_best_effort_async( - session_id, - &object_group, - ) - .await; + BidiChannelOwnerActionStep::Complete(events) => { + background_events.extend(events); + return; } - return; } - }; - let mut listener_events = Vec::new(); - let mut listener_post_response_events = Vec::new(); - let response_flush = CommandResponseFlushContext::default(); - let listener_response_seen = if let Some(messages) = messages { - self.route_renderer_command_turn_output_into( - messages, - Some(command_id), - session_id, - &response_flush, - &mut listener_events, - &mut listener_post_response_events, - ) - .await - .0 - } else { - false - }; - if !listener_response_seen { - tracing::debug!( - command_id, - "BiDi channel listener inspector response was consumed before command response routing" - ); } - if listener_response_seen { - renderer_response_rx.take(); - } - listener_events.extend(listener_post_response_events); - let non_listener_response_count = listener_events - .iter() - .filter(|event| event.protocol_message_id().is_some()) - .count(); - if non_listener_response_count > 0 { - tracing::debug!( - messages = non_listener_response_count, - "BiDi channel listener produced non-listener protocol messages on background route" - ); - } - background_events.extend( - listener_events - .into_iter() - .filter(|event| event.protocol_message_id().is_none()), + } + + pub(crate) async fn release_bidi_channel_object_group_for_session_owner_best_effort_async( + &mut self, + session_id: Option<&str>, + object_group: &str, + ) { + let Some(owner) = BidiChannelPageOwner::capture(self, session_id) else { + self.unregister_runtime_remote_object_group_for_session_owner(session_id, object_group); + return; + }; + let mut step = self.start_bidi_channel_owner_action( + BidiChannelOwnerAction::release_object_group(owner, object_group), ); - if let Some(renderer_response_rx) = renderer_response_rx { - // Listener responses use the same response-ready lane whether the - // oneshot is already completed or still pending. - if self.start_or_enqueue_registered_runtime_inspector_response_ready( - command_id, - session_id, - renderer_response_rx, - ) { - return; - } - let object_group = self - .remove_pending_inspector_await_for_cancellation(command_id, session_id) - .and_then(|entry| { - entry - .bidi_channel_listener() - .map(|listener| listener.channel_object_group().to_owned()) - }); - tracing::debug!( - channel_object_group = object_group.as_deref(), - "BiDi channel listener started without scheduler runtime response hook" - ); - if let Some(object_group) = object_group { - self.release_bidi_channel_object_group_for_session_owner_best_effort_async( - session_id, - &object_group, - ) - .await; - } + while let BidiChannelOwnerActionStep::Pending(pending) = step { + step = self.complete_bidi_channel_owner_action((*pending).wait().await); } } @@ -5611,7 +5977,7 @@ impl CdpConnection { let target_id = self .target_owner_identity_for_session(session_id) .and_then(|(_, target_id)| target_id); - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; let target_id = target_id.as_deref(); let devtools_target_id = target_id.map(DevToolsTargetId::from); let realms = page @@ -5641,7 +6007,7 @@ impl CdpConnection { &mut self, session_id: Option<&str>, ) -> Result, String> { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.default_execution_context_id_async() .await .map_err(|error| format!("runtime default execution context lookup failed: {error}")) @@ -5651,7 +6017,7 @@ impl CdpConnection { &mut self, session_id: Option<&str>, ) -> Result, String> { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.default_or_initial_execution_context_id_async() .await .map_err(|error| format!("runtime default execution context lookup failed: {error}")) @@ -5666,7 +6032,7 @@ impl CdpConnection { let owner_target_id = self .target_owner_identity_for_session(session_id) .and_then(|(_, target_id)| target_id); - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; let result = if let Some(frame_id) = frame_id && owner_target_id.as_deref() != Some(frame_id) { @@ -5691,7 +6057,7 @@ impl CdpConnection { session_id: Option<&str>, execution_context_id: i64, ) -> Result { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.has_isolated_execution_context_id_async(execution_context_id) .await .map_err(|error| format!("runtime isolated context lookup failed: {error}")) @@ -5702,7 +6068,7 @@ impl CdpConnection { session_id: Option<&str>, execution_context_id: i64, ) -> Result { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.child_frame_id_for_default_execution_context_id_async(execution_context_id) .await .map(|frame_id| frame_id.is_some()) @@ -5714,7 +6080,7 @@ impl CdpConnection { session_id: Option<&str>, frame_id: &str, ) -> Result, String> { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.child_default_execution_context_id_for_frame_id_async(frame_id) .await .map_err(|error| format!("runtime child default context lookup failed: {error}")) @@ -5740,7 +6106,7 @@ impl CdpConnection { completed: CompletedRuntimeChildDefaultContextLookupDispatch, ) -> Result { let session_id = completed.session_id.as_deref(); - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.finish_child_frame_id_for_default_execution_context_id(completed.completion) .map(|frame_id| frame_id.is_some()) .map_err(|error| format!("runtime child default context lookup failed: {error}")) @@ -5762,7 +6128,7 @@ impl CdpConnection { session_id: Option<&str>, execution_context_id: i64, ) -> Result, String> { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.ensure_isolated_worlds_attached_to_inspector_async() .await .map_err(|error| { @@ -5789,7 +6155,7 @@ impl CdpConnection { session_id: Option<&str>, execution_context_id: i64, ) -> Result, String> { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.ensure_isolated_worlds_attached_to_inspector_async() .await .map_err(|error| { @@ -5841,7 +6207,7 @@ impl CdpConnection { await_promise: bool, ) -> Result { let payload = { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.evaluate_runtime_expression_in_execution_context_without_navigation_follow_with_await_async( execution_context_id, expression, @@ -5876,7 +6242,7 @@ impl CdpConnection { execution_context_name: Option<&str>, execution_context_id: Option, ) -> Result<(), String> { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.install_runtime_binding_async(name, execution_context_name, execution_context_id) .await .map_err(|error| format!("runtime binding install failed: {error}")) @@ -5935,7 +6301,7 @@ impl CdpConnection { self.target_runtime_bindings_for_current_inspector_session_owner(session_id); let inspector_session_id = self.target_renderer_runtime_inspector_session_id_for_session(session_id); - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.set_runtime_binding_state_async( inspector_session_id, &stored_runtime_bindings, @@ -5955,7 +6321,7 @@ impl CdpConnection { session_id: Option<&str>, name: &str, ) -> Result<(), String> { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.remove_runtime_binding_async(name) .await .map_err(|error| format!("runtime binding removal failed: {error}")) @@ -5982,7 +6348,7 @@ impl CdpConnection { completed: CompletedRuntimeBindingPageCommandDispatch, ) -> Result<(), String> { let session_id = completed.session_id.as_deref(); - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.finish_unit_runtime_page_command(completed.completion, completed.operation) .map_err(|error| format!("{} failed: {error}", completed.operation)) } @@ -5997,7 +6363,7 @@ impl CdpConnection { session_id: Option<&str>, name: &str, ) -> Result<(), String> { - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.remove_default_runtime_binding_async(name) .await .map_err(|error| format!("runtime default binding removal failed: {error}")) @@ -6009,7 +6375,7 @@ impl CdpConnection { ) -> Result { let inspector_session_id = self.target_renderer_runtime_inspector_session_id_for_session(session_id); - let page = self.runtime_session_owner_page_mut(session_id)?; + let mut page = self.runtime_session_owner_page_mut(session_id)?; page.detach_runtime_inspector_session_async(inspector_session_id.as_deref()) .await .map_err(|error| format!("runtime inspector session detach failed: {error}")) @@ -6316,16 +6682,114 @@ mod tests { ); } + #[tokio::test] + async fn renderer_replay_batch_waits_one_move_owned_page_dispatch_at_a_time() { + let mut ctx = TestContext::new(); + let mut browser_context = BrowserContext::new("BID-replay-batch".to_owned()); + browser_context.set_active_target_id("TID-replay-batch"); + browser_context.attach_active_session("SID-replay-batch".to_owned()); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,replay batch", + Some("SID-replay-batch"), + ) + .await; + + let attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(Some("SID-replay-batch")) + .expect("loaded Page should expose its exact renderer attachment"); + let mut response_receivers = Vec::new(); + let mut stale_senders = Vec::new(); + for command_id in [71, 72] { + let prepared = ctx + .conn + .try_register_renderer_call_for_session_owner( + Some("SID-replay-batch"), + command_id, + Some(attachment), + RendererCommandDescriptor::from_synthesized_payload( + json!({ + "id": command_id, + "method": "Console.clearMessages", + "params": {}, + }) + .to_string(), + ) + .unwrap(), + ) + .expect("replayable renderer command should register"); + let (_, stale_sender, response_receiver) = prepared.into_parts(); + stale_senders.push(stale_sender); + response_receivers.push( + response_receiver.expect("command-reply replay should retain its local receiver"), + ); + } + let replays = ctx + .conn + .with_target_devtools_session_state_for_session_mut(Some("SID-replay-batch"), |state| { + state.prepare_renderer_call_replays(attachment, attachment) + }) + .expect("session state should remain resident") + .expect("renderer call ids should remain available") + .into_iter() + .map(|replay| { + SessionRendererCallReplay::new(Some("SID-replay-batch".to_owned()), None, replay) + }) + .collect::>(); + assert_eq!(replays.len(), 2); + for stale_sender in stale_senders { + assert!( + stale_sender.send(json!({ "id": 1, "result": {} })).is_err(), + "rotating the replay lease must retire the old renderer sender" + ); + } + + let (start_events, first) = ctx + .conn + .start_prepared_renderer_calls_after_navigation(replays, attachment); + assert!(start_events.is_empty()); + let first = first.expect("the first exact replay should start"); + assert_eq!( + ctx.conn + .current_renderer_agent_attachment_id_for_session_owner(Some("SID-replay-batch")), + Some(attachment), + "the connection remains independently accessible while the participant owns its wait" + ); + + let first = first.wait().await; + let (first_events, second) = ctx + .conn + .complete_prepared_renderer_call_after_navigation(first) + .await; + assert!(first_events.is_empty()); + let second = second.expect("the second replay must not start before the first applies"); + let second = second.wait().await; + let (second_events, terminal) = ctx + .conn + .complete_prepared_renderer_call_after_navigation(second) + .await; + assert!(second_events.is_empty()); + assert!(terminal.is_none()); + + for response_receiver in response_receivers { + let completion = response_receiver + .await + .expect("each replay should complete its original frontend response lease"); + let response = completion + .output + .protocol_response(completion.call_id) + .expect("Console.clearMessages replay should produce a protocol response"); + assert_eq!(response["result"], json!({})); + } + } + fn connection_with_bidi_page_session() -> CdpConnection { let mut conn = CdpConnection::default(); let mut browser_context = BrowserContext::new("BID-owner".to_owned()); browser_context.set_active_target_id("TID-active"); browser_context.attach_active_session("SID-active".to_owned()); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); conn } @@ -6339,7 +6803,7 @@ mod tests { browser_context .assign_auxiliary_session_to_target("TID-active", "SID-auxiliary".to_owned(),) ); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); conn.register_runtime_remote_object_ids_for_session_owner( Some("SID-active"), @@ -6481,10 +6945,11 @@ mod tests { .browser_context .as_mut() .expect("test browser context should remain loaded"); + let (primary, auxiliary) = browser_context.devtools_session_states_mut(); crate::conn::state::prepare_renderer_call_replacements_for_devtools_sessions( Some("SID-navigation-termination"), - &mut browser_context.devtools_session_state, - &mut browser_context.auxiliary_devtools_session_states, + primary, + auxiliary, old_attachment, terminal_attachment, ) @@ -6578,10 +7043,11 @@ mod tests { .browser_context .as_mut() .expect("test browser context should remain loaded"); + let (primary, auxiliary) = browser_context.devtools_session_states_mut(); crate::conn::state::prepare_renderer_call_replacements_for_devtools_sessions( Some("SID-navigation-primary"), - &mut browser_context.devtools_session_state, - &mut browser_context.auxiliary_devtools_session_states, + primary, + auxiliary, old_attachment, terminal_attachment, ) @@ -6677,10 +7143,11 @@ mod tests { .browser_context .as_mut() .expect("test browser context should remain loaded"); + let (primary, auxiliary) = browser_context.devtools_session_states_mut(); crate::conn::state::prepare_renderer_call_replacements_for_devtools_sessions( None, - &mut browser_context.devtools_session_state, - &mut browser_context.auxiliary_devtools_session_states, + primary, + auxiliary, old_attachment, terminal_attachment, ) @@ -7180,13 +7647,22 @@ mod tests { let mut ctx = TestContext::new(); let mut browser_context = BrowserContext::new("BID-runtime-node-snapshot".to_owned()); browser_context.set_active_target_id("TID-runtime-node-snapshot".to_owned()); - ctx.conn.browser_context = Some(browser_context); - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,
live
", - None, - ) - .await; - ctx.sent.clear(); + ctx.conn.insert_browser_context(browser_context); + let page = ctx + .conn + .load_page_via_runtime_async( + "data:text/html,
live
", + ) + .await + .expect("test page should load"); + let _ = ctx + .conn + .browser_context + .as_mut() + .expect("test BrowserContext should remain selected") + .active_target + .runtime_slot + .replace_loaded_page(Some(page)); ctx.process_async(json!({ "id": 1, @@ -7442,7 +7918,7 @@ mod tests { Some("SID-bg".to_owned()), "about:blank#bg".to_owned(), )); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); conn.register_pending_inspector_await(1, Some("SID-active")); conn.register_pending_inspector_await(2, Some("SID-bg")); @@ -7451,18 +7927,15 @@ mod tests { let browser_context = conn.browser_context.as_ref().expect("browser context"); assert!( browser_context - .devtools_session_state + .devtools_session_state() .has_pending_inspector_awaits(), "active DevTools session should physically store its pending await" ); assert!( browser_context - .target_parking - .page_session_state("TID-bg") - .is_some_and(|state| state - .devtools_session_state - .has_pending_inspector_awaits()), - "parked DevTools session should physically store its pending await" + .primary_devtools_session_state_for_target("TID-bg") + .is_some_and(DevToolsSessionState::has_pending_inspector_awaits), + "background DevTools session should remain in the exact Target registry" ); } @@ -7523,7 +7996,7 @@ mod tests { Some("SID-bg".to_owned()), "about:blank#bg".to_owned(), )); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); conn.register_pending_inspector_await(1, Some("SID-active")); conn.register_pending_inspector_await(1, Some("SID-bg")); @@ -7596,7 +8069,7 @@ mod tests { let mut browser_context = BrowserContext::new("BID-duplicate-owner".to_owned()); browser_context.set_active_target_id("TID-duplicate-owner".to_owned()); browser_context.attach_active_session("SID-duplicate-owner".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let original = conn .try_register_renderer_call_for_session_owner( @@ -7642,11 +8115,7 @@ mod tests { let mut browser_context = BrowserContext::new("BID-listener-cancel".to_owned()); browser_context.set_active_target_id("TID-listener-cancel".to_owned()); browser_context.attach_active_session("SID-listener-cancel".to_owned()); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); conn.try_register_renderer_call_for_session_owner( Some("SID-listener-cancel"), @@ -7688,7 +8157,7 @@ mod tests { let mut browser_context = BrowserContext::new("BID-command-cancel".to_owned()); browser_context.set_active_target_id("TID-command-cancel".to_owned()); browser_context.attach_active_session("SID-command-cancel".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); conn.try_register_renderer_call_for_session_owner( Some("SID-command-cancel"), @@ -7718,7 +8187,7 @@ mod tests { let mut browser_context = BrowserContext::new("BID-terminal".to_owned()); browser_context.set_active_target_id("TID-terminal".to_owned()); browser_context.attach_active_session("SID-terminal".to_owned()); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let attachment = RendererAgentAttachmentId::allocate(); let prepared = conn @@ -7803,7 +8272,7 @@ mod tests { Some("SID-bg".to_owned()), "about:blank#bg".to_owned(), )); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); conn.try_register_pending_inspector_await_with_object_group( 77, @@ -7871,9 +8340,13 @@ mod tests { conn.publish_bidi_channel_listener_start(listener); let held_listener_work = take_only_protocol_work(&mut conn); + let current_generation = conn + .runtime_session_owner_slot(Some("SID-active")) + .expect("test runtime slot") + .loaded_page_generation(); conn.runtime_session_owner_slot_mut(Some("SID-active")) .expect("test runtime slot") - .replace_page_attachment_id_for_test(); + .set_loaded_page_generation(current_generation + 1); let mut direct_events = Vec::new(); let mut claimed_events = Vec::new(); @@ -8001,9 +8474,14 @@ mod tests { ); let work = take_only_protocol_work(&mut conn); + let next_generation = conn + .runtime_session_owner_slot(Some("SID-active")) + .expect("test runtime slot") + .loaded_page_generation() + + 1; conn.runtime_session_owner_slot_mut(Some("SID-active")) .expect("test runtime slot") - .replace_page_attachment_id_for_test(); + .set_loaded_page_generation(next_generation); conn.register_runtime_remote_object_ids_for_session_owner_with_group( Some("SID-active"), vec!["replacement-object".to_owned()], @@ -8028,9 +8506,14 @@ mod tests { let listener = bidi_channel_listener_residence_for_test(&conn, "SID-active", "stale-reply"); conn.register_pending_bidi_channel_listener(9, Some("SID-active"), listener); + let next_generation = conn + .runtime_session_owner_slot(Some("SID-active")) + .expect("test runtime slot") + .loaded_page_generation() + + 1; conn.runtime_session_owner_slot_mut(Some("SID-active")) .expect("test runtime slot") - .replace_page_attachment_id_for_test(); + .set_loaded_page_generation(next_generation); conn.register_runtime_remote_object_ids_for_session_owner_with_group( Some("SID-active"), vec!["replacement-object".to_owned()], diff --git a/moli-protocol/src/conn/runtime_eval/normalization.rs b/moli-protocol/src/conn/runtime_eval/normalization.rs new file mode 100644 index 0000000000..441f779be6 --- /dev/null +++ b/moli-protocol/src/conn/runtime_eval/normalization.rs @@ -0,0 +1,571 @@ +use std::{collections::VecDeque, time::Instant}; + +use moli_core::page::{ + CompletedPageCommand, PendingPageCommand, RendererCommandTurnOutput, + RendererRuntimeInspectorMessage, +}; +use serde_json::{Value, json}; + +use super::{ + CdpConnection, CompletedRuntimeProtocolMessageDispatch, RuntimeProtocolMessagePageRoute, + collect_remote_object_paths, +}; + +/// One exact Page wait required to normalize frozen Runtime protocol output. +/// +/// Context-id compatibility and DOM-node subtype discovery both require +/// renderer queries. Keeping those queries in a move-owned state machine +/// prevents a seemingly local output-apply turn from borrowing +/// `CdpConnection` across another Page command wait. +pub(crate) struct PendingRuntimeProtocolMessageNormalization { + continuation: RuntimeProtocolMessageNormalizationContinuation, + operation: PendingRuntimeProtocolMessageNormalizationOperation, +} + +pub(crate) struct CompletedRuntimeProtocolMessageNormalization { + continuation: RuntimeProtocolMessageNormalizationContinuation, + operation: CompletedRuntimeProtocolMessageNormalizationOperation, +} + +pub(crate) enum RuntimeProtocolMessageCompletionStep { + Pending(Box), + Complete(Box, String>>), +} + +struct RuntimeProtocolMessageNormalizationContinuation { + session_id: Option, + route: RuntimeProtocolMessagePageRoute, + output: RendererCommandTurnOutput, + jobs: VecDeque, + timing_started: Option, +} + +enum RuntimeProtocolMessageNormalizationJob { + ContextId(RuntimeContextIdNormalizationJob), + Node(RuntimeNodeNormalizationJob), +} + +struct RuntimeContextIdNormalizationJob { + message_index: usize, + context_pointer: &'static str, + inspector_context_id: i64, +} + +struct RuntimeNodeNormalizationJob { + message_index: usize, + path: String, + object_id: String, +} + +enum PendingRuntimeProtocolMessageNormalizationOperation { + EnsureContextWorlds { + job: RuntimeContextIdNormalizationJob, + pending: PendingPageCommand, + }, + ResolveContextId { + job: RuntimeContextIdNormalizationJob, + pending: PendingPageCommand, + }, + ResolveNode { + job: RuntimeNodeNormalizationJob, + pending: PendingPageCommand, + }, +} + +enum CompletedRuntimeProtocolMessageNormalizationOperation { + EnsureContextWorlds { + job: RuntimeContextIdNormalizationJob, + completed: Result, + }, + ResolveContextId { + job: RuntimeContextIdNormalizationJob, + completed: Result, + }, + ResolveNode { + job: RuntimeNodeNormalizationJob, + completed: Result, + }, +} + +impl PendingRuntimeProtocolMessageNormalization { + pub(crate) async fn wait(self) -> CompletedRuntimeProtocolMessageNormalization { + let operation = match self.operation { + PendingRuntimeProtocolMessageNormalizationOperation::EnsureContextWorlds { + job, + pending, + } => CompletedRuntimeProtocolMessageNormalizationOperation::EnsureContextWorlds { + job, + completed: pending.wait().await.map_err(|error| error.to_string()), + }, + PendingRuntimeProtocolMessageNormalizationOperation::ResolveContextId { + job, + pending, + } => CompletedRuntimeProtocolMessageNormalizationOperation::ResolveContextId { + job, + completed: pending.wait().await.map_err(|error| error.to_string()), + }, + PendingRuntimeProtocolMessageNormalizationOperation::ResolveNode { job, pending } => { + CompletedRuntimeProtocolMessageNormalizationOperation::ResolveNode { + job, + completed: pending.wait().await.map_err(|error| error.to_string()), + } + } + }; + CompletedRuntimeProtocolMessageNormalization { + continuation: self.continuation, + operation, + } + } +} + +pub(super) fn start_runtime_protocol_message_completion( + conn: &mut CdpConnection, + completed: CompletedRuntimeProtocolMessageDispatch, +) -> RuntimeProtocolMessageCompletionStep { + let timing_started = moli_trace::cdp_nav_timing_enabled().then(Instant::now); + let completion = match completed.completion { + moli_core::page::CompletedRuntimeInspectorCommandDispatch::Owner(completion) + | moli_core::page::CompletedRuntimeInspectorCommandDispatch::OwnerSessionResponse { + completion, + .. + } => *completion, + moli_core::page::CompletedRuntimeInspectorCommandDispatch::Inspector + | moli_core::page::CompletedRuntimeInspectorCommandDispatch::InspectorSessionResponse { + .. + } + | moli_core::page::CompletedRuntimeInspectorCommandDispatch::OwnerSessionErrorSettled(_) => { + return RuntimeProtocolMessageCompletionStep::Complete(Box::new(Ok(None))); + } + }; + let mut output = + match conn.consume_runtime_protocol_message_completion(&completed.route, completion) { + Ok(output) => output, + Err(error) => { + return RuntimeProtocolMessageCompletionStep::Complete(Box::new(Err(error))); + } + }; + if let Some(started) = timing_started { + tracing::info!( + target: "moli_cdp_nav_timing", + stage = "runtime_inspector_page_dispatch_done", + output_messages = output + .runtime_inspector_output() + .map_or(0, |messages| messages.len()), + elapsed_ms = started.elapsed().as_millis(), + ); + } + conn.ingest_runtime_protocol_message_started_route_output_updates(&completed.route); + if let Some(started) = timing_started { + tracing::info!( + target: "moli_cdp_nav_timing", + stage = "runtime_inspector_output_ingested", + elapsed_ms = started.elapsed().as_millis(), + ); + } + let runtime_messages = match output.runtime_inspector_output_mut() { + Some(runtime_messages) => runtime_messages, + None => { + return RuntimeProtocolMessageCompletionStep::Complete(Box::new(Err( + "runtime inspector dispatch completed with a non-Runtime renderer reply".to_owned(), + ))); + } + }; + runtime_messages.bind_renderer_agent_attachment(completed.route.renderer_agent_attachment_id); + let jobs = collect_runtime_protocol_message_normalization_jobs(runtime_messages.messages()); + start_next_runtime_protocol_message_normalization( + conn, + RuntimeProtocolMessageNormalizationContinuation { + session_id: completed.session_id, + route: completed.route, + output, + jobs, + timing_started, + }, + ) +} + +pub(super) fn complete_runtime_protocol_message_normalization( + conn: &mut CdpConnection, + completed: CompletedRuntimeProtocolMessageNormalization, +) -> RuntimeProtocolMessageCompletionStep { + let CompletedRuntimeProtocolMessageNormalization { + mut continuation, + operation, + } = completed; + if conn + .runtime_protocol_message_started_page_mut(&continuation.route) + .is_err() + { + return finish_runtime_protocol_message_normalization(conn, continuation); + } + match operation { + CompletedRuntimeProtocolMessageNormalizationOperation::EnsureContextWorlds { + job, + completed, + } => { + let ensured = completed.and_then(|completed| { + conn.runtime_protocol_message_started_page_mut(&continuation.route) + .and_then(|mut page| { + page.finish_ensure_isolated_worlds_attached_to_inspector(completed) + .map_err(|error| error.to_string()) + }) + }); + if let Err(error) = ensured { + tracing::debug!(%error, "Runtime context-id normalization setup failed"); + return start_next_runtime_protocol_message_normalization(conn, continuation); + } + let pending = conn + .runtime_protocol_message_started_page_mut(&continuation.route) + .and_then(|page| { + page.start_isolated_execution_context_id_for_inspector_context( + job.inspector_context_id, + ) + .map_err(|error| error.to_string()) + }); + match pending { + Ok(pending) => RuntimeProtocolMessageCompletionStep::Pending(Box::new( + PendingRuntimeProtocolMessageNormalization { + continuation, + operation: + PendingRuntimeProtocolMessageNormalizationOperation::ResolveContextId { + job, + pending, + }, + }, + )), + Err(error) => { + tracing::debug!(%error, "Runtime context-id normalization lookup failed to start"); + start_next_runtime_protocol_message_normalization(conn, continuation) + } + } + } + CompletedRuntimeProtocolMessageNormalizationOperation::ResolveContextId { + job, + completed, + } => { + let compatibility_context_id = completed + .and_then(|completed| { + conn.runtime_protocol_message_started_page_mut(&continuation.route) + .and_then(|mut page| { + page.finish_isolated_execution_context_id_for_inspector_context( + completed, + ) + .map_err(|error| error.to_string()) + }) + }) + .ok() + .flatten(); + if let Some(compatibility_context_id) = compatibility_context_id + && let Some(mut message) = + runtime_protocol_message_value_mut(&mut continuation.output, job.message_index) + && let Some(context_id) = message.pointer_mut(job.context_pointer) + { + *context_id = json!(compatibility_context_id); + } + start_next_runtime_protocol_message_normalization(conn, continuation) + } + CompletedRuntimeProtocolMessageNormalizationOperation::ResolveNode { job, completed } => { + let is_node = completed + .and_then(|completed| { + conn.runtime_protocol_message_started_page_mut(&continuation.route) + .and_then(|mut page| { + page.finish_document_node_snapshot_for_object_id(completed) + .map_err(|error| error.to_string()) + }) + }) + .ok() + .flatten() + .is_some(); + if is_node + && let Some(mut message) = + runtime_protocol_message_value_mut(&mut continuation.output, job.message_index) + && let Some(remote_object) = message + .pointer_mut(&job.path) + .and_then(Value::as_object_mut) + { + remote_object.insert("subtype".to_owned(), json!("node")); + } + start_next_runtime_protocol_message_normalization(conn, continuation) + } + } +} + +fn start_next_runtime_protocol_message_normalization( + conn: &mut CdpConnection, + mut continuation: RuntimeProtocolMessageNormalizationContinuation, +) -> RuntimeProtocolMessageCompletionStep { + loop { + let Some(job) = continuation.jobs.pop_front() else { + return finish_runtime_protocol_message_normalization(conn, continuation); + }; + let operation = match job { + RuntimeProtocolMessageNormalizationJob::ContextId(job) => { + let pending = conn + .runtime_protocol_message_started_page_mut(&continuation.route) + .and_then(|page| { + page.start_ensure_isolated_worlds_attached_to_inspector() + .map_err(|error| error.to_string()) + }); + match pending { + Ok(pending) => { + PendingRuntimeProtocolMessageNormalizationOperation::EnsureContextWorlds { + job, + pending, + } + } + Err(error) => { + tracing::debug!(%error, "Runtime context-id normalization failed to start"); + continue; + } + } + } + RuntimeProtocolMessageNormalizationJob::Node(job) => { + let include_whitespace = + crate::domains::dom::dom_agent_includes_whitespace_for_session( + conn, + continuation.session_id.as_deref(), + ); + let pending = conn + .runtime_protocol_message_started_page_mut(&continuation.route) + .and_then(|page| { + page.start_document_node_snapshot_for_object_id_in_inspector_session( + continuation.route.inspector_session_id.clone(), + include_whitespace, + &job.object_id, + 0, + false, + ) + .map_err(|error| error.to_string()) + }); + match pending { + Ok(pending) => { + PendingRuntimeProtocolMessageNormalizationOperation::ResolveNode { + job, + pending, + } + } + Err(error) => { + tracing::debug!(%error, "Runtime node normalization failed to start"); + continue; + } + } + } + }; + return RuntimeProtocolMessageCompletionStep::Pending(Box::new( + PendingRuntimeProtocolMessageNormalization { + continuation, + operation, + }, + )); + } +} + +fn finish_runtime_protocol_message_normalization( + conn: &mut CdpConnection, + mut continuation: RuntimeProtocolMessageNormalizationContinuation, +) -> RuntimeProtocolMessageCompletionStep { + let runtime_messages = match continuation.output.runtime_inspector_output_mut() { + Some(runtime_messages) => runtime_messages.messages_mut(), + None => { + return RuntimeProtocolMessageCompletionStep::Complete(Box::new(Err( + "runtime inspector output disappeared during normalization".to_owned(), + ))); + } + }; + conn.restore_frontend_command_ids_in_runtime_messages( + continuation.session_id.as_deref(), + Some(continuation.route.renderer_agent_attachment_id), + runtime_messages, + ); + if let Some(started) = continuation.timing_started { + tracing::info!( + target: "moli_cdp_nav_timing", + stage = "runtime_inspector_command_output_ready", + output_messages = runtime_messages.len(), + elapsed_ms = started.elapsed().as_millis(), + ); + } + RuntimeProtocolMessageCompletionStep::Complete(Box::new(Ok(Some(continuation.output)))) +} + +fn collect_runtime_protocol_message_normalization_jobs( + messages: &[RendererRuntimeInspectorMessage], +) -> VecDeque { + let mut jobs = VecDeque::new(); + for (message_index, message) in messages.iter().enumerate() { + let RendererRuntimeInspectorMessage::Protocol(message) = message else { + continue; + }; + let value = message.value(); + let context_pointer = match value.get("method").and_then(Value::as_str) { + Some("Runtime.consoleAPICalled") => Some("/params/executionContextId"), + Some("Runtime.exceptionThrown") => Some("/params/exceptionDetails/executionContextId"), + _ => None, + }; + if let Some(context_pointer) = context_pointer + && let Some(inspector_context_id) = + value.pointer(context_pointer).and_then(Value::as_i64) + { + jobs.push_back(RuntimeProtocolMessageNormalizationJob::ContextId( + RuntimeContextIdNormalizationJob { + message_index, + context_pointer, + inspector_context_id, + }, + )); + } + + let mut paths = Vec::new(); + collect_remote_object_paths(value, "", &mut paths); + for path in paths { + let Some(remote_object) = value.pointer(&path) else { + continue; + }; + let Some(object_id) = remote_object + .get("objectId") + .and_then(Value::as_str) + .map(str::to_owned) + else { + continue; + }; + let has_subtype = remote_object.get("subtype").is_some(); + let is_object_like = remote_object + .get("type") + .and_then(Value::as_str) + .is_some_and(|ty| matches!(ty, "object" | "function")); + if has_subtype || !is_object_like { + continue; + } + jobs.push_back(RuntimeProtocolMessageNormalizationJob::Node( + RuntimeNodeNormalizationJob { + message_index, + path, + object_id, + }, + )); + } + } + jobs +} + +fn runtime_protocol_message_value_mut( + output: &mut RendererCommandTurnOutput, + message_index: usize, +) -> Option> { + let message = output + .runtime_inspector_output_mut()? + .messages_mut() + .get_mut(message_index)?; + let RendererRuntimeInspectorMessage::Protocol(message) = message else { + return None; + }; + Some(message.value_mut()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conn::{BrowserContext, CommandResponseFlushContext}; + use crate::testing::TestContext; + + #[tokio::test] + async fn node_normalization_completion_does_not_enter_replacement_renderer_attachment() { + let mut ctx = TestContext::new(); + let mut browser_context = BrowserContext::new("BID-normalization-stale".to_owned()); + browser_context.set_active_target_id("TID-normalization-stale".to_owned()); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,old", + None, + ) + .await; + + let old_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(None) + .expect("the old Page should have an exact renderer attachment"); + let pending = ctx + .conn + .start_runtime_protocol_message_for_session_owner( + None, + json!({ + "id": 41, + "method": "Runtime.evaluate", + "params": { "expression": "document.body" }, + }) + .to_string(), + ) + .expect("Runtime.evaluate should start on the old Page"); + let completed = pending + .wait() + .await + .expect("Runtime.evaluate should complete on the old Page"); + let RuntimeProtocolMessageCompletionStep::Pending(pending) = ctx + .conn + .start_runtime_protocol_message_completion(completed) + else { + panic!("a DOM remote object must require an exact-Page normalization command"); + }; + let completed = (*pending).wait().await; + + let replacement = ctx + .conn + .load_page_via_runtime_async("data:text/html,replacement") + .await + .expect("replacement Page should load"); + let old_page = ctx + .conn + .runtime_session_owner_slot_mut(None) + .expect("the old target should remain resident") + .clear_loaded_page_for_test_fixture(); + drop(old_page); + ctx.conn + .runtime_session_owner_slot_mut(None) + .expect("the replacement target should remain resident") + .set_loaded_page_for_test(replacement); + let replacement_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(None) + .expect("the replacement Page should have an exact renderer attachment"); + assert_ne!(old_attachment, replacement_attachment); + + let RuntimeProtocolMessageCompletionStep::Complete(result) = ctx + .conn + .complete_runtime_protocol_message_normalization(completed) + else { + panic!("a stale normalization must finish without entering the replacement Page"); + }; + let Ok(Some(output)) = *result else { + panic!("a stale normalization must preserve the immutable old-Page output"); + }; + let response = output + .runtime_inspector_output() + .and_then(|output| { + output.messages().iter().find_map(|message| match message { + RendererRuntimeInspectorMessage::Protocol(message) => Some(message.value()), + RendererRuntimeInspectorMessage::RuntimeContext(_) => None, + }) + }) + .expect("Runtime.evaluate should retain its frozen old-Page response"); + assert_eq!(response.pointer("/result/result/subtype"), None); + + let mut events = Vec::new(); + let mut post_response_events = Vec::new(); + let (seen, _) = ctx.conn.route_normalized_renderer_command_turn_output_into( + output, + Some(41), + None, + &CommandResponseFlushContext::default(), + &mut events, + &mut post_response_events, + ); + assert!(!seen); + assert!(events.is_empty() && post_response_events.is_empty()); + assert_eq!( + ctx.conn + .current_renderer_agent_attachment_id_for_session_owner(None), + Some(replacement_attachment), + "stale normalization must leave the successor renderer attachment untouched" + ); + } +} diff --git a/moli-protocol/src/conn/runtime_load.rs b/moli-protocol/src/conn/runtime_load.rs index ee618eaa7f..1a6dc147c5 100644 --- a/moli-protocol/src/conn/runtime_load.rs +++ b/moli-protocol/src/conn/runtime_load.rs @@ -1,6 +1,7 @@ use data_url::DataUrl; use moli_core::{ RendererOutputFence, + browser_host::BrowserPageOwnerKey, page::{RendererMainDocumentCommit, RendererPageCreationDiagnostics, RendererRuntimeRealmInfo}, runtime::{ CommittedDocumentResourceSource, PageVmInitStage, PreparedDocumentPage, @@ -18,10 +19,12 @@ use std::sync::Arc; use tokio::sync::{mpsc, oneshot}; use url::Url; +use super::browser_document_page_builder::BrowserDocumentPageBuilder; use super::*; use crate::conn::state::{ InitialDocumentPageBuildWaiter, RendererPageResidenceIdentity, TargetPageAbsenceReason, }; +use crate::domains::command_output::BrowserNavigateCommandOutcomeDelivery; use crate::domains::network::{ CompletedDocumentProgressTransfer, CompletedDownloadProgressTransfer, CompletedMainDocumentNetworkEvents, MainDocumentBodyNetworkProgress, @@ -84,7 +87,7 @@ fn response_status_may_use_http_error_page(status: u16) -> bool { #[allow(clippy::too_many_arguments)] async fn prepare_browser_owned_error_page_navigation_with_engine_async( - engine: &mut NavigationEngine, + engine: &mut impl BrowserDocumentPageBuilder, page_reservation: RendererPageReservationToken, load_inputs: &TargetNavigationLoadInputs, unreachable_url: Url, @@ -131,7 +134,7 @@ async fn prepare_browser_owned_error_page_navigation_with_engine_async( #[allow(clippy::too_many_arguments)] async fn prepare_network_error_page_navigation_with_engine_async( - engine: &mut NavigationEngine, + engine: &mut impl BrowserDocumentPageBuilder, page_reservation: RendererPageReservationToken, load_inputs: &TargetNavigationLoadInputs, unreachable_url: Url, @@ -694,8 +697,8 @@ fn spawn_captured_body_replay( pub(crate) struct BackgroundNavigationLoadJob { engine: NavigationEngine, page_reservation: RendererPageReservationToken, - cancellation: FetchCancelHandle, - early_result: Option, + cancellation: BackgroundNavigationCancellation, + early_outcome: Option, load_inputs: TargetNavigationLoadInputs, method: String, raw_url: String, @@ -709,6 +712,91 @@ pub(crate) struct BackgroundNavigationLoadJob { shared_resource_runtime: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BackgroundInterceptedNavigationFetchMode { + Streaming, + CollectStreaming, + Buffered, +} + +/// Move-owned top-level request used after an intercepted navigation is +/// continued. +/// +/// Construction freezes the target's transport/cookie/profile inputs while +/// Browser Owner holds the physical executor. `run` owns the loader and request +/// and therefore performs no `CdpConnection` borrow while network I/O is +/// pending. +pub(crate) struct BackgroundInterceptedNavigationFetchJob { + loader: moli_core::network::ResourceRequestClient, + request: Request, + raw_url: String, + mode: BackgroundInterceptedNavigationFetchMode, +} + +pub(crate) enum BackgroundInterceptedNavigationFetchResult { + Streaming(NetworkFetchResult), + Buffered(NetworkFetchResult), +} + +pub(crate) struct BackgroundStreamingResponseCollectionJob { + response: NetworkFetchResult, +} + +pub(crate) struct BackgroundBufferedResponseNavigationLoadJob { + engine: NavigationEngine, + page_reservation: Option, + load_inputs: TargetNavigationLoadInputs, + requested_url: Url, + request_method: String, + request_headers: Vec<(String, String)>, + response: NetworkFetchResult, + body_progress_source: MainDocumentBodyProgressSource, + shared_resource_runtime: Option, +} + +/// Move-owned captured response replay for a paused top-level navigation. +/// +/// Construction freezes load inputs and reserves the future Page while the +/// Browser Host owns the physical executor. `run` can then await parser/ +/// renderer work without borrowing `CdpConnection`. +pub(crate) struct BackgroundCapturedResponseNavigationLoadJob { + engine: NavigationEngine, + page_reservation: Option, + load_inputs: TargetNavigationLoadInputs, + requested_url: Url, + request_method: String, + request_headers: Vec<(String, String)>, + head: ResponseHead, + body: CapturedBody, + network_observation_journal: NetworkObservationJournal, + body_progress_source: MainDocumentBodyProgressSource, + allow_download: bool, + reply_boundary: RendererReplyBoundary, + shared_resource_runtime: Option, +} + +pub(crate) struct BackgroundPausedStreamingResponseNavigationPreparationJob { + engine: NavigationEngine, + page_reservation: RendererPageReservationToken, + load_inputs: TargetNavigationLoadInputs, + requested_url: Url, + final_url: Url, + request_method: String, + request_headers: Vec<(String, String)>, + response_status: u16, + response_headers: Vec<(String, String)>, + response_cookie_reports: Vec, + response_from_cache: bool, + negotiated_http_version: Option, + initial_request_cookie_report: Option, + redirected: bool, + redirect_chain: Vec, + network_observation_journal: NetworkObservationJournal, + body_progress_source: MainDocumentBodyProgressSource, + shared_resource_runtime: Option, + timing_started: Option, +} + pub(crate) struct BackgroundStreamingResponseNavigationLoadJob { engine: NavigationEngine, page_reservation: RendererPageReservationToken, @@ -724,11 +812,9 @@ pub(crate) struct BackgroundStreamingResponseNavigationLoadJob { shared_resource_runtime: Option, } -pub(crate) struct BackgroundNavigationEarlyResult { +pub(crate) struct BackgroundNavigationEarlyOutcome { sender: BackgroundEventSender, - navigate_id: u64, - session_id: Option, - result_payload: Value, + delivery: BrowserNavigateCommandOutcomeDelivery, } pub(crate) struct BackgroundNavigationBodyCompletionSink { @@ -780,36 +866,39 @@ impl BackgroundNavigationBodyCompletionSink { } } -impl BackgroundNavigationEarlyResult { +impl BackgroundNavigationEarlyOutcome { pub(crate) fn new( sender: BackgroundEventSender, navigate_id: u64, session_id: Option, + requested_url: &str, result_payload: Value, ) -> Self { Self { sender, - navigate_id, - session_id, - result_payload, + delivery: BrowserNavigateCommandOutcomeDelivery::completed( + navigate_id, + session_id.as_deref(), + requested_url, + result_payload, + ), } } fn emit(self) -> bool { - let session_id = self.session_id; self.sender - .send(BackgroundProtocolEvent::command_success( - Some(self.navigate_id), - session_id.as_deref(), - self.result_payload, - )) + .send_browser_navigate_outcome(self.delivery) .is_ok() } } impl BackgroundNavigationLoadJob { - fn emit_early_result_for_successful_document( - early_result: &mut Option, + pub(crate) fn cancellation(&self) -> BackgroundNavigationCancellation { + self.cancellation.clone() + } + + fn emit_early_outcome_for_successful_document( + early_outcome: &mut Option, navigation: &Result, ) -> bool { let is_successful_document = match navigation { @@ -824,9 +913,9 @@ impl BackgroundNavigationLoadJob { if !is_successful_document { return false; } - early_result + early_outcome .take() - .is_some_and(BackgroundNavigationEarlyResult::emit) + .is_some_and(BackgroundNavigationEarlyOutcome::emit) } pub(crate) async fn run( @@ -840,13 +929,13 @@ impl BackgroundNavigationLoadJob { let timing_started = moli_trace::cdp_nav_timing_enabled().then(std::time::Instant::now); let timing_url = self.raw_url.clone(); let mut engine = self.engine; - let mut early_result = self.early_result.take(); + let mut early_outcome = self.early_outcome.take(); if let Some(resource_runtime) = self.shared_resource_runtime.take() && let Err(error) = engine.adopt_registered_resource_runtime(resource_runtime) { return (engine, Err(error.to_string()), false); } - let mut early_result_sent = false; + let mut early_outcome_sent = false; let navigation = async { if let Some(navigation) = load_inline_html_navigation_with_engine_async( &mut engine, @@ -859,8 +948,10 @@ impl BackgroundNavigationLoadJob { ) .await { - early_result_sent = - Self::emit_early_result_for_successful_document(&mut early_result, &navigation); + early_outcome_sent = Self::emit_early_outcome_for_successful_document( + &mut early_outcome, + &navigation, + ); return navigation; } @@ -875,8 +966,10 @@ impl BackgroundNavigationLoadJob { ) .await { - early_result_sent = - Self::emit_early_result_for_successful_document(&mut early_result, &navigation); + early_outcome_sent = Self::emit_early_outcome_for_successful_document( + &mut early_outcome, + &navigation, + ); return navigation; } @@ -900,7 +993,7 @@ impl BackgroundNavigationLoadJob { self.body, self.request_headers.clone(), None, - self.cancellation.clone(), + self.cancellation.fetch_cancel_handle(), ) .await; let navigation_response = match navigation_response { @@ -957,9 +1050,9 @@ 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 - && let Some(early_result) = early_result.take() + && let Some(early_outcome) = early_outcome.take() { - early_result_sent = early_result.emit(); + early_outcome_sent = early_outcome.emit(); } let navigation = build_navigation_from_streaming_raw_response_with_engine_async( &mut engine, @@ -980,8 +1073,10 @@ impl BackgroundNavigationLoadJob { ) .await; if defer_early_result_for_http_error_body { - early_result_sent = - Self::emit_early_result_for_successful_document(&mut early_result, &navigation); + early_outcome_sent = Self::emit_early_outcome_for_successful_document( + &mut early_outcome, + &navigation, + ); } navigation } @@ -994,7 +1089,291 @@ impl BackgroundNavigationLoadJob { elapsed_ms = started.elapsed().as_millis(), ); } - (engine, navigation, early_result_sent) + (engine, navigation, early_outcome_sent) + } +} + +impl BackgroundInterceptedNavigationFetchJob { + pub(crate) async fn run(self) -> Result { + match self.mode { + BackgroundInterceptedNavigationFetchMode::Streaming => self + .loader + .fetch_raw_stream_with_cancel_and_network_metadata( + self.request, + FetchCancelHandle::new(), + ) + .await + .map(BackgroundInterceptedNavigationFetchResult::Streaming) + .map_err(|error| format!("failed to fetch page `{}`: {error}", self.raw_url)), + BackgroundInterceptedNavigationFetchMode::CollectStreaming => { + let response = self + .loader + .fetch_raw_stream_with_cancel_and_network_metadata( + self.request, + FetchCancelHandle::new(), + ) + .await + .map_err(|error| format!("failed to fetch page `{}`: {error}", self.raw_url))?; + let (response, network_observation_journal) = + response.into_parts_with_observation_journal(); + let response = response + .into_materialized_raw_response() + .await + .map_err(|error| format!("failed to read page body from stream: {error}"))?; + Ok(BackgroundInterceptedNavigationFetchResult::Buffered( + NetworkFetchResult::with_observation_journal( + response, + network_observation_journal, + ), + )) + } + BackgroundInterceptedNavigationFetchMode::Buffered => self + .loader + .fetch_raw_with_network_metadata(self.request) + .await + .map(BackgroundInterceptedNavigationFetchResult::Buffered) + .map_err(|error| format!("failed to fetch page `{}`: {error}", self.raw_url)), + } + } +} + +impl BackgroundStreamingResponseCollectionJob { + pub(crate) async fn run(self) -> Result, String> { + let (response, network_observation_journal) = + self.response.into_parts_with_observation_journal(); + let response = response + .into_materialized_raw_response() + .await + .map_err(|error| format!("failed to read page body from stream: {error}"))?; + Ok(NetworkFetchResult::with_observation_journal( + response, + network_observation_journal, + )) + } +} + +impl BackgroundBufferedResponseNavigationLoadJob { + pub(crate) async fn run(mut self) -> (NavigationEngine, Result) { + if let Some(resource_runtime) = self.shared_resource_runtime.take() + && let Err(error) = self + .engine + .adopt_registered_resource_runtime(resource_runtime) + { + return (self.engine, Err(error.to_string())); + } + let (response, network_observation_journal) = + self.response.into_parts_with_observation_journal(); + if super::downloads::response_headers_indicate_download(&response.headers) { + let navigation = + NavigationLoadOutcome::download(materialize_download_from_raw_response( + self.request_method, + self.request_headers, + response, + network_observation_journal, + )); + return (self.engine, Ok(navigation)); + } + let Some(page_reservation) = self.page_reservation else { + return ( + self.engine, + Err("buffered navigation is missing its future Page reservation".to_owned()), + ); + }; + let head = response.head(); + let body = CapturedBody::from_bytes(response.clone_body_bytes()); + let navigation = prepare_navigation_from_captured_raw_response_with_engine_async( + &mut self.engine, + page_reservation, + &self.load_inputs, + self.requested_url, + self.request_method, + self.request_headers, + head, + body, + self.body_progress_source, + network_observation_journal, + None, + false, + RendererReplyBoundary::DocumentCommit, + ) + .await + .map(NavigationLoadOutcome::response_commit_ready); + (self.engine, navigation) + } +} + +impl BackgroundCapturedResponseNavigationLoadJob { + pub(crate) async fn run(mut self) -> (NavigationEngine, Result) { + if let Some(resource_runtime) = self.shared_resource_runtime.take() + && let Err(error) = self + .engine + .adopt_registered_resource_runtime(resource_runtime) + { + return (self.engine, Err(error.to_string())); + } + if self.allow_download + && super::downloads::response_headers_indicate_download(&self.head.headers) + { + let body = match self.body.materialize_bytes() { + Ok(body) => body, + Err(error) => { + return ( + self.engine, + Err(format!( + "failed to materialize captured download body: {error}" + )), + ); + } + }; + let navigation = + NavigationLoadOutcome::download(materialize_download_from_raw_response( + self.request_method, + self.request_headers, + RawResponse::from_head_and_body(self.head, body), + self.network_observation_journal, + )); + return (self.engine, Ok(navigation)); + } + let Some(page_reservation) = self.page_reservation else { + return ( + self.engine, + Err("captured navigation is missing its future Page reservation".to_owned()), + ); + }; + let navigation = prepare_navigation_from_captured_raw_response_with_engine_async( + &mut self.engine, + page_reservation, + &self.load_inputs, + self.requested_url, + self.request_method, + self.request_headers, + self.head, + self.body, + self.body_progress_source, + self.network_observation_journal, + None, + false, + self.reply_boundary, + ) + .await + .map(NavigationLoadOutcome::response_commit_ready); + (self.engine, navigation) + } +} + +impl BackgroundPausedStreamingResponseNavigationPreparationJob { + pub(crate) async fn run(mut self) -> Result { + if let Some(resource_runtime) = self.shared_resource_runtime.take() { + self.engine + .adopt_registered_resource_runtime(resource_runtime) + .map_err(|error| error.to_string())?; + } + let network_extra_info_available = !self.network_observation_journal.is_empty(); + let redirect_chain = self + .redirect_chain + .iter() + .cloned() + .map(Into::into) + .collect::>(); + let network_events = CompletedMainDocumentNetworkEvents::new( + self.request_method.clone(), + self.request_headers.clone(), + self.initial_request_cookie_report.clone(), + self.response_status, + self.response_headers.clone(), + self.response_cookie_reports.clone(), + redirect_chain.clone(), + network_extra_info_available, + self.response_from_cache, + ) + .with_negotiated_http_version(self.negotiated_http_version) + .with_network_observation_journal(self.network_observation_journal.clone()); + let body_network_progress_state = self + .body_progress_source + .body_network_progress_for_completed_events(network_events); + let (renderer_body_tx, renderer_body_rx) = + mpsc::channel(EXTERNAL_RAW_BODY_CHANNEL_CAPACITY); + let (renderer_completion_tx, renderer_completion_rx) = oneshot::channel(); + let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new( + renderer_body_rx, + renderer_completion_rx, + ); + let (fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type) = + self.load_inputs.fetch_subresource_interception; + let page_storage = self.load_inputs.page_storage_handles(); + let main_document_commit = self + .load_inputs + .main_document_commit_for_final_url(&self.final_url, None) + .map(Arc::new); + let prepared_page = self + .engine + .prepare_streaming_raw_page_from_external_body_with_storage_and_inspector_session_restores_async( + self.page_reservation, + page_storage.into_navigation_storage(), + self.requested_url.clone(), + self.final_url.clone(), + self.load_inputs.navigation_initiator_url.clone(), + self.redirected, + redirect_chain.len(), + self.response_status, + self.response_headers.clone(), + raw_body, + self.load_inputs.document_start_scripts.clone(), + self.load_inputs.runtime_bindings.clone(), + self.load_inputs.runtime_inspector_session_restore_snapshots.clone(), + self.load_inputs.extra_http_headers.clone(), + self.load_inputs.locale_override.clone(), + self.load_inputs.timezone_override.clone(), + self.load_inputs.script_execution_disabled, + self.load_inputs.bypass_content_security_policy, + self.load_inputs.cpu_throttling_rate, + self.load_inputs.emulated_media.clone(), + self.load_inputs.viewport_surface, + self.load_inputs.network_offline, + self.load_inputs.blocked_url_patterns.clone(), + fetch_subresource_interception_enabled, + fetch_subresource_interception_resource_type, + PageVmInitStage::DomContentLoaded, + RendererReplyBoundary::DocumentCommit, + self.load_inputs.root_frame_id.clone(), + CommittedDocumentResourceSource::Synthetic, + None, + main_document_commit.as_deref().cloned(), + ) + .await + .map_err(|error| { + format!( + "failed to prepare response-stage page `{}`: {error:#}", + self.requested_url + ) + })?; + if let Some(started) = self.timing_started { + tracing::info!( + target: "moli_cdp_nav_timing", + url = %self.requested_url, + stage = "response_stage_document_prepared", + elapsed_ms = started.elapsed().as_millis(), + ); + } + Ok(PausedResponsePreparedDocument { + prepared_page, + renderer_body_tx, + renderer_completion_tx, + body_progress_source: self.body_progress_source, + body_network_progress_state, + requested_url: self.requested_url, + final_url: self.final_url, + request_method: self.request_method, + request_headers: self.request_headers, + response_status: self.response_status, + response_headers: self.response_headers, + response_from_cache: self.response_from_cache, + negotiated_http_version: self.negotiated_http_version, + network_observation_journal: self.network_observation_journal, + engine: self.engine, + timing_started: self.timing_started, + main_document_commit, + }) } } @@ -1148,7 +1527,7 @@ fn inline_html_navigation_source( } async fn load_inline_html_navigation_with_engine_async( - engine: &mut NavigationEngine, + engine: &mut impl BrowserDocumentPageBuilder, page_reservation: RendererPageReservationToken, load_inputs: &TargetNavigationLoadInputs, method: &str, @@ -1198,7 +1577,7 @@ async fn load_inline_html_navigation_with_engine_async( } async fn load_data_url_navigation_with_engine_async( - engine: &mut NavigationEngine, + engine: &mut impl BrowserDocumentPageBuilder, page_reservation: RendererPageReservationToken, load_inputs: &TargetNavigationLoadInputs, method: &str, @@ -1238,7 +1617,7 @@ async fn load_data_url_navigation_with_engine_async( } async fn build_navigation_from_streaming_raw_response_with_engine_async( - engine: &mut NavigationEngine, + engine: &mut impl BrowserDocumentPageBuilder, page_reservation: RendererPageReservationToken, load_inputs: &TargetNavigationLoadInputs, requested_url: Url, @@ -1303,8 +1682,6 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( })); } - let (fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type) = - load_inputs.fetch_subresource_interception; body_progress_source.emit_response_metadata( &request_method, &request_headers, @@ -1379,40 +1756,20 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( .materialize_bytes() .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) .map_err(|error| format!("failed to materialize XML page body: {error}"))?; - let page_storage = load_inputs.page_storage_handles(); let main_document_commit = load_inputs .main_document_commit_for_final_url(&final_url, None) .map(Arc::new); let prepared_page = engine - .prepare_document_page_from_response_with_storage_and_inspector_session_restores_async( + .prepare_protocol_document_page( page_reservation, - page_storage.into_navigation_storage(), + load_inputs, requested_url.clone(), final_url.clone(), - load_inputs.navigation_initiator_url.clone(), redirected, redirect_chain.len(), response_status, response_headers.clone(), response_text, - load_inputs.document_start_scripts.clone(), - load_inputs.runtime_bindings.clone(), - load_inputs - .runtime_inspector_session_restore_snapshots - .clone(), - load_inputs.extra_http_headers.clone(), - load_inputs.locale_override.clone(), - load_inputs.timezone_override.clone(), - load_inputs.script_execution_disabled, - load_inputs.bypass_content_security_policy, - load_inputs.cpu_throttling_rate, - load_inputs.emulated_media.clone(), - load_inputs.viewport_surface, - load_inputs.network_offline, - load_inputs.blocked_url_patterns.clone(), - fetch_subresource_interception_enabled, - fetch_subresource_interception_resource_type, - load_inputs.root_frame_id.clone(), resource_source, main_document_commit.as_deref().cloned(), ) @@ -1452,46 +1809,25 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( let (body_tx, body_rx) = mpsc::channel(EXTERNAL_RAW_BODY_CHANNEL_CAPACITY); let (completion_tx, completion_rx) = oneshot::channel(); let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new(body_rx, completion_rx); - let page_storage = load_inputs.page_storage_handles(); let main_document_commit = load_inputs .main_document_commit_for_final_url(&final_url, None) .map(Arc::new); - let prepared_future = engine - .prepare_streaming_raw_page_from_external_body_with_storage_and_inspector_session_restores_async( - page_reservation, - page_storage.into_navigation_storage(), - requested_url.clone(), - final_url.clone(), - load_inputs.navigation_initiator_url.clone(), - response.redirected, - redirect_chain.len(), - response_status, - response_headers.clone(), - raw_body, - load_inputs.document_start_scripts.clone(), - load_inputs.runtime_bindings.clone(), - load_inputs - .runtime_inspector_session_restore_snapshots - .clone(), - load_inputs.extra_http_headers.clone(), - load_inputs.locale_override.clone(), - load_inputs.timezone_override.clone(), - load_inputs.script_execution_disabled, - load_inputs.bypass_content_security_policy, - load_inputs.cpu_throttling_rate, - load_inputs.emulated_media.clone(), - load_inputs.viewport_surface, - load_inputs.network_offline, - load_inputs.blocked_url_patterns.clone(), - fetch_subresource_interception_enabled, - fetch_subresource_interception_resource_type, - PageVmInitStage::DomContentLoaded, - reply_boundary, - load_inputs.root_frame_id.clone(), - resource_source, - reserved_service_worker_client, - main_document_commit.as_deref().cloned(), - ); + let prepared_future = engine.prepare_protocol_streaming_raw_page( + page_reservation, + load_inputs, + requested_url.clone(), + final_url.clone(), + response.redirected, + redirect_chain.len(), + response_status, + response_headers.clone(), + raw_body, + PageVmInitStage::DomContentLoaded, + reply_boundary, + resource_source, + reserved_service_worker_client, + main_document_commit.as_deref().cloned(), + ); let prepared_future = async { prepared_future .await @@ -1608,13 +1944,13 @@ impl CdpConnection { .flatten() } - pub(crate) async fn prepare_paused_streaming_response_navigation_async( + pub(crate) fn background_paused_streaming_response_navigation_preparation_job( &mut self, navigation: &NavigationDispatchState, response: &StreamingRawResponse, network_observation_journal: &NetworkObservationJournal, body_progress_source: MainDocumentBodyProgressSource, - ) -> Result, String> { + ) -> Result, String> { if super::downloads::response_headers_indicate_download(&response.headers) || response_headers_indicate_xml_document(&response.headers) || response_status_may_use_http_error_page(response.status) @@ -1626,7 +1962,6 @@ impl CdpConnection { return Ok(None); } let requested_url = navigation.requested_url.clone(); - let network_extra_info_available = !network_observation_journal.is_empty(); let request_method = navigation.request_method.clone(); let request_headers = navigation.request_headers.clone(); let timing_started = moli_trace::cdp_nav_timing_enabled().then(std::time::Instant::now); @@ -1637,123 +1972,57 @@ impl CdpConnection { let response_from_cache = response.from_cache; let negotiated_http_version = response.negotiated_http_version; let initial_request_cookie_report = response.request_cookie_report.clone(); - let redirect_chain = response - .redirect_chain - .clone() - .into_iter() - .map(Into::into) - .collect::>(); - let network_events = CompletedMainDocumentNetworkEvents::new( - request_method.clone(), - request_headers.clone(), - initial_request_cookie_report, - response_status, - response_headers.clone(), - response_cookie_reports.clone(), - redirect_chain.clone(), - network_extra_info_available, - response_from_cache, - ) - .with_negotiated_http_version(negotiated_http_version) - .with_network_observation_journal(network_observation_journal.clone()); - let body_network_progress_state = - body_progress_source.body_network_progress_for_completed_events(network_events); - let (renderer_body_tx, renderer_body_rx) = - mpsc::channel(EXTERNAL_RAW_BODY_CHANNEL_CAPACITY); - let (renderer_completion_tx, renderer_completion_rx) = oneshot::channel(); - let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new( - renderer_body_rx, - renderer_completion_rx, - ); + let redirect_chain = response.redirect_chain.clone(); let shared_resource_runtime = self.shared_resource_runtime_for_navigation_load_inputs(&load_inputs); - let mut engine = self.background_navigation_engine_for_load_inputs(&load_inputs); - if let Some(resource_runtime) = shared_resource_runtime { - engine - .adopt_registered_resource_runtime(resource_runtime) - .map_err(|error| error.to_string())?; - } - let (fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type) = - load_inputs.fetch_subresource_interception; - let page_storage = load_inputs.page_storage_handles(); - let main_document_commit = load_inputs - .main_document_commit_for_final_url(&final_url, None) - .map(Arc::new); + let engine = self.background_navigation_engine_for_load_inputs(&load_inputs); let page_reservation = self.reserve_renderer_page_for_session_owner( navigation.navigate_session_id.as_deref(), &load_inputs, &engine, ); - let prepared_page = engine - .prepare_streaming_raw_page_from_external_body_with_storage_and_inspector_session_restores_async( + Ok(Some( + BackgroundPausedStreamingResponseNavigationPreparationJob { + engine, page_reservation, - page_storage.into_navigation_storage(), - requested_url.clone(), - final_url.clone(), - load_inputs.navigation_initiator_url.clone(), - response.redirected, - redirect_chain.len(), + load_inputs, + requested_url, + final_url, + request_method, + request_headers, response_status, - response_headers.clone(), - raw_body, - load_inputs.document_start_scripts.clone(), - load_inputs.runtime_bindings.clone(), - load_inputs - .runtime_inspector_session_restore_snapshots - .clone(), - load_inputs.extra_http_headers.clone(), - load_inputs.locale_override.clone(), - load_inputs.timezone_override.clone(), - load_inputs.script_execution_disabled, - load_inputs.bypass_content_security_policy, - load_inputs.cpu_throttling_rate, - load_inputs.emulated_media.clone(), - load_inputs.viewport_surface, - load_inputs.network_offline, - load_inputs.blocked_url_patterns.clone(), - fetch_subresource_interception_enabled, - fetch_subresource_interception_resource_type, - PageVmInitStage::DomContentLoaded, - RendererReplyBoundary::DocumentCommit, - load_inputs.root_frame_id.clone(), - CommittedDocumentResourceSource::Synthetic, - None, - main_document_commit.as_deref().cloned(), - ) - .await - .map_err(|error| { - format!( - "failed to prepare response-stage page `{}`: {error:#}", - requested_url - ) - })?; - if let Some(started) = timing_started { - tracing::info!( - target: "moli_cdp_nav_timing", - url = %requested_url, - stage = "response_stage_document_prepared", - elapsed_ms = started.elapsed().as_millis(), - ); - } - Ok(Some(PausedResponsePreparedDocument { - prepared_page, - renderer_body_tx, - renderer_completion_tx, + response_headers, + response_cookie_reports, + response_from_cache, + negotiated_http_version, + initial_request_cookie_report, + redirected: response.redirected, + redirect_chain, + network_observation_journal: network_observation_journal.clone(), + body_progress_source, + shared_resource_runtime, + timing_started, + }, + )) + } + + pub(crate) async fn prepare_paused_streaming_response_navigation_async( + &mut self, + navigation: &NavigationDispatchState, + response: &StreamingRawResponse, + network_observation_journal: &NetworkObservationJournal, + body_progress_source: MainDocumentBodyProgressSource, + ) -> Result, String> { + let Some(job) = self.background_paused_streaming_response_navigation_preparation_job( + navigation, + response, + network_observation_journal, body_progress_source, - body_network_progress_state, - requested_url, - final_url, - request_method, - request_headers, - response_status, - response_headers, - response_from_cache, - negotiated_http_version, - network_observation_journal: network_observation_journal.clone(), - engine, - timing_started, - main_document_commit, - })) + )? + else { + return Ok(None); + }; + job.run().await.map(Some) } pub(crate) fn start_initial_document_page_ensure_for_session_owner( @@ -1894,9 +2163,9 @@ impl CdpConnection { // command; binding after `start_*` would leave a real cross-thread race // where the concrete FIFO receives its first publication ownerless. let page_owner = self - .pending_target_page_residence_identity_for_session(session_id) + .target_page_owner_key_for_session(session_id) .ok_or_else(|| "initial document Page reservation lost its target owner".to_owned())?; - self.bind_renderer_page_output_owner(renderer_page, page_owner); + self.bind_renderer_page_output_target(renderer_page, page_owner); let pending = engine .start_build_html_page_from_response_with_storage_and_inspector_session_restores( page_reservation, @@ -2034,10 +2303,10 @@ impl CdpConnection { &self, owner: &InitialDocumentPageOwner, ) -> bool { - self.browser_context_by_id(&owner.browser_context_id) - .is_some_and(|browser_context| { - browser_context.can_install_current_initial_empty_document_page(&owner.target_id) - }) + self.can_install_target_initial_empty_document_page(&BrowserPageOwnerKey::new( + &owner.browser_context_id, + &owner.target_id, + )) } fn fail_initial_document_page_build_for_owner( @@ -2108,6 +2377,7 @@ impl CdpConnection { let install_result = self .install_initial_loaded_page_for_page_owner_async(&owner, page, page_creation_artifacts) .await + .map_err(|error| error.to_string()) .inspect_err(|message| { self.fail_initial_document_page_build_for_owner(&owner, message.clone()); })?; @@ -2116,7 +2386,11 @@ impl CdpConnection { return Ok(LoadedPageCreationDiagnosticsParts::default()); } if let Some(engine) = engine { - self.adopt_loaded_navigation_engine_for_page_owner(&owner, engine); + self.adopt_loaded_navigation_engine_for_target_owner( + BrowserPageOwnerKey::new(owner.browser_context_id.clone(), owner.target_id.clone()), + engine, + ) + .map_err(|error| format!("failed to adopt initial Document engine: {error}"))?; } self.complete_initial_document_page_build_for_page_owner(&owner); Ok(diagnostics) @@ -2270,56 +2544,11 @@ impl CdpConnection { request_headers: Vec<(String, String)>, body_progress_source: MainDocumentBodyProgressSource, ) -> Result { - if load_inputs.browser_context_id.is_none() { - let page_reservation = self.engine.reserve_page_for_creation(); - if let Some(navigation) = self - .load_inline_html_navigation_async( - page_reservation, - &load_inputs, - method, - raw_url, - request_headers.clone(), - ) - .await - { - return navigation; - } - if let Some(navigation) = load_data_url_navigation_with_engine_async( - &mut self.engine, - page_reservation, - &load_inputs, - method, - raw_url, - request_headers.clone(), - RendererReplyBoundary::Stage, - ) - .await - { - return navigation; - } - } 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, - ); - if let Some(navigation) = self - .load_inline_html_navigation_with_engine_async( - &mut inline_engine, - page_reservation, - &load_inputs, - method, - raw_url, - request_headers.clone(), - RendererReplyBoundary::Stage, - ) - .await - { - return navigation - .map(|navigation| navigation.with_navigation_engine(inline_engine)); - } - if let Some(navigation) = load_data_url_navigation_with_engine_async( + 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); + if let Some(navigation) = self + .load_inline_html_navigation_with_engine_async( &mut inline_engine, page_reservation, &load_inputs, @@ -2329,10 +2558,21 @@ impl CdpConnection { RendererReplyBoundary::Stage, ) .await - { - return navigation - .map(|navigation| navigation.with_navigation_engine(inline_engine)); - } + { + return navigation.map(|navigation| navigation.with_navigation_engine(inline_engine)); + } + if let Some(navigation) = load_data_url_navigation_with_engine_async( + &mut inline_engine, + page_reservation, + &load_inputs, + method, + raw_url, + request_headers.clone(), + RendererReplyBoundary::Stage, + ) + .await + { + return navigation.map(|navigation| navigation.with_navigation_engine(inline_engine)); } ensure_url_not_blocked_for_load_inputs(&load_inputs, raw_url)?; @@ -2362,60 +2602,240 @@ impl CdpConnection { None, Vec::new(), body_progress_source, - ) - .await + ) + .await + } + + pub(crate) fn background_navigation_load_job_for_navigation( + &mut self, + navigation: &NavigationDispatchState, + body_progress_source: MainDocumentBodyProgressSource, + early_outcome: Option, + ) -> BackgroundNavigationLoadJob { + let load_inputs = self.navigation_load_inputs_for_navigation(navigation); + // Ensure the connection's resident browser resource runtime exists, + // then share only that transport/cache owner with the background job. + // 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( + navigation.navigate_session_id.as_deref(), + &load_inputs, + &engine, + ); + BackgroundNavigationLoadJob { + engine, + page_reservation, + cancellation: BackgroundNavigationCancellation::new(), + early_outcome, + load_inputs, + method: navigation.request_method.clone(), + raw_url: navigation.requested_url.as_str().to_owned(), + body: navigation.clone_request_body_bytes(), + request_headers: navigation.request_headers.clone(), + body_progress_source, + shared_resource_runtime, + } + } + + pub(crate) fn background_intercepted_navigation_fetch_job_for_navigation( + &mut self, + navigation: &NavigationDispatchState, + auth: Option, + mode: BackgroundInterceptedNavigationFetchMode, + ) -> Result { + let load_inputs = self.navigation_load_inputs_for_navigation(navigation); + let raw_url = navigation.requested_url.as_str().to_owned(); + ensure_url_not_blocked_for_load_inputs(&load_inputs, &raw_url)?; + if load_inputs.network_offline { + return Err("Network emulation offline".to_owned()); + } + + let mut request = Request::new_bytes( + &navigation.request_method, + &raw_url, + navigation.clone_request_body_bytes(), + navigation.request_headers.clone(), + ) + .map_err(|error| format!("failed to build request for `{raw_url}`: {error}"))? + .with_top_level_navigation_cookie_context() + .with_browser_navigation_kind(load_inputs.browser_navigation_kind); + if !load_inputs.infer_navigation_referrer { + request = request.without_inferred_referrer(); + } + if let Some(initiator_url) = load_inputs.navigation_initiator_url.as_ref() { + request = request.with_initiator_url(initiator_url); + } + request.set_auth(auth.map(Into::into)); + let loader = self + .ensure_resource_request_client_for_navigation_load_inputs(&load_inputs)? + .clone(); + Ok(BackgroundInterceptedNavigationFetchJob { + loader, + request, + raw_url, + mode, + }) + } + + pub(crate) fn background_streaming_response_collection_job( + &self, + response: NetworkFetchResult, + ) -> BackgroundStreamingResponseCollectionJob { + BackgroundStreamingResponseCollectionJob { response } + } + + pub(crate) fn background_buffered_response_navigation_load_job_for_navigation( + &mut self, + navigation: &NavigationDispatchState, + response: NetworkFetchResult, + body_progress_source: MainDocumentBodyProgressSource, + ) -> BackgroundBufferedResponseNavigationLoadJob { + 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 = + (!super::downloads::response_headers_indicate_download(&response.response().headers)) + .then(|| { + self.reserve_renderer_page_for_session_owner( + navigation.navigate_session_id.as_deref(), + &load_inputs, + &engine, + ) + }); + BackgroundBufferedResponseNavigationLoadJob { + engine, + page_reservation, + load_inputs, + requested_url: navigation.requested_url.clone(), + request_method: navigation.request_method.clone(), + request_headers: navigation.request_headers.clone(), + response, + body_progress_source, + shared_resource_runtime, + } + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn background_captured_response_navigation_load_job_for_navigation( + &mut self, + navigation: &NavigationDispatchState, + mut head: ResponseHead, + body: CapturedBody, + network_observation_journal: NetworkObservationJournal, + body_progress_source: MainDocumentBodyProgressSource, + response_code: Option, + response_headers_override: Vec<(String, String)>, + ) -> BackgroundCapturedResponseNavigationLoadJob { + let load_inputs = self.navigation_load_inputs_for_navigation(navigation); + let has_response_override = + response_code.is_some() || !response_headers_override.is_empty(); + if has_response_override { + let response_status = response_code.unwrap_or(head.status); + let response_headers = if response_headers_override.is_empty() { + head.headers + } else { + response_headers_override + }; + let response_cookie_reports = + load_inputs.store_response_cookie_reports(&head.final_url, &response_headers); + // Preserve the previous buffered-override semantics: an explicit + // Fetch override becomes a synthetic response head rather than + // inheriting redirect/cache transport metadata from the wire. + head = ResponseHead { + final_url: head.final_url, + status: response_status, + headers: response_headers, + request_cookie_report: head.request_cookie_report, + cookie_set_reports: response_cookie_reports, + redirected: false, + redirect_chain: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }; + } + let allow_download = !has_response_override; + 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 = if allow_download + && super::downloads::response_headers_indicate_download(&head.headers) + { + None + } else { + Some(self.reserve_renderer_page_for_session_owner( + navigation.navigate_session_id.as_deref(), + &load_inputs, + &engine, + )) + }; + BackgroundCapturedResponseNavigationLoadJob { + engine, + page_reservation, + load_inputs, + requested_url: navigation.requested_url.clone(), + request_method: navigation.request_method.clone(), + request_headers: navigation.request_headers.clone(), + head, + body, + network_observation_journal, + body_progress_source, + allow_download, + reply_boundary: RendererReplyBoundary::DocumentCommit, + shared_resource_runtime, + } } - pub(crate) fn navigation_load_job_for_navigation( + #[allow(clippy::too_many_arguments)] + pub(crate) fn background_synthetic_response_navigation_load_job_for_navigation( &mut self, - token: &DocumentNavigationToken, navigation: &NavigationDispatchState, + final_url: Url, + response_code: u16, + response_headers: Vec<(String, String)>, + body: CapturedBody, + request_cookie_report: Option, body_progress_source: MainDocumentBodyProgressSource, - early_result: Option, - ) -> Option { - let cancellation = self.document_navigation_cancellation_handle(token)?; + ) -> BackgroundCapturedResponseNavigationLoadJob { let load_inputs = self.navigation_load_inputs_for_navigation(navigation); - // Ensure the connection's resident browser resource runtime exists, - // then share only that transport/cache owner with the background job. - // Page policy remains owned by the job's NavigationEngine. + let response_cookie_reports = + load_inputs.store_response_cookie_reports(&final_url, &response_headers); + let head = ResponseHead { + final_url, + status: response_code, + headers: response_headers, + request_cookie_report, + cookie_set_reports: response_cookie_reports, + redirected: false, + redirect_chain: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }; 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 page_reservation = Some(self.reserve_renderer_page_for_session_owner( navigation.navigate_session_id.as_deref(), &load_inputs, &engine, - ); - Some(BackgroundNavigationLoadJob { + )); + BackgroundCapturedResponseNavigationLoadJob { engine, page_reservation, - cancellation, - early_result, load_inputs, - method: navigation.request_method.clone(), - raw_url: navigation.requested_url.as_str().to_owned(), - body: navigation.clone_request_body_bytes(), + requested_url: navigation.requested_url.clone(), + request_method: navigation.request_method.clone(), request_headers: navigation.request_headers.clone(), + head, + body, + network_observation_journal: NetworkObservationJournal::default(), body_progress_source, + allow_download: false, + reply_boundary: RendererReplyBoundary::Stage, shared_resource_runtime, - }) - } - - pub(crate) fn background_navigation_load_job_for_navigation( - &mut self, - token: &DocumentNavigationToken, - navigation: &NavigationDispatchState, - body_progress_source: MainDocumentBodyProgressSource, - early_result: Option, - ) -> Option { - let job = self.navigation_load_job_for_navigation( - token, - navigation, - body_progress_source, - early_result, - )?; - self.arm_background_navigation_completion(token, None) - .then_some(job) + } } pub(crate) fn background_streaming_response_navigation_load_job_for_navigation( @@ -2495,14 +2915,16 @@ impl CdpConnection { Some(browser_context_id.as_str()), "navigation Page reservation must bind inside its captured browser context" ); - let renderer_page = RendererPageResidenceIdentity::from_parts( - page_reservation.local_host_id(), - page_reservation.page_id(), - ); let page_owner = self - .reserve_target_page_residence_identity_for_session(session_id, renderer_page) + .target_page_owner_key_for_session(session_id) .expect("navigation Page reservation must retain its target owner"); - self.bind_renderer_page_output_owner(renderer_page, page_owner); + self.bind_renderer_page_output_target( + RendererPageResidenceIdentity::from_parts( + page_reservation.local_host_id(), + page_reservation.page_id(), + ), + page_owner, + ); } } @@ -2510,31 +2932,44 @@ impl CdpConnection { &self, load_inputs: &TargetNavigationLoadInputs, ) -> NavigationEngine { + // Before a BrowserContext/Target is installed, `load_inputs` carries a + // synthetic renderer-context runtime on every call. It is storage + // input, not a renderer-owner identity. Replacing the unbound engine + // from that transient runtime would drop the render-runtime owner of + // Pages returned by an earlier fixture load. Keep the exact selected + // renderer owner while moving an engine clone across the await. + if load_inputs.browser_context_id.is_none() { + return self.active_navigation_engine_for_async_load(load_inputs); + } + // Same-browser-context CDP background targets retain only another // NavigationEngine wrapper; their page contexts still live under the // same renderer owner. Different browser contexts keep distinct // renderer owners. let mut engine = if self - .engine - .browser_context_runtime() - .shares_state_with(&load_inputs.renderer_runtime.runtime()) + .browser_host_state + .navigation_owner() + .active_runtime_shares_state_with(&load_inputs.renderer_runtime) { - NavigationEngine::new_with_runtime_config_and_shared_renderer_owner( - self.navigation_runtime_config_for_load_inputs(load_inputs), - &self.engine, - ) - .expect("active shared BrowserContext owner must be live") - } else if let Some(engine) = self - .retained_background_navigation_engine_for_load_inputs(load_inputs) - .cloned() + self.browser_host_state + .navigation_owner() + .new_engine_sharing_active_renderer_owner( + self.fetch_config_for_load_inputs(load_inputs), + ) + .expect("active shared BrowserContext owner must be live") + } else if let Some(engine) = + self.cloned_retained_background_navigation_engine_for_load_inputs(load_inputs) { engine } else { - NavigationEngine::new_with_runtime_config_and_browser_context_access( - self.navigation_runtime_config_for_load_inputs(load_inputs), - load_inputs.renderer_runtime.clone(), - ) - .expect("navigation load BrowserContext owner must be live") + let fetch_config = self.fetch_config_for_load_inputs(load_inputs); + self.browser_host_state + .navigation_owner() + .new_engine_for_browser_context_runtime( + fetch_config, + load_inputs.renderer_runtime.clone(), + ) + .expect("navigation load BrowserContext owner must be live") }; self.apply_fetch_overrides_to_background_navigation_engine(&mut engine, load_inputs); if !self.active_resource_runtime_matches_navigation_load_inputs(load_inputs) { @@ -2553,23 +2988,36 @@ impl CdpConnection { engine } - fn navigation_runtime_config_for_load_inputs( + /// Moves the selected engine's renderer capability into one async load + /// without borrowing Browser Host state across the wait. + /// + /// The returned clone is intentionally not a target handoff payload. The + /// authoritative selected engine remains resident and keeps every Page + /// built by active-owner setup helpers alive, matching their pre-split + /// contract. + fn active_navigation_engine_for_async_load( &self, load_inputs: &TargetNavigationLoadInputs, - ) -> moli_core::runtime::NavigationRuntimeConfig { - let mut config = self.engine.runtime_config(); - *config.fetch_config_mut() = self.fetch_config_for_load_inputs(load_inputs); - config + ) -> NavigationEngine { + let mut engine = self + .browser_host_state + .navigation_owner() + .clone_active_navigation_engine(); + self.apply_fetch_overrides_to_background_navigation_engine(&mut engine, load_inputs); + engine.set_bypass_service_worker(load_inputs.bypass_service_worker); + self.apply_scheduler_senders_to_navigation_engine(&engine); + engine } - fn retained_background_navigation_engine_for_load_inputs( + fn cloned_retained_background_navigation_engine_for_load_inputs( &self, load_inputs: &TargetNavigationLoadInputs, - ) -> Option<&NavigationEngine> { + ) -> Option { let browser_context_id = load_inputs.browser_context_id.as_ref()?; let target_id = load_inputs.root_frame_id.as_ref()?; - self.retained_background_navigation_engines - .get(&(browser_context_id.clone(), target_id.clone())) + self.browser_host_state + .navigation_owner() + .clone_retained_background_engine(browser_context_id, target_id) } fn apply_fetch_overrides_to_background_navigation_engine( @@ -2577,29 +3025,30 @@ impl CdpConnection { engine: &mut NavigationEngine, load_inputs: &TargetNavigationLoadInputs, ) { + let policy = self.browser_host_network_policy_snapshot(); engine.set_browser_identity_override( load_inputs .browser_identity_override .clone() - .or_else(|| self.global_browser_identity_override.clone()) - .unwrap_or_else(|| self.base_browser_identity.clone()), + .or_else(|| policy.global_browser_identity_override().cloned()) + .unwrap_or_else(|| policy.base_browser_identity().clone()), ); engine.set_http_proxy_override( load_inputs .http_proxy_override .clone() - .or_else(|| self.base_http_proxy.clone()), + .or_else(|| policy.base_http_proxy().map(str::to_owned)), ); engine.set_http_no_proxy_override( load_inputs .http_no_proxy_override .clone() - .or_else(|| self.base_http_no_proxy.clone()), + .or_else(|| policy.base_http_no_proxy().map(str::to_owned)), ); engine.set_tls_verify_host( load_inputs .tls_verify_host_override - .unwrap_or(self.base_tls_verify_host), + .unwrap_or(policy.base_tls_verify_host()), ); } @@ -2611,13 +3060,12 @@ impl CdpConnection { return None; } let resource_storage = load_inputs.resource_storage_handles(); - self.engine - .ensure_resource_runtime_ready_for_navigation_storage( - resource_storage.into_navigation_storage(), - ) + self.browser_host_state + .ensure_active_resource_runtime_ready(resource_storage.into_navigation_storage()) .ok()?; - self.engine - .resource_request_client() + self.browser_host_state + .navigation_owner() + .active_resource_request_client() .map(|client| client.browser_resource_runtime()) } @@ -2626,47 +3074,53 @@ impl CdpConnection { load_inputs: &TargetNavigationLoadInputs, ) -> bool { if !self - .engine - .browser_context_runtime() - .shares_state_with(&load_inputs.renderer_runtime.runtime()) + .browser_host_state + .navigation_owner() + .active_runtime_shares_state_with(&load_inputs.renderer_runtime) { return false; } let fetch_config = self.fetch_config_for_load_inputs(load_inputs); - fetch_config.browser_identity() == self.engine.fetch_config().browser_identity() - && fetch_config.http_proxy() == self.http_proxy() - && fetch_config.http_no_proxy() == self.http_no_proxy() - && fetch_config.tls_verify_host() == self.tls_verify_host() + let active_fetch_config = self + .browser_host_state + .navigation_owner() + .active_fetch_config() + .clone(); + fetch_config.browser_identity() == active_fetch_config.browser_identity() + && fetch_config.http_proxy() == active_fetch_config.http_proxy() + && fetch_config.http_no_proxy() == active_fetch_config.http_no_proxy() + && fetch_config.tls_verify_host() == active_fetch_config.tls_verify_host() } fn fetch_config_for_load_inputs( &self, load_inputs: &TargetNavigationLoadInputs, ) -> FetchConfig { - let mut fetch_config = self.fetch_config().clone(); + let policy = self.browser_host_network_policy_snapshot(); + let mut fetch_config = self.fetch_config(); fetch_config.set_browser_identity( load_inputs .browser_identity_override .clone() - .or_else(|| self.global_browser_identity_override.clone()) - .unwrap_or_else(|| self.base_browser_identity.clone()), + .or_else(|| policy.global_browser_identity_override().cloned()) + .unwrap_or_else(|| policy.base_browser_identity().clone()), ); fetch_config.set_http_proxy( load_inputs .http_proxy_override .clone() - .or_else(|| self.base_http_proxy.clone()), + .or_else(|| policy.base_http_proxy().map(str::to_owned)), ); fetch_config.set_http_no_proxy( load_inputs .http_no_proxy_override .clone() - .or_else(|| self.base_http_no_proxy.clone()), + .or_else(|| policy.base_http_no_proxy().map(str::to_owned)), ); fetch_config.set_tls_verify_host( load_inputs .tls_verify_host_override - .unwrap_or(self.base_tls_verify_host), + .unwrap_or(policy.base_tls_verify_host()), ); fetch_config } @@ -2674,31 +3128,12 @@ impl CdpConnection { pub async fn load_page_via_runtime_async(&mut self, raw_url: &str) -> Result { let navigation = self.load_navigation_via_runtime_async(raw_url).await?; if let Some(engine) = navigation.navigation_engine { - self.replace_navigation_engine(engine); + self.adopt_navigation_engine_for_current_owner(engine) + .map_err(|error| format!("failed to adopt loaded Page engine: {error}"))?; } Ok(navigation.page) } - async fn load_inline_html_navigation_async( - &mut self, - page_reservation: RendererPageReservationToken, - load_inputs: &TargetNavigationLoadInputs, - method: &str, - raw_url: &str, - request_headers: Vec<(String, String)>, - ) -> Option> { - load_inline_html_navigation_with_engine_async( - &mut self.engine, - page_reservation, - load_inputs, - method, - raw_url, - request_headers, - RendererReplyBoundary::Stage, - ) - .await - } - async fn load_inline_html_navigation_with_engine_async( &mut self, engine: &mut NavigationEngine, @@ -2749,6 +3184,7 @@ impl CdpConnection { response_body, None, initial_request_cookie_report, + false, ) .await } @@ -2834,6 +3270,7 @@ impl CdpConnection { response_body, None, initial_request_cookie_report, + false, ) .await } @@ -2896,14 +3333,11 @@ 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( - session_id, - load_inputs, - page_reservation, - ); - prepare_navigation_from_captured_raw_response_with_engine_async( - &mut self.engine, + 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 navigation = prepare_navigation_from_captured_raw_response_with_engine_async( + &mut engine, page_reservation, load_inputs, requested_url, @@ -2917,8 +3351,8 @@ impl CdpConnection { false, RendererReplyBoundary::Stage, ) - .await - .map(NavigationLoadOutcome::response_commit_ready) + .await?; + Ok(NavigationLoadOutcome::response_commit_ready(navigation).with_navigation_engine(engine)) } async fn build_loaded_navigation_from_buffered_response_with_request_cookie_report_async( @@ -2932,45 +3366,28 @@ impl CdpConnection { response_body: String, captured_response_body: Option, initial_request_cookie_report: Option, + retain_navigation_engine_for_owner_adoption: bool, ) -> Result { let response_cookie_reports = load_inputs.store_response_cookie_reports(&requested_url, &response_headers); - let (fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type) = - load_inputs.fetch_subresource_interception; - let page_storage = load_inputs.page_storage_handles(); let main_document_commit = load_inputs .main_document_commit_for_final_url(&requested_url, None) .map(Arc::new); - let built = self - .engine - .build_html_page_from_response_with_storage_and_inspector_session_restores_async( - page_storage.into_navigation_storage(), + let mut engine = if retain_navigation_engine_for_owner_adoption { + self.background_navigation_engine_for_load_inputs(load_inputs) + } else { + self.active_navigation_engine_for_async_load(load_inputs) + }; + let built = engine + .build_protocol_html_page( + load_inputs, requested_url.clone(), requested_url.clone(), - load_inputs.navigation_initiator_url.clone(), false, 0, response_status, response_headers.clone(), response_body.clone(), - load_inputs.document_start_scripts.clone(), - load_inputs.runtime_bindings.clone(), - load_inputs - .runtime_inspector_session_restore_snapshots - .clone(), - load_inputs.extra_http_headers.clone(), - load_inputs.locale_override.clone(), - load_inputs.timezone_override.clone(), - load_inputs.script_execution_disabled, - load_inputs.bypass_content_security_policy, - load_inputs.cpu_throttling_rate, - load_inputs.emulated_media.clone(), - load_inputs.viewport_surface, - load_inputs.network_offline, - load_inputs.blocked_url_patterns.clone(), - fetch_subresource_interception_enabled, - fetch_subresource_interception_resource_type, - load_inputs.root_frame_id.clone(), main_document_commit.as_deref().cloned(), ) .await @@ -3022,7 +3439,7 @@ impl CdpConnection { false, network_progress, ), - navigation_engine: None, + navigation_engine: retain_navigation_engine_for_owner_adoption.then_some(engine), network_error_page: None, }) } @@ -3041,9 +3458,8 @@ impl CdpConnection { return Err("Network emulation offline".to_owned()); } let resource_storage = load_inputs.resource_storage_handles(); - self.engine - .set_bypass_service_worker(load_inputs.bypass_service_worker); - self.engine + let mut engine = self.background_navigation_engine_for_load_inputs(&load_inputs); + let fetch_result = engine .fetch_navigation_response_with_storage_async( resource_storage.into_navigation_storage(), load_inputs.navigation_initiator_url.as_ref(), @@ -3055,8 +3471,10 @@ impl CdpConnection { request_headers, auth, ) - .await - .map_err(|error| format!("failed to fetch page `{raw_url}`: {error}")) + .await; + self.adopt_navigation_engine_for_current_owner(engine) + .map_err(|error| format!("failed to restore navigation engine after fetch: {error}"))?; + fetch_result.map_err(|error| format!("failed to fetch page `{raw_url}`: {error}")) } pub(crate) async fn fetch_navigation_auth_raw_response_for_session_owner_async( @@ -3179,6 +3597,18 @@ impl CdpConnection { .map_err(|error| format!("failed to fetch page `{raw_url}`: {error}")) } + pub fn ensure_navigation_response_status( + &self, + raw_url: &str, + status: u16, + allow_auth_challenge: bool, + ) -> Result<(), String> { + self.browser_host_state + .navigation_owner() + .ensure_active_navigation_response_status(raw_url, status, allow_auth_challenge) + .map_err(|error| error.to_string()) + } + pub async fn build_navigation_from_network_response_async( &mut self, requested_url: Url, @@ -3208,8 +3638,6 @@ impl CdpConnection { let (response, network_observation_journal) = response.into_parts_with_observation_journal(); let network_extra_info_available = !network_observation_journal.is_empty(); - let (fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type) = - load_inputs.fetch_subresource_interception; let (response_head, response_body, response_body_bytes) = response.into_parts(); let final_url = response_head.final_url.clone(); let response_status = response_head.status; @@ -3226,40 +3654,20 @@ impl CdpConnection { .into_iter() .map(Into::into) .collect(); - let page_storage = load_inputs.page_storage_handles(); let main_document_commit = load_inputs .main_document_commit_for_final_url(&final_url, None) .map(Arc::new); - let built = self - .engine - .build_html_page_from_response_with_storage_and_inspector_session_restores_async( - page_storage.into_navigation_storage(), + let mut engine = self.background_navigation_engine_for_load_inputs(&load_inputs); + let built = engine + .build_protocol_html_page( + &load_inputs, requested_url.clone(), final_url.clone(), - load_inputs.navigation_initiator_url.clone(), redirected, redirect_chain.len(), response_status, response_headers.clone(), response_body, - load_inputs.document_start_scripts.clone(), - load_inputs.runtime_bindings.clone(), - load_inputs - .runtime_inspector_session_restore_snapshots - .clone(), - load_inputs.extra_http_headers.clone(), - load_inputs.locale_override.clone(), - load_inputs.timezone_override.clone(), - load_inputs.script_execution_disabled, - load_inputs.bypass_content_security_policy, - load_inputs.cpu_throttling_rate, - load_inputs.emulated_media.clone(), - load_inputs.viewport_surface, - load_inputs.network_offline, - load_inputs.blocked_url_patterns.clone(), - fetch_subresource_interception_enabled, - fetch_subresource_interception_resource_type, - load_inputs.root_frame_id.clone(), main_document_commit.as_deref().cloned(), ) .await @@ -3312,7 +3720,7 @@ impl CdpConnection { false, network_progress, ), - navigation_engine: None, + navigation_engine: Some(engine), network_error_page: None, }) } @@ -3464,14 +3872,11 @@ 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, + 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 navigation = prepare_navigation_from_captured_raw_response_with_engine_async( + &mut engine, page_reservation, load_inputs, requested_url, @@ -3485,8 +3890,8 @@ 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( @@ -3591,28 +3996,6 @@ impl CdpConnection { ) -> Result { let (response, network_observation_journal) = response.into_parts_with_observation_journal(); - if load_inputs.browser_context_id.is_none() { - let page_reservation = self.engine.reserve_page_for_creation(); - return build_navigation_from_streaming_raw_response_with_engine_async( - &mut self.engine, - page_reservation, - load_inputs, - requested_url, - request_method, - request_headers, - response, - network_observation_journal, - response_code, - response_headers_override, - body_progress_source, - None, - None, - CommittedDocumentResourceSource::Synthetic, - RendererReplyBoundary::Stage, - ) - .await; - } - 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); @@ -3660,31 +4043,12 @@ impl CdpConnection { response: RawResponse, network_observation_journal: NetworkObservationJournal, ) -> DownloadNavigation { - let network_extra_info_available = !network_observation_journal.is_empty(); - let (head, body) = response.into_body(); - let body = body - .try_into_materialized_bytes() - .expect("RawResponse body should remain materialized at the download boundary"); - let response_from_cache = head.from_cache; - let negotiated_http_version = head.negotiated_http_version; - let final_url = head.final_url; - let network_events = CompletedMainDocumentNetworkEvents::new( + materialize_download_from_raw_response( 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, - response_from_cache, + response, + network_observation_journal, ) - .with_negotiated_http_version(negotiated_http_version) - .with_network_observation_journal(network_observation_journal); - DownloadNavigation { - final_url, - progress_transfer: CompletedDownloadProgressTransfer::new(body, network_events), - } } #[cfg(test)] @@ -3703,8 +4067,41 @@ impl CdpConnection { } } +fn materialize_download_from_raw_response( + request_method: String, + request_headers: Vec<(String, String)>, + response: RawResponse, + network_observation_journal: NetworkObservationJournal, +) -> DownloadNavigation { + let network_extra_info_available = !network_observation_journal.is_empty(); + let (head, body) = response.into_body(); + let body = body + .try_into_materialized_bytes() + .expect("RawResponse body should remain materialized at the download boundary"); + let response_from_cache = head.from_cache; + let negotiated_http_version = head.negotiated_http_version; + let final_url = head.final_url; + 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, + response_from_cache, + ) + .with_negotiated_http_version(negotiated_http_version) + .with_network_observation_journal(network_observation_journal); + DownloadNavigation { + final_url, + progress_transfer: CompletedDownloadProgressTransfer::new(body, network_events), + } +} + async fn prepare_navigation_from_captured_raw_response_with_engine_async( - engine: &mut NavigationEngine, + engine: &mut impl BrowserDocumentPageBuilder, page_reservation: RendererPageReservationToken, load_inputs: &TargetNavigationLoadInputs, requested_url: Url, @@ -3721,7 +4118,7 @@ async fn prepare_navigation_from_captured_raw_response_with_engine_async( let network_extra_info_available = !network_observation_journal.is_empty(); if network_error_page.is_none() && response_status_may_use_http_error_page(head.status) - && body.len() == 0 + && body.is_empty() { body_progress_source.emit_response_metadata( &request_method, @@ -3773,7 +4170,7 @@ async fn prepare_navigation_from_captured_raw_response_with_engine_async( #[allow(clippy::too_many_arguments)] async fn prepare_captured_document_response_with_engine_async( - engine: &mut NavigationEngine, + engine: &mut impl BrowserDocumentPageBuilder, page_reservation: RendererPageReservationToken, load_inputs: &TargetNavigationLoadInputs, requested_url: Url, @@ -3802,8 +4199,6 @@ async fn prepare_captured_document_response_with_engine_async( head.from_cache, head.negotiated_http_version, ); - let (fetch_subresource_interception_enabled, fetch_subresource_interception_resource_type) = - load_inputs.fetch_subresource_interception; let response_from_cache = head.from_cache; let negotiated_http_version = head.negotiated_http_version; let final_url = head.final_url; @@ -3837,46 +4232,25 @@ async fn prepare_captured_document_response_with_engine_async( let (body_tx, body_rx) = mpsc::channel(EXTERNAL_RAW_BODY_CHANNEL_CAPACITY); let (completion_tx, completion_rx) = oneshot::channel(); let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new(body_rx, completion_rx); - let page_storage = load_inputs.page_storage_handles(); let main_document_commit = load_inputs .main_document_commit_for_final_url(&final_url, network_error_page.as_ref()) .map(Arc::new); - let prepared_future = engine - .prepare_streaming_raw_page_from_external_body_with_storage_and_inspector_session_restores_async( - page_reservation, - page_storage.into_navigation_storage(), - requested_url.clone(), - final_url.clone(), - load_inputs.navigation_initiator_url.clone(), - redirected, - redirect_chain.len(), - response_status, - response_headers.clone(), - raw_body, - load_inputs.document_start_scripts.clone(), - load_inputs.runtime_bindings.clone(), - load_inputs - .runtime_inspector_session_restore_snapshots - .clone(), - load_inputs.extra_http_headers.clone(), - load_inputs.locale_override.clone(), - load_inputs.timezone_override.clone(), - load_inputs.script_execution_disabled, - load_inputs.bypass_content_security_policy, - load_inputs.cpu_throttling_rate, - load_inputs.emulated_media.clone(), - load_inputs.viewport_surface, - load_inputs.network_offline, - load_inputs.blocked_url_patterns.clone(), - fetch_subresource_interception_enabled, - fetch_subresource_interception_resource_type, - PageVmInitStage::DomContentLoaded, - reply_boundary, - load_inputs.root_frame_id.clone(), - CommittedDocumentResourceSource::Synthetic, - None, - main_document_commit.as_deref().cloned(), - ); + let prepared_future = engine.prepare_protocol_streaming_raw_page( + page_reservation, + load_inputs, + requested_url.clone(), + final_url.clone(), + redirected, + redirect_chain.len(), + response_status, + response_headers.clone(), + raw_body, + PageVmInitStage::DomContentLoaded, + reply_boundary, + CommittedDocumentResourceSource::Synthetic, + None, + main_document_commit.as_deref().cloned(), + ); let body_capture_task = spawn_captured_body_replay(body, body_tx, completion_tx); let prepared_page = match prepared_future.await { Ok(prepared_page) => prepared_page, @@ -3973,10 +4347,11 @@ async fn apply_navigation_load_input_overrides_async( #[cfg(test)] mod tests { use super::{ - BackgroundNavigationEarlyResult, decode_data_url_body, decode_data_url_response, + BackgroundNavigationEarlyOutcome, decode_data_url_body, decode_data_url_response, decode_text_html_data_url, decoded_data_url_navigation_response, inline_html_navigation_source, }; + use crate::conn::browser_background_output_channel; use serde_json::json; #[test] @@ -4072,16 +4447,17 @@ mod tests { } #[test] - fn background_navigation_early_result_emits_typed_command_response() { - let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); - let early_result = BackgroundNavigationEarlyResult::new( + fn background_navigation_early_outcome_projects_typed_command_response() { + let (sender, mut receiver) = browser_background_output_channel(); + let early_outcome = BackgroundNavigationEarlyOutcome::new( sender, 42, Some("SID-nav".to_owned()), + "https://example.test/", json!({ "frameId": "FRAME-1", "loaderId": "LOADER-1" }), ); - assert!(early_result.emit()); + assert!(early_outcome.emit()); let event = receiver .try_recv() .expect("early navigation result should be sent"); @@ -4091,6 +4467,16 @@ mod tests { event.protocol_message().is_none(), "early Page.navigate result should stay as a typed command response until wire projection" ); + let (command_id, outcome) = event + .browser_navigate_command_outcome() + .expect("early response should retain its protocol-neutral Browser outcome"); + assert_eq!(command_id, Some(42)); + let moli_core::browser_host::BrowserNavigateCommandOutcome::Completed(result) = outcome + else { + panic!("expected a completed Browser navigation outcome"); + }; + assert_eq!(result.requested_url(), "https://example.test/"); + assert_eq!(result.loader_id(), Some("LOADER-1")); assert_eq!( event.into_protocol_message(), json!({ diff --git a/moli-protocol/src/conn/scheduler_hooks.rs b/moli-protocol/src/conn/scheduler_hooks.rs index 7be1aeff0c..e82c3b2dd0 100644 --- a/moli-protocol/src/conn/scheduler_hooks.rs +++ b/moli-protocol/src/conn/scheduler_hooks.rs @@ -1,5 +1,3 @@ -use moli_core::RendererOutputTransportSender; - use super::{BackgroundEventSender, RuntimeInspectorResponseReadySender}; #[derive(Default)] @@ -8,7 +6,6 @@ pub(super) struct CdpSchedulerHooks { background_navigation_completion_sender: Option< tokio::sync::mpsc::UnboundedSender, >, - renderer_publication_sender: Option, runtime_inspector_response_ready_sender: Option, } @@ -54,15 +51,4 @@ impl CdpSchedulerHooks { pub(super) fn has_background_navigation_completion_sender(&self) -> bool { self.background_navigation_completion_sender.is_some() } - - pub(super) fn set_renderer_publication_sender( - &mut self, - sender: RendererOutputTransportSender, - ) { - self.renderer_publication_sender = Some(sender); - } - - pub(super) fn renderer_publication_sender(&self) -> Option { - self.renderer_publication_sender.clone() - } } diff --git a/moli-protocol/src/conn/scheduler_state.rs b/moli-protocol/src/conn/scheduler_state.rs index 6c05d6cdcb..24225a9858 100644 --- a/moli-protocol/src/conn/scheduler_state.rs +++ b/moli-protocol/src/conn/scheduler_state.rs @@ -7,8 +7,114 @@ use serde_json::{Value, json}; const RECENT_ACTIVITY_TRACE_LIMIT: usize = 128; +/// Transport cancellation authority for one exact background main-resource +/// navigation. +/// +/// The protocol scheduler keeps this beside the navigation gate so starting a +/// replacement in the same frame can abort the superseded fetch immediately, +/// matching the lifetime of Chromium's frame-owned `NavigationRequest`. +#[derive(Clone, Debug, Default)] +pub struct BackgroundNavigationCancellation { + handle: moli_fetch::FetchCancelHandle, +} + +impl BackgroundNavigationCancellation { + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.handle.cancel(); + } + + pub fn is_cancelled(&self) -> bool { + self.handle.is_cancelled() + } + + pub(crate) fn fetch_cancel_handle(&self) -> moli_fetch::FetchCancelHandle { + self.handle.clone() + } + + pub(crate) fn from_fetch_cancel_handle(handle: moli_fetch::FetchCancelHandle) -> Self { + Self { handle } + } +} + +#[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. + // Frontend correlation remains in protocol dispatch state; the + // browser-owned token deliberately contains no session identity. + Self { + target_id: Some(token.target_id().to_owned()), + session_id: state + .session_id + .clone() + .or_else(|| state.navigate_session_id.clone()), + frame_id: state.frame_id.clone(), + loader_id: token.loader_id().to_owned(), + navigation_request_id: Some(token.request_id().get()), + } + } + + /// Whether a newly-started navigation owns the same browsing-context + /// lane as an older in-flight navigation. + /// + /// Session identity is intentionally not part of a target-backed lane: + /// two DevTools sessions can address the same frame, but a cross-document + /// navigation from either session still supersedes the frame's previous + /// navigation. The exact loader/request fields remain part of equality so + /// a late completion cannot retire the replacement gate. + pub fn supersedes(&self, pending: &Self) -> bool { + if self.frame_id != pending.frame_id { + return false; + } + match (&self.target_id, &pending.target_id) { + (Some(target_id), Some(pending_target_id)) => target_id == pending_target_id, + (None, None) => self.session_id == pending.session_id, + (Some(_), None) | (None, Some(_)) => false, + } + } + + #[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 { + BackgroundNavigationStarted { + key: BackgroundNavigationGateKey, + cancellation: BackgroundNavigationCancellation, + }, ProtocolWorkPublished { work: crate::domains::activity::ProtocolSchedulerWork, }, @@ -227,10 +333,73 @@ impl CdpRendererOwnerTurnOutcome { } } +impl From for CdpRendererOwnerTurnOutcome { + fn from(turn: CdpTurnOutcome) -> Self { + Self { + turn, + renderer_output_predecessor: None, + } + } +} + #[cfg(test)] mod tests { use super::*; + use crate::conn::NavigationResultProjection; + use crate::conn::state::{ + DocumentNavigationToken, NavigationDispatchState, NavigationRequestLoadPolicy, + NavigationSourceDocumentSecurityContext, + }; use crate::devtools_runtime::AutomationEvent; + use url::Url; + + fn navigation_dispatch_state( + navigate_session_id: Option<&str>, + event_session_id: Option<&str>, + ) -> NavigationDispatchState { + NavigationDispatchState { + navigate_id: Some(7), + navigate_session_id: navigate_session_id.map(str::to_owned), + result_projection: NavigationResultProjection::Cdp(Value::Null), + frame_id: "frame-1".to_owned(), + session_id: event_session_id.map(str::to_owned), + request_id: None, + loader_id: "loader-1".to_owned(), + request_announced: false, + requested_url: Url::parse("https://example.test/").unwrap(), + request_method: "GET".to_owned(), + request_body: None, + request_body_bytes: None, + request_headers: Vec::new(), + request_load_policy: NavigationRequestLoadPolicy::BrowserInitiated, + timestamp: 1.0, + source_document_security: NavigationSourceDocumentSecurityContext::default(), + post_commit_target_activation: None, + } + } + + #[test] + fn background_gate_keeps_frontend_session_outside_browser_request_identity() { + let token = DocumentNavigationToken::new("target-1", "loader-1"); + let event_state = navigation_dispatch_state(Some("SID-command"), Some("SID-event")); + let event_key = BackgroundNavigationGateKey::for_navigation(&token, &event_state); + + assert_eq!(event_key.target_id.as_deref(), Some("target-1")); + assert_eq!(event_key.loader_id, "loader-1"); + assert_eq!(event_key.session_id.as_deref(), Some("SID-event")); + assert_eq!( + event_key.navigation_request_id, + Some(token.request_id().get()) + ); + + let command_state = navigation_dispatch_state(Some("SID-command"), None); + let command_key = BackgroundNavigationGateKey::for_navigation(&token, &command_state); + assert_eq!(command_key.session_id.as_deref(), Some("SID-command")); + assert_eq!( + command_key.navigation_request_id, + event_key.navigation_request_id + ); + } #[test] fn turn_outcome_raw_protocol_messages_regain_typed_sidecars() { @@ -266,6 +435,19 @@ mod tests { && event.mode == "selectSingle" )); } + + #[test] + fn frontend_projection_sequence_is_monotonic_and_not_protocol_work_order() { + let mut state = CdpConnectionSchedulerState::default(); + + assert_eq!(state.allocate_frontend_projection_sequence(), 1); + assert_eq!( + state.allocate_protocol_work_publish_sequence().get(), + 1, + "protocol work uses an independent order domain" + ); + assert_eq!(state.allocate_frontend_projection_sequence(), 2); + } } #[derive(Default)] @@ -273,6 +455,7 @@ pub(super) struct CdpConnectionSchedulerState { pending_navigation_background_events: Vec, next_deferred_main_document_load_observation_id: u64, next_protocol_work_publish_sequence: u64, + next_frontend_projection_sequence: u64, pub(super) renderer_output_ingress: crate::domains::activity::OrderedRendererOutputIngress, scheduler_events: Vec, recent_activity_traces: VecDeque, @@ -280,6 +463,14 @@ pub(super) struct CdpConnectionSchedulerState { } impl CdpConnectionSchedulerState { + pub(super) fn allocate_frontend_projection_sequence(&mut self) -> u64 { + self.next_frontend_projection_sequence = self + .next_frontend_projection_sequence + .checked_add(1) + .expect("frontend projection sequence exhausted"); + self.next_frontend_projection_sequence + } + pub(super) fn allocate_protocol_work_publish_sequence( &mut self, ) -> crate::domains::activity::ProtocolWorkPublishSequence { diff --git a/moli-protocol/src/conn/settings/engine.rs b/moli-protocol/src/conn/settings/engine.rs index b8d475b71f..492b3d0911 100644 --- a/moli-protocol/src/conn/settings/engine.rs +++ b/moli-protocol/src/conn/settings/engine.rs @@ -1,78 +1,94 @@ use super::*; use crate::conn::{EmulatedGeolocationOverrideState, EmulatedNetworkConditions}; +use moli_core::browser_host::BrowserHostPolicyUpdate; impl CdpConnection { pub(crate) fn apply_active_engine_fetch_overrides(&mut self) { + let policy = self.browser_host_network_policy_snapshot(); let browser_identity = self .browser_context .as_ref() .and_then(|bc| bc.effective_active_browser_identity_override_owned()) - .or_else(|| self.global_browser_identity_override.clone()) - .unwrap_or_else(|| self.base_browser_identity.clone()); + .or_else(|| policy.global_browser_identity_override().cloned()) + .unwrap_or_else(|| policy.base_browser_identity().clone()); let http_proxy = self .browser_context .as_ref() .and_then(|bc| bc.http_proxy_override.clone()) - .or_else(|| self.base_http_proxy.clone()); + .or_else(|| policy.base_http_proxy().map(str::to_owned)); let http_no_proxy = self .browser_context .as_ref() .and_then(|bc| bc.http_no_proxy_override.clone()) - .or_else(|| self.base_http_no_proxy.clone()); + .or_else(|| policy.base_http_no_proxy().map(str::to_owned)); let tls_verify_host = self .browser_context .as_ref() .and_then(|bc| bc.tls_verify_host_override) - .unwrap_or(self.base_tls_verify_host); + .unwrap_or(policy.base_tls_verify_host()); let bypass_service_worker = self .browser_context .as_ref() .is_some_and(|bc| bc.network_policy.bypass_service_worker()); - self.engine.set_browser_identity_override(browser_identity); - self.engine.set_http_proxy_override(http_proxy); - self.engine.set_http_no_proxy_override(http_no_proxy); - self.engine.set_tls_verify_host(tls_verify_host); - self.engine.set_bypass_service_worker(bypass_service_worker); + self.browser_host_state.configure_active_fetch( + moli_core::browser_host::BrowserPageFetchConfiguration { + browser_identity, + http_proxy, + http_no_proxy, + tls_verify_host, + bypass_service_worker, + }, + ); } pub async fn set_tls_verify_host_async(&mut self, enabled: bool) { if let Some(browser_context) = self.browser_context.as_mut() { browser_context.tls_verify_host_override = Some(enabled); } else { - self.base_tls_verify_host = enabled; + self.apply_browser_host_policy_update(BrowserHostPolicyUpdate::SetBaseTlsVerifyHost( + enabled, + )); } self.apply_active_engine_fetch_overrides(); self.rebuild_resource_runtime_for_loaded_page_async().await; } pub fn tls_verify_host(&self) -> bool { + let base_tls_verify_host = self + .browser_host_network_policy_snapshot() + .base_tls_verify_host(); self.browser_context .as_ref() .and_then(|bc| bc.tls_verify_host_override) - .unwrap_or(self.base_tls_verify_host) + .unwrap_or(base_tls_verify_host) } - pub fn user_agent(&self) -> &str { + pub fn user_agent(&self) -> String { + let policy = self.browser_host_network_policy_snapshot(); self.browser_context .as_ref() .and_then(|bc| bc.effective_active_browser_identity_override()) - .or(self.global_browser_identity_override.as_ref()) - .unwrap_or(&self.base_browser_identity) + .or_else(|| policy.global_browser_identity_override()) + .unwrap_or_else(|| policy.base_browser_identity()) .user_agent() + .to_owned() } pub async fn set_user_agent_override_async(&mut self, user_agent: impl Into) { let user_agent = user_agent.into(); + let policy = self.browser_host_network_policy_snapshot(); let browser_identity = moli_browser_profile::BrowserIdentityProfile::new( user_agent.clone(), - self.base_browser_identity.accept_language(), + policy.base_browser_identity().accept_language(), ); if let Some(browser_context) = self.browser_context.as_mut() { browser_context .network_policy .set_browser_identity_override(browser_identity); } else { - self.base_browser_identity = browser_identity; + self.apply_browser_host_policy_update(BrowserHostPolicyUpdate::SetBaseBrowserIdentity( + browser_identity, + )); } self.apply_active_engine_fetch_overrides(); self.rebuild_resource_runtime_for_loaded_page_async().await; @@ -82,12 +98,19 @@ impl CdpConnection { &mut self, user_agent: Option, ) { - self.global_browser_identity_override = user_agent.as_ref().map(|user_agent| { + let base_identity = self + .browser_host_network_policy_snapshot() + .base_browser_identity() + .clone(); + let identity = user_agent.as_ref().map(|user_agent| { moli_browser_profile::BrowserIdentityProfile::new( user_agent.clone(), - self.base_browser_identity.accept_language(), + base_identity.accept_language(), ) }); + self.apply_browser_host_policy_update( + BrowserHostPolicyUpdate::SetGlobalBrowserIdentityOverride(identity), + ); self.apply_active_engine_fetch_overrides(); } @@ -95,7 +118,9 @@ impl CdpConnection { &mut self, conditions: Option, ) { - self.global_network_conditions = conditions; + self.apply_browser_host_policy_update(BrowserHostPolicyUpdate::SetGlobalNetworkConditions( + conditions, + )); if let Some(browser_context) = self.browser_context.as_mut() { browser_context.global_network_conditions = conditions; } @@ -108,7 +133,9 @@ impl CdpConnection { &mut self, override_state: Option, ) { - self.global_geolocation_override = override_state.clone(); + self.apply_browser_host_policy_update( + BrowserHostPolicyUpdate::SetGlobalGeolocationOverride(override_state.clone()), + ); if let Some(browser_context) = self.browser_context.as_mut() { browser_context.global_geolocation_override = override_state.clone(); } @@ -122,17 +149,21 @@ impl CdpConnection { if let Some(browser_context) = self.browser_context.as_mut() { browser_context.http_proxy_override = proxy; } else { - self.base_http_proxy = proxy; + self.apply_browser_host_policy_update(BrowserHostPolicyUpdate::SetBaseHttpProxy(proxy)); } self.apply_active_engine_fetch_overrides(); self.rebuild_resource_runtime_for_loaded_page_async().await; } - pub fn http_proxy(&self) -> Option<&str> { + pub fn http_proxy(&self) -> Option { + let base_http_proxy = self + .browser_host_network_policy_snapshot() + .base_http_proxy() + .map(str::to_owned); self.browser_context .as_ref() - .and_then(|bc| bc.http_proxy_override.as_deref()) - .or(self.base_http_proxy.as_deref()) + .and_then(|bc| bc.http_proxy_override.clone()) + .or(base_http_proxy) } pub(crate) fn http_proxy_for_session_owner_owned( @@ -141,21 +172,34 @@ impl CdpConnection { ) -> Option { self.navigation_load_inputs_for_session_owner(session_id) .http_proxy_override - .or_else(|| self.base_http_proxy.clone()) + .or_else(|| { + self.browser_host_network_policy_snapshot() + .base_http_proxy() + .map(str::to_owned) + }) } - pub fn http_no_proxy(&self) -> Option<&str> { + pub fn http_no_proxy(&self) -> Option { + let base_http_no_proxy = self + .browser_host_network_policy_snapshot() + .base_http_no_proxy() + .map(str::to_owned); self.browser_context .as_ref() - .and_then(|bc| bc.http_no_proxy_override.as_deref()) - .or(self.base_http_no_proxy.as_deref()) + .and_then(|bc| bc.http_no_proxy_override.clone()) + .or(base_http_no_proxy) } - pub(crate) fn fetch_config(&self) -> &moli_fetch::FetchConfig { - self.engine.fetch_config() + pub(crate) fn fetch_config(&self) -> moli_fetch::FetchConfig { + self.browser_host_state + .navigation_owner() + .active_fetch_config() + .clone() } - pub(crate) fn base_browser_identity(&self) -> &moli_browser_profile::BrowserIdentityProfile { - &self.base_browser_identity + pub(crate) fn base_browser_identity(&self) -> moli_browser_profile::BrowserIdentityProfile { + self.browser_host_network_policy_snapshot() + .base_browser_identity() + .clone() } } diff --git a/moli-protocol/src/conn/settings/page_apply.rs b/moli-protocol/src/conn/settings/page_apply.rs index f01bee9931..9834793ac1 100644 --- a/moli-protocol/src/conn/settings/page_apply.rs +++ b/moli-protocol/src/conn/settings/page_apply.rs @@ -5,7 +5,8 @@ impl CdpConnection { &self, browser_context_id: &str, ) -> Vec { - self.permission_overrides + self.browser_host_policy_snapshot() + .permission_overrides() .iter() .filter(|entry| { entry.browser_context_id.is_none() diff --git a/moli-protocol/src/conn/state/attachment_identity.rs b/moli-protocol/src/conn/state/attachment_identity.rs index 163b7770f8..7f4276abc9 100644 --- a/moli-protocol/src/conn/state/attachment_identity.rs +++ b/moli-protocol/src/conn/state/attachment_identity.rs @@ -4,7 +4,6 @@ use std::{ }; static NEXT_TARGET_PAGE_ATTACHMENT_ID: AtomicU64 = AtomicU64::new(1); -static NEXT_NAVIGATION_REQUEST_ID: AtomicU64 = AtomicU64::new(1); /// Opaque identity for one current or reserved target Page attachment. /// @@ -32,18 +31,6 @@ impl TargetPageAttachmentId { } } -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -pub(crate) struct NavigationRequestId(NonZeroU64); - -impl NavigationRequestId { - pub(crate) fn allocate() -> Self { - Self(allocate_nonzero_u64( - &NEXT_NAVIGATION_REQUEST_ID, - "navigation request id", - )) - } -} - fn allocate_nonzero_u64(counter: &AtomicU64, name: &str) -> NonZeroU64 { let raw = counter .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { @@ -59,27 +46,19 @@ mod tests { use std::mem::size_of; #[test] - fn attachment_and_navigation_ids_are_nonzero_and_distinct() { + fn attachment_ids_are_nonzero_and_distinct() { let first_attachment = TargetPageAttachmentId::allocate(); let second_attachment = TargetPageAttachmentId::allocate(); - let first_navigation = NavigationRequestId::allocate(); - let second_navigation = NavigationRequestId::allocate(); assert_ne!(first_attachment, second_attachment); - assert_ne!(first_navigation, second_navigation); assert_ne!(first_attachment.get(), 0); - assert_ne!(first_navigation.0.get(), 0); } #[test] - fn optional_ids_preserve_the_nonzero_niche() { + fn optional_attachment_id_preserves_the_nonzero_niche() { assert_eq!( size_of::>(), size_of::() ); - assert_eq!( - size_of::>(), - size_of::() - ); } } diff --git a/moli-protocol/src/conn/state/bounds.rs b/moli-protocol/src/conn/state/bounds.rs deleted file mode 100644 index e4ed56d899..0000000000 --- a/moli-protocol/src/conn/state/bounds.rs +++ /dev/null @@ -1,20 +0,0 @@ -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BrowserWindowBounds { - pub left: Option, - pub top: Option, - pub width: Option, - pub height: Option, - pub window_state: String, -} - -impl Default for BrowserWindowBounds { - fn default() -> Self { - Self { - left: None, - top: None, - width: None, - height: None, - window_state: "normal".to_owned(), - } - } -} diff --git a/moli-protocol/src/conn/state/browser_context.rs b/moli-protocol/src/conn/state/browser_context.rs index 3d7a32d397..97a340a990 100644 --- a/moli-protocol/src/conn/state/browser_context.rs +++ b/moli-protocol/src/conn/state/browser_context.rs @@ -7,6 +7,10 @@ use moli_cookie_jar::{ BrowserCookieStore, CookieSource, NetworkCookieRequestContext, SharedBrowserCookieStore, StoredCookie, StoredCookieQueryReport, new_shared_browser_cookie_store, }; +use moli_core::browser_host::{ + BrowserContextHandle, BrowserPageResidenceHandle, BrowserTargetHandle, + BrowserTargetSessionStorageAccess, EmulatedGeolocationOverrideState, EmulatedNetworkConditions, +}; use moli_core::network::{SharedWebStorageStore, new_shared_web_storage_store}; use moli_core::runtime::{ NavigationPageStorageHandles, NavigationResourceStorageHandles, RendererBrowserContextRuntime, @@ -24,26 +28,22 @@ use super::{ DevToolsSessionState, dedicated_worker_target::DedicatedWorkerTargetState, devtools_sessions_have_pending_inspector_awaits, - devtools_sessions_pending_inspector_await_count, - emulation::{ - EmulatedDeviceMetrics, EmulatedGeolocationOverrideState, EmulatedMediaOverrides, - EmulatedNetworkConditions, - }, + emulation::{EmulatedDeviceMetrics, EmulatedMediaOverrides}, identity::TargetIdentityState, javascript_dialog::TargetPreparedJavaScriptDialog, - page_slot::{DocumentNavigationToken, DocumentStartScript}, - parking::{TargetOwnerState, TargetParkingStateStore}, + page_slot::DocumentStartScript, + parking::TargetParkingStateStore, service_worker_target::ServiceWorkerTargetState, session::{ActiveTargetState, TargetNetworkPolicyState}, shared_worker_target::SharedWorkerTargetState, + target_attachment::TopLevelTargetFrontendSessionRegistry, }; pub struct BrowserContext { pub id: String, + browser_context_handle: BrowserContextHandle, storage_partition: BrowserContextStoragePartition, - target_id: Option, - session_id: Option, - pub auxiliary_target_sessions: HashMap, + target_frontend_sessions: TopLevelTargetFrontendSessionRegistry, // Chromium exposes the live creator target, immutable creator frame, and // window.opener access as three independent TargetInfo properties. pub target_opener_ids: HashMap, @@ -63,10 +63,12 @@ pub struct BrowserContext { pub(crate) service_worker_targets: BTreeMap, pub(crate) service_worker_domain_sessions: BTreeSet>, target_identity: TargetIdentityState, - pub(crate) devtools_session_state: DevToolsSessionState, - pub(crate) auxiliary_devtools_session_states: HashMap, pub(crate) network_policy: TargetNetworkPolicyState, pub(crate) default_extra_headers: Vec<(String, String)>, + // Applied projections of BrowserHostPolicyState for this physical runtime. + // BrowserHostState remains authoritative; these copies exist only because + // Page/worker update participants have not yet moved into the Host + // executor and must never be written back on frontend teardown. pub(crate) global_extra_headers: Vec<(String, String)>, pub(crate) default_browser_identity_override: Option, @@ -106,9 +108,12 @@ pub struct BrowserContext { pub(crate) storage_quota_overrides: HashMap, pub(crate) http_cache_root: Option, pub(crate) http_cache_max_bytes: Option, - // Keep the sole strong per-context network owner last. Protocol teardown - // must drop pages and NavigationEngines before this field is released. - pub(crate) renderer_runtime_owner: Option, + // Non-owning access to the Browser Host-owned renderer/network runtime. + // A newly constructed, not-yet-registered Context temporarily carries the + // unique candidate root below; successful Core registration moves it into + // BrowserHostState before this projection becomes visible. + renderer_runtime_access: RendererBrowserContextRuntimeOwnerAccess, + renderer_runtime_owner_for_registration: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -218,6 +223,7 @@ impl BrowserContextStoragePartitionIdentity { } } + #[cfg(test)] pub(crate) fn kind(&self) -> BrowserContextStoragePartitionKind { self.kind } @@ -284,15 +290,10 @@ impl BrowserContextStoragePartitionHandles { ) } - pub(crate) fn from_storage_partition( - initial_cookies: impl IntoIterator, - storage_partition: &StoragePartitionState, - ) -> Self { - let cookie_store = new_shared_browser_cookie_store(); - seed_initial_cookies(&cookie_store, initial_cookies); + pub(crate) fn from_storage_partition(storage_partition: &StoragePartitionState) -> Self { let shared_storage = storage_partition.shared_storage_handles(); Self::from_initial_storage_partition( - cookie_store, + shared_storage.cookie_store(), shared_storage.web_storage_store(), shared_storage.indexed_db_manager(), shared_storage.storage_bucket_store(), @@ -370,9 +371,10 @@ impl std::fmt::Debug for BrowserContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BrowserContext") .field("id", &self.id) + .field("browser_context_handle", &self.browser_context_handle) .field("storage_partition", &self.storage_partition) - .field("target_id", &self.target_id) - .field("session_id", &self.session_id) + .field("target_handle", &self.active_target.target_handle) + .field("session_id", &self.active_session_id()) .field("has_loaded_page", &self.has_loaded_page()) .finish_non_exhaustive() } @@ -383,6 +385,22 @@ fn usize_to_u64_saturating(value: usize) -> u64 { } impl BrowserContext { + pub(crate) fn adopt_browser_network_artifact_store( + &mut self, + browser_artifacts: moli_core::browser_host::BrowserNetworkArtifactStore, + ) { + self.active_target + .runtime_slot + .adopt_browser_network_artifact_store(browser_artifacts.clone()); + for target in &mut self.background_targets { + target + .runtime_slot + .adopt_browser_network_artifact_store(browser_artifacts.clone()); + } + self.target_parking + .adopt_browser_network_artifact_store(browser_artifacts); + } + /// Parks a dialog only until the matching lightweight-popup target obtains /// a concrete protocol attachment. /// @@ -497,13 +515,15 @@ impl BrowserContext { identity: BrowserContextStoragePartitionIdentity, ) -> Self { let storage_partition = BrowserContextStoragePartition::new(identity, partition); + let browser_context_handle = BrowserContextHandle::staged(id.clone()); + let renderer_runtime_owner = RendererBrowserContextRuntime::new(); + let renderer_runtime_access = renderer_runtime_owner.owner_access(); Self { id, + browser_context_handle, storage_partition, - target_id: None, - session_id: None, - auxiliary_target_sessions: HashMap::new(), + target_frontend_sessions: TopLevelTargetFrontendSessionRegistry::default(), target_opener_ids: HashMap::new(), target_opener_frame_ids: HashMap::new(), target_can_access_opener: HashSet::new(), @@ -516,8 +536,6 @@ impl BrowserContext { service_worker_targets: BTreeMap::new(), service_worker_domain_sessions: BTreeSet::new(), target_identity: TargetIdentityState::about_blank(), - devtools_session_state: DevToolsSessionState::default(), - auxiliary_devtools_session_states: HashMap::new(), network_policy: TargetNetworkPolicyState::default(), default_extra_headers: Vec::new(), global_extra_headers: Vec::new(), @@ -557,10 +575,24 @@ impl BrowserContext { storage_quota_overrides: HashMap::new(), http_cache_root, http_cache_max_bytes, - renderer_runtime_owner: Some(RendererBrowserContextRuntime::new()), + renderer_runtime_access, + renderer_runtime_owner_for_registration: Some(renderer_runtime_owner), } } + pub(crate) fn browser_context_handle(&self) -> &BrowserContextHandle { + &self.browser_context_handle + } + + #[cfg(test)] + pub(crate) fn replace_browser_context_handle( + &mut self, + handle: BrowserContextHandle, + ) -> BrowserContextHandle { + std::mem::replace(&mut self.browser_context_handle, handle) + } + + #[cfg(test)] pub(crate) fn is_profile_backed_storage_partition(&self) -> bool { self.storage_partition.identity().kind() == BrowserContextStoragePartitionKind::ProfileBacked @@ -586,6 +618,17 @@ impl BrowserContext { .page_storage_handles(self.active_target.session_storage_namespace.store().clone()) } + pub(crate) fn target_session_storage_store( + &self, + target_id: &str, + ) -> Option { + if self.is_active_target(target_id) { + return Some(self.active_target.session_storage_namespace.store().clone()); + } + self.background_target(target_id) + .map(|target| target.session_storage_store().clone()) + } + pub(crate) fn page_storage_handles_for_target( &self, target_id: &str, @@ -712,23 +755,22 @@ impl BrowserContext { } pub(crate) fn renderer_runtime(&self) -> RendererBrowserContextRuntime { - self.renderer_runtime_owner - .as_ref() - .expect("BrowserContext renderer owner was already taken for teardown") - .handle() + self.renderer_runtime_access.runtime() } pub(crate) fn renderer_runtime_owner_access(&self) -> RendererBrowserContextRuntimeOwnerAccess { - self.renderer_runtime_owner - .as_ref() - .expect("BrowserContext renderer owner was already taken for teardown") - .owner_access() + self.renderer_runtime_access.clone() } - pub(crate) fn take_renderer_runtime_owner_for_teardown( + pub(crate) fn take_renderer_runtime_owner_for_host_registration( &mut self, ) -> Option { - self.renderer_runtime_owner.take() + self.renderer_runtime_owner_for_registration.take() + } + + #[cfg(test)] + pub(crate) fn carries_renderer_runtime_registration_owner_for_test(&self) -> bool { + self.renderer_runtime_owner_for_registration.is_some() } pub(crate) fn routes_renderer_browser_context_runtime( @@ -746,7 +788,7 @@ impl BrowserContext { .loaded_page() .is_some_and(|page| page.renderer_owner_local_host_id() == owner_local_host_id) { - return self.target_id.clone(); + return self.active_target_id_owned(); } self.background_targets.iter().find_map(|target| { target @@ -772,20 +814,20 @@ impl BrowserContext { let service_worker_target_pending_inspector_await_count = self.service_worker_target_pending_inspector_await_count_for_diagnostics(); let auxiliary_devtools_pending_inspector_await_count: usize = self - .auxiliary_devtools_session_states + .auxiliary_devtools_session_states() .values() .map(DevToolsSessionState::pending_inspector_await_count) .sum(); let runtime_session_diagnostics = json!({ - "runtimeEnabled": self.devtools_session_state.runtime_session_state.runtime_frontend_enabled, - "inspectorEnabled": self.devtools_session_state.runtime_session_state.inspector_enabled, - "inspectorTargetCrashedDelivered": self.devtools_session_state.runtime_session_state.inspector_target_crashed_delivered(), + "runtimeEnabled": self.devtools_session_state().runtime_session_state.runtime_frontend_enabled, + "inspectorEnabled": self.devtools_session_state().runtime_session_state.inspector_enabled, + "inspectorTargetCrashedDelivered": self.devtools_session_state().runtime_session_state.inspector_target_crashed_delivered(), "profilerCommandStateSource": "renderer-v8-inspector-agent", - "v8InspectorStateBytes": self.devtools_session_state.inspector_session_state.v8_state.as_ref().map_or(0, |state| state.len()), - "auxiliaryDevToolsSessionStateCount": self.auxiliary_devtools_session_states.len(), - "pendingInspectorAwaitCount": self.devtools_session_state.pending_inspector_await_count() + "v8InspectorStateBytes": self.devtools_session_state().inspector_session_state.v8_state.as_ref().map_or(0, |state| state.len()), + "auxiliaryDevToolsSessionStateCount": self.auxiliary_devtools_session_states().len(), + "pendingInspectorAwaitCount": self.devtools_session_state().pending_inspector_await_count() + auxiliary_devtools_pending_inspector_await_count, - "primaryPendingInspectorAwaitCount": self.devtools_session_state.pending_inspector_await_count(), + "primaryPendingInspectorAwaitCount": self.devtools_session_state().pending_inspector_await_count(), "auxiliaryPendingInspectorAwaitCount": auxiliary_devtools_pending_inspector_await_count, }); json!({ @@ -813,7 +855,7 @@ impl BrowserContext { .iter() .filter(|info| info.attached) .count(), - "auxiliaryTargetSessionCount": self.auxiliary_target_sessions.len(), + "auxiliaryTargetSessionCount": self.target_frontend_sessions.auxiliary_session_count(), "targetOpenerCount": self.target_opener_ids.len(), "targetOpenerFrameCount": self.target_opener_frame_ids.len(), "targetCanAccessOpenerCount": self.target_can_access_opener.len(), @@ -846,21 +888,21 @@ impl BrowserContext { }, "runtimeSession": runtime_session_diagnostics, "pageSession": { - "pageLifecycleEvents": self.devtools_session_state.page_session_state.page_lifecycle_events, - "logEnabled": self.devtools_session_state.page_session_state.log_enabled, - "consoleEnabled": self.devtools_session_state.console_output_session_state.console_enabled, + "pageLifecycleEvents": self.devtools_session_state().page_session_state.page_lifecycle_events, + "logEnabled": self.devtools_session_state().page_session_state.log_enabled, + "consoleEnabled": self.devtools_session_state().console_output_session_state.console_enabled, "performanceEnabled": self - .devtools_session_state + .devtools_session_state() .page_session_state .performance .enabled(), "performanceTimeDomain": self - .devtools_session_state + .devtools_session_state() .page_session_state .performance .time_domain() .as_str(), - "pageFontFamilyCount": self.devtools_session_state.page_session_state.page_font_families.len(), + "pageFontFamilyCount": self.devtools_session_state().page_session_state.page_font_families.len(), }, "activeRuntimeSlot": self.active_target.runtime_slot.moli_memory_diagnostics(), "activeFetch": self.active_target.fetch_owner.moli_memory_diagnostics(), @@ -927,52 +969,6 @@ impl BrowserContext { }) } - pub(crate) fn assert_target_materialized_initial_empty_document_has_page( - &self, - target_id: &str, - ) -> Result<(), String> { - if self.active_target_id() == Some(target_id) { - return materialized_initial_empty_document_missing_page_error( - target_id, - &self.active_target.owner_state, - self.has_loaded_page(), - ) - .map_or(Ok(()), Err); - } - - let Some(target) = self.background_target(target_id) else { - return Ok(()); - }; - let Some(owner_state) = self.parked_target_owner_state(target_id) else { - return Ok(()); - }; - materialized_initial_empty_document_missing_page_error( - target_id, - owner_state, - target.has_loaded_page(), - ) - .map_or(Ok(()), Err) - } - - pub(crate) fn can_install_current_initial_empty_document_page(&self, target_id: &str) -> bool { - if self.active_target_id() == Some(target_id) { - return !self.has_loaded_page() - && self - .active_target - .owner_state - .can_install_current_initial_empty_document_page(); - } - - let Some(target) = self.background_target(target_id) else { - return false; - }; - if target.has_loaded_page() { - return false; - } - self.parked_target_owner_state(target_id) - .is_none_or(TargetOwnerState::can_install_current_initial_empty_document_page) - } - pub(crate) fn loaded_document_renderer_owner_ids_for_diagnostics(&self) -> HashSet { let mut owner_ids = HashSet::new(); if let Some(page) = self.loaded_page() { @@ -998,13 +994,13 @@ impl BrowserContext { pub(crate) fn dedicated_worker_running_worker_isolate_count_for_diagnostics(&self) -> usize { self.loaded_pages_for_diagnostics() - .map( - moli_core::page::Page::dedicated_worker_running_worker_isolate_count_for_diagnostics, - ) + .map(|page| page.dedicated_worker_running_worker_isolate_count_for_diagnostics()) .sum() } - fn loaded_pages_for_diagnostics(&self) -> impl Iterator { + fn loaded_pages_for_diagnostics( + &self, + ) -> impl Iterator + '_ { self.loaded_page().into_iter().chain( self.background_targets .iter() @@ -1013,35 +1009,76 @@ impl BrowserContext { } pub(crate) fn page_target_pending_inspector_await_count_for_diagnostics(&self) -> usize { - devtools_sessions_pending_inspector_await_count( - &self.devtools_session_state, - &self.auxiliary_devtools_session_states, - ) + self.target_parking.pending_inspector_await_count() + self.target_frontend_sessions + .session_states() + .map(DevToolsSessionState::pending_inspector_await_count) + .sum() } pub(crate) fn has_pending_javascript_dialog(&self) -> bool { - !self - .devtools_session_state - .page_session_state - .javascript_dialog_state - .is_empty() - || self - .auxiliary_devtools_session_states - .values() - .any(|session| { - !session - .page_session_state - .javascript_dialog_state - .is_empty() - }) - || self.target_parking.has_pending_javascript_dialog() + self.target_frontend_sessions + .session_states() + .any(|session| { + !session + .page_session_state + .javascript_dialog_state + .is_empty() + }) } pub(crate) fn page_target_with_pending_inspector_await_count_for_diagnostics(&self) -> usize { - usize::from(devtools_sessions_have_pending_inspector_awaits( - &self.devtools_session_state, - &self.auxiliary_devtools_session_states, - )) + self.target_parking.pending_inspector_await_target_count() + let bound_target_count = self + .target_frontend_sessions + .target_entries() + .filter(|(_, primary, auxiliary)| { + devtools_sessions_have_pending_inspector_awaits(primary, auxiliary) + }) + .count(); + bound_target_count + + if self.has_active_target() { + 0 + } else { + usize::from(devtools_sessions_have_pending_inspector_awaits( + self.devtools_session_state(), + self.auxiliary_devtools_session_states(), + )) + } + } + + pub(crate) fn has_page_domain_enabled_session(&self) -> bool { + self.target_frontend_sessions + .session_states() + .any(|state| state.page_session_state.page_domain_enabled) + } + + pub(crate) fn has_runtime_remote_object_id_in_top_level_target(&self, object_id: &str) -> bool { + self.target_frontend_sessions + .session_states() + .any(|state| state.has_runtime_remote_object_id(object_id)) + } + + pub(crate) fn runtime_remote_object_id_known_for_different_top_level_owner( + &self, + target_id: &str, + devtools_session_id: Option<&str>, + object_id: &str, + ) -> bool { + let current_target = self.top_level_target_handle(target_id); + self.target_frontend_sessions + .target_entries() + .any(|(target, primary, auxiliary)| { + if Some(target) != current_target { + return primary.has_runtime_remote_object_id(object_id) + || auxiliary + .values() + .any(|state| state.has_runtime_remote_object_id(object_id)); + } + (devtools_session_id.is_some() && primary.has_runtime_remote_object_id(object_id)) + || auxiliary.iter().any(|(session_id, state)| { + Some(session_id.as_str()) != devtools_session_id + && state.has_runtime_remote_object_id(object_id) + }) + }) } pub(crate) fn shared_worker_target_pending_inspector_await_count_for_diagnostics( @@ -1087,233 +1124,10 @@ impl BrowserContext { .shared_worker_runtime_diagnostics_for_diagnostics() } - #[cfg(test)] - pub(crate) fn start_document_navigation_for_active_target( - &mut self, - loader_id: String, - ) -> Option { - let target_id = self.active_target_id()?.to_owned(); - let token = self - .active_target - .runtime_slot - .start_document_navigation(target_id.clone(), loader_id); - self.mark_target_initial_empty_document_pending_cross_document_navigation(&target_id); - Some(token) - } - - pub(crate) fn start_document_navigation_for_target( - &mut self, - target_id: &str, - loader_id: String, - ) -> Option { - if self.active_target_id() == Some(target_id) { - let target_id = target_id.to_owned(); - let token = self - .active_target - .runtime_slot - .start_document_navigation(target_id.clone(), loader_id); - self.mark_target_initial_empty_document_pending_cross_document_navigation(&target_id); - return Some(token); - } - let target = self.background_target_mut(target_id)?; - let token = target - .runtime_slot - .start_document_navigation(target_id.to_owned(), loader_id); - self.mark_target_initial_empty_document_pending_cross_document_navigation(target_id); - Some(token) - } - - pub(crate) fn accepts_pending_document_navigation_event( - &self, - token: &DocumentNavigationToken, - ) -> bool { - if self.active_target_id() == Some(token.target_id.as_str()) { - return self - .active_target - .runtime_slot - .accepts_pending_document_navigation_event(token); - } - self.background_target(&token.target_id) - .is_some_and(|target| { - target - .runtime_slot() - .accepts_pending_document_navigation_event(token) - }) - } - - pub(crate) fn document_navigation_cancellation_handle( - &self, - token: &DocumentNavigationToken, - ) -> Option { - if self.active_target_id() == Some(token.target_id.as_str()) { - return self - .active_target - .runtime_slot - .document_navigation_cancellation_handle(token); - } - self.background_target(&token.target_id).and_then(|target| { - target - .runtime_slot() - .document_navigation_cancellation_handle(token) - }) - } - - pub(crate) fn arm_background_navigation_completion( - &mut self, - token: &DocumentNavigationToken, - additional_cancellation: Option, - ) -> bool { - if self.active_target_id() == Some(token.target_id.as_str()) { - return self - .active_target - .runtime_slot - .arm_background_navigation_completion(token, additional_cancellation); - } - let Some(target) = self.background_target_mut(&token.target_id) else { - if let Some(cancellation) = additional_cancellation { - cancellation.cancel(); - } - return false; - }; - target - .runtime_slot - .arm_background_navigation_completion(token, additional_cancellation) - } - - pub(crate) fn settle_background_navigation_completion( - &mut self, - token: &DocumentNavigationToken, - ) -> bool { - if self.active_target_id() == Some(token.target_id.as_str()) { - return self - .active_target - .runtime_slot - .settle_background_navigation_completion(token); - } - self.background_target_mut(&token.target_id) - .is_some_and(|target| { - target - .runtime_slot - .settle_background_navigation_completion(token) - }) - } - - pub(crate) fn has_inflight_background_navigation(&self) -> bool { - self.active_target - .runtime_slot - .has_inflight_background_navigation() - || self - .background_targets - .iter() - .any(|target| target.runtime_slot().has_inflight_background_navigation()) - } - - pub(crate) fn has_inflight_background_navigation_for_target(&self, target_id: &str) -> bool { - if self.active_target_id() == Some(target_id) { - return self - .active_target - .runtime_slot - .has_inflight_background_navigation(); - } - self.background_target(target_id) - .is_some_and(|target| target.runtime_slot().has_inflight_background_navigation()) - } - - pub(crate) fn accepts_document_body_completion_event( - &self, - token: &DocumentNavigationToken, - ) -> bool { - if self.active_target_id() == Some(token.target_id.as_str()) { - return self - .active_target - .runtime_slot - .accepts_document_body_completion_event(token); - } - self.background_target(&token.target_id) - .is_some_and(|target| { - target - .runtime_slot() - .accepts_document_body_completion_event(token) - }) - } - - pub(crate) fn has_pending_document_navigation_for_target( - &self, - target_id: Option<&str>, - ) -> bool { - let Some(target_id) = target_id else { - return false; - }; - if self.active_target_id() == Some(target_id) { - return self - .active_target - .runtime_slot - .has_pending_document_navigation(); - } - self.background_target(target_id) - .is_some_and(|target| target.runtime_slot().has_pending_document_navigation()) - } - - pub(crate) fn clear_pending_document_navigation_for_target_if_loader_matches( - &mut self, - target_id: Option<&str>, - loader_id: &str, - ) { - let Some(target_id) = target_id else { - return; - }; - if self.active_target_id() == Some(target_id) { - let cleared = self - .active_target - .runtime_slot - .clear_pending_document_navigation_if_loader_matches(loader_id); - if cleared { - self.clear_target_initial_empty_document_pending_cross_document_navigation( - target_id, - ); - } - return; - } - if let Some(target) = self.background_target_mut(target_id) { - let cleared = target - .runtime_slot - .clear_pending_document_navigation_if_loader_matches(loader_id); - if cleared { - self.clear_target_initial_empty_document_pending_cross_document_navigation( - target_id, - ); - } - } - } - - pub(crate) fn commit_document_navigation_if_matches( - &mut self, - token: &DocumentNavigationToken, - ) { - if self.active_target_id() == Some(token.target_id.as_str()) { - let committed = self - .active_target - .runtime_slot - .commit_pending_document_navigation_if_matches(token); - if committed { - self.mark_target_initial_empty_document_exited(&token.target_id); - } - return; - } - if let Some(target) = self.background_target_mut(&token.target_id) { - let committed = target - .runtime_slot - .commit_pending_document_navigation_if_matches(token); - if committed { - self.mark_target_initial_empty_document_exited(&token.target_id); - } - } - } - - pub(crate) fn clear_document_navigation_state_for_active_target(&mut self) { + pub(crate) fn clear_renderer_document_protocol_state_for_active_target(&mut self) { self.active_target .runtime_slot - .clear_document_navigation_state(); + .clear_renderer_document_protocol_state(); } pub(crate) fn clear_indexed_db_origin(&self, origin: &str) -> Result<(), String> { @@ -1523,11 +1337,399 @@ impl BrowserContext { } pub(crate) fn active_target_id(&self) -> Option<&str> { - self.target_id.as_deref() + self.active_target + .target_handle + .as_ref() + .map(BrowserTargetHandle::target_id) } pub(crate) fn active_target_id_owned(&self) -> Option { - self.target_id.clone() + self.active_target_id().map(str::to_owned) + } + + pub(crate) fn active_target_handle(&self) -> Option<&BrowserTargetHandle> { + self.active_target.target_handle.as_ref() + } + + pub(crate) fn top_level_target_handle(&self, target_id: &str) -> Option<&BrowserTargetHandle> { + if self.is_active_target(target_id) { + return self.active_target_handle(); + } + self.background_targets + .iter() + .find(|target| target.is_target(target_id)) + .map(super::parking::BackgroundTarget::target_handle) + } + + /// Primary/root frontend state for the current exact active Target. + /// + /// While no active Target is registered this addresses the unbound root + /// candidate; binding the next new active Target adopts the candidate. + pub(crate) fn devtools_session_state(&self) -> &DevToolsSessionState { + self.target_frontend_sessions + .primary_state_or_unbound(self.active_target_handle()) + } + + #[cfg(test)] + pub(crate) fn devtools_session_state_mut(&mut self) -> &mut DevToolsSessionState { + let target = self.active_target_handle().cloned(); + self.target_frontend_sessions + .primary_state_or_unbound_mut(target.as_ref()) + } + + pub(crate) fn auxiliary_devtools_session_states( + &self, + ) -> &HashMap { + self.target_frontend_sessions + .auxiliary_states_or_unbound(self.active_target_handle()) + } + + pub(crate) fn devtools_session_states_mut( + &mut self, + ) -> ( + &mut DevToolsSessionState, + &mut HashMap, + ) { + let target = self.active_target_handle().cloned(); + self.target_frontend_sessions + .states_or_unbound_mut(target.as_ref()) + } + + #[cfg(test)] + pub(crate) fn active_frontend_and_policy_state_mut( + &mut self, + ) -> ( + &mut DevToolsSessionState, + &mut TargetNetworkPolicyState, + &mut Option, + ) { + let target = self.active_target_handle().cloned(); + let session_state = self + .target_frontend_sessions + .primary_state_or_unbound_mut(target.as_ref()); + ( + session_state, + &mut self.network_policy, + &mut self.tls_verify_host_override, + ) + } + + pub(crate) fn devtools_session_states_for_target( + &self, + target_id: &str, + ) -> Option<( + &DevToolsSessionState, + &HashMap, + )> { + let target = self.top_level_target_handle(target_id)?; + Some(( + self.target_frontend_sessions.primary_state(target)?, + self.target_frontend_sessions.auxiliary_states(target)?, + )) + } + + pub(crate) fn devtools_session_states_for_target_mut( + &mut self, + target_id: &str, + ) -> Option<( + &mut DevToolsSessionState, + &mut HashMap, + )> { + let target = self.top_level_target_handle(target_id)?.clone(); + self.target_frontend_sessions.states_mut(&target) + } + + pub(crate) fn devtools_session_state_for_target( + &self, + target_id: &str, + is_auxiliary: bool, + session_id: Option<&str>, + ) -> Option<&DevToolsSessionState> { + let target = self.top_level_target_handle(target_id)?; + self.target_frontend_sessions + .session_state(target, is_auxiliary, session_id) + } + + #[cfg(test)] + pub(crate) fn primary_devtools_session_state_for_target( + &self, + target_id: &str, + ) -> Option<&DevToolsSessionState> { + self.devtools_session_state_for_target(target_id, false, None) + } + + #[cfg(test)] + pub(crate) fn primary_devtools_session_state_for_target_mut( + &mut self, + target_id: &str, + ) -> Option<&mut DevToolsSessionState> { + let target = self.top_level_target_handle(target_id)?.clone(); + self.target_frontend_sessions.primary_state_mut(&target) + } + + pub(crate) fn mutate_devtools_session_state_for_target( + &mut self, + target_id: &str, + is_auxiliary: bool, + session_id: Option<&str>, + mutate: impl FnOnce(&mut DevToolsSessionState) -> T, + ) -> Option { + let target = self.top_level_target_handle(target_id)?.clone(); + self.target_frontend_sessions + .session_state_mut(&target, is_auxiliary, session_id) + .map(mutate) + } + + pub(crate) fn mutate_target_frontend_and_policy_state( + &mut self, + target_id: &str, + is_auxiliary: bool, + session_id: Option<&str>, + mutate: impl FnOnce( + Option<( + &mut DevToolsSessionState, + &mut TargetNetworkPolicyState, + &mut Option, + )>, + ) -> T, + ) -> T { + let Some(target) = self.top_level_target_handle(target_id).cloned() else { + return mutate(None); + }; + if self.active_target_handle() == Some(&target) { + let Some(session_state) = + self.target_frontend_sessions + .session_state_mut(&target, is_auxiliary, session_id) + else { + return mutate(None); + }; + return mutate(Some(( + session_state, + &mut self.network_policy, + &mut self.tls_verify_host_override, + ))); + } + + // Resolve the frontend projection before removing the parked policy + // payload. A stale/missing session route must not make the physical + // Target state disappear while the frontend falls back to NoLoaded. + if self + .target_frontend_sessions + .session_state(&target, is_auxiliary, session_id) + .is_none() + { + return mutate(None); + } + + let mut parked = self.target_parking.take_page_session_state(target_id); + let Some(session_state) = + self.target_frontend_sessions + .session_state_mut(&target, is_auxiliary, session_id) + else { + self.target_parking + .replace_page_session_state(target_id.to_owned(), parked); + return mutate(None); + }; + let result = mutate(Some(( + session_state, + &mut parked.network_policy, + &mut parked.tls_verify_host_override, + ))); + self.target_parking + .replace_page_session_state(target_id.to_owned(), parked); + result + } + + pub(crate) fn mutate_active_frontend_and_policy_state( + &mut self, + is_auxiliary: bool, + session_id: Option<&str>, + mutate: impl FnOnce( + Option<( + &mut DevToolsSessionState, + &mut TargetNetworkPolicyState, + &mut Option, + )>, + ) -> T, + ) -> T { + if let Some(target) = self.active_target_handle().cloned() { + let Some(session_state) = + self.target_frontend_sessions + .session_state_mut(&target, is_auxiliary, session_id) + else { + return mutate(None); + }; + return mutate(Some(( + session_state, + &mut self.network_policy, + &mut self.tls_verify_host_override, + ))); + } + if is_auxiliary { + return mutate(None); + } + + // Before the first active Target exists, root/browser-session + // commands still configure the unbound candidate. The first newly + // registered active Target adopts this state atomically. + let session_state = self + .target_frontend_sessions + .primary_state_or_unbound_mut(None); + mutate(Some(( + session_state, + &mut self.network_policy, + &mut self.tls_verify_host_override, + ))) + } + + pub(crate) fn reset_primary_devtools_session_state_for_target( + &mut self, + target_id: &str, + ) -> bool { + let Some(target) = self.top_level_target_handle(target_id).cloned() else { + return false; + }; + self.target_frontend_sessions.reset_primary_state(&target) + } + + pub(crate) fn primary_session_id_for_target(&self, target_id: &str) -> Option<&str> { + let target = self.top_level_target_handle(target_id)?; + self.target_frontend_sessions.primary_session_id(target) + } + + #[cfg(test)] + pub(crate) fn primary_session_id_for_exact_target( + &self, + target: &BrowserTargetHandle, + ) -> Option<&str> { + self.target_frontend_sessions.primary_session_id(target) + } + + #[cfg(test)] + pub(crate) fn primary_devtools_session_state_for_exact_target( + &self, + target: &BrowserTargetHandle, + ) -> Option<&DevToolsSessionState> { + self.target_frontend_sessions.primary_state(target) + } + + pub(crate) fn register_top_level_target_attachment( + &mut self, + target: BrowserTargetHandle, + primary_session_id: Option, + ) { + self.target_frontend_sessions + .register_target(target.clone()); + if primary_session_id.is_some() { + self.target_frontend_sessions + .replace_primary_session(&target, primary_session_id); + } + } + + /// Converts legacy constructor input used by old tests into the exact + /// attachment registry used by production commands. This runs only after + /// Core has replaced staged handles with the registered Target handles. + #[cfg(test)] + pub(crate) fn adopt_background_target_fixture_attachments(&mut self) { + let attachments = self + .background_targets + .iter_mut() + .map(|target| { + ( + target.target_handle().clone(), + target.take_fixture_primary_session_id(), + ) + }) + .collect::>(); + for (target, session_id) in attachments { + self.register_top_level_target_attachment(target, session_id); + } + } + + pub(crate) fn remove_top_level_target_attachment( + &mut self, + target: &BrowserTargetHandle, + ) -> (Option, Vec) { + self.target_frontend_sessions.remove_target(target) + } + + pub(crate) fn take_top_level_target_attachment_for_target( + &mut self, + target_id: &str, + ) -> (Option, Vec) { + let Some(target) = self.top_level_target_handle(target_id).cloned() else { + return (None, Vec::new()); + }; + self.target_frontend_sessions.remove_target(&target) + } + + pub(crate) fn primary_attachment_target_id_for_session( + &self, + session_id: &str, + ) -> Option<&str> { + let target = self + .target_frontend_sessions + .primary_target_for_session(session_id)?; + self.top_level_target_handle(target.target_id()) + .filter(|current| *current == target) + .map(BrowserTargetHandle::target_id) + } + + pub(crate) fn replace_primary_session_for_target( + &mut self, + target_id: &str, + session_id: Option, + ) -> Option { + let target = self.top_level_target_handle(target_id)?.clone(); + self.target_frontend_sessions + .replace_primary_session(&target, session_id) + } + + pub(crate) fn attach_auxiliary_session_for_target( + &mut self, + target_id: &str, + session_id: String, + ) -> bool { + let Some(target) = self.top_level_target_handle(target_id).cloned() else { + return false; + }; + self.target_frontend_sessions + .attach_auxiliary_session(&target, session_id); + true + } + + pub(crate) fn auxiliary_attachment_target_id_for_session( + &self, + session_id: &str, + ) -> Option<&str> { + let target = self + .target_frontend_sessions + .target_for_session(session_id)?; + let current = self.top_level_target_handle(target.target_id())?; + (current == target + && self + .target_frontend_sessions + .is_auxiliary_session_for_target(target, session_id)) + .then(|| target.target_id()) + } + + pub(crate) fn auxiliary_attachment_session_ids_for_target( + &self, + target_id: &str, + ) -> Vec { + self.top_level_target_handle(target_id) + .map(|target| self.target_frontend_sessions.auxiliary_session_ids(target)) + .unwrap_or_default() + } + + pub(crate) fn remove_auxiliary_attachment_session( + &mut self, + session_id: &str, + ) -> Option { + self.auxiliary_attachment_target_id_for_session(session_id)?; + self.target_frontend_sessions + .remove_auxiliary_session(session_id) + .map(|target| target.target_id().to_owned()) } pub(crate) fn effective_active_browser_identity_override( @@ -1604,46 +1806,134 @@ impl BrowserContext { } pub(crate) fn has_active_target(&self) -> bool { - self.target_id.is_some() + self.active_target.target_handle.is_some() } pub(crate) fn is_active_target(&self, target_id: &str) -> bool { self.active_target_id() == Some(target_id) } + pub(crate) fn stage_active_target_for_browser_context_registration( + &mut self, + target_id: impl Into, + ) { + assert!( + self.active_target_handle().is_none(), + "BrowserContext registration may only stage its initial active Target" + ); + self.bind_new_active_target_handles( + BrowserTargetHandle::staged(target_id), + BrowserPageResidenceHandle::default(), + ); + } + + #[cfg(test)] pub(crate) fn set_active_target_id(&mut self, target_id: impl Into) { let target_id = target_id.into(); - let target_changed = self.target_id.as_deref() != Some(target_id.as_str()); - if target_changed && !self.has_loaded_page() { - self.active_target - .runtime_slot - .prepare_renderer_channel_for_new_target(); + if self.active_target_id() == Some(target_id.as_str()) { + return; } - if self - .target_id - .as_deref() - .is_some_and(|current_target_id| current_target_id != target_id) + self.bind_new_active_target_handles( + BrowserTargetHandle::staged(target_id), + BrowserPageResidenceHandle::default(), + ); + } + + /// Binds a newly registered Browser Core Target and its exact Page slot. + /// + /// Promotion uses `set_active_target_handle` because the runtime slot (and + /// therefore its Page capability) moves with the Target. New Target + /// creation must instead install the capability allocated by Core. + pub(crate) fn bind_new_active_target_handles( + &mut self, + target_handle: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, + ) { + debug_assert!( + !self.has_loaded_page(), + "a newly registered Target cannot replace a loaded Page slot in place" + ); + if let Some(previous_target) = self.active_target.target_handle.as_ref() + && previous_target != &target_handle { + self.target_frontend_sessions.remove_target(previous_target); self.active_target.session_storage_namespace = Default::default(); } - self.target_id = Some(target_id); + self.active_target + .runtime_slot + .prepare_renderer_channel_for_new_target(page_residence); + self.target_frontend_sessions + .register_new_active_target(target_handle.clone()); + self.active_target.target_handle = Some(target_handle); + } + + pub(crate) fn bind_new_active_target_registration( + &mut self, + target_handle: BrowserTargetHandle, + page_residence: BrowserPageResidenceHandle, + session_storage_access: BrowserTargetSessionStorageAccess, + ) { + debug_assert_eq!( + session_storage_access.target_handle(), + &target_handle, + "one Target registration must bind its own exact sessionStorage access" + ); + self.bind_new_active_target_handles(target_handle, page_residence); + self.active_target + .session_storage_namespace + .bind_browser_access(session_storage_access); + } + + pub(crate) fn bind_target_session_storage_access( + &mut self, + access: BrowserTargetSessionStorageAccess, + ) -> bool { + let target_id = access.target_handle().target_id().to_owned(); + if self.active_target_handle() == Some(access.target_handle()) { + self.active_target + .session_storage_namespace + .bind_browser_access(access); + return true; + } + let Some(target) = self.background_target_mut(&target_id) else { + return false; + }; + if target.target_handle() != access.target_handle() { + return false; + } + target.bind_session_storage_access(access); + true + } + + pub(crate) fn set_active_target_handle(&mut self, target_handle: BrowserTargetHandle) { + let target_changed = self.active_target.target_handle.as_ref() != Some(&target_handle); + if target_changed && self.active_target.target_handle.is_some() { + self.active_target.session_storage_namespace = Default::default(); + } + self.target_frontend_sessions + .register_new_active_target(target_handle.clone()); + self.active_target.target_handle = Some(target_handle); } pub(crate) fn clear_active_target_id(&mut self) { - self.target_id = None; + if let Some(target) = self.active_target.target_handle.take() { + self.target_frontend_sessions + .retire_target_to_unbound(&target); + } self.active_target.session_storage_namespace = Default::default(); } pub(crate) fn active_session_id(&self) -> Option<&str> { - self.session_id.as_deref() + let target = self.active_target_handle()?; + self.target_frontend_sessions.primary_session_id(target) } pub(crate) fn active_session_id_owned(&self) -> Option { - self.session_id.clone() + self.active_session_id().map(str::to_owned) } pub(crate) fn has_active_session(&self) -> bool { - self.session_id.is_some() + self.active_session_id().is_some() } pub(crate) fn active_target_is_unclaimed_default_placeholder( @@ -1659,16 +1949,27 @@ impl BrowserContext { && self.service_worker_targets.is_empty() } + #[cfg(test)] pub(crate) fn attach_active_session(&mut self, session_id: impl Into) { - self.session_id = Some(session_id.into()); + let Some(target) = self.active_target_handle().cloned() else { + return; + }; + self.target_frontend_sessions + .replace_primary_session(&target, Some(session_id.into())); } pub(crate) fn replace_active_session(&mut self, session_id: Option) { - self.session_id = session_id; + let Some(target) = self.active_target_handle().cloned() else { + return; + }; + self.target_frontend_sessions + .replace_primary_session(&target, session_id); } pub(crate) fn detach_active_session(&mut self) -> Option { - self.session_id.take() + let target = self.active_target_handle()?.clone(); + self.target_frontend_sessions + .replace_primary_session(&target, None) } pub(crate) fn target_url(&self) -> &str { @@ -1713,19 +2014,6 @@ impl BrowserContext { } } -fn materialized_initial_empty_document_missing_page_error( - target_id: &str, - owner_state: &TargetOwnerState, - has_loaded_page: bool, -) -> Option { - if owner_state.has_materialized_current_initial_empty_document() && !has_loaded_page { - return Some(format!( - "TargetInitialEmptyDocumentMissingPage: target {target_id} has materialized current initial empty document without loaded Page" - )); - } - None -} - pub(crate) fn seed_initial_cookies( cookie_store: &SharedBrowserCookieStore, initial_cookies: impl IntoIterator, @@ -1735,3 +2023,37 @@ pub(crate) fn seed_initial_cookies( let _ = store.upsert_with_request_url_report(cookie, None, CookieSource::Cdp); } } + +#[cfg(test)] +mod attachment_projection_tests { + use super::*; + + #[test] + fn stale_exact_attachment_routes_cannot_authorize_same_public_target_id() { + let predecessor = BrowserTargetHandle::staged("TID-reused"); + let successor = BrowserTargetHandle::staged("TID-reused"); + let mut context = BrowserContext::new("BID-1".to_owned()); + context + .target_frontend_sessions + .register_target(predecessor.clone()); + context + .target_frontend_sessions + .replace_primary_session(&predecessor, Some("SID-old".to_owned())); + context + .target_frontend_sessions + .attach_auxiliary_session(&predecessor, "SID-old-aux".to_owned()); + context + .target_frontend_sessions + .register_target(successor.clone()); + context.active_target.target_handle = Some(successor); + + assert_eq!( + context.primary_attachment_target_id_for_session("SID-old"), + None + ); + assert_eq!( + context.auxiliary_attachment_target_id_for_session("SID-old-aux"), + None + ); + } +} diff --git a/moli-protocol/src/conn/state/devtools_renderer_channel.rs b/moli-protocol/src/conn/state/devtools_renderer_channel.rs index 8dfee565f6..1ee88a042b 100644 --- a/moli-protocol/src/conn/state/devtools_renderer_channel.rs +++ b/moli-protocol/src/conn/state/devtools_renderer_channel.rs @@ -185,6 +185,23 @@ impl DevToolsRendererChannel { &mut self, candidate: PreparedRendererAgentAttachment, ) -> Result { + self.validate_candidate_commit(&candidate)?; + self.inflight_cross_document_navigations + .retain(|navigation| navigation == candidate.navigation()); + self.committed_latest_navigation = Some(candidate.navigation.clone()); + let current = candidate.attachment; + let previous = self.current.replace(current); + Ok(CommittedRendererAgentAttachment { + navigation: candidate.navigation, + current, + previous, + }) + } + + pub(crate) fn validate_candidate_commit( + &self, + candidate: &PreparedRendererAgentAttachment, + ) -> Result<(), DevToolsRendererChannelError> { self.ensure_open()?; if !self .inflight_cross_document_navigations @@ -198,16 +215,7 @@ impl DevToolsRendererChannel { if self.committed_latest_navigation.as_ref() == Some(candidate.navigation()) { return Err(DevToolsRendererChannelError::NavigationAlreadyCommitted); } - self.inflight_cross_document_navigations - .retain(|navigation| navigation == candidate.navigation()); - self.committed_latest_navigation = Some(candidate.navigation.clone()); - let current = candidate.attachment; - let previous = self.current.replace(current); - Ok(CommittedRendererAgentAttachment { - navigation: candidate.navigation, - current, - previous, - }) + Ok(()) } pub(crate) fn rollback_committed_candidate( @@ -215,8 +223,11 @@ impl DevToolsRendererChannel { transaction: CommittedRendererAgentAttachment, ) -> Result<(), DevToolsRendererChannelError> { self.ensure_open()?; - if self.committed_latest_navigation.as_ref() != Some(transaction.navigation()) - || self.current != Some(transaction.current()) + if self.current != Some(transaction.current()) + || self + .committed_latest_navigation + .as_ref() + .is_some_and(|navigation| navigation != transaction.navigation()) { return Err(DevToolsRendererChannelError::CommittedCandidateMismatch); } @@ -465,11 +476,7 @@ mod tests { use serde_json::json; fn navigation(label: u64) -> DocumentNavigationToken { - DocumentNavigationToken { - target_id: "TID-channel".to_owned(), - loader_id: format!("LID-{label}"), - request_id: crate::conn::state::NavigationRequestId::allocate(), - } + DocumentNavigationToken::new("TID-channel", format!("LID-{label}")) } fn batch( @@ -653,6 +660,40 @@ mod tests { ); } + #[test] + fn superseding_navigation_can_roll_back_exact_current_candidate_transaction() { + let initial_agent = RendererDevToolsAgentToken::allocate(); + let candidate_agent = RendererDevToolsAgentToken::allocate(); + let request_a = navigation(1); + let request_b = navigation(2); + let mut channel = DevToolsRendererChannel::default(); + channel + .attach_current(initial_agent) + .expect("initial attach"); + let initial = channel.current().expect("initial attachment"); + channel + .navigation_started(request_a.clone()) + .expect("navigation A"); + let candidate = channel + .attach_candidate(&request_a, candidate_agent) + .expect("candidate A"); + let transaction = channel + .commit_candidate_transaction(candidate) + .expect("candidate A commit"); + + channel + .navigation_started(request_b.clone()) + .expect("navigation B supersedes Browser Core request A"); + assert_eq!(channel.committed_latest_navigation, None); + channel + .rollback_committed_candidate(transaction) + .expect("the exact still-current candidate transaction should roll back"); + + assert_eq!(channel.current(), Some(initial)); + assert_eq!(channel.latest_started_navigation, Some(request_b)); + assert!(channel.output_is_suspended()); + } + #[test] fn output_remains_suspended_until_all_overlapping_navigations_finish() { let request_a = navigation(1); diff --git a/moli-protocol/src/conn/state/devtools_session.rs b/moli-protocol/src/conn/state/devtools_session.rs index 2206c0e71d..c0f9bb8150 100644 --- a/moli-protocol/src/conn/state/devtools_session.rs +++ b/moli-protocol/src/conn/state/devtools_session.rs @@ -108,6 +108,18 @@ impl SessionRendererCallTermination { } impl SessionRendererCallReplay { + pub(crate) fn new( + frontend_session_id: Option, + renderer_inspector_session_id: Option, + replay: PreparedRendererCallReplay, + ) -> Self { + Self { + frontend_session_id, + renderer_inspector_session_id, + replay, + } + } + pub(crate) fn frontend_session_id(&self) -> Option<&str> { self.frontend_session_id.as_deref() } @@ -558,17 +570,6 @@ pub(crate) fn devtools_sessions_have_pending_inspector_awaits( .any(DevToolsSessionState::has_pending_inspector_awaits) } -pub(crate) fn devtools_sessions_pending_inspector_await_count( - primary: &DevToolsSessionState, - auxiliary: &HashMap, -) -> usize { - primary.pending_inspector_await_count() - + auxiliary - .values() - .map(DevToolsSessionState::pending_inspector_await_count) - .sum::() -} - pub(crate) fn drain_pending_inspector_awaits_for_devtools_sessions( primary: &mut DevToolsSessionState, auxiliary: &mut HashMap, @@ -591,10 +592,8 @@ pub(crate) fn prepare_renderer_call_replays_for_devtools_sessions( let mut replays = primary .prepare_renderer_call_replays(old_attachment_id, new_attachment_id)? .into_iter() - .map(|replay| SessionRendererCallReplay { - frontend_session_id: primary_session_id.map(str::to_owned), - renderer_inspector_session_id: None, - replay, + .map(|replay| { + SessionRendererCallReplay::new(primary_session_id.map(str::to_owned), None, replay) }) .collect::>(); let mut auxiliary_session_ids = auxiliary.keys().cloned().collect::>(); @@ -607,10 +606,12 @@ pub(crate) fn prepare_renderer_call_replays_for_devtools_sessions( state .prepare_renderer_call_replays(old_attachment_id, new_attachment_id)? .into_iter() - .map(|replay| SessionRendererCallReplay { - frontend_session_id: Some(session_id.clone()), - renderer_inspector_session_id: Some(session_id.clone()), - replay, + .map(|replay| { + SessionRendererCallReplay::new( + Some(session_id.clone()), + Some(session_id.clone()), + replay, + ) }), ); } diff --git a/moli-protocol/src/conn/state/document_lifecycle_observer.rs b/moli-protocol/src/conn/state/document_lifecycle_observer.rs deleted file mode 100644 index 3243b402af..0000000000 --- a/moli-protocol/src/conn/state/document_lifecycle_observer.rs +++ /dev/null @@ -1,128 +0,0 @@ -use tokio::sync::watch; - -/// The authoritative result of observing one exact renderer Document milestone. -/// -/// `Pending` is transport state only. Every other variant is terminal and is -/// published by the Page-slot lifecycle authority that owns the exact -/// Document/epoch binding. A generic renderer wake never changes this value. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum RendererDocumentLifecycleObservation { - Pending, - Reached, - Interrupted, - Superseded, - Unavailable, -} - -impl RendererDocumentLifecycleObservation { - pub(crate) fn is_terminal(self) -> bool { - self != Self::Pending - } -} - -/// The Page-slot side of one exact lifecycle observation. -/// -/// This publisher remains with the registered lifecycle waiter. Dropping the -/// Page slot closes the channel, which the observer interprets as -/// `Unavailable`; replacement and lifecycle termination publish their typed -/// terminal before the registration is retired. -#[derive(Debug)] -pub(crate) struct RendererDocumentLifecycleObservationPublisher { - sender: watch::Sender, -} - -impl RendererDocumentLifecycleObservationPublisher { - pub(crate) fn publish(&self, observation: RendererDocumentLifecycleObservation) { - assert!( - observation.is_terminal(), - "a lifecycle observation publisher must publish a terminal fact" - ); - self.sender.send_replace(observation); - } - - pub(crate) fn has_observer(&self) -> bool { - self.sender.receiver_count() != 0 - } -} - -/// A move-only wait token for one exact renderer Document milestone. -/// -/// The token is deliberately independent of renderer wake routing. Its state -/// changes only when the Page-slot lifecycle authority observes the requested -/// milestone, termination, replacement, or loss of the owning Page slot. -#[derive(Debug)] -pub(crate) struct RendererDocumentLifecycleObserver { - receiver: watch::Receiver, -} - -impl RendererDocumentLifecycleObserver { - pub(crate) fn channel( - initial: RendererDocumentLifecycleObservation, - ) -> ( - RendererDocumentLifecycleObservationPublisher, - RendererDocumentLifecycleObserver, - ) { - let (sender, receiver) = watch::channel(initial); - ( - RendererDocumentLifecycleObservationPublisher { sender }, - RendererDocumentLifecycleObserver { receiver }, - ) - } - - pub(crate) fn resolved(observation: RendererDocumentLifecycleObservation) -> Self { - assert!( - observation.is_terminal(), - "a resolved lifecycle observer requires a terminal fact" - ); - let (publisher, observer) = Self::channel(observation); - drop(publisher); - observer - } - - pub(crate) fn observation(&self) -> RendererDocumentLifecycleObservation { - *self.receiver.borrow() - } - - pub(crate) async fn wait(mut self) -> RendererDocumentLifecycleObservation { - loop { - let observation = *self.receiver.borrow_and_update(); - if observation.is_terminal() { - return observation; - } - if self.receiver.changed().await.is_err() { - return RendererDocumentLifecycleObservation::Unavailable; - } - } - } -} - -#[cfg(test)] -mod tests { - use super::{RendererDocumentLifecycleObservation, RendererDocumentLifecycleObserver}; - - #[tokio::test] - async fn observer_waits_for_a_typed_terminal() { - let (publisher, observer) = RendererDocumentLifecycleObserver::channel( - RendererDocumentLifecycleObservation::Pending, - ); - publisher.publish(RendererDocumentLifecycleObservation::Reached); - - assert_eq!( - observer.wait().await, - RendererDocumentLifecycleObservation::Reached - ); - } - - #[tokio::test] - async fn observer_maps_publisher_loss_to_unavailable() { - let (publisher, observer) = RendererDocumentLifecycleObserver::channel( - RendererDocumentLifecycleObservation::Pending, - ); - drop(publisher); - - assert_eq!( - observer.wait().await, - RendererDocumentLifecycleObservation::Unavailable - ); - } -} diff --git a/moli-protocol/src/conn/state/emulation.rs b/moli-protocol/src/conn/state/emulation.rs index 0118525a07..b7900c7228 100644 --- a/moli-protocol/src/conn/state/emulation.rs +++ b/moli-protocol/src/conn/state/emulation.rs @@ -9,21 +9,6 @@ pub struct EmulatedDeviceMetrics { pub screen_height: u32, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct EmulatedNetworkConditions { - offline: bool, -} - -impl EmulatedNetworkConditions { - pub(crate) fn offline() -> Self { - Self { offline: true } - } - - pub(crate) fn navigator_online(&self) -> bool { - !self.offline - } -} - const DEFAULT_VIEWPORT_WIDTH: u32 = 1920; const DEFAULT_VIEWPORT_HEIGHT: u32 = 1080; const DEFAULT_SCREEN_WIDTH: u32 = 1920; @@ -215,32 +200,6 @@ pub(crate) fn viewport_surface_install_script( ) } -#[derive(Debug, Clone, PartialEq)] -pub struct EmulatedGeolocationOverride { - pub latitude: f64, - pub longitude: f64, - pub accuracy: f64, - pub altitude: Option, - pub altitude_accuracy: Option, - pub heading: Option, - pub speed: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum EmulatedGeolocationOverrideState { - Position(EmulatedGeolocationOverride), - PositionUnavailable, -} - -impl EmulatedGeolocationOverrideState { - pub(crate) fn position(&self) -> Option<&EmulatedGeolocationOverride> { - match self { - Self::Position(position) => Some(position), - Self::PositionUnavailable => None, - } - } -} - #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct EmulatedMediaOverrides { pub media: Option, diff --git a/moli-protocol/src/conn/state/fetch.rs b/moli-protocol/src/conn/state/fetch.rs index b8b37766cb..e57572d48b 100644 --- a/moli-protocol/src/conn/state/fetch.rs +++ b/moli-protocol/src/conn/state/fetch.rs @@ -2120,6 +2120,14 @@ impl TargetFetchOwner { .contains_key(request_id) } + #[cfg(test)] + pub(crate) fn pending_fetch_navigation_for_test( + &self, + request_id: &str, + ) -> Option<&PendingFetchNavigation> { + self.pending.pending_fetch_navigations.get(request_id) + } + #[cfg(test)] pub(crate) fn has_pending_fetch_request_id_for_test(&self, request_id: &str) -> bool { self.pending.pending_fetch_request_ids.contains(request_id) @@ -2137,6 +2145,14 @@ impl TargetFetchOwner { .contains_key(request_id) } + #[cfg(test)] + pub(crate) fn pending_fetch_auth_navigation_for_test( + &self, + request_id: &str, + ) -> Option<&PendingFetchAuthNavigation> { + self.pending.pending_fetch_auth_navigations.get(request_id) + } + #[cfg(test)] pub(crate) fn has_pending_fetch_state_for_test(&self) -> bool { !self.pending.pending_fetch_request_ids.is_empty() @@ -2237,7 +2253,7 @@ mod tests { } fn test_page_owner() -> crate::conn::TargetPageResidenceIdentity { - crate::conn::TargetPageResidenceIdentity::new_for_test( + crate::conn::TargetPageResidenceIdentity::new( "BID-fetch-state".to_owned(), Some("TID-fetch-state".to_owned()), 1, @@ -2395,6 +2411,7 @@ mod tests { request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }, request_cookie_report: None, auth_response: PendingFetchAuthNavigation::test_auth_response(test_url("auth-page")), diff --git a/moli-protocol/src/conn/state/identity.rs b/moli-protocol/src/conn/state/identity.rs index 374eab945f..822bf82e9c 100644 --- a/moli-protocol/src/conn/state/identity.rs +++ b/moli-protocol/src/conn/state/identity.rs @@ -1,10 +1,10 @@ use moli_core::{PageId, RendererOutputResidenceIdentity, RendererOwnerLocalHostId, page::Page}; -use super::TargetPageAttachmentId; +pub(crate) use moli_core::browser_host::PageResidenceIdentity as TargetPageResidenceIdentity; pub const URL_BASE: &str = "chrome://newtab/"; -/// Exact renderer Page residence captured with one deferred-load owner action. +/// Exact renderer Page residence captured with one deferred-load fact projection. /// /// A protocol session can survive a Page replacement, so session identity is /// not precise enough to decide whether a later renderer publication is a @@ -67,67 +67,10 @@ impl RendererPageResidenceIdentity { } } -/// Identifies one current or reserved Page attachment within a protocol target. -/// -/// This identity deliberately does not include the renderer Document. A -/// `document.open()` replacement keeps the same Page residence, while taking, -/// replacing, or retiring the Page changes `page_attachment_id`. Deferred -/// protocol work that belongs to a Page should therefore use this identity for -/// authorization and may carry a renderer Document identity separately as -/// causal metadata. The attachment id is allocated when the Page is reserved, -/// so work emitted before installation and work emitted after commit retain the -/// same identity without predicting a numeric generation. A target without a -/// current or reserved Page has no Page residence identity. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)] -pub struct TargetPageResidenceIdentity { - browser_context_id: String, - target_id: Option, - page_attachment_id: TargetPageAttachmentId, -} - -impl TargetPageResidenceIdentity { - pub(crate) fn new( - browser_context_id: String, - target_id: Option, - page_attachment_id: TargetPageAttachmentId, - ) -> Self { - Self { - browser_context_id, - target_id, - page_attachment_id, - } - } - - #[cfg(test)] - pub(crate) fn new_for_test( - browser_context_id: String, - target_id: Option, - page_attachment_id: u64, - ) -> Self { - Self::new( - browser_context_id, - target_id, - TargetPageAttachmentId::from_raw_for_test(page_attachment_id), - ) - } - - pub(crate) fn browser_context_id(&self) -> &str { - &self.browser_context_id - } - - pub(crate) fn target_id(&self) -> Option<&str> { - self.target_id.as_deref() - } - - pub(crate) fn page_attachment_id(&self) -> TargetPageAttachmentId { - self.page_attachment_id - } -} - -/// Identifies one protocol attachment to one target Page residence. +/// Identifies one protocol attachment to one Browser Core-owned target Page. /// /// A Page residence can be observed by more than one CDP session over its -/// lifetime. Deferred output must therefore retain both the Page attachment +/// lifetime. Deferred output must therefore retain both the Page generation /// and the exact session that captured it. Explicit session ids are allocated /// monotonically by one `CdpConnection` and are never reused. `None` denotes /// the connection's implicit Page attachment; the embedded Page identity keeps diff --git a/moli-protocol/src/conn/state/javascript_dialog.rs b/moli-protocol/src/conn/state/javascript_dialog.rs index 2a6a019faa..cf79449129 100644 --- a/moli-protocol/src/conn/state/javascript_dialog.rs +++ b/moli-protocol/src/conn/state/javascript_dialog.rs @@ -398,7 +398,7 @@ mod tests { let scope = TargetJavaScriptDialogScope::default(); let prepared = TargetPreparedJavaScriptDialog::capture( TargetPageProtocolAttachmentIdentity::new( - TargetPageResidenceIdentity::new_for_test( + TargetPageResidenceIdentity::new( "BID-dialog-drop".to_owned(), Some("TID-dialog-drop".to_owned()), 1, diff --git a/moli-protocol/src/conn/state/mod.rs b/moli-protocol/src/conn/state/mod.rs index 018774b0d3..176758780b 100644 --- a/moli-protocol/src/conn/state/mod.rs +++ b/moli-protocol/src/conn/state/mod.rs @@ -1,10 +1,8 @@ mod attachment_identity; -mod bounds; mod browser_context; mod dedicated_worker_target; mod devtools_renderer_channel; mod devtools_session; -mod document_lifecycle_observer; mod emulation; mod fetch; mod identity; @@ -25,18 +23,19 @@ mod session; mod session_storage; mod shared_worker_attachment; mod shared_worker_target; +mod target_attachment; #[cfg(test)] mod tests; // Re-export everything so `use super::state::*` paths continue to work. -pub(crate) use attachment_identity::{NavigationRequestId, TargetPageAttachmentId}; -pub use identity::TargetPageResidenceIdentity as DevToolsPageResidenceIdentity; +pub(crate) use attachment_identity::TargetPageAttachmentId; pub use identity::URL_BASE; pub(crate) use identity::{ RendererPageResidenceIdentity, TargetIdentityState, TargetPageProtocolAttachmentIdentity, TargetPageResidenceIdentity, TargetRootDocumentProtocolAttachmentIdentity, }; +pub use moli_core::browser_host::PageResidenceIdentity as DevToolsPageResidenceIdentity; pub(crate) use devtools_renderer_channel::{ CommittedRendererAgentAttachment, DevToolsRendererChannelError, @@ -50,24 +49,21 @@ pub(crate) use devtools_session::{ DevToolsConsoleOutputSessionState, DevToolsLogViolationThreshold, DevToolsSessionState, PreparedRendererCallReplacements, SessionRendererCallReplay, SessionRendererCallTermination, devtools_sessions_have_pending_inspector_awaits, - devtools_sessions_pending_inspector_await_count, drain_pending_inspector_awaits_for_devtools_sessions, page_bypass_csp_enabled_for_devtools_sessions, prepare_renderer_call_replacements_for_devtools_sessions, runtime_bindings_for_renderer, }; -pub(crate) use document_lifecycle_observer::{ - RendererDocumentLifecycleObservation, RendererDocumentLifecycleObserver, +pub use moli_core::browser_host::{ + BrowserWindowBounds, EmulatedGeolocationOverride, EmulatedGeolocationOverrideState, }; pub(crate) use page_residence_token::{TargetPageResidenceObservation, TargetPageResidenceToken}; -pub use bounds::BrowserWindowBounds; - +pub(crate) use moli_core::browser_host::BrowserDocumentNavigation as DocumentNavigationToken; pub(crate) use page_resource::MainDocumentResourceSnapshot; #[cfg(test)] pub(crate) use page_slot::TargetPageSlot; pub(crate) use page_slot::{ - CommittedRendererDocumentBinding, DocumentNavigationToken, InitialDocumentPageBuildWaiter, - RendererDocumentLifecycleWaiterId, TargetPageAbsenceReason, + CommittedRendererDocumentBinding, InitialDocumentPageBuildWaiter, TargetPageAbsenceReason, }; pub use page_slot::{DocumentStartScript, IsolatedWorldDefinition, RuntimeBindingDefinition}; @@ -120,20 +116,17 @@ pub(crate) use browser_context::{ pub use navigation::{PageNavigationHistoryEntry, PendingNavigationHistoryUpdate}; +pub(crate) use moli_core::browser_host::BrowserInitialEmptyDocumentCreator as TargetInitialEmptyDocumentCreator; pub use parking::{BackgroundTarget, ParkedNetworkArtifacts, ParkedPageSessionState}; pub(crate) use parking::{ ParkedTargetAuxState, ParkedTargetOwnerState, PendingBidiChannelListener, - PendingInspectorAwait, TargetInitialEmptyDocumentCreator, TargetOwnerState, TargetSlotState, + PendingInspectorAwait, StagedBackgroundTargetSlot, TargetOwnerState, TargetSlotState, TargetWindowSurfaceState, }; -pub use emulation::{ - EmulatedDeviceMetrics, EmulatedGeolocationOverride, EmulatedGeolocationOverrideState, - EmulatedMediaOverrides, -}; -pub(crate) use emulation::{ - EmulatedNetworkConditions, EmulatedViewportSurface, viewport_surface_install_script, -}; +pub use emulation::{EmulatedDeviceMetrics, EmulatedMediaOverrides}; +pub(crate) use emulation::{EmulatedViewportSurface, viewport_surface_install_script}; +pub(crate) use moli_core::browser_host::EmulatedNetworkConditions; pub(crate) use navigation_outcome::{CompletedDownloadBody, CompletedDownloadBodyArtifact}; pub use navigation_outcome::{ diff --git a/moli-protocol/src/conn/state/navigation.rs b/moli-protocol/src/conn/state/navigation.rs index e1d777e19f..c6da7fa52b 100644 --- a/moli-protocol/src/conn/state/navigation.rs +++ b/moli-protocol/src/conn/state/navigation.rs @@ -1,367 +1,8 @@ -use moli_core::page::SameDocumentHistoryUpdate; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PageNavigationHistoryEntry { - pub id: i32, - pub url: String, - pub user_typed_url: String, - pub title: String, - pub transition_type: String, - pub document_sequence_number: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PendingNavigationHistoryUpdate { - ReplaceCurrent, - ReplaceInitialEmptyDocument, - TraverseToEntry(i32), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TargetNavigationHistoryState { - entries: Vec, - current_index: Option, - next_entry_id: i32, - next_document_sequence_number: u64, - pending_update: Option, -} - -impl Default for TargetNavigationHistoryState { - fn default() -> Self { - Self { - entries: Vec::new(), - current_index: None, - next_entry_id: 1, - next_document_sequence_number: 1, - pending_update: None, - } - } -} - -impl TargetNavigationHistoryState { - pub(crate) fn clear(&mut self) { - *self = Self::default(); - } - - pub(crate) fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - pub(crate) fn allocate_entry_id(&mut self) -> i32 { - let id = self.next_entry_id; - self.next_entry_id = self - .next_entry_id - .checked_add(1) - .expect("Page navigation history entry id overflow"); - id - } - - fn push_entry(&mut self, entry: PageNavigationHistoryEntry) { - if let Some(current_index) = self.current_index { - self.entries.truncate(current_index + 1); - } - self.entries.push(entry); - self.current_index = self.entries.len().checked_sub(1); - } - - fn allocate_document_sequence_number(&mut self) -> u64 { - let sequence_number = self.next_document_sequence_number; - self.next_document_sequence_number = self - .next_document_sequence_number - .checked_add(1) - .expect("Page navigation Document sequence number overflow"); - sequence_number - } - - fn assign_new_document_sequence_number(&mut self, entry: &mut PageNavigationHistoryEntry) { - entry.document_sequence_number = Some(self.allocate_document_sequence_number()); - } - - fn assign_current_document_sequence_number(&mut self, entry: &mut PageNavigationHistoryEntry) { - entry.document_sequence_number = self - .current_index - .and_then(|index| self.entries.get(index)) - .and_then(|entry| entry.document_sequence_number) - .or_else(|| Some(self.allocate_document_sequence_number())); - } - - fn replace_current_entry(&mut self, mut entry: PageNavigationHistoryEntry) { - if let Some(current_index) = self.current_index - && let Some(current_entry) = self.entries.get_mut(current_index) - { - entry.id = current_entry.id; - *current_entry = entry; - return; - } - self.push_entry(entry); - } - - fn traverse_to_entry(&mut self, entry_id: i32, mut loaded_entry: PageNavigationHistoryEntry) { - if let Some(index) = self.entries.iter().position(|entry| entry.id == entry_id) { - loaded_entry.transition_type = self.entries[index].transition_type.clone(); - loaded_entry.user_typed_url = self.entries[index].user_typed_url.clone(); - loaded_entry.document_sequence_number = self.entries[index].document_sequence_number; - loaded_entry.id = entry_id; - self.entries[index] = loaded_entry; - self.current_index = Some(index); - return; - } - self.push_entry(loaded_entry); - } - - pub(crate) fn mark_replace_current(&mut self) { - self.pending_update = Some(PendingNavigationHistoryUpdate::ReplaceCurrent); - } - - pub(crate) fn mark_replace_initial_empty_document(&mut self) { - self.pending_update = Some(PendingNavigationHistoryUpdate::ReplaceInitialEmptyDocument); - } - - pub(crate) fn mark_traverse_to_entry(&mut self, entry_id: i32) { - self.pending_update = Some(PendingNavigationHistoryUpdate::TraverseToEntry(entry_id)); - } - - pub(crate) fn clear_pending_update(&mut self) { - self.pending_update = None; - } - - pub(crate) fn entry_url(&self, entry_id: i32) -> Option { - self.entries - .iter() - .find(|entry| entry.id == entry_id) - .map(|entry| entry.url.clone()) - } - - pub(crate) fn refresh_current_entry_title(&mut self, title: String) -> bool { - let Some(current_entry) = self - .current_index - .and_then(|current_index| self.entries.get_mut(current_index)) - else { - return false; - }; - if current_entry.title == title { - return false; - } - current_entry.title = title; - true - } - - pub(crate) fn snapshot(&self) -> (usize, Vec) { - (self.current_index.unwrap_or(0), self.entries.clone()) - } - - pub(crate) fn can_prune_all_but_current(&self) -> bool { - !matches!( - self.pending_update, - Some(PendingNavigationHistoryUpdate::TraverseToEntry(_)) - ) && self - .current_index - .is_some_and(|current_index| current_index < self.entries.len()) - } - - pub(crate) fn prune_all_but_current(&mut self) -> bool { - if !self.can_prune_all_but_current() { - return false; - } - let Some(current_index) = self.current_index else { - return false; - }; - let Some(current_entry) = self.entries.get(current_index).cloned() else { - return false; - }; - self.entries.clear(); - self.entries.push(current_entry); - self.current_index = Some(0); - true - } - - pub(crate) fn seed_entry(&mut self, mut entry: PageNavigationHistoryEntry) { - self.assign_new_document_sequence_number(&mut entry); - self.push_entry(entry); - } - - pub(crate) fn record_loaded_entry(&mut self, mut entry: PageNavigationHistoryEntry) { - match self.pending_update.take() { - Some(PendingNavigationHistoryUpdate::ReplaceCurrent) => { - entry.transition_type = "reload".to_owned(); - if let Some(current_entry) = - self.current_index.and_then(|index| self.entries.get(index)) - { - entry.user_typed_url = current_entry.user_typed_url.clone(); - } - self.assign_new_document_sequence_number(&mut entry); - self.replace_current_entry(entry); - } - Some(PendingNavigationHistoryUpdate::ReplaceInitialEmptyDocument) => { - entry.transition_type = "auto_toplevel".to_owned(); - self.assign_new_document_sequence_number(&mut entry); - self.replace_current_entry(entry); - } - Some(PendingNavigationHistoryUpdate::TraverseToEntry(entry_id)) => { - self.traverse_to_entry(entry_id, entry); - } - None => { - self.assign_new_document_sequence_number(&mut entry); - self.push_entry(entry); - } - } - } - - pub(crate) fn record_same_document_update( - &mut self, - url: String, - title: String, - history_update: SameDocumentHistoryUpdate, - ) -> bool { - match history_update { - SameDocumentHistoryUpdate::Push | SameDocumentHistoryUpdate::Replace => { - let mut entry = PageNavigationHistoryEntry { - id: self.allocate_entry_id(), - url, - user_typed_url: self - .current_index - .and_then(|index| self.entries.get(index)) - .map(|entry| entry.user_typed_url.clone()) - .unwrap_or_default(), - title, - transition_type: "link".to_owned(), - document_sequence_number: None, - }; - self.assign_current_document_sequence_number(&mut entry); - match history_update { - SameDocumentHistoryUpdate::Push => self.push_entry(entry), - SameDocumentHistoryUpdate::Replace => self.replace_current_entry(entry), - SameDocumentHistoryUpdate::Traverse { .. } => unreachable!(), - } - true - } - SameDocumentHistoryUpdate::Traverse { delta } => { - let Some(current_index) = self.current_index else { - return false; - }; - let Ok(current_index) = i64::try_from(current_index) else { - return false; - }; - let Some(target_index) = current_index.checked_add(delta) else { - return false; - }; - let Ok(target_index) = usize::try_from(target_index) else { - return false; - }; - let Some(target_entry) = self.entries.get(target_index) else { - return false; - }; - debug_assert_eq!( - target_entry.url, url, - "renderer/browser same-document traversal URL drift: current_index={current_index}, delta={delta}, target_index={target_index}, browser_target_url={}, renderer_target_url={url}", - target_entry.url, - ); - self.current_index = Some(target_index); - true - } - } - } -} - +// Compatibility names for the protocol facade. The authoritative history +// state and all cursor/update operations are owned by Browser Core. #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn same_document_traversal_moves_cursor_without_allocating_or_appending() { - let mut history = TargetNavigationHistoryState::default(); - assert!(history.record_same_document_update( - "https://example.test/a".to_owned(), - "A".to_owned(), - SameDocumentHistoryUpdate::Push, - )); - assert!(history.record_same_document_update( - "https://example.test/b".to_owned(), - "B".to_owned(), - SameDocumentHistoryUpdate::Push, - )); - - assert!(history.record_same_document_update( - "https://example.test/a".to_owned(), - "ignored during traversal".to_owned(), - SameDocumentHistoryUpdate::Traverse { delta: -1 }, - )); - let (current_index, entries) = history.snapshot(); - assert_eq!(current_index, 0); - assert_eq!(entries.len(), 2); - assert_eq!( - entries.iter().map(|entry| entry.id).collect::>(), - vec![1, 2] - ); - - assert!(history.record_same_document_update( - "https://example.test/c".to_owned(), - "C".to_owned(), - SameDocumentHistoryUpdate::Push, - )); - let (current_index, entries) = history.snapshot(); - assert_eq!(current_index, 1); - assert_eq!( - entries - .iter() - .map(|entry| (entry.id, entry.url.as_str())) - .collect::>(), - vec![(1, "https://example.test/a"), (3, "https://example.test/c")], - "a traversal must neither allocate an id nor survive as a forward entry after push" - ); - } - - #[test] - fn same_document_replace_preserves_entry_id_and_out_of_range_traverse_is_atomic() { - let mut history = TargetNavigationHistoryState::default(); - assert!(history.record_same_document_update( - "https://example.test/a".to_owned(), - "A".to_owned(), - SameDocumentHistoryUpdate::Push, - )); - assert!(history.record_same_document_update( - "https://example.test/replaced".to_owned(), - "Replaced".to_owned(), - SameDocumentHistoryUpdate::Replace, - )); - let before = history.snapshot(); - assert_eq!(before.0, 0); - assert_eq!(before.1[0].id, 1); - assert_eq!(before.1[0].url, "https://example.test/replaced"); - - assert!(!history.record_same_document_update( - "https://example.test/missing".to_owned(), - String::new(), - SameDocumentHistoryUpdate::Traverse { delta: -1 }, - )); - assert_eq!(history.snapshot(), before); - } - - #[test] - fn title_refresh_updates_only_current_entry_metadata() { - let mut history = TargetNavigationHistoryState::default(); - let first_id = history.allocate_entry_id(); - history.seed_entry(PageNavigationHistoryEntry { - id: first_id, - url: "https://example.test/a".to_owned(), - user_typed_url: "https://example.test/a".to_owned(), - title: String::new(), - transition_type: "typed".to_owned(), - document_sequence_number: None, - }); - let before = history.snapshot().1[0].clone(); - - assert!(history.refresh_current_entry_title("A".to_owned())); - assert!(!history.refresh_current_entry_title("A".to_owned())); - - let refreshed = &history.snapshot().1[0]; - assert_eq!(refreshed.title, "A"); - assert_eq!(refreshed.id, before.id); - assert_eq!(refreshed.url, before.url); - assert_eq!(refreshed.user_typed_url, before.user_typed_url); - assert_eq!( - refreshed.document_sequence_number, - before.document_sequence_number - ); - } -} +pub(crate) use moli_core::browser_host::BrowserNavigationHistory as TargetNavigationHistoryState; +pub use moli_core::browser_host::{ + BrowserNavigationHistoryEntry as PageNavigationHistoryEntry, + BrowserNavigationHistoryUpdate as PendingNavigationHistoryUpdate, +}; diff --git a/moli-protocol/src/conn/state/navigation_outcome.rs b/moli-protocol/src/conn/state/navigation_outcome.rs index 74259aaf95..3ca6747b9d 100644 --- a/moli-protocol/src/conn/state/navigation_outcome.rs +++ b/moli-protocol/src/conn/state/navigation_outcome.rs @@ -1,3 +1,4 @@ +use moli_core::browser_host::{BrowserContextHandle, BrowserTargetHandle}; use moli_core::page::{ Page, RendererMainDocumentCommit, RendererPageCreationArtifacts, RendererPendingDownloadActivation, RendererRuntimeRealmInfo, @@ -335,6 +336,12 @@ pub struct NavigationDispatchState { pub request_load_policy: NavigationRequestLoadPolicy, pub timestamp: f64, pub(crate) source_document_security: NavigationSourceDocumentSecurityContext, + /// Exact foreground continuation for a renderer-created auxiliary Target. + /// + /// The continuation travels with the navigation completion instead of a + /// parallel connection map. It is published back to Browser Host only + /// after this navigation commits its successor Document. + pub(crate) post_commit_target_activation: Option<(BrowserContextHandle, BrowserTargetHandle)>, } impl NavigationDispatchState { diff --git a/moli-protocol/src/conn/state/page_slot.rs b/moli-protocol/src/conn/state/page_slot.rs index 3ee27ba80a..b9d3d2675b 100644 --- a/moli-protocol/src/conn/state/page_slot.rs +++ b/moli-protocol/src/conn/state/page_slot.rs @@ -1,21 +1,20 @@ -use std::hash::{Hash, Hasher}; - -use moli_core::page::{ - Page, RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, - RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, - RendererDocumentLifecycleSnapshot, RendererDocumentLifecycleWaitOutcome, - RendererDocumentLifecycleWaiter, RendererDocumentToken, RendererFrameToken, - RendererLifecycleEpoch, RendererLifecycleEventStamp, RendererLifecycleStartReason, - RendererLifecycleTerminationStamp, RendererPageCreationArtifacts, +use moli_core::{ + browser_host::{ + BrowserPageResidenceHandle, BrowserPageResidenceTransition, + BrowserPageResidenceTransitionKind, BrowserPageRuntimeAccess, BrowserPageRuntimeLease, + }, + page::{ + Page, RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, + RendererDocumentLifecycleIdentity, RendererDocumentLifecycleMilestone, + RendererDocumentLifecycleSnapshot, RendererDocumentToken, RendererFrameToken, + RendererLifecycleEpoch, RendererLifecycleEventStamp, RendererLifecycleStartReason, + RendererLifecycleTerminationStamp, RendererPageCreationArtifacts, + }, }; use tokio::sync::watch; -use super::document_lifecycle_observer::{ - RendererDocumentLifecycleObservation, RendererDocumentLifecycleObservationPublisher, - RendererDocumentLifecycleObserver, -}; use super::page_residence_token::{TargetPageResidencePublisher, TargetPageResidenceToken}; -use super::{NavigationRequestId, RendererPageResidenceIdentity, TargetPageAttachmentId}; +use super::{DocumentNavigationToken, RendererPageResidenceIdentity, TargetPageAttachmentId}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) enum TargetPageAbsenceReason { @@ -45,101 +44,6 @@ impl TargetPageAbsenceReason { } } -#[derive(Debug, Clone)] -pub(crate) struct DocumentNavigationToken { - pub(crate) target_id: String, - pub(crate) loader_id: String, - pub(crate) request_id: NavigationRequestId, -} - -impl PartialEq for DocumentNavigationToken { - fn eq(&self, other: &Self) -> bool { - self.request_id == other.request_id - && self.target_id == other.target_id - && self.loader_id == other.loader_id - } -} - -impl Eq for DocumentNavigationToken {} - -impl Hash for DocumentNavigationToken { - fn hash(&self, state: &mut H) { - self.request_id.hash(state); - self.target_id.hash(state); - self.loader_id.hash(state); - } -} - -/// The target-owned lifetime of one cross-Document navigation request. -/// -/// The exact token remains here from navigation admission until the request -/// either commits or fails. Background navigation additionally keeps this -/// owner alive until its lifecycle completion is drained; transport -/// cancellation is therefore retired by the same exact-token transition as -/// the protocol gate instead of by a scheduler-side mirror. -#[derive(Debug)] -pub(crate) struct PendingNavigationRequest { - token: DocumentNavigationToken, - page_attachment_id: TargetPageAttachmentId, - cancellation_handles: Vec, - background_completion_pending: bool, - committed: bool, -} - -impl PendingNavigationRequest { - fn new(token: DocumentNavigationToken) -> Self { - Self { - token, - page_attachment_id: TargetPageAttachmentId::allocate(), - cancellation_handles: vec![moli_fetch::FetchCancelHandle::new()], - background_completion_pending: false, - committed: false, - } - } - - fn matches(&self, token: &DocumentNavigationToken) -> bool { - self.token == *token - } - - fn cancellation_handle(&self) -> moli_fetch::FetchCancelHandle { - self.cancellation_handles - .first() - .expect("a pending navigation request must own cancellation authority") - .clone() - } - - fn arm_background_completion( - &mut self, - additional_cancellation: Option, - ) { - if let Some(cancellation) = additional_cancellation { - self.cancellation_handles.push(cancellation); - } - self.background_completion_pending = true; - } - - fn settle_background_completion(&mut self) { - self.background_completion_pending = false; - self.cancellation_handles.clear(); - } - - fn retire_without_cancellation(&mut self) { - self.cancellation_handles.clear(); - } - - fn cancel(&self) { - for cancellation in &self.cancellation_handles { - cancellation.cancel(); - } - } -} - -impl Drop for PendingNavigationRequest { - fn drop(&mut self) { - self.cancel(); - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CommittedRendererDocumentBinding { pub(crate) renderer_frame: RendererFrameToken, @@ -176,6 +80,27 @@ struct RendererDocumentLifecycleProtocolCursor { last_sequence: Option, } +/// One authoritative renderer lifecycle ingress and the subset immediately +/// visible to protocol projection. +/// +/// A command-response visibility barrier may hold back `visible`, but it must +/// not change which concrete renderer records became browser facts. +#[derive(Debug, Default)] +pub(crate) struct RendererDocumentLifecycleIngress { + authoritative: Vec, + visible: Vec, +} + +impl RendererDocumentLifecycleIngress { + pub(crate) fn authoritative(&self) -> &[RendererDocumentLifecycleEvent] { + &self.authoritative + } + + pub(crate) fn into_visible(self) -> Vec { + self.visible + } +} + impl RendererDocumentLifecycleProtocolCursor { fn from_snapshot(snapshot: RendererDocumentLifecycleSnapshot) -> Self { Self { @@ -245,51 +170,6 @@ struct RendererDocumentLoadVisibility { deferred_tail: Vec, } -#[derive(Debug)] -struct RegisteredRendererDocumentLifecycleWaiter { - id: RendererDocumentLifecycleWaiterId, - renderer_document: RendererDocumentToken, - renderer_epoch: RendererLifecycleEpoch, - frame_id: String, - loader_id: String, - waiter: RendererDocumentLifecycleWaiter, - observer_publisher: Option, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(crate) struct RendererDocumentLifecycleWaiterId(u64); - -impl RendererDocumentLifecycleWaiterId { - #[cfg(test)] - pub(crate) const fn new_for_test(id: u64) -> Self { - Self(id) - } - - fn allocate_next(&mut self) -> Self { - self.0 = self - .0 - .checked_add(1) - .expect("renderer Document lifecycle waiter id overflow"); - *self - } -} - -fn lifecycle_observation_from_wait_outcome( - outcome: RendererDocumentLifecycleWaitOutcome, -) -> RendererDocumentLifecycleObservation { - match outcome { - RendererDocumentLifecycleWaitOutcome::Pending => { - RendererDocumentLifecycleObservation::Pending - } - RendererDocumentLifecycleWaitOutcome::Reached(_) => { - RendererDocumentLifecycleObservation::Reached - } - RendererDocumentLifecycleWaitOutcome::Interrupted(_) => { - RendererDocumentLifecycleObservation::Interrupted - } - } -} - #[derive(Debug)] struct RootPostLoadObservation { binding: CommittedRendererDocumentBinding, @@ -329,66 +209,52 @@ impl InitialDocumentPageBuildWaiter { /// because both builds used the same target/session. #[derive(Debug, Clone, PartialEq, Eq)] enum PendingRendererPageBinding { - PageBuild { - renderer_page: RendererPageResidenceIdentity, - page_attachment_id: TargetPageAttachmentId, - }, InitialDocumentBuild { renderer_page: RendererPageResidenceIdentity, - page_attachment_id: TargetPageAttachmentId, }, DocumentNavigation { navigation: DocumentNavigationToken, renderer_page: RendererPageResidenceIdentity, - page_attachment_id: TargetPageAttachmentId, }, } impl PendingRendererPageBinding { fn renderer_page(&self) -> RendererPageResidenceIdentity { match self { - Self::PageBuild { renderer_page, .. } - | Self::InitialDocumentBuild { renderer_page, .. } + Self::InitialDocumentBuild { renderer_page } | Self::DocumentNavigation { renderer_page, .. } => *renderer_page, } } - - fn page_attachment_id(&self) -> TargetPageAttachmentId { - match self { - Self::PageBuild { - page_attachment_id, .. - } - | Self::InitialDocumentBuild { - page_attachment_id, .. - } - | Self::DocumentNavigation { - page_attachment_id, .. - } => *page_attachment_id, - } - } } #[derive(Debug, Default)] pub(crate) struct TargetPageSlot { - loaded_page: Option, + loaded_page: Option, loaded_page_absence_reason: TargetPageAbsenceReason, page_attachment_id: Option, + page_residence: BrowserPageResidenceHandle, page_residence_publisher: Option, - pending_navigation_request: Option, - committed_document_navigation: Option, renderer_document_lifecycle: RendererDocumentLifecycleProtocolState, - next_renderer_document_lifecycle_waiter_id: RendererDocumentLifecycleWaiterId, - renderer_document_lifecycle_waiters: Vec, root_post_load_observation: Option, initial_document_page_build_completion: Option>>>, pending_renderer_page: Option, } impl TargetPageSlot { + #[cfg(test)] pub(crate) fn empty_for_initial_document_page_build() -> Self { + Self::empty_for_initial_document_page_build_with_residence( + BrowserPageResidenceHandle::default(), + ) + } + + pub(crate) fn empty_for_initial_document_page_build_with_residence( + page_residence: BrowserPageResidenceHandle, + ) -> Self { Self { loaded_page: None, loaded_page_absence_reason: TargetPageAbsenceReason::InitialDocumentPageBuildPending, + page_residence, ..Default::default() } } @@ -405,32 +271,50 @@ impl TargetPageSlot { #[cfg(test)] pub(crate) fn with_loaded_page_for_test(loaded_page: Page) -> Self { Self { - loaded_page: Some(loaded_page), + loaded_page: Some(BrowserPageRuntimeAccess::from_page_for_test_fixture( + loaded_page, + )), page_attachment_id: Some(TargetPageAttachmentId::allocate()), ..Default::default() } } - pub(crate) fn loaded_page(&self) -> Option<&Page> { - self.loaded_page.as_ref() + pub(crate) fn loaded_page(&self) -> Option { + self.loaded_page + .as_ref() + .and_then(BrowserPageRuntimeAccess::checkout_page) + } + + pub(crate) fn loaded_renderer_page_residence(&self) -> Option { + self.loaded_page.as_ref().map(|page| { + RendererPageResidenceIdentity::from_parts( + page.renderer_owner_local_host_id(), + page.renderer_page_id(), + ) + }) } - pub(crate) fn loaded_page_mut(&mut self) -> Option<&mut Page> { - self.loaded_page.as_mut() + pub(crate) fn loaded_page_mut(&self) -> Option { + self.loaded_page() } pub(crate) fn has_loaded_page(&self) -> bool { - self.loaded_page.is_some() + self.loaded_page + .as_ref() + .is_some_and(BrowserPageRuntimeAccess::is_live) + } + + #[cfg(test)] + pub(crate) fn loaded_page_runtime_access_for_test(&self) -> Option { + self.loaded_page.clone() } pub(crate) fn loaded_page_absence_reason(&self) -> Option { - self.loaded_page - .is_none() - .then_some(self.loaded_page_absence_reason) + (!self.has_loaded_page()).then_some(self.loaded_page_absence_reason) } pub(crate) fn mark_loaded_page_absent(&mut self, reason: TargetPageAbsenceReason) { - if self.loaded_page.is_none() { + if !self.has_loaded_page() { if self.loaded_page_absence_reason == TargetPageAbsenceReason::InitialDocumentPageBuildInProgress && reason != TargetPageAbsenceReason::InitialDocumentPageBuildInProgress @@ -442,7 +326,7 @@ impl TargetPageSlot { } pub(crate) fn start_initial_document_page_build(&mut self) { - if self.loaded_page.is_none() { + if !self.has_loaded_page() { self.loaded_page_absence_reason = TargetPageAbsenceReason::InitialDocumentPageBuildInProgress; } @@ -455,7 +339,7 @@ impl TargetPageSlot { &mut self, renderer_page: RendererPageResidenceIdentity, ) -> bool { - if self.loaded_page.is_some() + if self.has_loaded_page() || self.loaded_page_absence_reason != TargetPageAbsenceReason::InitialDocumentPageBuildInProgress || self.initial_document_page_build_completion.is_none() @@ -463,10 +347,8 @@ impl TargetPageSlot { { return false; } - self.pending_renderer_page = Some(PendingRendererPageBinding::InitialDocumentBuild { - renderer_page, - page_attachment_id: TargetPageAttachmentId::allocate(), - }); + self.pending_renderer_page = + Some(PendingRendererPageBinding::InitialDocumentBuild { renderer_page }); true } @@ -504,33 +386,16 @@ impl TargetPageSlot { } } - pub(crate) fn replace_loaded_page_with_reason( + fn replace_loaded_page_fields( &mut self, - page: Option, + page: Option, absence_reason: TargetPageAbsenceReason, - ) -> Option { - let next_page_attachment_id = page.as_ref().map(|page| { - let renderer_page = RendererPageResidenceIdentity::from_page(page); - match self.pending_renderer_page.as_ref() { - Some(binding) => { - assert_eq!( - binding.renderer_page(), - renderer_page, - "installed Page must match its explicit renderer Page reservation" - ); - binding.page_attachment_id() - } - None => TargetPageAttachmentId::allocate(), - } - }); - if self.loaded_page.is_some() || page.is_some() { - self.finish_renderer_document_lifecycle_observers( - RendererDocumentLifecycleObservation::Superseded, - ); - } + ) -> Option { + self.pending_renderer_page = None; if page.is_some() { self.complete_initial_document_page_build(); self.loaded_page_absence_reason = TargetPageAbsenceReason::NoTarget; + self.page_attachment_id = Some(TargetPageAttachmentId::allocate()); } else { if self.loaded_page_absence_reason == TargetPageAbsenceReason::InitialDocumentPageBuildInProgress @@ -538,14 +403,132 @@ impl TargetPageSlot { self.fail_initial_document_page_build("InitialDocumentPageBuildCancelled".into()); } self.loaded_page_absence_reason = absence_reason; + self.page_attachment_id = None; } - self.page_attachment_id = next_page_attachment_id; - self.pending_renderer_page = None; let previous = std::mem::replace(&mut self.loaded_page, page); self.supersede_page_residence(); previous } + #[cfg(test)] + pub(crate) fn replace_loaded_page_with_reason( + &mut self, + page: Option, + absence_reason: TargetPageAbsenceReason, + ) -> Option { + let page_residence_changes = self.has_loaded_page() || page.is_some(); + let page = page.map(BrowserPageRuntimeAccess::from_page_for_test_fixture); + let previous = self.replace_loaded_page_fields(page, absence_reason); + if page_residence_changes { + self.page_residence.advance_generation_for_test_fixture(); + } + previous.and_then(BrowserPageRuntimeAccess::retire_and_take_page_for_test_fixture) + } + + fn project_page_residence_transition_after_browser_owner_commit( + &mut self, + page: Option, + absence_reason: TargetPageAbsenceReason, + transition: &BrowserPageResidenceTransition, + expected_kind: BrowserPageResidenceTransitionKind, + ) { + debug_assert_eq!( + transition.kind(), + expected_kind, + "physical Page projection must match the Browser Owner transition kind" + ); + debug_assert!( + self.page_residence.is_current(transition.current_page()), + "physical Page projection must match the committed Browser Owner residence" + ); + let _ = self.replace_loaded_page_fields(page, absence_reason); + } + + pub(crate) fn project_initial_document_page_after_browser_owner_commit( + &mut self, + transition: &BrowserPageResidenceTransition, + ) { + let page = transition.current_page_runtime().cloned(); + debug_assert!( + page.is_some(), + "initial Document commit must own a Page runtime" + ); + self.project_page_residence_transition_after_browser_owner_commit( + page, + TargetPageAbsenceReason::NoTarget, + transition, + BrowserPageResidenceTransitionKind::InitialDocumentMaterialization, + ) + } + + pub(crate) fn project_failed_navigation_page_absence_after_browser_owner_commit( + &mut self, + transition: &BrowserPageResidenceTransition, + ) { + self.project_page_residence_transition_after_browser_owner_commit( + None, + TargetPageAbsenceReason::NavigationFailed, + transition, + BrowserPageResidenceTransitionKind::FailedNavigationDiscard, + ) + } + + /// Drops a physical Page after Browser Core has already forgotten the + /// owning Target/Context. Exact owner lookup is already impossible, so the + /// orphaned capability must not be advanced by the projector. + pub(crate) fn retire_page_projection_after_browser_owner_forget(&mut self) -> Option { + let previous = self.replace_loaded_page_fields(None, TargetPageAbsenceReason::TargetClosed); + #[cfg(any(test, feature = "test-support"))] + { + previous.and_then(BrowserPageRuntimeAccess::retire_and_take_page_for_test_fixture) + } + #[cfg(not(any(test, feature = "test-support")))] + { + drop(previous); + None + } + } + + /// Projects a Page replacement already committed by Browser Core. + /// + /// The authoritative operation has advanced this slot's shared residence + /// capability. Protocol storage must install the physical Page without + /// advancing it a second time. + pub(crate) fn project_loaded_page_after_browser_owner_commit( + &mut self, + replacement: &moli_core::browser_host::BrowserPageReplacement, + ) { + assert!( + self.page_residence.is_current(replacement.current_page()), + "protocol Page projection must match the committed Browser Owner residence" + ); + let page = replacement.current_page_runtime().cloned(); + debug_assert!(page.is_some(), "loaded Page commit must own a Page runtime"); + let _ = self.replace_loaded_page_fields(page, TargetPageAbsenceReason::NoTarget); + } + + /// Projects Page absence after Browser Core has committed a Target + /// terminal transition and advanced this shared residence capability. + pub(crate) fn project_page_absence_after_browser_owner_termination( + &mut self, + reason: TargetPageAbsenceReason, + terminal_page: &moli_core::browser_host::PageResidenceIdentity, + ) { + assert!( + matches!( + reason, + TargetPageAbsenceReason::TargetClosed | TargetPageAbsenceReason::TargetCrashed + ), + "Browser Owner Target termination must project a terminal Page absence" + ); + assert!( + self.page_residence.is_current(terminal_page), + "protocol Target termination projection must match the committed Browser Owner residence" + ); + let _ = self.replace_loaded_page_fields(None, reason); + } + + #[cfg(test)] pub(crate) fn replace_loaded_page(&mut self, page: Option) -> Option { let Some(page) = page else { panic!( @@ -559,56 +542,17 @@ impl TargetPageSlot { self.page_attachment_id } - pub(crate) fn pending_page_attachment_id(&self) -> Option { - self.pending_renderer_page - .as_ref() - .map(PendingRendererPageBinding::page_attachment_id) - .or_else(|| { - self.pending_navigation_request - .as_ref() - .filter(|request| !request.committed) - .map(|request| request.page_attachment_id) - }) + #[cfg(test)] + pub(crate) fn loaded_page_generation(&self) -> u64 { + self.page_residence.generation() } - pub(crate) fn reserve_renderer_page_attachment( - &mut self, - renderer_page: RendererPageResidenceIdentity, - ) -> TargetPageAttachmentId { - if let Some(binding) = self.pending_renderer_page.as_ref() { - if binding.renderer_page() == renderer_page { - return binding.page_attachment_id(); - } - // A newly reserved renderer Page supersedes an earlier build that - // never reached installation. Its old output-owner binding remains - // harmless because this slot no longer routes that renderer Page. - self.pending_renderer_page = None; - } - - if let Some(request) = self - .pending_navigation_request - .as_ref() - .filter(|request| !request.committed) - { - let page_attachment_id = request.page_attachment_id; - self.pending_renderer_page = Some(PendingRendererPageBinding::DocumentNavigation { - navigation: request.token.clone(), - renderer_page, - page_attachment_id, - }); - return page_attachment_id; - } - - let page_attachment_id = TargetPageAttachmentId::allocate(); - self.pending_renderer_page = Some(PendingRendererPageBinding::PageBuild { - renderer_page, - page_attachment_id, - }); - page_attachment_id + pub(crate) fn page_residence_handle(&self) -> &BrowserPageResidenceHandle { + &self.page_residence } pub(crate) fn page_residence_token(&mut self) -> Option { - let attachment_id = self.page_attachment_id()?; + let attachment_id = self.page_attachment_id?; let publisher = self .page_residence_publisher .get_or_insert_with(|| TargetPageResidencePublisher::new(attachment_id)); @@ -621,143 +565,64 @@ impl TargetPageSlot { } } - #[cfg(test)] - pub(crate) fn set_page_attachment_id_for_test(&mut self, raw: u64) -> TargetPageAttachmentId { - let attachment_id = TargetPageAttachmentId::from_raw_for_test(raw); - self.install_page_attachment_id_for_test(attachment_id); - attachment_id - } - - #[cfg(test)] - pub(crate) fn replace_page_attachment_id_for_test(&mut self) -> TargetPageAttachmentId { - let mut attachment_id = TargetPageAttachmentId::allocate(); - while self.page_attachment_id == Some(attachment_id) { - attachment_id = TargetPageAttachmentId::allocate(); - } - self.install_page_attachment_id_for_test(attachment_id); - attachment_id + pub(crate) fn prepare_for_new_target(&mut self, page_residence: BrowserPageResidenceHandle) { + debug_assert!( + !self.has_loaded_page(), + "a loaded Page residence must move with its target slot" + ); + self.page_residence = page_residence; } #[cfg(test)] - pub(crate) fn install_page_attachment_id_for_test( + pub(crate) fn replace_page_residence_handle( &mut self, - attachment_id: TargetPageAttachmentId, + page_residence: BrowserPageResidenceHandle, ) { - let attachment_changed = self.page_attachment_id != Some(attachment_id); - self.page_attachment_id = Some(attachment_id); - if attachment_changed { - self.finish_renderer_document_lifecycle_observers( - RendererDocumentLifecycleObservation::Superseded, - ); - self.supersede_page_residence(); - } + self.page_residence = page_residence; } - pub(crate) fn start_document_navigation( - &mut self, - target_id: String, - loader_id: String, - ) -> DocumentNavigationToken { - self.finish_renderer_document_lifecycle_observers( - RendererDocumentLifecycleObservation::Superseded, - ); - let token = DocumentNavigationToken { - target_id, - loader_id, - request_id: NavigationRequestId::allocate(), - }; - self.pending_renderer_page = None; - self.pending_navigation_request = Some(PendingNavigationRequest::new(token.clone())); - token + #[cfg(test)] + fn advance_loaded_page_generation(&mut self) { + self.page_residence.advance_generation_for_test_fixture(); } - pub(crate) fn document_navigation_cancellation_handle( - &self, - token: &DocumentNavigationToken, - ) -> Option { - self.pending_navigation_request - .as_ref() - .filter(|request| request.matches(token) && !request.committed) - .map(PendingNavigationRequest::cancellation_handle) + #[cfg(test)] + pub(crate) fn bump_loaded_page_generation(&mut self) { + self.advance_loaded_page_generation(); } - pub(crate) fn arm_background_navigation_completion( - &mut self, - token: &DocumentNavigationToken, - additional_cancellation: Option, - ) -> bool { - let Some(request) = self.pending_navigation_request.as_mut().filter(|request| { - request.matches(token) && !request.committed && !request.background_completion_pending - }) else { - if let Some(cancellation) = additional_cancellation { - cancellation.cancel(); - } - return false; - }; - request.arm_background_completion(additional_cancellation); - true + #[cfg(test)] + pub(crate) fn set_loaded_page_generation(&mut self, generation: u64) { + self.page_residence + .set_generation_for_test_fixture(generation); } - pub(crate) fn settle_background_navigation_completion( - &mut self, - token: &DocumentNavigationToken, - ) -> bool { - let Some(request) = self - .pending_navigation_request - .as_mut() - .filter(|request| request.matches(token) && request.background_completion_pending) - else { - return false; - }; - request.settle_background_completion(); - if request.committed { - self.pending_navigation_request = None; + #[cfg(test)] + pub(crate) fn set_page_attachment_id_for_test(&mut self, raw: u64) -> TargetPageAttachmentId { + let attachment_id = TargetPageAttachmentId::from_raw_for_test(raw); + let attachment_changed = self.page_attachment_id != Some(attachment_id); + self.page_attachment_id = Some(attachment_id); + if attachment_changed { + self.supersede_page_residence(); } - true + attachment_id } - pub(crate) fn has_inflight_background_navigation(&self) -> bool { - self.pending_navigation_request - .as_ref() - .is_some_and(|request| request.background_completion_pending) + pub(crate) fn begin_document_navigation_protocol_state(&mut self) { + self.pending_renderer_page = None; } - pub(crate) fn bind_pending_document_navigation_renderer_page( + pub(crate) fn bind_document_navigation_renderer_page( &mut self, token: &DocumentNavigationToken, renderer_page: RendererPageResidenceIdentity, ) -> bool { - if !self - .pending_navigation_request - .as_ref() - .is_some_and(|request| request.matches(token) && !request.committed) - { + if self.pending_renderer_page.is_some() { return false; } - if let Some(binding) = self.pending_renderer_page.as_ref() { - return matches!( - binding, - PendingRendererPageBinding::DocumentNavigation { - navigation, - renderer_page: bound_renderer_page, - page_attachment_id, - } if navigation == token - && *bound_renderer_page == renderer_page - && Some(*page_attachment_id) - == self - .pending_navigation_request - .as_ref() - .map(|request| request.page_attachment_id) - ); - } self.pending_renderer_page = Some(PendingRendererPageBinding::DocumentNavigation { navigation: token.clone(), renderer_page, - page_attachment_id: self - .pending_navigation_request - .as_ref() - .expect("validated pending navigation request must remain installed") - .page_attachment_id, }); true } @@ -766,110 +631,37 @@ impl TargetPageSlot { &self, renderer_page: RendererPageResidenceIdentity, ) -> bool { - self.loaded_page - .as_ref() - .is_some_and(|page| RendererPageResidenceIdentity::from_page(page) == renderer_page) + self.loaded_renderer_page_residence() == Some(renderer_page) || self .pending_renderer_page .as_ref() .is_some_and(|binding| binding.renderer_page() == renderer_page) } - pub(crate) fn accepts_pending_document_navigation_event( - &self, - token: &DocumentNavigationToken, - ) -> bool { - self.pending_navigation_request - .as_ref() - .is_some_and(|request| request.matches(token) && !request.committed) - } - - pub(crate) fn accepts_document_body_completion_event( - &self, - token: &DocumentNavigationToken, - ) -> bool { - match self.pending_navigation_request.as_ref() { - Some(pending) => pending.matches(token), - None => self.committed_document_navigation.as_ref() == Some(token), - } - } - - pub(crate) fn has_pending_document_navigation(&self) -> bool { - self.pending_navigation_request - .as_ref() - .is_some_and(|request| !request.committed) - } - - pub(crate) fn current_document_loader_id(&self) -> Option<&str> { - self.pending_navigation_request - .as_ref() - .filter(|request| !request.committed) - .map(|request| &request.token) - .or(self.committed_document_navigation.as_ref()) - .map(|navigation| navigation.loader_id.as_str()) - } - - pub(crate) fn committed_document_loader_id(&self) -> Option<&str> { - self.committed_document_navigation - .as_ref() - .map(|navigation| navigation.loader_id.as_str()) - } - - pub(crate) fn commit_pending_document_navigation_if_matches( + pub(crate) fn clear_pending_renderer_page_if_loader_matches( &mut self, - token: &DocumentNavigationToken, + loader_id: &str, ) -> bool { - let Some(request) = self - .pending_navigation_request - .as_mut() - .filter(|request| request.matches(token) && !request.committed) - else { + if !matches!( + self.pending_renderer_page.as_ref(), + Some(PendingRendererPageBinding::DocumentNavigation { + navigation, + .. + }) if navigation.loader_id() == loader_id + ) { return false; - }; - self.committed_document_navigation = Some(token.clone()); - request.committed = true; - if !request.background_completion_pending { - request.retire_without_cancellation(); - self.pending_navigation_request = None; } + self.pending_renderer_page = None; true } - pub(crate) fn clear_pending_document_navigation_if_loader_matches( - &mut self, - loader_id: &str, - ) -> bool { - if self - .pending_navigation_request - .as_ref() - .is_some_and(|pending| !pending.committed && pending.token.loader_id == loader_id) - { - if matches!( - self.pending_renderer_page.as_ref(), - Some(PendingRendererPageBinding::DocumentNavigation { - navigation, - .. - }) if navigation.loader_id == loader_id - ) { - self.pending_renderer_page = None; - } - self.pending_navigation_request = None; - return true; - } - false - } - - pub(crate) fn clear_document_navigation_state(&mut self) { - self.finish_renderer_document_lifecycle_observers( - RendererDocumentLifecycleObservation::Unavailable, - ); - self.pending_navigation_request = None; - self.committed_document_navigation = None; + pub(crate) fn clear_renderer_document_protocol_state(&mut self) { self.pending_renderer_page = None; self.renderer_document_lifecycle = RendererDocumentLifecycleProtocolState::default(); self.root_post_load_observation = None; } + #[cfg(test)] pub(crate) fn bind_renderer_document_lifecycle( &mut self, artifacts: RendererPageCreationArtifacts, @@ -877,6 +669,19 @@ impl TargetPageSlot { frame_id: String, loader_id: String, ) -> Vec { + self.bind_renderer_document_lifecycle_with_ingress( + artifacts, navigation, frame_id, loader_id, + ) + .into_visible() + } + + pub(crate) fn bind_renderer_document_lifecycle_with_ingress( + &mut self, + artifacts: RendererPageCreationArtifacts, + navigation: Option, + frame_id: String, + loader_id: String, + ) -> RendererDocumentLifecycleIngress { let RendererPageCreationArtifacts { active_document, active_epoch, @@ -893,15 +698,15 @@ impl TargetPageSlot { snapshot_epoch = ?lifecycle_snapshot.epoch, "rejecting inconsistent renderer page creation lifecycle artifacts" ); - return Vec::new(); + return RendererDocumentLifecycleIngress::default(); } - let Some(page_attachment_id) = self.page_attachment_id() else { + let Some(page_attachment_id) = self.page_attachment_id else { tracing::debug!( ?active_document, ?active_epoch, "dropping renderer lifecycle artifacts without a current Page attachment" ); - return Vec::new(); + return RendererDocumentLifecycleIngress::default(); }; let initial_snapshot = initial_lifecycle_events .iter() @@ -936,11 +741,6 @@ impl TargetPageSlot { page_attachment_id, document_open_replacement_epoch: None, }; - if self.renderer_document_lifecycle.binding.as_ref() != Some(&binding) { - self.finish_renderer_document_lifecycle_observers( - RendererDocumentLifecycleObservation::Superseded, - ); - } tracing::trace!( target: "moli_renderer_document_lifecycle", renderer_document = ?active_document, @@ -959,7 +759,7 @@ impl TargetPageSlot { load_visibility: RendererDocumentLoadVisibility::default(), }; self.root_post_load_observation = None; - self.ingest_renderer_document_lifecycle_events(initial_lifecycle_events) + self.ingest_renderer_document_lifecycle_events_with_ingress(initial_lifecycle_events) } pub(crate) fn begin_renderer_document_load_visibility_barrier( @@ -1054,20 +854,24 @@ impl TargetPageSlot { .is_some() } + #[cfg(test)] pub(crate) fn ingest_renderer_document_lifecycle_events( &mut self, events: Vec, ) -> Vec { + self.ingest_renderer_document_lifecycle_events_with_ingress(events) + .into_visible() + } + + pub(crate) fn ingest_renderer_document_lifecycle_events_with_ingress( + &mut self, + events: Vec, + ) -> RendererDocumentLifecycleIngress { let binding_is_current = self .renderer_document_lifecycle .binding .as_ref() - .is_some_and(|binding| { - Some(binding.page_attachment_id) == self.page_attachment_id() - && binding.navigation.as_ref().is_none_or(|navigation| { - self.committed_document_navigation.as_ref() == Some(navigation) - }) - }); + .is_some_and(|binding| Some(binding.page_attachment_id) == self.page_attachment_id); if !binding_is_current { if !events.is_empty() { tracing::debug!( @@ -1075,9 +879,9 @@ impl TargetPageSlot { "dropping renderer lifecycle events for stale protocol binding" ); } - return Vec::new(); + return RendererDocumentLifecycleIngress::default(); } - let mut accepted = Vec::new(); + let mut ingress = RendererDocumentLifecycleIngress::default(); for event in events { let load_visibility_barrier_active = self .renderer_document_lifecycle @@ -1147,9 +951,6 @@ impl TargetPageSlot { continue; } if restarts_same_document { - self.finish_renderer_document_lifecycle_observers( - RendererDocumentLifecycleObservation::Superseded, - ); self.renderer_document_lifecycle .binding .as_mut() @@ -1168,32 +969,10 @@ impl TargetPageSlot { ) .then_some(event.epoch); } - for registration in &mut self.renderer_document_lifecycle_waiters { - registration.waiter.observe(event); - let observation = - lifecycle_observation_from_wait_outcome(registration.waiter.outcome()); - if observation.is_terminal() - && let Some(publisher) = registration.observer_publisher.as_ref() - { - publisher.publish(observation); - } - } - self.renderer_document_lifecycle_waiters - .retain(|registration| { - registration - .observer_publisher - .as_ref() - .is_none_or(|publisher| { - publisher.has_observer() - && !lifecycle_observation_from_wait_outcome( - registration.waiter.outcome(), - ) - .is_terminal() - }) - }); self.renderer_document_lifecycle .authoritative .observe(event); + ingress.authoritative.push(event); if defer_load_visibility { self.renderer_document_lifecycle .load_visibility @@ -1201,10 +980,10 @@ impl TargetPageSlot { .push(event); } else { self.renderer_document_lifecycle.visible.observe(event); - accepted.push(event); + ingress.visible.push(event); } } - accepted + ingress } pub(crate) fn renderer_document_lifecycle_binding( @@ -1213,12 +992,7 @@ impl TargetPageSlot { self.renderer_document_lifecycle .binding .as_ref() - .filter(|binding| { - Some(binding.page_attachment_id) == self.page_attachment_id() - && binding.navigation.as_ref().is_none_or(|navigation| { - self.committed_document_navigation.as_ref() == Some(navigation) - }) - }) + .filter(|binding| Some(binding.page_attachment_id) == self.page_attachment_id) } #[cfg(test)] @@ -1234,146 +1008,6 @@ impl TargetPageSlot { self.renderer_document_lifecycle.visible.snapshot } - pub(crate) fn register_renderer_document_lifecycle_waiter( - &mut self, - milestone: RendererDocumentLifecycleMilestone, - expected_loader_id: &str, - ) -> Option<( - RendererDocumentLifecycleWaiterId, - CommittedRendererDocumentBinding, - )> { - let binding = self.renderer_document_lifecycle.binding.clone()?; - if binding.loader_id != expected_loader_id { - return None; - } - let snapshot = self.renderer_document_lifecycle.authoritative.snapshot?; - let id = self - .next_renderer_document_lifecycle_waiter_id - .allocate_next(); - self.renderer_document_lifecycle_waiters - .push(RegisteredRendererDocumentLifecycleWaiter { - id, - renderer_document: binding.renderer_document, - renderer_epoch: binding.renderer_epoch, - frame_id: binding.frame_id.clone(), - loader_id: binding.loader_id.clone(), - waiter: RendererDocumentLifecycleWaiter::from_snapshot(snapshot, milestone), - observer_publisher: None, - }); - Some((id, binding)) - } - - pub(crate) fn register_exact_renderer_document_lifecycle_observer( - &mut self, - expected_binding: &CommittedRendererDocumentBinding, - milestone: RendererDocumentLifecycleMilestone, - ) -> RendererDocumentLifecycleObserver { - let Some(binding) = self.renderer_document_lifecycle.binding.as_ref() else { - return RendererDocumentLifecycleObserver::resolved( - RendererDocumentLifecycleObservation::Unavailable, - ); - }; - if binding != expected_binding { - return RendererDocumentLifecycleObserver::resolved( - RendererDocumentLifecycleObservation::Superseded, - ); - } - let Some(snapshot) = self.renderer_document_lifecycle.authoritative.snapshot else { - return RendererDocumentLifecycleObserver::resolved( - RendererDocumentLifecycleObservation::Unavailable, - ); - }; - let waiter = RendererDocumentLifecycleWaiter::from_snapshot(snapshot, milestone); - let observation = lifecycle_observation_from_wait_outcome(waiter.outcome()); - let (publisher, observer) = RendererDocumentLifecycleObserver::channel(observation); - if observation == RendererDocumentLifecycleObservation::Pending { - let id = self - .next_renderer_document_lifecycle_waiter_id - .allocate_next(); - self.renderer_document_lifecycle_waiters - .retain(|registration| { - registration - .observer_publisher - .as_ref() - .is_none_or(RendererDocumentLifecycleObservationPublisher::has_observer) - }); - self.renderer_document_lifecycle_waiters.push( - RegisteredRendererDocumentLifecycleWaiter { - id, - renderer_document: binding.renderer_document, - renderer_epoch: binding.renderer_epoch, - frame_id: binding.frame_id.clone(), - loader_id: binding.loader_id.clone(), - waiter, - observer_publisher: Some(publisher), - }, - ); - } - observer - } - - fn finish_renderer_document_lifecycle_observers( - &mut self, - observation: RendererDocumentLifecycleObservation, - ) { - assert!( - observation.is_terminal(), - "retiring lifecycle waiters requires a terminal observation" - ); - self.renderer_document_lifecycle_waiters - .retain(|registration| { - let Some(publisher) = registration.observer_publisher.as_ref() else { - // Polling DevTools wait keys own their explicit release - // protocol. Preserve their reached/interrupted result - // across a successor binding until that consumer reads - // and releases the exact registration. - return true; - }; - publisher.publish(observation); - false - }); - } - - pub(crate) fn renderer_document_lifecycle_waiter_outcome( - &self, - id: RendererDocumentLifecycleWaiterId, - renderer_document: RendererDocumentToken, - renderer_epoch: RendererLifecycleEpoch, - frame_id: &str, - loader_id: &str, - ) -> Option { - self.renderer_document_lifecycle_waiters - .iter() - .find(|registration| { - registration.id == id - && registration.renderer_document == renderer_document - && registration.renderer_epoch == renderer_epoch - && registration.frame_id == frame_id - && registration.loader_id == loader_id - }) - .map(|registration| registration.waiter.outcome()) - } - - pub(crate) fn release_renderer_document_lifecycle_waiter( - &mut self, - id: RendererDocumentLifecycleWaiterId, - renderer_document: RendererDocumentToken, - renderer_epoch: RendererLifecycleEpoch, - frame_id: &str, - loader_id: &str, - ) -> bool { - let previous_len = self.renderer_document_lifecycle_waiters.len(); - self.renderer_document_lifecycle_waiters - .retain(|registration| { - registration.id != id - || registration.renderer_document != renderer_document - || registration.renderer_epoch != renderer_epoch - || registration.frame_id != frame_id - || registration.loader_id != loader_id - }); - self.renderer_document_lifecycle_waiters.len() != previous_len - } - pub(crate) fn arm_root_post_load_observation(&mut self, loader_id: &str) -> bool { let Some(binding) = self .renderer_document_lifecycle @@ -1381,7 +1015,7 @@ impl TargetPageSlot { .as_ref() .filter(|binding| { binding.loader_id == loader_id - && Some(binding.page_attachment_id) == self.page_attachment_id() + && Some(binding.page_attachment_id) == self.page_attachment_id }) .cloned() else { @@ -1432,8 +1066,9 @@ impl TargetPageSlot { pub(crate) fn take_root_network_idle_binding( &mut self, + has_pending_document_navigation: bool, ) -> Option { - if self.has_pending_document_navigation() { + if has_pending_document_navigation { return None; } if !self.root_post_load_binding_is_current() { @@ -1488,30 +1123,20 @@ mod page_residence_tests { use crate::conn::TargetPageResidenceObservation; #[test] - fn empty_slot_never_exposes_a_page_attachment() { - let mut slot = TargetPageSlot::default(); - - assert_eq!(slot.page_attachment_id(), None); - assert!( - slot.replace_loaded_page_with_reason(None, TargetPageAbsenceReason::TestFixture) - .is_none() - ); - assert_eq!(slot.page_attachment_id(), None); - } - - #[test] - fn attachment_token_terminates_on_attachment_replacement() { + fn attachment_token_ignores_generation_and_terminates_on_attachment_replacement() { let mut slot = TargetPageSlot::default(); slot.set_page_attachment_id_for_test(91); let token = slot .page_residence_token() .expect("the installed attachment should expose its lifetime token"); + slot.bump_loaded_page_generation(); + let mut wait = Box::pin(token.wait()); let mut context = Context::from_waker(Waker::noop()); assert!( matches!(wait.as_mut().poll(&mut context), Poll::Pending), - "a live attachment token must remain pending" + "changing only the slot generation must not terminate an attachment token" ); slot.set_page_attachment_id_for_test(92); @@ -1553,60 +1178,29 @@ mod pending_renderer_page_tests { assert!(!slot.routes_renderer_page(expected)); } - #[test] - fn navigation_reservation_preallocates_one_exact_page_attachment() { - let mut slot = TargetPageSlot::default(); - let current_attachment = slot.set_page_attachment_id_for_test(19); - let navigation = - slot.start_document_navigation("TID-pending-page".to_owned(), "LOADER-next".to_owned()); - let reserved_attachment = slot - .pending_page_attachment_id() - .expect("navigation should reserve its future Page attachment"); - let reserved_page = renderer_page(8, 20); - - assert_ne!(reserved_attachment, current_attachment); - assert_eq!( - slot.reserve_renderer_page_attachment(reserved_page), - reserved_attachment - ); - assert_eq!( - slot.reserve_renderer_page_attachment(reserved_page), - reserved_attachment, - "revisiting the same renderer Page reservation must be idempotent" - ); - assert!( - slot.bind_pending_document_navigation_renderer_page(&navigation, reserved_page), - "the navigation binding should accept its already-reserved renderer Page" - ); - } - #[test] fn navigation_binding_cannot_follow_a_superseding_navigation() { let mut slot = TargetPageSlot::default(); - let first = slot - .start_document_navigation("TID-pending-page".to_owned(), "LOADER-first".to_owned()); + let first = DocumentNavigationToken::new("TID-pending-page", "LOADER-first"); + slot.begin_document_navigation_protocol_state(); let first_page = renderer_page(8, 21); - assert!(slot.bind_pending_document_navigation_renderer_page(&first, first_page)); + assert!(slot.bind_document_navigation_renderer_page(&first, first_page)); assert!(slot.routes_renderer_page(first_page)); assert!( - !slot.bind_pending_document_navigation_renderer_page(&first, renderer_page(8, 23),), - "one navigation request cannot replace its bound renderer Page" + !slot.bind_document_navigation_renderer_page(&first, renderer_page(8, 23),), + "one navigation generation cannot replace its bound renderer Page" ); - let second = slot - .start_document_navigation("TID-pending-page".to_owned(), "LOADER-second".to_owned()); + let second = DocumentNavigationToken::new("TID-pending-page", "LOADER-second"); + slot.begin_document_navigation_protocol_state(); assert!( !slot.routes_renderer_page(first_page), "a new navigation must retire the prior pending renderer Page route" ); - assert!( - !slot.bind_pending_document_navigation_renderer_page(&first, first_page), - "a superseded navigation cannot reinstall its renderer Page route" - ); let second_page = renderer_page(8, 22); - assert!(slot.bind_pending_document_navigation_renderer_page(&second, second_page)); - assert!(slot.clear_pending_document_navigation_if_loader_matches("LOADER-second")); + assert!(slot.bind_document_navigation_renderer_page(&second, second_page)); + assert!(slot.clear_pending_renderer_page_if_loader_matches("LOADER-second")); assert!(!slot.routes_renderer_page(second_page)); } } @@ -1729,9 +1323,7 @@ mod renderer_document_lifecycle_tests { ); let mut slot = page_slot_with_attachment(); slot.set_page_attachment_id_for_test(4); - let navigation = - slot.start_document_navigation("FRAME-9".to_owned(), "LOADER-9".to_owned()); - assert!(slot.commit_pending_document_navigation_if_matches(&navigation)); + let navigation = DocumentNavigationToken::new("FRAME-9", "LOADER-9"); let accepted = slot.bind_renderer_document_lifecycle( RendererPageCreationArtifacts { active_document: document, @@ -1834,9 +1426,7 @@ mod renderer_document_lifecycle_tests { ); let mut slot = page_slot_with_attachment(); slot.set_page_attachment_id_for_test(5); - let navigation = - slot.start_document_navigation("FRAME-10".to_owned(), "LOADER-10".to_owned()); - assert!(slot.commit_pending_document_navigation_if_matches(&navigation)); + let navigation = DocumentNavigationToken::new("FRAME-10", "LOADER-10"); assert_eq!( slot.bind_renderer_document_lifecycle( RendererPageCreationArtifacts { @@ -1863,13 +1453,6 @@ mod renderer_document_lifecycle_tests { vec![started] ); assert!(slot.begin_renderer_document_load_visibility_barrier("LOADER-10")); - let (load_waiter_id, load_waiter_binding) = slot - .register_renderer_document_lifecycle_waiter( - RendererDocumentLifecycleMilestone::Load, - "LOADER-10", - ) - .expect("load waiter should bind to the authoritative document state"); - assert_eq!( slot.ingest_renderer_document_lifecycle_events(vec![dcl, load, terminated]), vec![dcl], @@ -1884,22 +1467,6 @@ mod renderer_document_lifecycle_tests { }), "load readiness is authoritative even while its protocol event is hidden" ); - assert_eq!( - slot.renderer_document_lifecycle_waiter_outcome( - load_waiter_id, - load_waiter_binding.renderer_document, - load_waiter_binding.renderer_epoch, - &load_waiter_binding.frame_id, - &load_waiter_binding.loader_id, - ), - Some(RendererDocumentLifecycleWaitOutcome::Reached( - RendererLifecycleEventStamp { - sequence: 3, - timestamp_micros: 30, - } - )), - "navigation waiters observe authoritative load readiness" - ); let visible_before_release = slot .renderer_document_lifecycle_visible_snapshot() .expect("visible lifecycle cursor"); @@ -2057,9 +1624,7 @@ mod renderer_document_lifecycle_tests { ); let mut slot = page_slot_with_attachment(); slot.set_page_attachment_id_for_test(6); - let navigation = - slot.start_document_navigation("FRAME-11".to_owned(), "LOADER-11".to_owned()); - assert!(slot.commit_pending_document_navigation_if_matches(&navigation)); + let navigation = DocumentNavigationToken::new("FRAME-11", "LOADER-11"); assert_eq!( slot.bind_renderer_document_lifecycle( RendererPageCreationArtifacts { @@ -2305,7 +1870,7 @@ mod renderer_document_lifecycle_tests { } #[test] - fn successor_document_binding_discards_deferred_tail_but_preserves_reached_waiter() { + fn successor_document_binding_discards_deferred_tail_and_resets_projection() { let page_id = moli_core::PageId::new_for_testing(14); let document = RendererDocumentToken::new_for_testing(page_id, 1); let epoch = RendererLifecycleEpoch(1); @@ -2351,20 +1916,6 @@ mod renderer_document_lifecycle_tests { "FRAME-14".to_owned(), "LOADER-14".to_owned(), ); - assert!( - slot.register_renderer_document_lifecycle_waiter( - RendererDocumentLifecycleMilestone::Load, - "LOADER-previous", - ) - .is_none(), - "a fast-ack navigation must not register against the previous loader" - ); - let (waiter_id, binding) = slot - .register_renderer_document_lifecycle_waiter( - RendererDocumentLifecycleMilestone::Load, - "LOADER-14", - ) - .expect("source document load waiter"); let load = event( document, epoch, @@ -2428,24 +1979,6 @@ mod renderer_document_lifecycle_tests { slot.renderer_document_lifecycle_visible_snapshot() .is_some_and(|snapshot| snapshot.document == successor && snapshot.load.is_none()) ); - - assert!(matches!( - slot.renderer_document_lifecycle_waiter_outcome( - waiter_id, - binding.renderer_document, - binding.renderer_epoch, - &binding.frame_id, - &binding.loader_id, - ), - Some(RendererDocumentLifecycleWaitOutcome::Reached(stamp)) if stamp.sequence == 3 - )); - assert!(slot.release_renderer_document_lifecycle_waiter( - waiter_id, - binding.renderer_document, - binding.renderer_epoch, - &binding.frame_id, - &binding.loader_id, - )); } #[test] @@ -2496,8 +2029,7 @@ mod renderer_document_lifecycle_tests { assert!(slot.arm_root_post_load_observation("LOADER-12")); assert!(!slot.arm_root_post_load_observation("LOADER-12")); - slot.start_document_navigation("FRAME-12".to_owned(), "LOADER-13".to_owned()); - assert!(slot.take_root_network_idle_binding().is_none()); + assert!(slot.take_root_network_idle_binding(true).is_none()); assert_eq!( slot.take_root_frame_stopped_loading_binding() .expect("frame-stop observation") @@ -2505,13 +2037,12 @@ mod renderer_document_lifecycle_tests { "LOADER-12" ); assert!(slot.take_root_frame_stopped_loading_binding().is_none()); - assert!(slot.clear_pending_document_navigation_if_loader_matches("LOADER-13")); assert_eq!( - slot.take_root_network_idle_binding() + slot.take_root_network_idle_binding(false) .expect("network-idle observation after provisional navigation failure") .frame_id, "FRAME-12" ); - assert!(slot.take_root_network_idle_binding().is_none()); + assert!(slot.take_root_network_idle_binding(false).is_none()); } } diff --git a/moli-protocol/src/conn/state/parking.rs b/moli-protocol/src/conn/state/parking.rs index 9e18ac57f9..be768ec24d 100644 --- a/moli-protocol/src/conn/state/parking.rs +++ b/moli-protocol/src/conn/state/parking.rs @@ -1,5 +1,9 @@ use std::collections::{HashMap, HashSet}; +use moli_core::browser_host::{ + BrowserPageResidenceHandle, BrowserTargetHandle, BrowserTargetSessionStorageAccess, + EmulatedGeolocationOverrideState, EmulatedNetworkConditions, +}; use moli_core::network::SharedWebStorageStore; use moli_page_types::FrontendCommandId; use serde_json::{Value, json}; @@ -19,15 +23,9 @@ use crate::{ }; use super::{ - DevToolsSessionState, devtools_sessions_have_pending_inspector_awaits, - devtools_sessions_pending_inspector_await_count, - emulation::{ - EmulatedDeviceMetrics, EmulatedGeolocationOverrideState, EmulatedMediaOverrides, - EmulatedNetworkConditions, - }, + emulation::{EmulatedDeviceMetrics, EmulatedMediaOverrides}, fetch::{ParkedFetchState, TargetFetchConfig}, identity::TargetIdentityState, - navigation::{PageNavigationHistoryEntry, TargetNavigationHistoryState}, page_resource::TargetPageResourceStore, page_slot::{DocumentStartScript, IsolatedWorldDefinition, TargetPageSlot}, pending_renderer_command::{ @@ -43,8 +41,12 @@ use super::{ #[derive(Debug)] pub struct BackgroundTarget { - pub(in crate::conn) target_id: String, - pub(in crate::conn) session_id: Option, + pub(in crate::conn) target_handle: BrowserTargetHandle, + /// Legacy fixture input consumed into `BrowserContext`'s attachment + /// registry as soon as a test context is registered. It is never present + /// in production and is not consulted by runtime routing. + #[cfg(test)] + fixture_primary_session_id: Option, pub(in crate::conn) target_identity: TargetIdentityState, pub(in crate::conn) runtime_slot: TargetRuntimeSlot, session_storage_namespace: TargetSessionStorageNamespace, @@ -52,14 +54,14 @@ pub struct BackgroundTarget { impl BackgroundTarget { pub(crate) fn new( - target_id: String, - session_id: Option, + target_handle: impl Into, target_identity: TargetIdentityState, target_page_slot: TargetPageSlot, ) -> Self { Self { - target_id, - session_id, + target_handle: target_handle.into(), + #[cfg(test)] + fixture_primary_session_id: None, target_identity, runtime_slot: TargetRuntimeSlot::from_page_slot(target_page_slot), session_storage_namespace: TargetSessionStorageNamespace::default(), @@ -67,60 +69,97 @@ impl BackgroundTarget { } #[cfg(test)] - pub(crate) fn with_url(target_id: String, session_id: Option, url: String) -> Self { - Self::new( + pub(crate) fn new_with_frontend_session( + target_handle: impl Into, + frontend_session_id: Option, + target_identity: TargetIdentityState, + target_page_slot: TargetPageSlot, + ) -> Self { + let mut target = Self::new(target_handle, target_identity, target_page_slot); + target.fixture_primary_session_id = frontend_session_id; + target + } + + #[cfg(test)] + pub(crate) fn with_url( + target_id: String, + frontend_session_id: Option, + url: String, + ) -> Self { + Self::new_with_frontend_session( target_id, - session_id, + frontend_session_id, TargetIdentityState::with_url(url), TargetPageSlot::empty_for_test_fixture(), ) } pub(crate) fn with_identity( - target_id: String, - session_id: Option, + target_handle: impl Into, + page_residence: BrowserPageResidenceHandle, target_identity: TargetIdentityState, ) -> Self { Self::new( - target_id, - session_id, + target_handle, target_identity, - TargetPageSlot::empty_for_initial_document_page_build(), + TargetPageSlot::empty_for_initial_document_page_build_with_residence(page_residence), ) } pub(crate) fn target_id(&self) -> &str { - &self.target_id - } - - pub(crate) fn is_target(&self, target_id: &str) -> bool { - self.target_id() == target_id + self.target_handle.target_id() } - pub(crate) fn session_id(&self) -> Option<&str> { - self.session_id.as_deref() + pub(crate) fn target_handle(&self) -> &BrowserTargetHandle { + &self.target_handle } - pub(crate) fn has_session(&self) -> bool { - self.session_id.is_some() + #[cfg(test)] + pub(crate) fn replace_target_handle(&mut self, target_handle: BrowserTargetHandle) { + assert_eq!( + self.target_id(), + target_handle.target_id(), + "physical Target payload cannot change its public identity while binding a Core handle" + ); + self.target_handle = target_handle; } - pub(crate) fn is_session(&self, session_id: &str) -> bool { - self.session_id() == Some(session_id) + #[cfg(test)] + pub(crate) fn take_fixture_primary_session_id(&mut self) -> Option { + self.fixture_primary_session_id.take() } - pub(crate) fn attach_session(&mut self, session_id: String) { - self.session_id = Some(session_id); + #[cfg(test)] + pub(crate) fn replace_page_residence_handle( + &mut self, + page_residence: BrowserPageResidenceHandle, + ) { + self.runtime_slot + .page_slot_mut() + .replace_page_residence_handle(page_residence); } - pub(crate) fn detach_session(&mut self) -> Option { - self.session_id.take() + pub(crate) fn is_target(&self, target_id: &str) -> bool { + self.target_id() == target_id } pub(crate) fn session_storage_store(&self) -> &SharedWebStorageStore { self.session_storage_namespace.store() } + pub(crate) fn bind_session_storage_access( + &mut self, + access: BrowserTargetSessionStorageAccess, + ) { + debug_assert_eq!( + access.target_handle(), + &self.target_handle, + "one background Target must bind its own exact sessionStorage access" + ); + self.session_storage_namespace.bind_browser_access(access); + } + + #[cfg(test)] pub(crate) fn deep_clone_session_storage_namespace(&self) -> TargetSessionStorageNamespace { self.session_storage_namespace.deep_clone() } @@ -149,8 +188,7 @@ impl TargetSlotState { } pub(crate) fn from_active_snapshot( - target_id: String, - session_id: Option, + target_handle: BrowserTargetHandle, target_identity: TargetIdentityState, runtime_slot: TargetRuntimeSlot, session_storage_namespace: TargetSessionStorageNamespace, @@ -158,8 +196,9 @@ impl TargetSlotState { ) -> Self { Self { target: BackgroundTarget { - target_id, - session_id, + target_handle, + #[cfg(test)] + fixture_primary_session_id: None, target_identity, runtime_slot, session_storage_namespace, @@ -187,10 +226,26 @@ impl TargetSlotState { } } +/// A physical background Target payload removed from its exact vector slot +/// while Browser Core decides the matching topology transaction. +#[derive(Debug)] +pub(crate) struct StagedBackgroundTargetSlot { + index: usize, + slot: TargetSlotState, +} + +impl StagedBackgroundTargetSlot { + pub(crate) fn new(index: usize, slot: TargetSlotState) -> Self { + Self { index, slot } + } + + pub(crate) fn into_parts(self) -> (usize, TargetSlotState) { + (self.index, self.slot) + } +} + #[derive(Debug, Clone, PartialEq)] pub struct ParkedPageSessionState { - pub(crate) devtools_session_state: DevToolsSessionState, - pub(crate) auxiliary_devtools_session_states: HashMap, pub network_enabled: bool, pub(crate) network_policy: TargetNetworkPolicyState, pub http_proxy_override: Option, @@ -214,8 +269,6 @@ pub struct ParkedPageSessionState { impl Default for ParkedPageSessionState { fn default() -> Self { Self { - devtools_session_state: DevToolsSessionState::default(), - auxiliary_devtools_session_states: HashMap::new(), network_enabled: false, network_policy: TargetNetworkPolicyState::default(), http_proxy_override: None, @@ -238,118 +291,68 @@ impl Default for ParkedPageSessionState { } } -impl ParkedPageSessionState { - pub(crate) fn has_pending_javascript_dialog(&self) -> bool { - !self - .devtools_session_state - .page_session_state - .javascript_dialog_state - .is_empty() - || self - .auxiliary_devtools_session_states - .values() - .any(|session| { - !session - .page_session_state - .javascript_dialog_state - .is_empty() - }) - } -} - -fn parked_page_session_has_page_domain_enabled(state: &ParkedPageSessionState) -> bool { - state - .devtools_session_state - .page_session_state - .page_domain_enabled - || state - .auxiliary_devtools_session_states - .values() - .any(|session_state| session_state.page_session_state.page_domain_enabled) -} - -fn parked_page_session_has_runtime_remote_object_id( - state: &ParkedPageSessionState, - object_id: &str, -) -> bool { - state - .devtools_session_state - .has_runtime_remote_object_id(object_id) - || state - .auxiliary_devtools_session_states - .values() - .any(|session_state| session_state.has_runtime_remote_object_id(object_id)) -} - -fn parked_page_session_has_runtime_remote_object_id_for_different_session( - state: &ParkedPageSessionState, - devtools_session_id: Option<&str>, - object_id: &str, -) -> bool { - if devtools_session_id.is_some() - && state - .devtools_session_state - .has_runtime_remote_object_id(object_id) - { - return true; - } - state - .auxiliary_devtools_session_states - .iter() - .any(|(session_id, session_state)| { - Some(session_id.as_str()) != devtools_session_id - && session_state.has_runtime_remote_object_id(object_id) - }) -} - -pub(crate) fn parked_page_session_has_pending_inspector_awaits( - state: &ParkedPageSessionState, -) -> bool { - devtools_sessions_have_pending_inspector_awaits( - &state.devtools_session_state, - &state.auxiliary_devtools_session_states, - ) -} - -pub(crate) fn parked_page_session_pending_inspector_await_count( - state: &ParkedPageSessionState, -) -> usize { - devtools_sessions_pending_inspector_await_count( - &state.devtools_session_state, - &state.auxiliary_devtools_session_states, - ) -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default)] pub struct ParkedNetworkArtifacts { target_network_artifacts: TargetNetworkArtifacts, request_counters: TargetNetworkRequestCounters, + browser_artifacts: moli_core::browser_host::BrowserNetworkArtifactStore, +} + +impl PartialEq for ParkedNetworkArtifacts { + fn eq(&self, other: &Self) -> bool { + self.target_network_artifacts == other.target_network_artifacts + && self.request_counters == other.request_counters + } } +impl Eq for ParkedNetworkArtifacts {} + impl ParkedNetworkArtifacts { + pub(crate) fn adopt_browser_network_artifact_store( + &mut self, + browser_artifacts: moli_core::browser_host::BrowserNetworkArtifactStore, + ) { + let request_ids = self + .target_network_artifacts + .body_request_ids() + .map(str::to_owned) + .collect::>(); + browser_artifacts.adopt_entries_from( + &self.browser_artifacts, + request_ids.iter().map(String::as_str), + ); + self.browser_artifacts = browser_artifacts; + } + pub(crate) fn snapshot_from_runtime_slot(runtime_slot: &TargetRuntimeSlot) -> Self { Self { target_network_artifacts: runtime_slot.snapshot_network_artifacts(), request_counters: runtime_slot.snapshot_network_request_counters(), + browser_artifacts: runtime_slot.browser_network_artifact_store(), } } pub(crate) fn restore_into_runtime_slot(self, runtime_slot: &mut TargetRuntimeSlot) { + runtime_slot.adopt_browser_network_artifact_store(self.browser_artifacts); runtime_slot.restore_network_artifacts(self.target_network_artifacts); runtime_slot.restore_network_request_counters(self.request_counters); } pub(crate) fn collected_network_data_artifacts(&self) -> Vec { self.target_network_artifacts - .collected_network_data_artifacts() + .collected_network_data_artifacts(&self.browser_artifacts) } pub(crate) fn drain_from_background_target(&mut self, target: &mut BackgroundTarget) { self.target_network_artifacts = target.runtime_slot.take_network_artifacts(); self.request_counters = target.runtime_slot.take_network_request_counters(); + self.browser_artifacts = target.runtime_slot.browser_network_artifact_store(); } pub(crate) fn drain_into_background_target(&mut self, target: &mut BackgroundTarget) { + target + .runtime_slot + .adopt_browser_network_artifact_store(self.browser_artifacts.clone()); target .runtime_slot .restore_network_artifacts(std::mem::take(&mut self.target_network_artifacts)); @@ -767,137 +770,12 @@ pub(crate) struct TargetWindowSurfaceGeometry { pub(crate) y: i32, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TargetInitialEmptyDocumentCreator { - target_id: String, - security_origin: String, - secure_context_type: String, -} - -impl TargetInitialEmptyDocumentCreator { - pub(crate) fn new( - target_id: String, - security_origin: String, - secure_context_type: String, - ) -> Self { - Self { - target_id, - security_origin, - secure_context_type, - } - } - - pub(crate) fn target_id(&self) -> &str { - &self.target_id - } - - pub(crate) fn security_origin(&self) -> &str { - &self.security_origin - } - - pub(crate) fn secure_context_type(&self) -> &str { - &self.secure_context_type - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TargetInitialEmptyDocumentState { - target_id: String, - loader_id: String, - initial_url: String, - creator: Option, - storage_key: Option, - materialized: bool, - exited: bool, - pending_cross_document_navigation: bool, -} - -impl TargetInitialEmptyDocumentState { - fn new( - target_id: String, - initial_url: String, - creator: Option, - storage_key: Option, - ) -> Self { - let loader_id = format!("LID-INITIAL-{target_id}"); - Self { - target_id, - loader_id, - initial_url, - creator, - storage_key, - materialized: false, - exited: false, - pending_cross_document_navigation: false, - } - } - - pub(crate) fn target_id(&self) -> &str { - &self.target_id - } - - pub(crate) fn initial_url(&self) -> &str { - &self.initial_url - } - - pub(crate) fn loader_id(&self) -> &str { - &self.loader_id - } - - pub(crate) fn creator(&self) -> Option<&TargetInitialEmptyDocumentCreator> { - self.creator.as_ref() - } - - pub(crate) fn storage_key(&self) -> Option<&moli_storage_key::MoliStorageKey> { - self.storage_key.as_ref() - } - - pub(crate) fn materialized(&self) -> bool { - self.materialized - } - - pub(crate) fn exited(&self) -> bool { - self.exited - } - - pub(crate) fn pending_cross_document_navigation(&self) -> bool { - self.pending_cross_document_navigation - } - - pub(crate) fn is_on_initial_empty_document(&self) -> bool { - !self.exited - } - - fn mark_materialized(&mut self) { - if !self.exited { - self.materialized = true; - } - } - - fn mark_pending_cross_document_navigation(&mut self) { - if !self.exited { - self.pending_cross_document_navigation = true; - } - } - - fn clear_pending_cross_document_navigation(&mut self) { - self.pending_cross_document_navigation = false; - } - - fn mark_exited(&mut self) { - self.exited = true; - self.pending_cross_document_navigation = false; - } -} - #[derive(Debug, Clone, Default)] pub(crate) struct TargetOwnerState { - pub(crate) initial_empty_document: Option, pub(crate) committed_document_title: Option, pub(crate) next_document_start_script_id: u32, pub(crate) document_start_scripts: Vec<(String, DocumentStartScript)>, pub(crate) isolated_worlds: Vec, - pub(crate) navigation_history_state: TargetNavigationHistoryState, pub(crate) page_resource_store: TargetPageResourceStore, pub(crate) runtime_observable_state: TargetRuntimeObservableState, pub(crate) console_output_state: TargetConsoleOutputState, @@ -916,9 +794,7 @@ impl TargetOwnerState { pub(crate) fn commit_document_title(&mut self, title: String) -> bool { let changed = self.committed_document_title.as_deref().unwrap_or_default() != title; - self.committed_document_title = Some(title.clone()); - self.navigation_history_state - .refresh_current_entry_title(title); + self.committed_document_title = Some(title); changed } @@ -928,125 +804,8 @@ impl TargetOwnerState { .any(|(_, script)| script.has_bidi_channel_argument) } - pub(crate) fn begin_initial_empty_document( - &mut self, - target_id: String, - initial_url: String, - creator: Option, - storage_key: Option, - ) { - if !is_initial_empty_document_url(&initial_url) { - self.initial_empty_document = None; - return; - } - if self.navigation_history_state.is_empty() { - let entry = PageNavigationHistoryEntry { - id: self.navigation_history_state.allocate_entry_id(), - url: initial_url.clone(), - user_typed_url: initial_url.clone(), - title: String::new(), - transition_type: "auto_toplevel".to_owned(), - document_sequence_number: None, - }; - self.navigation_history_state.seed_entry(entry); - } - self.initial_empty_document = Some(TargetInitialEmptyDocumentState::new( - target_id, - initial_url, - creator, - storage_key, - )); - } - - pub(crate) fn mark_initial_empty_document_materialized(&mut self) { - if let Some(state) = self.initial_empty_document.as_mut() { - state.mark_materialized(); - } - } - - pub(crate) fn mark_initial_empty_document_pending_cross_document_navigation(&mut self) { - if let Some(state) = self.initial_empty_document.as_mut() { - state.mark_pending_cross_document_navigation(); - } - } - - pub(crate) fn clear_initial_empty_document_pending_cross_document_navigation(&mut self) { - if let Some(state) = self.initial_empty_document.as_mut() { - state.clear_pending_cross_document_navigation(); - } - } - - pub(crate) fn mark_initial_empty_document_exited(&mut self) { - if let Some(state) = self.initial_empty_document.as_mut() { - state.mark_exited(); - } - } - - pub(crate) fn initial_empty_document_state(&self) -> Option<&TargetInitialEmptyDocumentState> { - self.initial_empty_document.as_ref() - } - - pub(crate) fn initial_empty_document_url_if_current(&self) -> Option<&str> { - self.initial_empty_document_state() - .filter(|state| state.is_on_initial_empty_document()) - .map(TargetInitialEmptyDocumentState::initial_url) - } - - pub(crate) fn initial_empty_document_loader_id_if_current(&self) -> Option<&str> { - self.initial_empty_document_state() - .filter(|state| state.is_on_initial_empty_document()) - .map(TargetInitialEmptyDocumentState::loader_id) - } - - pub(crate) fn initial_empty_document_storage_key_if_current( - &self, - ) -> Option<&moli_storage_key::MoliStorageKey> { - self.initial_empty_document_state() - .filter(|state| state.is_on_initial_empty_document()) - .and_then(TargetInitialEmptyDocumentState::storage_key) - } - - pub(crate) fn is_on_initial_empty_document(&self) -> Option { - self.initial_empty_document_state() - .map(TargetInitialEmptyDocumentState::is_on_initial_empty_document) - } - - pub(crate) fn can_install_current_initial_empty_document_page(&self) -> bool { - self.initial_empty_document_state() - .is_none_or(TargetInitialEmptyDocumentState::is_on_initial_empty_document) - } - - pub(crate) fn initial_empty_document_pending_cross_document_navigation(&self) -> bool { - self.initial_empty_document_state() - .is_some_and(TargetInitialEmptyDocumentState::pending_cross_document_navigation) - } - - pub(crate) fn has_materialized_current_initial_empty_document(&self) -> bool { - self.initial_empty_document_state() - .is_some_and(|state| state.is_on_initial_empty_document() && state.materialized()) - } - pub(crate) fn moli_memory_diagnostics(&self) -> Value { - let initial_empty_document = self.initial_empty_document.as_ref().map(|state| { - let creator = state.creator().map(|creator| { - json!({ - "targetId": creator.target_id(), - "securityOrigin": creator.security_origin(), - "secureContextType": creator.secure_context_type(), - }) - }); - json!({ - "targetId": state.target_id(), - "initialUrl": state.initial_url(), - "creator": creator, - "materialized": state.materialized(), - "exited": state.exited(), - "pendingCrossDocumentNavigation": state.pending_cross_document_navigation(), - "isOnInitialEmptyDocument": state.is_on_initial_empty_document(), - }) - }); json!({ - "initialEmptyDocument": initial_empty_document, "documentStartScriptCount": self.document_start_scripts.len(), "isolatedWorldCount": self.isolated_worlds.len(), "retainedPageResourceBodyBytes": self.page_resource_store.retained_body_bytes(), @@ -1057,105 +816,6 @@ impl TargetOwnerState { }) } - fn navigation_history_entry_for_page_snapshot( - &mut self, - page_snapshot: (String, String), - ) -> PageNavigationHistoryEntry { - let (url, title) = page_snapshot; - PageNavigationHistoryEntry { - id: self.navigation_history_state.allocate_entry_id(), - user_typed_url: url.clone(), - url, - title, - transition_type: "typed".to_owned(), - document_sequence_number: None, - } - } - - fn reconcile_navigation_history_page_snapshot( - &mut self, - page_snapshot: Option<(String, String)>, - ) { - let Some(mut page_snapshot) = page_snapshot else { - return; - }; - if let Some(title) = self.committed_document_title() { - page_snapshot.1 = title.to_owned(); - } - if !self.navigation_history_state.is_empty() { - let (_, title) = page_snapshot; - self.navigation_history_state - .refresh_current_entry_title(title); - return; - } - let entry = self.navigation_history_entry_for_page_snapshot(page_snapshot); - self.navigation_history_state.seed_entry(entry); - } - - pub(crate) fn refresh_current_navigation_history_title(&mut self, title: String) -> bool { - self.navigation_history_state - .refresh_current_entry_title(title) - } - - pub(crate) fn mark_next_navigation_history_replace_current(&mut self) { - self.navigation_history_state.mark_replace_current(); - } - - pub(crate) fn mark_next_navigation_history_replace_initial_empty_document(&mut self) { - self.navigation_history_state - .mark_replace_initial_empty_document(); - } - - pub(crate) fn mark_next_navigation_history_traverse_to_entry(&mut self, entry_id: i32) { - self.navigation_history_state - .mark_traverse_to_entry(entry_id); - } - - pub(crate) fn clear_pending_navigation_history_update(&mut self) { - self.navigation_history_state.clear_pending_update(); - } - - pub(crate) fn navigation_history_entry_url( - &mut self, - page_snapshot: Option<(String, String)>, - entry_id: i32, - ) -> Option { - self.reconcile_navigation_history_page_snapshot(page_snapshot); - self.navigation_history_state.entry_url(entry_id) - } - - pub(crate) fn navigation_history_snapshot( - &mut self, - page_snapshot: Option<(String, String)>, - ) -> (usize, Vec) { - self.reconcile_navigation_history_page_snapshot(page_snapshot); - self.navigation_history_state.snapshot() - } - - pub(crate) fn reset_navigation_history( - &mut self, - page_snapshot: Option<(String, String)>, - ) -> bool { - self.reconcile_navigation_history_page_snapshot(page_snapshot); - self.navigation_history_state.prune_all_but_current() - } - - pub(crate) fn can_reset_navigation_history( - &mut self, - page_snapshot: Option<(String, String)>, - ) -> bool { - self.reconcile_navigation_history_page_snapshot(page_snapshot); - self.navigation_history_state.can_prune_all_but_current() - } - - pub(crate) fn record_loaded_page_navigation_history( - &mut self, - page_snapshot: (String, String), - ) { - let entry = self.navigation_history_entry_for_page_snapshot(page_snapshot); - self.navigation_history_state.record_loaded_entry(entry); - } - pub(crate) fn clear_loaded_document_context_state(&mut self) { self.clear_attached_child_frame_ids(); } @@ -1166,27 +826,10 @@ impl TargetOwnerState { self.clear_loaded_document_context_state(); } - pub(crate) fn record_same_document_navigation_history( - &mut self, - page_snapshot: Option<(String, String)>, - url: String, - mut title: String, - history_update: moli_core::page::SameDocumentHistoryUpdate, - ) { - self.reconcile_navigation_history_page_snapshot(page_snapshot); - if let Some(committed_title) = self.committed_document_title() { - title = committed_title.to_owned(); - } - let _ = - self.navigation_history_state - .record_same_document_update(url, title, history_update); - } - pub(crate) fn clear_page_local_state(&mut self) { self.next_document_start_script_id = 0; self.isolated_worlds.clear(); self.attached_child_frame_ids.clear(); - self.navigation_history_state.clear(); self.page_resource_store.clear(); self.target_crash_state.clear(); } @@ -1232,12 +875,9 @@ impl TargetOwnerState { } pub(crate) fn is_default(&self) -> bool { - self.initial_empty_document.is_none() - && self.committed_document_title.is_none() - && self.next_document_start_script_id == 0 + self.next_document_start_script_id == 0 && self.document_start_scripts.is_empty() && self.isolated_worlds.is_empty() - && self.navigation_history_state == TargetNavigationHistoryState::default() && self.page_resource_store.is_empty() && self.runtime_observable_state == TargetRuntimeObservableState::default() && self.console_output_state == TargetConsoleOutputState::default() @@ -1277,12 +917,6 @@ pub(crate) struct ParkedTargetAuxState { pub(crate) target_owner_state: ParkedTargetOwnerState, } -impl ParkedTargetAuxState { - pub(crate) fn has_pending_javascript_dialog(&self) -> bool { - self.page_session_state.has_pending_javascript_dialog() - } -} - #[derive(Debug, Default)] pub(crate) struct TargetParkingStateStore { cookie_manager_surfaces: @@ -1294,6 +928,15 @@ pub(crate) struct TargetParkingStateStore { } impl TargetParkingStateStore { + pub(crate) fn adopt_browser_network_artifact_store( + &mut self, + browser_artifacts: moli_core::browser_host::BrowserNetworkArtifactStore, + ) { + for artifacts in self.network_artifacts.values_mut() { + artifacts.adopt_browser_network_artifact_store(browser_artifacts.clone()); + } + } + pub(crate) fn moli_memory_diagnostics(&self) -> Value { let owner_state_summaries = self .target_owner_states @@ -1311,9 +954,6 @@ impl TargetParkingStateStore { "fetchStateCount": self.fetch_states.len(), "networkArtifactCount": self.network_artifacts.len(), "targetOwnerStateCount": self.target_owner_states.len(), - "targetOwnerStateWithPendingInspectorAwaitCount": self - .pending_inspector_await_target_count(), - "pendingInspectorAwaitCount": self.pending_inspector_await_count(), "nonEmptyFetchStateCount": self .fetch_states .values() @@ -1394,12 +1034,6 @@ impl TargetParkingStateStore { self.page_session_states.get(target_id) } - pub(crate) fn has_page_domain_enabled_session(&self) -> bool { - self.page_session_states - .values() - .any(parked_page_session_has_page_domain_enabled) - } - pub(crate) fn take_page_session_state(&mut self, target_id: &str) -> ParkedPageSessionState { self.page_session_states .remove(target_id) @@ -1483,74 +1117,4 @@ impl TargetParkingStateStore { } self.target_owner_states.insert(target_id, state); } - - pub(crate) fn has_pending_javascript_dialog(&self) -> bool { - self.page_session_states.values().any(|state| { - !state - .devtools_session_state - .page_session_state - .javascript_dialog_state - .is_empty() - || state - .auxiliary_devtools_session_states - .values() - .any(|session| { - !session - .page_session_state - .javascript_dialog_state - .is_empty() - }) - }) - } - - pub(crate) fn has_pending_inspector_awaits(&self) -> bool { - self.page_session_states - .values() - .any(parked_page_session_has_pending_inspector_awaits) - } - - pub(crate) fn pending_inspector_await_count(&self) -> usize { - self.page_session_states - .values() - .map(parked_page_session_pending_inspector_await_count) - .sum() - } - - pub(crate) fn pending_inspector_await_target_count(&self) -> usize { - self.page_session_states - .values() - .filter(|state| parked_page_session_has_pending_inspector_awaits(state)) - .count() - } - - pub(crate) fn has_runtime_remote_object_id(&self, object_id: &str) -> bool { - self.page_session_states - .values() - .any(|state| parked_page_session_has_runtime_remote_object_id(state, object_id)) - } - - pub(crate) fn runtime_remote_object_id_known_for_different_page_owner( - &self, - target_id: &str, - devtools_session_id: Option<&str>, - object_id: &str, - ) -> bool { - self.page_session_states.iter().any(|(id, state)| { - if id == target_id { - return parked_page_session_has_runtime_remote_object_id_for_different_session( - state, - devtools_session_id, - object_id, - ); - } - parked_page_session_has_runtime_remote_object_id(state, object_id) - }) - } -} - -fn is_initial_empty_document_url(raw_url: &str) -> bool { - url::Url::parse(raw_url) - .ok() - .as_ref() - .is_some_and(moli_url::is_about_blank) } diff --git a/moli-protocol/src/conn/state/runtime_slot.rs b/moli-protocol/src/conn/state/runtime_slot.rs index 8df55d07df..3612a9b34c 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -1,3 +1,7 @@ +use moli_core::browser_host::{ + BrowserNetworkArtifactStore as ConnectionNetworkRequestIdAllocator, BrowserPageResidenceHandle, + BrowserPageRuntimeLease, +}; use moli_core::page::{ Page, RendererAgentAttachmentId, RendererDevToolsAgentToken, RendererDocumentLifecycleIdentity, RendererRuntimeInspectorMessageBatch, ScriptNetworkOutputItem, SubresourceNetworkRequestHandle, @@ -7,7 +11,7 @@ use moli_core::page::{RendererPageDiagnosticsSnapshot, RendererRuntimeObservable use serde_json::{Value, json}; use crate::{ - conn::{CapturedBody, ConnectionNetworkRequestIdAllocator}, + conn::CapturedBody, domains::{ log_output_state::{TargetLogOutputQueueState, TargetNetworkLogEntry}, network::{ @@ -23,13 +27,10 @@ use crate::{ }; use super::devtools_renderer_channel::{DevToolsRendererChannel, RendererAgentDetachReason}; -use super::page_slot::{ - DocumentNavigationToken, InitialDocumentPageBuildWaiter, TargetPageAbsenceReason, - TargetPageSlot, -}; +use super::page_slot::{InitialDocumentPageBuildWaiter, TargetPageAbsenceReason, TargetPageSlot}; use super::{ CommittedRendererAgentAttachment, CommittedRendererDocumentBinding, - DevToolsRendererChannelError, PreparedRendererAgentAttachment, + DevToolsRendererChannelError, DocumentNavigationToken, PreparedRendererAgentAttachment, PreparedRendererCallReplacements, RendererAgentAttachment, RendererPageResidenceIdentity, TargetJavaScriptDialogScope, TargetJavaScriptDialogScopeObserver, TargetPageAttachmentId, }; @@ -42,7 +43,6 @@ pub(crate) struct FinishedRendererDocumentNavigation { #[derive(Debug)] struct RetiringRendererDocumentOutput { renderer_page: RendererPageResidenceIdentity, - page_attachment_id: TargetPageAttachmentId, binding: CommittedRendererDocumentBinding, network_agent: RetiringTargetNetworkAgentState, } @@ -105,13 +105,6 @@ impl TargetNetworkRequestIdAllocator<'_> { let network_request_id = network_request_id_allocator.allocate_request_id(); (request_id, network_request_id) } - - #[cfg(test)] - pub(crate) fn allocate_network_request_id(&mut self) -> String { - self.runtime_slot - .network_agent - .allocate_network_request_id() - } } impl TargetRuntimeSlot { @@ -133,17 +126,21 @@ impl TargetRuntimeSlot { &mut self.page_slot } - pub(crate) fn loaded_page(&self) -> Option<&Page> { + pub(crate) fn loaded_page(&self) -> Option { self.page_slot.loaded_page() } + pub(crate) fn loaded_renderer_page_residence(&self) -> Option { + self.page_slot.loaded_renderer_page_residence() + } + pub(crate) fn committed_renderer_document_binding( &self, ) -> Option<&CommittedRendererDocumentBinding> { self.page_slot.renderer_document_lifecycle_binding() } - pub(crate) fn loaded_page_mut(&mut self) -> Option<&mut Page> { + pub(crate) fn loaded_page_mut(&mut self) -> Option { self.page_slot.loaded_page_mut() } @@ -151,6 +148,13 @@ impl TargetRuntimeSlot { self.page_slot.has_loaded_page() } + #[cfg(test)] + pub(crate) fn loaded_page_runtime_access_for_test( + &self, + ) -> Option { + self.page_slot.loaded_page_runtime_access_for_test() + } + pub(crate) fn has_pending_initial_document_page_build(&self) -> bool { matches!( self.page_slot.loaded_page_absence_reason(), @@ -206,29 +210,13 @@ impl TargetRuntimeSlot { } } + #[cfg(test)] pub(crate) fn replace_loaded_page(&mut self, page: Option) -> Option { self.javascript_dialog_scope.retire(); let mut page = page; - let retiring_document = self - .page_slot - .loaded_page() - .zip(self.page_slot.renderer_document_lifecycle_binding()) - .map(|(loaded_page, binding)| { - ( - RendererPageResidenceIdentity::from_page(loaded_page), - binding.page_attachment_id, - binding.clone(), - ) - }); + let retiring_renderer_page = self.loaded_renderer_page_residence(); let retiring_document = - retiring_document.map(|(renderer_page, page_attachment_id, binding)| { - RetiringRendererDocumentOutput { - renderer_page, - page_attachment_id, - binding, - network_agent: self.network_agent.rotate_document_for_replacement(), - } - }); + self.prepare_retiring_renderer_document_output(retiring_renderer_page); self.ensure_renderer_attachment_for_replacement(page.as_mut()); let previous = self.page_slot.replace_loaded_page(page); if let Some(retiring_document) = retiring_document { @@ -242,6 +230,88 @@ impl TargetRuntimeSlot { previous } + pub(crate) fn project_loaded_page_after_browser_owner_commit( + &mut self, + replacement: &moli_core::browser_host::BrowserPageReplacement, + retiring_renderer_page: Option, + ) { + self.javascript_dialog_scope.retire(); + let retiring_document = + self.prepare_retiring_renderer_document_output(retiring_renderer_page); + self.page_slot + .project_loaded_page_after_browser_owner_commit(replacement); + self.ensure_loaded_page_renderer_attachment(); + if let Some(retiring_document) = retiring_document { + self.retiring_renderer_document_outputs + .push(retiring_document); + self.reset_replacement_document_output_state(); + } else { + self.reset_document_output_state(); + } + self.ingest_owner_page_observable_output_updates(); + } + + fn prepare_retiring_renderer_document_output( + &mut self, + renderer_page: Option, + ) -> Option { + let renderer_page = renderer_page?; + let binding = self + .page_slot + .renderer_document_lifecycle_binding()? + .clone(); + Some(RetiringRendererDocumentOutput { + renderer_page, + binding, + network_agent: self.network_agent.rotate_document_for_replacement(), + }) + } + + pub(crate) fn project_initial_document_page_after_browser_owner_commit( + &mut self, + transition: &moli_core::browser_host::BrowserPageResidenceTransition, + ) { + self.javascript_dialog_scope.retire(); + self.page_slot + .project_initial_document_page_after_browser_owner_commit(transition); + self.ensure_loaded_page_renderer_attachment(); + self.reset_document_output_state(); + self.ingest_owner_page_observable_output_updates(); + } + + pub(crate) fn project_failed_navigation_page_absence_after_browser_owner_commit( + &mut self, + transition: &moli_core::browser_host::BrowserPageResidenceTransition, + ) -> Option { + self.javascript_dialog_scope.retire(); + self.page_slot + .project_failed_navigation_page_absence_after_browser_owner_commit(transition); + self.transition_renderer_channel_for_page_absence( + TargetPageAbsenceReason::NavigationFailed, + ); + self.reset_document_output_state(); + self.ingest_owner_page_observable_output_updates(); + None + } + + pub(crate) fn project_target_termination_after_browser_owner_commit( + &mut self, + reason: TargetPageAbsenceReason, + termination: &moli_core::browser_host::BrowserTargetTermination, + ) -> Option { + self.javascript_dialog_scope.retire(); + self.page_slot + .project_page_absence_after_browser_owner_termination( + reason, + termination.terminal_page(), + ); + self.transition_renderer_channel_for_page_absence(reason); + self.reset_document_output_state(); + self.ingest_owner_page_observable_output_updates(); + None + } + + #[cfg(test)] pub(crate) fn clear_loaded_page_with_reason( &mut self, reason: TargetPageAbsenceReason, @@ -282,6 +352,17 @@ impl TargetRuntimeSlot { self.transition_renderer_channel_for_page_absence(reason); } + pub(crate) fn retire_page_projection_after_browser_owner_forget(&mut self) -> Option { + self.javascript_dialog_scope.retire(); + let previous = self + .page_slot + .retire_page_projection_after_browser_owner_forget(); + self.transition_renderer_channel_for_page_absence(TargetPageAbsenceReason::TargetClosed); + self.reset_document_output_state(); + self.ingest_owner_page_observable_output_updates(); + previous + } + #[cfg(test)] pub(crate) fn set_loaded_page_for_test(&mut self, page: Page) { let _ = self.replace_loaded_page(Some(page)); @@ -291,47 +372,34 @@ impl TargetRuntimeSlot { self.page_slot.page_attachment_id() } - pub(crate) fn pending_page_attachment_id(&self) -> Option { - self.page_slot.pending_page_attachment_id() + #[cfg(test)] + pub(crate) fn set_page_attachment_id_for_test(&mut self, raw: u64) -> TargetPageAttachmentId { + self.page_slot.set_page_attachment_id_for_test(raw) } - pub(crate) fn reserve_renderer_page_attachment( - &mut self, - renderer_page: RendererPageResidenceIdentity, - ) -> TargetPageAttachmentId { - self.page_slot - .reserve_renderer_page_attachment(renderer_page) + #[cfg(test)] + pub(crate) fn loaded_page_generation(&self) -> u64 { + self.page_slot.loaded_page_generation() } - #[cfg(test)] - pub(crate) fn set_page_attachment_id_for_test(&mut self, raw: u64) -> TargetPageAttachmentId { - let attachment_changed = self - .page_slot - .page_attachment_id() - .map(TargetPageAttachmentId::get) - != Some(raw); - if attachment_changed { - self.javascript_dialog_scope.retire(); - } - self.page_slot.set_page_attachment_id_for_test(raw) + pub(crate) fn page_residence_handle( + &self, + ) -> &moli_core::browser_host::BrowserPageResidenceHandle { + self.page_slot.page_residence_handle() } #[cfg(test)] - pub(crate) fn replace_page_attachment_id_for_test(&mut self) -> TargetPageAttachmentId { + pub(crate) fn bump_loaded_page_generation(&mut self) { self.javascript_dialog_scope.retire(); - self.page_slot.replace_page_attachment_id_for_test() + self.page_slot.bump_loaded_page_generation(); } #[cfg(test)] - pub(crate) fn install_page_attachment_id_for_test( - &mut self, - attachment_id: TargetPageAttachmentId, - ) { - if self.page_slot.page_attachment_id() != Some(attachment_id) { + pub(crate) fn set_loaded_page_generation(&mut self, generation: u64) { + if self.page_slot.loaded_page_generation() != generation { self.javascript_dialog_scope.retire(); } - self.page_slot - .install_page_attachment_id_for_test(attachment_id); + self.page_slot.set_loaded_page_generation(generation); } pub(crate) fn javascript_dialog_scope_observer(&self) -> TargetJavaScriptDialogScopeObserver { @@ -349,31 +417,15 @@ impl TargetRuntimeSlot { self.javascript_dialog_scope.retire(); } - pub(crate) fn start_document_navigation( + pub(crate) fn begin_document_navigation_protocol_state( &mut self, - target_id: String, - loader_id: String, - ) -> DocumentNavigationToken { + token: DocumentNavigationToken, + ) { self.devtools_renderer_channel.reopen_after_target_crash(); - let token = self - .page_slot - .start_document_navigation(target_id, loader_id); + self.page_slot.begin_document_navigation_protocol_state(); self.devtools_renderer_channel - .navigation_started(token.clone()) + .navigation_started(token) .expect("an open target runtime slot must accept a new document navigation"); - token - } - - #[cfg(test)] - pub(crate) fn prepare_renderer_agent_candidate( - &self, - token: &DocumentNavigationToken, - page: &mut Page, - ) -> Result { - let candidate = self - .prepare_renderer_agent_candidate_token(token, page.renderer_devtools_agent_token())?; - page.bind_renderer_agent_attachment(candidate.id()); - Ok(candidate) } pub(crate) fn prepare_renderer_agent_candidate_token( @@ -402,7 +454,7 @@ impl TargetRuntimeSlot { .commit_candidate_transaction(candidate)?; if self .page_slot - .bind_pending_document_navigation_renderer_page(transaction.navigation(), renderer_page) + .bind_document_navigation_renderer_page(transaction.navigation(), renderer_page) { return Ok(transaction); } @@ -411,15 +463,23 @@ impl TargetRuntimeSlot { Err(DevToolsRendererChannelError::CommittedCandidateMismatch) } + pub(crate) fn validate_renderer_agent_candidate_commit( + &self, + candidate: &PreparedRendererAgentAttachment, + ) -> Result<(), DevToolsRendererChannelError> { + self.devtools_renderer_channel + .validate_candidate_commit(candidate) + } + pub(crate) fn rollback_committed_renderer_agent_candidate( &mut self, transaction: CommittedRendererAgentAttachment, ) -> Result<(), DevToolsRendererChannelError> { - let loader_id = transaction.navigation().loader_id.clone(); + let loader_id = transaction.navigation().loader_id().to_owned(); self.devtools_renderer_channel .rollback_committed_candidate(transaction)?; self.page_slot - .clear_pending_document_navigation_if_loader_matches(&loader_id); + .clear_pending_renderer_page_if_loader_matches(&loader_id); Ok(()) } @@ -491,23 +551,20 @@ impl TargetRuntimeSlot { self.devtools_renderer_channel.current() } - pub(crate) fn routes_current_renderer_page_owner( + pub(crate) fn routes_renderer_page( &self, renderer_page: RendererPageResidenceIdentity, - page_attachment_id: TargetPageAttachmentId, ) -> bool { - self.page_slot.page_attachment_id() == Some(page_attachment_id) - && self.page_slot.routes_renderer_page(renderer_page) + self.page_slot.routes_renderer_page(renderer_page) } - pub(crate) fn routes_retiring_renderer_page_owner( + pub(crate) fn routes_retiring_renderer_page( &self, renderer_page: RendererPageResidenceIdentity, - page_attachment_id: TargetPageAttachmentId, ) -> bool { - self.retiring_renderer_document_outputs.iter().any(|entry| { - entry.renderer_page == renderer_page && entry.page_attachment_id == page_attachment_id - }) + self.retiring_renderer_document_outputs + .iter() + .any(|entry| entry.renderer_page == renderer_page) } pub(crate) fn finish_renderer_page_output_retirement( @@ -548,54 +605,6 @@ impl TargetRuntimeSlot { Ok(previous) } - pub(crate) fn accepts_pending_document_navigation_event( - &self, - token: &DocumentNavigationToken, - ) -> bool { - self.page_slot - .accepts_pending_document_navigation_event(token) - } - - pub(crate) fn document_navigation_cancellation_handle( - &self, - token: &DocumentNavigationToken, - ) -> Option { - self.page_slot - .document_navigation_cancellation_handle(token) - } - - pub(crate) fn arm_background_navigation_completion( - &mut self, - token: &DocumentNavigationToken, - additional_cancellation: Option, - ) -> bool { - self.page_slot - .arm_background_navigation_completion(token, additional_cancellation) - } - - pub(crate) fn settle_background_navigation_completion( - &mut self, - token: &DocumentNavigationToken, - ) -> bool { - self.page_slot - .settle_background_navigation_completion(token) - } - - pub(crate) fn has_inflight_background_navigation(&self) -> bool { - self.page_slot.has_inflight_background_navigation() - } - - pub(crate) fn accepts_document_body_completion_event( - &self, - token: &DocumentNavigationToken, - ) -> bool { - self.page_slot.accepts_document_body_completion_event(token) - } - - pub(crate) fn has_pending_document_navigation(&self) -> bool { - self.page_slot.has_pending_document_navigation() - } - pub(crate) fn moli_memory_diagnostics(&self) -> Value { json!({ "hasLoadedPage": self.has_loaded_page(), @@ -604,7 +613,6 @@ impl TargetRuntimeSlot { .loaded_page_absence_reason() .map(TargetPageAbsenceReason::label), "pageAttachmentId": self.page_attachment_id().map(TargetPageAttachmentId::get), - "hasPendingDocumentNavigation": self.has_pending_document_navigation(), "rendererChannelClosed": self.devtools_renderer_channel.is_closed(), "rendererChannelHasCurrentAttachment": self.devtools_renderer_channel.current().is_some(), @@ -654,53 +662,53 @@ impl TargetRuntimeSlot { .claim_completed_subresource_request_id(request_id) } - pub(crate) fn current_document_loader_id(&self) -> Option<&str> { - self.page_slot.current_document_loader_id() - } - - pub(crate) fn committed_document_loader_id(&self) -> Option<&str> { - self.page_slot.committed_document_loader_id() - } - - pub(crate) fn commit_pending_document_navigation_if_matches( - &mut self, - token: &DocumentNavigationToken, - ) -> bool { - self.page_slot - .commit_pending_document_navigation_if_matches(token) - } - - pub(crate) fn clear_pending_document_navigation_if_loader_matches( + pub(crate) fn clear_pending_renderer_page_if_loader_matches( &mut self, loader_id: &str, ) -> bool { self.page_slot - .clear_pending_document_navigation_if_loader_matches(loader_id) + .clear_pending_renderer_page_if_loader_matches(loader_id) } - pub(crate) fn clear_document_navigation_state(&mut self) { + pub(crate) fn clear_renderer_document_protocol_state(&mut self) { self.javascript_dialog_scope.retire(); - self.page_slot.clear_document_navigation_state(); + self.page_slot.clear_renderer_document_protocol_state(); } fn ensure_loaded_page_renderer_attachment(&mut self) { - let Some(page) = self.page_slot.loaded_page_mut() else { + let Some(mut page) = self.page_slot.loaded_page_mut() else { return; }; - let agent_token = page.renderer_devtools_agent_token(); - let attachment = self - .devtools_renderer_channel - .attach_current(agent_token) - .expect("a loaded page cannot be attached to a closed renderer channel"); - debug_assert!(attachment.is_none()); - let current = self + if self.devtools_renderer_channel.current().is_none() { + let agent_token = page.renderer_devtools_agent_token(); + if let Err(error) = self.devtools_renderer_channel.attach_current(agent_token) { + tracing::warn!( + %error, + page_id = page.page_id(), + "could not project Browser-owned Page into a closed renderer channel" + ); + return; + } + } + let Some(attachment_id) = self .devtools_renderer_channel .current() - .expect("a loaded page must have a renderer attachment"); - page.bind_renderer_agent_attachment(current.id()); + .map(|attachment| attachment.id()) + else { + tracing::warn!( + page_id = page.page_id(), + "Browser-owned Page projection has no renderer attachment" + ); + return; + }; + page.bind_renderer_agent_attachment(attachment_id); } - pub(crate) fn prepare_renderer_channel_for_new_target(&mut self) { + pub(crate) fn prepare_renderer_channel_for_new_target( + &mut self, + page_residence: BrowserPageResidenceHandle, + ) { + self.page_slot.prepare_for_new_target(page_residence); if self.devtools_renderer_channel.is_closed() { self.devtools_renderer_channel = DevToolsRendererChannel::default(); } @@ -735,6 +743,7 @@ impl TargetRuntimeSlot { } } + #[cfg(test)] fn ensure_renderer_attachment_for_replacement(&mut self, page: Option<&mut Page>) { if self.devtools_renderer_channel.current().is_some() { return; @@ -774,6 +783,7 @@ impl TargetRuntimeSlot { let current_renderer_page = self .page_slot .loaded_page() + .as_deref() .map(RendererPageResidenceIdentity::from_page); if let Some(binding) = self.page_slot.renderer_document_lifecycle_binding() && binding.renderer_document_identity() == source_document @@ -907,11 +917,11 @@ impl TargetRuntimeSlot { } pub(crate) fn ingest_owner_page_observable_output_updates(&mut self) -> bool { - let Some(page) = self.page_slot.loaded_page_mut() else { + let Some(mut page) = self.page_slot.loaded_page_mut() else { self.observable_queue.reset_output_queue(); return false; }; - Self::ingest_page_observable_output_update(&mut self.observable_queue, page); + Self::ingest_page_observable_output_update(&mut self.observable_queue, &mut page); true } @@ -1167,14 +1177,28 @@ impl TargetRuntimeSlot { .record_pending_response_body(request_id, session_ids); } - pub(crate) fn captured_response_body(&self, request_id: &str) -> Option<&CapturedResponseBody> { + pub(crate) fn captured_response_body(&self, request_id: &str) -> Option { self.network_agent.captured_response_body(request_id) } - pub(crate) fn captured_request_body(&self, request_id: &str) -> Option<&CapturedRequestBody> { + pub(crate) fn captured_request_body(&self, request_id: &str) -> Option { self.network_agent.captured_request_body(request_id) } + pub(crate) fn adopt_browser_network_artifact_store( + &mut self, + browser_artifacts: moli_core::browser_host::BrowserNetworkArtifactStore, + ) { + self.network_agent + .adopt_browser_network_artifact_store(browser_artifacts); + } + + pub(crate) fn browser_network_artifact_store( + &self, + ) -> moli_core::browser_host::BrowserNetworkArtifactStore { + self.network_agent.browser_network_artifact_store() + } + pub(crate) fn collected_network_data_artifacts( &self, ) -> Vec { @@ -1305,17 +1329,6 @@ impl TargetRuntimeSlot { self.network_agent.captured_response_bodies_empty() } - #[cfg(test)] - pub(crate) fn set_next_network_request_sequence_for_test(&mut self, sequence: u64) { - self.network_agent - .set_next_network_request_sequence_for_test(sequence); - } - - #[cfg(test)] - pub(crate) fn next_network_request_sequence_for_test(&self) -> u64 { - self.network_agent.next_network_request_sequence_for_test() - } - #[cfg(test)] pub(crate) fn io_streams_empty_for_test(&self) -> bool { self.network_agent.io_streams_empty() @@ -1434,7 +1447,7 @@ mod tests { "the old target cannot reopen its terminal renderer channel" ); - slot.prepare_renderer_channel_for_new_target(); + slot.prepare_renderer_channel_for_new_target(BrowserPageResidenceHandle::default()); assert!( slot.devtools_renderer_channel .attach_current(RendererDevToolsAgentToken::allocate()) @@ -1481,7 +1494,6 @@ mod tests { moli_core::RendererOwnerLocalHostId::new_for_testing(3), page_id, ), - page_attachment_id: TargetPageAttachmentId::from_raw_for_test(1), binding: CommittedRendererDocumentBinding { renderer_frame: RendererFrameToken { page_id }, renderer_document: RendererDocumentToken::new_for_testing(page_id, 1), diff --git a/moli-protocol/src/conn/state/session.rs b/moli-protocol/src/conn/state/session.rs index 88d52e5ba1..ce2b7c02f5 100644 --- a/moli-protocol/src/conn/state/session.rs +++ b/moli-protocol/src/conn/state/session.rs @@ -4,6 +4,7 @@ use super::parking::TargetOwnerState; use super::runtime_slot::TargetRuntimeSlot; use super::session_storage::TargetSessionStorageNamespace; use crate::domains::audits_output_state::TargetAuditsSessionState; +use moli_core::browser_host::BrowserTargetHandle; use moli_core::page::V8InspectorSessionState; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -61,6 +62,7 @@ impl TargetPerformanceSessionState { #[derive(Debug, Default)] pub(crate) struct ActiveTargetState { + pub(crate) target_handle: Option, pub(crate) runtime_slot: TargetRuntimeSlot, pub(crate) fetch_owner: TargetFetchOwner, pub(crate) owner_state: TargetOwnerState, diff --git a/moli-protocol/src/conn/state/session_storage.rs b/moli-protocol/src/conn/state/session_storage.rs index 8f1b765256..374f9cd13a 100644 --- a/moli-protocol/src/conn/state/session_storage.rs +++ b/moli-protocol/src/conn/state/session_storage.rs @@ -1,36 +1,58 @@ use std::sync::OnceLock; -use moli_core::network::{ - SharedWebStorageStore, deep_clone_shared_web_storage_store, new_shared_web_storage_store, -}; +use moli_core::browser_host::BrowserTargetSessionStorageAccess; +#[cfg(test)] +use moli_core::network::deep_clone_shared_web_storage_store; +use moli_core::network::{SharedWebStorageStore, new_shared_web_storage_store}; #[derive(Clone, Default)] pub(crate) struct TargetSessionStorageNamespace { - store: OnceLock, + candidate_store: OnceLock, + browser_access: Option, } impl std::fmt::Debug for TargetSessionStorageNamespace { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TargetSessionStorageNamespace") - .field("initialized", &self.store.get().is_some()) + .field( + "candidate_initialized", + &self.candidate_store.get().is_some(), + ) + .field( + "browser_owned", + &self + .browser_access + .as_ref() + .is_some_and(|access| access.is_live()), + ) .finish() } } impl TargetSessionStorageNamespace { + #[cfg(test)] pub(crate) fn from_store(store: SharedWebStorageStore) -> Self { Self { - store: OnceLock::from(store), + candidate_store: OnceLock::from(store), + browser_access: None, } } pub(crate) fn store(&self) -> &SharedWebStorageStore { - self.store.get_or_init(new_shared_web_storage_store) + if let Some(access) = self.browser_access.as_ref() { + return access.store(); + } + self.candidate_store + .get_or_init(new_shared_web_storage_store) } + #[cfg(test)] pub(crate) fn deep_clone(&self) -> Self { - Self { - store: OnceLock::from(deep_clone_shared_web_storage_store(self.store())), - } + Self::from_store(deep_clone_shared_web_storage_store(self.store())) + } + + pub(crate) fn bind_browser_access(&mut self, access: BrowserTargetSessionStorageAccess) { + let _ = self.candidate_store.take(); + self.browser_access = Some(access); } } diff --git a/moli-protocol/src/conn/state/target_attachment.rs b/moli-protocol/src/conn/state/target_attachment.rs new file mode 100644 index 0000000000..ee903c3512 --- /dev/null +++ b/moli-protocol/src/conn/state/target_attachment.rs @@ -0,0 +1,620 @@ +use std::collections::HashMap; + +use moli_core::browser_host::BrowserTargetHandle; + +use super::DevToolsSessionState; + +/// Frontend-owned CDP session state for one exact top-level Target. +/// +/// The primary state also represents the root/no-`sessionId` route. Its +/// optional session id is only an attachment route: detaching or rebinding +/// that route must not move the state to the physical active/background +/// Target payload. Auxiliary states, by contrast, have an exact session +/// lifetime and disappear with their attachment route. +#[derive(Clone, Debug, Default, PartialEq)] +struct TopLevelTargetFrontendSessionProjection { + primary_session_id: Option, + primary_state: DevToolsSessionState, + auxiliary_session_states: HashMap, +} + +/// Exact-Target frontend session registry. +/// +/// `unbound` is the root frontend candidate used while a BrowserContext has no +/// active Target. Binding a newly registered active Target adopts the current +/// candidate. Terminal retirement can reseed it with the explicitly retained +/// successor configuration, but it is never a residence for a registered +/// Target. +#[derive(Debug, Default)] +pub(crate) struct TopLevelTargetFrontendSessionRegistry { + unbound: TopLevelTargetFrontendSessionProjection, + by_target: HashMap, + target_by_session: HashMap, +} + +impl TopLevelTargetFrontendSessionRegistry { + fn clear_session_route(&mut self, session_id: &str) -> Option { + let target = self.target_by_session.remove(session_id)?; + if let Some(projection) = self.by_target.get_mut(&target) { + if projection.primary_session_id.as_deref() == Some(session_id) { + // The primary/root state belongs to the exact Target, not to + // the optional explicit attachment route. + projection.primary_session_id = None; + } else { + projection.auxiliary_session_states.remove(session_id); + } + } + Some(target) + } + + pub(crate) fn register_target(&mut self, target: BrowserTargetHandle) { + self.by_target.entry(target).or_default(); + } + + pub(crate) fn register_new_active_target(&mut self, target: BrowserTargetHandle) { + if self.by_target.contains_key(&target) { + return; + } + let candidate = std::mem::take(&mut self.unbound); + debug_assert!( + candidate.primary_session_id.is_none() && candidate.auxiliary_session_states.is_empty(), + "an unbound frontend candidate cannot own explicit Target routes" + ); + self.by_target.insert(target, candidate); + } + + #[cfg(test)] + pub(crate) fn contains_target(&self, target: &BrowserTargetHandle) -> bool { + self.by_target.contains_key(target) + } + + pub(crate) fn auxiliary_session_count(&self) -> usize { + self.by_target + .values() + .map(|projection| projection.auxiliary_session_states.len()) + .sum() + } + + pub(crate) fn target_entries( + &self, + ) -> impl Iterator< + Item = ( + &BrowserTargetHandle, + &DevToolsSessionState, + &HashMap, + ), + > { + self.by_target.iter().map(|(target, projection)| { + ( + target, + &projection.primary_state, + &projection.auxiliary_session_states, + ) + }) + } + + pub(crate) fn session_states(&self) -> impl Iterator { + self.by_target + .values() + .flat_map(|projection| { + std::iter::once(&projection.primary_state) + .chain(projection.auxiliary_session_states.values()) + }) + .chain(std::iter::once(&self.unbound.primary_state)) + .chain(self.unbound.auxiliary_session_states.values()) + } + + pub(crate) fn primary_session_id(&self, target: &BrowserTargetHandle) -> Option<&str> { + self.by_target + .get(target) + .and_then(|projection| projection.primary_session_id.as_deref()) + } + + pub(crate) fn primary_state( + &self, + target: &BrowserTargetHandle, + ) -> Option<&DevToolsSessionState> { + self.by_target + .get(target) + .map(|projection| &projection.primary_state) + } + + #[cfg(test)] + pub(crate) fn primary_state_mut( + &mut self, + target: &BrowserTargetHandle, + ) -> Option<&mut DevToolsSessionState> { + self.by_target + .get_mut(target) + .map(|projection| &mut projection.primary_state) + } + + pub(crate) fn primary_state_or_unbound( + &self, + target: Option<&BrowserTargetHandle>, + ) -> &DevToolsSessionState { + target + .and_then(|target| self.primary_state(target)) + .unwrap_or(&self.unbound.primary_state) + } + + pub(crate) fn primary_state_or_unbound_mut( + &mut self, + target: Option<&BrowserTargetHandle>, + ) -> &mut DevToolsSessionState { + if let Some(projection) = target.and_then(|target| self.by_target.get_mut(target)) { + return &mut projection.primary_state; + } + &mut self.unbound.primary_state + } + + pub(crate) fn auxiliary_states( + &self, + target: &BrowserTargetHandle, + ) -> Option<&HashMap> { + self.by_target + .get(target) + .map(|projection| &projection.auxiliary_session_states) + } + + pub(crate) fn auxiliary_states_or_unbound( + &self, + target: Option<&BrowserTargetHandle>, + ) -> &HashMap { + target + .and_then(|target| self.auxiliary_states(target)) + .unwrap_or(&self.unbound.auxiliary_session_states) + } + + pub(crate) fn states_or_unbound_mut( + &mut self, + target: Option<&BrowserTargetHandle>, + ) -> ( + &mut DevToolsSessionState, + &mut HashMap, + ) { + let projection = match target.and_then(|target| self.by_target.get_mut(target)) { + Some(projection) => projection, + None => &mut self.unbound, + }; + ( + &mut projection.primary_state, + &mut projection.auxiliary_session_states, + ) + } + + pub(crate) fn states_mut( + &mut self, + target: &BrowserTargetHandle, + ) -> Option<( + &mut DevToolsSessionState, + &mut HashMap, + )> { + let projection = self.by_target.get_mut(target)?; + Some(( + &mut projection.primary_state, + &mut projection.auxiliary_session_states, + )) + } + + pub(crate) fn replace_primary_session( + &mut self, + target: &BrowserTargetHandle, + session_id: Option, + ) -> Option { + let previous = self + .by_target + .entry(target.clone()) + .or_default() + .primary_session_id + .take(); + if let Some(previous) = previous.as_ref() + && self.target_by_session.get(previous) == Some(target) + { + self.target_by_session.remove(previous); + } + if let Some(session_id) = session_id { + self.clear_session_route(&session_id); + self.by_target + .entry(target.clone()) + .or_default() + .primary_session_id = Some(session_id.clone()); + self.target_by_session.insert(session_id, target.clone()); + } + previous + } + + pub(crate) fn attach_auxiliary_session( + &mut self, + target: &BrowserTargetHandle, + session_id: String, + ) -> bool { + if self.is_auxiliary_session_for_target(target, &session_id) { + return false; + } + self.clear_session_route(&session_id); + let projection = self.by_target.entry(target.clone()).or_default(); + projection + .auxiliary_session_states + .insert(session_id.clone(), DevToolsSessionState::default()); + self.target_by_session.insert(session_id, target.clone()); + true + } + + pub(crate) fn target_for_session(&self, session_id: &str) -> Option<&BrowserTargetHandle> { + self.target_by_session.get(session_id) + } + + pub(crate) fn primary_target_for_session( + &self, + session_id: &str, + ) -> Option<&BrowserTargetHandle> { + let target = self.target_by_session.get(session_id)?; + (self.primary_session_id(target) == Some(session_id)).then_some(target) + } + + pub(crate) fn is_auxiliary_session_for_target( + &self, + target: &BrowserTargetHandle, + session_id: &str, + ) -> bool { + self.by_target + .get(target) + .is_some_and(|projection| projection.auxiliary_session_states.contains_key(session_id)) + } + + pub(crate) fn auxiliary_session_ids(&self, target: &BrowserTargetHandle) -> Vec { + let mut ids = self + .by_target + .get(target) + .map(|projection| { + projection + .auxiliary_session_states + .keys() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + ids.sort(); + ids + } + + pub(crate) fn session_state( + &self, + target: &BrowserTargetHandle, + is_auxiliary: bool, + session_id: Option<&str>, + ) -> Option<&DevToolsSessionState> { + let projection = self.by_target.get(target)?; + if is_auxiliary { + return session_id + .and_then(|session_id| projection.auxiliary_session_states.get(session_id)); + } + Some(&projection.primary_state) + } + + pub(crate) fn session_state_mut( + &mut self, + target: &BrowserTargetHandle, + is_auxiliary: bool, + session_id: Option<&str>, + ) -> Option<&mut DevToolsSessionState> { + let projection = self.by_target.get_mut(target)?; + if is_auxiliary { + return session_id + .and_then(|session_id| projection.auxiliary_session_states.get_mut(session_id)); + } + Some(&mut projection.primary_state) + } + + pub(crate) fn reset_primary_state(&mut self, target: &BrowserTargetHandle) -> bool { + let Some(projection) = self.by_target.get_mut(target) else { + return false; + }; + projection.primary_state = DevToolsSessionState::default(); + true + } + + pub(crate) fn remove_auxiliary_session( + &mut self, + session_id: &str, + ) -> Option { + let target = self.target_by_session.remove(session_id)?; + let projection = self.by_target.get_mut(&target)?; + if projection + .auxiliary_session_states + .remove(session_id) + .is_some() + { + Some(target) + } else { + // The reverse route may refer to a primary session. Restore it; + // callers of this method only own auxiliary detach semantics. + self.target_by_session.insert(session_id.to_owned(), target); + None + } + } + + pub(crate) fn remove_target( + &mut self, + target: &BrowserTargetHandle, + ) -> (Option, Vec) { + let Some((_projection, primary_session_id, auxiliary_session_ids)) = + self.take_target_projection_and_routes(target) + else { + return (None, Vec::new()); + }; + (primary_session_id, auxiliary_session_ids) + } + + pub(crate) fn retire_target_to_unbound( + &mut self, + target: &BrowserTargetHandle, + ) -> (Option, Vec) { + let Some((projection, primary_session_id, auxiliary_session_ids)) = + self.take_target_projection_and_routes(target) + else { + return (None, Vec::new()); + }; + let successor_primary_state = DevToolsSessionState { + runtime_bindings: projection.primary_state.runtime_bindings, + ..Default::default() + }; + self.unbound = TopLevelTargetFrontendSessionProjection { + primary_state: successor_primary_state, + ..Default::default() + }; + (primary_session_id, auxiliary_session_ids) + } + + fn take_target_projection_and_routes( + &mut self, + target: &BrowserTargetHandle, + ) -> Option<( + TopLevelTargetFrontendSessionProjection, + Option, + Vec, + )> { + let projection = self.by_target.remove(target)?; + let primary_session_id = projection.primary_session_id.clone(); + let mut auxiliary_session_ids = projection + .auxiliary_session_states + .keys() + .cloned() + .collect::>(); + auxiliary_session_ids.sort(); + for session_id in primary_session_id + .iter() + .chain(auxiliary_session_ids.iter()) + { + if self.target_by_session.get(session_id) == Some(target) { + self.target_by_session.remove(session_id); + } + } + Some((projection, primary_session_id, auxiliary_session_ids)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn residence_changes_do_not_change_exact_frontend_session_state() { + let target = BrowserTargetHandle::staged("TID-1"); + let mut registry = TopLevelTargetFrontendSessionRegistry::default(); + registry.register_target(target.clone()); + registry.replace_primary_session(&target, Some("SID-primary".to_owned())); + registry.attach_auxiliary_session(&target, "SID-aux".to_owned()); + registry + .primary_state_mut(&target) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled = true; + registry + .session_state_mut(&target, true, Some("SID-aux")) + .unwrap() + .page_session_state + .page_lifecycle_events = true; + + assert_eq!(registry.primary_session_id(&target), Some("SID-primary")); + assert_eq!(registry.target_for_session("SID-primary"), Some(&target)); + assert_eq!(registry.target_for_session("SID-aux"), Some(&target)); + assert!( + registry + .primary_state(&target) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled + ); + assert!( + registry + .session_state(&target, true, Some("SID-aux")) + .unwrap() + .page_session_state + .page_lifecycle_events + ); + } + + #[test] + fn same_public_id_cannot_inherit_predecessor_sessions_or_state() { + let predecessor = BrowserTargetHandle::staged("TID-1"); + let successor = BrowserTargetHandle::staged("TID-1"); + let mut registry = TopLevelTargetFrontendSessionRegistry::default(); + registry.register_target(predecessor.clone()); + registry.replace_primary_session(&predecessor, Some("SID-old".to_owned())); + registry.attach_auxiliary_session(&predecessor, "SID-old-aux".to_owned()); + registry + .primary_state_mut(&predecessor) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled = true; + registry.register_target(successor.clone()); + + assert_eq!(registry.primary_session_id(&successor), None); + assert!( + !registry + .primary_state(&successor) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled + ); + assert_eq!(registry.target_for_session("SID-old"), Some(&predecessor)); + assert_eq!( + registry.target_for_session("SID-old-aux"), + Some(&predecessor) + ); + let removed = registry.remove_target(&predecessor); + assert_eq!(removed.0.as_deref(), Some("SID-old")); + assert_eq!(removed.1, ["SID-old-aux"]); + assert_eq!(registry.target_for_session("SID-old"), None); + assert_eq!(registry.target_for_session("SID-old-aux"), None); + assert!(registry.contains_target(&successor)); + } + + #[test] + fn reusing_a_session_id_moves_one_route_and_drops_auxiliary_state() { + let first = BrowserTargetHandle::staged("TID-1"); + let second = BrowserTargetHandle::staged("TID-2"); + let mut registry = TopLevelTargetFrontendSessionRegistry::default(); + registry.register_target(first.clone()); + registry.register_target(second.clone()); + registry.attach_auxiliary_session(&first, "SID-reused".to_owned()); + registry + .session_state_mut(&first, true, Some("SID-reused")) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled = true; + + registry.replace_primary_session(&second, Some("SID-reused".to_owned())); + + assert!(!registry.is_auxiliary_session_for_target(&first, "SID-reused")); + assert_eq!(registry.primary_session_id(&second), Some("SID-reused")); + assert_eq!(registry.target_for_session("SID-reused"), Some(&second)); + assert!(registry.auxiliary_session_ids(&first).is_empty()); + } + + #[test] + fn primary_state_survives_detach_and_rebind_on_the_same_exact_target() { + let target = BrowserTargetHandle::staged("TID-1"); + let mut registry = TopLevelTargetFrontendSessionRegistry::default(); + registry.register_target(target.clone()); + registry.replace_primary_session(&target, Some("SID-old".to_owned())); + registry + .primary_state_mut(&target) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled = true; + + registry.replace_primary_session(&target, None); + registry.replace_primary_session(&target, Some("SID-new".to_owned())); + + assert_eq!(registry.primary_session_id(&target), Some("SID-new")); + assert!( + registry + .primary_state(&target) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled + ); + } + + #[test] + fn first_active_target_adopts_only_the_unbound_root_candidate() { + let target = BrowserTargetHandle::staged("TID-1"); + let mut registry = TopLevelTargetFrontendSessionRegistry::default(); + registry + .primary_state_or_unbound_mut(None) + .runtime_session_state + .runtime_frontend_enabled = true; + + registry.register_new_active_target(target.clone()); + + assert!( + registry + .primary_state(&target) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled + ); + assert!( + !registry + .primary_state_or_unbound(None) + .runtime_session_state + .runtime_frontend_enabled + ); + } + + #[test] + fn a_background_registration_does_not_hide_the_unbound_root_candidate() { + let background = BrowserTargetHandle::staged("TID-background"); + let mut registry = TopLevelTargetFrontendSessionRegistry::default(); + registry + .primary_state_or_unbound_mut(None) + .runtime_session_state + .runtime_frontend_enabled = true; + registry.register_target(background); + + assert!( + registry + .session_states() + .any(|state| state.runtime_session_state.runtime_frontend_enabled) + ); + } + + #[test] + fn retiring_an_active_target_reseeds_the_next_root_candidate_without_routes() { + let first = BrowserTargetHandle::staged("TID-first"); + let second = BrowserTargetHandle::staged("TID-second"); + let mut registry = TopLevelTargetFrontendSessionRegistry::default(); + registry.register_new_active_target(first.clone()); + registry.replace_primary_session(&first, Some("SID-first".to_owned())); + registry.attach_auxiliary_session(&first, "SID-aux".to_owned()); + registry + .primary_state_mut(&first) + .unwrap() + .runtime_bindings + .push(crate::conn::RuntimeBindingDefinition { + name: "persisted".to_owned(), + execution_context_name: None, + }); + registry + .primary_state_mut(&first) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled = true; + registry + .primary_state_mut(&first) + .unwrap() + .page_session_state + .page_lifecycle_events = true; + + let removed = registry.retire_target_to_unbound(&first); + registry.register_new_active_target(second.clone()); + + assert_eq!(removed.0.as_deref(), Some("SID-first")); + assert_eq!(removed.1, ["SID-aux"]); + assert_eq!(registry.target_for_session("SID-first"), None); + assert_eq!(registry.target_for_session("SID-aux"), None); + assert_eq!( + registry.primary_state(&second).unwrap().runtime_bindings[0].name, + "persisted" + ); + assert!( + !registry + .primary_state(&second) + .unwrap() + .runtime_session_state + .runtime_frontend_enabled, + "Target-local Runtime subscription must not enter the successor candidate" + ); + assert!( + !registry + .primary_state(&second) + .unwrap() + .page_session_state + .page_lifecycle_events, + "Target-local Page subscription must not enter the successor candidate" + ); + } +} diff --git a/moli-protocol/src/conn/state/tests.rs b/moli-protocol/src/conn/state/tests.rs index 0ae9ab7ba0..b2b642729b 100644 --- a/moli-protocol/src/conn/state/tests.rs +++ b/moli-protocol/src/conn/state/tests.rs @@ -1,6 +1,9 @@ use crate::devtools_runtime::DevToolsNetworkResourceType; use moli_cookie_jar::new_shared_browser_cookie_store; -use moli_core::page::{SameDocumentHistoryUpdate, SubresourceResourceType}; +use moli_core::{ + browser_host::{BrowserInitialEmptyDocumentSeed, BrowserPageOwnerKey}, + page::{SameDocumentHistoryUpdate, SubresourceResourceType}, +}; use super::super::fetch_support::{ FetchAuthChallenge, PendingFetchAuthNavigation, PendingFetchNavigation, @@ -20,6 +23,7 @@ use super::parking::{ }; use super::runtime_slot::TargetRuntimeSlot; use super::session::TargetPageSessionState; +use crate::conn::CdpConnection; use serde_json::json; use std::collections::HashMap; @@ -45,6 +49,7 @@ fn test_navigation_dispatch_state(fetch_request_id: &str) -> NavigationDispatchS request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, } } @@ -53,26 +58,17 @@ fn target_parking_store_collapses_default_target_state() { let mut store = TargetParkingStateStore::default(); let mut page_state = ParkedPageSessionState { - devtools_session_state: DevToolsSessionState { - page_session_state: TargetPageSessionState { - log_enabled: true, - ..Default::default() - }, - ..Default::default() - }, + network_enabled: true, ..Default::default() }; store.replace_page_session_state("TID-A".to_owned(), page_state.clone()); assert!( store .page_session_state("TID-A") - .is_some_and(|state| state.devtools_session_state.page_session_state.log_enabled) + .is_some_and(|state| state.network_enabled) ); - page_state - .devtools_session_state - .page_session_state - .log_enabled = false; + page_state.network_enabled = false; store.replace_page_session_state("TID-A".to_owned(), page_state); assert!(store.page_session_state("TID-A").is_none()); } @@ -1075,50 +1071,6 @@ fn navigation_history_seed_entry_preserves_pending_update() { assert_eq!(entries[0].transition_type, "reload"); } -#[test] -fn initial_empty_document_seeds_browser_navigation_history_metadata() { - let mut owner = TargetOwnerState::default(); - - owner.begin_initial_empty_document( - "TID-initial".to_owned(), - "about:blank".to_owned(), - None, - None, - ); - - let (current_index, entries) = owner.navigation_history_snapshot(None); - assert_eq!(current_index, 0); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].url, "about:blank"); - assert_eq!(entries[0].user_typed_url, "about:blank"); - assert_eq!(entries[0].transition_type, "auto_toplevel"); -} - -#[test] -fn direct_target_initial_url_replaces_empty_document_history_entry() { - let mut owner = TargetOwnerState::default(); - - owner.begin_initial_empty_document( - "TID-direct".to_owned(), - "about:blank".to_owned(), - None, - None, - ); - owner.mark_next_navigation_history_replace_initial_empty_document(); - owner.record_loaded_page_navigation_history(( - "https://example.test/direct".to_owned(), - "direct".to_owned(), - )); - - let (current_index, entries) = owner.navigation_history_snapshot(None); - assert_eq!(current_index, 0); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].url, "https://example.test/direct"); - assert_eq!(entries[0].user_typed_url, "https://example.test/direct"); - assert_eq!(entries[0].title, "direct"); - assert_eq!(entries[0].transition_type, "auto_toplevel"); -} - #[test] fn navigation_history_prune_rejects_only_pending_existing_entry_traversal() { let mut history = TargetNavigationHistoryState::default(); @@ -1131,11 +1083,13 @@ fn navigation_history_prune_rejects_only_pending_existing_entry_traversal() { transition_type: "typed".to_owned(), document_sequence_number: None, }); - assert!(history.record_same_document_update( - "https://example.test/pushed".to_owned(), - "pushed".to_owned(), - SameDocumentHistoryUpdate::Push, - )); + history + .record_same_document_update( + "https://example.test/pushed".to_owned(), + "pushed".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("same-Document push should commit"); let pushed_id = history.snapshot().1[1].id; history.mark_replace_current(); @@ -1169,11 +1123,13 @@ fn navigation_history_traversal_reuses_same_document_entries() { transition_type: "typed".to_owned(), document_sequence_number: None, }); - assert!(history.record_same_document_update( - "https://example.test/page?state=pushed".to_owned(), - "page".to_owned(), - SameDocumentHistoryUpdate::Push, - )); + history + .record_same_document_update( + "https://example.test/page?state=pushed".to_owned(), + "page".to_owned(), + SameDocumentHistoryUpdate::Push, + ) + .expect("same-Document push should commit"); let (_, entries) = history.snapshot(); let pushed_id = entries[1].id; @@ -1184,22 +1140,26 @@ fn navigation_history_traversal_reuses_same_document_entries() { assert_eq!(entries[1].user_typed_url, "https://example.test/page"); assert_eq!(entries[1].transition_type, "link"); - assert!(history.record_same_document_update( - "https://example.test/page".to_owned(), - "page".to_owned(), - SameDocumentHistoryUpdate::Traverse { delta: -1 }, - )); + history + .record_same_document_update( + "https://example.test/page".to_owned(), + "page".to_owned(), + SameDocumentHistoryUpdate::Traverse { delta: -1 }, + ) + .expect("back traversal should commit"); let (current_index, entries) = history.snapshot(); assert_eq!(current_index, 0); assert_eq!(entries.len(), 2); assert_eq!(entries[0].id, initial_id); assert_eq!(entries[1].id, pushed_id); - assert!(history.record_same_document_update( - "https://example.test/page?state=pushed".to_owned(), - "page".to_owned(), - SameDocumentHistoryUpdate::Traverse { delta: 1 }, - )); + history + .record_same_document_update( + "https://example.test/page?state=pushed".to_owned(), + "page".to_owned(), + SameDocumentHistoryUpdate::Traverse { delta: 1 }, + ) + .expect("forward traversal should commit"); let (current_index, entries) = history.snapshot(); assert_eq!(current_index, 1); assert_eq!(entries.len(), 2); @@ -1461,44 +1421,18 @@ fn browser_context_clears_origin_site_data_through_partition_owner() { ); } -#[test] -fn navigation_request_identity_rejects_stale_tokens_without_ordering() { - let mut context = BrowserContext::new("CTX-nav".to_owned()); - context.set_active_target_id("TID-nav"); - context.attach_active_session("SID-nav"); - - let first = context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) - .expect("active target should create first navigation token"); - assert!(context.accepts_pending_document_navigation_event(&first)); - - let second = context - .start_document_navigation_for_active_target("LOADER-2".to_owned()) - .expect("active target should create second navigation token"); - assert!(context.accepts_pending_document_navigation_event(&second)); - assert!( - !context.accepts_pending_document_navigation_event(&first), - "a new navigation request identity must make previous events stale" - ); - assert_ne!(second.request_id, first.request_id); - assert_eq!(second.target_id, "TID-nav"); - assert_eq!(second.loader_id, "LOADER-2"); -} - -#[test] -fn clearing_document_navigation_rejects_late_events() { - let mut context = BrowserContext::new("CTX-nav".to_owned()); - context.set_active_target_id("TID-nav"); - let token = context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) - .expect("active target should create navigation token"); - - context.clear_document_navigation_state_for_active_target(); - - assert!( - !context.accepts_pending_document_navigation_event(&token), - "closed or reset targets must reject late navigation events" - ); +fn commit_initial_document_materialization_without_projection_for_invariant_test( + conn: &mut CdpConnection, + owner: &BrowserPageOwnerKey, +) { + let permit = conn + .browser_host_state + .navigation_owner() + .prepare_initial_document_page_materialization(owner) + .expect("current initial Document should prepare materialization"); + conn.browser_host_state + .commit_page_residence_transition_without_renderer_owner_for_testing(permit) + .expect("exact test transition should commit"); } #[test] @@ -1506,7 +1440,16 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() { let mut context = BrowserContext::new("CTX-initial-active".to_owned()); context.set_active_target_id("TID-initial-active"); context.set_target_url("about:blank#active".to_owned()); - context.begin_active_target_initial_empty_document("about:blank#active".to_owned()); + context.mark_active_initial_document_page_build_pending(); + let mut conn = CdpConnection::default(); + conn.insert_browser_context(context); + let owner = BrowserPageOwnerKey::new("CTX-initial-active", "TID-initial-active"); + conn.register_target_initial_empty_document_for_test( + &owner, + BrowserInitialEmptyDocumentSeed::new("about:blank#active"), + ) + .expect("registered active Target should accept test metadata"); + let context = conn.browser_context.as_ref().expect("browser context"); assert_eq!( context.active_target.runtime_slot.moli_memory_diagnostics()["loadedPageAbsenceReason"], @@ -1519,10 +1462,8 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() { "active initial document target without a Page should count as pending page build" ); - let initial = context - .active_target - .owner_state - .initial_empty_document_state() + let initial = conn + .target_initial_empty_document_for_owner(&owner) .expect("active target should record initial empty document"); assert_eq!(initial.target_id(), "TID-initial-active"); assert_eq!(initial.initial_url(), "about:blank#active"); @@ -1530,53 +1471,49 @@ fn active_target_initial_empty_document_record_tracks_navigation_lifecycle() { assert!(!initial.materialized()); assert!(!initial.pending_cross_document_navigation()); - context.mark_target_initial_empty_document_materialized("TID-initial-active"); + commit_initial_document_materialization_without_projection_for_invariant_test( + &mut conn, &owner, + ); assert!( - context - .active_target - .owner_state - .initial_empty_document_state() + conn.target_initial_empty_document_for_owner(&owner) .expect("initial empty document state") .materialized() ); - let token = context - .start_document_navigation_for_active_target("LOADER-initial-active".to_owned()) + let token = conn + .start_document_navigation_for_session_owner(None, "LOADER-initial-active".to_owned()) .expect("active target should start document navigation"); - let pending = context - .active_target - .owner_state - .initial_empty_document_state() + let pending = conn + .target_initial_empty_document_for_owner(&owner) .expect("initial empty document state"); assert!(pending.is_on_initial_empty_document()); assert!(pending.pending_cross_document_navigation()); - context.clear_pending_document_navigation_for_target_if_loader_matches( - Some("TID-initial-active"), - "LOADER-initial-active", - ); - let cleared = context - .active_target - .owner_state - .initial_empty_document_state() + assert!(conn.fail_document_navigation_for_session_owner_if_matches( + None, + &token, + moli_core::browser_host::BrowserNavigationFailure::Canceled { + error_text: "canceled".to_owned(), + }, + )); + let cleared = conn + .target_initial_empty_document_for_owner(&owner) .expect("initial empty document state"); assert!(cleared.is_on_initial_empty_document()); assert!(!cleared.pending_cross_document_navigation()); - let committed = context - .start_document_navigation_for_active_target("LOADER-initial-active-2".to_owned()) + let committed = conn + .start_document_navigation_for_session_owner(None, "LOADER-initial-active-2".to_owned()) .expect("active target should restart document navigation"); - context.commit_document_navigation_if_matches(&committed); - let exited = context - .active_target - .owner_state - .initial_empty_document_state() + conn.commit_document_navigation_for_session_owner_if_matches(None, &committed); + let exited = conn + .target_initial_empty_document_for_owner(&owner) .expect("initial empty document state"); assert!(exited.exited()); assert!(!exited.pending_cross_document_navigation()); assert!(!exited.is_on_initial_empty_document()); assert!( - !context.accepts_pending_document_navigation_event(&token), + !conn.accepts_pending_document_navigation_for_session_owner(None, &token), "cleared navigation must stay rejected" ); } @@ -1586,23 +1523,31 @@ fn materialized_active_initial_empty_document_requires_loaded_page() { let mut context = BrowserContext::new("CTX-initial-active-invariant".to_owned()); context.set_active_target_id("TID-initial-active-invariant"); context.set_target_url("about:blank#active".to_owned()); - context.begin_active_target_initial_empty_document("about:blank#active".to_owned()); + context.mark_active_initial_document_page_build_pending(); + let mut conn = CdpConnection::default(); + conn.insert_browser_context(context); + let owner = BrowserPageOwnerKey::new( + "CTX-initial-active-invariant", + "TID-initial-active-invariant", + ); + conn.register_target_initial_empty_document_for_test( + &owner, + BrowserInitialEmptyDocumentSeed::new("about:blank#active"), + ) + .expect("registered active Target should accept test metadata"); assert_eq!( - context.assert_target_materialized_initial_empty_document_has_page( - "TID-initial-active-invariant" - ), + conn.assert_target_materialized_initial_empty_document_has_page(&owner), Ok(()), "pending initial document Page build is allowed before materialization" ); - context.mark_target_initial_empty_document_materialized("TID-initial-active-invariant"); + commit_initial_document_materialization_without_projection_for_invariant_test( + &mut conn, &owner, + ); assert_eq!( - context - .assert_target_materialized_initial_empty_document_has_page( - "TID-initial-active-invariant" - ) + conn.assert_target_materialized_initial_empty_document_has_page(&owner) .expect_err("materialized active initial document must have a Page"), "TargetInitialEmptyDocumentMissingPage: target TID-initial-active-invariant has materialized current initial empty document without loaded Page" ); @@ -1618,6 +1563,15 @@ fn background_target_initial_empty_document_record_tracks_navigation_lifecycle() None, None, ); + let mut conn = CdpConnection::default(); + conn.insert_browser_context(context); + let owner = BrowserPageOwnerKey::new("CTX-initial-bg", "TID-initial-bg"); + conn.register_target_initial_empty_document_for_test( + &owner, + BrowserInitialEmptyDocumentSeed::new("about:blank#background"), + ) + .expect("registered background Target should accept test metadata"); + let context = conn.browser_context.as_ref().expect("browser context"); assert_eq!( context.background_targets[0] @@ -1632,39 +1586,38 @@ fn background_target_initial_empty_document_record_tracks_navigation_lifecycle() "background initial document target without a Page should count as pending page build" ); - let initial = context - .parked_target_owner_state("TID-initial-bg") - .and_then(TargetOwnerState::initial_empty_document_state) + let initial = conn + .target_initial_empty_document_for_owner(&owner) .expect("background target should record initial empty document"); assert_eq!(initial.target_id(), "TID-initial-bg"); assert_eq!(initial.initial_url(), "about:blank#background"); assert!(initial.is_on_initial_empty_document()); assert!(!initial.materialized()); - context.mark_target_initial_empty_document_materialized("TID-initial-bg"); + commit_initial_document_materialization_without_projection_for_invariant_test( + &mut conn, &owner, + ); assert!( - context - .parked_target_owner_state("TID-initial-bg") - .and_then(TargetOwnerState::initial_empty_document_state) + conn.target_initial_empty_document_for_owner(&owner) .expect("initial empty document state") .materialized() ); - let token = context - .start_document_navigation_for_target("TID-initial-bg", "LOADER-initial-bg".to_owned()) + let token = conn + .start_document_navigation_for_session_owner( + Some("SID-initial-bg"), + "LOADER-initial-bg".to_owned(), + ) .expect("background target should start document navigation"); assert!( - context - .parked_target_owner_state("TID-initial-bg") - .and_then(TargetOwnerState::initial_empty_document_state) + conn.target_initial_empty_document_for_owner(&owner) .expect("initial empty document state") .pending_cross_document_navigation() ); - context.commit_document_navigation_if_matches(&token); - let exited = context - .parked_target_owner_state("TID-initial-bg") - .and_then(TargetOwnerState::initial_empty_document_state) + conn.commit_document_navigation_for_session_owner_if_matches(Some("SID-initial-bg"), &token); + let exited = conn + .target_initial_empty_document_for_owner(&owner) .expect("initial empty document state"); assert!(exited.exited()); assert!(!exited.pending_cross_document_navigation()); @@ -1682,47 +1635,27 @@ fn materialized_background_initial_empty_document_requires_loaded_page() { None, ); + let mut conn = CdpConnection::default(); + conn.insert_browser_context(context); + let owner = BrowserPageOwnerKey::new("CTX-initial-bg-invariant", "TID-initial-bg-invariant"); + conn.register_target_initial_empty_document_for_test( + &owner, + BrowserInitialEmptyDocumentSeed::new("about:blank#background"), + ) + .expect("registered background Target should accept test metadata"); assert_eq!( - context - .assert_target_materialized_initial_empty_document_has_page("TID-initial-bg-invariant"), + conn.assert_target_materialized_initial_empty_document_has_page(&owner), Ok(()), "pending initial document Page build is allowed before materialization" ); - context.mark_target_initial_empty_document_materialized("TID-initial-bg-invariant"); + commit_initial_document_materialization_without_projection_for_invariant_test( + &mut conn, &owner, + ); assert_eq!( - context - .assert_target_materialized_initial_empty_document_has_page("TID-initial-bg-invariant") + conn.assert_target_materialized_initial_empty_document_has_page(&owner) .expect_err("materialized background initial document must have a Page"), "TargetInitialEmptyDocumentMissingPage: target TID-initial-bg-invariant has materialized current initial empty document without loaded Page" ); } - -#[test] -fn committed_document_navigation_accepts_matching_late_body_only_until_next_navigation() { - let mut context = BrowserContext::new("CTX-nav".to_owned()); - context.set_active_target_id("TID-nav"); - - let first = context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) - .expect("active target should create first navigation token"); - assert!( - context.accepts_document_body_completion_event(&first), - "pending body completion should be accepted for the matching token" - ); - context.commit_document_navigation_if_matches(&first); - assert!( - context.accepts_document_body_completion_event(&first), - "late body completion should be accepted after the matching document commit" - ); - - let second = context - .start_document_navigation_for_active_target("LOADER-2".to_owned()) - .expect("active target should create second navigation token"); - assert!( - !context.accepts_document_body_completion_event(&first), - "starting a newer navigation must make the previous body completion stale" - ); - assert!(context.accepts_document_body_completion_event(&second)); -} diff --git a/moli-protocol/src/conn/target/activation.rs b/moli-protocol/src/conn/target/activation.rs deleted file mode 100644 index c79e2bae63..0000000000 --- a/moli-protocol/src/conn/target/activation.rs +++ /dev/null @@ -1,227 +0,0 @@ -use moli_core::runtime::NavigationEngine; - -use crate::conn::{BackgroundProtocolEvent, BrowserContext, CdpConnection}; - -/// The stable Target identities on both sides of one foreground selection. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TargetActivationTransition { - promoted_target_id: String, - previous_active_target_id: Option, -} - -impl TargetActivationTransition { - pub(crate) fn new( - promoted_target_id: impl Into, - previous_active_target_id: Option, - ) -> Self { - Self { - promoted_target_id: promoted_target_id.into(), - previous_active_target_id, - } - } - - fn promoted_target_id(&self) -> &str { - &self.promoted_target_id - } - - fn previous_active_target_id(&self) -> Option<&str> { - self.previous_active_target_id.as_deref() - } - - fn demoted_target_id(&self) -> Option<&str> { - self.previous_active_target_id() - .filter(|target_id| *target_id != self.promoted_target_id()) - } - - fn changed_active_target(&self) -> bool { - self.previous_active_target_id() != Some(self.promoted_target_id()) - } -} - -/// Successful target selection and the Page events caused by its surface move. -/// -/// The events stay attached to the completion so every activation entry point -/// must preserve their position relative to its own response or owner action. -#[derive(Debug)] -pub(crate) struct CompletedTargetActivation { - protocol_events: Vec, -} - -impl CompletedTargetActivation { - fn new(protocol_events: Vec) -> Self { - Self { protocol_events } - } - - pub(crate) fn into_protocol_events(self) -> Vec { - self.protocol_events - } -} - -impl CdpConnection { - /// Completes the renderer-surface half of a foreground transition that was - /// staged synchronously while creating a new target. - pub(crate) async fn complete_staged_target_activation_async( - &mut self, - transition: &TargetActivationTransition, - ) -> CompletedTargetActivation { - let Some(demoted_target_id) = transition.demoted_target_id() else { - return CompletedTargetActivation::new(Vec::new()); - }; - let protocol_events = self - .page_screencast_session_ids_for_target(demoted_target_id) - .into_iter() - .map(|session_id| { - BackgroundProtocolEvent::page_screencast_visibility_changed( - session_id.as_deref(), - false, - ) - }) - .collect(); - if let Err(error) = self - .apply_parked_target_surface_overrides_async(demoted_target_id) - .await - { - tracing::warn!( - target_id = demoted_target_id, - promoted_target_id = transition.promoted_target_id(), - %error, - "failed to update Page visibility after target activation" - ); - } - CompletedTargetActivation::new(protocol_events) - } - - pub(crate) fn handoff_navigation_engine_for_target_activation(&mut self, target_id: &str) { - let Some(browser_context) = self.browser_context.as_ref() else { - return; - }; - let browser_context_id = browser_context.id.clone(); - let renderer_runtime = browser_context.renderer_runtime_owner_access(); - let navigation_runtime_config = self.engine.runtime_config(); - let promoted_key = (browser_context_id.clone(), target_id.to_owned()); - let promoted_engine = self - .retained_background_navigation_engines - .remove(&promoted_key); - - let active_target_id = browser_context.active_target_id().map(str::to_owned); - let active_has_loaded_page = browser_context.has_loaded_page(); - if let Some(active_target_id) = active_target_id - && active_has_loaded_page - { - let next_engine = promoted_engine.unwrap_or_else(|| { - NavigationEngine::new_with_runtime_config_and_browser_context_access( - navigation_runtime_config, - renderer_runtime.clone(), - ) - .expect("active BrowserContext owner must be live") - }); - self.apply_scheduler_senders_to_navigation_engine(&next_engine); - let active_engine = self.engine.replace(next_engine); - self.retain_background_navigation_engine( - browser_context_id, - active_target_id, - active_engine, - ) - .expect("demoted active engine must retain its exact BrowserContext owner"); - } else if let Some(promoted_engine) = promoted_engine { - self.replace_navigation_engine(promoted_engine); - } - } - - pub(crate) fn handoff_navigation_engine_for_active_target_demotion(&mut self) { - let Some(browser_context) = self.browser_context.as_ref() else { - return; - }; - let Some(active_target_id) = browser_context.active_target_id().map(str::to_owned) else { - return; - }; - if !browser_context.has_loaded_page() { - return; - } - - let browser_context_id = browser_context.id.clone(); - let renderer_runtime = browser_context.renderer_runtime_owner_access(); - let next_engine = NavigationEngine::new_with_runtime_config_and_browser_context_access( - self.engine.runtime_config(), - renderer_runtime, - ) - .expect("active BrowserContext owner must be live"); - self.apply_scheduler_senders_to_navigation_engine(&next_engine); - let active_engine = self.engine.replace(next_engine); - self.retain_background_navigation_engine( - browser_context_id, - active_target_id, - active_engine, - ) - .expect("demoted active engine must retain its exact BrowserContext owner"); - } - - pub(crate) async fn promote_background_target_to_active_for_connection_async( - &mut self, - target_id: &str, - ) -> Result, String> { - let previous_active_target_id = self - .browser_context - .as_ref() - .and_then(BrowserContext::active_target_id_owned); - let transition = TargetActivationTransition::new(target_id, previous_active_target_id); - let hidden_screencast_sessions = transition - .demoted_target_id() - .map(|active_target_id| self.page_screencast_session_ids_for_target(active_target_id)) - .unwrap_or_default(); - self.handoff_navigation_engine_for_target_activation(target_id); - let Some(browser_context) = self.browser_context.as_mut() else { - return Err("BrowserContextNotLoaded".to_owned()); - }; - let promoted = browser_context - .promote_background_target_to_active_slot_async(target_id) - .await?; - if !promoted { - return Ok(None); - } - self.refresh_active_browser_context_loader_async().await; - self.notify_target_host_activated(target_id); - - let mut protocol_events = Vec::new(); - if transition.changed_active_target() { - // Chromium's PageHandler reports RenderWidgetHost visibility only - // while that attachment has an active screencast. Hide the old - // surface before exposing the promoted one. - protocol_events.extend(hidden_screencast_sessions.into_iter().map(|session_id| { - BackgroundProtocolEvent::page_screencast_visibility_changed( - session_id.as_deref(), - false, - ) - })); - protocol_events.extend( - self.page_screencast_session_ids_for_target(target_id) - .into_iter() - .map(|session_id| { - BackgroundProtocolEvent::page_screencast_visibility_changed( - session_id.as_deref(), - true, - ) - }), - ); - } - Ok(Some(CompletedTargetActivation::new(protocol_events))) - } - - pub(crate) fn page_screencast_session_ids_for_target( - &mut self, - target_id: &str, - ) -> Vec> { - let Some(route) = self.target_session_route_for_target_id(target_id) else { - return Vec::new(); - }; - let mut route_scope = self.scoped_none_session_owner_route_override(route); - let conn = route_scope.conn_mut(); - conn.page_event_session_ids_for_session_owner(None) - .into_iter() - .filter(|session_id| { - conn.target_page_session_state_for_session(session_id.as_deref()) - .is_some_and(|state| state.page_screencast.is_active()) - }) - .collect() - } -} diff --git a/moli-protocol/src/conn/target/auto_attach_owner.rs b/moli-protocol/src/conn/target/auto_attach_owner.rs deleted file mode 100644 index 21dd16624f..0000000000 --- a/moli-protocol/src/conn/target/auto_attach_owner.rs +++ /dev/null @@ -1,94 +0,0 @@ -use crate::conn::{AutoAttachOwnerPolicy, CdpConnection, CdpTargetFilter}; - -impl CdpConnection { - fn sync_auto_attach_flags_from_owners(&mut self) { - self.auto_attach = !self.auto_attach_owner_sessions.is_empty(); - self.auto_attach_wait_for_debugger_on_start = self - .auto_attach_owner_sessions - .values() - .any(|policy| policy.wait_for_debugger_on_start); - } - - pub(crate) fn has_auto_attach_owner(&self, session_id: Option<&str>) -> bool { - self.auto_attach_owner_sessions - .contains_key(&session_id.map(str::to_owned)) - } - - pub(crate) fn auto_attach_owner_count(&self) -> usize { - self.auto_attach_owner_sessions.len() - } - - pub(crate) fn auto_attach_owner_sessions_for_target_type( - &self, - target_type: &str, - ) -> Vec> { - if self.auto_attach_owner_sessions.is_empty() && self.auto_attach { - return CdpTargetFilter::default_auto_attach() - .matches(target_type) - .then_some(None) - .into_iter() - .collect(); - } - self.auto_attach_owner_sessions - .iter() - .filter(|(_, policy)| policy.target_filter.matches(target_type)) - .map(|(session_id, _)| session_id.clone()) - .collect::>() - } - - pub(crate) fn auto_attach_owner_allows_target_type( - &self, - session_id: Option<&str>, - target_type: &str, - ) -> bool { - self.auto_attach_owner_sessions - .get(&session_id.map(str::to_owned)) - .is_some_and(|policy| policy.target_filter.matches(target_type)) - || (self.auto_attach_owner_sessions.is_empty() - && self.auto_attach - && CdpTargetFilter::default_auto_attach().matches(target_type)) - } - - pub(crate) fn auto_attach_owner_waits_for_debugger_on_start( - &self, - session_id: Option<&str>, - ) -> bool { - if self.auto_attach_owner_sessions.is_empty() && self.auto_attach { - return self.auto_attach_wait_for_debugger_on_start; - } - self.auto_attach_owner_sessions - .get(&session_id.map(str::to_owned)) - .is_some_and(|policy| policy.wait_for_debugger_on_start) - } - - pub(crate) fn set_auto_attach_owner( - &mut self, - session_id: Option<&str>, - enabled: bool, - wait_for_debugger_on_start: bool, - target_filter: CdpTargetFilter, - ) { - self.clear_service_worker_auto_attach_related_owner(session_id); - let key = session_id.map(str::to_owned); - if enabled { - self.target_control.ensure_owner(session_id); - self.auto_attach_owner_sessions.insert( - key, - AutoAttachOwnerPolicy { - wait_for_debugger_on_start, - target_filter, - }, - ); - } else { - self.auto_attach_owner_sessions.remove(&key); - } - self.sync_auto_attach_flags_from_owners(); - } - - pub(crate) fn clear_auto_attach_owner(&mut self, session_id: Option<&str>) { - self.clear_service_worker_auto_attach_related_owner(session_id); - let key = session_id.map(str::to_owned); - self.auto_attach_owner_sessions.remove(&key); - self.sync_auto_attach_flags_from_owners(); - } -} diff --git a/moli-protocol/src/conn/target/control.rs b/moli-protocol/src/conn/target/control.rs index a437e49253..11afa68b2d 100644 --- a/moli-protocol/src/conn/target/control.rs +++ b/moli-protocol/src/conn/target/control.rs @@ -1,6 +1,5 @@ use std::collections::HashSet; -use super::graph::TabTarget; use crate::devtools_runtime::{ DevToolsSessionId, DevToolsTargetFilterEntry, DevToolsTargetId, DevToolsTargetInfo, DevToolsTargetKind, TargetAttachmentEvent, TargetDetachmentEvent, @@ -21,9 +20,13 @@ pub(crate) struct TargetControlPlane { } impl TargetControlPlane { - pub(crate) fn register_tab(&mut self, tab_target_id: String, primary_page_target_id: String) { + pub(crate) fn register_top_level_page( + &mut self, + page_target_id: String, + tab_target_id: String, + ) { self.registry - .register_tab(tab_target_id, primary_page_target_id); + .register_top_level_page(page_target_id, tab_target_id); } pub(crate) fn register_worker(&mut self, target_id: String, kind: DevToolsTargetKind) { @@ -39,12 +42,9 @@ impl TargetControlPlane { .tab_target_id_for_page_target_id(page_target_id) } - pub(crate) fn primary_page_target_id_for_tab_target_id( - &self, - tab_target_id: &str, - ) -> Option<&str> { + pub(crate) fn page_target_id_for_tab_target_id(&self, tab_target_id: &str) -> Option<&str> { self.registry - .primary_page_target_id_for_tab_target_id(tab_target_id) + .page_target_id_for_tab_target_id(tab_target_id) } pub(crate) fn primary_session_id_for_tab_target_id(&self, tab_target_id: &str) -> Option<&str> { @@ -66,17 +66,26 @@ impl TargetControlPlane { self.registry.remove_tab_session(session_id) } - pub(crate) fn remove_tab_by_page_target_id( + pub(crate) fn remove_top_level_page_by_page_target_id( &mut self, page_target_id: &str, ) -> Option { - self.registry.remove_tab_by_page_target_id(page_target_id) + self.registry + .remove_top_level_page_by_page_target_id(page_target_id) } pub(crate) fn tab_target_id_for_session_id(&self, session_id: &str) -> Option<&str> { self.registry.tab_target_id_for_session_id(session_id) } + pub(crate) fn auto_attached_target_ids_for_owner( + &self, + owner_session_id: Option<&str>, + ) -> Vec { + self.sessions + .auto_attached_target_ids_for_owner(owner_session_id) + } + pub(crate) fn tab_target_info_for_page_target_info( &self, page_target_info: DevToolsTargetInfo, @@ -85,48 +94,48 @@ impl TargetControlPlane { return None; } let page_target_id = page_target_info.target_id.as_ref()?.as_str(); - let target = self.registry.tab_for_page_target_id(page_target_id)?; + let target = self + .registry + .top_level_target_for_page_target_id(page_target_id)?; Some(super::projection::tab_target_info_from_page_target_info( target, page_target_info, )) } - pub(crate) fn project_page_tab_target_infos_for_destruction( + pub(crate) fn project_tab_page_target_infos( &self, target_info: DevToolsTargetInfo, ) -> Vec { - let target = target_info - .target_id - .as_ref() - .and_then(|target_id| self.registry.tab_for_page_target_id(target_id.as_str())); - super::projection::project_page_tab_target_infos_for_destruction(target, target_info) + let target = target_info.target_id.as_ref().and_then(|target_id| { + self.registry + .top_level_target_for_page_target_id(target_id.as_str()) + }); + super::projection::project_tab_page_target_infos(target, target_info) } - fn paired_tab_target(&self, target_id: &str) -> Option<&TabTarget> { + fn paired_top_level_target(&self, target_id: &str) -> Option<&super::graph::TopLevelTarget> { self.registry - .tab_for_page_target_id(target_id) - .or_else(|| self.registry.tab(target_id)) + .top_level_target_for_page_target_id(target_id) + .or_else(|| self.registry.top_level_target_for_tab_target_id(target_id)) } pub(crate) fn target_created_deltas(&self, target_id: &str) -> Vec { - let Some(target) = self.paired_tab_target(target_id) else { + let Some(target) = self.paired_top_level_target(target_id) else { return vec![TargetHostDelta::created(target_id.to_owned())]; }; - target - .target_ids_in_creation_order() - .into_iter() - .map(TargetHostDelta::created) - .collect() + vec![ + TargetHostDelta::created(target.tab_target_id()), + TargetHostDelta::created(target.page_target_id()), + ] } pub(crate) fn target_destroyed_deltas(&self, target_id: &str) -> Vec { - if let Some(target) = self.paired_tab_target(target_id) { - return target - .target_ids_in_destruction_order() - .into_iter() - .map(TargetHostDelta::destroyed) - .collect(); + if let Some(target) = self.paired_top_level_target(target_id) { + return vec![ + TargetHostDelta::destroyed(target.page_target_id()), + TargetHostDelta::destroyed(target.tab_target_id()), + ]; } vec![TargetHostDelta::destroyed(target_id.to_owned())] } @@ -477,11 +486,6 @@ impl TargetControlPlane { self.sessions.attached_sessions_for_target(target_id) } - pub(crate) fn target_has_waiting_for_debugger_session(&self, target_id: &str) -> bool { - self.sessions - .target_has_waiting_for_debugger_session(target_id) - } - pub(crate) fn auto_attached_sessions_for_owner( &self, owner_session_id: Option<&str>, @@ -490,14 +494,6 @@ impl TargetControlPlane { .auto_attached_sessions_for_owner(owner_session_id) } - pub(crate) fn auto_attached_target_ids_for_owner( - &self, - owner_session_id: Option<&str>, - ) -> Vec { - self.sessions - .auto_attached_target_ids_for_owner(owner_session_id) - } - pub(crate) fn attached_session_cascade_for_owner( &self, owner_session_id: Option<&str>, diff --git a/moli-protocol/src/conn/target/graph.rs b/moli-protocol/src/conn/target/graph.rs index 869de7418b..354c370e7d 100644 --- a/moli-protocol/src/conn/target/graph.rs +++ b/moli-protocol/src/conn/target/graph.rs @@ -40,107 +40,103 @@ impl TargetSessionSet { } } -/// One stable browser tab and its current top-level page target. -/// -/// Moli keeps the same page target across ordinary document navigations and -/// does not yet expose prerender page targets, so the supported relationship -/// is intentionally one-to-one. The tab identity is still independent: it is -/// the durable browser surface and owns its own DevTools sessions. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TabTarget { +pub(crate) struct TopLevelTarget { + page_target_id: String, tab_target_id: String, - primary_page_target_id: String, - sessions: TargetSessionSet, + tab_sessions: TargetSessionSet, } -impl TabTarget { - fn new(tab_target_id: String, primary_page_target_id: String) -> Self { +impl TopLevelTarget { + fn new(page_target_id: String, tab_target_id: String) -> Self { Self { + page_target_id, tab_target_id, - primary_page_target_id, - sessions: TargetSessionSet::default(), + tab_sessions: TargetSessionSet::default(), } } - pub(crate) fn id(&self) -> &str { - &self.tab_target_id - } - - pub(crate) fn primary_page_target_id(&self) -> &str { - &self.primary_page_target_id - } - - pub(crate) fn session_ids(&self) -> Vec { - self.sessions.session_ids() + pub(crate) fn page_target_id(&self) -> &str { + &self.page_target_id } - pub(crate) fn has_session(&self) -> bool { - self.sessions.has_session() + pub(crate) fn tab_target_id(&self) -> &str { + &self.tab_target_id } - /// Chromium publishes a WebContents-backed Tab host before its primary - /// RenderFrame-backed Page host becomes observable. - pub(crate) fn target_ids_in_creation_order(&self) -> [&str; 2] { - [self.id(), self.primary_page_target_id()] + pub(crate) fn tab_session_ids(&self) -> Vec { + self.tab_sessions.session_ids() } - /// The primary Page host goes away before the WebContents-backed Tab host. - pub(crate) fn target_ids_in_destruction_order(&self) -> [&str; 2] { - [self.primary_page_target_id(), self.id()] + pub(crate) fn tab_has_session(&self) -> bool { + self.tab_sessions.has_session() } } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct TargetGraph { - tabs: HashMap, + top_level_targets: HashMap, page_to_tab: HashMap, + tab_to_page: HashMap, tab_session_to_tab: HashMap, } impl TargetGraph { - pub(crate) fn register_tab(&mut self, tab_target_id: String, primary_page_target_id: String) { - self.remove_tab_by_page_target_id(&primary_page_target_id); - self.remove_tab_by_target_id(&tab_target_id); + pub(crate) fn register_top_level_page( + &mut self, + page_target_id: String, + tab_target_id: String, + ) { + self.remove_top_level_page_by_page_target_id(&page_target_id); + self.remove_top_level_page_by_tab_target_id(&tab_target_id); self.page_to_tab - .insert(primary_page_target_id.clone(), tab_target_id.clone()); - self.tabs.insert( - tab_target_id.clone(), - TabTarget::new(tab_target_id, primary_page_target_id), + .insert(page_target_id.clone(), tab_target_id.clone()); + self.tab_to_page + .insert(tab_target_id.clone(), page_target_id.clone()); + self.top_level_targets.insert( + page_target_id.clone(), + TopLevelTarget::new(page_target_id, tab_target_id), ); } - pub(crate) fn remove_tab_by_page_target_id( + pub(crate) fn remove_top_level_page_by_page_target_id( &mut self, page_target_id: &str, - ) -> Option { - let tab_target_id = self.page_to_tab.get(page_target_id)?.clone(); - self.remove_tab_by_target_id(&tab_target_id) - } - - pub(crate) fn remove_tab_by_target_id(&mut self, tab_target_id: &str) -> Option { - let target = self.tabs.remove(tab_target_id)?; - for session_id in target.sessions.session_ids() { - self.tab_session_to_tab.remove(&session_id); + ) -> Option { + let target = self.top_level_targets.remove(page_target_id)?; + if let Some(session_id) = target.tab_sessions.primary_session_id() { + self.tab_session_to_tab.remove(session_id); } - self.page_to_tab.remove(target.primary_page_target_id()); + for session_id in &target.tab_sessions.auxiliary_session_ids { + self.tab_session_to_tab.remove(session_id); + } + self.page_to_tab.remove(target.page_target_id()); + self.tab_to_page.remove(target.tab_target_id()); Some(target) } + pub(crate) fn remove_top_level_page_by_tab_target_id( + &mut self, + tab_target_id: &str, + ) -> Option { + let page_target_id = self.tab_to_page.get(tab_target_id)?.clone(); + self.remove_top_level_page_by_page_target_id(&page_target_id) + } + pub(crate) fn tab_target_id_for_page_target_id(&self, page_target_id: &str) -> Option<&str> { self.page_to_tab.get(page_target_id).map(String::as_str) } - pub(crate) fn primary_page_target_id_for_tab_target_id( - &self, - tab_target_id: &str, - ) -> Option<&str> { - self.tabs - .get(tab_target_id) - .map(TabTarget::primary_page_target_id) + pub(crate) fn page_target_id_for_tab_target_id(&self, tab_target_id: &str) -> Option<&str> { + self.tab_to_page.get(tab_target_id).map(String::as_str) } pub(crate) fn primary_session_id_for_tab_target_id(&self, tab_target_id: &str) -> Option<&str> { - self.tabs.get(tab_target_id)?.sessions.primary_session_id() + let page_target_id = self.tab_to_page.get(tab_target_id)?; + self.top_level_targets + .get(page_target_id)? + .tab_sessions + .primary_session_id() } pub(crate) fn assign_session_to_tab_target( @@ -150,11 +146,14 @@ impl TargetGraph { auxiliary: bool, ) -> bool { self.remove_tab_session(&session_id); - let Some(target) = self.tabs.get_mut(tab_target_id) else { + let Some(page_target_id) = self.tab_to_page.get(tab_target_id).cloned() else { + return false; + }; + let Some(target) = self.top_level_targets.get_mut(&page_target_id) else { return false; }; target - .sessions + .tab_sessions .insert_session(session_id.clone(), auxiliary); self.tab_session_to_tab .insert(session_id, tab_target_id.to_owned()); @@ -163,8 +162,9 @@ impl TargetGraph { pub(crate) fn remove_tab_session(&mut self, session_id: &str) -> Option { let tab_target_id = self.tab_session_to_tab.remove(session_id)?; - let target = self.tabs.get_mut(&tab_target_id)?; - target.sessions.remove_session(session_id); + let page_target_id = self.tab_to_page.get(&tab_target_id)?; + let target = self.top_level_targets.get_mut(page_target_id)?; + target.tab_sessions.remove_session(session_id); Some(tab_target_id) } @@ -172,23 +172,21 @@ impl TargetGraph { self.tab_session_to_tab.get(session_id).map(String::as_str) } - pub(crate) fn tab_for_page_target_id(&self, page_target_id: &str) -> Option<&TabTarget> { - let tab_target_id = self.page_to_tab.get(page_target_id)?; - self.tabs.get(tab_target_id) - } - - pub(crate) fn tab(&self, tab_target_id: &str) -> Option<&TabTarget> { - self.tabs.get(tab_target_id) + pub(crate) fn top_level_target_for_page_target_id( + &self, + page_target_id: &str, + ) -> Option<&TopLevelTarget> { + self.top_level_targets.get(page_target_id) } #[cfg(test)] pub(crate) fn len(&self) -> usize { - self.tabs.len() + self.top_level_targets.len() } #[cfg(test)] pub(crate) fn is_empty(&self) -> bool { - self.tabs.is_empty() + self.top_level_targets.is_empty() } } @@ -197,44 +195,45 @@ mod tests { use super::TargetGraph; #[test] - fn target_graph_registers_stable_tab_with_primary_page() { + fn target_graph_registers_top_level_page_tab_pair() { let mut graph = TargetGraph::default(); - graph.register_tab("TAB-1".to_owned(), "TID-page".to_owned()); + graph.register_top_level_page("TID-page".to_owned(), "TAB-TID-page".to_owned()); assert_eq!(graph.len(), 1); assert_eq!( graph.tab_target_id_for_page_target_id("TID-page"), - Some("TAB-1") + Some("TAB-TID-page") ); assert_eq!( - graph.primary_page_target_id_for_tab_target_id("TAB-1"), + graph.page_target_id_for_tab_target_id("TAB-TID-page"), Some("TID-page") ); - let tab = graph - .tab_for_page_target_id("TID-page") - .expect("tab target"); - assert_eq!(tab.id(), "TAB-1"); - assert_eq!(tab.primary_page_target_id(), "TID-page"); - assert!(!tab.has_session()); + let target = graph + .top_level_target_for_page_target_id("TID-page") + .expect("top level target"); + assert_eq!(target.page_target_id(), "TID-page"); + assert_eq!(target.tab_target_id(), "TAB-TID-page"); + assert!(!target.tab_has_session()); } #[test] fn target_graph_rekey_removes_stale_reverse_entries() { let mut graph = TargetGraph::default(); - graph.register_tab("TAB-a".to_owned(), "TID-a".to_owned()); - graph.register_tab("TAB-b".to_owned(), "TID-a".to_owned()); + graph.register_top_level_page("TID-a".to_owned(), "TAB-a".to_owned()); + graph.register_top_level_page("TID-a".to_owned(), "TAB-b".to_owned()); assert_eq!(graph.len(), 1); assert_eq!( graph.tab_target_id_for_page_target_id("TID-a"), Some("TAB-b") ); + assert_eq!(graph.page_target_id_for_tab_target_id("TAB-a"), None); assert_eq!( - graph.primary_page_target_id_for_tab_target_id("TAB-a"), - None + graph.page_target_id_for_tab_target_id("TAB-b"), + Some("TID-a") ); - graph.register_tab("TAB-b".to_owned(), "TID-b".to_owned()); + graph.register_top_level_page("TID-b".to_owned(), "TAB-b".to_owned()); assert_eq!(graph.len(), 1); assert_eq!(graph.tab_target_id_for_page_target_id("TID-a"), None); assert_eq!( @@ -242,36 +241,22 @@ mod tests { Some("TAB-b") ); assert_eq!( - graph.primary_page_target_id_for_tab_target_id("TAB-b"), + graph.page_target_id_for_tab_target_id("TAB-b"), Some("TID-b") ); } #[test] - fn target_graph_removes_tab_from_either_identity() { + fn target_graph_removes_pair_from_either_side() { let mut graph = TargetGraph::default(); - graph.register_tab("TAB-1".to_owned(), "TID-page".to_owned()); + graph.register_top_level_page("TID-page".to_owned(), "TAB-TID-page".to_owned()); - let removed = graph.remove_tab_by_target_id("TAB-1").expect("removed tab"); - assert_eq!(removed.primary_page_target_id(), "TID-page"); + let removed = graph + .remove_top_level_page_by_tab_target_id("TAB-TID-page") + .expect("removed target"); + assert_eq!(removed.page_target_id(), "TID-page"); assert!(graph.is_empty()); assert_eq!(graph.tab_target_id_for_page_target_id("TID-page"), None); - assert_eq!( - graph.primary_page_target_id_for_tab_target_id("TAB-1"), - None - ); - } - - #[test] - fn target_graph_exposes_chromium_host_lifecycle_order() { - let mut graph = TargetGraph::default(); - graph.register_tab("TAB-1".to_owned(), "TID-page".to_owned()); - let target = graph.tab("TAB-1").expect("tab target"); - - assert_eq!(target.target_ids_in_creation_order(), ["TAB-1", "TID-page"]); - assert_eq!( - target.target_ids_in_destruction_order(), - ["TID-page", "TAB-1"] - ); + assert_eq!(graph.page_target_id_for_tab_target_id("TAB-TID-page"), None); } } diff --git a/moli-protocol/src/conn/target/host.rs b/moli-protocol/src/conn/target/host.rs index 86d0d22e16..9544609144 100644 --- a/moli-protocol/src/conn/target/host.rs +++ b/moli-protocol/src/conn/target/host.rs @@ -1,19 +1,56 @@ use crate::devtools_runtime::DevToolsTargetKind; -/// Stable protocol identity for one live CDP target. -/// -/// Ownership and association belong to `TargetGraph`; keeping them out of the -/// host avoids conflating Moli's internal Tab/Page relationship with CDP's -/// public `parentId` semantics. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TargetHostParent { + Browser, + Tab { tab_target_id: String }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TargetHostLifecycle { + Live, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct TargetHost { id: String, kind: DevToolsTargetKind, + parent: TargetHostParent, + lifecycle: TargetHostLifecycle, } impl TargetHost { - pub(crate) fn new(id: String, kind: DevToolsTargetKind) -> Self { - Self { id, kind } + pub(crate) fn tab(id: String) -> Self { + Self { + id, + kind: DevToolsTargetKind::Tab, + parent: TargetHostParent::Browser, + lifecycle: TargetHostLifecycle::Live, + } + } + + pub(crate) fn page(id: String, tab_target_id: String) -> Self { + Self { + id, + kind: DevToolsTargetKind::Page, + parent: TargetHostParent::Tab { tab_target_id }, + lifecycle: TargetHostLifecycle::Live, + } + } + + pub(crate) fn worker(id: String, kind: DevToolsTargetKind) -> Self { + debug_assert!(matches!( + kind, + DevToolsTargetKind::Worker + | DevToolsTargetKind::SharedWorker + | DevToolsTargetKind::ServiceWorker + )); + Self { + id, + kind, + parent: TargetHostParent::Browser, + lifecycle: TargetHostLifecycle::Live, + } } pub(crate) fn id(&self) -> &str { @@ -23,4 +60,12 @@ impl TargetHost { pub(crate) fn kind(&self) -> DevToolsTargetKind { self.kind } + + pub(crate) fn parent(&self) -> &TargetHostParent { + &self.parent + } + + pub(crate) fn is_live(&self) -> bool { + self.lifecycle == TargetHostLifecycle::Live + } } diff --git a/moli-protocol/src/conn/target/mod.rs b/moli-protocol/src/conn/target/mod.rs index e1c04bd8a4..19691f268f 100644 --- a/moli-protocol/src/conn/target/mod.rs +++ b/moli-protocol/src/conn/target/mod.rs @@ -1,23 +1,15 @@ -mod activation; -mod auto_attach_owner; mod control; mod graph; mod host; mod observer; mod projection; mod registry; -mod route; mod session; -mod session_binding; mod transaction; -mod worker_auto_attach; -mod worker_session; -pub(crate) use activation::TargetActivationTransition; pub(crate) use control::TargetControlPlane; pub(crate) use observer::{TargetHandlerStore, target_destroyed_automation_events}; pub(crate) use registry::{TargetClosurePlan, TargetHostDelta, TargetRegistry}; -pub(crate) use route::{CdpSessionRoute, TargetHandlerAccessMode}; pub(crate) use session::{ CommittedAttachSession, DetachedTargetSession, PreparedAttachSession, TargetSessionRegistry, }; diff --git a/moli-protocol/src/conn/target/projection.rs b/moli-protocol/src/conn/target/projection.rs index ceeea064c8..86d8e95471 100644 --- a/moli-protocol/src/conn/target/projection.rs +++ b/moli-protocol/src/conn/target/projection.rs @@ -1,16 +1,16 @@ -use super::graph::TabTarget; +use super::graph::TopLevelTarget; use crate::devtools_runtime::{DevToolsTargetId, DevToolsTargetInfo, DevToolsTargetKind}; pub(crate) fn tab_target_info_from_page_target_info( - target: &TabTarget, + target: &TopLevelTarget, page_target_info: DevToolsTargetInfo, ) -> DevToolsTargetInfo { DevToolsTargetInfo { - target_id: Some(DevToolsTargetId::from(target.id())), + target_id: Some(DevToolsTargetId::from(target.tab_target_id())), kind: DevToolsTargetKind::Tab, title: page_target_info.title, url: page_target_info.url, - attached: target.has_session(), + attached: target.tab_has_session(), // Chromium's tab DevToolsAgentHost delegates opener identity and // access to its primary frame host. Preserve the same relationship // when projecting our page target into a tab target. @@ -22,16 +22,20 @@ pub(crate) fn tab_target_info_from_page_target_info( } } -pub(crate) fn project_page_tab_target_infos_for_destruction( - target: Option<&TabTarget>, +pub(crate) fn project_tab_page_target_infos( + target: Option<&TopLevelTarget>, target_info: DevToolsTargetInfo, ) -> Vec { - let mut target_infos = vec![target_info.clone()]; + let mut target_infos = Vec::new(); if target_info.kind == DevToolsTargetKind::Page && let Some(target) = target { - target_infos.push(tab_target_info_from_page_target_info(target, target_info)); + target_infos.push(tab_target_info_from_page_target_info( + target, + target_info.clone(), + )); } + target_infos.push(target_info); target_infos } @@ -48,10 +52,10 @@ mod tests { #[test] fn tab_projection_preserves_noopener_creator_identity_and_access_policy() { let mut graph = TargetGraph::default(); - graph.register_tab("TID-tab".to_owned(), "TID-page".to_owned()); + graph.register_top_level_page("TID-page".to_owned(), "TID-tab".to_owned()); let target = graph - .tab_for_page_target_id("TID-page") - .expect("registered tab target"); + .top_level_target_for_page_target_id("TID-page") + .expect("registered top-level target"); let tab = tab_target_info_from_page_target_info( target, DevToolsTargetInfo { diff --git a/moli-protocol/src/conn/target/registry.rs b/moli-protocol/src/conn/target/registry.rs index cc0b2ab3d1..e93e7d7019 100644 --- a/moli-protocol/src/conn/target/registry.rs +++ b/moli-protocol/src/conn/target/registry.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; -use super::graph::{TabTarget, TargetGraph}; -use super::host::TargetHost; +use super::graph::{TargetGraph, TopLevelTarget}; +use super::host::{TargetHost, TargetHostParent}; use crate::devtools_runtime::DevToolsTargetKind; #[derive(Clone, Debug, PartialEq, Eq)] @@ -41,25 +41,24 @@ impl TargetHostDelta { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct TargetClosurePlan { - tab_target: TabTarget, + top_level_target: TopLevelTarget, deltas: Vec, } impl TargetClosurePlan { - fn from_tab_target(target: TabTarget) -> Self { - let deltas = target - .target_ids_in_destruction_order() - .into_iter() - .map(TargetHostDelta::destroyed) - .collect(); + fn from_top_level_target(target: TopLevelTarget) -> Self { + let deltas = vec![ + TargetHostDelta::destroyed(target.page_target_id()), + TargetHostDelta::destroyed(target.tab_target_id()), + ]; Self { - tab_target: target, + top_level_target: target, deltas, } } - pub(crate) fn tab_target(&self) -> &TabTarget { - &self.tab_target + pub(crate) fn top_level_target(&self) -> &TopLevelTarget { + &self.top_level_target } pub(crate) fn destroyed_target_ids(&self) -> impl Iterator { @@ -73,23 +72,27 @@ impl TargetClosurePlan { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct TargetRegistry { hosts: HashMap, - graph: TargetGraph, + top_level: TargetGraph, } impl TargetRegistry { - pub(crate) fn register_tab(&mut self, tab_target_id: String, primary_page_target_id: String) { - self.remove_tab_by_page_target_id(&primary_page_target_id); - self.remove_tab_by_target_id(&tab_target_id); + pub(crate) fn register_top_level_page( + &mut self, + page_target_id: String, + tab_target_id: String, + ) { + self.remove_top_level_page_by_page_target_id(&page_target_id); + self.remove_top_level_page_by_tab_target_id(&tab_target_id); self.hosts.insert( tab_target_id.clone(), - TargetHost::new(tab_target_id.clone(), DevToolsTargetKind::Tab), + TargetHost::tab(tab_target_id.clone()), ); self.hosts.insert( - primary_page_target_id.clone(), - TargetHost::new(primary_page_target_id.clone(), DevToolsTargetKind::Page), + page_target_id.clone(), + TargetHost::page(page_target_id.clone(), tab_target_id.clone()), ); - self.graph - .register_tab(tab_target_id, primary_page_target_id); + self.top_level + .register_top_level_page(page_target_id, tab_target_id); } pub(crate) fn register_worker(&mut self, target_id: String, kind: DevToolsTargetKind) { @@ -100,7 +103,7 @@ impl TargetRegistry { | DevToolsTargetKind::ServiceWorker )); self.hosts - .insert(target_id.clone(), TargetHost::new(target_id, kind)); + .insert(target_id.clone(), TargetHost::worker(target_id, kind)); } pub(crate) fn remove_worker(&mut self, target_id: &str) -> Option { @@ -116,53 +119,54 @@ impl TargetRegistry { self.hosts.remove(target_id) } - pub(crate) fn remove_tab_by_page_target_id( + pub(crate) fn remove_top_level_page_by_page_target_id( &mut self, page_target_id: &str, ) -> Option { - let target = self.graph.remove_tab_by_page_target_id(page_target_id)?; - self.remove_tab_hosts(&target); - Some(TargetClosurePlan::from_tab_target(target)) + let target = self + .top_level + .remove_top_level_page_by_page_target_id(page_target_id)?; + self.hosts.remove(target.page_target_id()); + self.hosts.remove(target.tab_target_id()); + Some(TargetClosurePlan::from_top_level_target(target)) } - pub(crate) fn remove_tab_by_target_id( + pub(crate) fn remove_top_level_page_by_tab_target_id( &mut self, tab_target_id: &str, ) -> Option { - let target = self.graph.remove_tab_by_target_id(tab_target_id)?; - self.remove_tab_hosts(&target); - Some(TargetClosurePlan::from_tab_target(target)) - } - - fn remove_tab_hosts(&mut self, target: &TabTarget) { - self.hosts.remove(target.id()); - self.hosts.remove(target.primary_page_target_id()); + let page_target_id = self + .top_level + .page_target_id_for_tab_target_id(tab_target_id)? + .to_owned(); + self.remove_top_level_page_by_page_target_id(&page_target_id) } pub(crate) fn tab_target_id_for_page_target_id(&self, page_target_id: &str) -> Option<&str> { let host = self.hosts.get(page_target_id)?; debug_assert_eq!(host.id(), page_target_id); - if host.kind() != DevToolsTargetKind::Page { + if host.kind() != crate::devtools_runtime::DevToolsTargetKind::Page || !host.is_live() { return None; } - self.graph.tab_target_id_for_page_target_id(page_target_id) + if !matches!(host.parent(), TargetHostParent::Tab { .. }) { + return None; + } + self.top_level + .tab_target_id_for_page_target_id(page_target_id) } - pub(crate) fn primary_page_target_id_for_tab_target_id( - &self, - tab_target_id: &str, - ) -> Option<&str> { + pub(crate) fn page_target_id_for_tab_target_id(&self, tab_target_id: &str) -> Option<&str> { let host = self.hosts.get(tab_target_id)?; debug_assert_eq!(host.id(), tab_target_id); - if host.kind() != DevToolsTargetKind::Tab { + if host.kind() != crate::devtools_runtime::DevToolsTargetKind::Tab || !host.is_live() { return None; } - self.graph - .primary_page_target_id_for_tab_target_id(tab_target_id) + self.top_level + .page_target_id_for_tab_target_id(tab_target_id) } pub(crate) fn primary_session_id_for_tab_target_id(&self, tab_target_id: &str) -> Option<&str> { - self.graph + self.top_level .primary_session_id_for_tab_target_id(tab_target_id) } @@ -173,34 +177,42 @@ impl TargetRegistry { auxiliary: bool, ) -> bool { if self - .primary_page_target_id_for_tab_target_id(tab_target_id) + .page_target_id_for_tab_target_id(tab_target_id) .is_none() { return false; } - self.graph + self.top_level .assign_session_to_tab_target(tab_target_id, session_id, auxiliary) } pub(crate) fn remove_tab_session(&mut self, session_id: &str) -> Option { - self.graph.remove_tab_session(session_id) + self.top_level.remove_tab_session(session_id) } pub(crate) fn tab_target_id_for_session_id(&self, session_id: &str) -> Option<&str> { - self.graph.tab_target_id_for_session_id(session_id) + self.top_level.tab_target_id_for_session_id(session_id) } - pub(crate) fn tab_for_page_target_id(&self, page_target_id: &str) -> Option<&TabTarget> { - self.graph.tab_for_page_target_id(page_target_id) + pub(crate) fn top_level_target_for_page_target_id( + &self, + page_target_id: &str, + ) -> Option<&TopLevelTarget> { + self.top_level + .top_level_target_for_page_target_id(page_target_id) } - pub(crate) fn tab(&self, tab_target_id: &str) -> Option<&TabTarget> { - self.graph.tab(tab_target_id) + pub(crate) fn top_level_target_for_tab_target_id( + &self, + tab_target_id: &str, + ) -> Option<&TopLevelTarget> { + let page_target_id = self.page_target_id_for_tab_target_id(tab_target_id)?; + self.top_level_target_for_page_target_id(page_target_id) } #[cfg(test)] pub(crate) fn len(&self) -> usize { - self.graph.len() + self.top_level.len() } #[cfg(test)] @@ -215,59 +227,67 @@ impl TargetRegistry { #[cfg(test)] mod tests { + use super::TargetHostParent; use crate::devtools_runtime::DevToolsTargetKind; use super::TargetRegistry; #[test] - fn target_registry_registers_distinct_page_and_tab_hosts() { + fn target_registry_registers_page_tab_hosts() { let mut registry = TargetRegistry::default(); - registry.register_tab("TAB-1".to_owned(), "TID-page".to_owned()); + registry.register_top_level_page("TID-page".to_owned(), "TAB-TID-page".to_owned()); assert_eq!(registry.len(), 1); assert_eq!( registry.tab_target_id_for_page_target_id("TID-page"), - Some("TAB-1") + Some("TAB-TID-page") ); assert_eq!( - registry.primary_page_target_id_for_tab_target_id("TAB-1"), + registry.page_target_id_for_tab_target_id("TAB-TID-page"), Some("TID-page") ); let page = registry.host("TID-page").expect("page host"); assert_eq!(page.id(), "TID-page"); assert_eq!(page.kind(), DevToolsTargetKind::Page); + assert_eq!( + page.parent(), + &TargetHostParent::Tab { + tab_target_id: "TAB-TID-page".to_owned() + } + ); - let tab = registry.host("TAB-1").expect("tab host"); - assert_eq!(tab.id(), "TAB-1"); + let tab = registry.host("TAB-TID-page").expect("tab host"); + assert_eq!(tab.id(), "TAB-TID-page"); assert_eq!(tab.kind(), DevToolsTargetKind::Tab); + assert_eq!(tab.parent(), &TargetHostParent::Browser); } #[test] fn target_registry_removes_page_and_tab_hosts_together() { let mut registry = TargetRegistry::default(); - registry.register_tab("TAB-1".to_owned(), "TID-page".to_owned()); + registry.register_top_level_page("TID-page".to_owned(), "TAB-TID-page".to_owned()); let removed = registry - .remove_tab_by_target_id("TAB-1") - .expect("removed tab target"); - assert_eq!(removed.tab_target().primary_page_target_id(), "TID-page"); - assert_eq!(removed.tab_target().id(), "TAB-1"); + .remove_top_level_page_by_tab_target_id("TAB-TID-page") + .expect("removed top-level target"); + assert_eq!(removed.top_level_target().page_target_id(), "TID-page"); + assert_eq!(removed.top_level_target().tab_target_id(), "TAB-TID-page"); assert_eq!( removed.destroyed_target_ids().collect::>(), - vec!["TID-page", "TAB-1"] + vec!["TID-page", "TAB-TID-page"] ); assert_eq!(registry.host("TID-page"), None); - assert_eq!(registry.host("TAB-1"), None); + assert_eq!(registry.host("TAB-TID-page"), None); assert_eq!(registry.tab_target_id_for_page_target_id("TID-page"), None); assert_eq!( - registry.primary_page_target_id_for_tab_target_id("TAB-1"), + registry.page_target_id_for_tab_target_id("TAB-TID-page"), None ); } #[test] - fn target_registry_registers_and_removes_worker_host_without_tab_graph() { + fn target_registry_registers_and_removes_worker_host_without_top_level_graph() { let mut registry = TargetRegistry::default(); registry.register_worker( "TID-shared-worker".to_owned(), @@ -279,6 +299,7 @@ mod tests { let host = registry.host("TID-shared-worker").expect("worker host"); assert_eq!(host.id(), "TID-shared-worker"); assert_eq!(host.kind(), DevToolsTargetKind::SharedWorker); + assert_eq!(host.parent(), &TargetHostParent::Browser); let removed = registry .remove_worker("TID-shared-worker") diff --git a/moli-protocol/src/conn/target/route.rs b/moli-protocol/src/conn/target/route.rs deleted file mode 100644 index 6b076d4641..0000000000 --- a/moli-protocol/src/conn/target/route.rs +++ /dev/null @@ -1,402 +0,0 @@ -use crate::conn::{BrowserContext, CdpConnection}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum TargetHandlerAccessMode { - Browser, - Regular, - AutoAttachOnly, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum CdpSessionRoute { - Browser, - TabTarget { - browser_context_id: String, - tab_target_id: String, - }, - ActiveTarget { - browser_context_id: String, - target_id: Option, - }, - AuxiliaryTarget { - browser_context_id: String, - target_id: String, - }, - BackgroundTarget { - browser_context_id: String, - target_id: String, - }, - SharedWorkerTarget { - browser_context_id: String, - target_id: String, - }, - DedicatedWorkerTarget { - browser_context_id: String, - target_id: String, - }, - ServiceWorkerTarget { - browser_context_id: String, - target_id: String, - }, -} - -impl CdpSessionRoute { - pub(crate) fn browser_context_id(&self) -> Option<&str> { - match self { - Self::Browser => None, - Self::TabTarget { - browser_context_id, .. - } - | Self::ActiveTarget { - browser_context_id, .. - } - | Self::AuxiliaryTarget { - browser_context_id, .. - } - | Self::BackgroundTarget { - browser_context_id, .. - } - | Self::SharedWorkerTarget { - browser_context_id, .. - } - | Self::DedicatedWorkerTarget { - browser_context_id, .. - } - | Self::ServiceWorkerTarget { - browser_context_id, .. - } => Some(browser_context_id), - } - } - - /// Whether Chromium installs a handler for this CDP domain on the routed - /// DevToolsAgentHost. - /// - /// Page and worker capability checks remain in their domain handlers for - /// now. Browser and Tab hosts are browser-only sessions in Chromium; they - /// must not fall through to whichever Page happens to be active. - pub(crate) fn supports_cdp_domain(&self, domain: &str) -> bool { - match self { - Self::Browser => matches!( - domain, - "Browser" - | "Fetch" - | "IO" - | "Security" - | "Storage" - | "SystemInfo" - | "Target" - | "Tracing" - ), - Self::TabTarget { .. } => matches!(domain, "IO" | "Target" | "Tracing"), - Self::ActiveTarget { .. } - | Self::AuxiliaryTarget { .. } - | Self::BackgroundTarget { .. } - | Self::SharedWorkerTarget { .. } - | Self::DedicatedWorkerTarget { .. } - | Self::ServiceWorkerTarget { .. } => true, - } - } - - pub(crate) fn target_handler_access_mode(&self) -> TargetHandlerAccessMode { - match self { - Self::Browser => TargetHandlerAccessMode::Browser, - Self::TabTarget { .. } - | Self::ActiveTarget { .. } - | Self::AuxiliaryTarget { .. } - | Self::BackgroundTarget { .. } => TargetHandlerAccessMode::Regular, - Self::SharedWorkerTarget { .. } - | Self::DedicatedWorkerTarget { .. } - | Self::ServiceWorkerTarget { .. } => TargetHandlerAccessMode::AutoAttachOnly, - } - } -} -impl CdpConnection { - pub(crate) fn target_handler_access_mode( - &self, - session_id: Option<&str>, - ) -> TargetHandlerAccessMode { - let Some(session_id) = session_id else { - return TargetHandlerAccessMode::Browser; - }; - self.session_route(Some(session_id)) - .map(|route| route.target_handler_access_mode()) - .unwrap_or(TargetHandlerAccessMode::Regular) - } - - pub(crate) fn target_handler_may_get_target_info( - &self, - session_id: Option<&str>, - target_id: Option<&str>, - ) -> bool { - if self.target_handler_access_mode(session_id) != TargetHandlerAccessMode::AutoAttachOnly { - return true; - } - let Some(session_id) = session_id else { - return false; - }; - let owner_target_id = self.non_browser_target_id_for_session(Some(session_id)); - let Some(target_id) = target_id.or(owner_target_id.as_deref()) else { - return false; - }; - owner_target_id.as_deref() == Some(target_id) - } - - pub(crate) fn target_handler_may_close_target( - &self, - session_id: Option<&str>, - target_id: &str, - ) -> bool { - if self.target_handler_access_mode(session_id) != TargetHandlerAccessMode::AutoAttachOnly { - return true; - } - let Some(session_id) = session_id else { - return false; - }; - self.non_browser_target_id_for_session(Some(session_id)) - .as_deref() - == Some(target_id) - || self - .target_control - .auto_attached_target_ids_for_owner(Some(session_id)) - .iter() - .any(|attached_target_id| attached_target_id == target_id) - } - - pub(crate) fn session_route(&self, session_id: Option<&str>) -> Option { - let session_id = session_id?; - if self.browser_session_ids.contains(session_id) { - return Some(CdpSessionRoute::Browser); - } - if let Some(tab_target_id) = self.tab_target_id_for_session_id(session_id) - && let Some(browser_context_id) = - self.browser_context_id_for_tab_target_id(tab_target_id) - { - return Some(CdpSessionRoute::TabTarget { - browser_context_id, - tab_target_id: tab_target_id.to_owned(), - }); - } - self.browser_contexts() - .find_map(|bc| browser_context_session_route(bc, session_id)) - } - - pub(crate) fn target_session_route_for_target_id( - &self, - target_id: &str, - ) -> Option { - if self - .primary_page_target_id_for_tab_target_id(target_id) - .is_some() - && let Some(browser_context_id) = self.browser_context_id_for_tab_target_id(target_id) - { - return Some(CdpSessionRoute::TabTarget { - browser_context_id, - tab_target_id: target_id.to_owned(), - }); - } - self.browser_contexts().find_map(|browser_context| { - if browser_context.active_target_id() == Some(target_id) { - return Some(CdpSessionRoute::ActiveTarget { - browser_context_id: browser_context.id.clone(), - target_id: None, - }); - } - if browser_context.background_target(target_id).is_some() { - return Some(CdpSessionRoute::BackgroundTarget { - browser_context_id: browser_context.id.clone(), - target_id: target_id.to_owned(), - }); - } - if browser_context.has_shared_worker_target(target_id) { - return Some(CdpSessionRoute::SharedWorkerTarget { - browser_context_id: browser_context.id.clone(), - target_id: target_id.to_owned(), - }); - } - if browser_context.has_dedicated_worker_target(target_id) { - return Some(CdpSessionRoute::DedicatedWorkerTarget { - browser_context_id: browser_context.id.clone(), - target_id: target_id.to_owned(), - }); - } - browser_context - .has_service_worker_target(target_id) - .then(|| CdpSessionRoute::ServiceWorkerTarget { - browser_context_id: browser_context.id.clone(), - target_id: target_id.to_owned(), - }) - }) - } - - /// Returns the DevToolsAgentHost owned by a non-browser session. - /// - /// Chromium's Target domain uses its handler's `owner_target_id` when - /// `Target.getTargetInfo` omits `targetId`. A tab session therefore owns - /// the stable Tab target, while a page or worker session owns its exact - /// execution target. - pub(crate) fn non_browser_target_id_for_session( - &self, - session_id: Option<&str>, - ) -> Option { - match self.session_route(session_id)? { - CdpSessionRoute::TabTarget { - tab_target_id: target_id, - .. - } - | CdpSessionRoute::AuxiliaryTarget { target_id, .. } - | CdpSessionRoute::BackgroundTarget { target_id, .. } - | CdpSessionRoute::SharedWorkerTarget { target_id, .. } - | CdpSessionRoute::DedicatedWorkerTarget { target_id, .. } - | CdpSessionRoute::ServiceWorkerTarget { target_id, .. } - | CdpSessionRoute::ActiveTarget { - target_id: Some(target_id), - .. - } => Some(target_id), - CdpSessionRoute::ActiveTarget { - browser_context_id, - target_id: None, - } => self - .browser_context_by_id(&browser_context_id) - .and_then(|browser_context| browser_context.active_target_id()) - .map(str::to_owned), - CdpSessionRoute::Browser => None, - } - } - - pub fn worker_target_id_for_session(&self, session_id: Option<&str>) -> Option { - match self.session_route(session_id)? { - CdpSessionRoute::SharedWorkerTarget { target_id, .. } - | CdpSessionRoute::DedicatedWorkerTarget { target_id, .. } - | CdpSessionRoute::ServiceWorkerTarget { target_id, .. } => Some(target_id), - _ => None, - } - } - - pub(crate) fn target_session_route_for_child_frame_id( - &self, - frame_id: &str, - ) -> Option { - self.browser_contexts().find_map(|browser_context| { - if browser_context - .active_target - .owner_state - .has_attached_child_frame_id(frame_id) - { - return Some(CdpSessionRoute::ActiveTarget { - browser_context_id: browser_context.id.clone(), - target_id: None, - }); - } - browser_context - .background_targets - .iter() - .find_map(|target| { - browser_context - .parked_target_owner_state(target.target_id()) - .is_some_and(|owner_state| { - owner_state.has_attached_child_frame_id(frame_id) - }) - .then(|| CdpSessionRoute::BackgroundTarget { - browser_context_id: browser_context.id.clone(), - target_id: target.target_id().to_owned(), - }) - }) - }) - } - - pub(crate) fn has_attached_child_frame_id(&self, frame_id: &str) -> bool { - self.browser_contexts() - .any(|browser_context| browser_context.has_attached_child_frame_id(frame_id)) - } - - #[cfg(test)] - pub(crate) fn has_background_target_session(&self, session_id: Option<&str>) -> bool { - self.background_target_route(session_id).is_some() - } - - #[cfg(test)] - pub(crate) fn background_target_id_for_session( - &self, - session_id: Option<&str>, - ) -> Option { - self.background_target_route(session_id) - .map(|(_, target_id)| target_id) - } - - pub(crate) fn background_target_route( - &self, - session_id: Option<&str>, - ) -> Option<(String, String)> { - match self.session_route(session_id)? { - CdpSessionRoute::BackgroundTarget { - browser_context_id, - target_id, - } => Some((browser_context_id, target_id)), - CdpSessionRoute::AuxiliaryTarget { - browser_context_id, - target_id, - } => self - .browser_context_by_id(&browser_context_id)? - .background_target(&target_id) - .is_some() - .then_some((browser_context_id, target_id)), - CdpSessionRoute::Browser - | CdpSessionRoute::TabTarget { .. } - | CdpSessionRoute::ActiveTarget { .. } - | CdpSessionRoute::SharedWorkerTarget { .. } - | CdpSessionRoute::DedicatedWorkerTarget { .. } - | CdpSessionRoute::ServiceWorkerTarget { .. } => None, - } - } -} -fn browser_context_session_route( - browser_context: &BrowserContext, - session_id: &str, -) -> Option { - if browser_context.active_session_id() == Some(session_id) { - return Some(CdpSessionRoute::ActiveTarget { - browser_context_id: browser_context.id.clone(), - target_id: browser_context.active_target_id().map(str::to_owned), - }); - } - - if let Some(target_id) = browser_context.auxiliary_target_id_for_session(session_id) { - return Some(CdpSessionRoute::AuxiliaryTarget { - browser_context_id: browser_context.id.clone(), - target_id: target_id.to_owned(), - }); - } - - browser_context - .background_targets - .iter() - .find(|target| target.is_session(session_id)) - .map(|target| CdpSessionRoute::BackgroundTarget { - browser_context_id: browser_context.id.clone(), - target_id: target.target_id().to_owned(), - }) - .or_else(|| { - browser_context - .shared_worker_target_id_for_session(session_id) - .map(|target_id| CdpSessionRoute::SharedWorkerTarget { - browser_context_id: browser_context.id.clone(), - target_id: target_id.to_owned(), - }) - .or_else(|| { - browser_context - .dedicated_worker_target_id_for_session(session_id) - .map(|target_id| CdpSessionRoute::DedicatedWorkerTarget { - browser_context_id: browser_context.id.clone(), - target_id: target_id.to_owned(), - }) - .or_else(|| { - browser_context - .service_worker_target_id_for_session(session_id) - .map(|target_id| CdpSessionRoute::ServiceWorkerTarget { - browser_context_id: browser_context.id.clone(), - target_id: target_id.to_owned(), - }) - }) - }) - }) -} diff --git a/moli-protocol/src/conn/target/session.rs b/moli-protocol/src/conn/target/session.rs index e6df6a11a7..af38fbec73 100644 --- a/moli-protocol/src/conn/target/session.rs +++ b/moli-protocol/src/conn/target/session.rs @@ -355,18 +355,6 @@ impl TargetSessionRegistry { sorted_index_values(self.attached_sessions_by_target.get(target_id)) } - pub(crate) fn target_has_waiting_for_debugger_session(&self, target_id: &str) -> bool { - self.attached_sessions_by_target - .get(target_id) - .is_some_and(|session_ids| { - session_ids.iter().any(|session_id| { - self.attached_sessions - .get(session_id) - .is_some_and(|session| session.waiting_for_debugger) - }) - }) - } - pub(crate) fn attached_session_owner_session_id(&self, session_id: &str) -> Option<&str> { self.attached_sessions .get(session_id)? @@ -748,31 +736,6 @@ mod tests { ); } - #[test] - fn target_session_registry_scopes_waiting_for_debugger_to_attached_target() { - let mut registry = TargetSessionRegistry::default(); - registry.commit_attached_session(PreparedAttachSession::new( - "SID-waiting".to_owned(), - Some("SID-owner"), - "TID-waiting", - None, - true, - true, - )); - registry.commit_attached_session(PreparedAttachSession::new( - "SID-running".to_owned(), - Some("SID-owner"), - "TID-running", - None, - true, - false, - )); - - assert!(registry.target_has_waiting_for_debugger_session("TID-waiting")); - assert!(!registry.target_has_waiting_for_debugger_session("TID-running")); - assert!(!registry.target_has_waiting_for_debugger_session("TID-unattached")); - } - #[test] fn target_session_registry_clears_attached_session_and_auto_attached_indexes() { let mut registry = TargetSessionRegistry::default(); diff --git a/moli-protocol/src/conn/target/session_binding.rs b/moli-protocol/src/conn/target/session_binding.rs deleted file mode 100644 index 4863962c5c..0000000000 --- a/moli-protocol/src/conn/target/session_binding.rs +++ /dev/null @@ -1,1072 +0,0 @@ -use crate::conn::CdpConnection; -use crate::devtools_runtime::DevToolsTargetInfo; - -use super::{ - CdpSessionRoute, PreparedTargetAttach, TargetAttachRollbackPlan, TargetAttachSessionCommit, - TargetAutoAttachedSessionDetachPlan, TargetBindingCleanupAction, TargetBindingCleanupPlan, - TargetClosureCleanupPlan, TargetEventPlan, TargetSessionDetachCleanupPlan, -}; - -impl CdpConnection { - pub(crate) fn is_browser_session_id(&self, session_id: Option<&str>) -> bool { - let Some(session_id) = session_id else { - return false; - }; - self.browser_session_ids.contains(session_id) - } - - #[cfg(test)] - pub(crate) fn register_browser_session(&mut self, session_id: String) { - self.browser_session_ids.insert(session_id); - } - - pub(crate) fn remove_browser_session(&mut self, session_id: &str) -> bool { - self.browser_session_ids.remove(session_id) - } - - fn clear_browser_session_owner_state(&mut self, session_id: &str) -> bool { - if !self.remove_browser_session(session_id) { - return false; - } - self.download_behavior - .set_browser_events_enabled_for_session(Some(session_id), false); - self.cancel_tracing_for_session_owner(Some(session_id)); - self.clear_auto_attach_owner(Some(session_id)); - self.clear_target_discovery_for_owner(Some(session_id)); - self.set_service_worker_pause_on_start_owner(Some(session_id), false); - self.set_dedicated_worker_pause_on_start_owner(Some(session_id), false); - true - } - - pub(crate) fn detach_browser_session_owner_without_event( - &mut self, - session_id: &str, - ) -> Option { - if !self.clear_browser_session_owner_state(session_id) { - return None; - } - let rollback_plan = self.rollback_attached_session_without_event(session_id); - Some(rollback_plan) - } - - pub(crate) fn detach_browser_session_owner_event_plan( - &mut self, - session_id: &str, - ) -> Option { - let owner_session_id = self - .target_control - .attached_session_owner_session_id(session_id) - .map(str::to_owned); - if !self.clear_browser_session_owner_state(session_id) { - return None; - } - let plan = self.target_control.detach_attached_session_event_plan( - session_id, - None, - owner_session_id.as_deref(), - ); - self.clear_detached_target_session_owner_state(session_id); - plan - } - - pub(crate) fn release_root_target_frontend_owner_without_event(&mut self) { - self.download_behavior - .set_browser_events_enabled_for_session(None, false); - self.cancel_tracing_for_session_owner(None); - self.clear_auto_attach_owner(None); - self.clear_target_discovery_for_owner(None); - self.target_control.remove_owner(None); - } - - pub(crate) fn release_primary_target_session_binding_without_event( - &mut self, - session_id: &str, - ) -> bool { - let released = self - .browser_context - .as_mut() - .is_some_and(|browser_context| { - browser_context - .release_primary_session_binding_preserving_frontend_state(session_id) - }); - if released { - self.rollback_attached_session_without_event(session_id); - } - released - } - - #[cfg(test)] - pub(crate) fn register_auto_attached_session( - &mut self, - session_id: String, - owner_session_id: Option<&str>, - ) { - let target_id = self.non_browser_target_id_for_session(Some(&session_id)); - self.register_auto_attached_session_for_target( - session_id, - owner_session_id, - target_id.as_deref(), - ); - } - - #[cfg(test)] - pub(crate) fn register_auto_attached_session_for_target( - &mut self, - session_id: String, - owner_session_id: Option<&str>, - target_id: Option<&str>, - ) { - let route = self.session_route(Some(&session_id)); - self.target_control.commit_auto_attached_session_for_target( - session_id, - owner_session_id, - target_id, - route, - false, - ); - } - - pub(crate) fn commit_prepared_attach_event_plan( - &mut self, - prepared: PreparedTargetAttach, - ) -> TargetEventPlan { - self.commit_prepared_attach_event_plan_with_attached_state_delta(prepared, true) - } - - pub(crate) fn commit_prepared_dedicated_worker_attach_event_plan( - &mut self, - prepared: PreparedTargetAttach, - ) -> TargetEventPlan { - self.commit_prepared_attach_event_plan_with_attached_state_delta(prepared, false) - } - - fn commit_prepared_attach_event_plan_with_attached_state_delta( - &mut self, - prepared: PreparedTargetAttach, - emit_attached_state_delta: bool, - ) -> TargetEventPlan { - let (target_id, target_info, sessions) = prepared.into_parts(); - let should_emit_attached_state_delta = emit_attached_state_delta && !sessions.is_empty(); - let attached_state_delta_plan = should_emit_attached_state_delta - .then(|| self.exact_target_info_changed_event_plan_for_target_delta(&target_id)); - let mut plan = TargetEventPlan::default(); - for session in sessions { - let (session_id, owner_session_id, route, auto_attached, waiting_for_debugger) = - session.into_parts(); - if auto_attached { - self.target_control - .ensure_owner(owner_session_id.as_deref()); - } - plan.extend(self.target_control.commit_attached_session_event( - session_id, - owner_session_id.as_deref(), - &target_id, - route, - auto_attached, - waiting_for_debugger, - target_info.clone(), - )); - } - if let Some(attached_state_delta_plan) = attached_state_delta_plan { - plan.extend(attached_state_delta_plan); - } - plan - } - - pub(crate) fn prepare_auto_attach_session_commit( - &self, - session_id: impl Into, - owner_session_id: Option, - waiting_for_debugger: bool, - ) -> TargetAttachSessionCommit { - let session_id = session_id.into(); - let route = self.session_route(Some(&session_id)); - TargetAttachSessionCommit::auto_attached(session_id, owner_session_id, waiting_for_debugger) - .with_route(route) - } - - pub(crate) fn prepare_direct_attach_session_commit( - &self, - session_id: impl Into, - owner_session_id: Option, - waiting_for_debugger: bool, - ) -> TargetAttachSessionCommit { - let session_id = session_id.into(); - let route = self.session_route(Some(&session_id)); - TargetAttachSessionCommit::direct(session_id, owner_session_id, waiting_for_debugger) - .with_route(route) - } - - pub(crate) fn attach_tab_target_session_event_plan( - &mut self, - session_id: String, - owner_session_id: Option<&str>, - tab_target_id: &str, - auxiliary: bool, - ) -> Result { - if !self.assign_session_to_tab_target(tab_target_id, session_id.clone(), auxiliary) { - return Err("UnknownTargetId"); - } - let prepared_session = self.prepare_direct_attach_session_commit( - session_id, - owner_session_id.map(str::to_owned), - false, - ); - let Some(target_info) = self.tab_target_info(tab_target_id) else { - self.rollback_prepared_attach_session_sync_without_event(&prepared_session); - return Err("UnknownTargetId"); - }; - if prepared_session.route().is_none() { - self.rollback_prepared_attach_session_sync_without_event(&prepared_session); - return Err("InvalidSessionId"); - } - Ok( - self.commit_prepared_attach_event_plan(PreparedTargetAttach::new( - tab_target_id, - target_info, - [prepared_session], - )), - ) - } - - pub(crate) fn attach_shared_worker_target_session_event_plan( - &mut self, - session_id: String, - owner_session_id: Option<&str>, - target_id: &str, - ) -> Result { - let session_id_for_binding = session_id.clone(); - let target_info = { - let Some(bc) = self.browser_context.as_mut() else { - return Err("BrowserContextNotLoaded"); - }; - if !bc.assign_session_to_shared_worker_target(target_id, session_id_for_binding) { - return Err("UnknownTargetId"); - } - bc.devtools_target_info(target_id) - }; - let prepared_session = self.prepare_direct_attach_session_commit( - session_id, - owner_session_id.map(str::to_owned), - false, - ); - let Some(target_info) = target_info else { - self.rollback_prepared_attach_session_sync_without_event(&prepared_session); - return Err("UnknownTargetId"); - }; - if prepared_session.route().is_none() { - self.rollback_prepared_attach_session_sync_without_event(&prepared_session); - return Err("InvalidSessionId"); - } - Ok( - self.commit_prepared_attach_event_plan(PreparedTargetAttach::new( - target_id, - target_info, - [prepared_session], - )), - ) - } - - pub(crate) fn attach_service_worker_target_session_event_plan( - &mut self, - session_id: String, - owner_session_id: Option<&str>, - target_id: &str, - ) -> Result { - let session_id_for_binding = session_id.clone(); - let target_info = { - let Some(bc) = self.browser_context.as_mut() else { - return Err("BrowserContextNotLoaded"); - }; - if !bc.assign_session_to_service_worker_target(target_id, session_id_for_binding) { - return Err("UnknownTargetId"); - } - bc.devtools_target_info(target_id) - }; - let prepared_session = self.prepare_direct_attach_session_commit( - session_id, - owner_session_id.map(str::to_owned), - false, - ); - let Some(target_info) = target_info else { - self.rollback_prepared_attach_session_sync_without_event(&prepared_session); - return Err("UnknownTargetId"); - }; - if prepared_session.route().is_none() { - self.rollback_prepared_attach_session_sync_without_event(&prepared_session); - return Err("InvalidSessionId"); - } - Ok( - self.commit_prepared_attach_event_plan(PreparedTargetAttach::new( - target_id, - target_info, - [prepared_session], - )), - ) - } - - pub(crate) fn attach_dedicated_worker_target_session_event_plan( - &mut self, - session_id: String, - owner_session_id: Option<&str>, - target_id: &str, - ) -> Result { - let session_id_for_binding = session_id.clone(); - let target_info = { - let Some(bc) = self.browser_context.as_mut() else { - return Err("BrowserContextNotLoaded"); - }; - if !bc.assign_session_to_dedicated_worker_target(target_id, session_id_for_binding) { - return Err("UnknownTargetId"); - } - bc.devtools_target_info(target_id) - }; - let prepared_session = self.prepare_direct_attach_session_commit( - session_id, - owner_session_id.map(str::to_owned), - false, - ); - let Some(target_info) = target_info else { - self.rollback_prepared_attach_session_sync_without_event(&prepared_session); - return Err("UnknownTargetId"); - }; - if prepared_session.route().is_none() { - self.rollback_prepared_attach_session_sync_without_event(&prepared_session); - return Err("InvalidSessionId"); - } - Ok( - self.commit_prepared_dedicated_worker_attach_event_plan(PreparedTargetAttach::new( - target_id, - target_info, - [prepared_session], - )), - ) - } - - pub(crate) fn prepare_auto_attached_tab_session_binding( - &mut self, - tab_target_id: &str, - session_id: String, - owner_session_id: Option<&str>, - ) -> bool { - self.assign_session_to_tab_target(tab_target_id, session_id, owner_session_id.is_some()) - } - - pub(crate) fn prepare_auto_attached_page_session_binding( - &mut self, - target_id: &str, - session_id: String, - ) -> bool { - self.browser_context - .as_mut() - .is_some_and(|bc| bc.assign_auto_attached_session_to_target(target_id, session_id)) - } - - pub(crate) fn prepare_auto_attached_page_session_binding_in_browser_context( - &mut self, - browser_context_id: &str, - target_id: &str, - session_id: String, - ) -> bool { - self.browser_context_by_id_mut(browser_context_id) - .is_some_and(|bc| bc.assign_auto_attached_session_to_target(target_id, session_id)) - } - - pub(crate) fn prepare_auto_attached_shared_worker_session_binding( - &mut self, - target_id: &str, - session_id: String, - ) -> bool { - self.browser_context - .as_mut() - .is_some_and(|bc| bc.assign_session_to_shared_worker_target(target_id, session_id)) - } - - pub(crate) fn prepare_auto_attached_dedicated_worker_session_binding( - &mut self, - target_id: &str, - session_id: String, - ) -> bool { - self.browser_context - .as_mut() - .is_some_and(|bc| bc.assign_session_to_dedicated_worker_target(target_id, session_id)) - } - - pub(crate) fn prepare_auto_attached_service_worker_session_binding( - &mut self, - target_id: &str, - session_id: String, - ) -> bool { - self.browser_context - .as_mut() - .is_some_and(|bc| bc.assign_session_to_service_worker_target(target_id, session_id)) - } - - pub(crate) fn prepare_auto_attached_shared_worker_session_binding_info_in_browser_context( - &mut self, - browser_context_id: &str, - target_id: &str, - session_id: String, - ) -> Option { - let bc = self.browser_context_by_id_mut(browser_context_id)?; - if !bc.assign_session_to_shared_worker_target(target_id, session_id) { - return None; - } - bc.devtools_target_info(target_id) - } - - pub(crate) fn prepare_auto_attached_dedicated_worker_session_binding_info_in_browser_context( - &mut self, - browser_context_id: &str, - target_id: &str, - session_id: String, - ) -> Option { - let bc = self.browser_context_by_id_mut(browser_context_id)?; - if !bc.assign_session_to_dedicated_worker_target(target_id, session_id) { - return None; - } - bc.devtools_target_info(target_id) - } - - pub(crate) fn prepare_auto_attached_service_worker_session_binding_info_in_browser_context( - &mut self, - browser_context_id: &str, - target_id: &str, - session_id: String, - ) -> Option { - let bc = self.browser_context_by_id_mut(browser_context_id)?; - if !bc.assign_session_to_service_worker_target(target_id, session_id) { - return None; - } - bc.devtools_target_info(target_id) - } - - pub(crate) fn prepare_auto_attached_service_worker_session_binding_info( - &mut self, - target_id: &str, - session_id: String, - ) -> Option { - let bc = self.browser_context.as_mut()?; - if !bc.assign_session_to_service_worker_target(target_id, session_id) { - return None; - } - bc.devtools_target_info(target_id) - } - - pub(crate) fn commit_browser_attached_session_event_plan( - &mut self, - session_id: String, - owner_session_id: Option<&str>, - target_id: &str, - target_info: DevToolsTargetInfo, - ) -> TargetEventPlan { - self.browser_session_ids.insert(session_id.clone()); - self.target_control.commit_attached_session_event( - session_id, - owner_session_id, - target_id, - Some(CdpSessionRoute::Browser), - false, - false, - target_info, - ) - } - - pub(crate) fn rollback_attached_session_without_event( - &mut self, - session_id: &str, - ) -> TargetEventPlan { - let plan = self - .target_control - .rollback_attached_session_without_event(session_id); - for session_id in plan.rolled_back_session_ids() { - self.clear_detached_target_session_owner_state(session_id); - } - plan - } - - pub(crate) fn detach_known_session_event_plan( - &mut self, - target_id: &str, - session_id: &str, - reason: Option<&str>, - parent_session_id: Option<&str>, - ) -> TargetEventPlan { - self.detach_known_session_event_plan_with_attached_state_delta( - target_id, - session_id, - reason, - parent_session_id, - true, - ) - } - - fn detach_known_session_event_plan_with_attached_state_delta( - &mut self, - target_id: &str, - session_id: &str, - reason: Option<&str>, - parent_session_id: Option<&str>, - emit_attached_state_delta: bool, - ) -> TargetEventPlan { - let attached_state_delta_plan = emit_attached_state_delta - .then(|| self.exact_target_info_changed_event_plan_for_target_delta(target_id)); - let mut plan = self.target_control.detach_known_session_event_plan( - target_id, - session_id, - reason, - parent_session_id, - ); - self.clear_detached_target_session_owner_state(session_id); - if let Some(attached_state_delta_plan) = attached_state_delta_plan { - plan.extend(attached_state_delta_plan); - } - plan - } - - pub(crate) fn detach_target_closure_cleanup_event_plan( - &mut self, - cleanup_plan: TargetClosureCleanupPlan, - parent_session_id: Option<&str>, - ) -> TargetEventPlan { - let plan = self - .target_control - .detach_target_closure_cleanup_event_plan(cleanup_plan, parent_session_id); - for session in plan.detached_sessions() { - self.clear_detached_target_session_owner_state(session.session_id()); - } - plan - } - - pub(crate) async fn rollback_prepared_attach_session_without_event_async( - &mut self, - prepared: &TargetAttachSessionCommit, - ) -> TargetEventPlan { - self.rollback_attached_session_with_cleanup_without_event_async( - TargetAttachRollbackPlan::from_prepared_attach_session(prepared), - ) - .await - } - - pub(crate) fn rollback_prepared_attach_session_sync_without_event( - &mut self, - prepared: &TargetAttachSessionCommit, - ) -> TargetEventPlan { - self.rollback_attached_session_with_cleanup_without_event_sync( - TargetAttachRollbackPlan::from_prepared_attach_session(prepared), - ) - } - - fn rollback_attached_session_with_cleanup_without_event_sync( - &mut self, - rollback_plan: TargetAttachRollbackPlan, - ) -> TargetEventPlan { - if let Some(cleanup_plan) = rollback_plan.cleanup_plan() { - if matches!( - cleanup_plan.action(), - TargetBindingCleanupAction::ActiveTargetPrimaryAutoAttached - ) { - debug_assert!( - false, - "active target rollback requires async binding cleanup" - ); - } else { - self.execute_target_binding_cleanup_without_event_sync(cleanup_plan); - } - } - self.rollback_attached_session_without_event(rollback_plan.session_id()) - } - - fn execute_target_binding_cleanup_without_event_sync( - &mut self, - cleanup_plan: &TargetBindingCleanupPlan, - ) { - match cleanup_plan.action() { - TargetBindingCleanupAction::BackgroundTargetPrimaryAutoAttached { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.clear_background_target_primary_auto_attached_session( - cleanup_plan.session_id(), - ); - } - } - TargetBindingCleanupAction::AuxiliaryTarget { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.remove_auxiliary_session(cleanup_plan.session_id()); - } - } - TargetBindingCleanupAction::TabTarget { .. } => { - self.remove_tab_session(cleanup_plan.session_id()); - } - TargetBindingCleanupAction::SharedWorkerTarget { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.detach_shared_worker_target_session(cleanup_plan.session_id()); - } - } - TargetBindingCleanupAction::DedicatedWorkerTarget { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.detach_dedicated_worker_target_session(cleanup_plan.session_id()); - } - } - TargetBindingCleanupAction::ServiceWorkerTarget { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.detach_service_worker_target_session(cleanup_plan.session_id()); - } - } - TargetBindingCleanupAction::None - | TargetBindingCleanupAction::ActiveTargetPrimaryAutoAttached => {} - } - } - - async fn rollback_attached_session_with_cleanup_without_event_async( - &mut self, - rollback_plan: TargetAttachRollbackPlan, - ) -> TargetEventPlan { - let Some(browser_context_id) = rollback_plan.browser_context_id().map(str::to_owned) else { - return self.rollback_attached_session_without_event(rollback_plan.session_id()); - }; - if !self - .activate_browser_context_by_id_async(&browser_context_id) - .await - { - return self.rollback_attached_session_without_event(rollback_plan.session_id()); - } - - if let Some(cleanup_plan) = rollback_plan.cleanup_plan() { - self.execute_target_binding_cleanup_without_event_async(cleanup_plan) - .await; - } - self.rollback_attached_session_without_event(rollback_plan.session_id()) - } - - pub(crate) fn auto_attached_session_detach_plan( - &self, - session_id: &str, - ) -> TargetAutoAttachedSessionDetachPlan { - TargetAutoAttachedSessionDetachPlan::from_session_route( - session_id, - self.session_route(Some(session_id)), - ) - } - - pub(crate) fn rollback_auto_attached_session_detach_plan_without_event( - &mut self, - detach_plan: &TargetAutoAttachedSessionDetachPlan, - ) -> TargetEventPlan { - self.rollback_attached_session_without_event(detach_plan.session_id()) - } - - pub(crate) async fn execute_target_binding_cleanup_without_event_async( - &mut self, - cleanup_plan: &TargetBindingCleanupPlan, - ) { - match cleanup_plan.action() { - TargetBindingCleanupAction::ActiveTargetPrimaryAutoAttached => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc - .clear_active_target_primary_auto_attached_session_async() - .await; - } - } - TargetBindingCleanupAction::BackgroundTargetPrimaryAutoAttached { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.clear_background_target_primary_auto_attached_session( - cleanup_plan.session_id(), - ); - } - } - TargetBindingCleanupAction::AuxiliaryTarget { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.remove_auxiliary_session(cleanup_plan.session_id()); - } - } - TargetBindingCleanupAction::TabTarget { .. } => { - self.remove_tab_session(cleanup_plan.session_id()); - } - TargetBindingCleanupAction::SharedWorkerTarget { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.detach_shared_worker_target_session(cleanup_plan.session_id()); - } - } - TargetBindingCleanupAction::DedicatedWorkerTarget { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.detach_dedicated_worker_target_session(cleanup_plan.session_id()); - } - } - TargetBindingCleanupAction::ServiceWorkerTarget { .. } => { - if let Some(bc) = self.browser_context.as_mut() { - let _ = bc.detach_service_worker_target_session(cleanup_plan.session_id()); - } - } - TargetBindingCleanupAction::None => {} - } - } - - pub(crate) async fn execute_target_binding_cleanup_for_session_without_event_async( - &mut self, - session_id: &str, - ) -> bool { - let Some(route) = self.session_route(Some(session_id)) else { - return false; - }; - self.cancel_tracing_for_session_owner_async(Some(session_id)) - .await; - let cleanup_plan = TargetBindingCleanupPlan::from_route(session_id, &route); - self.execute_target_binding_cleanup_without_event_async(&cleanup_plan) - .await; - true - } - - pub(crate) async fn detach_session_with_binding_cleanup_event_plan_async( - &mut self, - cleanup_plan: TargetSessionDetachCleanupPlan, - ) -> TargetEventPlan { - let session_id = cleanup_plan.session_id().to_owned(); - let target_id = cleanup_plan.target_id().to_owned(); - let reason = cleanup_plan.reason().map(str::to_owned); - let parent_session_id = cleanup_plan - .parent_session_id() - .or_else(|| { - self.target_control - .attached_session_owner_session_id(&session_id) - }) - .map(str::to_owned); - let _ = self - .execute_target_binding_cleanup_for_session_without_event_async(&session_id) - .await; - self.detach_known_session_event_plan( - &target_id, - &session_id, - reason.as_deref(), - parent_session_id.as_deref(), - ) - } - - pub(crate) async fn detach_dedicated_worker_session_with_binding_cleanup_event_plan_async( - &mut self, - cleanup_plan: TargetSessionDetachCleanupPlan, - ) -> TargetEventPlan { - let session_id = cleanup_plan.session_id().to_owned(); - let target_id = cleanup_plan.target_id().to_owned(); - let reason = cleanup_plan.reason().map(str::to_owned); - let parent_session_id = cleanup_plan - .parent_session_id() - .or_else(|| { - self.target_control - .attached_session_owner_session_id(&session_id) - }) - .map(str::to_owned); - let _ = self - .execute_target_binding_cleanup_for_session_without_event_async(&session_id) - .await; - self.detach_known_session_event_plan_with_attached_state_delta( - &target_id, - &session_id, - reason.as_deref(), - parent_session_id.as_deref(), - false, - ) - } - - pub(crate) fn detach_dedicated_worker_session_after_target_removal_event_plan( - &mut self, - cleanup_plan: TargetSessionDetachCleanupPlan, - ) -> TargetEventPlan { - let parent_session_id = cleanup_plan - .parent_session_id() - .or_else(|| { - self.target_control - .attached_session_owner_session_id(cleanup_plan.session_id()) - }) - .map(str::to_owned); - self.detach_known_session_event_plan_with_attached_state_delta( - cleanup_plan.target_id(), - cleanup_plan.session_id(), - cleanup_plan.reason(), - parent_session_id.as_deref(), - false, - ) - } - - pub(crate) async fn detach_target_sessions_with_binding_cleanup_event_plan_async( - &mut self, - cleanup_plan: TargetClosureCleanupPlan, - parent_session_id: Option<&str>, - ) -> TargetEventPlan { - let target_id = cleanup_plan.target_id().to_owned(); - let reason = cleanup_plan.reason().map(str::to_owned); - let session_ids = cleanup_plan - .session_ids() - .map(str::to_owned) - .collect::>(); - let mut plan = TargetEventPlan::default(); - for session_id in session_ids { - if self - .execute_target_binding_cleanup_for_session_without_event_async(&session_id) - .await - { - plan.extend(self.detach_known_session_event_plan( - &target_id, - &session_id, - reason.as_deref(), - parent_session_id, - )); - } - } - plan - } - - pub(crate) async fn detach_active_target_session_binding_event_plan_async( - &mut self, - cleanup_plan: TargetSessionDetachCleanupPlan, - ) -> Result { - let session_id = cleanup_plan.session_id().to_owned(); - let target_id = cleanup_plan.target_id().to_owned(); - let reason = cleanup_plan.reason().map(str::to_owned); - let parent_session_id = cleanup_plan.parent_session_id().map(str::to_owned); - self.clear_active_target_session_binding_for_detach_async() - .await?; - Ok(self.detach_known_session_event_plan( - &target_id, - &session_id, - reason.as_deref(), - parent_session_id.as_deref(), - )) - } - - pub(crate) async fn detach_background_target_session_binding_event_plan_async( - &mut self, - cleanup_plan: TargetSessionDetachCleanupPlan, - ) -> Result, String> { - let session_id = cleanup_plan.session_id().to_owned(); - let target_id = cleanup_plan.target_id().to_owned(); - let reason = cleanup_plan.reason().map(str::to_owned); - let parent_session_id = cleanup_plan.parent_session_id().map(str::to_owned); - let Some(detached_target_id) = self - .clear_background_target_session_binding_for_detach_async(&session_id) - .await? - else { - return Ok(None); - }; - debug_assert_eq!(detached_target_id, target_id); - Ok(Some(self.detach_known_session_event_plan( - &target_id, - &session_id, - reason.as_deref(), - parent_session_id.as_deref(), - ))) - } - - pub(crate) fn background_target_session_detach_cleanup_plans( - &self, - reason: Option<&str>, - parent_session_id: Option<&str>, - ) -> Vec { - self.browser_context - .as_ref() - .map(|bc| { - bc.background_targets - .iter() - .filter_map(|target| { - Some(TargetSessionDetachCleanupPlan::new( - target.target_id().to_owned(), - target.session_id()?.to_owned(), - reason, - parent_session_id, - )) - }) - .collect() - }) - .unwrap_or_default() - } - - pub(crate) async fn clear_active_target_session_binding_for_detach_async( - &mut self, - ) -> Result<(), String> { - let Some(bc) = self.browser_context.as_mut() else { - return Ok(()); - }; - bc.clear_active_target_session_binding_and_scoped_state_async() - .await - } - - pub(crate) async fn clear_background_target_session_binding_for_detach_async( - &mut self, - session_id: &str, - ) -> Result, String> { - let Some(bc) = self.browser_context.as_mut() else { - return Ok(None); - }; - bc.clear_background_target_session_binding_and_scoped_state_async(session_id) - .await - } - - pub(crate) async fn detach_all_shared_worker_target_sessions_event_plan_async( - &mut self, - reason: Option<&str>, - parent_session_id: Option<&str>, - ) -> TargetEventPlan { - let target_ids = self - .browser_context - .as_ref() - .map(|bc| { - bc.shared_worker_targets - .values() - .filter(|target| target.has_session()) - .map(|target| target.target_id.clone()) - .collect::>() - }) - .unwrap_or_default(); - let mut plan = TargetEventPlan::default(); - for target_id in target_ids { - let session_ids = self - .browser_context - .as_ref() - .and_then(|bc| bc.shared_worker_target(&target_id)) - .map(|target| target.session_ids()) - .unwrap_or_default(); - for session_id in session_ids { - if self - .execute_target_binding_cleanup_for_session_without_event_async(&session_id) - .await - { - plan.extend(self.detach_known_session_event_plan( - &target_id, - &session_id, - reason, - parent_session_id, - )); - } - } - } - plan - } - - pub(crate) async fn detach_all_service_worker_target_sessions_event_plan_async( - &mut self, - reason: Option<&str>, - parent_session_id: Option<&str>, - ) -> TargetEventPlan { - let target_ids = self - .browser_context - .as_ref() - .map(|bc| { - bc.service_worker_targets - .values() - .filter(|target| target.has_session()) - .map(|target| target.target_id.clone()) - .collect::>() - }) - .unwrap_or_default(); - let mut plan = TargetEventPlan::default(); - for target_id in target_ids { - let session_ids = self - .browser_context - .as_ref() - .and_then(|bc| bc.service_worker_target(&target_id)) - .map(|target| target.session_ids()) - .unwrap_or_default(); - for session_id in session_ids { - if self - .execute_target_binding_cleanup_for_session_without_event_async(&session_id) - .await - { - plan.extend(self.detach_known_session_event_plan( - &target_id, - &session_id, - reason, - parent_session_id, - )); - } - } - } - plan - } - - pub(crate) async fn detach_all_dedicated_worker_target_sessions_event_plan_async( - &mut self, - reason: Option<&str>, - parent_session_id: Option<&str>, - ) -> TargetEventPlan { - let target_ids = self - .browser_context - .as_ref() - .map(|bc| { - bc.dedicated_worker_targets - .values() - .filter(|target| target.has_session()) - .map(|target| target.target_id.clone()) - .collect::>() - }) - .unwrap_or_default(); - let mut plan = TargetEventPlan::default(); - for target_id in target_ids { - let session_ids = self - .browser_context - .as_ref() - .and_then(|bc| bc.dedicated_worker_target(&target_id)) - .map(|target| target.session_ids()) - .unwrap_or_default(); - for session_id in session_ids { - if self - .execute_target_binding_cleanup_for_session_without_event_async(&session_id) - .await - { - plan.extend(self.detach_known_session_event_plan( - &target_id, - &session_id, - reason, - parent_session_id, - )); - } - } - } - plan - } - - fn clear_detached_target_session_owner_state(&mut self, session_id: &str) { - self.download_behavior - .set_browser_events_enabled_for_session(Some(session_id), false); - self.cancel_tracing_for_session_owner(Some(session_id)); - self.clear_auto_attach_owner(Some(session_id)); - self.set_service_worker_pause_on_start_owner(Some(session_id), false); - self.target_control.remove_owner(Some(session_id)); - } - - pub(crate) fn attached_sessions_for_target(&self, target_id: &str) -> Vec { - self.target_control.attached_sessions_for_target(target_id) - } - - pub(crate) fn target_has_waiting_for_debugger_session(&self, target_id: &str) -> bool { - self.target_control - .target_has_waiting_for_debugger_session(target_id) - } - - pub(crate) fn auto_attached_sessions_for_owner( - &self, - owner_session_id: Option<&str>, - ) -> Vec { - self.target_control - .auto_attached_sessions_for_owner(owner_session_id) - } - - pub(crate) fn attached_session_cascade_for_owner( - &self, - owner_session_id: Option<&str>, - ) -> Vec { - self.target_control - .attached_session_cascade_for_owner(owner_session_id) - } - - pub(crate) fn attached_session_cascade_for_root_frontend(&self) -> Vec { - self.target_control - .attached_session_cascade_for_root_frontend() - } - - pub(crate) fn auto_attached_session_cascade_for_owner( - &self, - owner_session_id: Option<&str>, - ) -> Vec { - self.target_control - .auto_attached_session_cascade_for_owner(owner_session_id) - } -} diff --git a/moli-protocol/src/conn/target/transaction.rs b/moli-protocol/src/conn/target/transaction.rs index b3a02d31b5..ec8bc80751 100644 --- a/moli-protocol/src/conn/target/transaction.rs +++ b/moli-protocol/src/conn/target/transaction.rs @@ -1,5 +1,5 @@ use crate::conn::{BackgroundProtocolEvent, CdpSessionRoute}; -use crate::devtools_runtime::DevToolsTargetInfo; +use crate::devtools_runtime::{DevToolsTargetId, DevToolsTargetInfo, TargetLifecycleEvent}; use super::{CommittedAttachSession, DetachedTargetSession, TargetHostDelta}; @@ -78,6 +78,15 @@ impl TargetEventPlan { .extend(other.rolled_back_session_ids); } + pub(crate) fn without_target_lifecycle_automation_sidecars(mut self) -> Self { + self.events = self + .events + .into_iter() + .map(BackgroundProtocolEvent::without_target_lifecycle_automation_sidecar) + .collect(); + self + } + #[cfg(test)] pub(crate) fn committed_sessions(&self) -> &[CommittedAttachSession] { &self.committed_sessions @@ -175,6 +184,27 @@ impl PreparedTargetHostClosure { pub(crate) fn into_parts(self) -> (Vec, Vec) { (self.detached_info_deltas, self.destroyed_deltas) } + + pub(crate) fn destroyed_target_lifecycle_event( + &self, + target_id: &str, + ) -> Option { + let target_info = self.destroyed_deltas.iter().find_map(|prepared| { + (prepared.target_id() == target_id) + .then_some(prepared.snapshot.as_ref()) + .flatten() + })?; + Some(TargetLifecycleEvent { + target_id: target_info + .target_id + .clone() + .unwrap_or_else(|| DevToolsTargetId::from(target_id)), + browser_context_id: target_info.browser_context_id.clone(), + kind: target_info.kind, + url: target_info.url.clone(), + target_info: Some(target_info.clone()), + }) + } } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/moli-protocol/src/conn/target/worker_auto_attach.rs b/moli-protocol/src/conn/target/worker_auto_attach.rs deleted file mode 100644 index 4b058c7770..0000000000 --- a/moli-protocol/src/conn/target/worker_auto_attach.rs +++ /dev/null @@ -1,188 +0,0 @@ -use crate::conn::{ - BrowserContext, CdpConnection, ServiceWorkerAutoAttachRelatedOwner, - ServiceWorkerAutoAttachRelatedOwnerSession, -}; - -impl CdpConnection { - pub(crate) fn set_service_worker_auto_attach_related_owner( - &mut self, - owner_session_id: Option<&str>, - browser_context_id: &str, - registration_id: u64, - base_version_id: u64, - script_url: String, - scope_url: String, - allow_service_worker_targets: bool, - wait_for_debugger_on_start: bool, - ) { - let owner_key = owner_session_id.map(str::to_owned); - self.service_worker_auto_attach_related_owners - .retain(|owner| { - !(owner.owner_session_id == owner_key - && owner.browser_context_id == browser_context_id - && owner.registration_id == registration_id - && owner.script_url == script_url - && owner.scope_url == scope_url) - }); - self.service_worker_auto_attach_related_owners - .push(ServiceWorkerAutoAttachRelatedOwner { - owner_session_id: owner_key, - browser_context_id: browser_context_id.to_owned(), - registration_id, - base_version_id, - script_url, - scope_url, - allow_service_worker_targets, - wait_for_debugger_on_start, - }); - self.sync_service_worker_related_pause_on_start_for_devtools(); - } - - pub(crate) fn replace_service_worker_auto_attach_related_owner( - &mut self, - owner_session_id: Option<&str>, - browser_context_id: &str, - registration_id: u64, - base_version_id: u64, - script_url: String, - scope_url: String, - allow_service_worker_targets: bool, - wait_for_debugger_on_start: bool, - ) { - self.clear_auto_attach_owner(owner_session_id); - self.set_service_worker_auto_attach_related_owner( - owner_session_id, - browser_context_id, - registration_id, - base_version_id, - script_url, - scope_url, - allow_service_worker_targets, - wait_for_debugger_on_start, - ); - } - - pub(crate) fn clear_service_worker_auto_attach_related_owner( - &mut self, - owner_session_id: Option<&str>, - ) { - let owner_key = owner_session_id.map(str::to_owned); - self.service_worker_auto_attach_related_owners - .retain(|owner| owner.owner_session_id != owner_key); - self.sync_service_worker_related_pause_on_start_for_devtools(); - } - - pub(crate) fn service_worker_auto_attach_related_owner_sessions_for_target( - &self, - browser_context_id: &str, - registration_id: u64, - version_id: u64, - script_url: &str, - scope_url: &str, - ) -> Vec { - let mut owners = Vec::new(); - for owner in &self.service_worker_auto_attach_related_owners { - if !owner.allow_service_worker_targets - || owner.browser_context_id != browser_context_id - || owner.registration_id != registration_id - || version_id <= owner.base_version_id - || owner.script_url != script_url - || owner.scope_url != scope_url - { - continue; - } - if !owners - .iter() - .any(|existing: &ServiceWorkerAutoAttachRelatedOwnerSession| { - existing.owner_session_id == owner.owner_session_id - }) - { - owners.push(ServiceWorkerAutoAttachRelatedOwnerSession { - owner_session_id: owner.owner_session_id.clone(), - wait_for_debugger_on_start: owner.wait_for_debugger_on_start, - }); - } - } - owners - } - - fn sync_service_worker_related_pause_on_start_for_devtools(&self) { - for browser_context in self.browser_contexts() { - let policies = self - .service_worker_auto_attach_related_owners - .iter() - .filter(|owner| { - owner.browser_context_id == browser_context.id - && owner.allow_service_worker_targets - && owner.wait_for_debugger_on_start - }) - .map(|owner| { - ( - owner.registration_id, - owner.base_version_id, - owner.script_url.clone(), - owner.scope_url.clone(), - ) - }) - .collect::>(); - browser_context - .renderer_runtime() - .set_service_worker_related_pause_on_start_policies_for_devtools(policies); - } - } - - pub(crate) fn set_service_worker_pause_on_start_owner( - &mut self, - session_id: Option<&str>, - enabled: bool, - ) -> bool { - let key = session_id.map(str::to_owned); - if enabled { - self.service_worker_pause_on_start_owner_sessions - .insert(key); - } else { - self.service_worker_pause_on_start_owner_sessions - .remove(&key); - } - let pause = self.service_worker_pause_on_start_for_devtools(); - let runtimes = self - .browser_contexts() - .map(BrowserContext::renderer_runtime) - .collect::>(); - for runtime in runtimes { - runtime.set_service_worker_pause_on_start_for_devtools(pause); - } - pause - } - - pub(crate) fn service_worker_pause_on_start_for_devtools(&self) -> bool { - !self.service_worker_pause_on_start_owner_sessions.is_empty() - } - - pub(crate) fn set_dedicated_worker_pause_on_start_owner( - &mut self, - session_id: Option<&str>, - enabled: bool, - ) -> bool { - let key = session_id.map(str::to_owned); - if enabled { - self.dedicated_worker_pause_on_start_owner_sessions - .insert(key); - } else { - self.dedicated_worker_pause_on_start_owner_sessions - .remove(&key); - } - self.dedicated_worker_pause_on_start_for_devtools() - } - - pub(crate) fn dedicated_worker_pause_on_start_for_devtools(&self) -> bool { - !self - .dedicated_worker_pause_on_start_owner_sessions - .is_empty() - } - - #[cfg(test)] - pub(crate) fn service_worker_pause_on_start_owner_count(&self) -> usize { - self.service_worker_pause_on_start_owner_sessions.len() - } -} diff --git a/moli-protocol/src/conn/target/worker_session.rs b/moli-protocol/src/conn/target/worker_session.rs deleted file mode 100644 index 942710e4d6..0000000000 --- a/moli-protocol/src/conn/target/worker_session.rs +++ /dev/null @@ -1,142 +0,0 @@ -use crate::conn::{ - CdpConnection, DedicatedWorkerTargetState, ServiceWorkerTargetState, SharedWorkerTargetState, - TargetSharedWorkerProtocolAttachmentIdentity, -}; - -use super::CdpSessionRoute; - -impl CdpConnection { - pub(crate) fn shared_worker_target_for_session( - &self, - session_id: Option<&str>, - ) -> Option<&SharedWorkerTargetState> { - let session_id = session_id?; - match self.session_route(Some(session_id))? { - CdpSessionRoute::SharedWorkerTarget { - browser_context_id, - target_id, - } => self - .browser_context_by_id(&browser_context_id)? - .shared_worker_target(&target_id), - CdpSessionRoute::DedicatedWorkerTarget { - browser_context_id, - target_id, - } => self - .browser_context_by_id(&browser_context_id)? - .dedicated_worker_target(&target_id) - .map(|target| &target.inner), - _ => None, - } - } - - /// Captures the exact renderer worker and protocol attachment addressed by - /// `session_id`. - /// - /// The attachment scope lives with the SharedWorker target's per-session - /// state. Holding this weak identity across a publication-capture boundary does - /// not keep a normally detached session alive and cannot be rebound by a - /// later current-session lookup. - pub(crate) fn shared_worker_protocol_attachment_identity_for_session( - &self, - session_id: Option<&str>, - ) -> Option { - let session_id = session_id?; - let CdpSessionRoute::SharedWorkerTarget { - browser_context_id, - target_id, - } = self.session_route(Some(session_id))? - else { - return None; - }; - self.browser_context_by_id(&browser_context_id)? - .shared_worker_target(&target_id)? - .protocol_attachment_identity(&browser_context_id, session_id) - } - - pub(crate) fn shared_worker_target_for_session_mut( - &mut self, - session_id: Option<&str>, - ) -> Option<&mut SharedWorkerTargetState> { - let session_id = session_id?; - match self.session_route(Some(session_id))? { - CdpSessionRoute::SharedWorkerTarget { - browser_context_id, - target_id, - } => self - .browser_context_by_id_mut(&browser_context_id)? - .shared_worker_target_mut(&target_id), - CdpSessionRoute::DedicatedWorkerTarget { - browser_context_id, - target_id, - } => self - .browser_context_by_id_mut(&browser_context_id)? - .dedicated_worker_target_mut(&target_id) - .map(|target| &mut target.inner), - _ => None, - } - } - - pub(crate) fn dedicated_worker_target_for_session( - &self, - session_id: Option<&str>, - ) -> Option<&DedicatedWorkerTargetState> { - let session_id = session_id?; - let CdpSessionRoute::DedicatedWorkerTarget { - browser_context_id, - target_id, - } = self.session_route(Some(session_id))? - else { - return None; - }; - self.browser_context_by_id(&browser_context_id)? - .dedicated_worker_target(&target_id) - } - - pub(crate) fn dedicated_worker_target_for_session_mut( - &mut self, - session_id: Option<&str>, - ) -> Option<&mut DedicatedWorkerTargetState> { - let session_id = session_id?; - let CdpSessionRoute::DedicatedWorkerTarget { - browser_context_id, - target_id, - } = self.session_route(Some(session_id))? - else { - return None; - }; - self.browser_context_by_id_mut(&browser_context_id)? - .dedicated_worker_target_mut(&target_id) - } - - pub(crate) fn service_worker_target_for_session( - &self, - session_id: Option<&str>, - ) -> Option<&ServiceWorkerTargetState> { - let session_id = session_id?; - let CdpSessionRoute::ServiceWorkerTarget { - browser_context_id, - target_id, - } = self.session_route(Some(session_id))? - else { - return None; - }; - self.browser_context_by_id(&browser_context_id)? - .service_worker_target(&target_id) - } - - pub(crate) fn service_worker_target_for_session_mut( - &mut self, - session_id: Option<&str>, - ) -> Option<&mut ServiceWorkerTargetState> { - let session_id = session_id?; - let CdpSessionRoute::ServiceWorkerTarget { - browser_context_id, - target_id, - } = self.session_route(Some(session_id))? - else { - return None; - }; - self.browser_context_by_id_mut(&browser_context_id)? - .service_worker_target_mut(&target_id) - } -} diff --git a/moli-protocol/src/conn/tests/mod.rs b/moli-protocol/src/conn/tests/mod.rs index 1b8ec90564..901ed72e55 100644 --- a/moli-protocol/src/conn/tests/mod.rs +++ b/moli-protocol/src/conn/tests/mod.rs @@ -7,8 +7,9 @@ use super::{ BrowserContextFirstCookieRequest, BrowserContextReservedSiteDataOwnerState, BrowserContextSiteDataManagerOwnerState, BrowserContextStructuredCookieCommandVerdict, BrowserContextStructuredCookieWriteBackendStatus, - BrowserContextStructuredCookieWriteReadinessStatus, CdpConnection, CdpSessionRoute, - CommandDispatchContext, CommandResponseFlushContext, NavigationBackgroundEvent, + BrowserContextStructuredCookieWriteReadinessStatus, CdpConnection, CdpInitialStoragePartition, + CdpSessionRoute, CommandDispatchContext, CommandResponseFlushContext, CompletedDownloadBody, + CompletedDownloadBodyArtifact, InitialDocumentPageInstallResult, NavigationBackgroundEvent, NavigationDispatchState, NavigationResultProjection, ServiceWorkerTargetState, SharedWorkerTargetState, build_event, }; @@ -18,8 +19,8 @@ use crate::devtools_runtime::{ }; use crate::domains::network::{ FailedNavigationDocumentPolicy, FailedNavigationResponseMode, - MaterializedFailedDocumentProgress, MaterializedNavigationLoadOutcome, - empty_main_document_progress_gate_for_test, + MaterializedDownloadDocumentProgress, MaterializedFailedDocumentProgress, + MaterializedNavigationLoadOutcome, empty_main_document_progress_gate_for_test, }; use crate::domains::page::MaterializedNavigationCompletion; use crate::testing::TestContext; @@ -29,17 +30,24 @@ use moli_cookie_jar::{ CookieSiteDataSummary, CookieStorageClearTarget, StoredCookie, StoredCookieSameSite, StoredCookieSetRejectionReason, StoredCookieSourceScheme, }; -use moli_core::page::RendererServiceWorkerVersionStatus; use moli_core::{ LayoutPolicy, OptionalResourceFetchMask, + browser_host::{ + BrowserDownloadArtifactOutcome, BrowserDownloadBehavior, BrowserDownloadPolicyUpdate, + BrowserFact, BrowserHostState, BrowserNavigationFailure, BrowserTargetTerminationKind, + }, + page::RendererServiceWorkerVersionStatus, runtime::{NavigationEngine, NavigationRuntimeConfig}, }; -use moli_fetch::FetchConfig; +use moli_fetch::{FetchCancelHandle, FetchConfig}; use moli_shared_worker::SharedWorkerInstanceId; use serde_json::json; -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, }; use url::Url; @@ -48,10 +56,17 @@ mod message; mod resource_runtime; mod site_data; +fn active_layout_policy(conn: &CdpConnection) -> LayoutPolicy { + conn.browser_host_state + .navigation_owner() + .clone_active_navigation_engine() + .layout_policy() +} + #[test] fn idle_navigation_engine_reset_preserves_mock_layout_policy() { let mut conn = CdpConnection::new_with_initial_storage_partition_and_runtime_config( - crate::CdpInitialStoragePartition::memory(), + CdpInitialStoragePartition::memory(), NavigationRuntimeConfig::new( FetchConfig::default(), OptionalResourceFetchMask::NONE, @@ -60,17 +75,17 @@ fn idle_navigation_engine_reset_preserves_mock_layout_policy() { ), ); - assert_eq!(conn.engine.layout_policy(), LayoutPolicy::Mock); + assert_eq!(active_layout_policy(&conn), LayoutPolicy::Mock); let reset = conn.release_idle_navigation_engine_memory_if_idle(); assert!(reset.reset); - assert_eq!(conn.engine.layout_policy(), LayoutPolicy::Mock); + assert_eq!(active_layout_policy(&conn), LayoutPolicy::Mock); } #[tokio::test] async fn browser_context_install_and_removal_preserve_mock_layout_policy() { let mut conn = CdpConnection::new_with_initial_storage_partition_and_runtime_config( - crate::CdpInitialStoragePartition::memory(), + CdpInitialStoragePartition::memory(), NavigationRuntimeConfig::new( FetchConfig::default(), OptionalResourceFetchMask::NONE, @@ -80,13 +95,338 @@ async fn browser_context_install_and_removal_preserve_mock_layout_policy() { ); conn.insert_browser_context(BrowserContext::new("CTX-layout".to_owned())); - assert_eq!(conn.engine.layout_policy(), LayoutPolicy::Mock); + assert_eq!(active_layout_policy(&conn), LayoutPolicy::Mock); let removed = conn .remove_browser_context_by_id_restoring_active_async("CTX-layout", None) .await; - assert!(removed.is_some()); - assert_eq!(conn.engine.layout_policy(), LayoutPolicy::Mock); + assert!(removed.is_ok()); + assert_eq!(active_layout_policy(&conn), LayoutPolicy::Mock); +} + +#[test] +fn browser_host_registry_outlives_protocol_adapter_state() { + let fetch_config = FetchConfig::default(); + let browser_host_state = BrowserHostState::new( + NavigationEngine::new_with_fetch_config_and_resource_loading( + fetch_config.clone(), + OptionalResourceFetchMask::NONE, + true, + ), + ); + let mut conn = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state.clone(), + CdpInitialStoragePartition::memory(), + ); + conn.install_default_browser_target(); + let target_id = conn.default_target_id().to_owned(); + let browser_instance_id = browser_host_state.navigation_owner().browser_instance_id(); + let browser_context = conn + .browser_context + .as_ref() + .expect("default physical BrowserContext projection"); + let browser_context_handle = browser_context.browser_context_handle().clone(); + let renderer_runtime_id = browser_context.renderer_runtime().id(); + assert!( + !browser_context.carries_renderer_runtime_registration_owner_for_test(), + "a committed frontend projection must not retain the unique runtime root" + ); + assert_eq!( + browser_host_state + .renderer_runtime_owner_access(&browser_context_handle) + .expect("Browser Host runtime root") + .runtime() + .id(), + renderer_runtime_id + ); + + drop(conn); + + let browser_owner = browser_host_state.navigation_owner(); + assert_eq!(browser_owner.browser_instance_id(), browser_instance_id); + assert_eq!(browser_owner.browser_context_count(), 1); + assert_eq!(browser_owner.target_count(), 1); + assert!(browser_owner.has_target(&target_id)); + drop(browser_owner); + assert_eq!( + browser_host_state + .renderer_runtime_owner_access(&browser_context_handle) + .expect("frontend teardown must leave the Browser Host runtime root live") + .runtime() + .id(), + renderer_runtime_id + ); +} + +#[tokio::test] +async fn browser_host_renderer_page_lifetime_outlives_protocol_adapter_state() { + let browser_host_state = BrowserHostState::new(NavigationEngine::new()); + let mut conn = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state.clone(), + CdpInitialStoragePartition::memory(), + ); + conn.install_default_browser_target(); + let owner = conn + .target_page_owner_key_for_session(None) + .expect("default Target owner"); + let initial_document_owner = conn + .initial_document_page_owner_for_session(None) + .expect("default Target initial Document owner"); + let mut navigation = conn + .load_navigation_via_runtime_async("data:text/html,host-owned") + .await + .expect("initial renderer navigation"); + if let Some(engine) = navigation.navigation_engine.take() { + conn.adopt_navigation_engine_for_current_owner(engine) + .expect("initial renderer engine should remain Browser-owned"); + } + let page = navigation.page; + let renderer_page_id = page.page_id(); + let page_creation_artifacts = navigation.page_creation_artifacts; + + assert_eq!( + conn.install_initial_loaded_page_for_page_owner_async( + &initial_document_owner, + page, + page_creation_artifacts, + ) + .await + .expect("initial Page installation should commit"), + InitialDocumentPageInstallResult::Installed + ); + assert_eq!( + browser_host_state + .navigation_owner() + .renderer_page_id_for_owner(&owner), + Some(renderer_page_id) + ); + + drop(conn); + + assert_eq!( + browser_host_state + .navigation_owner() + .renderer_page_id_for_owner(&owner), + Some(renderer_page_id), + "dropping the Protocol adapter must not retire the Browser-owned renderer Page" + ); + + let request = browser_host_state + .navigation_owner() + .capture_target_termination(&owner, BrowserTargetTerminationKind::Close) + .expect("Browser Host should still own the Target after frontend teardown"); + let permit = browser_host_state + .navigation_owner() + .prepare_target_termination(request) + .expect("retained Browser Target should still prepare termination"); + let mut termination = browser_host_state + .commit_target_termination(permit) + .expect("retained Browser Target should still commit termination"); + let retired_owner = termination + .take_retired_renderer_page_owner() + .expect("Browser Host termination should return its renderer Page owner"); + assert_eq!(retired_owner.page_id(), renderer_page_id); + retired_owner + .close_async() + .await + .expect("retained renderer Page should close after frontend teardown"); +} + +#[test] +fn browser_host_policy_is_shared_across_frontend_adapters() { + let mut fetch_config = FetchConfig::default(); + fetch_config.set_user_agent("BaseHostPolicy/1.0"); + let browser_host_state = BrowserHostState::new( + NavigationEngine::new_with_fetch_config_and_resource_loading( + fetch_config, + OptionalResourceFetchMask::NONE, + true, + ), + ); + let mut first = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state.clone(), + CdpInitialStoragePartition::memory(), + ); + let second = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state.clone(), + CdpInitialStoragePartition::memory(), + ); + + first.set_global_browser_identity_override_from_user_agent(Some( + "SharedHostPolicy/2.0".to_owned(), + )); + first.set_global_extra_headers(vec![("x-host-policy".to_owned(), "shared".to_owned())]); + first.set_global_cache_disabled(true); + first.set_global_network_conditions(Some(super::EmulatedNetworkConditions::offline())); + first.set_global_geolocation_override(Some(super::EmulatedGeolocationOverrideState::Position( + super::EmulatedGeolocationOverride { + latitude: 1.0, + longitude: 2.0, + accuracy: 3.0, + altitude: None, + altitude_accuracy: None, + heading: None, + speed: None, + }, + ))); + let mut bounds = first.browser_host_policy_snapshot().window_bounds().clone(); + bounds.width = Some(1440); + first.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplaceWindowBounds(bounds), + ); + first.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplacePermissionOverrides(vec![ + super::PermissionOverride { + permission: json!({ "name": "geolocation" }), + setting: "denied".to_owned(), + origin: Some("https://example.test".to_owned()), + embedded_origin: None, + browser_context_id: None, + }, + ]), + ); + drop(first); + + let policy = second.browser_host_policy_snapshot(); + assert_eq!(second.user_agent(), "SharedHostPolicy/2.0"); + assert!(policy.global_cache_disabled()); + assert_eq!( + policy.global_extra_headers(), + &[("x-host-policy".to_owned(), "shared".to_owned())] + ); + assert_eq!(policy.window_bounds().width, Some(1440)); + assert_eq!(policy.permission_overrides().len(), 1); + + let context = second.new_browser_context("BID-policy-reader".to_owned()); + assert!(context.network_policy.cache_disabled()); + assert_eq!( + context.global_extra_headers, + vec![("x-host-policy".to_owned(), "shared".to_owned())] + ); + assert!(context.effective_active_network_offline()); + assert_eq!( + context + .effective_active_geolocation_override() + .and_then(|state| state.position().cloned()) + .map(|position| (position.latitude, position.longitude)), + Some((1.0, 2.0)) + ); + assert_eq!( + second + .effective_permission_overrides_for_browser_context_id("BID-policy-reader") + .len(), + 1 + ); +} + +#[test] +fn browser_download_state_is_shared_and_frontend_subscriptions_are_not() { + let browser_host_state = BrowserHostState::new(NavigationEngine::new()); + let mut first = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state.clone(), + CdpInitialStoragePartition::memory(), + ); + let second = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state, + CdpInitialStoragePartition::memory(), + ); + + first.apply_browser_download_policy_update(BrowserDownloadPolicyUpdate::SetGlobal { + behavior: BrowserDownloadBehavior::AllowAndName, + download_path: Some("/tmp/shared-browser-downloads".to_owned()), + }); + first.set_automation_download_events_enabled_for_browser_context(None, true); + first.set_browser_download_events_enabled_for_session(None, true); + let cancel_handle = FetchCancelHandle::new(); + let registry = first.browser_download_registry(); + registry.register_active("shared-active".to_owned(), cancel_handle.clone()); + let artifact_path = PathBuf::from("/tmp/shared-browser-download-artifact"); + registry.record_completed("shared-complete", artifact_path.clone()); + + drop(first); + + let policy = second.effective_browser_download_policy(None); + assert_eq!(policy.behavior(), BrowserDownloadBehavior::AllowAndName); + assert_eq!( + policy.download_path(), + Some("/tmp/shared-browser-downloads") + ); + assert!( + !second.automation_download_events_enabled_for_browser_context(None), + "automation event projection must remain local to its frontend" + ); + assert!( + second + .browser_download_event_session_ids_for_test() + .is_empty(), + "wire subscription generations must remain local to each frontend" + ); + assert_eq!(second.cancel_download("shared-active"), Ok(())); + assert!(cancel_handle.is_cancelled()); + assert_eq!( + second + .browser_download_registry() + .artifact("shared-complete"), + BrowserDownloadArtifactOutcome::Ready(artifact_path) + ); +} + +#[test] +fn browser_host_identity_namespace_is_shared_across_frontend_adapters() { + let browser_host_state = BrowserHostState::new(NavigationEngine::new()); + let mut first = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state.clone(), + CdpInitialStoragePartition::memory(), + ); + let mut second = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state, + CdpInitialStoragePartition::memory(), + ); + + assert_eq!(first.gen_bc_id(), "BID-1"); + assert_eq!(second.gen_user_browser_context_id(), "user-context-2"); + assert_eq!(first.gen_target_id(), "TID-1"); + assert_eq!(second.gen_target_id(), "TID-2"); + assert_eq!( + first.browser_host_state.allocate_browser_command_id().get(), + 1 + ); + assert_eq!( + second + .browser_host_state + .allocate_browser_command_id() + .get(), + 2 + ); +} + +#[test] +fn browser_network_request_namespace_is_shared_across_frontend_adapters() { + let browser_host_state = BrowserHostState::new(NavigationEngine::new()); + let first = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state.clone(), + CdpInitialStoragePartition::memory(), + ); + let second = CdpConnection::new_with_browser_host_state_and_initial_storage_partition( + browser_host_state, + CdpInitialStoragePartition::memory(), + ); + + assert_eq!( + first + .browser_host_state + .network_artifacts() + .allocate_request_id(), + "REQ-1" + ); + assert_eq!( + second + .browser_host_state + .network_artifacts() + .allocate_request_id(), + "REQ-2", + "frontend construction must not create a private request-id namespace" + ); } #[test] @@ -264,45 +604,6 @@ fn background_navigation_completion_sender_routes_explicit_session_owners() { ); } -#[test] -fn navigation_gate_resolves_websocket_events_to_their_session_target() { - let mut conn = CdpConnection::new(); - let mut target_a = BrowserContext::new("BID-A".to_owned()); - target_a.set_active_target_id("TID-A"); - target_a.attach_active_session("SID-A"); - let navigation_a = target_a - .start_document_navigation_for_active_target("LOADER-A".to_owned()) - .expect("target A should accept a navigation request"); - conn.browser_context = Some(target_a); - assert!(conn.arm_background_navigation_completion(&navigation_a, None)); - - let mut target_b = BrowserContext::new("BID-B".to_owned()); - target_b.set_active_target_id("TID-B"); - target_b.attach_active_session("SID-B"); - conn.inactive_browser_contexts.push(target_b); - - let target_b_websocket = BackgroundProtocolEvent::immediate(json!({ - "method": "Network.webSocketClosed", - "sessionId": "SID-B", - "params": { - "requestId": "REQ-B", - "timestamp": 1.0 - } - })); - - assert!(target_b_websocket.should_wait_for_background_navigation_completion()); - assert!(conn.has_inflight_background_navigation()); - assert_eq!( - conn.background_navigation_target_id_for_event(&target_b_websocket) - .as_deref(), - Some("TID-B") - ); - assert!( - !conn.has_inflight_background_navigation_for_target("TID-B"), - "target A's navigation must not gate target B's WebSocket events" - ); -} - #[test] fn none_session_owner_route_override_scope_restores_previous_route_on_drop() { let mut conn = CdpConnection::new(); @@ -335,10 +636,10 @@ fn navigation_background_event_queue_drains_current_token() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("CTX-nav".to_owned()); browser_context.set_active_target_id("TID-nav"); - let token = browser_context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) - .expect("active target should produce navigation token"); conn.browser_context = Some(browser_context); + let token = conn + .start_document_navigation_for_session_owner(None, "LOADER-1".to_owned()) + .expect("active target should produce navigation token"); let message = build_event( "Page.frameStartedLoading", json!({ "frameId": "TID-nav" }), @@ -363,8 +664,9 @@ fn active_browser_context_installs_its_renderer_runtime_on_engine() { conn.insert_browser_context(browser_context); assert!( - conn.engine - .browser_context_runtime() + conn.browser_host_state + .navigation_owner() + .active_browser_context_runtime() .shares_state_with(&renderer_runtime) ); } @@ -376,13 +678,14 @@ fn activating_inactive_browser_context_switches_engine_renderer_runtime() { conn.insert_browser_context(first); let second = conn.new_browser_context("CTX-second".to_owned()); let second_renderer_runtime = second.renderer_runtime(); - conn.inactive_browser_contexts.push(second); + conn.insert_browser_context(second); assert!(conn.activate_browser_context_by_id("CTX-second")); assert!( - conn.engine - .browser_context_runtime() + conn.browser_host_state + .navigation_owner() + .active_browser_context_runtime() .shares_state_with(&second_renderer_runtime) ); } @@ -394,31 +697,149 @@ async fn removing_active_browser_context_switches_engine_to_promoted_context() { conn.insert_browser_context(first); let second = conn.new_browser_context("CTX-second".to_owned()); let second_renderer_runtime = second.renderer_runtime(); - conn.inactive_browser_contexts.push(second); + conn.insert_browser_context(second); let removed = conn .remove_browser_context_by_id_restoring_active_async("CTX-first", Some("CTX-first")) .await .expect("active context should be removable"); - assert_eq!(removed.id, "CTX-first"); + assert_eq!(removed, "CTX-first"); assert_eq!( conn.browser_context.as_ref().map(|bc| bc.id.as_str()), Some("CTX-second") ); assert!( - conn.engine - .browser_context_runtime() + conn.browser_host_state + .navigation_owner() + .active_browser_context_runtime() .shares_state_with(&second_renderer_runtime) ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("CTX-second") + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .browser_context_count(), + 1 + ); +} + +#[test] +fn unknown_browser_context_cannot_change_core_or_physical_selection() { + let mut conn = CdpConnection::new(); + let mut first = conn.new_browser_context("CTX-first".to_owned()); + first.set_active_target_id("TID-first"); + conn.insert_browser_context(first); + let mut second = conn.new_browser_context("CTX-second".to_owned()); + second.set_active_target_id("TID-second"); + conn.insert_browser_context(second); + + assert!(!conn.activate_browser_context_by_id("CTX-missing")); + + assert_eq!( + conn.browser_context + .as_ref() + .map(|context| context.id.as_str()), + Some("CTX-first") + ); + assert_eq!( + conn.inactive_browser_contexts + .iter() + .map(|context| context.id.as_str()) + .collect::>(), + vec!["CTX-second"] + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("CTX-first") + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .browser_context_count(), + 2 + ); +} + +#[test] +fn late_browser_context_removal_permit_cannot_change_physical_selection() { + use moli_core::browser_host::{ + BrowserContextRegistryError, BrowserContextRuntimeRegistryError, + BrowserContextSelectionProjection, + }; + + let mut conn = CdpConnection::new(); + let mut first = conn.new_browser_context("CTX-first".to_owned()); + first.set_active_target_id("TID-first"); + conn.insert_browser_context(first); + let mut second = conn.new_browser_context("CTX-second".to_owned()); + second.set_active_target_id("TID-second"); + conn.insert_browser_context(second); + let permit = conn + .browser_host_state + .navigation_owner() + .prepare_browser_context_removal("CTX-first") + .expect("selected context removal should prepare"); + + assert!(conn.activate_browser_context_by_id("CTX-second")); + let projection = BrowserContextSelectionProjection::new( + Some("CTX-second".to_owned()), + conn.selected_target_engine_disposition(), + ); + let error = conn + .browser_host_state + .commit_browser_context_removal_with_successor_runtime( + permit, + projection, + NavigationEngine::new, + ) + .expect_err("late removal permit must be rejected"); + + assert!(matches!( + error, + BrowserContextRuntimeRegistryError::Context( + BrowserContextRegistryError::StaleRemovalPermit { .. } + ) + )); + assert_eq!( + conn.browser_context + .as_ref() + .map(|context| context.id.as_str()), + Some("CTX-second") + ); + assert!( + conn.inactive_browser_contexts + .iter() + .any(|context| context.id == "CTX-first") + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .selected_browser_context_id(), + Some("CTX-second") + ); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .browser_context_count(), + 2 + ); } #[tokio::test] async fn memory_diagnostics_reports_page_vm_document_isolate_model() { let mut conn = CdpConnection::new(); - conn.replace_navigation_engine( + conn.adopt_navigation_engine_for_current_owner( NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics(), - ); + ) + .expect("diagnostics engine should adopt into the current owner"); conn.browser_context = Some(BrowserContext::new("BID-shared-diagnostics".to_owned())); let first_page = conn @@ -517,9 +938,9 @@ async fn memory_diagnostics_reports_page_vm_document_isolate_model() { } #[tokio::test] -async fn replacing_or_retiring_a_loaded_page_changes_its_attachment_identity() { +async fn replacing_or_retiring_a_loaded_page_advances_its_residence_generation() { let mut conn = CdpConnection::new(); - conn.browser_context = Some(BrowserContext::new("BID-page-attachment".to_owned())); + conn.browser_context = Some(BrowserContext::new("BID-page-generation".to_owned())); let first_page = conn .load_page_via_runtime_async("data:text/html,first") .await @@ -529,35 +950,25 @@ async fn replacing_or_retiring_a_loaded_page_changes_its_attachment_identity() { .await .expect("second Page"); let context = conn.browser_context.as_mut().unwrap(); - assert_eq!( - context.active_target.runtime_slot.page_attachment_id(), - None - ); + let initial_generation = context.active_target.runtime_slot.loaded_page_generation(); assert!(context.replace_loaded_page(Some(first_page)).is_none()); - let first_attachment = context - .active_target - .runtime_slot - .page_attachment_id() - .expect("first Page attachment"); + let first_generation = context.active_target.runtime_slot.loaded_page_generation(); + assert_eq!(first_generation, initial_generation.wrapping_add(1)); let first = context .replace_loaded_page(Some(second_page)) .expect("first Page should be replaced"); - let second_attachment = context - .active_target - .runtime_slot - .page_attachment_id() - .expect("second Page attachment"); - assert_ne!(second_attachment, first_attachment); + let second_generation = context.active_target.runtime_slot.loaded_page_generation(); + assert_eq!(second_generation, first_generation.wrapping_add(1)); let _ = first.close_async().await; let second = context .clear_loaded_page_with_reason(TargetPageAbsenceReason::TargetClosed) .expect("second Page should be retired"); assert_eq!( - context.active_target.runtime_slot.page_attachment_id(), - None + context.active_target.runtime_slot.loaded_page_generation(), + second_generation.wrapping_add(1) ); let _ = second.close_async().await; } @@ -565,20 +976,14 @@ async fn replacing_or_retiring_a_loaded_page_changes_its_attachment_identity() { #[tokio::test(flavor = "multi_thread")] async fn moli_diagnostics_preserves_runtime_observable_diagnostics() { let mut ctx = TestContext::new(); - let page = ctx - .conn - .load_page_via_runtime_async("data:text/html,diagnostics capture") - .await - .expect("diagnostics capture page should load"); + let page_url = "data:text/html,diagnostics capture"; let mut browser_context = BrowserContext::new("BID-diagnostics-capture".to_owned()); browser_context.set_active_target_id("TID-diagnostics-capture"); browser_context.attach_active_session("SID-diagnostics-capture"); - browser_context.set_target_url(page.final_url().as_str().to_owned()); - let _ = browser_context - .active_target - .runtime_slot - .replace_loaded_page(Some(page)); - ctx.conn.browser_context = Some(browser_context); + browser_context.set_target_url("about:blank".to_owned()); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-diagnostics-capture")) + .await; ctx.process_async(json!({ "id": 44_100, @@ -635,9 +1040,10 @@ async fn moli_diagnostics_preserves_runtime_observable_diagnostics() { #[tokio::test] async fn memory_diagnostics_ignores_empty_retained_renderer_owner_for_document_isolates() { let mut conn = CdpConnection::new(); - conn.replace_navigation_engine( + conn.adopt_navigation_engine_for_current_owner( NavigationEngine::new_with_page_vm_document_isolate_for_diagnostics(), - ); + ) + .expect("diagnostics engine should adopt into the current owner"); conn.browser_context = Some(BrowserContext::new("BID-doc-owner-diagnostics".to_owned())); let first_page = conn @@ -662,12 +1068,19 @@ async fn memory_diagnostics_ignores_empty_retained_renderer_owner_for_document_i let retained = NavigationEngine::new_with_fetch_config_and_browser_context_access( FetchConfig::default(), retained_context.renderer_runtime_owner_access(), - conn.engine.optional_resource_fetch_mask(), - conn.engine.subframe_loading_enabled(), + conn.browser_host_state + .navigation_owner() + .active_optional_resource_fetch_mask(), + conn.browser_host_state + .navigation_owner() + .active_subframe_loading_enabled(), ) .expect("retained diagnostics context owner should be live"); assert!( - !retained.shares_renderer_owner_with(&conn.engine), + !conn + .browser_host_state + .navigation_owner() + .detached_engine_shares_active_renderer_owner(&retained), "test setup must retain a distinct renderer owner without a loaded document" ); conn.inactive_browser_contexts.push(retained_context); @@ -796,7 +1209,7 @@ async fn memory_diagnostics_counts_different_browser_context_document_isolates_s let mut second_context = conn.new_browser_context("BID-doc-owner-second".to_owned()); second_context.set_active_target_id("TID-doc-owner-second"); - conn.inactive_browser_contexts.push(second_context); + conn.insert_browser_context(second_context); assert!(conn.activate_browser_context_by_id("BID-doc-owner-second")); let second_page = conn @@ -853,25 +1266,17 @@ async fn memory_diagnostics_counts_different_browser_context_document_isolates_s fn memory_diagnostics_counts_shared_retained_background_engine_by_renderer_owner() { let mut conn = CdpConnection::new(); let browser_context = BrowserContext::new("BID-shared-diagnostics".to_owned()); - let active = NavigationEngine::new_with_fetch_config_and_browser_context_access( - FetchConfig::default(), - browser_context.renderer_runtime_owner_access(), - conn.engine.optional_resource_fetch_mask(), - conn.engine.subframe_loading_enabled(), - ) - .expect("active diagnostics context owner should be live"); - conn.replace_navigation_engine(active); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); - let retained = NavigationEngine::new_with_fetch_config_and_shared_renderer_owner( - FetchConfig::default(), - &conn.engine, - conn.engine.optional_resource_fetch_mask(), - conn.engine.subframe_loading_enabled(), - ) - .expect("retained diagnostics wrapper should share a live context owner"); + let retained = conn + .browser_host_state + .navigation_owner() + .new_engine_sharing_active_renderer_owner(FetchConfig::default()) + .expect("retained diagnostics wrapper should share a live context owner"); assert!( - retained.shares_renderer_owner_with(&conn.engine), + conn.browser_host_state + .navigation_owner() + .detached_engine_shares_active_renderer_owner(&retained), "test setup must retain a NavigationEngine wrapper that shares the active renderer owner" ); conn.retain_background_navigation_engine( @@ -907,8 +1312,12 @@ fn retained_background_engine_rejects_a_foreign_browser_context_route() { let foreign_engine = NavigationEngine::new_with_fetch_config_and_browser_context_access( FetchConfig::default(), foreign_context.renderer_runtime_owner_access(), - conn.engine.optional_resource_fetch_mask(), - conn.engine.subframe_loading_enabled(), + conn.browser_host_state + .navigation_owner() + .active_optional_resource_fetch_mask(), + conn.browser_host_state + .navigation_owner() + .active_subframe_loading_enabled(), ) .expect("foreign context owner should be live during the regression"); @@ -921,7 +1330,12 @@ fn retained_background_engine_rejects_a_foreign_browser_context_route() { .expect_err("a retained route must reject an engine from another BrowserContext"); assert!(error.contains("does not match BrowserContext `BID-retain-route`")); - assert!(conn.retained_background_navigation_engines.is_empty()); + assert_eq!( + conn.browser_host_state + .navigation_owner() + .retained_background_engine_count(), + 0 + ); } #[tokio::test] @@ -945,10 +1359,10 @@ async fn memory_diagnostics_splits_pending_inspector_await_counts_by_target_owne browser_context.attach_active_session("SID-pending-await-active"); browser_context.replace_loaded_page(Some(active_page)); browser_context - .devtools_session_state + .devtools_session_state_mut() .register_pending_inspector_await(10_001, Some("SID-pending-await-active"), None); browser_context - .devtools_session_state + .devtools_session_state_mut() .register_pending_inspector_await( 10_002, Some("SID-pending-await-active"), @@ -961,15 +1375,12 @@ async fn memory_diagnostics_splits_pending_inspector_await_counts_by_target_owne "data:text/html,background".to_owned(), ); background.replace_loaded_page(Some(background_page)); - let mut background_page_session_state = super::ParkedPageSessionState::default(); - background_page_session_state - .devtools_session_state - .register_pending_inspector_await(20_001, Some("SID-pending-await-bg"), None); - browser_context.target_parking.replace_page_session_state( - "TID-pending-await-bg".to_owned(), - background_page_session_state, - ); browser_context.background_targets.push(background); + browser_context.adopt_background_target_fixture_attachments(); + browser_context + .primary_devtools_session_state_for_target_mut("TID-pending-await-bg") + .expect("background Target frontend state") + .register_pending_inspector_await(20_001, Some("SID-pending-await-bg"), None); let shared_worker_instance_id = SharedWorkerInstanceId::from_u64(30_001); let mut shared_worker_target = SharedWorkerTargetState::new( @@ -1070,9 +1481,11 @@ async fn memory_diagnostics_splits_pending_inspector_await_counts_by_target_owne diagnostics["activeBrowserContext"]["runtimeSession"]["primaryPendingInspectorAwaitCount"], json!(2) ); - assert_eq!( - diagnostics["activeBrowserContext"]["targetParking"]["pendingInspectorAwaitCount"], - json!(1) + assert!( + diagnostics["activeBrowserContext"]["targetParking"] + .get("pendingInspectorAwaitCount") + .is_none(), + "frontend await diagnostics must no longer be attributed to physical parking storage" ); } @@ -1081,13 +1494,13 @@ fn navigation_background_event_queue_drops_stale_token() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("CTX-nav".to_owned()); browser_context.set_active_target_id("TID-nav"); - let stale = browser_context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) + conn.browser_context = Some(browser_context); + let stale = conn + .start_document_navigation_for_session_owner(None, "LOADER-1".to_owned()) .expect("active target should produce stale token"); - let current = browser_context - .start_document_navigation_for_active_target("LOADER-2".to_owned()) + let current = conn + .start_document_navigation_for_session_owner(None, "LOADER-2".to_owned()) .expect("active target should produce current token"); - conn.browser_context = Some(browser_context); let stale_message = build_event( "Page.frameStartedLoading", json!({ "frameId": "TID-nav", "loaderId": "LOADER-1" }), @@ -1119,13 +1532,13 @@ fn navigation_background_event_queue_preserves_order_for_current_token() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("CTX-nav-order".to_owned()); browser_context.set_active_target_id("TID-nav-order"); - let stale = browser_context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) + conn.browser_context = Some(browser_context); + let stale = conn + .start_document_navigation_for_session_owner(None, "LOADER-1".to_owned()) .expect("active target should produce stale navigation token"); - let current = browser_context - .start_document_navigation_for_active_target("LOADER-2".to_owned()) + let current = conn + .start_document_navigation_for_session_owner(None, "LOADER-2".to_owned()) .expect("active target should produce current navigation token"); - conn.browser_context = Some(browser_context); let stale_message = build_event( "Page.frameStartedLoading", @@ -1165,14 +1578,14 @@ fn navigation_background_event_queue_preserves_order_for_current_token() { #[test] fn navigation_background_event_sender_preserves_typed_sidecar_for_current_token() { let mut conn = CdpConnection::new(); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let (tx, mut rx) = crate::conn::browser_background_output_channel(); conn.set_background_event_sender(tx); let mut browser_context = BrowserContext::new("CTX-nav-typed".to_owned()); browser_context.set_active_target_id("TID-nav-typed"); - let current = browser_context - .start_document_navigation_for_active_target("LOADER-typed".to_owned()) - .expect("active target should produce current navigation token"); conn.browser_context = Some(browser_context); + let current = conn + .start_document_navigation_for_session_owner(None, "LOADER-typed".to_owned()) + .expect("active target should produce current navigation token"); let message = build_event( "Page.frameStartedNavigating", json!({ @@ -1216,13 +1629,13 @@ async fn materialized_navigation_completion_drops_stale_token() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("CTX-nav".to_owned()); browser_context.set_active_target_id("TID-nav"); - let stale = browser_context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) + conn.insert_browser_context(browser_context); + let stale = conn + .start_document_navigation_for_session_owner(None, "LOADER-1".to_owned()) .expect("active target should produce stale token"); - let _current = browser_context - .start_document_navigation_for_active_target("LOADER-2".to_owned()) + let current = conn + .start_document_navigation_for_session_owner(None, "LOADER-2".to_owned()) .expect("active target should produce current token"); - conn.browser_context = Some(browser_context); let state = materialized_navigation_test_state(Some(7), "LOADER-1", "https://example.test/stale"); let navigation = @@ -1237,7 +1650,7 @@ async fn materialized_navigation_completion_drops_stale_token() { let mut command_context = CommandDispatchContext::default(); conn.drain_materialized_navigation_completion_into( &mut out, - MaterializedNavigationCompletion::new(stale, state, navigation), + MaterializedNavigationCompletion::new(stale.clone(), state, navigation), &mut command_context, ) .await; @@ -1245,22 +1658,34 @@ async fn materialized_navigation_completion_drops_stale_token() { assert_eq!(out.len(), 1, "stale completion must emit terminal reply"); let reply = &out[0]; assert_eq!(reply["id"], serde_json::json!(7)); - assert!( - reply.get("error").is_none(), - "CDP reports a superseded Page.navigate as a successful command: {reply:#?}" - ); + assert_eq!(reply["error"]["code"], serde_json::json!(-32000)); assert_eq!( - reply["result"], - serde_json::json!({ - "frameId": "TID-nav", - "errorText": "net::ERR_ABORTED", - "isDownload": false - }) + reply["error"]["message"], + serde_json::json!("Navigation aborted") ); assert!( reply.get("method").is_none(), "stale completion must emit a command reply, not an event" ); + let facts = conn.browser_fact_snapshot_for_test(); + assert!(facts.iter().any(|fact| { + matches!( + fact.fact(), + BrowserFact::NavigationAccepted { + navigation, + superseded_navigation: Some(superseded), + } if navigation == ¤t && superseded == &stale + ) + })); + assert!( + !facts.iter().any(|fact| { + matches!( + fact.fact(), + BrowserFact::NavigationFailed { navigation, .. } if navigation == &stale + ) + }), + "the successor admission owns the old request terminal without a second occurrence" + ); } #[tokio::test] @@ -1268,13 +1693,13 @@ async fn materialized_navigation_completion_drops_stale_token_without_navigate_i let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("CTX-nav-none".to_owned()); browser_context.set_active_target_id("TID-nav-none"); - let stale = browser_context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) + conn.insert_browser_context(browser_context); + let stale = conn + .start_document_navigation_for_session_owner(None, "LOADER-1".to_owned()) .expect("active target should produce stale navigation token"); - let _ = browser_context - .start_document_navigation_for_active_target("LOADER-2".to_owned()) + let _ = conn + .start_document_navigation_for_session_owner(None, "LOADER-2".to_owned()) .expect("active target should produce current navigation token"); - conn.browser_context = Some(browser_context); let state = materialized_navigation_test_state(None, "LOADER-1", "https://example.test/stale-no-id"); let navigation = @@ -1305,10 +1730,13 @@ async fn materialized_navigation_completion_drains_current_token() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("CTX-nav".to_owned()); browser_context.set_active_target_id("TID-nav"); - let current = browser_context - .start_document_navigation_for_active_target("LOADER-1".to_owned()) + conn.insert_browser_context(browser_context); + let current = conn + .start_document_navigation_for_session_owner(None, "LOADER-1".to_owned()) .expect("active target should produce current token"); - conn.browser_context = Some(browser_context); + let previous_page = conn + .target_page_residence_identity_for_session(None) + .expect("current Page residence"); let state = materialized_navigation_test_state(Some(8), "LOADER-1", "https://example.test/current"); let navigation = @@ -1323,7 +1751,7 @@ async fn materialized_navigation_completion_drains_current_token() { let mut command_context = CommandDispatchContext::default(); conn.drain_materialized_navigation_completion_into( &mut out, - MaterializedNavigationCompletion::new(current, state, navigation), + MaterializedNavigationCompletion::new(current.clone(), state, navigation), &mut command_context, ) .await; @@ -1335,6 +1763,78 @@ async fn materialized_navigation_completion_drains_current_token() { out[0]["error"]["message"], json!("current navigation should emit") ); + let facts = conn.browser_fact_snapshot_for_test(); + let failed = facts + .iter() + .find(|fact| { + matches!( + fact.fact(), + BrowserFact::NavigationFailed { navigation, .. } if navigation == ¤t + ) + }) + .expect("network failure should publish the exact request terminal"); + assert_eq!( + failed.fact(), + &BrowserFact::NavigationFailed { + navigation: current, + failure: BrowserNavigationFailure::Network { + error_text: "current navigation should emit".to_owned(), + }, + previous_page: Some(previous_page), + } + ); +} + +#[tokio::test] +async fn materialized_download_retires_request_without_replacing_current_page() { + let mut conn = CdpConnection::new(); + let mut browser_context = BrowserContext::new("CTX-download".to_owned()); + browser_context.set_active_target_id("TID-nav"); + conn.insert_browser_context(browser_context); + let navigation = conn + .start_document_navigation_for_session_owner(None, "LOADER-download".to_owned()) + .expect("active target should produce download token"); + let current_page = conn + .target_page_residence_identity_for_session(None) + .expect("current Page residence"); + let state = materialized_navigation_test_state( + Some(9), + "LOADER-download", + "https://example.test/download", + ); + let download = + MaterializedNavigationLoadOutcome::Download(MaterializedDownloadDocumentProgress { + final_url: Url::parse("https://example.test/download").expect("download URL"), + progress_gate: empty_main_document_progress_gate_for_test(), + body_artifact: CompletedDownloadBodyArtifact::from_body( + CompletedDownloadBody::Buffered(Vec::new()), + Vec::new(), + ), + }); + + let mut out = Vec::new(); + let mut command_context = CommandDispatchContext::default(); + conn.drain_materialized_navigation_completion_into( + &mut out, + MaterializedNavigationCompletion::new(navigation.clone(), state, download), + &mut command_context, + ) + .await; + + assert!(!conn.has_pending_document_navigation_for_session_owner(None)); + assert_eq!( + conn.target_page_residence_identity_for_session(None) + .as_ref(), + Some(¤t_page) + ); + let facts = conn.browser_fact_snapshot_for_test(); + assert!(facts.iter().any(|fact| { + fact.page_residence() == ¤t_page + && fact.fact() + == &BrowserFact::NavigationConvertedToDownload { + navigation: navigation.clone(), + } + })); } fn materialized_navigation_test_state( @@ -1361,6 +1861,7 @@ fn materialized_navigation_test_state( request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, } } diff --git a/moli-protocol/src/conn/tests/resource_runtime.rs b/moli-protocol/src/conn/tests/resource_runtime.rs index 3ddf620dfd..d588ac7ebc 100644 --- a/moli-protocol/src/conn/tests/resource_runtime.rs +++ b/moli-protocol/src/conn/tests/resource_runtime.rs @@ -13,6 +13,33 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::Notify; +const INACTIVE_OWNER_FIXTURE_SELECTED_CONTEXT_ID: &str = "BID-resource-runtime-selected-fixture"; + +fn insert_inactive_browser_context_for_navigation_fixture( + conn: &mut CdpConnection, + browser_context: BrowserContext, +) { + let browser_context_id = browser_context.id.clone(); + if conn.browser_context.is_none() { + conn.insert_browser_context(BrowserContext::new( + INACTIVE_OWNER_FIXTURE_SELECTED_CONTEXT_ID.to_owned(), + )); + } + conn.insert_browser_context(browser_context); + assert!( + conn.inactive_browser_contexts + .iter() + .any(|context| context.id == browser_context_id), + "fixture BrowserContext must remain inactive after formal registration" + ); +} + +fn inactive_owner_fixture_selection_remains(conn: &CdpConnection) -> bool { + conn.browser_context + .as_ref() + .is_some_and(|context| context.id == INACTIVE_OWNER_FIXTURE_SELECTED_CONTEXT_ID) +} + fn stored_cookie(name: &str, value: &str) -> moli_cookie_jar::StoredCookie { moli_cookie_jar::StoredCookie { name: name.to_owned(), @@ -103,18 +130,24 @@ fn connection_initial_cookies_seed_new_browser_contexts() { #[test] fn initial_storage_partition_derives_store_handles_from_core_owner() { let storage_partition = StoragePartitionState::open(None).expect("memory partition"); - let initial_storage_partition = CdpInitialStoragePartition::from_storage_partition( - vec![stored_cookie("sid", "seeded")], - &storage_partition, - ); + storage_partition + .import_cookies([stored_cookie("sid", "seeded")]) + .expect("seed Browser-owned cookies"); + let initial_storage_partition = + CdpInitialStoragePartition::from_storage_partition(&storage_partition); let conn = CdpConnection::new_with_initial_storage_partition(initial_storage_partition); let browser_context = conn.new_browser_context("BID-owner".to_owned()); let shared_storage = storage_partition.shared_storage_handles(); + let expected_cookie_store = shared_storage.cookie_store(); let expected_web_storage_store = shared_storage.web_storage_store(); let expected_indexed_db_manager = shared_storage.indexed_db_manager(); let expected_storage_bucket_store = shared_storage.storage_bucket_store(); + assert!(Arc::ptr_eq( + browser_context.cookie_store_for_test(), + &expected_cookie_store + )); assert!(Arc::ptr_eq( browser_context.web_storage_store_for_test(), &expected_web_storage_store @@ -137,7 +170,7 @@ fn initial_storage_partition_derives_store_handles_from_core_owner() { fn default_browser_contexts_reuse_partition_with_distinct_target_session_storage() { let storage_partition = StoragePartitionState::open(None).expect("memory partition"); let initial_storage_partition = - CdpInitialStoragePartition::from_storage_partition(Vec::new(), &storage_partition); + CdpInitialStoragePartition::from_storage_partition(&storage_partition); let conn = CdpConnection::new_with_initial_storage_partition(initial_storage_partition); let first = conn.new_browser_context("BID-first".to_owned()); @@ -188,7 +221,7 @@ fn connection_ephemeral_browser_context_uses_isolated_storage_partition() { fn connection_default_and_ephemeral_context_creation_use_named_partition_paths() { let storage_partition = StoragePartitionState::open(None).expect("memory partition"); let initial_storage_partition = - CdpInitialStoragePartition::from_storage_partition(Vec::new(), &storage_partition); + CdpInitialStoragePartition::from_storage_partition(&storage_partition); let conn = CdpConnection::new_with_initial_storage_partition(initial_storage_partition); let profile_backed = conn.new_browser_context("BID-profile".to_owned()); @@ -449,34 +482,6 @@ fn connection_snapshot_cookies_collects_active_and_inactive_contexts() { assert_eq!(names, vec!["active", "inactive"]); } -#[test] -fn connection_profile_backed_cookie_snapshot_ignores_ephemeral_contexts() { - let mut conn = CdpConnection::new(); - let profile_backed = conn.new_browser_context("BID-profile".to_owned()); - profile_backed.upsert_cookie_for_test(stored_cookie("profile", "1")); - let ephemeral = conn.new_ephemeral_browser_context("BID-ephemeral".to_owned()); - ephemeral.upsert_cookie_for_test(stored_cookie("ephemeral", "1")); - conn.browser_context = Some(ephemeral); - conn.inactive_browser_contexts.push(profile_backed); - - let cookies = conn - .snapshot_profile_backed_cookies() - .expect("profile-backed snapshot"); - - assert_eq!(cookies.len(), 1); - assert_eq!(cookies[0].name, "profile"); -} - -#[test] -fn connection_profile_backed_cookie_snapshot_is_none_without_profile_backed_context() { - let mut conn = CdpConnection::new_with_initial_cookies(vec![stored_cookie("sid", "seeded")]); - let ephemeral = conn.new_ephemeral_browser_context("BID-ephemeral".to_owned()); - assert!(ephemeral.snapshot_cookies().is_empty()); - conn.browser_context = Some(ephemeral); - - assert!(conn.snapshot_profile_backed_cookies().is_none()); -} - #[tokio::test] async fn build_loaded_navigation_from_buffered_response_updates_request_cookie_access_time() { let mut conn = CdpConnection::new(); @@ -623,7 +628,7 @@ async fn reset_resource_runtime_clears_loaded_page_cookie_backend() { .runtime_slot .set_loaded_page_for_test(navigation.page); - let page = conn + let mut page = conn .browser_context .as_mut() .unwrap() @@ -636,10 +641,11 @@ async fn reset_resource_runtime_clears_loaded_page_cookie_backend() { .await .expect("cookie read should succeed"); assert_eq!(before["value"], json!("theme=dark")); + drop(page); conn.reset_resource_runtime_async().await; - let page = conn + let mut page = conn .browser_context .as_mut() .unwrap() @@ -652,6 +658,7 @@ async fn reset_resource_runtime_clears_loaded_page_cookie_backend() { .await .expect("cookie read should still evaluate"); assert_eq!(after["value"], json!("")); + drop(page); let snapshot = conn .browser_context @@ -732,9 +739,8 @@ async fn same_target_navigations_reuse_local_and_session_storage() { #[tokio::test] async fn browser_context_storage_does_not_cross_context_switches() { let mut conn = CdpConnection::new(); - conn.browser_context = Some(BrowserContext::new("BID-1".into())); - conn.inactive_browser_contexts - .push(BrowserContext::new("BID-2".into())); + conn.insert_browser_context(BrowserContext::new("BID-1".into())); + conn.insert_browser_context(BrowserContext::new("BID-2".into())); let url = Url::parse("https://context-storage.example/app").unwrap(); let mut first = conn @@ -784,8 +790,8 @@ async fn browser_context_storage_buckets_reuse_within_context_and_isolate_betwee let mut conn = CdpConnection::new(); let context_a = conn.new_ephemeral_browser_context("BID-1".into()); let context_b = conn.new_ephemeral_browser_context("BID-2".into()); - conn.browser_context = Some(context_a); - conn.inactive_browser_contexts.push(context_b); + conn.insert_browser_context(context_a); + conn.insert_browser_context(context_b); let first_url = Url::parse("https://context-storage-buckets.example/app/one").unwrap(); let second_url = Url::parse("https://context-storage-buckets.example/app/two").unwrap(); @@ -894,7 +900,7 @@ async fn user_agent_override_rebinds_live_document_after_engine_runtime_invalida conn.invalidate_resource_runtime(); conn.set_user_agent_override_async("Moli/Reset").await; - let page = conn + let mut page = conn .browser_context .as_mut() .unwrap() @@ -937,7 +943,7 @@ async fn tls_and_proxy_overrides_rebind_live_document_after_engine_runtime_inval // authority; they must not retire that authority first. conn.invalidate_resource_runtime(); conn.set_tls_verify_host_async(false).await; - let page = conn + let mut page = conn .browser_context .as_mut() .unwrap() @@ -950,11 +956,12 @@ async fn tls_and_proxy_overrides_rebind_live_document_after_engine_runtime_inval .await .expect("cookie read should succeed after tls rebuild"); assert_eq!(after_tls["value"], json!("theme=dark")); + drop(page); conn.invalidate_resource_runtime(); conn.set_http_proxy_override_async(Some("http://proxy.test:8080".into())) .await; - let page = conn + let mut page = conn .browser_context .as_mut() .unwrap() @@ -1013,13 +1020,13 @@ async fn loader_uses_active_browser_context_user_agent_override() { first .network_policy .set_user_agent_override("Moli/Context-A".into()); - conn.browser_context = Some(first); + conn.insert_browser_context(first); let mut second = BrowserContext::new("BID-2".into()); second .network_policy .set_user_agent_override("Moli/Context-B".into()); - conn.inactive_browser_contexts.push(second); + conn.insert_browser_context(second); assert_eq!( conn.ensure_resource_request_client() @@ -1042,11 +1049,11 @@ async fn loader_uses_active_browser_context_http_proxy_override() { let mut conn = CdpConnection::new(); let mut first = BrowserContext::new("BID-1".into()); first.http_proxy_override = Some("http://proxy-a.test:8080".into()); - conn.browser_context = Some(first); + conn.insert_browser_context(first); let mut second = BrowserContext::new("BID-2".into()); second.http_proxy_override = Some("http://proxy-b.test:8080".into()); - conn.inactive_browser_contexts.push(second); + conn.insert_browser_context(second); assert_eq!( conn.ensure_resource_request_client() @@ -1069,11 +1076,11 @@ async fn loader_uses_active_browser_context_http_no_proxy_override() { let mut conn = CdpConnection::new(); let mut first = BrowserContext::new("BID-1".into()); first.http_no_proxy_override = Some("localhost,127.0.0.1".into()); - conn.browser_context = Some(first); + conn.insert_browser_context(first); let mut second = BrowserContext::new("BID-2".into()); second.http_no_proxy_override = Some("::1,.example.com".into()); - conn.inactive_browser_contexts.push(second); + conn.insert_browser_context(second); assert_eq!( conn.ensure_resource_request_client() @@ -1096,11 +1103,11 @@ async fn loader_uses_active_browser_context_tls_verify_host_override() { let mut conn = CdpConnection::new(); let mut first = BrowserContext::new("BID-1".into()); first.tls_verify_host_override = Some(false); - conn.browser_context = Some(first); + conn.insert_browser_context(first); let mut second = BrowserContext::new("BID-2".into()); second.tls_verify_host_override = Some(true); - conn.inactive_browser_contexts.push(second); + conn.insert_browser_context(second); assert!( !conn @@ -1125,19 +1132,19 @@ async fn removing_an_inactive_browser_context_keeps_the_previously_active_contex first .network_policy .set_user_agent_override("Moli/Context-A".into()); - conn.browser_context = Some(first); + conn.insert_browser_context(first); let mut second = BrowserContext::new("BID-B".into()); second .network_policy .set_user_agent_override("Moli/Context-B".into()); - conn.inactive_browser_contexts.push(second); + conn.insert_browser_context(second); let mut third = BrowserContext::new("BID-C".into()); third .network_policy .set_user_agent_override("Moli/Context-C".into()); - conn.inactive_browser_contexts.push(third); + conn.insert_browser_context(third); assert!(conn.activate_browser_context_by_id_async("BID-B").await); assert_eq!(conn.browser_context.as_ref().unwrap().id, "BID-B"); @@ -1146,7 +1153,7 @@ async fn removing_an_inactive_browser_context_keeps_the_previously_active_contex .remove_browser_context_by_id_restoring_active_async("BID-B", Some("BID-A")) .await .expect("inactive context should be removable after selection"); - assert_eq!(removed.id, "BID-B"); + assert_eq!(removed, "BID-B"); assert_eq!( conn.browser_context.as_ref().map(|bc| bc.id.as_str()), @@ -1175,13 +1182,13 @@ async fn manual_browser_context_restore_reselects_original_context_after_switch( first .network_policy .set_user_agent_override("Moli/Context-A".into()); - conn.browser_context = Some(first); + conn.insert_browser_context(first); let mut second = BrowserContext::new("BID-B".into()); second .network_policy .set_user_agent_override("Moli/Context-B".into()); - conn.inactive_browser_contexts.push(second); + conn.insert_browser_context(second); let previously_active_browser_context_id = conn.browser_context.as_ref().map(|bc| bc.id.clone()); @@ -1223,6 +1230,7 @@ async fn session_scoped_process_message_restores_previously_active_context_after conn.browser_context = Some(first); let mut second = BrowserContext::new("BID-B".into()); + second.set_active_target_id("TID-B"); second.attach_active_session("SID-B"); conn.inactive_browser_contexts.push(second); @@ -1262,6 +1270,7 @@ async fn session_scoped_process_message_async_restores_previously_active_context conn.browser_context = Some(first); let mut second = BrowserContext::new("BID-B".into()); + second.set_active_target_id("TID-B"); second.attach_active_session("SID-B"); conn.inactive_browser_contexts.push(second); @@ -1300,7 +1309,7 @@ async fn direct_network_enable_routes_to_inactive_active_owner_without_promoting let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-B"); - conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -1312,7 +1321,7 @@ async fn direct_network_enable_routes_to_inactive_active_owner_without_promoting vec![json!({"id": 1, "result": {}, "sessionId": "SID-B"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct Network.enable should not promote the inactive owner into the active slot" ); let inactive = conn @@ -1331,34 +1340,29 @@ async fn direct_network_enable_routes_to_inactive_active_owner_without_promoting #[tokio::test] async fn direct_runtime_evaluate_routes_to_inactive_active_owner_without_promoting_slot() { let mut ctx = crate::testing::TestContext::new(); - + let page_url = "data:text/html,runtime-direct-owner"; let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-B"); inactive - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; - ctx.conn.inactive_browser_contexts.push(inactive); - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,runtime-direct-owner", - Some("SID-B"), - ) - .await; + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); + ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-B")) + .await; - ctx.sent.clear(); ctx.process_async(json!({ "id": 1, "method": "Runtime.evaluate", "sessionId": "SID-B", - "params": {"expression": "document.title", "returnByValue": true} + "params": { + "expression": "document.title", + "returnByValue": true + } })) .await; - let response = std::mem::take(&mut ctx.sent); - let response = response - .iter() - .find(|message| message["id"] == json!(1)) - .unwrap_or_else(|| panic!("missing Runtime.evaluate response: {response:?}")); + let response = ctx.take_response_by_id(1); assert_eq!(response["id"], json!(1)); assert_eq!( response["result"]["result"]["value"], @@ -1367,7 +1371,7 @@ async fn direct_runtime_evaluate_routes_to_inactive_active_owner_without_promoti ); assert_eq!(response["sessionId"], json!("SID-B")); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Runtime.evaluate should not promote the inactive owner into the active slot" ); } @@ -1379,15 +1383,18 @@ async fn direct_runtime_evaluate_document_replacement_lifecycle_uses_inactive_ow inactive.set_active_target_id("TID-document-replacement".to_owned()); inactive.attach_active_session("SID-document-replacement"); inactive - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; - inactive.devtools_session_state.dom_session_state.enabled = true; inactive - .devtools_session_state + .devtools_session_state_mut() + .dom_session_state + .enabled = true; + inactive + .devtools_session_state_mut() .page_session_state .page_lifecycle_events = true; - ctx.conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner( "data:text/html,before", Some("SID-document-replacement"), @@ -1441,13 +1448,13 @@ async fn direct_runtime_evaluate_document_replacement_lifecycle_uses_inactive_ow "document replacement lifecycle should use the inactive owner frame id: {response:?}" ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Runtime.evaluate document replacement should not activate the inactive owner" ); } #[test] -fn devtools_document_lifecycle_wait_key_observes_interruption_and_target_loss() { +fn devtools_document_lifecycle_wait_key_consumes_retained_and_future_browser_facts() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-lifecycle-wait".into()); browser_context.set_active_target_id("TID-lifecycle-wait".to_owned()); @@ -1456,7 +1463,7 @@ fn devtools_document_lifecycle_wait_key_observes_interruption_and_target_loss() .active_target .runtime_slot .set_page_attachment_id_for_test(901); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page_id = moli_core::PageId::new_for_testing(901); let frame = moli_core::page::RendererFrameToken { page_id }; @@ -1531,11 +1538,19 @@ fn devtools_document_lifecycle_wait_key_observes_interruption_and_target_loss() moli_core::page::RendererDocumentLifecycleMilestone::DomContentLoaded ); assert_eq!( - conn.devtools_document_lifecycle_wait_state(&context, &dcl_key), + dcl_key.state(), DevToolsDocumentLifecycleWaitState::Reached, - "a waiter registered after DCL must observe the committed lifecycle snapshot" + "a fact wait captured after DCL must observe the retained Browser fact" + ); + assert!( + conn.capture_devtools_document_lifecycle_wait_key( + &context, + "LID-other", + moli_core::page::RendererDocumentLifecycleMilestone::Load, + ) + .is_none(), + "the frontend loader projection must not bind a fact wait to another Document" ); - assert!(conn.release_devtools_document_lifecycle_wait_key(&context, &dcl_key)); let key = conn .capture_devtools_document_lifecycle_wait_key( @@ -1548,10 +1563,7 @@ fn devtools_document_lifecycle_wait_key_observes_interruption_and_target_loss() key.milestone(), moli_core::page::RendererDocumentLifecycleMilestone::Load ); - assert_eq!( - conn.devtools_document_lifecycle_wait_state(&context, &key), - DevToolsDocumentLifecycleWaitState::Pending - ); + assert_eq!(key.state(), DevToolsDocumentLifecycleWaitState::Pending); let terminated = moli_core::page::RendererDocumentLifecycleEvent { sequence: 3, @@ -1570,55 +1582,9 @@ fn devtools_document_lifecycle_wait_key_observes_interruption_and_target_loss() ); assert_eq!(accepted, vec![terminated]); assert_eq!( - conn.devtools_document_lifecycle_wait_state(&context, &key), - DevToolsDocumentLifecycleWaitState::Interrupted - ); - - conn.browser_context = None; - assert_eq!( - conn.devtools_document_lifecycle_wait_state(&context, &key), - DevToolsDocumentLifecycleWaitState::Unavailable - ); - - let mut replacement_context = BrowserContext::new("BID-other".into()); - replacement_context.set_active_target_id("TID-other".to_owned()); - replacement_context.attach_active_session("SID-lifecycle-wait"); - replacement_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(902); - conn.browser_context = Some(replacement_context); - let (_, accepted) = conn.bind_renderer_document_lifecycle_for_session_owner( - Some("SID-lifecycle-wait"), - moli_core::page::RendererPageCreationArtifacts { - active_document: document, - active_epoch: epoch, - lifecycle_snapshot: moli_core::page::RendererDocumentLifecycleSnapshot { - frame, - document, - epoch, - started: moli_core::page::RendererLifecycleEventStamp { - sequence: 1, - timestamp_micros: 10, - }, - dom_content_loaded: Some(moli_core::page::RendererLifecycleEventStamp { - sequence: 2, - timestamp_micros: 20, - }), - load: None, - terminated: None, - }, - initial_lifecycle_events: vec![started, dcl], - }, - None, - "TID-other".to_owned(), - "LID-other".to_owned(), - ); - assert_eq!(accepted, vec![started, dcl]); - assert_eq!( - conn.devtools_document_lifecycle_wait_state(&context, &key), - DevToolsDocumentLifecycleWaitState::Unavailable, - "a missing targetId route must not fall back to a same-named session on another target" + key.state(), + DevToolsDocumentLifecycleWaitState::Interrupted, + "a future exact termination fact must settle the move-only wait without Page callbacks" ); } @@ -1630,11 +1596,11 @@ async fn direct_runtime_evaluate_same_document_navigation_updates_inactive_owner inactive.set_active_target_id("TID-same-document".to_owned()); inactive.attach_active_session("SID-same-document"); inactive - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; inactive.set_target_url(initial_url.clone()); - ctx.conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner(&initial_url, Some("SID-same-document")) .await; @@ -1689,7 +1655,7 @@ async fn direct_runtime_evaluate_same_document_navigation_updates_inactive_owner "same-document navigation should update the inactive owner target URL" ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Runtime.evaluate same-document navigation should not activate the inactive owner" ); } @@ -1706,13 +1672,13 @@ async fn direct_runtime_evaluate_javascript_dialog_uses_inactive_background_owne let mut inactive = BrowserContext::new("BID-dialog-background".into()); inactive.background_targets.push(background); - inactive.mutate_parked_page_session_state("TID-dialog-background", |state| { - state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled = true; - }); - ctx.conn.inactive_browser_contexts.push(inactive); + inactive.adopt_background_target_fixture_attachments(); + inactive + .primary_devtools_session_state_for_target_mut("TID-dialog-background") + .expect("background Target frontend state") + .runtime_session_state + .runtime_frontend_enabled = true; + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-dialog-background")) .await; @@ -1747,7 +1713,7 @@ async fn direct_runtime_evaluate_javascript_dialog_uses_inactive_background_owne "JavaScript dialog opening should use the background owner identity: {response:?}" ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Runtime.evaluate dialog output should not activate the inactive owner" ); @@ -1778,7 +1744,7 @@ async fn direct_runtime_evaluate_javascript_dialog_uses_inactive_background_owne "Page.handleJavaScriptDialog should resolve under the owner session: {response:?}" ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Page.handleJavaScriptDialog should not activate the inactive owner" ); let inactive = ctx @@ -1789,9 +1755,8 @@ async fn direct_runtime_evaluate_javascript_dialog_uses_inactive_background_owne .expect("inactive owner should remain parked"); assert!( inactive - .parked_page_session_state("TID-dialog-background") - .expect("background page session state should remain parked") - .devtools_session_state + .primary_devtools_session_state_for_target("TID-dialog-background") + .expect("background Target frontend state should remain resident") .page_session_state .javascript_dialog_state .is_empty(), @@ -1811,13 +1776,13 @@ async fn direct_runtime_evaluate_popup_creates_target_in_inactive_background_own let mut inactive = BrowserContext::new("BID-popup-background".into()); inactive.background_targets.push(background); - inactive.mutate_parked_page_session_state("TID-popup-background", |state| { - state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled = true; - }); - ctx.conn.inactive_browser_contexts.push(inactive); + inactive.adopt_background_target_fixture_attachments(); + inactive + .primary_devtools_session_state_for_target_mut("TID-popup-background") + .expect("background Target frontend state") + .runtime_session_state + .runtime_frontend_enabled = true; + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-popup-background")) .await; @@ -1861,7 +1826,7 @@ async fn direct_runtime_evaluate_popup_creates_target_in_inactive_background_own json!("TID-popup-background") ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Runtime.evaluate popup output should not activate the inactive owner" ); let popup_target_id = created["params"]["targetInfo"]["targetId"] @@ -1890,7 +1855,7 @@ async fn direct_runtime_evaluate_self_popup_does_not_navigate_active_target_for_ active.set_active_target_id("TID-active".to_owned()); active.attach_active_session("SID-active"); active.set_target_url("https://active.example/current".to_owned()); - ctx.conn.browser_context = Some(active); + ctx.conn.insert_browser_context(active); let page_url = "data:text/html,self-popup"; let background = BackgroundTarget::with_url( @@ -1901,13 +1866,13 @@ async fn direct_runtime_evaluate_self_popup_does_not_navigate_active_target_for_ let mut inactive = BrowserContext::new("BID-self-popup-background".into()); inactive.background_targets.push(background); - inactive.mutate_parked_page_session_state("TID-self-popup-background", |state| { - state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled = true; - }); - ctx.conn.inactive_browser_contexts.push(inactive); + inactive.adopt_background_target_fixture_attachments(); + inactive + .primary_devtools_session_state_for_target_mut("TID-self-popup-background") + .expect("background Target frontend state") + .runtime_session_state + .runtime_frontend_enabled = true; + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-self-popup-background")) .await; @@ -1956,17 +1921,17 @@ async fn direct_runtime_evaluate_file_chooser_uses_inactive_background_owner() { let mut inactive = BrowserContext::new("BID-file-background".into()); inactive.background_targets.push(background); - inactive.mutate_parked_page_session_state("TID-file-background", |state| { - state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled = true; + inactive.adopt_background_target_fixture_attachments(); + { + let state = inactive + .primary_devtools_session_state_for_target_mut("TID-file-background") + .expect("background Target frontend state"); + state.runtime_session_state.runtime_frontend_enabled = true; state - .devtools_session_state .page_session_state .page_file_chooser_opened_event_enabled = true; - }); - ctx.conn.inactive_browser_contexts.push(inactive); + } + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-file-background")) .await; @@ -2014,7 +1979,7 @@ async fn direct_runtime_evaluate_file_chooser_uses_inactive_background_owner() { "file chooser backendNodeId should use renderer registry namespace: {file_chooser:?}" ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Runtime.evaluate file chooser output should not activate the inactive owner" ); } @@ -2022,35 +1987,30 @@ async fn direct_runtime_evaluate_file_chooser_uses_inactive_background_owner() { #[tokio::test] async fn direct_runtime_evaluate_routes_to_inactive_auxiliary_owner_without_promoting_slot() { let mut ctx = crate::testing::TestContext::new(); - + let page_url = "data:text/html,runtime-direct-aux"; let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-primary"); assert!(inactive.assign_auxiliary_session_to_target("TID-B", "SID-aux".to_owned())); inactive - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; - ctx.conn.inactive_browser_contexts.push(inactive); - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,runtime-direct-aux", - Some("SID-aux"), - ) - .await; + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); + ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-aux")) + .await; - ctx.sent.clear(); ctx.process_async(json!({ "id": 1, "method": "Runtime.evaluate", "sessionId": "SID-aux", - "params": {"expression": "document.title", "returnByValue": true} + "params": { + "expression": "document.title", + "returnByValue": true + } })) .await; - let response = std::mem::take(&mut ctx.sent); - let response = response - .iter() - .find(|message| message["id"] == json!(1)) - .unwrap_or_else(|| panic!("missing Runtime.evaluate response: {response:?}")); + let response = ctx.take_response_by_id(1); assert_eq!(response["id"], json!(1)); assert_eq!( response["result"]["result"]["value"], @@ -2059,7 +2019,7 @@ async fn direct_runtime_evaluate_routes_to_inactive_auxiliary_owner_without_prom ); assert_eq!(response["sessionId"], json!("SID-aux")); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct auxiliary Runtime.evaluate should not promote the inactive owner" ); } @@ -2072,7 +2032,7 @@ async fn direct_network_enable_disable_routes_to_inactive_auxiliary_owner_withou inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-primary"); assert!(inactive.assign_auxiliary_session_to_target("TID-B", "SID-aux".to_owned())); - conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -2084,7 +2044,7 @@ async fn direct_network_enable_disable_routes_to_inactive_auxiliary_owner_withou vec![json!({"id": 1, "result": {}, "sessionId": "SID-aux"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct auxiliary Network.enable should not promote the inactive owner" ); let inactive = conn @@ -2116,7 +2076,7 @@ async fn direct_network_enable_disable_routes_to_inactive_auxiliary_owner_withou vec![json!({"id": 2, "result": {}, "sessionId": "SID-aux"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct auxiliary Network.disable should not promote the inactive owner" ); let inactive = conn @@ -2139,7 +2099,7 @@ async fn direct_page_preload_routes_to_inactive_active_owner_without_promoting_s let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-B"); - conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -2151,7 +2111,7 @@ async fn direct_page_preload_routes_to_inactive_active_owner_without_promoting_s vec![json!({"id": 1, "result": {"identifier": "1"}, "sessionId": "SID-B"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct Page.addScriptToEvaluateOnNewDocument should not activate the inactive owner" ); assert!( @@ -2174,7 +2134,7 @@ async fn direct_page_preload_routes_to_inactive_active_owner_without_promoting_s vec![json!({"id": 2, "result": {}, "sessionId": "SID-B"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct Page.removeScriptToEvaluateOnNewDocument should not activate the inactive owner" ); assert!( @@ -2191,13 +2151,15 @@ async fn direct_page_preload_routes_to_inactive_background_owner_without_promoti let mut conn = CdpConnection::new(); let mut inactive = BrowserContext::new("BID-B".into()); - inactive.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); - conn.inactive_browser_contexts.push(inactive); + inactive + .background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -2209,7 +2171,7 @@ async fn direct_page_preload_routes_to_inactive_background_owner_without_promoti vec![json!({"id": 1, "result": {"identifier": "1"}, "sessionId": "SID-background"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct Page.addScriptToEvaluateOnNewDocument should not activate the inactive background owner" ); assert!( @@ -2232,7 +2194,7 @@ async fn direct_page_preload_routes_to_inactive_background_owner_without_promoti vec![json!({"id": 2, "result": {}, "sessionId": "SID-background"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct Page.removeScriptToEvaluateOnNewDocument should not activate the inactive background owner" ); assert!( @@ -2249,13 +2211,15 @@ async fn direct_network_enable_routes_to_inactive_background_owner_without_promo let mut conn = CdpConnection::new(); let mut inactive = BrowserContext::new("BID-B".into()); - inactive.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); - conn.inactive_browser_contexts.push(inactive); + inactive + .background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -2267,7 +2231,7 @@ async fn direct_network_enable_routes_to_inactive_background_owner_without_promo vec![json!({"id": 1, "result": {}, "sessionId": "SID-background"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct background Network.enable should not promote the inactive owner" ); let inactive = conn @@ -2288,17 +2252,19 @@ async fn direct_auxiliary_network_enable_for_background_target_does_not_enable_p let mut conn = CdpConnection::new(); let mut inactive = BrowserContext::new("BID-B".into()); - inactive.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); + inactive + .background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); assert!( inactive .assign_auxiliary_session_to_target("TID-background", "SID-aux-background".to_owned()) ); - conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -2329,12 +2295,14 @@ async fn direct_network_enable_routes_to_active_background_owner_without_promoti let mut active = BrowserContext::new("BID-A".into()); active.set_active_target_id("TID-active".to_owned()); active.attach_active_session("SID-active"); - active.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); + active + .background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); conn.browser_context = Some(active); let response = conn @@ -2433,7 +2401,7 @@ async fn direct_network_enable_for_loaded_background_owner_starts_at_network_tai let mut inactive = BrowserContext::new("BID-B".into()); inactive.background_targets.push(background); - ctx.conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-background")) .await; assert!( @@ -2494,16 +2462,18 @@ async fn direct_background_command_does_not_emit_active_observable_output_under_ active.set_active_target_id("TID-active".to_owned()); active.attach_active_session("SID-active"); active - .devtools_session_state + .devtools_session_state_mut() .console_output_session_state .console_enabled = true; active.replace_loaded_page(Some(active_page)); - active.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); + active + .background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); conn.browser_context = Some(active); let response = conn @@ -2526,7 +2496,7 @@ async fn direct_console_routes_to_inactive_active_owner_without_promoting_slot() let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-B"); - ctx.conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-B")) .await; ctx.wait_for_scheduler_message("inactive console fixture load", |message| { @@ -2557,7 +2527,7 @@ async fn direct_console_routes_to_inactive_active_owner_without_promoting_slot() "Console.enable should still return success: {response:?}" ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Console.enable should not promote the inactive owner into the active slot" ); let inactive = ctx @@ -2568,7 +2538,7 @@ async fn direct_console_routes_to_inactive_active_owner_without_promoting_slot() .expect("inactive owner must remain parked"); assert!( inactive - .devtools_session_state + .devtools_session_state() .console_output_session_state .console_enabled ); @@ -2594,7 +2564,7 @@ async fn direct_console_routes_to_inactive_active_owner_without_promoting_slot() vec![json!({"id": 2, "result": {}, "sessionId": "SID-B"})] ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Console.clearMessages should not promote the inactive owner" ); @@ -2610,7 +2580,7 @@ async fn direct_console_routes_to_inactive_active_owner_without_promoting_slot() vec![json!({"id": 3, "result": {}, "sessionId": "SID-B"})] ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Console.disable should not promote the inactive owner" ); let inactive = ctx @@ -2621,7 +2591,7 @@ async fn direct_console_routes_to_inactive_active_owner_without_promoting_slot() .expect("inactive owner must remain parked"); assert!( !inactive - .devtools_session_state + .devtools_session_state() .console_output_session_state .console_enabled ); @@ -2641,13 +2611,15 @@ async fn direct_console_routes_to_inactive_background_owner_without_promoting_sl let mut conn = CdpConnection::new(); let mut inactive = BrowserContext::new("BID-B".into()); - inactive.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); - conn.inactive_browser_contexts.push(inactive); + inactive + .background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -2659,7 +2631,7 @@ async fn direct_console_routes_to_inactive_background_owner_without_promoting_sl vec![json!({"id": 1, "result": {}, "sessionId": "SID-background"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct background Console.enable should not promote the inactive owner" ); let inactive = conn @@ -2669,11 +2641,8 @@ async fn direct_console_routes_to_inactive_background_owner_without_promoting_sl .expect("inactive owner must remain parked"); assert!( inactive - .parked_page_session_state("TID-background") - .is_some_and(|state| state - .devtools_session_state - .console_output_session_state - .console_enabled), + .primary_devtools_session_state_for_target("TID-background") + .is_some_and(|state| state.console_output_session_state.console_enabled), "background target should stage Console.enable" ); @@ -2687,7 +2656,7 @@ async fn direct_console_routes_to_inactive_background_owner_without_promoting_sl vec![json!({"id": 2, "result": {}, "sessionId": "SID-background"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct background Console.clearMessages should not promote the inactive owner" ); let inactive = conn @@ -2697,11 +2666,8 @@ async fn direct_console_routes_to_inactive_background_owner_without_promoting_sl .expect("inactive owner must remain parked"); assert!( inactive - .parked_page_session_state("TID-background") - .is_some_and(|state| state - .devtools_session_state - .console_output_session_state - .console_enabled), + .primary_devtools_session_state_for_target("TID-background") + .is_some_and(|state| state.console_output_session_state.console_enabled), "clearMessages should not disable the staged background Console state" ); @@ -2715,7 +2681,7 @@ async fn direct_console_routes_to_inactive_background_owner_without_promoting_sl vec![json!({"id": 3, "result": {}, "sessionId": "SID-background"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct background Console.disable should not promote the inactive owner" ); let inactive = conn @@ -2725,11 +2691,8 @@ async fn direct_console_routes_to_inactive_background_owner_without_promoting_sl .expect("inactive owner must remain parked"); assert!( inactive - .parked_page_session_state("TID-background") - .is_none_or(|state| !state - .devtools_session_state - .console_output_session_state - .console_enabled), + .primary_devtools_session_state_for_target("TID-background") + .is_none_or(|state| !state.console_output_session_state.console_enabled), "background target should stage or collapse Console.disable" ); } @@ -2747,7 +2710,7 @@ async fn direct_console_routes_to_loaded_background_owner_and_advances_parked_cu let mut inactive = BrowserContext::new("BID-B".into()); inactive.background_targets.push(background); - ctx.conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-background")) .await; ctx.wait_for_scheduler_message("background console fixture load", |message| { @@ -2836,7 +2799,7 @@ async fn direct_log_enable_routes_to_inactive_active_owner_without_promoting_slo inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-B"); inactive.replace_loaded_page(Some(page)); - conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -2855,7 +2818,7 @@ async fn direct_log_enable_routes_to_inactive_active_owner_without_promoting_slo "direct Log.enable should not replay buffered console API output: {response:?}" ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct Log.enable should not promote the inactive owner into the active slot" ); let inactive = conn @@ -2865,13 +2828,13 @@ async fn direct_log_enable_routes_to_inactive_active_owner_without_promoting_slo .expect("inactive owner must remain parked"); assert!( inactive - .devtools_session_state + .devtools_session_state() .page_session_state .log_enabled ); assert_eq!( inactive - .devtools_session_state + .devtools_session_state() .console_output_session_state .log_lifecycle_entries, 0, @@ -2884,13 +2847,15 @@ async fn direct_log_enable_routes_to_inactive_background_owner_without_promoting let mut conn = CdpConnection::new(); let mut inactive = BrowserContext::new("BID-B".into()); - inactive.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); - conn.inactive_browser_contexts.push(inactive); + inactive + .background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -2902,7 +2867,7 @@ async fn direct_log_enable_routes_to_inactive_background_owner_without_promoting vec![json!({"id": 1, "result": {}, "sessionId": "SID-background"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct background Log.enable should not promote the inactive owner" ); let inactive = conn @@ -2912,8 +2877,8 @@ async fn direct_log_enable_routes_to_inactive_background_owner_without_promoting .expect("inactive owner must remain parked"); assert!( inactive - .parked_page_session_state("TID-background") - .is_some_and(|state| state.devtools_session_state.page_session_state.log_enabled), + .primary_devtools_session_state_for_target("TID-background") + .is_some_and(|state| state.page_session_state.log_enabled), "background target should stage Log.enable" ); } @@ -2928,7 +2893,7 @@ async fn direct_log_enable_routes_to_loaded_background_owner_without_replaying_c .await .expect("background page should load"); - let mut background = BackgroundTarget::new( + let mut background = BackgroundTarget::new_with_frontend_session( "TID-background".to_owned(), Some("SID-background".to_owned()), TargetIdentityState::about_blank(), @@ -2939,7 +2904,7 @@ async fn direct_log_enable_routes_to_loaded_background_owner_without_replaying_c let mut inactive = BrowserContext::new("BID-B".into()); inactive.background_targets.push(background); - conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -2964,9 +2929,8 @@ async fn direct_log_enable_routes_to_loaded_background_owner_without_replaying_c .expect("inactive owner must remain parked"); assert_eq!( inactive - .parked_page_session_state("TID-background") - .expect("parked session state") - .devtools_session_state + .primary_devtools_session_state_for_target("TID-background") + .expect("background Target frontend state") .console_output_session_state .log_lifecycle_entries, 0, @@ -2993,10 +2957,10 @@ async fn direct_log_disable_routes_to_inactive_active_owner_without_promoting_sl inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-B"); inactive - .devtools_session_state + .devtools_session_state_mut() .page_session_state .log_enabled = true; - ctx.conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut ctx.conn, inactive); ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-B")) .await; ctx.sent.clear(); @@ -3013,7 +2977,7 @@ async fn direct_log_disable_routes_to_inactive_active_owner_without_promoting_sl vec![json!({"id": 1, "result": {}, "sessionId": "SID-B"})] ); assert!( - ctx.conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&ctx.conn), "direct Log.disable should not promote the inactive owner into the active slot" ); let inactive = ctx @@ -3024,13 +2988,13 @@ async fn direct_log_disable_routes_to_inactive_active_owner_without_promoting_sl .expect("inactive owner must remain parked"); assert!( !inactive - .devtools_session_state + .devtools_session_state() .page_session_state .log_enabled ); assert_eq!( inactive - .devtools_session_state + .devtools_session_state() .console_output_session_state .log_lifecycle_entries, 0, @@ -3043,16 +3007,21 @@ async fn direct_log_disable_routes_to_inactive_background_owner_without_promotin let mut conn = CdpConnection::new(); let mut inactive = BrowserContext::new("BID-B".into()); - inactive.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); - inactive.mutate_parked_page_session_state("TID-background", |state| { - state.devtools_session_state.page_session_state.log_enabled = true; - }); - conn.inactive_browser_contexts.push(inactive); + inactive + .background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + inactive.adopt_background_target_fixture_attachments(); + inactive + .primary_devtools_session_state_for_target_mut("TID-background") + .expect("background Target frontend state") + .page_session_state + .log_enabled = true; + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); let response = conn .process_message_messages_only_for_test( @@ -3064,7 +3033,7 @@ async fn direct_log_disable_routes_to_inactive_background_owner_without_promotin vec![json!({"id": 1, "result": {}, "sessionId": "SID-background"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct background Log.disable should not promote the inactive owner" ); let inactive = conn @@ -3074,8 +3043,8 @@ async fn direct_log_disable_routes_to_inactive_background_owner_without_promotin .expect("inactive owner must remain parked"); assert!( inactive - .parked_page_session_state("TID-background") - .is_none_or(|state| !state.devtools_session_state.page_session_state.log_enabled), + .primary_devtools_session_state_for_target("TID-background") + .is_none_or(|state| !state.page_session_state.log_enabled), "background target should stage or collapse Log.disable" ); } @@ -3087,7 +3056,7 @@ async fn direct_network_policy_routes_to_inactive_active_owner_without_promoting let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-B"); - conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); for raw in [ r#"{"id":1,"method":"Network.setCacheDisabled","sessionId":"SID-B","params":{"cacheDisabled":true}}"#, @@ -3108,7 +3077,7 @@ async fn direct_network_policy_routes_to_inactive_active_owner_without_promoting vec![json!({"id": request_id, "result": {}, "sessionId": "SID-B"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct Network policy commands should not promote the inactive owner" ); } @@ -3152,7 +3121,7 @@ async fn direct_network_policy_invalid_params_return_owner_plan_error_without_pr let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B".to_owned()); inactive.attach_active_session("SID-B"); - conn.inactive_browser_contexts.push(inactive); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); for raw in [ r#"{"id":1,"method":"Network.setCacheDisabled","sessionId":"SID-B","params":{}}"#, @@ -3173,7 +3142,7 @@ async fn direct_network_policy_invalid_params_return_owner_plan_error_without_pr })] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "invalid direct Network policy commands should not promote the inactive owner" ); } @@ -3195,13 +3164,15 @@ async fn direct_network_policy_routes_to_inactive_background_owner_without_promo let mut conn = CdpConnection::new(); let mut inactive = BrowserContext::new("BID-B".into()); - inactive.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); - conn.inactive_browser_contexts.push(inactive); + inactive + .background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + insert_inactive_browser_context_for_navigation_fixture(&mut conn, inactive); for raw in [ r#"{"id":1,"method":"Network.setCacheDisabled","sessionId":"SID-background","params":{"cacheDisabled":true}}"#, @@ -3222,7 +3193,7 @@ async fn direct_network_policy_routes_to_inactive_background_owner_without_promo vec![json!({"id": request_id, "result": {}, "sessionId": "SID-background"})] ); assert!( - conn.browser_context.is_none(), + inactive_owner_fixture_selection_remains(&conn), "direct background Network policy commands should not promote the inactive owner" ); } diff --git a/moli-protocol/src/conn/top_level_navigation_work.rs b/moli-protocol/src/conn/top_level_navigation_work.rs deleted file mode 100644 index e1045ae849..0000000000 --- a/moli-protocol/src/conn/top_level_navigation_work.rs +++ /dev/null @@ -1,74 +0,0 @@ -use moli_core::page::RendererDocumentSourcedTopLevelLocationNavigation; - -use super::{CdpConnection, CommandOwnerScope, TargetPageResidenceIdentity}; - -/// One renderer-requested top-level navigation projected from a concrete -/// renderer output record. -/// -/// The renderer freezes and moves the request at its producing turn boundary. -/// Protocol only projects that immutable record into scheduler work; it never -/// rescans mutable Page state. The action retains both identities needed at its -/// eventual turn: -/// -/// - `page_owner` rejects execution after Page replacement or retirement; -/// - `owner_scope` restores the target route used by an implicit (`None`) -/// session instead of resolving whichever target is current later. -/// -/// The source Document remains inside `navigation` as causal metadata. A -/// same-Page `document.open()` replacement therefore does not manufacture a -/// new request, while replacement of the Page residence makes the action -/// stale. -#[derive(Debug)] -pub(crate) struct TopLevelLocationNavigationOwnerAction { - owner_scope: CommandOwnerScope, - page_owner: TargetPageResidenceIdentity, - navigation: RendererDocumentSourcedTopLevelLocationNavigation, -} - -impl TopLevelLocationNavigationOwnerAction { - /// Wraps a navigation that has already been claimed while its exact - /// protocol route is installed. - /// - /// Preparing renderer output moves the navigation out of the Page before - /// the output slot is drained. Draining must therefore publish this - /// concrete action rather than execute the navigation inline or rescan the - /// current Page. - pub(crate) fn from_prepared( - conn: &CdpConnection, - session_id: Option<&str>, - page_owner: TargetPageResidenceIdentity, - navigation: RendererDocumentSourcedTopLevelLocationNavigation, - ) -> Self { - Self { - owner_scope: CommandOwnerScope::capture(conn, session_id), - page_owner, - navigation, - } - } - - pub(crate) fn session_id(&self) -> Option<&str> { - self.owner_scope.session_id() - } - - pub(crate) fn target_id(&self) -> Option<&str> { - self.page_owner.target_id() - } - - pub(crate) fn source_document(&self) -> moli_core::RendererDocumentLifecycleIdentity { - self.navigation.source_document() - } - - pub(crate) fn url(&self) -> &str { - self.navigation.url() - } - - pub(crate) fn into_parts( - self, - ) -> ( - CommandOwnerScope, - TargetPageResidenceIdentity, - RendererDocumentSourcedTopLevelLocationNavigation, - ) { - (self.owner_scope, self.page_owner, self.navigation) - } -} diff --git a/moli-protocol/src/devtools_host_adapter.rs b/moli-protocol/src/devtools_host_adapter.rs new file mode 100644 index 0000000000..81283b0e02 --- /dev/null +++ b/moli-protocol/src/devtools_host_adapter.rs @@ -0,0 +1,648 @@ +use crate::{ + CdpConnection, + conn::{BrowserHostTurnExecution, BrowserHostTurnExecutorOwner}, +}; + +/// Application-owned DevTools adapter for one Browser Host. +/// +/// This is deliberately distinct from a CDP socket frontend. The application +/// owner task keeps this adapter alive beside the Browser Host owner lane, +/// while any number of short-lived frontend endpoints may attach and detach. +/// Browser Core remains authoritative for browser identity, topology, +/// navigation, Page lifetime and browser-global policy; the wrapped +/// [`CdpConnection`] retains the migration-period renderer/DevTools projection +/// needed to translate commands and facts. +/// +/// The wrapper is intentionally not `Clone`. There is one mutable adapter +/// residence per owner task, and a frontend endpoint never receives it. +pub struct DevToolsHostAdapter { + connection: CdpConnection, +} + +impl DevToolsHostAdapter { + /// Transfers a protocol adapter into its application owner residence. + pub fn for_application_owner(connection: CdpConnection) -> Self { + Self { connection } + } + + /// Binds the physical projection to one exact Browser Host execution + /// authority. The wrapped connection is not otherwise exposed mutably. + pub(crate) fn bind_browser_host_turn<'a>( + &'a mut self, + owner: &'a mut BrowserHostTurnExecutorOwner, + ) -> BrowserHostTurnExecution<'a> { + owner.bind_turn(&mut self.connection) + } + + /// Borrows the read-only identity and routing capability. + pub fn view(&self) -> DevToolsHostView<'_> { + DevToolsHostView { + connection: &self.connection, + } + } + + /// Borrows the host configuration and frontend-listener capability. + pub fn control(&mut self) -> DevToolsHostControl<'_> { + DevToolsHostControl { + connection: &mut self.connection, + } + } + + /// Borrows the frontend command execution capability. + pub fn commands(&mut self) -> DevToolsCommandDispatch<'_> { + DevToolsCommandDispatch { + connection: &mut self.connection, + } + } + + /// Borrows the scheduler-side physical projection capability. + pub fn projection(&mut self) -> DevToolsSchedulerProjection<'_> { + DevToolsSchedulerProjection { + connection: &mut self.connection, + } + } + + #[cfg(test)] + pub(crate) fn into_connection_for_test(self) -> CdpConnection { + self.connection + } +} + +/// Read-only browser identity and routing decisions. +/// +/// This capability cannot execute commands or mutate the physical protocol +/// projection. +pub struct DevToolsHostView<'a> { + connection: &'a CdpConnection, +} + +/// Host initialization and frontend-listener configuration. +/// +/// This capability cannot dispatch frontend commands or project scheduler +/// work. +pub struct DevToolsHostControl<'a> { + connection: &'a mut CdpConnection, +} + +/// Frontend command start/completion and its move-owned continuations. +/// +/// This capability deliberately excludes scheduler projection turns and raw +/// Browser Host execution authority. +pub struct DevToolsCommandDispatch<'a> { + connection: &'a mut CdpConnection, +} + +/// Scheduler-side projection of already-selected protocol and renderer work. +/// +/// This capability cannot start new frontend commands or select Browser Host +/// turns. +pub struct DevToolsSchedulerProjection<'a> { + connection: &'a mut CdpConnection, +} + +impl DevToolsHostView<'_> { + pub fn browser_host_state(&self) -> moli_core::browser_host::BrowserHostState { + self.connection.browser_host_state() + } + + #[cfg(feature = "test-support")] + #[doc(hidden)] + pub fn browser_host_handle_for_test_support( + &self, + ) -> Option { + self.connection.browser_host_handle_for_test_support() + } + + pub fn default_target_id(&self) -> &'static str { + self.connection.default_target_id() + } + + pub fn default_tab_target_id(&self) -> &'static str { + self.connection.default_tab_target_id() + } + + pub fn tab_target_id_for_page_target_id(&self, page_target_id: &str) -> Option<&str> { + self.connection + .tab_target_id_for_page_target_id(page_target_id) + } + + pub fn worker_target_id_for_session(&self, session_id: Option<&str>) -> Option { + self.connection.worker_target_id_for_session(session_id) + } + + pub fn has_pending_javascript_dialog(&self) -> bool { + self.connection.has_pending_javascript_dialog() + } + + pub fn renderer_document_navigation_is_suspended_for_session_owner( + &self, + session_id: Option<&str>, + ) -> bool { + self.connection + .renderer_document_navigation_is_suspended_for_session_owner(session_id) + } + + pub fn devtools_context_routes_to_top_level_target( + &self, + context: &crate::devtools_runtime::DevToolsCommandContext, + ) -> bool { + self.connection + .devtools_context_routes_to_top_level_target(context) + } + + pub fn renderer_output_cursor_is_projected( + &self, + cursor: moli_core::RendererOutputCursor, + ) -> bool { + self.connection.renderer_output_cursor_is_projected(cursor) + } + + pub fn observes_main_document_load_for_devtools_context( + &self, + work: &crate::ProtocolSchedulerWork, + context: &crate::devtools_runtime::DevToolsCommandContext, + ) -> bool { + work.observes_main_document_load_for_devtools_context(self.connection, context) + } + + pub fn background_event_route_is_current( + &self, + event: &crate::BackgroundProtocolEvent, + ) -> bool { + event.route_is_current(self.connection) + } +} + +impl DevToolsHostControl<'_> { + pub fn set_target_host_lifecycle_observer( + &mut self, + observer: crate::CdpTargetHostLifecycleObserver, + ) { + self.connection.set_target_host_lifecycle_observer(observer); + } + + pub fn set_automation_javascript_dialog_handler_enabled(&mut self, enabled: bool) -> bool { + self.connection + .set_automation_javascript_dialog_handler_enabled(enabled) + } + + pub fn enable_webdriver_bidi_download_events(&mut self) -> bool { + self.connection.enable_webdriver_bidi_download_events() + } + + pub fn disable_webdriver_bidi_download_events(&mut self) -> bool { + self.connection.disable_webdriver_bidi_download_events() + } + + pub fn enable_webdriver_bidi_target_lifecycle_projection(&mut self) -> bool { + self.connection + .enable_webdriver_bidi_target_lifecycle_projection() + } + + pub fn disable_webdriver_bidi_target_lifecycle_projection(&mut self) -> bool { + self.connection + .disable_webdriver_bidi_target_lifecycle_projection() + } + + pub fn set_background_event_sender(&mut self, sender: crate::BackgroundEventSender) { + self.connection.set_background_event_sender(sender); + } + + pub fn set_runtime_inspector_response_ready_sender( + &mut self, + sender: crate::conn::RuntimeInspectorResponseReadySender, + ) { + self.connection + .set_runtime_inspector_response_ready_sender(sender); + } + + pub fn set_background_navigation_completion_sender( + &mut self, + sender: tokio::sync::mpsc::UnboundedSender, + ) { + self.connection + .set_background_navigation_completion_sender(sender); + } + + pub fn set_renderer_publication_sender( + &mut self, + sender: moli_core::RendererOutputTransportSender, + ) { + self.connection.set_renderer_publication_sender(sender); + } + + pub fn install_default_browser_target(&mut self) { + self.connection.install_default_browser_target(); + } + + pub fn enable_default_target_on_auto_attach(&mut self) { + self.connection.enable_default_target_on_auto_attach(); + } + + pub fn replace_root_target_discovery_enabled(&mut self, enabled: bool) -> bool { + self.connection + .replace_root_target_discovery_enabled(enabled) + } + + pub fn enable_network_listener_for_target(&mut self, target_id: &str) -> bool { + self.connection + .enable_network_listener_for_target(target_id) + } + + pub fn disable_network_listener_for_target(&mut self, target_id: &str) -> bool { + self.connection + .disable_network_listener_for_target(target_id) + } + + pub fn enable_file_dialog_opened_listener_for_target(&mut self, target_id: &str) -> bool { + self.connection + .enable_file_dialog_opened_listener_for_target(target_id) + } + + pub fn disable_file_dialog_opened_listener_for_target(&mut self, target_id: &str) -> bool { + self.connection + .disable_file_dialog_opened_listener_for_target(target_id) + } + + pub async fn enable_runtime_listener_for_target( + &mut self, + target_id: &str, + ) -> Option { + self.connection + .enable_runtime_listener_for_target(target_id) + .await + } + + pub async fn disable_runtime_listener_for_target( + &mut self, + target_id: &str, + ) -> Option { + self.connection + .disable_runtime_listener_for_target(target_id) + .await + } +} + +impl DevToolsCommandDispatch<'_> { + pub fn page_residence_identity_for_devtools_context( + &mut self, + context: &crate::devtools_runtime::DevToolsCommandContext, + ) -> Option { + self.connection + .page_residence_identity_for_devtools_context(context) + } + + pub fn devtools_context_document_navigation_state( + &mut self, + context: &crate::devtools_runtime::DevToolsCommandContext, + ) -> crate::DevToolsDocumentNavigationState { + self.connection + .devtools_context_document_navigation_state(context) + } + + pub fn capture_devtools_document_lifecycle_wait_key( + &mut self, + context: &crate::devtools_runtime::DevToolsCommandContext, + expected_loader_id: &str, + milestone: moli_core::page::RendererDocumentLifecycleMilestone, + ) -> Option { + self.connection + .capture_devtools_document_lifecycle_wait_key(context, expected_loader_id, milestone) + } +} + +impl DevToolsSchedulerProjection<'_> { + pub fn admit_runtime_command_output_barrier( + &self, + barriers: &mut crate::RuntimeCommandOutputBarriers, + command_id: u64, + session_id: Option<&str>, + ) -> Option { + barriers.admit(self.connection, command_id, session_id) + } + + pub fn capture_browser_fact_wake( + &mut self, + through: moli_core::browser_host::BrowserFactSequence, + ) -> Result<(), crate::conn::BrowserFactProjectionError> { + self.connection.capture_browser_fact_wake(through) + } + + pub fn begin_command_response_flush_permit( + &mut self, + ) -> ( + crate::CommandResponseFlushPermit, + crate::CommandResponseFlushContext, + ) { + self.connection.begin_command_response_flush_permit() + } + + pub fn take_scheduler_events(&mut self) -> Vec { + self.connection.take_scheduler_events() + } + + pub fn page_screencast_subscription_status( + &mut self, + registration: &crate::PageScreencastRegistration, + ) -> crate::PageScreencastSubscriptionStatus { + self.connection + .page_screencast_subscription_status(registration) + } + + pub fn start_page_screencast_frame_capture( + &mut self, + registration: &crate::PageScreencastRegistration, + known_visual_state: Option, + ) -> crate::PageScreencastCaptureStart { + self.connection + .start_page_screencast_frame_capture(registration, known_visual_state) + } + + pub fn complete_page_screencast_frame_capture( + &mut self, + completed: crate::CompletedPageScreencastCapture, + ) -> crate::PageScreencastCaptureCompletion { + self.connection + .complete_page_screencast_frame_capture(completed) + } + + pub fn route_registered_runtime_inspector_response_into( + &mut self, + response: crate::conn::RuntimeInspectorResponseReady, + response_events: &mut Vec, + background_events: &mut Vec, + ) { + self.connection + .route_registered_runtime_inspector_response_into( + response, + response_events, + background_events, + ); + } +} + +impl DevToolsCommandDispatch<'_> { + pub fn start_parsed_command_dispatch_with_context( + &mut self, + command: &crate::ParsedCdpCommand, + command_context: &mut crate::CommandDispatchContext, + ) -> crate::CdpCommandTaskStep { + self.connection + .start_parsed_command_dispatch_with_context(command, command_context) + } + + pub async fn complete_pending_command_dispatch_with_context( + &mut self, + completed: crate::CompletedCdpCommandDispatch, + command_context: &mut crate::CommandDispatchContext, + ) -> crate::CdpCommandTaskStep { + self.connection + .complete_pending_command_dispatch_with_context(completed, command_context) + .await + } + + pub async fn process_message_with_turn_outcome_async( + &mut self, + raw: &str, + ) -> crate::CdpRendererOwnerTurnOutcome { + self.connection + .process_message_with_turn_outcome_async(raw) + .await + } + + pub async fn execute_devtools_command_with_protocol_events_with_background_command_id( + &mut self, + command: crate::devtools_runtime::DevToolsCommand, + background_command_id: Option, + ) -> crate::DevToolsCommandDispatchOutcome { + self.connection + .execute_devtools_command_with_protocol_events_with_background_command_id( + command, + background_command_id, + ) + .await + } + + pub async fn try_start_devtools_browser_owner_navigation_command( + &mut self, + command: crate::devtools_runtime::DevToolsCommand, + background_command_id: Option, + ) -> Result< + crate::DevToolsBrowserOwnerNavigationCommandTaskStep, + crate::devtools_runtime::DevToolsCommand, + > { + self.connection + .try_start_devtools_browser_owner_navigation_command(command, background_command_id) + .await + } + + pub async fn complete_devtools_browser_owner_navigation_command( + &mut self, + completed: crate::CompletedDevToolsBrowserOwnerNavigationCommand, + ) -> crate::DevToolsCommandDispatchOutcome { + self.connection + .complete_devtools_browser_owner_navigation_command(completed) + .await + } + + pub async fn try_start_devtools_browser_owner_context_disposal_command( + &mut self, + command: crate::devtools_runtime::DevToolsCommand, + ) -> Result< + crate::DevToolsBrowserOwnerContextDisposalCommandTaskStep, + crate::devtools_runtime::DevToolsCommand, + > { + self.connection + .try_start_devtools_browser_owner_context_disposal_command(command) + .await + } + + pub async fn complete_devtools_browser_owner_context_disposal_command( + &mut self, + completed: crate::CompletedDevToolsBrowserOwnerContextDisposalCommand, + ) -> crate::DevToolsCommandDispatchOutcome { + self.connection + .complete_devtools_browser_owner_context_disposal_command(completed) + .await + } + + pub async fn try_start_devtools_fetch_command_task( + &mut self, + command: crate::devtools_runtime::DevToolsCommand, + ) -> Result { + self.connection + .try_start_devtools_fetch_command_task(command) + .await + } + + pub async fn complete_devtools_fetch_command_task( + &mut self, + completed: crate::CompletedDevToolsFetchCommand, + ) -> crate::DevToolsCommandDispatchOutcome { + self.connection + .complete_devtools_fetch_command_task(completed) + .await + } + + pub async fn start_devtools_runtime_command_dispatch( + &mut self, + command: crate::devtools_runtime::DevToolsCommand, + ) -> crate::DevToolsRuntimeCommandTaskStep { + self.connection + .start_devtools_runtime_command_dispatch(command) + .await + } + + pub async fn complete_devtools_runtime_command_dispatch( + &mut self, + completed: crate::CompletedDevToolsRuntimeCommandDispatch, + ) -> crate::DevToolsRuntimeCommandTaskStep { + self.connection + .complete_devtools_runtime_command_dispatch(completed) + .await + } +} + +impl DevToolsSchedulerProjection<'_> { + pub async fn complete_ready_protocol_scheduler_work_turn( + &mut self, + work: crate::ProtocolSchedulerWork, + ) -> crate::CdpTurnOutcome { + self.connection + .complete_ready_protocol_scheduler_work_turn(work) + .await + } + + pub async fn ingest_renderer_output_turn_async( + &mut self, + publication: moli_core::RendererOutputTransportMessage, + barriers: &mut crate::RuntimeCommandOutputBarriers, + ) -> crate::CdpTurnOutcome { + self.connection + .ingest_renderer_output_turn_async(publication, barriers) + .await + } + + pub async fn project_protocol_local_command_outputs_turn_async( + &mut self, + session_id: Option<&str>, + ) -> crate::CdpTurnOutcome { + self.connection + .project_protocol_local_command_outputs_turn_async(session_id) + .await + } + + pub async fn release_runtime_command_output_barrier_turn_async( + &mut self, + barriers: &mut crate::RuntimeCommandOutputBarriers, + permit: crate::RuntimeCommandOutputBarrierPermit, + ) -> crate::RuntimeCommandOutputBarrierCompletion { + self.connection + .release_runtime_command_output_barrier_turn_async(barriers, permit) + .await + } + + pub async fn drain_background_navigation_completion_turn_async( + &mut self, + completion: crate::BackgroundNavigationCompletion, + ) -> ( + crate::CdpRendererOwnerTurnOutcome, + crate::BackgroundNavigationTurnDisposition, + ) { + self.connection + .drain_background_navigation_completion_turn_async(completion) + .await + } + + pub async fn complete_deferred_main_document_load_completion_for_scheduler( + &mut self, + completion: crate::CompletedDeferredMainDocumentLoadCompletion, + ) -> crate::CdpTurnOutcome { + self.connection + .complete_deferred_main_document_load_completion_for_scheduler(completion) + .await + } +} + +impl DevToolsCommandDispatch<'_> { + pub async fn route_runtime_deferred_inspector_response( + &mut self, + pending: &mut crate::PendingDevToolsRuntimeCommandDispatch, + response: crate::conn::RuntimeInspectorResponseReady, + ) -> bool { + pending + .route_scheduler_deferred_inspector_response(self.connection, response) + .await + } + + pub fn complete_runtime_deferred_inspector_reply( + &mut self, + pending: crate::PendingDevToolsRuntimeCommandDispatch, + ) -> crate::CompletedDevToolsRuntimeCommandDispatch { + pending.complete_scheduler_deferred_inspector_reply(self.connection) + } + + pub fn forget_runtime_deferred_inspector_reply( + &mut self, + pending: crate::PendingDevToolsRuntimeCommandDispatch, + ) { + pending.forget_scheduler_deferred_inspector_reply(self.connection); + } + + pub async fn route_cdp_deferred_inspector_response( + &mut self, + pending: &mut crate::PendingCdpCommandDispatch, + response: crate::conn::RuntimeInspectorResponseReady, + ) -> bool { + pending + .route_scheduler_deferred_inspector_response(self.connection, response) + .await + } + + pub fn complete_cdp_deferred_inspector_reply( + &mut self, + pending: crate::PendingCdpCommandDispatch, + ) -> crate::CompletedCdpCommandDispatch { + pending.complete_scheduler_deferred_inspector_reply(self.connection) + } + + pub fn forget_cdp_deferred_inspector_reply( + &mut self, + pending: crate::PendingCdpCommandDispatch, + ) { + pending.forget_scheduler_deferred_inspector_reply(self.connection); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn application_owner_adapter_preserves_the_exact_browser_host_residence() { + let connection = CdpConnection::new(); + let browser_instance_id = connection + .browser_host_state() + .navigation_owner() + .browser_instance_id(); + + let adapter = DevToolsHostAdapter::for_application_owner(connection); + + assert_eq!( + adapter + .view() + .browser_host_state() + .navigation_owner() + .browser_instance_id(), + browser_instance_id + ); + assert_eq!( + adapter + .into_connection_for_test() + .browser_host_state() + .navigation_owner() + .browser_instance_id(), + browser_instance_id + ); + } +} diff --git a/moli-protocol/src/devtools_runtime.rs b/moli-protocol/src/devtools_runtime.rs index 53da2d0d7d..3f17caf905 100644 --- a/moli-protocol/src/devtools_runtime.rs +++ b/moli-protocol/src/devtools_runtime.rs @@ -1426,8 +1426,6 @@ pub struct DevToolsNavigateResult { pub frame_id: Option, pub loader_id: Option, pub url: String, - pub error_text: Option, - pub is_download: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/moli-protocol/src/domains/accessibility.rs b/moli-protocol/src/domains/accessibility.rs index 83185f0d38..71d37d9672 100644 --- a/moli-protocol/src/domains/accessibility.rs +++ b/moli-protocol/src/domains/accessibility.rs @@ -427,11 +427,11 @@ fn start_pending_object_reference_command( message: "Could not find node with given id".to_owned(), }); }; - let Some(page) = helpers::loaded_page_mut_for_session(conn, cmd.session_id) else { + let Some(mut page) = helpers::loaded_page_mut_for_session(conn, cmd.session_id) else { return Err(PendingAccessibilityCommandStartError::no_document_loaded()); }; let pending = start_accessibility_object_page_command( - page, + &mut page, cmd.session_id.map(str::to_owned), object_id, &operation, @@ -540,10 +540,10 @@ fn start_pending_backend_reference_command( return Err(PendingAccessibilityCommandStartError::no_document_loaded()); }; let resolved_frame_id = frame_id.unwrap_or(top_frame_id.as_str()).to_owned(); - let Some(page) = helpers::loaded_page_mut_for_session(conn, cmd.session_id) else { + let Some(mut page) = helpers::loaded_page_mut_for_session(conn, cmd.session_id) else { return Err(PendingAccessibilityCommandStartError::no_document_loaded()); }; - let pending = start_accessibility_backend_page_command(page, backend_node_id, &operation) + let pending = start_accessibility_backend_page_command(&mut page, backend_node_id, &operation) .map_err(PendingAccessibilityCommandStartError::renderer_error)?; Ok(Some(PendingAccessibilityCommandDispatch { command_id: cmd.id, @@ -615,16 +615,16 @@ fn start_pending_frame_scoped_accessibility_command( return Err(PendingAccessibilityCommandStartError::no_document_loaded()); }; let resolved_frame_id = frame_id.unwrap_or(top_frame_id.as_str()); - let Some(page) = helpers::loaded_page_mut_for_session(conn, cmd.session_id) else { + let Some(mut page) = helpers::loaded_page_mut_for_session(conn, cmd.session_id) else { return Err(PendingAccessibilityCommandStartError::no_document_loaded()); }; let (kind, pending) = if resolved_frame_id == top_frame_id { - let pending = start_top_frame_accessibility_page_command(page, &top_frame_kind) + let pending = start_top_frame_accessibility_page_command(&mut page, &top_frame_kind) .map_err(PendingAccessibilityCommandStartError::renderer_error)?; (top_frame_kind, pending) } else { let pending = start_child_frame_accessibility_page_command( - page, + &mut page, resolved_frame_id, &child_frame_kind, ) @@ -674,7 +674,7 @@ pub(crate) async fn complete_pending_accessibility_command( -32000, message, )); } - let Some(page) = helpers::loaded_page_mut_for_session(conn, session_id_ref) else { + let Some(mut page) = helpers::loaded_page_mut_for_session(conn, session_id_ref) else { return AccessibilityCommandDispatchStep::Complete(CommandOutputPlan::error( -32000, "NoDocumentLoaded", @@ -712,7 +712,7 @@ pub(crate) async fn complete_pending_accessibility_command( PendingAccessibilityCommandKind::ChildFrameFullTree { max_depth } => { let _ = max_depth; match finish_child_frame_accessibility_nodes_for_protocol( - page, + &mut page, completion, "Could not build accessibility tree for frame", ) { @@ -722,7 +722,7 @@ pub(crate) async fn complete_pending_accessibility_command( } PendingAccessibilityCommandKind::ChildFrameRoot => { match finish_child_frame_accessibility_nodes_for_protocol( - page, + &mut page, completion, "Could not build root accessibility node for frame", ) { @@ -740,7 +740,7 @@ pub(crate) async fn complete_pending_accessibility_command( } => { return AccessibilityCommandDispatchStep::Complete( complete_object_accessibility_payloads_command( - page, + &mut page, completion, frame_id, top_frame_id, @@ -770,18 +770,19 @@ pub(crate) async fn complete_pending_accessibility_command( )); } }; - let pending = - match start_accessibility_backend_page_command(page, backend_node_id, &operation) { - Ok(pending) => pending, - Err(error) => { - return AccessibilityCommandDispatchStep::Complete( - CommandOutputPlan::error( - -32000, - format!("Could not build accessibility payload for node: {error}"), - ), - ); - } - }; + let pending = match start_accessibility_backend_page_command( + &mut page, + backend_node_id, + &operation, + ) { + Ok(pending) => pending, + Err(error) => { + return AccessibilityCommandDispatchStep::Complete(CommandOutputPlan::error( + -32000, + format!("Could not build accessibility payload for node: {error}"), + )); + } + }; return AccessibilityCommandDispatchStep::Pending( PendingAccessibilityCommandDispatch { command_id, @@ -802,7 +803,7 @@ pub(crate) async fn complete_pending_accessibility_command( } => { return AccessibilityCommandDispatchStep::Complete( complete_backend_accessibility_payloads_command( - page, + &mut page, completion, frame_id, top_frame_id, diff --git a/moli-protocol/src/domains/accessibility/helpers.rs b/moli-protocol/src/domains/accessibility/helpers.rs index 8a0348edca..22cbf56c62 100644 --- a/moli-protocol/src/domains/accessibility/helpers.rs +++ b/moli-protocol/src/domains/accessibility/helpers.rs @@ -2,7 +2,6 @@ use crate::conn::CdpConnection; pub(super) use chromiumoxide_cdp::cdp::browser_protocol::accessibility::{ GetChildAxNodesParams as ChildAxNodesParams, GetFullAxTreeParams, }; -use moli_core::page::Page; use serde::Deserialize; #[derive(Deserialize)] @@ -56,10 +55,10 @@ pub(super) struct PartialAxTreeParams { pub(super) fetch_relatives: Option, } -pub(super) fn loaded_page_mut_for_session<'a>( - conn: &'a mut CdpConnection, +pub(super) fn loaded_page_mut_for_session( + conn: &mut CdpConnection, session_id: Option<&str>, -) -> Option<&'a mut Page> { +) -> Option { conn.loaded_page_mut_for_protocol_access(session_id).ok() } diff --git a/moli-protocol/src/domains/accessibility/tests.rs b/moli-protocol/src/domains/accessibility/tests.rs index 0471b221a8..9138f516fc 100644 --- a/moli-protocol/src/domains/accessibility/tests.rs +++ b/moli-protocol/src/domains/accessibility/tests.rs @@ -33,10 +33,30 @@ async fn complete_command_task_for_test( } async fn load_page_async(ctx: &mut TestContext, html: &str) { - let mut bc = BrowserContext::new("BID-1".into()); - bc.set_active_target_id("TID-1"); + if ctx.conn.browser_context.is_none() { + let mut bc = BrowserContext::new("BID-1".into()); + bc.set_active_target_id("TID-1"); + ctx.conn.insert_browser_context(bc); + } else if !ctx + .conn + .browser_context + .as_ref() + .is_some_and(BrowserContext::has_active_target) + { + ctx.conn + .register_active_target_projection( + "TID-1", + |browser_context, target_handle, page_residence, session_storage_access| { + browser_context.bind_new_active_target_registration( + target_handle, + page_residence, + session_storage_access, + ); + }, + ) + .expect("active Target projection should register"); + } let data_url = format!("data:text/html,{html}"); - ctx.conn.browser_context = Some(bc); ctx.enable_page_events_for_test(None); ctx.install_navigation_fixture_for_session_owner(&data_url, None) .await; @@ -156,8 +176,8 @@ async fn accessibility_get_full_tree_rejects_while_main_document_navigation_is_p .as_mut() .expect("browser context should exist"); browser_context.attach_active_session("SID-1".to_owned()); - browser_context - .start_document_navigation_for_active_target("PENDING-LOADER".to_owned()) + ctx.conn + .start_document_navigation_for_session_owner(Some("SID-1"), "PENDING-LOADER".to_owned()) .expect("active navigation should start"); ctx.sent.clear(); @@ -225,7 +245,7 @@ async fn get_full_ax_tree_reads_live_renderer_dom_when_page_snapshot_is_stale() .any(|node| node["name"]["value"] == json!("old")) ); - let page = ctx + let mut page = ctx .conn .browser_context .as_mut() @@ -427,6 +447,7 @@ async fn accessibility_loaded_page_methods_target_background_owner_without_promo Some("SID-background"), ) .await; + ctx.sent.clear(); ctx.process_async(json!({ "id": 201, @@ -545,6 +566,7 @@ async fn accessibility_loaded_page_methods_target_inactive_owner_without_activat Some("SID-inactive"), ) .await; + ctx.sent.clear(); ctx.process_async(json!({ "id": 211, @@ -588,7 +610,8 @@ async fn get_full_ax_tree_requires_browser_context() { #[tokio::test(flavor = "multi_thread")] async fn get_full_ax_tree_requires_loaded_page() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({"id": 2, "method": "Accessibility.getFullAXTree"})) .await; ctx.expect_error(2, -32000, "NoDocumentLoaded"); @@ -905,7 +928,8 @@ async fn get_root_ax_node_rejects_foreign_frame() { #[tokio::test(flavor = "multi_thread")] async fn get_root_ax_node_requires_loaded_page() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 350, @@ -1096,7 +1120,8 @@ async fn get_child_ax_nodes_validates_ax_id_and_frame() { #[tokio::test(flavor = "multi_thread")] async fn get_child_ax_nodes_requires_loaded_page() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 390, @@ -2046,7 +2071,8 @@ async fn get_ax_node_and_ancestors_requires_context_loaded_page_and_bound_node() .await; ctx.expect_error(530, -31998, "BrowserContextNotLoaded"); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 531, "method": "Accessibility.getAXNodeAndAncestors", @@ -2533,7 +2559,8 @@ async fn query_ax_tree_requires_context_loaded_page_and_bound_node() { .await; ctx.expect_error(580, -31998, "BrowserContextNotLoaded"); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 581, "method": "Accessibility.queryAXTree", diff --git a/moli-protocol/src/domains/activity/main_document.rs b/moli-protocol/src/domains/activity/main_document.rs index acc978d72a..fe70d6c324 100644 --- a/moli-protocol/src/domains/activity/main_document.rs +++ b/moli-protocol/src/domains/activity/main_document.rs @@ -7,7 +7,6 @@ use crate::conn::{ BackgroundProtocolEvent, CdpConnection, CommandDispatchContext, CommandOwnerScope, CommittedRendererDocumentBinding, CompletedDownloadBodyArtifact, DeferredMainDocumentLoadObservationId, DocumentNavigationToken, NavigationDispatchState, - RendererDocumentLifecycleObservation, RendererDocumentLifecycleObserver, RendererPageResidenceIdentity, }; use crate::devtools_runtime::DevToolsProtocol; @@ -17,11 +16,17 @@ use crate::domains::network::{ MainDocumentProgressGate, }; use crate::domains::page; -use moli_core::RendererDocumentLifecycleIdentity; use moli_core::page::{ RendererDocumentLifecycleEvent, RendererDocumentLifecycleEventKind, RendererDocumentLifecycleMilestone, RendererPendingDownloadActivation, }; +use moli_core::{ + RendererDocumentLifecycleIdentity, + browser_host::{ + BrowserDocumentLifecycleWaitOutcome, BrowserDocumentLifecycleWaitReadiness, + BrowserDocumentLifecycleWaitTicket, + }, +}; pub(crate) struct MainDocumentNavigationActivity { state: NavigationDispatchState, @@ -63,7 +68,7 @@ pub(crate) struct DeferredMainDocumentLoadCompletionActivity { state: DeferredMainDocumentLoadCompletionState, observation_id: DeferredMainDocumentLoadObservationId, renderer_page_residence_identity: Option, - lifecycle_observer: RendererDocumentLifecycleObserver, + lifecycle_wait: BrowserDocumentLifecycleWaitTicket, } pub(crate) struct PendingDeferredMainDocumentLoadCompletionActivity { @@ -73,7 +78,7 @@ pub(crate) struct PendingDeferredMainDocumentLoadCompletionActivity { pub(crate) struct CompletedDeferredMainDocumentLoadCompletionActivity { state: DeferredMainDocumentLoadCompletionState, observation_id: DeferredMainDocumentLoadObservationId, - observation: RendererDocumentLifecycleObservation, + outcome: BrowserDocumentLifecycleWaitOutcome, } impl MainDocumentNavigationActivity { @@ -116,7 +121,13 @@ impl MainDocumentNavigationActivity { ); } - pub(crate) async fn emit_loaded_navigation_commit_async( + /// Projects the loaded-Document response and lifecycle prefix in one owner + /// apply turn. + /// + /// The load boundary itself is published as + /// `MainDocumentLoadFactProjection`; this prefix has no renderer wait and + /// must be visible before the caller can register another participant. + pub(crate) fn emit_loaded_navigation_commit( mut self, conn: &mut CdpConnection, out: &mut CommandOutputBuffer, @@ -148,11 +159,28 @@ impl MainDocumentNavigationActivity { self.emit_navigation_result_from_state_into_buffer(out); } let mut target_info_events = Vec::new(); - crate::domains::target::emit_target_info_changed_for_session_owner_background_event( - conn, - &mut target_info_events, - self.state.navigate_session_id.as_deref(), - ); + if let Some(navigation) = self.document_navigation_token.as_ref() { + let page = conn.target_page_residence_identity_for_session( + self.state.navigate_session_id.as_deref(), + ); + match page.as_ref().map(|page| { + conn.take_navigation_target_metadata_changed_fact(navigation, page) + .map(|projection| conn.project_target_metadata_changed_fact(projection)) + }) { + Some(Ok(plan)) => target_info_events.extend(plan), + Some(Err(error)) => tracing::error!( + %error, + target_id = navigation.target_id(), + loader_id = navigation.loader_id(), + "refusing to project Target metadata without its exact Browser fact" + ), + None => tracing::error!( + target_id = navigation.target_id(), + loader_id = navigation.loader_id(), + "refusing to project Target metadata without a current Browser Page" + ), + } + } out.extend_background_events_after_messages(target_info_events); if timing_enabled { tracing::info!( @@ -239,6 +267,7 @@ impl MainDocumentNavigationActivity { self.state.navigate_session_id.as_deref(), &self.state.loader_id, ); + self.publish_post_commit_target_activation(conn); return; } if !reached_domcontentloaded { @@ -312,6 +341,14 @@ impl MainDocumentNavigationActivity { self.activate_pending_download_async(conn, out, pending_download) .await; } + self.publish_post_commit_target_activation(conn); + } + + fn publish_post_commit_target_activation(&mut self, conn: &CdpConnection) { + crate::domains::page::publish_post_commit_target_activation( + conn, + self.state.post_commit_target_activation.take(), + ); } fn is_still_current(&self, conn: &CdpConnection) -> bool { @@ -427,31 +464,26 @@ impl MainDocumentNavigationActivity { } } - #[cfg(test)] - pub(crate) fn navigation_error_messages_for_test(&mut self, message: &str) -> Vec { - let state = self.state(); - let navigate_id = state.navigate_id; - let navigate_session_id = state.navigate_session_id.clone(); - let mut background_events = Vec::new(); - let mut output = MainDocumentProgressBackgroundEventBarrier::background_events( - &mut background_events, - &mut self.progress_gate, - ); - output.drain_progress(); - let mut out = Vec::new(); - out.extend( - background_events - .into_iter() - .map(BackgroundProtocolEvent::into_protocol_message), - ); - if navigate_id.is_some() { - crate::domains::command_output::CommandOutputPlan::error(-32000, message).emit_into( - &mut out, - navigate_id, - navigate_session_id.as_deref(), + /// Replaces a still-pending navigation success response with an error + /// without suppressing the already-committed Document's lifecycle tail. + pub(crate) fn emit_navigation_error_instead_of_result_into_buffer( + &mut self, + out: &mut CommandOutputBuffer, + message: impl Into, + ) { + let navigate_id = self.state.navigate_id.take(); + { + let mut background_events = Vec::new(); + let mut output = MainDocumentProgressBackgroundEventBarrier::background_events( + &mut background_events, + &mut self.progress_gate, ); + output.drain_progress(); + out.extend_background_events_after_messages(background_events); + } + if navigate_id.is_some() { + out.push_error_after_messages(-32000, message); } - out } fn emit_navigation_error_into_buffer(&mut self, out: &mut CommandOutputBuffer, message: &str) { @@ -501,8 +533,8 @@ impl MainDocumentNavigationActivity { self.flush_body_complete_activity_background_events(&mut body_complete_events); out.extend_background_events(body_complete_events); - // Reaching this boundary means the protocol-side exact lifecycle - // observer has already consumed the live concrete load record. Only + // Reaching this boundary means the exact Browser fact ticket has + // already consumed the live concrete load record. Only // commit-time events and the visibility-barrier tail remain to be // projected; rescanning renderer state here would rediscover output // owned by an earlier turn. @@ -684,31 +716,34 @@ impl DeferredMainDocumentLoadCompletionAdmission { self.state.navigation_activity.is_still_current(conn) } - pub(crate) fn bind_lifecycle_observer( + pub(crate) fn bind_fact_wait_ticket( self, - conn: &mut CdpConnection, + conn: &CdpConnection, observation_id: DeferredMainDocumentLoadObservationId, ) -> DeferredMainDocumentLoadCompletionActivity { let is_current = self.is_still_current_for_scheduler(conn); let renderer_page_residence_identity = is_current .then(|| conn.renderer_page_residence_identity_for_session_owner(self.session_id())) .flatten(); - let lifecycle_observer = if is_current { - conn.register_exact_renderer_document_lifecycle_observer_for_session_owner( + let lifecycle_wait = if is_current { + conn.exact_renderer_document_lifecycle_fact_wait_for_session_owner( self.session_id(), self.state.renderer_document_binding.as_ref(), RendererDocumentLifecycleMilestone::Load, ) } else { - RendererDocumentLifecycleObserver::resolved( - RendererDocumentLifecycleObservation::Superseded, + BrowserDocumentLifecycleWaitTicket::resolved( + conn.target_page_residence_identity_for_session(self.session_id()), + self.state.renderer_document_identity(), + RendererDocumentLifecycleMilestone::Load, + BrowserDocumentLifecycleWaitOutcome::Superseded, ) }; DeferredMainDocumentLoadCompletionActivity { state: self.state, observation_id, renderer_page_residence_identity, - lifecycle_observer, + lifecycle_wait, } } } @@ -726,17 +761,6 @@ impl DeferredMainDocumentLoadCompletionActivity { self.state.owner_scope.session_id() } - pub(crate) fn target_id(&self) -> &str { - self.state - .navigation_activity - .document_navigation_token - .as_ref() - .map_or( - self.state.navigation_activity.state.frame_id.as_str(), - |token| token.target_id.as_str(), - ) - } - pub(crate) fn observation_id(&self) -> DeferredMainDocumentLoadObservationId { self.observation_id } @@ -745,22 +769,21 @@ impl DeferredMainDocumentLoadCompletionActivity { self.renderer_page_residence_identity } - pub(crate) fn has_terminal_lifecycle_observation(&self) -> bool { - self.lifecycle_observer.observation().is_terminal() + pub(crate) fn has_terminal_browser_fact(&self) -> bool { + self.lifecycle_wait.is_terminal() } pub(crate) fn try_complete( self: Box, ) -> Result> { - let observation = self.lifecycle_observer.observation(); - if !observation.is_terminal() { + let Some(outcome) = self.lifecycle_wait.outcome() else { return Err(self); - } + }; let completion = *self; Ok(CompletedDeferredMainDocumentLoadCompletionActivity { state: completion.state, observation_id: completion.observation_id, - observation, + outcome, }) } @@ -806,18 +829,22 @@ impl PendingDeferredMainDocumentLoadCompletionActivity { self.completion.observation_id() } + pub(crate) fn fact_readiness(&self) -> BrowserDocumentLifecycleWaitReadiness { + self.completion.lifecycle_wait.readiness() + } + pub(crate) async fn wait(self) -> CompletedDeferredMainDocumentLoadCompletionActivity { let DeferredMainDocumentLoadCompletionActivity { state, observation_id, renderer_page_residence_identity: _, - lifecycle_observer, + lifecycle_wait, } = self.completion; - let observation = lifecycle_observer.wait().await; + let outcome = lifecycle_wait.wait().await; CompletedDeferredMainDocumentLoadCompletionActivity { state, observation_id, - observation, + outcome, } } } @@ -840,19 +867,19 @@ impl CompletedDeferredMainDocumentLoadCompletionActivity { conn: &mut CdpConnection, out: &mut BackgroundProtocolEventBuffer, ) { - match self.observation { - RendererDocumentLifecycleObservation::Reached => {} - RendererDocumentLifecycleObservation::Pending => { - unreachable!("a completed lifecycle observer cannot remain pending") - } - RendererDocumentLifecycleObservation::Interrupted => { + match self.outcome { + BrowserDocumentLifecycleWaitOutcome::Reached => {} + BrowserDocumentLifecycleWaitOutcome::Interrupted { .. } => { self.state .navigation_activity .emit_renderer_load_boundary_facts(conn, out); + self.state + .navigation_activity + .publish_post_commit_target_activation(conn); return; } - RendererDocumentLifecycleObservation::Superseded - | RendererDocumentLifecycleObservation::Unavailable => { + BrowserDocumentLifecycleWaitOutcome::Superseded + | BrowserDocumentLifecycleWaitOutcome::Unavailable(_) => { conn.cancel_renderer_document_load_visibility_barrier_for_session_owner( self.session_id(), &self.state.navigation_activity.state.loader_id, @@ -977,6 +1004,7 @@ mod tests { request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 12.5, source_document_security: Default::default(), + post_commit_target_activation: None, } } @@ -987,14 +1015,17 @@ mod tests { ) { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-deferred-load-observer".to_owned()); - browser_context.set_active_target_id("TID-deferred-load-observer"); - browser_context.attach_active_session("SID-nav"); - browser_context.set_target_url("https://example.test/start".to_owned()); + browser_context.stage_active_target_demoting_current( + "TID-deferred-load-observer".to_owned(), + Some("SID-nav".to_owned()), + "https://example.test/start".to_owned(), + Some("https://example.test/start".to_owned()), + ); browser_context .active_target .runtime_slot .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page_id = moli_core::PageId::new_for_testing(71); let frame = RendererFrameToken { page_id }; @@ -1104,7 +1135,7 @@ mod tests { }; assert_eq!( work.kind(), - ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction + ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection ); work } @@ -1133,6 +1164,7 @@ mod tests { fn navigation_activity_error_drains_progress_before_error_response() { let mut conn = CdpConnection::new(); let mut browser_context = BrowserContext::new("BID-1".to_owned()); + browser_context.set_active_target_id("TID-page"); browser_context.attach_active_session("SID-page"); browser_context .active_target @@ -1160,7 +1192,16 @@ mod tests { download.progress_gate, None, ); - let out = activity.navigation_error_messages_for_test("download activation failed"); + let mut output = CommandOutputBuffer::default(); + activity.emit_navigation_error_instead_of_result_into_buffer( + &mut output, + "download activation failed", + ); + activity.emit_navigation_result_from_state_into_buffer(&mut output); + let mut out = Vec::new(); + output + .into_plan() + .emit_into(&mut out, Some(77), Some("SID-nav")); assert_eq!(out.len(), 2); assert_eq!(out[0]["method"], json!("Network.requestWillBeSent")); @@ -1320,26 +1361,40 @@ mod tests { assert!(messages[0].get("result").is_none()); } - #[tokio::test(flavor = "multi_thread")] - async fn loaded_commit_does_not_rediscover_child_frame_activity_from_page_state() { + #[test] + fn loaded_commit_synchronously_projects_frozen_lifecycle_without_page_rediscovery() { let mut browser_context = BrowserContext::new("BID-child".to_owned()); - browser_context.set_active_target_id("FRAME-1"); - browser_context.attach_active_session("SID-nav"); - browser_context.set_target_url("https://example.test/parent".to_owned()); + browser_context.stage_active_target_demoting_current( + "FRAME-1".to_owned(), + Some("SID-nav".to_owned()), + "https://example.test/parent".to_owned(), + Some("https://example.test/parent".to_owned()), + ); browser_context - .devtools_session_state + .active_target + .runtime_slot + .set_page_attachment_id_for_test(1); + browser_context + .devtools_session_state_mut() .page_session_state .page_domain_enabled = true; let mut conn = CdpConnection::new(); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); + let bootstrap_sequence = conn + .last_projected_browser_fact_sequence_for_test() + .expect("BrowserContext bootstrap should consume its Target creation facts"); let state = navigation_state(); - let activity = MainDocumentNavigationActivity::new( + let mut activity = MainDocumentNavigationActivity::new( state.clone(), Url::parse("https://example.test/parent").unwrap(), empty_main_document_progress_gate_for_test(), None, ); let mut output = CommandOutputBuffer::default(); + activity.emit_navigation_error_instead_of_result_into_buffer( + &mut output, + "engine adoption rejected", + ); let page_id = moli_core::PageId::new_for_testing(17); let renderer_frame = RendererFrameToken { page_id }; let renderer_document = RendererDocumentToken::new_for_testing(page_id, 1); @@ -1354,39 +1409,58 @@ mod tests { page_attachment_id: crate::conn::TargetPageAttachmentId::from_raw_for_test(1), document_open_replacement_epoch: None, }; - let renderer_lifecycle_events = vec![ - RendererDocumentLifecycleEvent { - frame: renderer_frame, - document: renderer_document, - epoch: renderer_epoch, - sequence: 1, - timestamp_micros: 12_000_000, - kind: RendererDocumentLifecycleEventKind::Started { - reason: RendererLifecycleStartReason::InitialDocument, - }, + let started = RendererDocumentLifecycleEvent { + frame: renderer_frame, + document: renderer_document, + epoch: renderer_epoch, + sequence: 1, + timestamp_micros: 12_000_000, + kind: RendererDocumentLifecycleEventKind::Started { + reason: RendererLifecycleStartReason::InitialDocument, }, + }; + let renderer_lifecycle_events = vec![ + started, RendererDocumentLifecycleEvent { - frame: renderer_frame, - document: renderer_document, - epoch: renderer_epoch, sequence: 2, timestamp_micros: 12_345_678, kind: RendererDocumentLifecycleEventKind::Milestone( RendererDocumentLifecycleMilestone::DomContentLoaded, ), + ..started }, ]; + let page = conn + .target_page_residence_identity_for_session(Some("SID-nav")) + .expect("test target should expose exact Browser Page residence"); + conn.record_authoritative_renderer_document_lifecycle_facts( + Some(&page), + Some(&renderer_document_binding), + &renderer_lifecycle_events, + ); + let duplicate_binding = renderer_document_binding.clone(); + let duplicate_renderer_lifecycle_events = renderer_lifecycle_events.clone(); - activity - .emit_loaded_navigation_commit_async( - &mut conn, - &mut output, - None, - Some(renderer_document_binding), - renderer_lifecycle_events, - None, - ) - .await; + activity.emit_loaded_navigation_commit( + &mut conn, + &mut output, + None, + Some(renderer_document_binding), + renderer_lifecycle_events, + None, + ); + + assert!( + conn.take_scheduler_events().into_iter().any(|event| { + matches!( + event, + crate::conn::CdpSchedulerEvent::ProtocolWorkPublished { work } + if work.kind() + == crate::domains::activity::ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection + ) + }), + "the synchronous lifecycle apply must publish its deferred load fact projection before returning" + ); let mut out = Vec::new(); output.into_plan().emit_into( @@ -1398,12 +1472,40 @@ mod tests { .iter() .position(|message| message["method"] == json!("Page.domContentEventFired")) .expect("DCL should still be emitted"); + let response = out + .iter() + .find(|message| message["id"] == json!(77)) + .expect("navigation error response"); + assert_eq!( + response["error"]["message"], + json!("engine adoption rejected") + ); assert!( out.iter() .all(|message| message["method"] != json!("Page.frameAttached")), "main-document commit must not synthesize child output by reading current Page state" ); assert_eq!(out[dcl_index]["params"]["timestamp"], json!(12.345678)); + assert_eq!( + conn.last_projected_browser_fact_sequence_for_test(), + Some(bootstrap_sequence + 1), + "the emitted DCL must consume the exact Browser fact sequence" + ); + let mut duplicate_events = Vec::new(); + page::emit_bound_renderer_document_lifecycle_background_events( + &mut conn, + &mut duplicate_events, + Some("SID-nav"), + &duplicate_binding, + &duplicate_renderer_lifecycle_events, + ); + assert!( + duplicate_events.iter().all(|event| !matches!( + event.protocol_method(), + Some("Page.domContentEventFired" | "Page.loadEventFired") + )), + "replaying the raw renderer record without another Browser fact must not duplicate lifecycle output" + ); assert!( conn.runtime_session_owner_slot(Some("SID-nav")) .expect("owner slot should exist") @@ -1420,13 +1522,12 @@ mod tests { browser_context.set_active_target_id("TID-reload"); browser_context.attach_active_session("SID-nav"); browser_context.set_target_url(final_url.as_str().to_owned()); - let old_token = browser_context - .start_document_navigation_for_active_target("LID-1".to_owned()) - .expect("old navigation token"); - browser_context.commit_document_navigation_if_matches(&old_token); - let mut conn = CdpConnection::new(); conn.browser_context = Some(browser_context); + let old_token = conn + .start_document_navigation_for_session_owner(Some("SID-nav"), "LID-1".to_owned()) + .expect("old navigation token"); + conn.commit_document_navigation_for_session_owner_if_matches(Some("SID-nav"), &old_token); let activity = MainDocumentNavigationActivity::new( navigation_state(), final_url.clone(), @@ -1439,10 +1540,7 @@ mod tests { ); let new_token = conn - .browser_context - .as_mut() - .expect("browser context") - .start_document_navigation_for_active_target("LID-2".to_owned()) + .start_document_navigation_for_session_owner(Some("SID-nav"), "LID-2".to_owned()) .expect("new navigation token"); conn.browser_context .as_mut() @@ -1454,10 +1552,7 @@ mod tests { "URL equality is not enough: a newer pending navigation to the same URL must make the old load completion stale" ); - conn.browser_context - .as_mut() - .expect("browser context") - .commit_document_navigation_if_matches(&new_token); + conn.commit_document_navigation_for_session_owner_if_matches(Some("SID-nav"), &new_token); assert!( !activity.is_still_current(&conn), @@ -1472,13 +1567,12 @@ mod tests { browser_context.set_active_target_id("TID-same-doc"); browser_context.attach_active_session("SID-nav"); browser_context.set_target_url(final_url.as_str().to_owned()); - let token = browser_context - .start_document_navigation_for_active_target("LID-1".to_owned()) - .expect("navigation token"); - browser_context.commit_document_navigation_if_matches(&token); - let mut conn = CdpConnection::new(); conn.browser_context = Some(browser_context); + let token = conn + .start_document_navigation_for_session_owner(Some("SID-nav"), "LID-1".to_owned()) + .expect("navigation token"); + conn.commit_document_navigation_for_session_owner_if_matches(Some("SID-nav"), &token); let activity = MainDocumentNavigationActivity::new( navigation_state(), final_url, @@ -1497,17 +1591,17 @@ mod tests { } #[tokio::test] - async fn deferred_load_observer_waits_for_load_not_domcontentloaded() { + async fn deferred_load_fact_waits_for_load_not_domcontentloaded() { let (mut conn, binding, started) = connection_with_dcl_only_renderer_lifecycle(); - let observer = conn.register_exact_renderer_document_lifecycle_observer_for_session_owner( + let ticket = conn.exact_renderer_document_lifecycle_fact_wait_for_session_owner( Some("SID-nav"), Some(&binding), RendererDocumentLifecycleMilestone::Load, ); assert_eq!( - observer.observation(), - RendererDocumentLifecycleObservation::Pending, - "DOMContentLoaded must not satisfy an exact Load observer" + ticket.outcome(), + None, + "DOMContentLoaded must not satisfy an exact Load fact ticket" ); let load = renderer_load_event_for_test(started); @@ -1517,8 +1611,8 @@ mod tests { ); assert_eq!(accepted, vec![load]); assert_eq!( - observer.wait().await, - RendererDocumentLifecycleObservation::Reached + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Reached ); } @@ -1561,7 +1655,7 @@ mod tests { }; assert_eq!( work.kind(), - ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction + ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection ); assert_eq!(work.main_document_load_session_id(), Some("SID-nav")); work.publish_sequence().get() @@ -1583,7 +1677,7 @@ mod tests { let work = take_deferred_load_work_for_test(&mut conn); assert!( !work.is_ready(), - "DOMContentLoaded alone must leave the exact load owner action pending" + "DOMContentLoaded alone must leave the exact load fact projection pending" ); let load = renderer_load_event_for_test(started); @@ -1593,11 +1687,11 @@ mod tests { ); assert!( conn.take_scheduler_events().is_empty(), - "a typed lifecycle terminal wakes the existing observer and must not rebuild a source-shaped scheduler ticket" + "a typed lifecycle fact settles the existing ticket and must not rebuild a source-shaped scheduler ticket" ); assert!( work.is_ready(), - "the same durable owner-action residence must observe the terminal" + "the same durable fact-projection residence must observe the terminal" ); let outcome = conn.complete_ready_protocol_scheduler_work_turn(work).await; let (_, scheduler_events) = outcome.into_protocol_event_parts(); @@ -1606,16 +1700,16 @@ mod tests { event, crate::conn::CdpSchedulerEvent::ProtocolWorkPublished { work } if work.kind() - == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction + == ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection )), - "consuming the exact owner action must not recreate another load residence" + "consuming the exact fact projection must not recreate another load residence" ); } #[tokio::test] - async fn deferred_load_observer_reports_exact_document_interruption() { + async fn deferred_load_fact_reports_exact_document_interruption() { let (mut conn, binding, started) = connection_with_dcl_only_renderer_lifecycle(); - let observer = conn.register_exact_renderer_document_lifecycle_observer_for_session_owner( + let ticket = conn.exact_renderer_document_lifecycle_fact_wait_for_session_owner( Some("SID-nav"), Some(&binding), RendererDocumentLifecycleMilestone::Load, @@ -1626,16 +1720,19 @@ mod tests { vec![terminated], ); - assert_eq!( - observer.wait().await, - RendererDocumentLifecycleObservation::Interrupted - ); + assert!(matches!( + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Interrupted { + last_reached: Some(RendererDocumentLifecycleMilestone::DomContentLoaded), + .. + } + )); } #[tokio::test] - async fn newer_document_navigation_supersedes_deferred_load_observer() { + async fn newer_document_navigation_fact_supersedes_deferred_load_wait() { let (mut conn, binding, _) = connection_with_dcl_only_renderer_lifecycle(); - let observer = conn.register_exact_renderer_document_lifecycle_observer_for_session_owner( + let ticket = conn.exact_renderer_document_lifecycle_fact_wait_for_session_owner( Some("SID-nav"), Some(&binding), RendererDocumentLifecycleMilestone::Load, @@ -1644,29 +1741,61 @@ mod tests { .expect("replacement navigation token"); assert_eq!( - observer.wait().await, - RendererDocumentLifecycleObservation::Superseded + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Superseded ); } - #[tokio::test] - async fn losing_page_slot_terminates_deferred_load_observer() { + #[test] + fn losing_only_physical_projection_does_not_manufacture_a_browser_terminal() { let (mut conn, binding, _) = connection_with_dcl_only_renderer_lifecycle(); - let observer = conn.register_exact_renderer_document_lifecycle_observer_for_session_owner( + let ticket = conn.exact_renderer_document_lifecycle_fact_wait_for_session_owner( Some("SID-nav"), Some(&binding), RendererDocumentLifecycleMilestone::Load, ); conn.browser_context = None; + assert!( + !ticket.is_terminal(), + "frontend physical storage loss is not a Browser lifecycle fact" + ); + } + + #[tokio::test] + async fn target_crash_fact_terminates_deferred_load_wait() { + let (mut conn, binding, _) = connection_with_dcl_only_renderer_lifecycle(); + let ticket = conn.exact_renderer_document_lifecycle_fact_wait_for_session_owner( + Some("SID-nav"), + Some(&binding), + RendererDocumentLifecycleMilestone::Load, + ); + let request = conn + .capture_browser_target_termination_for_session_owner( + Some("SID-nav"), + crate::conn::BrowserTargetTerminationProjectionKind::Crash, + ) + .expect("exact Target should capture crash authority"); + let mut events = Vec::new(); + let _termination = conn + .start_browser_page_target_termination( + request, + crate::conn::BrowserTargetTerminationProjectionKind::Crash, + &mut events, + "test crash", + ) + .expect("exact Target crash should commit"); + assert_eq!( - observer.wait().await, - RendererDocumentLifecycleObservation::Unavailable + ticket.wait().await, + BrowserDocumentLifecycleWaitOutcome::Unavailable( + moli_core::browser_host::BrowserDocumentLifecycleWaitUnavailableReason::TargetCrashed + ) ); } #[tokio::test] - async fn superseded_deferred_load_completion_is_consumed_through_its_observer() { + async fn superseded_deferred_load_completion_is_consumed_through_its_fact_ticket() { let (mut conn, binding, _) = connection_with_dcl_only_renderer_lifecycle(); let old_completion = deferred_load_admission_for_test(&conn, binding); conn.enqueue_deferred_main_document_load_completion(old_completion); @@ -1677,7 +1806,7 @@ mod tests { assert!( conn.take_scheduler_events().is_empty(), - "replacement must resolve the existing observer without manufacturing another scheduler admission" + "navigation acceptance must resolve the existing fact ticket without manufacturing another scheduler admission" ); assert!(work.is_ready(), "replacement must publish Superseded"); let pending = work.start_main_document_load_wait(); diff --git a/moli-protocol/src/domains/activity/output_ingress/ordered_stream.rs b/moli-protocol/src/domains/activity/output_ingress/ordered_stream.rs index 24549d2229..b8e4a25d20 100644 --- a/moli-protocol/src/domains/activity/output_ingress/ordered_stream.rs +++ b/moli-protocol/src/domains/activity/output_ingress/ordered_stream.rs @@ -503,14 +503,8 @@ mod tests { page_id, ); RendererPublicationOwner::PageTarget { - browser_context_id: "BID-test".to_owned(), - target_id: Some("TID-test".to_owned()), + page_owner: moli_core::browser_host::BrowserPageOwnerKey::new("BID-test", "TID-test"), renderer_page, - page_owner: crate::conn::TargetPageResidenceIdentity::new_for_test( - "BID-test".to_owned(), - Some("TID-test".to_owned()), - 1, - ), } } diff --git a/moli-protocol/src/domains/activity/output_ingress/prepared_outputs.rs b/moli-protocol/src/domains/activity/output_ingress/prepared_outputs.rs index e14f98fef9..a5f5f89486 100644 --- a/moli-protocol/src/domains/activity/output_ingress/prepared_outputs.rs +++ b/moli-protocol/src/domains/activity/output_ingress/prepared_outputs.rs @@ -311,7 +311,7 @@ impl PreparedProtocolOutputs { } RendererOwnerAction::TopLevelHistoryTraversal(traversal) => { crate::domains::page::PagePreparedOutputs:: - from_renderer_top_level_history_traversal(traversal) + from_renderer_top_level_history_traversal(conn, session_id, traversal) .append_to_top_level_history_traversal_output_sink(&mut prepared); } RendererOwnerAction::SubresourceFetchPause { diff --git a/moli-protocol/src/domains/activity/output_ingress/renderer.rs b/moli-protocol/src/domains/activity/output_ingress/renderer.rs index 6516fd9325..99afc0c27c 100644 --- a/moli-protocol/src/domains/activity/output_ingress/renderer.rs +++ b/moli-protocol/src/domains/activity/output_ingress/renderer.rs @@ -266,7 +266,7 @@ mod tests { .active_target .runtime_slot .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); assert_eq!( renderer_owner_action_session_id(&conn, None, None).as_deref(), diff --git a/moli-protocol/src/domains/activity/output_slot.rs b/moli-protocol/src/domains/activity/output_slot.rs index 3f6e5c3219..6a919101d7 100644 --- a/moli-protocol/src/domains/activity/output_slot.rs +++ b/moli-protocol/src/domains/activity/output_slot.rs @@ -321,4 +321,18 @@ mod tests { ); } } + + #[test] + fn dedicated_worker_lifecycle_can_unlock_awaiting_runtime_response() { + assert_eq!( + ProtocolOutputSlot::DedicatedWorkerTargetLifecycle.command_response_order(), + ProtocolOutputResponseOrder::BeforeResponse, + "attachedToTarget must be publishable while the creating Runtime command awaits the paused Worker" + ); + assert_eq!( + ProtocolOutputSlot::DedicatedWorkerTargetLifecycle.delivery(), + ProtocolOutputDelivery::OwnerAction, + "worker auto-attach is progress, not a protocol-only observation" + ); + } } diff --git a/moli-protocol/src/domains/activity/output_work.rs b/moli-protocol/src/domains/activity/output_work.rs index da6dd1f236..7277107090 100644 --- a/moli-protocol/src/domains/activity/output_work.rs +++ b/moli-protocol/src/domains/activity/output_work.rs @@ -75,28 +75,11 @@ impl ProtocolOutputWork { session_ids: Vec>, frame_id: String, loader_id: String, - ) -> Self { - Self::root_frame_stopped_loading_for_target_test_support( - session_ids, - "__protocol_output_test_context__".to_owned(), - "__protocol_output_test_target__".to_owned(), - frame_id, - loader_id, - ) - } - - #[cfg(feature = "test-support")] - pub(super) fn root_frame_stopped_loading_for_target_test_support( - session_ids: Vec>, - browser_context_id: String, - target_id: String, - frame_id: String, - loader_id: String, ) -> Self { let page_owner = TargetPageResidenceIdentity::new( - browser_context_id, - Some(target_id), - crate::conn::TargetPageAttachmentId::allocate(), + "__protocol_output_test_context__".to_owned(), + Some("__protocol_output_test_target__".to_owned()), + 1, ); let attachments = session_ids .into_iter() @@ -114,20 +97,6 @@ impl ProtocolOutputWork { ) } - pub(crate) fn navigation_gate_target_id(&self) -> Option<&str> { - match &self.payload { - ProtocolOutputPayload::RootFrameStoppedLoading(output) => { - let target_id = output.attachments.first()?.page_owner().target_id()?; - debug_assert!( - output.attachments.iter().all(|attachment| { - attachment.page_owner().target_id() == Some(target_id) - }) - ); - Some(target_id) - } - } - } - pub(crate) fn into_background_events( self, conn: &CdpConnection, diff --git a/moli-protocol/src/domains/activity/publication_route.rs b/moli-protocol/src/domains/activity/publication_route.rs index d018dfbeb6..8a65865cd0 100644 --- a/moli-protocol/src/domains/activity/publication_route.rs +++ b/moli-protocol/src/domains/activity/publication_route.rs @@ -1,4 +1,4 @@ -use moli_core::RendererOutputResidenceIdentity; +use moli_core::{RendererOutputResidenceIdentity, browser_host::BrowserPageOwnerKey}; use crate::conn::{CdpConnection, CdpSessionRoute, RendererPageResidenceIdentity}; @@ -13,10 +13,8 @@ use crate::conn::{CdpConnection, CdpSessionRoute, RendererPageResidenceIdentity} #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum RendererPublicationOwner { PageTarget { - browser_context_id: String, - target_id: Option, + page_owner: BrowserPageOwnerKey, renderer_page: RendererPageResidenceIdentity, - page_owner: crate::conn::TargetPageResidenceIdentity, }, BrowserContext { browser_context_id: String, @@ -137,93 +135,119 @@ impl RendererPublicationOwner { }) } Self::PageTarget { - browser_context_id, - target_id, - renderer_page, page_owner, + renderer_page, .. } => conn .browser_context .iter() .chain(conn.inactive_browser_contexts.iter()) - .filter(|browser_context| browser_context.id == *browser_context_id) + .filter(|browser_context| browser_context.id == page_owner.browser_context_id()) .find_map(|browser_context| { - let projection_for = |runtime_slot: &crate::conn::TargetRuntimeSlot| { - if runtime_slot.routes_current_renderer_page_owner( - *renderer_page, - page_owner.page_attachment_id(), - ) { - Some(RendererPublicationProjection::CurrentPage) - } else if runtime_slot.routes_retiring_renderer_page_owner( - *renderer_page, - page_owner.page_attachment_id(), - ) { - Some(RendererPublicationProjection::RetiringNetworkOnly) + let (runtime_slot, route_target_id, session_id) = + if browser_context.active_target_id() == Some(page_owner.target_id()) { + ( + &browser_context.active_target.runtime_slot, + None, + browser_context.active_session_id_owned(), + ) } else { - None - } - }; - let route_for = - |runtime_slot: &crate::conn::TargetRuntimeSlot, - route_target_id: Option, - session_id: Option| { - projection_for(runtime_slot).map(|projection| { - RendererPublicationRoute::for_target( - browser_context.id.clone(), - route_target_id, - session_id, - projection, - ) - }) - }; - - // Prefer the target route captured when the renderer stream - // was bound. This disambiguates the brief handoff window in - // which active and parked state can both refer to one Page. - let frozen_route = if target_id.is_none() - || browser_context.active_target_id() == target_id.as_deref() - { - route_for( - &browser_context.active_target.runtime_slot, - None, - browser_context.active_session_id_owned(), - ) - } else { - target_id.as_deref().and_then(|target_id| { - let target = browser_context.background_target(target_id)?; - route_for( + let target = + browser_context.background_target(page_owner.target_id())?; + ( target.runtime_slot(), - Some(target_id.to_owned()), - target.session_id().map(str::to_owned), + Some(page_owner.target_id().to_owned()), + browser_context + .primary_session_id_for_target(page_owner.target_id()) + .map(str::to_owned), ) - }) + }; + let projection = if runtime_slot.routes_renderer_page(*renderer_page) { + RendererPublicationProjection::CurrentPage + } else if runtime_slot.routes_retiring_renderer_page(*renderer_page) { + RendererPublicationProjection::RetiringNetworkOnly + } else { + return None; }; - if frozen_route.is_some() { - return frozen_route; - } - - // A target can acquire its externally visible id after its - // initial Page was installed. The Page residence and Page - // attachment id remain stable across that rename, so use - // those identities to recover the current concrete route. - if let Some(route) = route_for( - &browser_context.active_target.runtime_slot, - None, - browser_context.active_session_id_owned(), - ) { - return Some(route); - } - browser_context - .background_targets - .iter() - .find_map(|target| { - route_for( - target.runtime_slot(), - Some(target.target_id().to_owned()), - target.session_id().map(str::to_owned), - ) - }) + Some(RendererPublicationRoute::for_target( + browser_context.id.clone(), + route_target_id, + session_id, + projection, + )) }), } } } + +#[cfg(test)] +mod tests { + use moli_core::{PageId, RendererOwnerLocalHostId, page::RendererDevToolsAgentToken}; + + use crate::conn::{BrowserContext, CdpConnection}; + + use super::*; + + #[test] + fn pending_renderer_page_route_is_independent_of_current_page_generation() { + let mut conn = CdpConnection::default(); + let mut browser_context = BrowserContext::new("BID-publication".to_owned()); + browser_context.set_active_target_id("TID-publication"); + browser_context.attach_active_session("SID-publication"); + let renderer_page = RendererPageResidenceIdentity::new( + RendererOwnerLocalHostId::new_for_testing(7), + PageId::new_for_testing(11), + ); + conn.browser_context = Some(browser_context); + let navigation = conn + .start_document_navigation_for_session_owner( + Some("SID-publication"), + "LOADER-publication".to_owned(), + ) + .expect("pending navigation should start"); + let candidate = conn + .prepare_renderer_agent_candidate_token_for_session_owner( + Some("SID-publication"), + &navigation, + RendererDevToolsAgentToken::allocate(), + ) + .expect("pending navigation should accept a renderer agent candidate"); + conn.commit_renderer_agent_candidate_for_session_owner( + Some("SID-publication"), + candidate, + renderer_page, + ) + .expect("committed candidate should bind the exact pending renderer Page"); + let owner = RendererPublicationOwner::PageTarget { + page_owner: BrowserPageOwnerKey::new("BID-publication", "TID-publication"), + renderer_page, + }; + + assert!(matches!( + owner.resolve(&conn), + Some(RendererPublicationRoute::AttachedSession { ref session_id, .. }) + if session_id == "SID-publication" + )); + + conn.browser_context + .as_mut() + .unwrap() + .active_target + .runtime_slot + .bump_loaded_page_generation(); + assert!( + owner.resolve(&conn).is_some(), + "a future renderer Page route must not borrow the old current Page generation" + ); + + conn.start_document_navigation_for_session_owner( + Some("SID-publication"), + "LOADER-successor".to_owned(), + ) + .expect("successor navigation should start"); + assert!( + owner.resolve(&conn).is_none(), + "superseding the exact renderer Page reservation must retire its output route" + ); + } +} diff --git a/moli-protocol/src/domains/activity/runtime_command_barrier.rs b/moli-protocol/src/domains/activity/runtime_command_barrier.rs index 0aef7f1cfd..31a350ccf8 100644 --- a/moli-protocol/src/domains/activity/runtime_command_barrier.rs +++ b/moli-protocol/src/domains/activity/runtime_command_barrier.rs @@ -502,7 +502,7 @@ mod tests { .active_target .runtime_slot .replace_loaded_page(Some(page)); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); conn } @@ -641,30 +641,30 @@ mod tests { .any(|message| message["method"] == "Page.frameStartedNavigating") } + fn install_browser_host_for_test( + conn: &mut CdpConnection, + ) -> moli_core::browser_host::BrowserHostActor { + let (actor, handle) = + moli_core::browser_host::BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + actor + } + async fn complete_published_top_level_navigation( conn: &mut CdpConnection, + browser_host: &mut moli_core::browser_host::BrowserHostActor, command_context: &mut CommandDispatchContext, ) { - let [event]: [crate::conn::CdpSchedulerEvent; 1] = conn - .take_scheduler_events() - .try_into() - .expect("barrier release should publish one concrete navigation action"); - let crate::conn::CdpSchedulerEvent::ProtocolWorkPublished { work } = event else { - panic!("barrier release must publish concrete protocol work"); - }; - assert!(work.is_top_level_location_navigation_owner_action()); - let (events, nested_scheduler_events) = conn - .complete_ready_protocol_scheduler_work_turn(work) + let dispatch = browser_host + .complete_next_turn(conn) + .expect("barrier release should execute one concrete Browser Host turn"); + let (events, _nested_scheduler_events, renderer_output_predecessor) = conn + .finish_browser_host_turn_for_test(dispatch) .await .into_protocol_event_parts(); + assert!(renderer_output_predecessor.is_none()); assert!( - !nested_scheduler_events.iter().any(|event| { - matches!( - event, - crate::conn::CdpSchedulerEvent::ProtocolWorkPublished { work } - if work.is_top_level_location_navigation_owner_action() - ) - }), + !browser_host.has_ready_input(), "executing the concrete navigation must not republish its own owner action" ); command_context.protocol_events_mut().extend(events); @@ -780,6 +780,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn output_is_owned_by_one_exact_command_not_every_command_on_the_page() { let mut conn = connection_with_loaded_page().await; + let mut browser_host = install_browser_host_for_test(&mut conn); let mut barriers = RuntimeCommandOutputBarriers::default(); let first = admit_registered_command(&mut conn, &mut barriers, 11); let second = admit_registered_command(&mut conn, &mut barriers, 12); @@ -820,7 +821,8 @@ mod tests { !contains_top_level_navigation(&protocol_messages(&mut command_context)), "barrier release must publish, not execute, its concrete owner action" ); - complete_published_top_level_navigation(&mut conn, &mut command_context).await; + complete_published_top_level_navigation(&mut conn, &mut browser_host, &mut command_context) + .await; assert!( contains_top_level_navigation(&protocol_messages(&mut command_context)), "the exact command response should release its held output for one scheduler turn" @@ -830,6 +832,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn canceling_an_unrelated_command_does_not_downgrade_held_output() { let mut conn = connection_with_loaded_page().await; + let mut browser_host = install_browser_host_for_test(&mut conn); let mut barriers = RuntimeCommandOutputBarriers::default(); let first = admit_registered_command(&mut conn, &mut barriers, 15); let second = admit_registered_command(&mut conn, &mut barriers, 16); @@ -870,7 +873,8 @@ mod tests { !contains_top_level_navigation(&protocol_messages(&mut command_context)), "barrier release must publish, not execute, the retained owner action" ); - complete_published_top_level_navigation(&mut conn, &mut command_context).await; + complete_published_top_level_navigation(&mut conn, &mut browser_host, &mut command_context) + .await; assert!( contains_top_level_navigation(&protocol_messages(&mut command_context)), "the exact command must retain and release its full protocol output after an unrelated cancel" @@ -909,6 +913,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn concrete_runtime_navigation_cause_survives_an_earlier_untagged_wake() { let mut conn = connection_with_loaded_page().await; + let mut browser_host = install_browser_host_for_test(&mut conn); let mut barriers = RuntimeCommandOutputBarriers::default(); let permit = admit_registered_command(&mut conn, &mut barriers, 14); let cause = renderer_cause_for_permit(&conn, &permit); @@ -954,7 +959,8 @@ mod tests { !contains_top_level_navigation(&protocol_messages(&mut command_context)), "barrier release must publish, not execute, the concrete navigation action" ); - complete_published_top_level_navigation(&mut conn, &mut command_context).await; + complete_published_top_level_navigation(&mut conn, &mut browser_host, &mut command_context) + .await; assert!( contains_top_level_navigation(&protocol_messages(&mut command_context)), "the exact response permit must release the concrete navigation for its scheduler turn" @@ -966,9 +972,12 @@ mod tests { let mut conn = connection_with_loaded_page().await; let mut barriers = RuntimeCommandOutputBarriers::default(); let permit = admit_registered_command(&mut conn, &mut barriers, 21); + let source_owner = conn + .target_page_residence_identity_for_session(Some(SESSION_ID)) + .expect("source Page should have an exact residence"); conn.runtime_session_owner_slot_mut(Some(SESSION_ID)) .expect("runtime slot should remain installed") - .replace_page_attachment_id_for_test(); + .set_loaded_page_generation(source_owner.loaded_page_generation() + 1); let mut command_context = CommandDispatchContext::default(); route_same_document_navigation( @@ -1002,8 +1011,12 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn replacement_after_capture_retires_held_old_page_output() { let mut conn = connection_with_loaded_page().await; + let mut browser_host = install_browser_host_for_test(&mut conn); let mut barriers = RuntimeCommandOutputBarriers::default(); let permit = admit_registered_command(&mut conn, &mut barriers, 30); + let source_owner = conn + .target_page_residence_identity_for_session(Some(SESSION_ID)) + .expect("source Page should have an exact residence"); let mut command_context = CommandDispatchContext::default(); route_top_level_navigation_for_command( @@ -1018,7 +1031,7 @@ mod tests { conn.runtime_session_owner_slot_mut(Some(SESSION_ID)) .expect("runtime slot should remain installed") - .replace_page_attachment_id_for_test(); + .set_loaded_page_generation(source_owner.loaded_page_generation() + 1); assert_eq!( barriers .release(&mut conn, permit, &mut command_context) @@ -1026,7 +1039,8 @@ mod tests { RuntimeCommandOutputBarrierTerminal::Superseded ); assert_eq!(barriers.held_output_count(), 0); - complete_published_top_level_navigation(&mut conn, &mut command_context).await; + complete_published_top_level_navigation(&mut conn, &mut browser_host, &mut command_context) + .await; assert!( !contains_top_level_navigation(&protocol_messages(&mut command_context)), "a held old-Page navigation action must not project into the replacement Page" @@ -1036,6 +1050,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn explicit_cancel_consumes_the_barrier_once() { let mut conn = connection_with_loaded_page().await; + let mut browser_host = install_browser_host_for_test(&mut conn); let mut barriers = RuntimeCommandOutputBarriers::default(); let permit = admit_registered_command(&mut conn, &mut barriers, 31); let mut command_context = CommandDispatchContext::default(); @@ -1061,7 +1076,8 @@ mod tests { !contains_top_level_navigation(&protocol_messages(&mut command_context)), "cancel must publish, not execute, the already-produced owner action" ); - complete_published_top_level_navigation(&mut conn, &mut command_context).await; + complete_published_top_level_navigation(&mut conn, &mut browser_host, &mut command_context) + .await; assert!( contains_top_level_navigation(&protocol_messages(&mut command_context)), "canceling the protocol command must still settle its already-produced browser-owner action" diff --git a/moli-protocol/src/domains/activity/scheduler_work.rs b/moli-protocol/src/domains/activity/scheduler_work.rs index 60184f2045..254e13b28d 100644 --- a/moli-protocol/src/domains/activity/scheduler_work.rs +++ b/moli-protocol/src/domains/activity/scheduler_work.rs @@ -7,7 +7,6 @@ use crate::{ BidiChannelOwnerAction, CdpConnection, DeferredMainDocumentLoadCompletionOutputAction, DeferredMainDocumentLoadCompletionOutputInterest, PendingDeferredMainDocumentLoadCompletion, PopupTargetActivationAction, - PopupTargetNavigationOwnerAction, TopLevelLocationNavigationOwnerAction, }, devtools_runtime::DevToolsCommandContext, }; @@ -39,28 +38,29 @@ impl ProtocolWorkPublishSequence { /// The semantic responsibility carried by one durable protocol work item. /// -/// This classification deliberately preserves the P6-R1 split. An -/// observation only projects an already-settled fact. An owner action must -/// remain resident and complete even when no frontend is listening. +/// A protocol observation projects an already-frozen event; the load fact +/// projection consumes the neutral Browser journal; an explicit owner action +/// must remain resident even when no frontend is listening. +/// +/// Renderer-sourced navigation and Page/Target termination are intentionally +/// absent. Their exact inputs publish directly to the Core `BrowserHostActor`; +/// adding a protocol work variant here would recreate the removed admission or +/// execution fallback. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ProtocolSchedulerWorkKind { ProtocolObservation, - MainDocumentLoadOwnerAction, + MainDocumentLoadFactProjection, BidiChannelOwnerAction, - TopLevelLocationNavigationOwnerAction, - PopupTargetNavigationOwnerAction, PopupTargetActivationAction, - PageTargetTerminationOwnerAction, } /// Durable protocol-owned work with concrete payload, exact route and one /// connection-local publication sequence. /// /// This move-only value never asks a later turn to scan a source. The private -/// payload is either a ready protocol observation or an exact browser-owner -/// continuation. The common wrapper exists only to give both classes one -/// scheduler residence and one ordering contract; it does not make an owner -/// action listener-dependent. +/// payload is a ready protocol observation, an exact Browser-fact projection, +/// or an explicit owner continuation. The common wrapper exists only to give +/// them one scheduler residence and one ordering contract. pub struct ProtocolSchedulerWork { publish_sequence: ProtocolWorkPublishSequence, payload: ProtocolSchedulerWorkPayload, @@ -68,12 +68,9 @@ pub struct ProtocolSchedulerWork { enum ProtocolSchedulerWorkPayload { ProtocolObservation(ProtocolOutputWork), - MainDocumentLoadOwnerAction(Box), + MainDocumentLoadFactProjection(Box), BidiChannelOwnerAction(BidiChannelOwnerAction), - TopLevelLocationNavigationOwnerAction(TopLevelLocationNavigationOwnerAction), - PopupTargetNavigationOwnerAction(PopupTargetNavigationOwnerAction), PopupTargetActivationAction(PopupTargetActivationAction), - PageTargetTerminationOwnerAction(crate::domains::page::PageTargetTerminationOwnerAction), } impl fmt::Debug for ProtocolSchedulerWork { @@ -86,7 +83,7 @@ impl fmt::Debug for ProtocolSchedulerWork { ProtocolSchedulerWorkPayload::ProtocolObservation(output) => { debug.field("payload", output); } - ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(completion) => { + ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(completion) => { debug .field("observation_id", &completion.observation_id()) .field("session_id", &completion.session_id()) @@ -98,37 +95,18 @@ impl fmt::Debug for ProtocolSchedulerWork { "renderer_document", &completion.renderer_document_identity(), ) - .field("terminal", &completion.has_terminal_lifecycle_observation()); + .field("terminal", &completion.has_terminal_browser_fact()); } ProtocolSchedulerWorkPayload::BidiChannelOwnerAction(action) => { debug .field("action", &action.kind()) .field("session_id", &action.owner().session_id()); } - ProtocolSchedulerWorkPayload::TopLevelLocationNavigationOwnerAction(action) => { - debug - .field("session_id", &action.session_id()) - .field("source_document", &action.source_document()) - .field("url", &action.url()); - } - ProtocolSchedulerWorkPayload::PopupTargetNavigationOwnerAction(action) => { - debug - .field("browser_context_id", &action.browser_context_id()) - .field("target_id", &action.target_id()) - .field("url", &action.url()) - .field("navigation_kind", &action.kind()); - } ProtocolSchedulerWorkPayload::PopupTargetActivationAction(action) => { debug .field("browser_context_id", &action.browser_context_id()) .field("target_id", &action.target_id()); } - ProtocolSchedulerWorkPayload::PageTargetTerminationOwnerAction(action) => { - debug - .field("action", &action.kind()) - .field("session_id", &action.owner_scope().session_id()) - .field("target_id", &action.target_id()); - } } debug.finish() } @@ -145,13 +123,13 @@ impl ProtocolSchedulerWork { } } - pub(crate) fn main_document_load_owner_action( + pub(crate) fn main_document_load_fact_projection( publish_sequence: ProtocolWorkPublishSequence, completion: DeferredMainDocumentLoadCompletionActivity, ) -> Self { Self { publish_sequence, - payload: ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(Box::new( + payload: ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(Box::new( completion, )), } @@ -167,26 +145,6 @@ impl ProtocolSchedulerWork { } } - pub(crate) fn top_level_location_navigation_owner_action( - publish_sequence: ProtocolWorkPublishSequence, - action: TopLevelLocationNavigationOwnerAction, - ) -> Self { - Self { - publish_sequence, - payload: ProtocolSchedulerWorkPayload::TopLevelLocationNavigationOwnerAction(action), - } - } - - pub(crate) fn popup_target_navigation_owner_action( - publish_sequence: ProtocolWorkPublishSequence, - action: PopupTargetNavigationOwnerAction, - ) -> Self { - Self { - publish_sequence, - payload: ProtocolSchedulerWorkPayload::PopupTargetNavigationOwnerAction(action), - } - } - pub(crate) fn popup_target_activation_action( publish_sequence: ProtocolWorkPublishSequence, action: PopupTargetActivationAction, @@ -197,16 +155,6 @@ impl ProtocolSchedulerWork { } } - pub(crate) fn page_target_termination_owner_action( - publish_sequence: ProtocolWorkPublishSequence, - action: crate::domains::page::PageTargetTerminationOwnerAction, - ) -> Self { - Self { - publish_sequence, - payload: ProtocolSchedulerWorkPayload::PageTargetTerminationOwnerAction(action), - } - } - pub fn publish_sequence(&self) -> ProtocolWorkPublishSequence { self.publish_sequence } @@ -216,24 +164,15 @@ impl ProtocolSchedulerWork { ProtocolSchedulerWorkPayload::ProtocolObservation(_) => { ProtocolSchedulerWorkKind::ProtocolObservation } - ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(_) => { - ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction + ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(_) => { + ProtocolSchedulerWorkKind::MainDocumentLoadFactProjection } ProtocolSchedulerWorkPayload::BidiChannelOwnerAction(_) => { ProtocolSchedulerWorkKind::BidiChannelOwnerAction } - ProtocolSchedulerWorkPayload::TopLevelLocationNavigationOwnerAction(_) => { - ProtocolSchedulerWorkKind::TopLevelLocationNavigationOwnerAction - } - ProtocolSchedulerWorkPayload::PopupTargetNavigationOwnerAction(_) => { - ProtocolSchedulerWorkKind::PopupTargetNavigationOwnerAction - } ProtocolSchedulerWorkPayload::PopupTargetActivationAction(_) => { ProtocolSchedulerWorkKind::PopupTargetActivationAction } - ProtocolSchedulerWorkPayload::PageTargetTerminationOwnerAction(_) => { - ProtocolSchedulerWorkKind::PageTargetTerminationOwnerAction - } } } @@ -241,75 +180,29 @@ impl ProtocolSchedulerWork { /// scheduler. /// /// Protocol observations and already-materialized BiDi owner actions are - /// intrinsically ready. A main-document load action becomes ready only - /// when its exact lifecycle observer has published a typed terminal. + /// intrinsically ready. A main-document load projection becomes ready + /// only after its exact Browser fact ticket consumes a typed terminal. pub fn is_ready(&self) -> bool { match &self.payload { ProtocolSchedulerWorkPayload::ProtocolObservation(_) => true, - ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(completion) => { - completion.has_terminal_lifecycle_observation() + ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(completion) => { + completion.has_terminal_browser_fact() } ProtocolSchedulerWorkPayload::BidiChannelOwnerAction(_) => true, - ProtocolSchedulerWorkPayload::TopLevelLocationNavigationOwnerAction(_) => true, - ProtocolSchedulerWorkPayload::PopupTargetNavigationOwnerAction(_) => true, ProtocolSchedulerWorkPayload::PopupTargetActivationAction(_) => true, - ProtocolSchedulerWorkPayload::PageTargetTerminationOwnerAction(_) => true, } } - /// Reports owner work that must complete inside the producing command's - /// turn. - /// - /// Popup navigation is safe to start while completing the producing - /// command: it retains its exact target route and cannot replace the - /// command's active renderer. Popup activation is deliberately excluded. - /// It can replace that renderer and must therefore cross the ordinary - /// client-turn predecessor so the opener's command result is collected - /// from its original owner first. pub fn is_command_followup(&self) -> bool { matches!( &self.payload, ProtocolSchedulerWorkPayload::BidiChannelOwnerAction(_) - | ProtocolSchedulerWorkPayload::TopLevelLocationNavigationOwnerAction(_) - | ProtocolSchedulerWorkPayload::PopupTargetNavigationOwnerAction(_) - | ProtocolSchedulerWorkPayload::PageTargetTerminationOwnerAction(_) ) } - pub fn navigation_gate_target_id(&self) -> Option<&str> { - match &self.payload { - ProtocolSchedulerWorkPayload::ProtocolObservation(output) => { - output.navigation_gate_target_id() - } - ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(completion) => { - Some(completion.target_id()) - } - ProtocolSchedulerWorkPayload::BidiChannelOwnerAction(action) => { - action.owner().target_id() - } - ProtocolSchedulerWorkPayload::TopLevelLocationNavigationOwnerAction(action) => { - action.target_id() - } - ProtocolSchedulerWorkPayload::PopupTargetNavigationOwnerAction(action) => { - Some(action.target_id()) - } - ProtocolSchedulerWorkPayload::PopupTargetActivationAction(action) => { - Some(action.target_id()) - } - ProtocolSchedulerWorkPayload::PageTargetTerminationOwnerAction(action) => { - Some(action.target_id()) - } - } - } - - /// Reports work whose transition is independent of an in-flight document - /// load on the same target. - /// - /// Foreground selection happens when Chromium accepts a user activation, - /// not when the selected target finishes loading. This also lets a target - /// paused by `waitForDebuggerOnStart` become active before its initial - /// navigation is released. The exact target id above still preserves - /// target-local ordering against earlier scheduler residences. + /// Foreground selection is independent of the selected Target's document + /// load. It must still wait for the opener client turn, but not for the + /// popup navigation to finish (including wait-for-debugger staging). pub fn bypasses_inflight_navigation_gate(&self) -> bool { matches!( &self.payload, @@ -317,28 +210,6 @@ impl ProtocolSchedulerWork { ) } - pub fn is_top_level_location_navigation_owner_action(&self) -> bool { - matches!( - &self.payload, - ProtocolSchedulerWorkPayload::TopLevelLocationNavigationOwnerAction(_) - ) - } - - /// Reports work that requires the scheduler-owned background navigation - /// channels rather than the command fixture's inline fallback. - /// - /// Popup target creation is projected before the causing Runtime response, - /// while its URL load is deliberately independent. A protocol-only test - /// harness without those channels must retain this action instead of - /// accidentally turning it into a blocking navigation wait. - #[cfg(test)] - pub(crate) fn requires_background_navigation_scheduler(&self) -> bool { - matches!( - &self.payload, - ProtocolSchedulerWorkPayload::PopupTargetNavigationOwnerAction(_) - ) - } - #[cfg(test)] pub(crate) fn bidi_channel_owner_action_kind( &self, @@ -360,7 +231,8 @@ impl ProtocolSchedulerWork { pub fn main_document_load_output_interest( &self, ) -> Option { - let ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(completion) = &self.payload + let ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(completion) = + &self.payload else { return None; }; @@ -373,7 +245,8 @@ impl ProtocolSchedulerWork { pub fn main_document_load_observation_id( &self, ) -> Option { - let ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(completion) = &self.payload + let ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(completion) = + &self.payload else { return None; }; @@ -382,7 +255,8 @@ impl ProtocolSchedulerWork { #[cfg(test)] pub(crate) fn main_document_load_session_id(&self) -> Option<&str> { - let ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(completion) = &self.payload + let ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(completion) = + &self.payload else { return None; }; @@ -402,7 +276,8 @@ impl ProtocolSchedulerWork { conn: &CdpConnection, context: &DevToolsCommandContext, ) -> bool { - let ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(completion) = &self.payload + let ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(completion) = + &self.payload else { return false; }; @@ -412,15 +287,12 @@ impl ProtocolSchedulerWork { pub fn start_main_document_load_wait(self) -> PendingDeferredMainDocumentLoadCompletion { match self.payload { - ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(completion) => { + ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(completion) => { PendingDeferredMainDocumentLoadCompletion::new((*completion).start_scheduler_step()) } ProtocolSchedulerWorkPayload::ProtocolObservation(_) | ProtocolSchedulerWorkPayload::BidiChannelOwnerAction(_) - | ProtocolSchedulerWorkPayload::TopLevelLocationNavigationOwnerAction(_) - | ProtocolSchedulerWorkPayload::PopupTargetNavigationOwnerAction(_) - | ProtocolSchedulerWorkPayload::PopupTargetActivationAction(_) - | ProtocolSchedulerWorkPayload::PageTargetTerminationOwnerAction(_) => { + | ProtocolSchedulerWorkPayload::PopupTargetActivationAction(_) => { panic!("only main-document load owner work can start a lifecycle wait") } } @@ -442,39 +314,15 @@ impl ProtocolSchedulerWork { ), ) } - - #[cfg(feature = "test-support")] - pub(crate) fn root_frame_stopped_loading_for_target_test_support( - publish_sequence: u64, - session_ids: Vec>, - browser_context_id: String, - target_id: String, - frame_id: String, - loader_id: String, - ) -> Self { - Self::protocol_observation( - ProtocolWorkPublishSequence::new(publish_sequence), - ProtocolOutputWork::root_frame_stopped_loading_for_target_test_support( - session_ids, - browser_context_id, - target_id, - frame_id, - loader_id, - ), - ) - } } pub(crate) enum ReadyProtocolSchedulerWork { ProtocolObservation(ProtocolOutputWork), - MainDocumentLoadOwnerAction( + MainDocumentLoadFactProjection( Box, ), BidiChannelOwnerAction(BidiChannelOwnerAction), - TopLevelLocationNavigationOwnerAction(TopLevelLocationNavigationOwnerAction), - PopupTargetNavigationOwnerAction(PopupTargetNavigationOwnerAction), PopupTargetActivationAction(PopupTargetActivationAction), - PageTargetTerminationOwnerAction(crate::domains::page::PageTargetTerminationOwnerAction), } impl ProtocolSchedulerWork { @@ -483,29 +331,20 @@ impl ProtocolSchedulerWork { ProtocolSchedulerWorkPayload::ProtocolObservation(output) => { ReadyProtocolSchedulerWork::ProtocolObservation(output) } - ProtocolSchedulerWorkPayload::MainDocumentLoadOwnerAction(completion) => { + ProtocolSchedulerWorkPayload::MainDocumentLoadFactProjection(completion) => { let completion = completion.try_complete().unwrap_or_else(|_| { panic!( "pending main-document load work cannot be completed by a nonblocking scheduler turn" ) }); - ReadyProtocolSchedulerWork::MainDocumentLoadOwnerAction(Box::new(completion)) + ReadyProtocolSchedulerWork::MainDocumentLoadFactProjection(Box::new(completion)) } ProtocolSchedulerWorkPayload::BidiChannelOwnerAction(action) => { ReadyProtocolSchedulerWork::BidiChannelOwnerAction(action) } - ProtocolSchedulerWorkPayload::TopLevelLocationNavigationOwnerAction(action) => { - ReadyProtocolSchedulerWork::TopLevelLocationNavigationOwnerAction(action) - } - ProtocolSchedulerWorkPayload::PopupTargetNavigationOwnerAction(action) => { - ReadyProtocolSchedulerWork::PopupTargetNavigationOwnerAction(action) - } ProtocolSchedulerWorkPayload::PopupTargetActivationAction(action) => { ReadyProtocolSchedulerWork::PopupTargetActivationAction(action) } - ProtocolSchedulerWorkPayload::PageTargetTerminationOwnerAction(action) => { - ReadyProtocolSchedulerWork::PageTargetTerminationOwnerAction(action) - } } } } diff --git a/moli-protocol/src/domains/audits.rs b/moli-protocol/src/domains/audits.rs index 9a9214a329..4d70c22410 100644 --- a/moli-protocol/src/domains/audits.rs +++ b/moli-protocol/src/domains/audits.rs @@ -86,12 +86,13 @@ mod tests { browser_context.set_target_url("data:text/html,audits-test".to_owned()); browser_context.set_active_target_id("TID-audits".to_owned()); browser_context.attach_active_session("SID-audits".to_owned()); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.install_navigation_fixture_for_session_owner( &format!("data:text/html,{html}"), Some("SID-audits"), ) .await; + ctx.sent.clear(); } #[tokio::test(flavor = "multi_thread")] diff --git a/moli-protocol/src/domains/autofill.rs b/moli-protocol/src/domains/autofill.rs index 57e57c631d..ef57f18c48 100644 --- a/moli-protocol/src/domains/autofill.rs +++ b/moli-protocol/src/domains/autofill.rs @@ -135,7 +135,7 @@ pub(crate) fn complete_pending_autofill_command( ) -> CommandOutputPlan { let outcome = completed.completed.and_then(|completion| { conn.loaded_page_mut_for_protocol_access(completed.session_id.as_deref()) - .and_then(|page| { + .and_then(|mut page| { page.finish_autofill_trigger(completion) .map_err(|error| error.to_string()) }) diff --git a/moli-protocol/src/domains/browser.rs b/moli-protocol/src/domains/browser.rs index ceec80423d..3161f508d2 100644 --- a/moli-protocol/src/domains/browser.rs +++ b/moli-protocol/src/domains/browser.rs @@ -13,15 +13,12 @@ use crate::devtools_runtime::{ use crate::domains::actions::BrowserAction; use crate::domains::command_output::CommandOutputPlan; use crate::version; -use moli_core::page::{CompletedPageCommand, PendingPageCommand}; +use moli_core::{ + browser_host::{BrowserDownloadBehavior, BrowserDownloadPolicyUpdate}, + page::{CompletedPageCommand, PendingPageCommand}, +}; const DEV_TOOLS_WINDOW_ID: u32 = 1_923_710_101; -const DOWNLOAD_BEHAVIORS: &[&str] = &["default", "deny", "allow", "allowAndName"]; - -pub(crate) fn is_valid_download_behavior(behavior: &str) -> bool { - DOWNLOAD_BEHAVIORS.contains(&behavior) -} - pub(crate) struct PendingBrowserCommandDispatch { command_id: Option, response_session_id: Option, @@ -208,9 +205,10 @@ fn bounds_json(bounds: &BrowserWindowBounds) -> Value { } fn get_window_for_target(conn: &CdpConnection) -> CommandOutputPlan { + let policy = conn.browser_host_policy_snapshot(); CommandOutputPlan::result(json!({ "windowId": DEV_TOOLS_WINDOW_ID, - "bounds": bounds_json(&conn.window_bounds) + "bounds": bounds_json(policy.window_bounds()) })) } @@ -300,13 +298,17 @@ fn set_window_bounds(conn: &mut CdpConnection, cmd: &Cmd<'_>) -> CommandOutputPl return CommandOutputPlan::error(-32602, "InvalidParams"); } }; - conn.window_bounds.left = left; - conn.window_bounds.top = top; - conn.window_bounds.width = width; - conn.window_bounds.height = height; + let mut bounds = conn.browser_host_policy_snapshot().window_bounds().clone(); + bounds.left = left; + bounds.top = top; + bounds.width = width; + bounds.height = height; if let Some(window_state) = params.bounds.window_state { - conn.window_bounds.window_state = window_state.as_ref().to_owned(); + bounds.window_state = window_state.as_ref().to_owned(); } + conn.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplaceWindowBounds(bounds), + ); CommandOutputPlan::success() } @@ -327,22 +329,27 @@ pub(crate) fn set_download_behavior_command_output_plan( { return CommandOutputPlan::error(-31998, "UnknownBrowserContextId"); } - if !is_valid_download_behavior(params.behavior.as_str()) { + let Some(behavior) = BrowserDownloadBehavior::parse(params.behavior.as_str()) else { return CommandOutputPlan::error(-32602, "InvalidParams"); - } + }; match params.browser_context_id { - Some(browser_context_id) => conn.download_behavior.set_browser_context_policy( - browser_context_id, - params.behavior, - params.download_path, - ), - None => conn - .download_behavior - .set_global_policy(params.behavior, params.download_path), + Some(browser_context_id) => { + conn.apply_browser_download_policy_update( + BrowserDownloadPolicyUpdate::SetBrowserContext { + browser_context_id: browser_context_id.clone(), + behavior, + download_path: params.download_path, + }, + ); + conn.ensure_automation_download_event_override_for_browser_context(&browser_context_id); + } + None => conn.apply_browser_download_policy_update(BrowserDownloadPolicyUpdate::SetGlobal { + behavior, + download_path: params.download_path, + }), } - conn.download_behavior - .set_browser_events_enabled_for_session(cmd.session_id, params.events_enabled); + conn.set_browser_download_events_enabled_for_session(cmd.session_id, params.events_enabled); CommandOutputPlan::success() } @@ -406,38 +413,57 @@ fn execute_devtools_set_download_behavior( match target_contexts { Some(user_contexts) => { for browser_context_id in user_contexts { - conn.download_behavior - .reset_browser_context(browser_context_id.as_str()); + let browser_context_id = browser_context_id.into_string(); + conn.apply_browser_download_policy_update( + BrowserDownloadPolicyUpdate::RemoveBrowserContext { + browser_context_id: browser_context_id.clone(), + }, + ); + conn.clear_automation_download_events_for_browser_context(&browser_context_id); } } - None => conn.download_behavior.reset_global(), + None => { + conn.apply_browser_download_policy_update(BrowserDownloadPolicyUpdate::ResetGlobal); + conn.set_automation_download_events_enabled_for_browser_context(None, false); + } } return Ok(DevToolsCommandResult::Empty); }; - if !is_valid_download_behavior(behavior.behavior.as_str()) { + let Some(parsed_behavior) = BrowserDownloadBehavior::parse(behavior.behavior.as_str()) else { return Err(DevToolsError::new( DevToolsErrorKind::InvalidArgument, "download behavior is invalid", )); - } + }; match target_contexts { Some(user_contexts) => { for browser_context_id in user_contexts { - conn.download_behavior.set_browser_context( - browser_context_id.into_string(), - behavior.behavior.clone(), - behavior.download_path.clone(), + let browser_context_id = browser_context_id.into_string(); + conn.apply_browser_download_policy_update( + BrowserDownloadPolicyUpdate::SetBrowserContext { + browser_context_id: browser_context_id.clone(), + behavior: parsed_behavior, + download_path: behavior.download_path.clone(), + }, + ); + conn.set_automation_download_events_enabled_for_browser_context( + Some(&browser_context_id), behavior.events_enabled, ); } } - None => conn.download_behavior.set_global( - behavior.behavior, - behavior.download_path, - behavior.events_enabled, - ), + None => { + conn.apply_browser_download_policy_update(BrowserDownloadPolicyUpdate::SetGlobal { + behavior: parsed_behavior, + download_path: behavior.download_path, + }); + conn.set_automation_download_events_enabled_for_browser_context( + None, + behavior.events_enabled, + ); + } } Ok(DevToolsCommandResult::Empty) } @@ -457,20 +483,30 @@ async fn execute_devtools_set_permission_command_async( .browser_context_id .map(|browser_context_id| browser_context_id.into_string()); - conn.permission_overrides.retain(|override_entry| { - override_entry.permission != command.permission - || override_entry.origin.as_deref() != Some(command.origin.as_str()) - || override_entry.embedded_origin != command.embedded_origin + let permission = command.permission; + let setting = normalize_permission_setting(command.setting); + let origin = command.origin; + let embedded_origin = command.embedded_origin; + let mut overrides = conn + .browser_host_policy_snapshot() + .permission_overrides() + .to_vec(); + overrides.retain(|override_entry| { + override_entry.permission != permission + || override_entry.origin.as_deref() != Some(origin.as_str()) + || override_entry.embedded_origin != embedded_origin || override_entry.browser_context_id != browser_context_id }); - conn.permission_overrides - .push(crate::conn::PermissionOverride { - permission: command.permission, - setting: normalize_permission_setting(command.setting), - origin: Some(command.origin), - embedded_origin: command.embedded_origin, - browser_context_id, - }); + overrides.push(crate::conn::PermissionOverride { + permission, + setting, + origin: Some(origin), + embedded_origin, + browser_context_id, + }); + conn.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplacePermissionOverrides(overrides), + ); let pending = start_loaded_page_permission_override_commands(conn) .map_err(|error| DevToolsError::new(DevToolsErrorKind::Internal, error))?; @@ -561,20 +597,26 @@ fn start_set_permission_command(conn: &mut CdpConnection, cmd: &Cmd<'_>) -> Brow return browser_error_step(-31998, "UnknownBrowserContextId"); } - conn.permission_overrides.retain(|override_entry| { + let mut overrides = conn + .browser_host_policy_snapshot() + .permission_overrides() + .to_vec(); + overrides.retain(|override_entry| { override_entry.permission != params.permission || override_entry.origin != params.origin || override_entry.embedded_origin != params.embedded_origin || override_entry.browser_context_id != params.browser_context_id }); - conn.permission_overrides - .push(crate::conn::PermissionOverride { - permission: params.permission, - setting: normalize_permission_setting(params.setting), - origin: params.origin, - embedded_origin: params.embedded_origin, - browser_context_id: params.browser_context_id, - }); + overrides.push(crate::conn::PermissionOverride { + permission: params.permission, + setting: normalize_permission_setting(params.setting), + origin: params.origin, + embedded_origin: params.embedded_origin, + browser_context_id: params.browser_context_id, + }); + conn.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplacePermissionOverrides(overrides), + ); start_apply_permission_overrides_command(conn, cmd) } @@ -592,22 +634,28 @@ fn start_grant_permissions_command( return browser_error_step(-31998, "UnknownBrowserContextId"); } + let mut overrides = conn + .browser_host_policy_snapshot() + .permission_overrides() + .to_vec(); for permission in params.permissions { - conn.permission_overrides.retain(|override_entry| { + overrides.retain(|override_entry| { override_entry.permission != permission || override_entry.origin != params.origin || override_entry.embedded_origin.is_some() || override_entry.browser_context_id != params.browser_context_id }); - conn.permission_overrides - .push(crate::conn::PermissionOverride { - permission, - setting: PermissionSetting::Granted.label().to_owned(), - origin: params.origin.clone(), - embedded_origin: None, - browser_context_id: params.browser_context_id.clone(), - }); + overrides.push(crate::conn::PermissionOverride { + permission, + setting: PermissionSetting::Granted.label().to_owned(), + origin: params.origin.clone(), + embedded_origin: None, + browser_context_id: params.browser_context_id.clone(), + }); } + conn.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplacePermissionOverrides(overrides), + ); start_apply_permission_overrides_command(conn, cmd) } @@ -630,11 +678,22 @@ fn start_reset_permissions_command( } if let Some(browser_context_id) = params.browser_context_id { - conn.permission_overrides.retain(|entry| { + let mut overrides = conn + .browser_host_policy_snapshot() + .permission_overrides() + .to_vec(); + overrides.retain(|entry| { entry.browser_context_id.as_deref() != Some(browser_context_id.as_str()) }); + conn.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplacePermissionOverrides(overrides), + ); } else { - conn.permission_overrides.clear(); + conn.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplacePermissionOverrides( + Vec::new(), + ), + ); } start_apply_permission_overrides_command(conn, cmd) @@ -669,7 +728,10 @@ fn start_apply_permission_overrides_command( fn start_loaded_page_permission_override_commands( conn: &mut CdpConnection, ) -> Result, String> { - let all_overrides = conn.permission_overrides.clone(); + let all_overrides = conn + .browser_host_policy_snapshot() + .permission_overrides() + .to_vec(); let mut pending = Vec::new(); for browser_context in conn .browser_context @@ -761,7 +823,7 @@ fn finish_pending_permission_override_command( target: PendingBrowserPageTarget, completion: CompletedPageCommand, ) -> Result<(), String> { - let page = match target { + let mut page = match target { PendingBrowserPageTarget::BrowserContextActive { browser_context_id } => conn .browser_context_by_id_mut(&browser_context_id) .and_then(|browser_context| { @@ -781,3 +843,824 @@ fn finish_pending_permission_override_command( } // ──────────────────────────────────────────────────────────────────────────── +// Tests – ported from lightpanda/src/cdp/domains/browser.zig +// ──────────────────────────────────────────────────────────────────────────── +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::*; + use crate::conn::CdpCommandTaskStep; + use crate::testing::TestContext; + use axum::{Router, response::IntoResponse, routing::get}; + use tokio::net::TcpListener; + + #[test] + fn permission_setting_parses_standard_cdp_tokens() { + assert_eq!( + PermissionSetting::parse("granted"), + Some(PermissionSetting::Granted) + ); + assert_eq!( + PermissionSetting::parse("denied"), + Some(PermissionSetting::Denied) + ); + assert_eq!( + PermissionSetting::parse("prompt"), + Some(PermissionSetting::Prompt) + ); + assert_eq!(PermissionSetting::parse("Granted"), None); + assert_eq!(PermissionSetting::parse("unknown"), None); + } + + #[test] + fn normalize_permission_setting_preserves_unknown_tokens() { + assert_eq!( + normalize_permission_setting("granted".to_owned()), + "granted" + ); + assert_eq!(normalize_permission_setting("prompt".to_owned()), "prompt"); + assert_eq!( + normalize_permission_setting("experimental".to_owned()), + "experimental" + ); + } + + fn take_response_by_id(ctx: &mut TestContext, id: u64) -> Value { + let pos = ctx + .sent + .iter() + .position(|message| message["id"] == json!(id)) + .expect("expected response with matching id"); + ctx.sent.remove(pos) + } + + async fn with_loaded_document_async(ctx: &mut TestContext, url: &str) { + let mut browser_context = crate::conn::BrowserContext::new("BID-1".into()); + browser_context.set_active_target_id("TID-1"); + browser_context.attach_active_session("SID-1"); + ctx.conn.insert_browser_context(browser_context); + let page = ctx + .conn + .load_page_via_runtime_async(url) + .await + .expect("must load document"); + let _ = ctx + .conn + .browser_context + .as_mut() + .expect("inserted browser context must be selected") + .active_target + .runtime_slot + .replace_loaded_page(Some(page)); + } + + async fn current_permission_state_async( + ctx: &mut TestContext, + permission_name: &str, + ) -> String { + current_permission_state_for_session_async(ctx, "SID-1", permission_name).await + } + + async fn current_permission_state_for_session_async( + ctx: &mut TestContext, + session_id: &str, + permission_name: &str, + ) -> String { + ctx.process_async(json!({ + "id": 9_000, + "method": "Runtime.evaluate", + "sessionId": session_id, + "params": { + "expression": format!( + "(() => {{ globalThis.__permissionState = 'pending'; navigator.permissions.query({{ name: '{}' }}).then(status => {{ globalThis.__permissionState = status.state; }}); return 'scheduled'; }})()", + permission_name + ) + } + })) + .await; + let response = take_response_by_id(ctx, 9_000); + assert_eq!(response["result"]["result"]["value"], json!("scheduled")); + + ctx.process_async(json!({ + "id": 9_001, + "method": "Runtime.evaluate", + "sessionId": session_id, + "params": { "expression": "globalThis.__permissionState" } + })) + .await; + take_response_by_id(ctx, 9_001)["result"]["result"]["value"] + .as_str() + .expect("permission state should be a string") + .to_owned() + } + + /// cdp.browser: getVersion + #[tokio::test(flavor = "multi_thread")] + async fn get_version_returns_chromium_compatible_metadata() { + let mut ctx = TestContext::new(); + ctx.process_async(json!({"id": 32, "method": "Browser.getVersion"})) + .await; + ctx.expect_result( + 32, + json!({ + "protocolVersion": version::PROTOCOL_VERSION, + "product": version::PRODUCT, + "revision": version::REVISION, + "userAgent": moli_fetch::FetchConfig::DEFAULT_USER_AGENT, + "jsVersion": version::js_version(), + }), + None, + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_version_preserves_target_session_route() { + let mut ctx = TestContext::new(); + let mut browser_context = crate::conn::BrowserContext::new("BID-1".into()); + browser_context.set_active_target_id("TID-1"); + browser_context.attach_active_session("SID-1"); + ctx.conn.browser_context = Some(browser_context); + ctx.process_async(json!({ + "id": 32_001, + "method": "Browser.getVersion", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result( + 32_001, + json!({ + "protocolVersion": version::PROTOCOL_VERSION, + "product": version::PRODUCT, + "revision": version::REVISION, + "userAgent": moli_fetch::FetchConfig::DEFAULT_USER_AGENT, + "jsVersion": version::js_version(), + }), + Some("SID-1"), + ); + } + + /// cdp.browser: getWindowForTarget + #[tokio::test(flavor = "multi_thread")] + async fn get_window_for_target_returns_window_info() { + let mut ctx = TestContext::new(); + ctx.process_async(json!({"id": 33, "method": "Browser.getWindowForTarget"})) + .await; + ctx.expect_result( + 33, + json!({ + "windowId": DEV_TOOLS_WINDOW_ID, + "bounds": { "windowState": "normal" } + }), + None, + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_window_bounds_updates_get_window_for_target() { + let mut ctx = TestContext::new(); + ctx.process_async(json!({ + "id": 34, + "method": "Browser.setWindowBounds", + "params": { + "windowId": DEV_TOOLS_WINDOW_ID, + "bounds": { + "left": 10, + "top": 20, + "width": 1280, + "height": 720, + "windowState": "minimized" + } + } + })) + .await; + ctx.expect_result(34, json!({}), None); + + ctx.process_async(json!({"id": 35, "method": "Browser.getWindowForTarget"})) + .await; + ctx.expect_result( + 35, + json!({ + "windowId": DEV_TOOLS_WINDOW_ID, + "bounds": { + "left": 10, + "top": 20, + "width": 1280, + "height": 720, + "windowState": "minimized" + } + }), + None, + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_window_bounds_rejects_unknown_window_id() { + let mut ctx = TestContext::new(); + ctx.process_async(json!({ + "id": 36, + "method": "Browser.setWindowBounds", + "params": { + "windowId": 999, + "bounds": { "windowState": "normal" } + } + })) + .await; + ctx.expect_error(36, -32602, "InvalidParams"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_download_behavior_updates_connection_state_and_context_override() { + let mut ctx = TestContext::new(); + ctx.conn + .insert_browser_context(crate::conn::BrowserContext::new("BID-1".into())); + + ctx.process_async(json!({ + "id": 36_901, + "method": "Browser.setDownloadBehavior", + "params": { + "behavior": "allowAndName", + "downloadPath": "/tmp/global-downloads", + "eventsEnabled": true + } + })) + .await; + ctx.expect_result(36_901, json!({}), None); + + ctx.process_async(json!({ + "id": 37, + "method": "Browser.setDownloadBehavior", + "params": { + "behavior": "allow", + "downloadPath": "/tmp/downloads", + "eventsEnabled": true, + "browserContextId": "BID-1" + } + })) + .await; + ctx.expect_result(37, json!({}), None); + + let policy = ctx.conn.browser_download_policy_snapshot(); + assert_eq!( + policy.global().behavior(), + BrowserDownloadBehavior::AllowAndName + ); + assert_eq!( + policy.global().download_path(), + Some("/tmp/global-downloads") + ); + assert!( + !ctx.conn + .automation_download_events_enabled_for_browser_context(None) + ); + assert_eq!( + ctx.conn.browser_download_event_session_ids_for_test(), + vec![None] + ); + assert!(policy.browser_context_override("BID-1").is_some()); + + let context_settings = policy.effective_for_browser_context(Some("BID-1")); + assert_eq!(context_settings.behavior(), BrowserDownloadBehavior::Allow); + assert_eq!(context_settings.download_path(), Some("/tmp/downloads")); + assert!( + !ctx.conn + .automation_download_events_enabled_for_browser_context(Some("BID-1")) + ); + + let global_settings = policy.effective_for_browser_context(None); + assert_eq!( + global_settings.behavior(), + BrowserDownloadBehavior::AllowAndName + ); + assert_eq!( + global_settings.download_path(), + Some("/tmp/global-downloads") + ); + assert!( + !ctx.conn + .automation_download_events_enabled_for_browser_context(None) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_download_behavior_tracks_event_observers_per_session_and_detach() { + let mut ctx = TestContext::new(); + ctx.conn + .register_browser_session("SID-browser-a".to_owned()); + ctx.conn + .register_browser_session("SID-browser-b".to_owned()); + + ctx.process_async(json!({ + "id": 37_001, + "method": "Browser.setDownloadBehavior", + "sessionId": "SID-browser-a", + "params": { + "behavior": "allowAndName", + "downloadPath": "/tmp/downloads-a", + "eventsEnabled": true + } + })) + .await; + ctx.expect_result(37_001, json!({}), Some("SID-browser-a")); + + ctx.process_async(json!({ + "id": 37_002, + "method": "Browser.setDownloadBehavior", + "sessionId": "SID-browser-b", + "params": { + "behavior": "allowAndName", + "downloadPath": "/tmp/downloads-b", + "eventsEnabled": false + } + })) + .await; + ctx.expect_result(37_002, json!({}), Some("SID-browser-b")); + assert_eq!( + ctx.conn.browser_download_event_session_ids_for_test(), + vec![Some("SID-browser-a".to_owned())] + ); + + assert!( + ctx.conn + .detach_browser_session_owner_without_event("SID-browser-a") + .is_some() + ); + assert!( + ctx.conn + .browser_download_event_session_ids_for_test() + .is_empty() + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_download_behavior_rejects_unknown_browser_context_id() { + let mut ctx = TestContext::new(); + ctx.process_async(json!({ + "id": 38, + "method": "Browser.setDownloadBehavior", + "params": { + "behavior": "allow", + "browserContextId": "BID-missing" + } + })) + .await; + ctx.expect_error(38, -31998, "UnknownBrowserContextId"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_download_behavior_rejects_invalid_params() { + let mut ctx = TestContext::new(); + ctx.process_async(json!({ + "id": 39, + "method": "Browser.setDownloadBehavior", + "params": {} + })) + .await; + ctx.expect_error(39, -32602, "InvalidParams"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_download_behavior_rejects_unknown_behavior() { + let mut ctx = TestContext::new(); + ctx.process_async(json!({ + "id": 390, + "method": "Browser.setDownloadBehavior", + "params": { + "behavior": "allowandname", + "downloadPath": "/tmp/downloads" + } + })) + .await; + ctx.expect_error(390, -32602, "InvalidParams"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn cancel_download_rejects_unknown_guid() { + let mut ctx = TestContext::new(); + ctx.process_async(json!({ + "id": 391, + "method": "Browser.cancelDownload", + "params": { "guid": "missing-guid" } + })) + .await; + ctx.expect_error(391, -32602, "No download item found for the given GUID"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn open_download_as_stream_reads_completed_artifact_via_io_domain() { + let mut ctx = TestContext::new(); + let artifact_path = std::env::temp_dir().join(format!( + "moli-cdp-download-stream-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::write(&artifact_path, [0_u8, 255_u8, b'a']) + .expect("test download artifact should be written"); + ctx.conn + .test_insert_completed_download("DOWNLOAD-STREAM-1", artifact_path.clone()); + + ctx.process_async(json!({ + "id": 392, + "method": "Browser.openDownloadAsStream", + "params": { "guid": "DOWNLOAD-STREAM-1" } + })) + .await; + let stream = take_response_by_id(&mut ctx, 392)["result"]["stream"] + .as_str() + .expect("stream handle") + .to_owned(); + assert!(stream.starts_with("BROWSER-STREAM-")); + + ctx.process_async(json!({ + "id": 393, + "method": "IO.read", + "params": { "handle": stream } + })) + .await; + ctx.expect_result( + 393, + json!({ "base64Encoded": true, "data": "AP9h", "eof": true }), + None, + ); + + let _ = std::fs::remove_file(artifact_path); + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_permission_updates_connection_state() { + let mut ctx = TestContext::new(); + ctx.conn + .insert_browser_context(crate::conn::BrowserContext::new("BID-1".into())); + + ctx.process_async(json!({ + "id": 40, + "method": "Browser.setPermission", + "params": { + "permission": { "name": "clipboard-read" }, + "setting": "denied", + "origin": "https://example.com", + "browserContextId": "BID-1" + } + })) + .await; + ctx.expect_result(40, json!({}), None); + let policy = ctx.conn.browser_host_policy_snapshot(); + let permission_overrides = policy.permission_overrides(); + assert_eq!(permission_overrides.len(), 1); + assert_eq!( + permission_overrides[0].permission, + json!({ "name": "clipboard-read" }) + ); + assert_eq!(permission_overrides[0].setting, "denied"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn grant_permissions_records_granted_entries() { + let mut ctx = TestContext::new(); + ctx.conn + .insert_browser_context(crate::conn::BrowserContext::new("BID-1".into())); + + ctx.process_async(json!({ + "id": 41, + "method": "Browser.grantPermissions", + "params": { + "permissions": [ + { "name": "geolocation" }, + { "name": "clipboard-read" } + ], + "origin": "https://example.com", + "browserContextId": "BID-1" + } + })) + .await; + ctx.expect_result(41, json!({}), None); + let policy = ctx.conn.browser_host_policy_snapshot(); + assert_eq!(policy.permission_overrides().len(), 2); + assert!( + policy + .permission_overrides() + .iter() + .all(|entry| entry.setting == "granted") + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn reset_permissions_clears_matching_browser_context_scope() { + let mut ctx = TestContext::new(); + ctx.conn + .insert_browser_context(crate::conn::BrowserContext::new("BID-1".into())); + ctx.conn.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplacePermissionOverrides(vec![ + crate::conn::PermissionOverride { + permission: json!({ "name": "clipboard-read" }), + setting: "granted".into(), + origin: Some("https://example.com".into()), + embedded_origin: None, + browser_context_id: Some("BID-1".into()), + }, + crate::conn::PermissionOverride { + permission: json!({ "name": "clipboard-write" }), + setting: "granted".into(), + origin: Some("https://example.com".into()), + embedded_origin: None, + browser_context_id: None, + }, + ]), + ); + + ctx.process_async(json!({ + "id": 42, + "method": "Browser.resetPermissions", + "params": { "browserContextId": "BID-1" } + })) + .await; + ctx.expect_result(42, json!({}), None); + let policy = ctx.conn.browser_host_policy_snapshot(); + assert_eq!(policy.permission_overrides().len(), 1); + assert_eq!(policy.permission_overrides()[0].browser_context_id, None); + } + + #[tokio::test(flavor = "multi_thread")] + async fn permission_methods_reject_unknown_browser_context_id() { + let mut ctx = TestContext::new(); + ctx.process_async(json!({ + "id": 43, + "method": "Browser.setPermission", + "params": { + "permission": { "name": "clipboard-read" }, + "setting": "granted", + "browserContextId": "BID-missing" + } + })) + .await; + ctx.expect_error(43, -31998, "UnknownBrowserContextId"); + + ctx.process_async(json!({ + "id": 44, + "method": "Browser.grantPermissions", + "params": { + "permissions": [{ "name": "geolocation" }], + "browserContextId": "BID-missing" + } + })) + .await; + ctx.expect_error(44, -31998, "UnknownBrowserContextId"); + + ctx.process_async(json!({ + "id": 45, + "method": "Browser.resetPermissions", + "params": { "browserContextId": "BID-missing" } + })) + .await; + ctx.expect_error(45, -31998, "UnknownBrowserContextId"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn grant_and_reset_permissions_update_loaded_page_query_state() { + let mut ctx = TestContext::new(); + with_loaded_document_async(&mut ctx, "data:text/html,").await; + + ctx.process_async(json!({ "id": 46, "method": "Runtime.enable", "sessionId": "SID-1" })) + .await; + let response = take_response_by_id(&mut ctx, 46); + assert_eq!(response["result"], json!({})); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 47, + "method": "Browser.grantPermissions", + "params": { + "permissions": [{ "name": "geolocation" }], + "browserContextId": "BID-1" + } + })) + .await; + ctx.expect_result(47, json!({}), None); + assert_eq!( + current_permission_state_async(&mut ctx, "geolocation").await, + "granted" + ); + + ctx.process_async(json!({ + "id": 48, + "method": "Browser.resetPermissions", + "params": { "browserContextId": "BID-1" } + })) + .await; + ctx.expect_result(48, json!({}), None); + assert_eq!( + current_permission_state_async(&mut ctx, "geolocation").await, + "prompt" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_permission_origin_updates_matching_loaded_page_query_state() { + async fn handler() -> impl IntoResponse { + "ok" + } + + 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("/page", get(handler))) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + let url = format!("http://{addr}/page"); + with_loaded_document_async(&mut ctx, &url).await; + + ctx.process_async(json!({ "id": 49, "method": "Runtime.enable", "sessionId": "SID-1" })) + .await; + let response = take_response_by_id(&mut ctx, 49); + assert_eq!(response["result"], json!({})); + ctx.sent.clear(); + + ctx.process_async(json!({ + "id": 50, + "method": "Browser.setPermission", + "params": { + "permission": { "name": "geolocation" }, + "setting": "denied", + "origin": format!("http://{addr}"), + "browserContextId": "BID-1" + } + })) + .await; + ctx.expect_result(50, json!({}), None); + assert_eq!( + current_permission_state_async(&mut ctx, "geolocation").await, + "denied" + ); + + server.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn async_dispatch_set_permission_updates_matching_loaded_page_query_state() { + async fn handler() -> impl IntoResponse { + "ok" + } + + 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("/page", get(handler))) + .await + .unwrap(); + }); + + let mut ctx = TestContext::new(); + let url = format!("http://{addr}/page"); + with_loaded_document_async(&mut ctx, &url).await; + + ctx.process_async(json!({ + "id": 51, + "method": "Browser.setPermission", + "params": { + "permission": { "name": "geolocation" }, + "setting": "denied", + "origin": format!("http://{addr}"), + "browserContextId": "BID-1" + } + })) + .await; + ctx.expect_result(51, json!({}), None); + assert_eq!( + current_permission_state_async(&mut ctx, "geolocation").await, + "denied" + ); + + server.abort(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn pending_permission_overrides_use_concrete_page_targets_across_ambient_owner_change() { + let mut ctx = TestContext::new(); + let mut browser_context = + crate::conn::BrowserContext::new("BID-browser-permission".to_owned()); + browser_context.set_active_target_id("TID-browser-permission-active".to_owned()); + browser_context.attach_active_session("SID-browser-permission-active".to_owned()); + let background = crate::conn::BackgroundTarget::with_url( + "TID-browser-permission-background".to_owned(), + Some("SID-browser-permission-background".to_owned()), + "data:text/html,background permissions".to_owned(), + ); + browser_context.background_targets.push(background); + browser_context.adopt_background_target_fixture_attachments(); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,active permissions", + Some("SID-browser-permission-active"), + ) + .await; + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,background permissions", + Some("SID-browser-permission-background"), + ) + .await; + + for (id, session_id) in [ + (52, "SID-browser-permission-active"), + (53, "SID-browser-permission-background"), + ] { + ctx.process_async(json!({ + "id": id, + "method": "Runtime.enable", + "sessionId": session_id + })) + .await; + ctx.expect_result(id, json!({}), Some(session_id)); + } + ctx.sent.clear(); + + let background_route = ctx + .conn + .target_session_route_for_target_id("TID-browser-permission-background") + .expect("background target route"); + let raw = serde_json::to_string(&json!({ + "id": 54, + "method": "Browser.grantPermissions", + "params": { + "permissions": [{ "name": "geolocation" }], + "browserContextId": "BID-browser-permission" + } + })) + .expect("Browser.grantPermissions command should serialize"); + let pending = { + let previous_route = ctx + .conn + .replace_none_session_owner_route_override(Some(background_route)); + let step = ctx.conn.start_command_dispatch(&raw); + ctx.conn + .replace_none_session_owner_route_override(previous_route); + match step { + CdpCommandTaskStep::Pending(pending) => pending, + CdpCommandTaskStep::Complete(outcome) => { + panic!( + "Browser.grantPermissions should update loaded page permission overrides: {:?}", + outcome.into_parts().0 + ) + } + } + }; + + let active_route = ctx + .conn + .target_session_route_for_target_id("TID-browser-permission-active") + .expect("active target route"); + let previous_route = ctx + .conn + .replace_none_session_owner_route_override(Some(active_route)); + let (messages, scheduler_events) = ctx + .complete_command_task_step_for_test(CdpCommandTaskStep::Pending(pending)) + .await; + ctx.conn + .replace_none_session_owner_route_override(previous_route); + + assert!(scheduler_events.is_empty()); + assert_eq!(messages, vec![json!({ "id": 54, "result": {} })]); + assert_eq!( + ctx.conn + .browser_context + .as_ref() + .and_then(crate::conn::BrowserContext::active_target_id), + Some("TID-browser-permission-active") + ); + + assert_eq!( + current_permission_state_for_session_async( + &mut ctx, + "SID-browser-permission-active", + "geolocation" + ) + .await, + "granted" + ); + assert_eq!( + current_permission_state_for_session_async( + &mut ctx, + "SID-browser-permission-background", + "geolocation" + ) + .await, + "granted" + ); + } + + /// Noop methods should return an empty result without error. + #[tokio::test(flavor = "multi_thread")] + async fn noop_methods_return_empty_result() { + for method in &["Browser.setPermission", "Browser.grantPermissions"] { + let mut ctx = TestContext::new(); + ctx.process_async(json!({"id": 1, "method": method, "params": {}})) + .await; + ctx.expect_error(1, -32602, "InvalidParams"); + } + } +} diff --git a/moli-protocol/src/domains/command_output.rs b/moli-protocol/src/domains/command_output.rs index 7289257190..750cbb0983 100644 --- a/moli-protocol/src/domains/command_output.rs +++ b/moli-protocol/src/domains/command_output.rs @@ -1,6 +1,12 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use serde_json::{Value, json}; +mod browser_navigate; + +pub(crate) use browser_navigate::{ + BrowserNavigateCommandOutcomeDelivery, BrowserNavigateCommandProjection, +}; + use crate::devtools_runtime::{ AutomationEvent, BrowserDownloadProgressEvent, BrowserDownloadWillBeginEvent, DevToolsCommandResult, DevToolsError, DevToolsErrorKind, DevToolsFetchRequestId, @@ -1610,12 +1616,6 @@ fn cdp_result_payload_from_devtools_result(result: DevToolsCommandResult) -> Val if let Some(loader_id) = result.loader_id { payload["loaderId"] = json!(loader_id.into_string()); } - if let Some(error_text) = result.error_text { - payload["errorText"] = json!(error_text); - } - if let Some(is_download) = result.is_download { - payload["isDownload"] = json!(is_download); - } payload } DevToolsCommandResult::GetNavigationHistory(result) => json!({ @@ -2275,8 +2275,6 @@ mod tests { frame_id: Some(crate::devtools_runtime::DevToolsFrameId::from("FRAME-1")), loader_id: Some(crate::devtools_runtime::DevToolsLoaderId::from("LOADER-1")), url: "https://example.test/".to_owned(), - error_text: None, - is_download: None, }, ), ) @@ -2295,37 +2293,6 @@ mod tests { ); } - #[test] - fn command_output_plan_serializes_aborted_cdp_navigate_as_result() { - let mut out = Vec::new(); - CommandOutputPlan::from_devtools_result( - crate::devtools_runtime::DevToolsCommandResult::Navigate( - crate::devtools_runtime::DevToolsNavigateResult { - navigation_id: None, - frame_id: Some(crate::devtools_runtime::DevToolsFrameId::from("FRAME-1")), - loader_id: None, - url: "https://superseded.example.test/".to_owned(), - error_text: Some("net::ERR_ABORTED".to_owned()), - is_download: Some(false), - }, - ), - ) - .emit_into(&mut out, Some(13), Some("SID-1")); - - assert_eq!( - out, - vec![json!({ - "id": 13, - "result": { - "frameId": "FRAME-1", - "errorText": "net::ERR_ABORTED", - "isDownload": false - }, - "sessionId": "SID-1" - })] - ); - } - #[test] fn command_output_plan_serializes_devtools_script_value_result() { let mut out = Vec::new(); diff --git a/moli-protocol/src/domains/command_output/browser_navigate.rs b/moli-protocol/src/domains/command_output/browser_navigate.rs new file mode 100644 index 0000000000..989bf1e3ce --- /dev/null +++ b/moli-protocol/src/domains/command_output/browser_navigate.rs @@ -0,0 +1,841 @@ +use moli_core::browser_host::{ + BrowserNavigateCommandError, BrowserNavigateCommandErrorKind, BrowserNavigateCommandOutcome, + BrowserNavigateCommandResult, BrowserTargetId, +}; +use moli_page_types::{DevToolsSessionKey, FrontendCommandId}; +use serde_json::{Value, json}; + +use super::{ + CommandOutput, CommandOutputPlan, CommandResponseOutput, CommandResponseResult, + RendererOutputBoundary, +}; + +/// Protocol-only information needed to put one detached Browser navigation +/// outcome back at its exact command-output position. +/// +/// The sidecar contains no Browser execution authority. It retains only wire +/// routing, extension fields, error decoration and renderer ordering state. +#[derive(Debug)] +pub(crate) struct BrowserNavigateCommandProjection { + plan: CommandOutputPlan, + response: Option, +} + +#[derive(Debug)] +struct BrowserNavigateResponseProjection { + output_index: usize, + before_renderer_boundary: bool, + route: CommandResponseRoute, + shape: BrowserNavigateResponseProjectionShape, +} + +/// One protocol-neutral Browser navigation outcome plus the frontend-only +/// correlation needed to project it when its shared output FIFO is consumed. +/// +/// The producer reaches the navigation response boundary independently of a +/// frontend poll. Command id, session routing and CDP result decoration stay +/// in this Protocol sidecar rather than entering the Core outcome. +#[derive(Debug)] +pub(crate) struct BrowserNavigateCommandOutcomeDelivery { + frontend_command_id: FrontendCommandId, + session_key: DevToolsSessionKey, + outcome: BrowserNavigateCommandOutcome, + projection: BrowserNavigateCommandProjection, +} + +#[derive(Clone, Copy, Debug)] +enum CommandResponseRoute { + InheritSession, + WithoutSession, +} + +#[derive(Debug)] +enum BrowserNavigateResponseProjectionShape { + Success { + extension_fields: serde_json::Map, + target_id_field: bool, + loader_id_field: bool, + error_text_field: bool, + is_download_field: bool, + }, + Error { + code: i32, + data: Option, + }, +} + +impl CommandOutputPlan { + /// Separates a Browser navigation command outcome from its CDP projection. + /// + /// A background navigation may have no response in this terminal plan: its + /// response is still produced by the migration-period early-result path. + /// That state is represented by `None` instead of manufacturing a Browser + /// completion before the response boundary has actually been reached. + pub(crate) fn into_browser_navigate_outcome_and_projection( + self, + requested_url: &str, + ) -> ( + Option, + BrowserNavigateCommandProjection, + ) { + let CommandOutputPlan { + outputs, + post_response_events, + renderer_output_predecessor, + renderer_output_boundary, + } = self; + let boundary_index = renderer_output_boundary + .as_ref() + .map(|boundary| boundary.output_index); + let mut retained_before_boundary = 0usize; + let mut retained_outputs = Vec::with_capacity(outputs.len()); + let mut outcome = None; + let mut response_projection = None; + let mut produced_multiple_responses = false; + + for (index, output) in outputs.into_iter().enumerate() { + let (route, response) = match output { + CommandOutput::Command(response) => { + (CommandResponseRoute::InheritSession, response) + } + CommandOutput::CommandWithoutSession(response) => { + (CommandResponseRoute::WithoutSession, response) + } + CommandOutput::OwnerEvent(_) | CommandOutput::BackgroundEvent(_) => { + if boundary_index.is_some_and(|boundary| index < boundary) { + retained_before_boundary = retained_before_boundary.saturating_add(1); + } + retained_outputs.push(output); + continue; + } + }; + + if response_projection.is_some() { + produced_multiple_responses = true; + continue; + } + + let output_index = retained_outputs.len(); + let before_renderer_boundary = boundary_index.is_some_and(|boundary| index < boundary); + match detach_browser_navigate_response(response, requested_url) { + Ok((detached_outcome, shape)) => { + outcome = Some(detached_outcome); + response_projection = Some(BrowserNavigateResponseProjection { + output_index, + before_renderer_boundary, + route, + shape, + }); + } + Err(message) => { + outcome = Some(BrowserNavigateCommandOutcome::Rejected( + BrowserNavigateCommandError::new( + BrowserNavigateCommandErrorKind::Failed, + message, + ), + )); + response_projection = Some(BrowserNavigateResponseProjection { + output_index, + before_renderer_boundary, + route, + shape: BrowserNavigateResponseProjectionShape::Error { + code: -32000, + data: None, + }, + }); + } + } + } + + if produced_multiple_responses { + tracing::error!("Browser navigation terminal plan produced multiple command responses"); + outcome = Some(BrowserNavigateCommandOutcome::Rejected( + BrowserNavigateCommandError::new( + BrowserNavigateCommandErrorKind::Failed, + "BrowserNavigateProducedMultipleResponses", + ), + )); + if let Some(response) = response_projection.as_mut() { + response.shape = BrowserNavigateResponseProjectionShape::Error { + code: -32000, + data: None, + }; + } + } + + let plan = Self { + outputs: retained_outputs, + post_response_events, + renderer_output_predecessor, + renderer_output_boundary: renderer_output_boundary.map(|boundary| { + Box::new(RendererOutputBoundary { + output_index: retained_before_boundary, + cursor: boundary.cursor, + }) + }), + }; + ( + outcome, + BrowserNavigateCommandProjection { + plan, + response: response_projection, + }, + ) + } + + /// Converts a deferred navigation response into an ordinary background + /// output plan while retaining its protocol-neutral Browser outcome on the + /// exact command-response envelope. + pub(crate) fn into_browser_navigate_background_event_plan( + self, + requested_url: &str, + command_id: Option, + session_id: Option<&str>, + ) -> Self { + let (outcome, projection) = + self.into_browser_navigate_outcome_and_projection(requested_url); + let outcome_sidecar = outcome.clone(); + let mut plan = projection + .project(outcome) + .into_background_event_plan(command_id, session_id); + let Some(outcome) = outcome_sidecar else { + return plan; + }; + + let mut outcome = Some(outcome); + for output in &mut plan.outputs { + let CommandOutput::BackgroundEvent(event) = output else { + continue; + }; + let Some((event_command_id, _, _)) = event.command_response_payload_ref() else { + continue; + }; + if event_command_id != command_id { + continue; + } + let Some(outcome) = outcome.take() else { + break; + }; + let attached = event.attach_browser_navigate_command_outcome(outcome); + debug_assert!( + attached, + "typed command response must accept navigation outcome" + ); + break; + } + if outcome.is_some() { + tracing::error!( + command_id, + "deferred Browser navigation outcome had no matching response envelope" + ); + } + plan + } +} + +impl BrowserNavigateCommandProjection { + /// Rebuilds the frontend command response without changing the relative + /// position of Browser events or the renderer insertion boundary. + pub(crate) fn project( + self, + outcome: Option, + ) -> CommandOutputPlan { + let (mut plan, projected_response) = self.into_projected_response(outcome); + if let Some((output_index, before_renderer_boundary, route, response)) = projected_response + { + insert_projected_command_response( + &mut plan, + output_index, + before_renderer_boundary, + route, + response, + ); + } + plan + } + + /// Validates the frontend response shape while leaving the Browser effect + /// stream detached from that response. + /// + /// Direct typed frontends return the neutral result through their own + /// adapter. Re-inserting and then flattening the CDP response here would + /// discard the exact renderer insertion boundary carried by `plan`. + pub(crate) fn project_status_and_browser_effects( + self, + outcome: Option, + ) -> ( + Option>, + CommandOutputPlan, + ) { + if self.response.is_none() { + // A typed wait:none frontend consumes the neutral accepted outcome + // directly. Its detached background load has Browser effects but + // deliberately carries no second wire-response projection. + return (None, self.plan); + } + let (plan, projected_response) = self.into_projected_response(outcome); + let status = projected_response + .as_ref() + .map(|(_, _, _, response)| response.status()); + (status, plan) + } + + /// Drops only the abandoned frontend response while retaining all Browser + /// side effects and ordering fences. + pub(crate) fn into_browser_effects_plan(self) -> CommandOutputPlan { + self.plan + } + + fn into_projected_response( + mut self, + outcome: Option, + ) -> ( + CommandOutputPlan, + Option<(usize, bool, CommandResponseRoute, CommandResponseOutput)>, + ) { + let projected_response = match (self.response.take(), outcome) { + (None, None) => None, + (Some(projection), Some(outcome)) => { + let BrowserNavigateResponseProjection { + output_index, + before_renderer_boundary, + route, + shape, + } = projection; + let response = project_browser_navigate_response(outcome, shape); + Some((output_index, before_renderer_boundary, route, response)) + } + (None, Some(outcome)) => { + tracing::error!( + ?outcome, + "Browser navigation outcome has no frontend response projection" + ); + None + } + (Some(projection), None) => { + tracing::error!( + "Browser navigation response projection has no protocol-neutral outcome" + ); + Some(( + projection.output_index, + projection.before_renderer_boundary, + projection.route, + CommandResponseOutput::Error { + code: -32000, + message: "BrowserNavigateOutcomeMissing".to_owned(), + data: None, + }, + )) + } + }; + (self.plan, projected_response) + } +} + +impl BrowserNavigateCommandOutcomeDelivery { + pub(crate) fn completed( + command_id: u64, + session_id: Option<&str>, + requested_url: &str, + result_payload: Value, + ) -> Self { + let (outcome, projection) = CommandOutputPlan::result(result_payload) + .into_browser_navigate_outcome_and_projection(requested_url); + let outcome = outcome.unwrap_or_else(|| { + tracing::error!( + "completed Browser navigation response did not produce a neutral outcome" + ); + BrowserNavigateCommandOutcome::Rejected(BrowserNavigateCommandError::new( + BrowserNavigateCommandErrorKind::Failed, + "BrowserNavigateOutcomeMissing", + )) + }); + Self { + frontend_command_id: FrontendCommandId::new(command_id), + session_key: DevToolsSessionKey::from_wire_session_id(session_id), + outcome, + projection, + } + } + + /// Projects exactly one command response at the frontend-consumption + /// boundary. This adapter never executes or advances Browser work. + pub(crate) fn into_background_protocol_event(self) -> crate::conn::BackgroundProtocolEvent { + let Self { + frontend_command_id, + session_key, + outcome, + projection, + } = self; + let command_id = frontend_command_id.get(); + let session_id = session_key.wire_session_id(); + let outcome_sidecar = outcome.clone(); + let mut events = projection + .project(Some(outcome)) + .into_background_events(Some(command_id), session_id); + if events.len() == 1 { + let event = match events.pop() { + Some(event) => event, + None => browser_navigate_projection_failure_event(command_id, session_id), + }; + return event.bind_browser_navigate_command_outcome(outcome_sidecar); + } + + tracing::error!( + event_count = events.len(), + "Browser navigation outcome projection did not produce exactly one response" + ); + browser_navigate_projection_failure_event(command_id, session_id) + .bind_browser_navigate_command_outcome(outcome_sidecar) + } +} + +fn browser_navigate_projection_failure_event( + command_id: u64, + session_id: Option<&str>, +) -> crate::conn::BackgroundProtocolEvent { + crate::conn::BackgroundProtocolEvent::command_error( + Some(command_id), + session_id, + -32000, + "BrowserNavigateOutcomeProjectionFailed".to_owned(), + None, + ) +} + +fn detach_browser_navigate_response( + response: CommandResponseOutput, + requested_url: &str, +) -> Result< + ( + BrowserNavigateCommandOutcome, + BrowserNavigateResponseProjectionShape, + ), + String, +> { + match response { + CommandResponseOutput::Success(result) => { + let mut extension_fields = match result { + CommandResponseResult::Empty => serde_json::Map::new(), + CommandResponseResult::Json(Value::Object(payload)) => payload, + CommandResponseResult::Json(_) => { + return Err("BrowserNavigateResultMustBeAnObject".to_owned()); + } + }; + let target_id = take_string_projection_field(&mut extension_fields, "frameId") + .map(BrowserTargetId::new); + let target_id_field = target_id.is_some(); + let loader_id = take_string_projection_field(&mut extension_fields, "loaderId"); + let loader_id_field = loader_id.is_some(); + let loader_id = + loader_id.or_else(|| projected_bidi_navigation_loader_id(&extension_fields)); + let error_text = take_string_projection_field(&mut extension_fields, "errorText"); + let error_text_field = error_text.is_some(); + let is_download = take_bool_projection_field(&mut extension_fields, "isDownload"); + let is_download_field = is_download.is_some(); + Ok(( + BrowserNavigateCommandOutcome::Completed(BrowserNavigateCommandResult::new( + requested_url, + target_id, + loader_id, + error_text, + is_download, + )), + BrowserNavigateResponseProjectionShape::Success { + extension_fields, + target_id_field, + loader_id_field, + error_text_field, + is_download_field, + }, + )) + } + CommandResponseOutput::Error { + code, + message, + data, + } => Ok(( + BrowserNavigateCommandOutcome::Rejected(BrowserNavigateCommandError::new( + browser_navigate_error_kind_from_cdp_code(code), + message, + )), + BrowserNavigateResponseProjectionShape::Error { code, data }, + )), + } +} + +fn projected_bidi_navigation_loader_id(fields: &serde_json::Map) -> Option { + fields + .get("navigation") + .and_then(Value::as_str) + .and_then(|navigation_id| navigation_id.strip_prefix("navigation-")) + .filter(|loader_id| !loader_id.is_empty()) + .map(str::to_owned) +} + +fn take_string_projection_field( + fields: &mut serde_json::Map, + name: &str, +) -> Option { + match fields.remove(name) { + Some(Value::String(value)) => Some(value), + Some(value) => { + fields.insert(name.to_owned(), value); + None + } + None => None, + } +} + +fn take_bool_projection_field( + fields: &mut serde_json::Map, + name: &str, +) -> Option { + match fields.remove(name) { + Some(Value::Bool(value)) => Some(value), + Some(value) => { + fields.insert(name.to_owned(), value); + None + } + None => None, + } +} + +fn browser_navigate_error_kind_from_cdp_code(code: i32) -> BrowserNavigateCommandErrorKind { + match code { + -32602 => BrowserNavigateCommandErrorKind::InvalidInput, + -32001 => BrowserNavigateCommandErrorKind::RequesterUnavailable, + -31998 => BrowserNavigateCommandErrorKind::TargetUnavailable, + _ => BrowserNavigateCommandErrorKind::Failed, + } +} + +fn project_browser_navigate_response( + outcome: BrowserNavigateCommandOutcome, + projection: BrowserNavigateResponseProjectionShape, +) -> CommandResponseOutput { + match (outcome, projection) { + ( + BrowserNavigateCommandOutcome::Completed(result), + BrowserNavigateResponseProjectionShape::Success { + mut extension_fields, + target_id_field, + loader_id_field, + error_text_field, + is_download_field, + }, + ) => { + if target_id_field && let Some(target_id) = result.target_id() { + extension_fields.insert("frameId".to_owned(), json!(target_id.as_str())); + } + if loader_id_field && let Some(loader_id) = result.loader_id() { + extension_fields.insert("loaderId".to_owned(), json!(loader_id)); + } + if error_text_field && let Some(error_text) = result.error_text() { + extension_fields.insert("errorText".to_owned(), json!(error_text)); + } + if is_download_field && let Some(is_download) = result.is_download() { + extension_fields.insert("isDownload".to_owned(), json!(is_download)); + } + CommandResponseOutput::Success(CommandResponseResult::Json(Value::Object( + extension_fields, + ))) + } + ( + BrowserNavigateCommandOutcome::Rejected(error), + BrowserNavigateResponseProjectionShape::Error { code, data }, + ) => CommandResponseOutput::Error { + code, + message: error.message().to_owned(), + data, + }, + (outcome, projection) => { + tracing::error!( + ?outcome, + ?projection, + "Browser navigation outcome and frontend projection shape diverged" + ); + CommandResponseOutput::Error { + code: -32000, + message: "BrowserNavigateProjectionMismatch".to_owned(), + data: None, + } + } + } +} + +fn insert_projected_command_response( + plan: &mut CommandOutputPlan, + requested_index: usize, + before_renderer_boundary: bool, + route: CommandResponseRoute, + response: CommandResponseOutput, +) { + let output_index = requested_index.min(plan.outputs.len()); + if output_index != requested_index { + tracing::error!( + requested_index, + output_index, + "Browser navigation response insertion index exceeded its projection sidecar" + ); + } + if before_renderer_boundary && let Some(boundary) = plan.renderer_output_boundary.as_mut() { + boundary.output_index = boundary.output_index.saturating_add(1); + } + let output = match route { + CommandResponseRoute::InheritSession => CommandOutput::Command(response), + CommandResponseRoute::WithoutSession => CommandOutput::CommandWithoutSession(response), + }; + plan.outputs.insert(output_index, output); +} + +#[cfg(test)] +mod tests { + use moli_core::{ + PageId, RendererOutputCursor, RendererOutputFence, RendererOutputStreamIdentity, + browser_host::{BrowserNavigateCommandErrorKind, BrowserNavigateCommandOutcome}, + }; + use serde_json::json; + + use super::*; + use crate::domains::command_output::protocol_message_background_event; + + #[test] + fn browser_navigate_outcome_round_trips_response_at_renderer_boundary() { + let mut plan = CommandOutputPlan::default(); + plan.push_background_event(protocol_message_background_event(json!({ + "method": "Page.beforeNavigateResponse", + "params": {"sequence": 1} + }))); + plan.push_result(json!({ + "frameId": "FRAME-1", + "loaderId": "LOADER-1", + "futureField": {"preserved": true} + })); + let stream = + RendererOutputStreamIdentity::new_page_for_protocol_test(PageId::new_for_testing(91)); + plan.insert_renderer_output_boundary(RendererOutputFence::new_for_test( + RendererOutputCursor::new_for_test(stream, 7), + )); + plan.push_background_event(protocol_message_background_event(json!({ + "method": "Page.afterRendererCommit", + "params": {"sequence": 2} + }))); + + let (outcome, projection) = + plan.into_browser_navigate_outcome_and_projection("https://requested.example/"); + let Some(BrowserNavigateCommandOutcome::Completed(result)) = outcome.as_ref() else { + panic!("expected a completed Browser navigation outcome"); + }; + assert_eq!(result.requested_url(), "https://requested.example/"); + assert_eq!(result.target_id().map(|id| id.as_str()), Some("FRAME-1")); + assert_eq!(result.loader_id(), Some("LOADER-1")); + assert_eq!(result.error_text(), None); + assert_eq!(result.is_download(), None); + + let (before, boundary, after, post_response) = projection + .project(outcome) + .into_renderer_fenced_background_and_post_response_events(Some(42), Some("SID-1")); + let before = before + .into_iter() + .map(|event| event.into_protocol_message()) + .collect::>(); + let after = after + .into_iter() + .map(|event| event.into_protocol_message()) + .collect::>(); + + assert_eq!( + before, + vec![ + json!({ + "method": "Page.beforeNavigateResponse", + "params": {"sequence": 1} + }), + json!({ + "id": 42, + "result": { + "frameId": "FRAME-1", + "loaderId": "LOADER-1", + "futureField": {"preserved": true} + }, + "sessionId": "SID-1" + }), + ] + ); + assert_eq!(boundary.map(|fence| fence.cursor().sequence()), Some(7)); + assert_eq!( + after, + vec![json!({ + "method": "Page.afterRendererCommit", + "params": {"sequence": 2} + })] + ); + assert!(post_response.is_empty()); + } + + #[test] + fn browser_navigate_rejection_round_trips_wire_error_decoration() { + let mut plan = CommandOutputPlan::default(); + plan.push_error_with_data( + -31998, + "NoSuchTarget", + Some(json!({"targetId": "FRAME-stale"})), + ); + + let (outcome, projection) = + plan.into_browser_navigate_outcome_and_projection("https://stale.example/"); + let Some(BrowserNavigateCommandOutcome::Rejected(error)) = outcome.as_ref() else { + panic!("expected a rejected Browser navigation outcome"); + }; + assert_eq!( + error.kind(), + BrowserNavigateCommandErrorKind::TargetUnavailable + ); + assert_eq!(error.message(), "NoSuchTarget"); + + let mut messages = Vec::new(); + projection + .project(outcome) + .emit_into(&mut messages, Some(43), Some("SID-stale")); + assert_eq!( + messages, + vec![json!({ + "id": 43, + "error": { + "code": -31998, + "message": "NoSuchTarget", + "data": {"targetId": "FRAME-stale"} + }, + "sessionId": "SID-stale" + })] + ); + } + + #[test] + fn browser_navigate_bidi_projection_detaches_loader_without_changing_wire_shape() { + let plan = CommandOutputPlan::result(json!({ + "navigation": "navigation-LOADER-BIDI", + "url": "https://bidi.example/final", + })); + + let (outcome, projection) = + plan.into_browser_navigate_outcome_and_projection("https://bidi.example/requested"); + let Some(BrowserNavigateCommandOutcome::Completed(result)) = outcome.as_ref() else { + panic!("expected a completed Browser navigation outcome"); + }; + assert_eq!(result.requested_url(), "https://bidi.example/requested"); + assert_eq!(result.loader_id(), Some("LOADER-BIDI")); + + let messages = projection + .project(outcome) + .into_background_events(Some(45), None) + .into_iter() + .map(|event| event.into_protocol_message()) + .collect::>(); + assert_eq!( + messages, + vec![json!({ + "id": 45, + "result": { + "navigation": "navigation-LOADER-BIDI", + "url": "https://bidi.example/final", + }, + })] + ); + } + + #[test] + fn deferred_browser_navigate_response_retains_exact_rejection_sidecar() { + let plan = CommandOutputPlan::error(-32000, "Navigation aborted by Fetch"); + + let events = plan + .into_browser_navigate_background_event_plan( + "https://blocked.example/", + Some(46), + Some("SID-bidi"), + ) + .into_background_events(Some(99), None); + + assert_eq!(events.len(), 1); + let (command_id, outcome) = events[0] + .browser_navigate_command_outcome() + .expect("deferred response must carry the neutral navigation outcome"); + assert_eq!(command_id, Some(46)); + let BrowserNavigateCommandOutcome::Rejected(error) = outcome else { + panic!("expected a rejected Browser navigation outcome"); + }; + assert_eq!(error.message(), "Navigation aborted by Fetch"); + assert_eq!( + events[0].clone().into_protocol_message(), + json!({ + "id": 46, + "error": { + "code": -32000, + "message": "Navigation aborted by Fetch", + }, + "sessionId": "SID-bidi", + }) + ); + } + + #[test] + fn browser_navigate_deferred_response_does_not_invent_an_outcome() { + let mut plan = CommandOutputPlan::default(); + plan.push_background_event(protocol_message_background_event(json!({ + "method": "Network.requestWillBeSent", + "params": {"requestId": "REQ-1"} + }))); + + let (outcome, projection) = + plan.into_browser_navigate_outcome_and_projection("https://background.example/"); + assert!(outcome.is_none()); + + let mut messages = Vec::new(); + projection + .project(outcome) + .emit_into(&mut messages, Some(44), Some("SID-background")); + assert_eq!( + messages, + vec![json!({ + "method": "Network.requestWillBeSent", + "params": {"requestId": "REQ-1"} + })] + ); + } + + #[test] + fn typed_wait_none_outcome_keeps_response_free_background_effects() { + let mut plan = CommandOutputPlan::default(); + plan.push_background_event(protocol_message_background_event(json!({ + "method": "Page.frameStartedNavigating", + "params": {"frameId": "FRAME-WAIT-NONE"} + }))); + let (detached_outcome, projection) = + plan.into_browser_navigate_outcome_and_projection("https://wait-none.example/"); + assert!(detached_outcome.is_none()); + let accepted = Some(BrowserNavigateCommandOutcome::Completed( + BrowserNavigateCommandResult::new( + "https://wait-none.example/", + Some(BrowserTargetId::new("FRAME-WAIT-NONE")), + Some("LOADER-WAIT-NONE".to_owned()), + None, + None, + ), + )); + + let (status, effects) = + projection.project_status_and_browser_effects(accepted.as_ref().cloned()); + assert!(status.is_none()); + let (effect_status, events) = effects.into_command_status_and_background_events(); + assert!(effect_status.is_none()); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].clone().into_protocol_message(), + json!({ + "method": "Page.frameStartedNavigating", + "params": {"frameId": "FRAME-WAIT-NONE"} + }) + ); + } +} diff --git a/moli-protocol/src/domains/console.rs b/moli-protocol/src/domains/console.rs index d2b175a5bc..cf420176e9 100644 --- a/moli-protocol/src/domains/console.rs +++ b/moli-protocol/src/domains/console.rs @@ -161,7 +161,7 @@ mod tests { bc.set_target_url("data:text/html,console-test".to_owned()); bc.set_active_target_id("TID-1".to_owned()); bc.attach_active_session("SID-1".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner( &format!("data:text/html,{html}"), Some("SID-1"), @@ -200,7 +200,7 @@ mod tests { let browser_context = ctx.conn.browser_context.as_ref().expect("browser context"); assert!( !browser_context - .devtools_session_state + .devtools_session_state() .console_output_session_state .console_enabled, "transitional observable-output enabled bit should track Console.disable" @@ -222,7 +222,7 @@ mod tests { .browser_context .as_ref() .expect("browser context should exist") - .devtools_session_state + .devtools_session_state() .console_output_session_state .console_enabled, "transitional Console projection must not flip before V8 Console.enable succeeds" @@ -246,7 +246,7 @@ mod tests { .browser_context .as_ref() .expect("browser context should exist") - .devtools_session_state + .devtools_session_state() .console_output_session_state .console_enabled, "transitional Console projection should flip after V8 Console.enable succeeds" @@ -323,7 +323,7 @@ mod tests { .browser_context .as_mut() .expect("browser context should be loaded"); - bc.devtools_session_state + bc.devtools_session_state_mut() .console_output_session_state .console_enabled = true; assert_eq!( @@ -547,17 +547,14 @@ mod tests { let active = ctx.conn.browser_context.as_ref().expect("browser context"); assert!( !active - .devtools_session_state + .devtools_session_state() .console_output_session_state .console_enabled ); assert!( active - .parked_page_session_state("TID-background") - .is_some_and(|state| state - .devtools_session_state - .console_output_session_state - .console_enabled), + .primary_devtools_session_state_for_target("TID-background") + .is_some_and(|state| state.console_output_session_state.console_enabled), "background target should stage Console.enable" ); } diff --git a/moli-protocol/src/domains/css.rs b/moli-protocol/src/domains/css.rs index de9f02ed2f..f44babbd44 100644 --- a/moli-protocol/src/domains/css.rs +++ b/moli-protocol/src/domains/css.rs @@ -5,7 +5,7 @@ use chromiumoxide_cdp::cdp::browser_protocol::css::{ GetStyleSheetTextParams as StyleSheetIdParams, SetStyleSheetTextParams, }; use moli_core::page::{ - CompletedPageCommand, Page, PendingPageCommand, RendererDocumentNodeAttributesResolution, + CompletedPageCommand, PendingPageCommand, RendererDocumentNodeAttributesResolution, }; use moli_css_parse::{DeclarationParseOptions, parse_declaration_list}; use serde::Deserialize; @@ -383,7 +383,7 @@ pub(crate) fn complete_pending_css_command( ) -> CssCommandDispatchStep { let command_id = completed.command_id; let session_id = completed.session_id.as_deref(); - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return CssCommandDispatchStep::Complete(CommandOutputPlan::error( -32000, "NoDocumentLoaded", @@ -661,10 +661,10 @@ fn add_empty_matched_style_scaffolding(result: &mut Value) { result_object.insert("cssKeyframesRules".to_owned(), json!([])); } -fn loaded_page_mut_for_session<'a>( - conn: &'a mut CdpConnection, +fn loaded_page_mut_for_session( + conn: &mut CdpConnection, session_id: Option<&str>, -) -> Option<&'a mut Page> { +) -> Option { conn.loaded_page_mut_for_protocol_access(session_id).ok() } @@ -737,7 +737,7 @@ mod tests { use crate::domains::page::LOADER_ID; use crate::testing::{TestContext, wait_until_renderer_document_load}; use moli_core::page::{ - CompletedPageCommand, Page, RENDERER_BACKEND_NODE_ID_START, is_renderer_backend_node_id, + CompletedPageCommand, RENDERER_BACKEND_NODE_ID_START, is_renderer_backend_node_id, }; use serde_json::Value; use serde_json::json; @@ -767,13 +767,15 @@ mod tests { async fn with_loaded_document_async(ctx: &mut TestContext, html: &str) { let mut bc = crate::conn::BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&format!("data:text/html,{html}"), None) .await; wait_until_renderer_document_load(ctx, None, "TID-1", LOADER_ID).await; } - fn loaded_page_mut_for_test(ctx: &mut TestContext) -> &mut Page { + fn loaded_page_mut_for_test( + ctx: &mut TestContext, + ) -> moli_core::browser_host::BrowserPageRuntimeLease { ctx.conn .browser_context .as_mut() @@ -828,8 +830,8 @@ mod tests { .as_mut() .expect("browser context should exist"); browser_context.attach_active_session("SID-1".to_owned()); - browser_context - .start_document_navigation_for_active_target("PENDING-LOADER".to_owned()) + ctx.conn + .start_document_navigation_for_session_owner(Some("SID-1"), "PENDING-LOADER".to_owned()) .expect("active navigation should start"); ctx.sent.clear(); } @@ -1599,39 +1601,26 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn pending_css_command_keeps_background_owner_route_across_completion() { let mut ctx = TestContext::new(); - let active_page = ctx - .conn - .load_page_via_runtime_async( - "data:text/html,", - ) - .await - .expect("active page should load"); - let background_page = ctx - .conn - .load_page_via_runtime_async( - "data:text/html,", - ) - .await - .expect("background page should load"); + let active_url = "data:text/html,"; + let background_url = "data:text/html,"; let mut browser_context = BrowserContext::new("BID-css-owner-route".to_owned()); browser_context.set_active_target_id("TID-css-active".to_owned()); - browser_context - .active_target - .runtime_slot - .set_loaded_page_for_test(active_page); browser_context.stage_background_target( "TID-css-background".to_owned(), - None, - "data:text/html,background css".to_owned(), + Some("SID-css-background".to_owned()), + "about:blank".to_owned(), None, None, ); - browser_context - .background_target_mut("TID-css-background") - .expect("background target") - .replace_loaded_page(Some(background_page)); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner(active_url, None) + .await; + ctx.install_navigation_fixture_for_session_owner( + background_url, + Some("SID-css-background"), + ) + .await; let background_route = ctx .conn @@ -1887,7 +1876,7 @@ mod tests { }) ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -2007,7 +1996,7 @@ mod tests { }) ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); diff --git a/moli-protocol/src/domains/debugger.rs b/moli-protocol/src/domains/debugger.rs index f601da7859..46a71ebf5f 100644 --- a/moli-protocol/src/domains/debugger.rs +++ b/moli-protocol/src/domains/debugger.rs @@ -32,25 +32,14 @@ mod tests { use crate::testing::TestContext; async fn with_loaded_document(ctx: &mut TestContext) { - ctx.conn - .insert_browser_context(BrowserContext::new("BID-debugger".into())); - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .set_active_target_id("TID-debugger"); - let page = ctx - .conn - .load_page_via_runtime_async("data:text/html,debugger") - .await - .expect("load debugger test page"); - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .active_target - .runtime_slot - .replace_loaded_page(Some(page)); + let mut browser_context = BrowserContext::new("BID-debugger".into()); + browser_context.set_active_target_id("TID-debugger"); + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,debugger", + None, + ) + .await; } async fn command(ctx: &mut TestContext, message: Value, command_id: u64) -> Value { @@ -184,10 +173,20 @@ mod tests { let _ = ctx .conn .finish_renderer_document_navigation_for_session_owner(None, &navigation); + assert!( + ctx.conn + .fail_document_navigation_for_session_owner_if_matches( + None, + &navigation, + moli_core::browser_host::BrowserNavigationFailure::Canceled { + error_text: "debugger interrupt test cleanup".to_owned(), + }, + ) + ); ctx.conn - .clear_pending_document_navigation_for_session_owner_if_loader_matches( + .clear_document_navigation_protocol_tail_for_session_owner_if_loader_matches( None, - &navigation.loader_id, + navigation.loader_id(), ); } @@ -349,18 +348,28 @@ mod tests { assert!(breakpoint.get("error").is_none(), "{breakpoint:?}"); ctx.sent.clear(); - let navigate = command( - &mut ctx, - json!({ + // Keep the renderer's post-commit parser continuation behind the + // production response-flush boundary. Releasing it eagerly lets the + // instrumentation breakpoint pause the replacement renderer before + // Page.navigate can expose its response, a state the wire actor never + // creates. + let response_flush = ctx + .process_command_holding_response_flush_for_test(json!({ "id": 37, "method": "Page.navigate", "params": { "url": "data:text/html," } - }), - 37, - ) - .await; + })) + .await; + let navigate = ctx + .sent + .iter() + .position(|message| message["id"] == json!(37)) + .map(|position| ctx.sent.remove(position)) + .expect("Page.navigate response"); + ctx.finish_held_command_response_flush_for_test(response_flush) + .await; assert!( navigate["result"]["frameId"].as_str().is_some(), "{navigate:?}" @@ -377,6 +386,10 @@ mod tests { }, ) .await; + ctx.wait_for_scheduler_message("replacement instrumentation pause", |message| { + message["method"] == json!("Debugger.paused") + }) + .await; let created = ctx .sent @@ -389,6 +402,13 @@ mod tests { json!("data:text/html,"), "the instrumented replacement script must reach V8" ); + + let resume = command(&mut ctx, json!({"id": 38, "method": "Debugger.resume"}), 38).await; + assert_eq!(resume["result"], json!({}), "{resume:?}"); + ctx.wait_for_scheduler_message("replacement Debugger.resumed", |message| { + message["method"] == json!("Debugger.resumed") + }) + .await; } #[tokio::test] diff --git a/moli-protocol/src/domains/dom.rs b/moli-protocol/src/domains/dom.rs index 3cee2dbedb..329e56688e 100644 --- a/moli-protocol/src/domains/dom.rs +++ b/moli-protocol/src/domains/dom.rs @@ -182,10 +182,10 @@ pub(crate) fn dom_agent_includes_whitespace_for_session( }) } -fn loaded_page_mut_for_session<'a>( - conn: &'a mut CdpConnection, +fn loaded_page_mut_for_session( + conn: &mut CdpConnection, session_id: Option<&str>, -) -> Option<&'a mut Page> { +) -> Option { conn.loaded_page_mut_for_protocol_access(session_id).ok() } diff --git a/moli-protocol/src/domains/dom/child_frame.rs b/moli-protocol/src/domains/dom/child_frame.rs index 59e62bf307..1667f627e0 100644 --- a/moli-protocol/src/domains/dom/child_frame.rs +++ b/moli-protocol/src/domains/dom/child_frame.rs @@ -133,7 +133,7 @@ async fn query_selector_command_in_session( .backend_node_id } }; - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; let pending = page .start_child_frame_document_query_selector_for_backend_node_id( @@ -170,13 +170,14 @@ async fn resolve_frontend_node_reference( let pending = page .start_document_frontend_node_binding(renderer_inspector_session_id, frontend_node_id) .map_err(PendingDomCommandStartError::renderer_error)?; + drop(page); let completion = pending .wait() .await .map_err(PendingDomCommandStartError::renderer_error)?; - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - frontend_binding::finish_reference(page, completion).map_err(|message| { + frontend_binding::finish_reference(&mut page, completion).map_err(|message| { PendingDomCommandStartError { code: -32000, message, @@ -196,11 +197,12 @@ async fn child_frame_document_root_node_reference( let pending = page .start_child_frame_document_root_node_reference(frame_id, renderer_inspector_session_id) .map_err(PendingDomCommandStartError::renderer_error)?; + drop(page); let completion = pending .wait() .await .map_err(PendingDomCommandStartError::renderer_error)?; - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; page.finish_document_node_reference(completion) .map_err(PendingDomCommandStartError::renderer_error)? @@ -218,9 +220,9 @@ async fn attributes_command( let mut route_scope = conn.scoped_none_session_owner_route_override(route); let conn = route_scope.conn_mut(); let reference = resolve_frontend_node_reference(conn, None, reference).await?; - let page = loaded_page_mut_for_session(conn, None) + let mut page = loaded_page_mut_for_session(conn, None) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let pending = start_document_node_attributes_for_reference(page, reference)?; + let pending = start_document_node_attributes_for_reference(&page, reference)?; let completion = pending .wait() .await @@ -250,9 +252,9 @@ async fn text_command( let mut route_scope = conn.scoped_none_session_owner_route_override(route); let conn = route_scope.conn_mut(); let reference = resolve_frontend_node_reference(conn, None, reference).await?; - let page = loaded_page_mut_for_session(conn, None) + let mut page = loaded_page_mut_for_session(conn, None) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let pending = start_document_node_text_for_reference(page, reference)?; + let pending = start_document_node_text_for_reference(&page, reference)?; let completion = pending .wait() .await @@ -283,9 +285,9 @@ async fn property_command( let mut route_scope = conn.scoped_none_session_owner_route_override(route); let conn = route_scope.conn_mut(); let reference = resolve_frontend_node_reference(conn, None, reference).await?; - let page = loaded_page_mut_for_session(conn, None) + let mut page = loaded_page_mut_for_session(conn, None) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let pending = start_document_node_property_for_reference(page, reference, name)?; + let pending = start_document_node_property_for_reference(&page, reference, name)?; let completion = pending .wait() .await @@ -347,7 +349,7 @@ async fn outer_html_command_in_session( .backend_node_id } }; - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; let pending = page .start_outer_html_for_backend_node_id(backend_node_id, include_shadow_dom) @@ -383,7 +385,7 @@ async fn resolve_node_command_in_session( let renderer_inspector_session_id = conn.target_renderer_runtime_inspector_session_id_for_session(session_id); let remote_object = { - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; let backend_node_id = required_child_frame_backend_node_id(&reference)?; let pending = page @@ -495,7 +497,7 @@ async fn node_snapshot_for_reference_in_session( let renderer_inspector_session_id = conn.target_renderer_runtime_inspector_session_id_for_session(session_id); let include_whitespace = dom_agent_includes_whitespace_for_session(conn, session_id); - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; let backend_node_id = required_child_frame_backend_node_id(&reference)?; let pending = page @@ -535,7 +537,7 @@ async fn child_frame_root_node_snapshot( let renderer_inspector_session_id = conn.target_renderer_runtime_inspector_session_id_for_session(session_id); let include_whitespace = dom_agent_includes_whitespace_for_session(conn, session_id); - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; let pending = page .start_document_node_snapshot_for_backend_node_id_in_inspector_session( @@ -610,7 +612,7 @@ async fn document_geometry_for_reference_in_session( reference: DevToolsDomNodeReference, ) -> Result { let reference = resolve_frontend_node_reference(conn, session_id, reference).await?; - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; let backend_node_id = required_child_frame_backend_node_id(&reference)?; let pending = page diff --git a/moli-protocol/src/domains/dom/patchright_shadow_tests.rs b/moli-protocol/src/domains/dom/patchright_shadow_tests.rs index 6adc354933..501103cc16 100644 --- a/moli-protocol/src/domains/dom/patchright_shadow_tests.rs +++ b/moli-protocol/src/domains/dom/patchright_shadow_tests.rs @@ -7,7 +7,7 @@ use super::tests::navigate_to_data_html_async; fn load_bc(ctx: &mut TestContext, bc_id: &str) { let mut bc = BrowserContext::new(bc_id.into()); bc.set_active_target_id("TID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); } fn take_response_by_id(ctx: &mut TestContext, id: u64) -> Value { diff --git a/moli-protocol/src/domains/dom/resolve.rs b/moli-protocol/src/domains/dom/resolve.rs index 5bac73aeb5..4459e66d06 100644 --- a/moli-protocol/src/domains/dom/resolve.rs +++ b/moli-protocol/src/domains/dom/resolve.rs @@ -1182,7 +1182,7 @@ fn start_cdp_dom_focus_command( } let page = loaded_page_mut_for_session(conn, cmd.session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let pending = start_focus_document_node_for_reference(page, reference)?; + let pending = start_focus_document_node_for_reference(&page, reference)?; Ok(PendingDomCommandDispatch { command_id: cmd.id, session_id: cmd.session_id.map(str::to_owned), @@ -1346,7 +1346,7 @@ fn start_devtools_remove_node_command( } let page = loaded_page_mut_for_session(conn, command_session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let pending = start_remove_document_node_for_reference(page, command.reference)?; + let pending = start_remove_document_node_for_reference(&page, command.reference)?; Ok(Some(PendingDomCommandDispatch { command_id, session_id: command_session_id.map(str::to_owned), @@ -1401,7 +1401,7 @@ fn start_devtools_dom_geometry_command( let reference = command.reference; let page = loaded_page_mut_for_session(conn, command_session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let (pending, kind) = start_client_rect_for_reference(page, reference, command.operation)?; + let (pending, kind) = start_client_rect_for_reference(&page, reference, command.operation)?; Ok(Some(PendingDomCommandDispatch { command_id, session_id: command_session_id.map(str::to_owned), @@ -1467,7 +1467,7 @@ fn start_devtools_describe_node_command( let page = loaded_page_mut_for_session(conn, command_session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; let pending = start_inspector_document_node_snapshot_for_reference( - page, + &page, renderer_inspector_session_id, include_whitespace, reference, @@ -1551,7 +1551,7 @@ fn start_devtools_request_child_nodes_command( let page = loaded_page_mut_for_session(conn, command_session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; let (pending, kind) = start_request_child_nodes_for_reference( - page, + &page, renderer_inspector_session_id, include_whitespace, DevToolsDomNodeReference::BackendNodeId(backend_node_id), @@ -2167,7 +2167,7 @@ fn start_devtools_resolve_node_command( } let object_group = command.object_group; let resolution = start_resolve_runtime_object_for_reference( - page, + &page, renderer_inspector_session_id, reference, None, @@ -2483,7 +2483,7 @@ fn start_devtools_get_outer_html_command( } let page = loaded_page_mut_for_session(conn, command_session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let (pending, kind) = start_outer_html_for_reference(page, reference, include_shadow_dom)?; + let (pending, kind) = start_outer_html_for_reference(&page, reference, include_shadow_dom)?; Ok(Some(PendingDomCommandDispatch { command_id, session_id: command_session_id.map(str::to_owned), @@ -2728,7 +2728,7 @@ fn complete_pending_dom_command( } kind => kind, }; - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { out.push_error(-32000, "NoDocumentLoaded"); return DomCommandTaskStep::Complete; }; @@ -2753,7 +2753,7 @@ fn complete_pending_dom_command( append, } => { return set_file_input::complete_preflight( - page, command_id, session_id, completion, reference, file_paths, append, out, + &mut page, command_id, session_id, completion, reference, file_paths, append, out, ); } PendingDomCommandKind::Focus { @@ -2834,25 +2834,27 @@ fn complete_pending_dom_command( } } PendingDomCommandKind::SetFileInputFiles => { - return set_file_input::complete_set_file_input_files(page, completion, out); + return set_file_input::complete_set_file_input_files(&mut page, completion, out); } PendingDomCommandKind::SetFileInputFilesObjectReference => { return set_file_input::complete_set_file_input_files_object_reference( - page, completion, out, + &mut page, completion, out, ); } PendingDomCommandKind::RendererBackendNodeClientRect { operation } => { - return complete_renderer_backend_node_client_rect(operation, page, completion, out); + return complete_renderer_backend_node_client_rect( + operation, &mut page, completion, out, + ); } PendingDomCommandKind::GetNodeForLocation { top_frame_id } => { - match finish_document_hit_test(page, completion, top_frame_id) { + match finish_document_hit_test(&mut page, completion, top_frame_id) { Ok(result) => out.push_result(get_node_for_location_result_value(&result)), Err(error) => out.push_error(-32000, error.message), } } PendingDomCommandKind::RendererBackendNodeScrollIntoViewIfNeeded => { return complete_renderer_backend_node_scroll_into_view_if_needed( - page, completion, out, + &mut page, completion, out, ); } PendingDomCommandKind::PushNodesByBackendIdsToFrontend { @@ -2861,7 +2863,7 @@ fn complete_pending_dom_command( renderer_backend_positions, } => { return complete_push_nodes_by_backend_ids_to_frontend( - page, + &mut page, completion, session_id, backend_node_ids, @@ -2887,38 +2889,40 @@ fn complete_pending_dom_command( } } PendingDomCommandKind::QuerySelectorLive { multiple } => { - return complete_query_selector_live(page, completion, multiple, out); + return complete_query_selector_live(&mut page, completion, multiple, out); } PendingDomCommandKind::GetAttributesLive => { - return complete_get_attributes_live(page, completion, out); + return complete_get_attributes_live(&mut page, completion, out); } PendingDomCommandKind::GetTextLive => { - return complete_get_text_live(page, completion, out); + return complete_get_text_live(&mut page, completion, out); } PendingDomCommandKind::GetPropertyLive => { - return complete_get_property_live(page, completion, out); + return complete_get_property_live(&mut page, completion, out); } PendingDomCommandKind::RequestNodeObjectReference => { - return complete_request_node_object_reference(session_id, page, completion, out); + return complete_request_node_object_reference(session_id, &mut page, completion, out); } PendingDomCommandKind::GetOuterHtmlDocument => { - return complete_get_outer_html_document(page, completion, out); + return complete_get_outer_html_document(&mut page, completion, out); } PendingDomCommandKind::GetOuterHtmlObjectReference => { - return complete_get_outer_html_object_reference(page, completion, out); + return complete_get_outer_html_object_reference(&mut page, completion, out); } PendingDomCommandKind::GetOuterHtmlBackendNodeReference => { - return complete_get_outer_html_backend_node_reference(page, completion, out); + return complete_get_outer_html_backend_node_reference(&mut page, completion, out); } PendingDomCommandKind::ScrollIntoViewIfNeededObjectReference => { - return complete_scroll_into_view_if_needed_object_reference(page, completion, out); + return complete_scroll_into_view_if_needed_object_reference( + &mut page, completion, out, + ); } PendingDomCommandKind::DescribeNodeObjectReference { cached_object_node, top_frame_id, } => { return complete_describe_node_object_reference( - page, + &mut page, completion, cached_object_node, top_frame_id, @@ -2926,13 +2930,15 @@ fn complete_pending_dom_command( ); } PendingDomCommandKind::ObjectReferenceLiveClientRect { operation } => { - return complete_object_reference_live_client_rect(operation, page, completion, out); + return complete_object_reference_live_client_rect( + operation, &mut page, completion, out, + ); } PendingDomCommandKind::DocumentSnapshot { operation, top_frame_id, } => { - return complete_document_snapshot(operation, page, completion, top_frame_id, out); + return complete_document_snapshot(operation, &mut page, completion, top_frame_id, out); } PendingDomCommandKind::SetChildNodesSnapshotForBackendNode { after, @@ -2941,7 +2947,7 @@ fn complete_pending_dom_command( } => { return complete_set_child_nodes_snapshot_for_backend_node( session_id, - page, + &mut page, completion, after, top_frame_id, @@ -2955,7 +2961,7 @@ fn complete_pending_dom_command( } => { return complete_query_selector_set_child_nodes_live( session_id, - page, + &mut page, completion, multiple, top_frame_id, @@ -3097,7 +3103,7 @@ fn complete_pending_dom_command( return DomCommandTaskStep::Complete; } return match start_resolve_runtime_object_for_reference( - page, + &page, renderer_inspector_session_id.clone(), reference, Some(execution_context_id), @@ -3359,37 +3365,39 @@ fn complete_pending_dom_command_result( ); } PendingDomCommandKind::SetFileInputFiles => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - set_file_input::complete_set_file_input_files_result(page, completion) + set_file_input::complete_set_file_input_files_result(&mut page, completion) .map(|()| DevToolsCommandResult::Empty) } PendingDomCommandKind::SetFileInputFilesObjectReference => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - set_file_input::complete_set_file_input_files_object_reference_result(page, completion) - .map(|()| DevToolsCommandResult::Empty) + set_file_input::complete_set_file_input_files_object_reference_result( + &mut page, completion, + ) + .map(|()| DevToolsCommandResult::Empty) } PendingDomCommandKind::GetNodeForLocation { top_frame_id } => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - finish_document_hit_test(page, completion, top_frame_id) + finish_document_hit_test(&mut page, completion, top_frame_id) .map(DevToolsCommandResult::GetNodeForLocation) } PendingDomCommandKind::RendererBackendNodeClientRect { operation } => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", @@ -3403,13 +3411,13 @@ fn complete_pending_dom_command_result( .map(DevToolsCommandResult::DomGeometry) } PendingDomCommandKind::RendererBackendNodeScrollIntoViewIfNeeded => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_renderer_backend_node_scroll_into_view_if_needed_result(page, completion) + complete_renderer_backend_node_scroll_into_view_if_needed_result(&mut page, completion) .map(|()| DevToolsCommandResult::Empty) } PendingDomCommandKind::PushNodesByBackendIdsToFrontend { @@ -3417,14 +3425,14 @@ fn complete_pending_dom_command_result( node_ids, renderer_backend_positions, } => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; match complete_push_nodes_by_backend_ids_to_frontend_result( - page, + &mut page, completion, backend_node_ids, node_ids, @@ -3435,62 +3443,62 @@ fn complete_pending_dom_command_result( } } PendingDomCommandKind::GetFrameOwner { frame_id } => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_get_frame_owner_result(page, completion, &frame_id) + complete_get_frame_owner_result(&mut page, completion, &frame_id) .map(DevToolsCommandResult::GetFrameOwner) } PendingDomCommandKind::QuerySelectorLive { multiple } => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_query_selector_live_result(page, completion, multiple) + complete_query_selector_live_result(&mut page, completion, multiple) .map(DevToolsCommandResult::QuerySelector) } PendingDomCommandKind::QuerySelectorSetChildNodesLive { multiple, .. } => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_query_selector_set_child_nodes_live_result(page, completion, multiple) + complete_query_selector_set_child_nodes_live_result(&mut page, completion, multiple) .map(DevToolsCommandResult::QuerySelector) } PendingDomCommandKind::GetAttributesLive => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_get_attributes_live_result(page, completion) + complete_get_attributes_live_result(&mut page, completion) .map(DevToolsCommandResult::GetAttributes) } PendingDomCommandKind::GetTextLive => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_get_text_live_result(page, completion).map(DevToolsCommandResult::GetText) + complete_get_text_live_result(&mut page, completion).map(DevToolsCommandResult::GetText) } PendingDomCommandKind::GetPropertyLive => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_get_property_live_result(page, completion) + complete_get_property_live_result(&mut page, completion) .map(DevToolsCommandResult::GetProperty) } PendingDomCommandKind::ResolveNode { @@ -3498,13 +3506,13 @@ fn complete_pending_dom_command_result( cache_top_frame_id, } => { let remote_object = { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - match complete_resolve_node_page_result(page, completion) { + match complete_resolve_node_page_result(&mut page, completion) { Ok(result) => result, Err(error) => return devtools_dom_command_task_complete(Err(error)), } @@ -3558,7 +3566,7 @@ fn complete_pending_dom_command_result( top_frame_id, } => { { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", @@ -3591,7 +3599,7 @@ fn complete_pending_dom_command_result( object_group, top_frame_id, } => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", @@ -3600,7 +3608,7 @@ fn complete_pending_dom_command_result( return complete_resolve_node_execution_context_frame_result( session_id, renderer_inspector_session_id.clone(), - page, + &mut page, completion, reference, execution_context_id, @@ -3613,67 +3621,67 @@ fn complete_pending_dom_command_result( "UnsupportedDevToolsCommand", )), PendingDomCommandKind::ObjectReferenceLiveClientRect { operation } => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_object_reference_live_client_rect_result(page, completion, operation) + complete_object_reference_live_client_rect_result(&mut page, completion, operation) .map(DevToolsCommandResult::DomGeometry) } PendingDomCommandKind::GetOuterHtmlObjectReference => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_get_outer_html_object_reference_result(page, completion) + complete_get_outer_html_object_reference_result(&mut page, completion) .map(DevToolsCommandResult::GetOuterHtml) } PendingDomCommandKind::GetOuterHtmlDocument => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_get_outer_html_document_result(page, completion) + complete_get_outer_html_document_result(&mut page, completion) .map(DevToolsCommandResult::GetOuterHtml) } PendingDomCommandKind::GetOuterHtmlBackendNodeReference => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_get_outer_html_backend_node_reference_result(page, completion) + complete_get_outer_html_backend_node_reference_result(&mut page, completion) .map(DevToolsCommandResult::GetOuterHtml) } PendingDomCommandKind::ScrollIntoViewIfNeededObjectReference => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; - complete_scroll_into_view_if_needed_object_reference_result(page, completion) + complete_scroll_into_view_if_needed_object_reference_result(&mut page, completion) .map(|()| DevToolsCommandResult::Empty) } PendingDomCommandKind::DescribeNodeObjectReference { cached_object_node, top_frame_id, } => { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", ))); }; complete_describe_node_object_reference_result( - page, + &mut page, completion, cached_object_node, top_frame_id, @@ -3920,11 +3928,11 @@ fn complete_frontend_node_binding_reference( completion: CompletedPageCommand, out: &mut DomCommandOutput, ) -> Result { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { out.push_error(-32000, "NoDocumentLoaded"); return Err(DomCommandTaskStep::Complete); }; - match super::frontend_binding::finish_reference(page, completion) { + match super::frontend_binding::finish_reference(&mut page, completion) { Ok(reference) => Ok(reference), Err(message) => { out.push_error(-32000, message); @@ -3938,13 +3946,13 @@ fn complete_frontend_node_binding_reference_result( session_id: Option<&str>, completion: CompletedPageCommand, ) -> Result { - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return Err(devtools_dom_command_task_complete(Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", )))); }; - super::frontend_binding::finish_reference(page, completion).map_err(|message| { + super::frontend_binding::finish_reference(&mut page, completion).map_err(|message| { let kind = if message == "Could not find node with given id" { DevToolsErrorKind::NoSuchNode } else { @@ -3978,7 +3986,7 @@ where out.push_error(-32000, "NoDocumentLoaded"); return DomCommandTaskStep::Complete; }; - match start(page, reference) { + match start(&page, reference) { Ok((pending, kind)) => DomCommandTaskStep::Pending(Box::new(PendingDomCommandDispatch { command_id, session_id: session_id.map(str::to_owned), @@ -4017,7 +4025,7 @@ where "NoDocumentLoaded", ))); }; - match start(page, reference) { + match start(&page, reference) { Ok((pending, kind)) => { DevToolsDomCommandTaskStep::Pending(Box::new(PendingDomCommandDispatch { command_id, @@ -5659,7 +5667,7 @@ fn start_devtools_get_attributes_command( let reference = command.reference; let page = loaded_page_mut_for_session(conn, command_session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let pending = start_document_node_attributes_for_reference(page, reference)?; + let pending = start_document_node_attributes_for_reference(&page, reference)?; Ok(Some(PendingDomCommandDispatch { command_id, session_id: command_session_id.map(str::to_owned), @@ -5717,7 +5725,7 @@ fn start_devtools_get_text_command( let reference = command.reference; let page = loaded_page_mut_for_session(conn, command_session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let pending = start_document_node_text_for_reference(page, reference)?; + let pending = start_document_node_text_for_reference(&page, reference)?; Ok(Some(PendingDomCommandDispatch { command_id, session_id: command_session_id.map(str::to_owned), @@ -5747,7 +5755,7 @@ fn start_devtools_get_property_command( let reference = command.reference; let page = loaded_page_mut_for_session(conn, command_session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let pending = start_document_node_property_for_reference(page, reference, &command.name)?; + let pending = start_document_node_property_for_reference(&page, reference, &command.name)?; Ok(Some(PendingDomCommandDispatch { command_id, session_id: command_session_id.map(str::to_owned), @@ -6198,7 +6206,7 @@ fn start_devtools_scroll_into_view_if_needed_command( } let page = loaded_page_mut_for_session(conn, command_session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - let (pending, kind) = start_scroll_into_view_for_reference(page, reference, command.rect)?; + let (pending, kind) = start_scroll_into_view_for_reference(&page, reference, command.rect)?; Ok(Some(PendingDomCommandDispatch { command_id, session_id: command_session_id.map(str::to_owned), diff --git a/moli-protocol/src/domains/dom/search.rs b/moli-protocol/src/domains/dom/search.rs index c610f24989..697bfddea3 100644 --- a/moli-protocol/src/domains/dom/search.rs +++ b/moli-protocol/src/domains/dom/search.rs @@ -191,7 +191,7 @@ pub(super) fn complete_perform_search_live( out: &mut DomCommandOutput, ) -> DomCommandTaskStep { let search = { - let Some(page) = super::loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = super::loaded_page_mut_for_session(conn, session_id) else { out.push_error(-32000, "NoDocumentLoaded"); return DomCommandTaskStep::Complete; }; @@ -217,7 +217,7 @@ pub(super) fn complete_get_search_results_live( out: &mut DomCommandOutput, ) -> DomCommandTaskStep { let resolution = { - let Some(page) = super::loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = super::loaded_page_mut_for_session(conn, session_id) else { out.push_error(-32000, "NoDocumentLoaded"); return DomCommandTaskStep::Complete; }; @@ -255,7 +255,7 @@ pub(super) fn complete_discard_search_results_live( completion: CompletedPageCommand, out: &mut DomCommandOutput, ) -> DomCommandTaskStep { - let Some(page) = super::loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = super::loaded_page_mut_for_session(conn, session_id) else { out.push_success(); return DomCommandTaskStep::Complete; }; diff --git a/moli-protocol/src/domains/dom/set_file_input.rs b/moli-protocol/src/domains/dom/set_file_input.rs index 68e27cf7fc..5b80b83819 100644 --- a/moli-protocol/src/domains/dom/set_file_input.rs +++ b/moli-protocol/src/domains/dom/set_file_input.rs @@ -82,7 +82,7 @@ pub(super) fn start_cdp_set_file_input_files_by_node_reference( let page = loaded_page_mut_for_session(conn, cmd.session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; start_set_file_input_files_preflight_dispatch( - page, + &page, cmd.id, cmd.session_id, reference, @@ -351,7 +351,7 @@ fn start_set_file_input_files_after_bidi_binding_resolution( let page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; start_set_file_input_files_for_reference_dispatch( - page, + &page, command_id, session_id, DevToolsDomNodeReference::BackendNodeId(backend_node_id), @@ -381,7 +381,7 @@ fn start_set_file_input_files_after_frontend_binding_resolution( let page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; start_set_file_input_files_preflight_dispatch( - page, command_id, session_id, reference, file_paths, append, + &page, command_id, session_id, reference, file_paths, append, ) } @@ -390,7 +390,7 @@ fn finish_renderer_bidi_node_binding( session_id: Option<&str>, completion: CompletedPageCommand, ) -> Result { - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; page.finish_document_bidi_node_binding(completion) .map_err(|error| { @@ -406,9 +406,9 @@ fn finish_renderer_frontend_node_binding( completion: CompletedPageCommand, _frontend_node_id: u32, ) -> Result { - let page = loaded_page_mut_for_session(conn, session_id) + let mut page = loaded_page_mut_for_session(conn, session_id) .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; - super::frontend_binding::finish_reference(page, completion) + super::frontend_binding::finish_reference(&mut page, completion) .map_err(PendingDomCommandStartError::renderer_error) } @@ -454,7 +454,7 @@ fn start_set_file_input_files_for_remote_reference( .ok_or_else(PendingDomCommandStartError::no_document_loaded)?; let object_id = reference; start_set_file_input_files_for_runtime_object( - page, + &page, command_id, command_session_id, renderer_inspector_session_id, diff --git a/moli-protocol/src/domains/dom/stack_traces.rs b/moli-protocol/src/domains/dom/stack_traces.rs index c298724800..36bb4d47e8 100644 --- a/moli-protocol/src/domains/dom/stack_traces.rs +++ b/moli-protocol/src/domains/dom/stack_traces.rs @@ -71,7 +71,7 @@ pub(super) fn complete_set_node_stack_traces_enabled_command( completion: CompletedPageCommand, out: &mut DomCommandOutput, ) -> DomCommandTaskStep { - let Some(page) = super::loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = super::loaded_page_mut_for_session(conn, session_id) else { out.push_error(-32000, "NoDocumentLoaded"); return DomCommandTaskStep::Complete; }; @@ -93,7 +93,7 @@ pub(super) fn complete_get_node_stack_traces_command( out: &mut DomCommandOutput, ) -> DomCommandTaskStep { let resolution = { - let Some(page) = super::loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = super::loaded_page_mut_for_session(conn, session_id) else { out.push_error(-32000, "NoDocumentLoaded"); return DomCommandTaskStep::Complete; }; diff --git a/moli-protocol/src/domains/dom/tests/geometry.rs b/moli-protocol/src/domains/dom/tests/geometry.rs index f966c0bac4..aeceea8c14 100644 --- a/moli-protocol/src/domains/dom/tests/geometry.rs +++ b/moli-protocol/src/domains/dom/tests/geometry.rs @@ -50,7 +50,7 @@ async fn dom_resolve_geometry_and_mutation_target_loaded_background_owner_withou bc.set_active_target_id("TID-active".to_owned()); bc.attach_active_session("SID-active".to_owned()); bc.background_targets.push(background); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_buffered_navigation_fixture_for_session_owner( background_url, "
background
".to_owned(), @@ -288,29 +288,20 @@ async fn dom_resolve_geometry_and_mutation_target_loaded_background_owner_withou #[tokio::test(flavor = "multi_thread")] async fn dom_resolve_geometry_targets_inactive_loaded_owner_without_activation() { let mut ctx = TestContext::new(); + let inactive_url = "data:text/html,
inactive
"; + let mut active = BrowserContext::new("BID-active".to_owned()); active.set_active_target_id("TID-active".to_owned()); active.attach_active_session("SID-active".to_owned()); - ctx.conn.browser_context = Some(active); + ctx.conn.insert_browser_context(active); let mut inactive = BrowserContext::new("BID-inactive".to_owned()); inactive.set_active_target_id("TID-inactive".to_owned()); - inactive.set_target_url("about:blank".to_owned()); + inactive.set_target_url(inactive_url.to_owned()); inactive.attach_active_session("SID-inactive".to_owned()); - ctx.conn.inactive_browser_contexts.push(inactive); - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,
inactive
", - Some("SID-inactive"), - ) - .await; - crate::testing::wait_until_renderer_document_load( - &mut ctx, - Some("SID-inactive"), - "TID-inactive", - crate::domains::page::LOADER_ID, - ) - .await; - ctx.sent.clear(); + ctx.conn.insert_browser_context(inactive); + ctx.install_navigation_fixture_for_session_owner(inactive_url, Some("SID-inactive")) + .await; ctx.process_async(json!({ "id": 361, diff --git a/moli-protocol/src/domains/dom/tests/mod.rs b/moli-protocol/src/domains/dom/tests/mod.rs index b6e5953781..dff2b7f2ad 100644 --- a/moli-protocol/src/domains/dom/tests/mod.rs +++ b/moli-protocol/src/domains/dom/tests/mod.rs @@ -249,11 +249,20 @@ fn assert_non_empty_geometry_quads(value: &Value) { async fn complete_pending_command_task_for_test( ctx: &mut TestContext, - pending: PendingCdpCommandDispatch, + mut pending: PendingCdpCommandDispatch, ) -> Vec { - ctx.complete_command_task_step_for_test(CdpCommandTaskStep::Pending(Box::new(pending))) - .await - .0 + loop { + let completed = Box::pin(pending.wait()).await; + match Box::pin(ctx.conn.complete_pending_command_dispatch(completed)).await { + CdpCommandTaskStep::Pending(next) => pending = *next, + CdpCommandTaskStep::Complete(outcome) => { + return ctx + .route_completed_command_outcome_for_test(outcome) + .await + .0; + } + } + } } async fn complete_command_dispatch_without_legacy_fallback_for_test( @@ -262,8 +271,16 @@ async fn complete_command_dispatch_without_legacy_fallback_for_test( _expectation: &str, ) -> Vec { let raw = command.to_string(); - let step = ctx.conn.start_command_dispatch(&raw); - ctx.complete_command_task_step_for_test(step).await.0 + match ctx.conn.start_command_dispatch(&raw) { + CdpCommandTaskStep::Complete(outcome) => { + ctx.route_completed_command_outcome_for_test(outcome) + .await + .0 + } + CdpCommandTaskStep::Pending(pending) => { + complete_pending_command_task_for_test(ctx, *pending).await + } + } } fn child_element_by_node_name<'a>(node: &'a Value, node_name: &str) -> &'a Value { diff --git a/moli-protocol/src/domains/dom/tests/node_ops.rs b/moli-protocol/src/domains/dom/tests/node_ops.rs index c1e08d4c09..298898a1bb 100644 --- a/moli-protocol/src/domains/dom/tests/node_ops.rs +++ b/moli-protocol/src/domains/dom/tests/node_ops.rs @@ -40,7 +40,9 @@ async fn renderer_backend_node_for_live_expression( take_response_by_id(ctx, describe_id)["result"]["node"].clone() } -fn loaded_page_mut_for_test(ctx: &mut TestContext) -> &mut moli_core::page::Page { +fn loaded_page_mut_for_test( + ctx: &mut TestContext, +) -> moli_core::browser_host::BrowserPageRuntimeLease { ctx.conn .browser_context .as_mut() @@ -68,7 +70,7 @@ async fn renderer_frontend_binding_for_test( .await .expect("renderer frontend node binding lookup should complete") }; - let page = loaded_page_mut_for_test(ctx); + let mut page = loaded_page_mut_for_test(ctx); page.finish_document_frontend_node_binding(completion) .expect("renderer frontend node binding lookup should finish") } @@ -2250,7 +2252,76 @@ async fn set_file_input_files_validates_node_before_reading_files() { } #[tokio::test(flavor = "multi_thread")] -async fn file_chooser_backend_node_id_resolves_detached_source_after_document_open() { +async fn file_chooser_opened_registers_renderer_shared_id_in_current_document() { + let mut ctx = TestContext::new(); + load_bc(&mut ctx, "BID-A"); + navigate_to_data_html_async( + &mut ctx, + 1, + "", + ) + .await; + + let _ = enable_runtime_and_take_execution_context_id_async(&mut ctx, 10).await; + let _ = ctx.take_all(); + ctx.process_async(json!({ + "id": 2, + "method": "Page.setInterceptFileChooserDialog", + "params": { "enabled": true } + })) + .await; + ctx.expect_result(2, json!({}), None); + let _ = ctx.take_all(); + + ctx.process_async(json!({ + "id": 3, + "method": "Runtime.evaluate", + "params": { + "expression": "document.getElementById('picker').click(); 'clicked'", + "returnByValue": true + } + })) + .await; + assert_eq!( + take_response_by_id(&mut ctx, 3)["result"]["result"]["value"], + json!("clicked") + ); + let file_chooser = ctx + .sent + .iter() + .find(|message| message["method"] == json!("Page.fileChooserOpened")) + .cloned() + .unwrap_or_else(|| panic!("Page.fileChooserOpened should be emitted: {:?}", ctx.sent)); + let backend_node_id = file_chooser["params"]["backendNodeId"] + .as_u64() + .and_then(|id| u32::try_from(id).ok()) + .expect("file chooser event should include u32 backendNodeId"); + + let renderer_shared_id = ctx + .conn + .document_bidi_node_shared_id_for_backend_node_id_for_session_owner_async( + None, + backend_node_id, + ) + .await + .expect("renderer shared-id reverse lookup should run"); + let moli_core::page::RendererDomBidiNodeSharedIdResolution::SharedId(renderer_shared_id) = + renderer_shared_id + else { + panic!("current-Document file chooser should register renderer shared id"); + }; + assert!( + crate::devtools_runtime::is_webdriver_bidi_node_shared_id(&renderer_shared_id), + "file chooser renderer shared id should use the BiDi node shared id shape: {renderer_shared_id}" + ); + assert!( + !renderer_shared_id.contains("moli:bidi-node:"), + "file chooser renderer shared id must not encode a legacy storage node index: {renderer_shared_id}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn file_chooser_opened_renderer_backend_node_id_is_scoped_to_document_replacement() { let mut ctx = TestContext::new(); load_bc(&mut ctx, "BID-A"); navigate_to_data_html_async( @@ -2931,7 +3002,7 @@ async fn push_nodes_by_renderer_backend_ids_binds_fresh_live_dom() { assert_eq!(attrs.get("id").map(String::as_str), Some("fresh-push")); assert_eq!(attrs.get("data-state").map(String::as_str), Some("live")); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -2964,7 +3035,7 @@ async fn get_attributes_low_node_id_misses_without_frontend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3204,7 +3275,7 @@ async fn set_file_input_files_low_node_id_misses_without_frontend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3244,7 +3315,7 @@ async fn set_file_input_files_low_backend_id_misses_without_backend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3277,7 +3348,7 @@ async fn resolve_node_low_node_id_misses_without_frontend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3310,7 +3381,7 @@ async fn resolve_node_low_backend_id_misses_without_backend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3343,7 +3414,7 @@ async fn describe_node_low_node_id_misses_without_frontend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3376,7 +3447,7 @@ async fn get_outer_html_low_backend_id_misses_without_backend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3409,7 +3480,7 @@ async fn get_box_model_low_node_id_misses_without_frontend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3442,7 +3513,7 @@ async fn get_content_quads_low_backend_id_misses_without_backend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3475,7 +3546,7 @@ async fn scroll_into_view_low_backend_id_misses_without_backend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3508,7 +3579,7 @@ async fn query_selector_low_root_node_id_misses_without_frontend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3541,7 +3612,7 @@ async fn query_selector_all_low_root_node_id_misses_without_frontend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3574,7 +3645,7 @@ async fn request_child_nodes_low_node_id_misses_without_frontend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); @@ -3607,7 +3678,7 @@ async fn remove_node_low_node_id_misses_without_frontend_binding() { })] ); - let page = loaded_page_mut_for_test(&mut ctx); + let mut page = loaded_page_mut_for_test(&mut ctx); let _ = page .finish_runtime_protocol_message(mutation_completion) .expect("runtime mutation completion should finish"); diff --git a/moli-protocol/src/domains/dom_debugger.rs b/moli-protocol/src/domains/dom_debugger.rs index 5cd8375c92..baa9a5e674 100644 --- a/moli-protocol/src/domains/dom_debugger.rs +++ b/moli-protocol/src/domains/dom_debugger.rs @@ -252,7 +252,7 @@ pub(crate) fn complete_pending_dom_debugger_command( CompletedDomDebuggerOperation::GetEventListeners => { let resolution = completed.completed.and_then(|completion| { conn.loaded_page_mut_for_protocol_access(session_id.as_deref()) - .and_then(|page| { + .and_then(|mut page| { page.finish_dom_debugger_get_event_listeners(completion) .map_err(|error| error.to_string()) }) @@ -273,7 +273,7 @@ pub(crate) fn complete_pending_dom_debugger_command( } => { let completion = completed.completed.and_then(|completion| { conn.loaded_page_mut_for_protocol_access(session_id.as_deref()) - .and_then(|page| { + .and_then(|mut page| { page.finish_unit_runtime_page_command( completion, "DOMDebugger event listener breakpoint", @@ -309,7 +309,7 @@ pub(crate) fn complete_pending_dom_debugger_command( } => { let completion = completed.completed.and_then(|completion| { conn.loaded_page_mut_for_protocol_access(session_id.as_deref()) - .and_then(|page| { + .and_then(|mut page| { page.finish_unit_runtime_page_command( completion, "DOMDebugger XHR breakpoint", @@ -338,7 +338,7 @@ pub(crate) fn complete_pending_dom_debugger_command( CompletedDomDebuggerOperation::ConfigureDomBreakpoint => { let resolution = completed.completed.and_then(|completion| { conn.loaded_page_mut_for_protocol_access(session_id.as_deref()) - .and_then(|page| { + .and_then(|mut page| { page.finish_dom_debugger_configure_dom_breakpoint(completion) .map_err(|error| error.to_string()) }) @@ -504,7 +504,7 @@ mod tests { browser_context.set_target_url("data:text/html,dom-debugger-test".to_owned()); browser_context.set_active_target_id("TID-1".to_owned()); browser_context.attach_active_session("SID-1".to_owned()); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.install_navigation_fixture_for_session_owner( &format!("data:text/html,{html}"), Some("SID-1"), diff --git a/moli-protocol/src/domains/dom_snapshot.rs b/moli-protocol/src/domains/dom_snapshot.rs index 85a4f0d8d0..ea13735fae 100644 --- a/moli-protocol/src/domains/dom_snapshot.rs +++ b/moli-protocol/src/domains/dom_snapshot.rs @@ -5,7 +5,7 @@ //! include resolved computed styles plus lightweight geometry for automation clients. use moli_core::page::{ - CompletedPageCommand, Page, PendingPageCommand, RendererDomSnapshotCaptureOptions, + CompletedPageCommand, PendingPageCommand, RendererDomSnapshotCaptureOptions, }; use serde::Deserialize; @@ -153,7 +153,7 @@ pub(crate) fn complete_pending_dom_snapshot_command( completed: CompletedDomSnapshotCommandDispatch, ) -> DomSnapshotCommandDispatchStep { let session_id = completed.session_id.as_deref(); - let Some(page) = loaded_page_mut_for_session(conn, session_id) else { + let Some(mut page) = loaded_page_mut_for_session(conn, session_id) else { return DomSnapshotCommandDispatchStep::Complete(CommandOutputPlan::error( -32000, "NoDocumentLoaded", @@ -187,10 +187,10 @@ pub(crate) fn complete_pending_dom_snapshot_command( )) } -fn loaded_page_mut_for_session<'a>( - conn: &'a mut CdpConnection, +fn loaded_page_mut_for_session( + conn: &mut CdpConnection, session_id: Option<&str>, -) -> Option<&'a mut Page> { +) -> Option { conn.loaded_page_mut_for_protocol_access(session_id).ok() } @@ -212,7 +212,7 @@ mod tests { async fn load_document(ctx: &mut TestContext, html: &str) { let mut bc = BrowserContext::new("BID-1".to_owned()); bc.set_active_target_id("TID-1".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&format!("data:text/html,{html}"), None) .await; wait_until_renderer_document_load(ctx, None, "TID-1", LOADER_ID).await; @@ -225,18 +225,40 @@ mod tests { async fn process_via_command_dispatch(ctx: &mut TestContext, msg: serde_json::Value) { let raw = serde_json::to_string(&msg).expect("test command should serialize"); - let step = ctx.conn.start_command_dispatch(&raw); - let (messages, _) = ctx.complete_command_task_step_for_test(step).await; - ctx.sent.extend(messages); + let mut step = ctx.conn.start_command_dispatch(&raw); + loop { + match step { + CdpCommandTaskStep::Complete(outcome) => { + let (messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + ctx.sent.extend(messages); + break; + } + CdpCommandTaskStep::Pending(pending) => { + step = ctx + .conn + .complete_pending_command_dispatch(pending.wait().await) + .await; + } + } + } } async fn complete_pending_command_task_for_test( ctx: &mut TestContext, - pending: crate::conn::PendingCdpCommandDispatch, + mut pending: crate::conn::PendingCdpCommandDispatch, ) -> Vec { - ctx.complete_command_task_step_for_test(CdpCommandTaskStep::Pending(Box::new(pending))) - .await - .0 + loop { + let completed = pending.wait().await; + match ctx.conn.complete_pending_command_dispatch(completed).await { + CdpCommandTaskStep::Pending(next) => pending = *next, + CdpCommandTaskStep::Complete(outcome) => { + return ctx + .route_completed_command_outcome_for_test(outcome) + .await + .0; + } + } + } } async fn wait_until_runtime_value( @@ -1020,26 +1042,21 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn capture_snapshot_targets_loaded_background_owner_without_promotion() { let mut ctx = TestContext::new(); - let page = ctx - .conn - .load_page_via_runtime_async( - "data:text/html,Background Snapshot
owner
", - ) - .await - .expect("background page should load"); + let page_url = "data:text/html,Background Snapshot
owner
"; - let mut background = BackgroundTarget::with_url( + let background = BackgroundTarget::with_url( "TID-background".to_owned(), Some("SID-background".to_owned()), - page.final_url().as_str().to_owned(), + "about:blank".to_owned(), ); - background.replace_loaded_page(Some(page)); let mut bc = BrowserContext::new("BID-DS-BG".to_owned()); bc.set_active_target_id("TID-active".to_owned()); bc.attach_active_session("SID-active".to_owned()); bc.background_targets.push(background); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); + ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-background")) + .await; ctx.process_async(json!({ "id": 101, @@ -1079,25 +1096,20 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn capture_snapshot_targets_inactive_owner_without_activation() { let mut ctx = TestContext::new(); - let page = ctx - .conn - .load_page_via_runtime_async( - "data:text/html,Inactive Snapshot
inactive
", - ) - .await - .expect("inactive page should load"); + let page_url = "data:text/html,Inactive Snapshot
inactive
"; let mut active = BrowserContext::new("BID-active".to_owned()); active.set_active_target_id("TID-active".to_owned()); active.attach_active_session("SID-active".to_owned()); - ctx.conn.browser_context = Some(active); + ctx.conn.insert_browser_context(active); let mut inactive = BrowserContext::new("BID-inactive".to_owned()); inactive.set_active_target_id("TID-inactive".to_owned()); - inactive.set_target_url(page.final_url().as_str().to_owned()); + inactive.set_target_url("about:blank".to_owned()); inactive.attach_active_session("SID-inactive".to_owned()); - inactive.replace_loaded_page(Some(page)); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); + ctx.install_navigation_fixture_for_session_owner(page_url, Some("SID-inactive")) + .await; ctx.process_async(json!({ "id": 111, diff --git a/moli-protocol/src/domains/dom_storage/activity.rs b/moli-protocol/src/domains/dom_storage/activity.rs index 7e5e70a3e5..2b694f3b23 100644 --- a/moli-protocol/src/domains/dom_storage/activity.rs +++ b/moli-protocol/src/domains/dom_storage/activity.rs @@ -86,7 +86,7 @@ fn dom_storage_subscriptions_for_browser_context_owner( let mut subscriptions = Vec::new(); if let Some(subscription) = browser_context - .devtools_session_state + .devtools_session_state() .dom_storage_session_state .mutation_subscription() { @@ -97,7 +97,7 @@ fn dom_storage_subscriptions_for_browser_context_owner( } subscriptions.extend( browser_context - .auxiliary_devtools_session_states + .auxiliary_devtools_session_states() .iter() .filter_map(|(session_id, state)| { state @@ -107,26 +107,23 @@ fn dom_storage_subscriptions_for_browser_context_owner( }), ); for target in &browser_context.background_targets { - let Some(state) = browser_context.parked_page_session_state(target.target_id()) else { + let Some((primary, auxiliary)) = + browser_context.devtools_session_states_for_target(target.target_id()) + else { continue; }; if let (Some(session_id), Some(subscription)) = ( - target.session_id(), - state - .devtools_session_state - .dom_storage_session_state - .mutation_subscription(), + browser_context.primary_session_id_for_target(target.target_id()), + primary.dom_storage_session_state.mutation_subscription(), ) { subscriptions.push((Some(session_id.to_owned()), subscription.clone())); } - subscriptions.extend(state.auxiliary_devtools_session_states.iter().filter_map( - |(session_id, state)| { - state - .dom_storage_session_state - .mutation_subscription() - .map(|subscription| (Some(session_id.clone()), subscription.clone())) - }, - )); + subscriptions.extend(auxiliary.iter().filter_map(|(session_id, state)| { + state + .dom_storage_session_state + .mutation_subscription() + .map(|subscription| (Some(session_id.clone()), subscription.clone())) + })); } subscriptions.sort_by(|left, right| left.0.cmp(&right.0)); subscriptions diff --git a/moli-protocol/src/domains/dom_storage/commands.rs b/moli-protocol/src/domains/dom_storage/commands.rs index aba6ec592f..2341cba0ae 100644 --- a/moli-protocol/src/domains/dom_storage/commands.rs +++ b/moli-protocol/src/domains/dom_storage/commands.rs @@ -303,7 +303,7 @@ fn complete_top_frame_resolution( }; let mut route_scope = owner_scope.enter(conn); let storage_key = { - let Ok(page) = route_scope + let Ok(mut page) = route_scope .conn_mut() .loaded_page_mut_for_protocol_access(owner_scope.session_id()) else { @@ -374,7 +374,7 @@ fn complete_child_frame_resolution( }; let mut route_scope = owner_scope.enter(conn); let child_frames = { - let Ok(page) = route_scope + let Ok(mut page) = route_scope .conn_mut() .loaded_page_mut_for_protocol_access(owner_scope.session_id()) else { diff --git a/moli-protocol/src/domains/dom_storage/tests.rs b/moli-protocol/src/domains/dom_storage/tests.rs index 1ed43e6423..455407ba45 100644 --- a/moli-protocol/src/domains/dom_storage/tests.rs +++ b/moli-protocol/src/domains/dom_storage/tests.rs @@ -116,7 +116,7 @@ async fn loaded_dom_storage_context() -> (TestContext, String, String, tokio::ta browser_context.set_target_url(page_url.clone()); browser_context.set_target_security_origin(origin.clone()); browser_context.set_target_secure_context_type("Secure".to_owned()); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); let page = ctx .conn @@ -452,7 +452,7 @@ async fn dom_storage_resolves_child_frame_storage_ids_without_collapsing_to_top_ browser_context.set_target_url(page_url.clone()); browser_context.set_target_security_origin(top_origin.clone()); browser_context.set_target_secure_context_type("Secure".to_owned()); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); let page = ctx .conn .load_page_via_runtime_async(&page_url) diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index 6e26c4805e..db21f30bf3 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -880,7 +880,7 @@ fn start_clear_device_metrics_override_command( } }; match start_runtime_emulation_protocol_message( - page, + &page, runtime_call_id, device::LIVE_DEVICE_METRICS_CLEAR_SCRIPT.to_owned(), ) { @@ -949,7 +949,7 @@ fn start_devtools_set_viewport_command( let script = device::live_device_metrics_override_script(&metrics, !had_existing_device_metrics); let (pending_runtime, runtime_response_rx) = - start_runtime_emulation_protocol_message(page, runtime_call_id, script) + start_runtime_emulation_protocol_message(&page, runtime_call_id, script) .map_err(|error| DevToolsError::new(DevToolsErrorKind::Internal, error))?; Ok(Some(PendingEmulationCommandDispatch { command_id, @@ -1460,27 +1460,25 @@ fn start_extra_headers_update_for_route( }]) } -fn loaded_page_mut_for_pending_emulation_target<'a>( - conn: &'a mut CdpConnection, +fn loaded_page_mut_for_pending_emulation_target( + conn: &mut CdpConnection, target: &PendingEmulationPageTarget, -) -> Option<&'a moli_core::page::Page> { +) -> Option { match target { PendingEmulationPageTarget::BrowserContextActive { browser_context_id } => conn .browser_context_by_id_mut(browser_context_id) .and_then(|browser_context| { browser_context.active_target.runtime_slot.loaded_page_mut() - }) - .map(|page| &*page), + }), PendingEmulationPageTarget::BrowserContextBackground { browser_context_id, target_id, } => conn .browser_context_by_id_mut(browser_context_id) .and_then(|browser_context| browser_context.background_target_mut(target_id)) - .and_then(|target| target.loaded_page_mut()) - .map(|page| &*page), + .and_then(|target| target.loaded_page_mut()), PendingEmulationPageTarget::SessionOwner { owner_scope } => { - loaded_page_mut_for_session(conn, owner_scope.session_id()).map(|page| &*page) + loaded_page_mut_for_session(conn, owner_scope.session_id()) } } } @@ -1793,7 +1791,7 @@ fn start_locale_update_for_current_route( let Some(page) = loaded_page_mut_for_session(conn, None) else { return Ok(Vec::new()); }; - start_locale_override_page_commands(target, page, &headers, locale_override.as_deref()) + start_locale_override_page_commands(target, &page, &headers, locale_override.as_deref()) .map_err(|error| DevToolsError::new(DevToolsErrorKind::Internal, error)) } @@ -1978,7 +1976,7 @@ fn resolve_bidi_browser_context_ids( .collect::>(); if default_context_ids.is_empty() { let id = conn.default_browser_context_id().to_owned(); - conn.insert_browser_context(conn.new_browser_context(id.clone())); + conn.try_insert_browser_context(conn.new_browser_context(id.clone()))?; default_context_ids.push(id); } resolved.extend(default_context_ids); @@ -2245,7 +2243,7 @@ fn resolve_set_viewport_browser_context_ids( .collect::>(); if default_context_ids.is_empty() { let id = conn.default_browser_context_id().to_owned(); - conn.insert_browser_context(conn.new_browser_context(id.clone())); + conn.try_insert_browser_context(conn.new_browser_context(id.clone()))?; default_context_ids.push(id); } resolved.extend(default_context_ids); @@ -2323,7 +2321,7 @@ fn start_browser_context_default_device_metrics_page_commands( runtime_response_rx: None, }); let (pending_runtime, runtime_response_rx) = start_runtime_emulation_protocol_message( - page, + &page, runtime_call_ids.pop().ok_or_else(|| { DevToolsError::new(DevToolsErrorKind::Internal, "MissingRuntimeInspectorCallId") })?, @@ -2366,7 +2364,7 @@ fn start_browser_context_default_device_metrics_page_commands( runtime_response_rx: None, }); let (pending_runtime, runtime_response_rx) = start_runtime_emulation_protocol_message( - page, + &page, runtime_call_ids.pop().ok_or_else(|| { DevToolsError::new(DevToolsErrorKind::Internal, "MissingRuntimeInspectorCallId") })?, @@ -2514,10 +2512,10 @@ pub(crate) fn complete_pending_emulation_command( CommandOutputPlan::result(json!({})) } -fn loaded_page_mut_for_session<'a>( - conn: &'a mut CdpConnection, +fn loaded_page_mut_for_session( + conn: &mut CdpConnection, session_id: Option<&str>, -) -> Option<&'a mut moli_core::page::Page> { +) -> Option { conn.loaded_page_mut_for_protocol_access(session_id).ok() } @@ -2543,7 +2541,7 @@ pub(crate) async fn clear_emulated_media_for_detached_session_async( } let page_overrides: moli_core::page::EmulatedMediaOverrides = (&overrides).into(); - let Some(page) = loaded_page_mut_for_session(conn, Some(session_id)) else { + let Some(mut page) = loaded_page_mut_for_session(conn, Some(session_id)) else { return Ok(()); }; page.set_emulated_media_async(&page_overrides) @@ -2635,7 +2633,7 @@ fn start_session_locale_override_page_commands( }; start_locale_override_page_commands( PendingEmulationPageTarget::SessionOwner { owner_scope }, - page, + &page, &headers, locale_override.as_deref(), ) @@ -2658,7 +2656,7 @@ fn start_context_locale_override_page_commands( PendingEmulationPageTarget::BrowserContextActive { browser_context_id: browser_context_id.clone(), }, - page, + &page, &active_headers, active_locale.as_deref(), )?); @@ -2675,7 +2673,7 @@ fn start_context_locale_override_page_commands( browser_context_id: browser_context_id.clone(), target_id, }, - page, + &page, &active_headers, active_locale.as_deref(), )?); @@ -2704,7 +2702,7 @@ fn start_geolocation_surface_override_page_commands( }; start_surface_override_page_command( PendingEmulationPageTarget::BrowserContextActive { browser_context_id }, - page, + &page, script, runtime_call_id, ) @@ -2742,7 +2740,7 @@ fn start_session_surface_override_page_command( }; start_surface_override_page_command( PendingEmulationPageTarget::SessionOwner { owner_scope }, - page, + &page, script, runtime_call_id, ) @@ -2780,7 +2778,7 @@ fn start_surface_override_for_route( let Some(page) = loaded_page_mut_for_session(conn, None) else { return Ok(Vec::new()); }; - start_surface_override_page_command(target, page, script, runtime_call_id) + start_surface_override_page_command(target, &page, script, runtime_call_id) .map(|pending| vec![pending]) } @@ -2882,30 +2880,30 @@ fn finish_pending_emulation_page_command( completion, ); } - let page = + let mut page = loaded_page_mut_for_session(route_scope.conn_mut(), owner_scope.session_id()) .ok_or_else(|| "NoDocumentLoaded".to_owned())?; - finish_emulation_page_operation(page, operation, completion) + finish_emulation_page_operation(&mut page, operation, completion) } PendingEmulationPageTarget::BrowserContextActive { browser_context_id } => { - let page = conn + let mut page = conn .browser_context_by_id_mut(&browser_context_id) .and_then(|browser_context| { browser_context.active_target.runtime_slot.loaded_page_mut() }) .ok_or_else(|| "NoDocumentLoaded".to_owned())?; - finish_emulation_page_operation(page, operation, completion) + finish_emulation_page_operation(&mut page, operation, completion) } PendingEmulationPageTarget::BrowserContextBackground { browser_context_id, target_id, } => { - let page = conn + let mut page = conn .browser_context_by_id_mut(&browser_context_id) .and_then(|browser_context| browser_context.background_target_mut(&target_id)) .and_then(|target| target.loaded_page_mut()) .ok_or_else(|| "NoDocumentLoaded".to_owned())?; - finish_emulation_page_operation(page, operation, completion) + finish_emulation_page_operation(&mut page, operation, completion) } } } diff --git a/moli-protocol/src/domains/emulation/tests.rs b/moli-protocol/src/domains/emulation/tests.rs index f9e9b3540d..28b04df848 100644 --- a/moli-protocol/src/domains/emulation/tests.rs +++ b/moli-protocol/src/domains/emulation/tests.rs @@ -51,19 +51,20 @@ async fn load_session_page_for_pending_emulation_test_at_url(ctx: &mut TestConte let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - install_session_page_for_emulation_test(ctx, bc, url).await; + insert_browser_context_with_navigation(ctx, bc, url, Some("SID-1")).await; } -async fn install_session_page_for_emulation_test( +async fn insert_browser_context_with_navigation( ctx: &mut TestContext, - bc: BrowserContext, + browser_context: BrowserContext, url: &str, + session_id: Option<&str>, ) { - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(browser_context); // A production navigation binds the reserved renderer Page to its target // before output can arrive. Use the production-shaped fixture transaction // instead of inserting a bare Page and racing its first publication. - ctx.install_navigation_fixture_for_session_owner(url, Some("SID-1")) + ctx.install_navigation_fixture_for_session_owner(url, session_id) .await; } @@ -112,7 +113,7 @@ async fn bidi_set_extra_headers_merges_global_user_context_and_context_layers() let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); execute_set_extra_headers_for_test( &mut ctx, @@ -337,7 +338,7 @@ async fn idle_override_updates_idle_detector_and_clear_restores_actual_state() { .await; { - let page = ctx + let mut page = ctx .conn .browser_context .as_mut() @@ -383,7 +384,7 @@ async fn idle_override_updates_idle_detector_and_clear_restores_actual_state() { })); { - let page = ctx + let mut page = ctx .conn .browser_context .as_mut() @@ -420,7 +421,7 @@ async fn idle_override_updates_idle_detector_and_clear_restores_actual_state() { && message["result"] == json!({}) })); - let page = ctx + let mut page = ctx .conn .browser_context .as_mut() @@ -437,6 +438,7 @@ async fn idle_override_updates_idle_detector_and_clear_restores_actual_state() { "value": r#"["active","unlocked",["active/unlocked","idle/locked","active/unlocked"]]"# }) ); + drop(page); let set_raw = json!({ "id": 9106, @@ -481,7 +483,7 @@ async fn pure_state_emulation_commands_complete_through_command_dispatch() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); for (id, method, params) in [ ( @@ -531,7 +533,7 @@ async fn set_cpu_throttling_rate_rejects_invalid_params() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 9115, @@ -558,7 +560,7 @@ async fn live_apply_emulation_commands_without_loaded_page_do_not_use_legacy_fal let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); for (id, method, params) in [ ( @@ -627,7 +629,13 @@ async fn live_geolocation_override_uses_pending_command_dispatch() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - install_session_page_for_emulation_test(&mut ctx, bc, "data:text/html,geo").await; + insert_browser_context_with_navigation( + &mut ctx, + bc, + "data:text/html,geo", + Some("SID-1"), + ) + .await; let raw = json!({ "id": 9127, @@ -706,7 +714,8 @@ async fn set_timezone_override_without_loaded_browser_context_errors() { #[tokio::test(flavor = "multi_thread")] async fn async_emulation_device_state_updates_browser_context() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 501, @@ -861,7 +870,7 @@ async fn set_user_agent_override_applies_to_subsequent_navigation_requests() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 6, @@ -931,7 +940,7 @@ async fn emulation_user_agent_override_replaces_network_override() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 9, @@ -999,7 +1008,7 @@ async fn set_user_agent_override_applies_to_current_page_xhr_requests() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner( "data:text/html,ok", Some("SID-1"), @@ -1083,11 +1092,12 @@ async fn set_user_agent_override_applies_complete_chromium_identity_profile() { }); let mut ctx = TestContext::new(); + ctx.enable_background_navigation_scheduler_for_test(); let natural_identity = ctx.conn.base_browser_identity().clone(); let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 200, @@ -1121,13 +1131,21 @@ async fn set_user_agent_override_applies_complete_chromium_identity_profile() { .await; ctx.expect_result(200, json!({}), Some("SID-1")); - ctx.process_async(json!({ - "id": 201, - "method": "Page.navigate", - "sessionId": "SID-1", - "params": { "url": format!("http://{addr}/page") } - })) - .await; + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_async(json!({ + "id": 201, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/page") } + })) + .await; + ctx.wait_for_only_background_navigation_gate_to_settle( + "complete BrowserIdentity navigation", + ) + .await; + }) + .await; let _ = ctx.take_all(); ctx.process_async(json!({ @@ -1152,12 +1170,11 @@ async fn set_user_agent_override_applies_complete_chromium_identity_profile() { })) .await; let response = ctx.take_response_by_id(202); - let identity: serde_json::Value = serde_json::from_str( - response["result"]["result"]["value"] - .as_str() - .expect("identity result should be JSON"), - ) - .expect("identity result should parse"); + let identity_json = response["result"]["result"]["value"] + .as_str() + .unwrap_or_else(|| panic!("identity result should be JSON: {response}")); + let identity: serde_json::Value = + serde_json::from_str(identity_json).expect("identity result should parse"); assert_eq!(identity["userAgent"], json!("LinuxChrome/145")); assert_eq!(identity["platform"], json!("Linux x86_64")); assert_eq!(identity["language"], json!("fr-CA")); @@ -1363,7 +1380,13 @@ async fn emulation_async_dispatch_updates_live_page_user_agent_and_xhr_header() let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - install_session_page_for_emulation_test(&mut ctx, bc, "data:text/html,ok").await; + insert_browser_context_with_navigation( + &mut ctx, + bc, + "data:text/html,ok", + Some("SID-1"), + ) + .await; ctx.process_async(json!({ "id": 130, @@ -1455,7 +1478,7 @@ async fn emulation_async_dispatch_updates_live_page_surface_and_accept_language( let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&format!("http://{addr}/page"), Some("SID-1")) .await; @@ -1637,7 +1660,13 @@ async fn set_geolocation_override_updates_loaded_page_geolocation_surface() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - install_session_page_for_emulation_test(&mut ctx, bc, "data:text/html,ok").await; + insert_browser_context_with_navigation( + &mut ctx, + bc, + "data:text/html,ok", + Some("SID-1"), + ) + .await; ctx.process_async(json!({ "id": 87, @@ -1665,7 +1694,7 @@ async fn set_geolocation_override_applies_to_subsequent_navigation_surface() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 89, @@ -1700,7 +1729,13 @@ async fn set_geolocation_override_missing_position_reports_unavailable() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - install_session_page_for_emulation_test(&mut ctx, bc, "data:text/html,ok").await; + insert_browser_context_with_navigation( + &mut ctx, + bc, + "data:text/html,ok", + Some("SID-1"), + ) + .await; ctx.process_async(json!({ "id": 92, @@ -1732,7 +1767,13 @@ async fn clear_geolocation_override_restores_default_after_explicit_unavailable( speed: None, }, )); - install_session_page_for_emulation_test(&mut ctx, bc, "data:text/html,geo").await; + insert_browser_context_with_navigation( + &mut ctx, + bc, + "data:text/html,geo", + Some("SID-1"), + ) + .await; ctx.process_async(json!({ "id": 97, @@ -1785,7 +1826,13 @@ async fn set_geolocation_override_respects_denied_permission() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - install_session_page_for_emulation_test(&mut ctx, bc, "data:text/html,ok").await; + insert_browser_context_with_navigation( + &mut ctx, + bc, + "data:text/html,ok", + Some("SID-1"), + ) + .await; ctx.process_async(json!({ "id": 94, @@ -1817,7 +1864,7 @@ async fn device_metrics_override_updates_layout_metrics() { let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 12, @@ -1905,7 +1952,7 @@ async fn locale_override_updates_headers_and_navigator_language() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 14, @@ -1990,7 +2037,7 @@ async fn touch_and_timezone_overrides_apply_to_document_start_surface() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 16, @@ -2029,7 +2076,7 @@ async fn locale_and_timezone_overrides_apply_to_locale_date_formatting() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 181, @@ -2066,23 +2113,21 @@ async fn locale_and_timezone_overrides_apply_to_locale_date_formatting() { #[tokio::test(flavor = "multi_thread")] async fn context_emulated_media_applies_to_loaded_background_page_without_promotion() { let mut ctx = TestContext::new(); - let background = BackgroundTarget::new( + let page_url = "data:text/html,background"; + + let mut background = BackgroundTarget::new_with_frontend_session( "TID-background".to_owned(), Some("SID-background".to_owned()), TargetIdentityState::about_blank(), TargetPageSlot::empty_for_test_fixture(), ); + background.set_target_url(page_url.to_owned()); let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-active".to_owned()); bc.attach_active_session("SID-active"); bc.background_targets.push(background); - ctx.conn.browser_context = Some(bc); - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,background", - Some("SID-background"), - ) - .await; + insert_browser_context_with_navigation(&mut ctx, bc, page_url, Some("SID-background")).await; ctx.sent.clear(); ctx.process_async(json!({ @@ -2142,23 +2187,21 @@ async fn context_emulated_media_applies_to_loaded_background_page_without_promot #[tokio::test(flavor = "multi_thread")] async fn context_locale_override_applies_to_loaded_background_page_without_promotion() { let mut ctx = TestContext::new(); - let background = BackgroundTarget::new( + let page_url = "data:text/html,background"; + + let mut background = BackgroundTarget::new_with_frontend_session( "TID-background".to_owned(), Some("SID-background".to_owned()), TargetIdentityState::about_blank(), TargetPageSlot::empty_for_test_fixture(), ); + background.set_target_url(page_url.to_owned()); let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-active".to_owned()); bc.attach_active_session("SID-active"); bc.background_targets.push(background); - ctx.conn.browser_context = Some(bc); - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,background", - Some("SID-background"), - ) - .await; + insert_browser_context_with_navigation(&mut ctx, bc, page_url, Some("SID-background")).await; ctx.sent.clear(); ctx.process_async(json!({ @@ -2219,23 +2262,20 @@ async fn context_locale_override_applies_to_loaded_background_page_without_promo #[tokio::test(flavor = "multi_thread")] async fn session_emulation_routes_to_loaded_background_owner_without_promotion() { let mut ctx = TestContext::new(); - let background = BackgroundTarget::new( + let page_url = "data:text/html,background"; + let mut background = BackgroundTarget::new_with_frontend_session( "TID-background".to_owned(), Some("SID-background".to_owned()), - TargetIdentityState::about_blank(), + TargetIdentityState::with_url(page_url.to_owned()), TargetPageSlot::empty_for_test_fixture(), ); + background.set_target_url(page_url.to_owned()); let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-active".to_owned()); bc.attach_active_session("SID-active"); bc.background_targets.push(background); - ctx.conn.browser_context = Some(bc); - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,background", - Some("SID-background"), - ) - .await; + insert_browser_context_with_navigation(&mut ctx, bc, page_url, Some("SID-background")).await; ctx.sent.clear(); ctx.process_async(json!({ @@ -2348,13 +2388,13 @@ async fn emulated_media_updates_existing_media_query_list_matches() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); - ctx.install_navigation_fixture_for_session_owner( + insert_browser_context_with_navigation( + &mut ctx, + bc, "data:text/html,", Some("SID-1"), ) .await; - ctx.sent.clear(); ctx.process_async(json!({ "id": 184, @@ -2421,10 +2461,13 @@ async fn generated_surface_refresh_does_not_freeze_match_media_override() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); - ctx.install_navigation_fixture_for_session_owner("data:text/html,", Some("SID-1")) - .await; - ctx.sent.clear(); + insert_browser_context_with_navigation( + &mut ctx, + bc, + "data:text/html,", + Some("SID-1"), + ) + .await; ctx.process_async(json!({ "id": 187, @@ -2478,10 +2521,8 @@ async fn target_session_detach_clears_emulated_media_before_reattach() { let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); - ctx.conn.browser_context = Some(bc); - ctx.install_navigation_fixture_for_session_owner("data:text/html,", None) + insert_browser_context_with_navigation(&mut ctx, bc, "data:text/html,", None) .await; - ctx.sent.clear(); ctx.conn.register_top_level_page_target("TID-1"); ctx.process_async(json!({ @@ -2607,7 +2648,7 @@ async fn emulated_media_color_scheme_applies_to_match_media_surface() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 184, @@ -2643,7 +2684,7 @@ async fn active_document_start_surface_reports_active_focus_and_visibility() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 18, @@ -2666,7 +2707,7 @@ async fn focus_emulation_override_applies_to_document_start_surface() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 19, diff --git a/moli-protocol/src/domains/fetch.rs b/moli-protocol/src/domains/fetch.rs index a4e7f49f62..4f917f55ca 100644 --- a/moli-protocol/src/domains/fetch.rs +++ b/moli-protocol/src/domains/fetch.rs @@ -1,8 +1,11 @@ mod auth; mod body_stream; mod commands; +mod frontend_task; mod helpers; mod navigation; +mod navigation_decision; +mod navigation_resume; mod params; mod patterns; mod state; @@ -33,6 +36,9 @@ pub(crate) use crate::conn::FetchResourceTypeFilter; pub(crate) use crate::conn::PendingFetchAuthNavigation; #[cfg(test)] pub(crate) use crate::conn::PendingFetchNavigation; +pub use frontend_task::{ + CompletedDevToolsFetchCommand, DevToolsFetchCommandTaskStep, PendingDevToolsFetchCommand, +}; #[cfg(test)] pub(crate) use helpers::encode_basic_auth; pub(crate) use helpers::request_paused_background_event; @@ -47,6 +53,12 @@ pub(crate) use helpers::{ #[cfg(test)] pub(crate) use moli_fetch::url_pattern_matches; pub(crate) use navigation::continue_navigation_without_request_pause_into_buffer_async; +pub(crate) use navigation_decision::{ + CompletedPausedNavigationDecisionOwnerTask, PausedNavigationDecisionOwnerTaskStep, + PendingPausedNavigationDecisionOwnerTask, + complete_page_owned_paused_navigation_decision_owner_task, + start_page_owned_paused_navigation_decision_owner_task, +}; use params::EnableParams; use patterns::supported_pattern_config; pub(crate) use subresource::{ @@ -110,6 +122,9 @@ enum PendingFetchCommandKind { enum PendingFetchCommandOperation { Ready, Page(moli_core::page::PendingPageCommand), + BrowserOwnerPausedNavigationDecision( + crate::conn::PendingBrowserOwnerPausedNavigationDecisionCommand, + ), MaterializeResponseBody { request_id: String, transfer: Box, @@ -120,6 +135,9 @@ enum PendingFetchCommandOperation { enum CompletedFetchCommandOperation { Ready, Page(Box>), + BrowserOwnerPausedNavigationDecision( + Box>, + ), MaterializeResponseBody { request_id: String, result: Box< @@ -168,6 +186,28 @@ impl FetchCommandOutput { self.plan.extend(plan); } + fn extend_browser_owner_projection_as_command_response( + &mut self, + completed: crate::conn::CompletedBrowserOwnerPausedNavigationDecisionCommand, + ) { + let (plan, mut command_context) = completed.into_parts(); + let (before_boundary, boundary, after_boundary) = + command_context.take_renderer_fenced_protocol_events(); + let mut detached_projection = CommandOutputPlan::default(); + detached_projection.extend_background_events(before_boundary); + if let Some(boundary) = boundary { + detached_projection.insert_renderer_output_boundary(boundary); + } + detached_projection.extend_background_events(after_boundary); + detached_projection + .extend_post_response_events(command_context.take_post_response_events()); + if let Some(predecessor) = command_context.take_renderer_output_predecessor() { + detached_projection.set_renderer_output_predecessor(predecessor); + } + detached_projection.extend(plan); + self.extend_plan_as_command_response(detached_projection); + } + fn extend_plan_as_background_events( &mut self, plan: CommandOutputPlan, @@ -238,12 +278,24 @@ impl PendingFetchCommandDispatch { } } + pub(crate) fn holds_navigation_renderer_publication_gate(&self) -> bool { + matches!( + self.pending, + PendingFetchCommandOperation::BrowserOwnerPausedNavigationDecision(_) + ) + } + pub(crate) async fn wait(self) -> CompletedFetchCommandDispatch { let completed = match self.pending { PendingFetchCommandOperation::Ready => CompletedFetchCommandOperation::Ready, PendingFetchCommandOperation::Page(pending) => CompletedFetchCommandOperation::Page( Box::new(pending.wait().await.map_err(|error| error.to_string())), ), + PendingFetchCommandOperation::BrowserOwnerPausedNavigationDecision(pending) => { + CompletedFetchCommandOperation::BrowserOwnerPausedNavigationDecision(Box::new( + pending.wait().await, + )) + } PendingFetchCommandOperation::MaterializeResponseBody { request_id, transfer, @@ -282,13 +334,26 @@ impl CompletedFetchCommandOperation { .ok() .and_then(moli_core::page::CompletedPageCommand::renderer_output_predecessor), Self::Ready | Self::MaterializeResponseBody { .. } => None, + Self::BrowserOwnerPausedNavigationDecision(_) => None, } } fn into_page_completion(self) -> Option> { match self { Self::Page(completed) => Some(*completed), - Self::Ready | Self::MaterializeResponseBody { .. } => None, + Self::Ready + | Self::BrowserOwnerPausedNavigationDecision(_) + | Self::MaterializeResponseBody { .. } => None, + } + } + + fn into_browser_owner_paused_navigation_decision( + self, + ) -> Option> + { + match self { + Self::BrowserOwnerPausedNavigationDecision(completed) => Some(*completed), + Self::Ready | Self::Page(_) | Self::MaterializeResponseBody { .. } => None, } } } @@ -349,36 +414,57 @@ pub(crate) async fn execute_devtools_fetch_command_async_with_protocol_events( None, owner_session_id.as_deref(), command, + false, ) }; match step { - FetchCommandTaskStep::Complete(mut plan) => { - let renderer_output_predecessor = plan.take_renderer_output_predecessor(); - let (status, events) = plan.into_command_status_and_background_events(); - DevToolsCommandExecutionOutput::from_parts( - status - .unwrap_or_else(|| { - Err(DevToolsError::new( - DevToolsErrorKind::Internal, - "MissingFetchCommandResponse", - )) - }) - .map(|()| success_result), - events, - renderer_output_predecessor, - ) + FetchCommandTaskStep::Complete(plan) => { + devtools_fetch_execution_output_from_plan(plan, success_result) } FetchCommandTaskStep::Pending(pending) => { let completed = pending.wait().await; let mut route_scope = conn.scoped_optional_none_session_owner_route_override(owner_route); - complete_pending_devtools_fetch_command(route_scope.conn_mut(), completed) - .await - .into_devtools_result_and_background_events(success_result) + complete_pending_devtools_fetch_execution_output( + route_scope.conn_mut(), + completed, + success_result, + ) + .await } } } +fn devtools_fetch_execution_output_from_plan( + mut plan: CommandOutputPlan, + success_result: DevToolsCommandResult, +) -> DevToolsCommandExecutionOutput { + let renderer_output_predecessor = plan.take_renderer_output_predecessor(); + let (status, events) = plan.into_command_status_and_background_events(); + DevToolsCommandExecutionOutput::from_parts( + status + .unwrap_or_else(|| { + Err(DevToolsError::new( + DevToolsErrorKind::Internal, + "MissingFetchCommandResponse", + )) + }) + .map(|()| success_result), + events, + renderer_output_predecessor, + ) +} + +async fn complete_pending_devtools_fetch_execution_output( + conn: &mut CdpConnection, + completed: CompletedFetchCommandDispatch, + success_result: DevToolsCommandResult, +) -> DevToolsCommandExecutionOutput { + complete_pending_devtools_fetch_command(conn, completed) + .await + .into_devtools_result_and_background_events(success_result) +} + fn devtools_fetch_success_result(command: &DevToolsCommand) -> DevToolsCommandResult { match command { DevToolsCommand::AddNetworkIntercept(command) => { @@ -395,6 +481,7 @@ fn start_devtools_fetch_command( command_id: Option, command_session_id: Option<&str>, command: DevToolsCommand, + admit_main_document_to_browser_owner: bool, ) -> FetchCommandTaskStep { match &command { DevToolsCommand::AddNetworkIntercept(command) => { @@ -415,7 +502,13 @@ fn start_devtools_fetch_command( && command.context.target_id.is_none(), ) } - _ => commands::start_devtools_fetch_command(conn, command_id, command_session_id, command), + _ => commands::start_devtools_fetch_command( + conn, + command_id, + command_session_id, + command, + admit_main_document_to_browser_owner, + ), } } @@ -727,7 +820,7 @@ async fn complete_pending_fetch_command_inner( commands::complete_continue_request_command_async( conn, completed.session_id.as_deref(), - completed.completed.into_page_completion(), + completed.completed, *state, &mut out, ) @@ -737,7 +830,7 @@ async fn complete_pending_fetch_command_inner( auth::complete_continue_with_auth_command_async( conn, completed.session_id.as_deref(), - completed.completed.into_page_completion(), + completed.completed, *state, &mut out, ) @@ -747,7 +840,7 @@ async fn complete_pending_fetch_command_inner( commands::complete_fail_request_command_async( conn, completed.session_id.as_deref(), - completed.completed.into_page_completion(), + completed.completed, *state, &mut out, ) @@ -757,7 +850,7 @@ async fn complete_pending_fetch_command_inner( commands::complete_fulfill_request_command_async( conn, completed.session_id.as_deref(), - completed.completed.into_page_completion(), + completed.completed, *state, &mut out, ) @@ -785,7 +878,7 @@ async fn complete_pending_fetch_command_inner( commands::complete_continue_response_command_async( conn, completed.session_id.as_deref(), - completed.completed.into_page_completion(), + completed.completed, *state, &mut out, ) @@ -822,7 +915,7 @@ fn complete_fetch_config_update_command( Ok(completion) => completion, Err(error) => return CommandOutputPlan::error(-32000, error), }; - let page = match conn.loaded_page_mut_for_protocol_access(completed.session_id.as_deref()) { + let mut page = match conn.loaded_page_mut_for_protocol_access(completed.session_id.as_deref()) { Ok(page) => page, Err(message) if message == "NoDocumentLoaded" => { return CommandOutputPlan::from_devtools_result(result); @@ -880,7 +973,7 @@ async fn complete_disable_command_async( } }; match conn.loaded_page_mut_for_protocol_access(session_id) { - Ok(page) => { + Ok(mut page) => { if let Err(error) = page.finish_set_fetch_subresource_interception(completion) { out.push_error( -32000, diff --git a/moli-protocol/src/domains/fetch/auth.rs b/moli-protocol/src/domains/fetch/auth.rs index 39661e494e..ec5c0fb7c5 100644 --- a/moli-protocol/src/domains/fetch/auth.rs +++ b/moli-protocol/src/domains/fetch/auth.rs @@ -1,16 +1,18 @@ use crate::conn::{ - BackgroundProtocolEvent, CdpConnection, Cmd, PendingFetchAuthNavigation, - PendingFetchNavigation, PendingSubresourceFetchAuthRequest, PendingSubresourceFetchRequest, + BackgroundProtocolEvent, BrowserOwnerPausedNavigationSidecar, CdpConnection, Cmd, + CommandDispatchContext, PendingFetchAuthNavigation, PendingFetchNavigation, + PendingSubresourceFetchAuthRequest, PendingSubresourceFetchRequest, + TargetPageResidenceIdentity, }; use crate::devtools_runtime::{ - DevToolsAuthChallengeAction, DevToolsCommand, DevToolsContinueWithAuthCommand, - DevToolsProtocol, DevToolsRequestId, + DevToolsAuthChallengeAction, DevToolsContinueWithAuthCommand, DevToolsProtocol, + DevToolsRequestId, }; use crate::domains::command_output::CommandOutputPlan; use crate::domains::{activity, network}; +use moli_core::browser_host::BrowserPausedNavigationDecision; use moli_core::page::{CompletedPageCommand, SubresourceAuthCredentials}; -use super::PendingFetchCommandOperation; use super::helpers::{ pending_fetch_auth_navigation_required_event, pending_subresource_auth_required_event, request_auth_for_challenge, @@ -26,11 +28,13 @@ use super::state::{ take_pending_auth_navigation_for_action_session, take_pending_subresource_auth_request_for_action_session, }; +use super::{CompletedFetchCommandOperation, PendingFetchCommandOperation}; use super::{ FetchCommandOutput, FetchCommandTaskStep, PendingFetchCommandDispatch, PendingFetchCommandKind, }; pub(super) enum PendingContinueWithAuthState { + BrowserOwnerNavigationDecision, SubresourceAuthCancel { pending: Box, correlation: Option, @@ -67,12 +71,7 @@ pub(super) fn start_continue_with_auth_command( } }; let command = build_cdp_continue_with_auth_command(conn, cmd, params); - super::commands::start_devtools_fetch_command( - conn, - cmd.id, - cmd.session_id, - DevToolsCommand::ContinueWithAuth(command), - ) + start_devtools_continue_with_auth_command(conn, cmd.id, cmd.session_id, &command, true) } fn build_cdp_continue_with_auth_command( @@ -108,12 +107,14 @@ pub(super) fn start_devtools_continue_with_auth_command( command_id: Option, command_session_id: Option<&str>, command: &DevToolsContinueWithAuthCommand, + admit_to_browser_owner: bool, ) -> FetchCommandTaskStep { if let Some(step) = start_devtools_continue_with_auth_command_for_pending( conn, command_id, command_session_id, command, + admit_to_browser_owner, ) { return step; } @@ -131,6 +132,7 @@ pub(super) fn start_devtools_continue_with_auth_command_for_pending( command_id: Option, command_session_id: Option<&str>, command: &DevToolsContinueWithAuthCommand, + admit_to_browser_owner: bool, ) -> Option { let request_id = command.request_id.as_str().to_owned(); let action_session_id = action_session_id_for_devtools_context( @@ -327,43 +329,80 @@ pub(super) fn start_devtools_continue_with_auth_command_for_pending( &request_id, )?; - Some(match command.action { - DevToolsAuthChallengeAction::Default - if pending - .auth_stage_pause_state() - .is_some_and(|chain| !chain.remaining_sessions.is_empty()) => - { - FetchCommandTaskStep::Complete( - chained_navigation_auth_required_output_plan(conn, command_session_id, pending) - .unwrap_or_else(|| CommandOutputPlan::error(-32000, "RequestNotFound")), - ) - } - DevToolsAuthChallengeAction::Default => { - FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( - conn, - command_id, - command_session_id, - PendingFetchCommandKind::ContinueWithAuth { - state: Box::new(PendingContinueWithAuthState::NavigationFail { - pending: Box::new(pending), - }), - }, - PendingFetchCommandOperation::Ready, - )) - } - DevToolsAuthChallengeAction::Cancel => { - FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( - conn, - command_id, - command_session_id, - PendingFetchCommandKind::ContinueWithAuth { - state: Box::new(PendingContinueWithAuthState::NavigationCancel { - pending: Box::new(pending), - }), - }, - PendingFetchCommandOperation::Ready, - )) - } + if matches!(command.action, DevToolsAuthChallengeAction::Default) + && pending + .auth_stage_pause_state() + .is_some_and(|chain| !chain.remaining_sessions.is_empty()) + { + return Some(FetchCommandTaskStep::Complete( + chained_navigation_auth_required_output_plan(conn, command_session_id, pending) + .unwrap_or_else(|| CommandOutputPlan::error(-32000, "RequestNotFound")), + )); + } + + if !admit_to_browser_owner { + return Some(match command.action { + DevToolsAuthChallengeAction::Default => { + FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::ContinueWithAuth { + state: Box::new(PendingContinueWithAuthState::NavigationFail { + pending: Box::new(pending), + }), + }, + PendingFetchCommandOperation::Ready, + )) + } + DevToolsAuthChallengeAction::Cancel => { + FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::ContinueWithAuth { + state: Box::new(PendingContinueWithAuthState::NavigationCancel { + pending: Box::new(pending), + }), + }, + PendingFetchCommandOperation::Ready, + )) + } + DevToolsAuthChallengeAction::ProvideCredentials => { + let Some(auth) = request_auth_for_challenge( + &pending.challenge, + command.username.as_deref().unwrap_or_default(), + command.password.as_deref().unwrap_or_default(), + ) else { + conn.register_pending_fetch_auth_navigation_for_session_owner( + command_session_id, + request_id.clone(), + pending, + ); + return Some(FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "NotImplemented", + ))); + }; + FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::ContinueWithAuth { + state: Box::new(PendingContinueWithAuthState::NavigationContinue { + pending: Box::new(pending), + auth, + }), + }, + PendingFetchCommandOperation::Ready, + )) + } + }); + } + + let decision = match command.action { + DevToolsAuthChallengeAction::Default => BrowserPausedNavigationDecision::fail_auth(), + DevToolsAuthChallengeAction::Cancel => BrowserPausedNavigationDecision::cancel_auth(), DevToolsAuthChallengeAction::ProvideCredentials => { let Some(auth) = request_auth_for_challenge( &pending.challenge, @@ -372,7 +411,7 @@ pub(super) fn start_devtools_continue_with_auth_command_for_pending( ) else { conn.register_pending_fetch_auth_navigation_for_session_owner( command_session_id, - request_id.clone(), + request_id, pending, ); return Some(FetchCommandTaskStep::Complete(CommandOutputPlan::error( @@ -380,22 +419,114 @@ pub(super) fn start_devtools_continue_with_auth_command_for_pending( "NotImplemented", ))); }; - FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( - conn, - command_id, - command_session_id, - PendingFetchCommandKind::ContinueWithAuth { - state: Box::new(PendingContinueWithAuthState::NavigationContinue { - pending: Box::new(pending), - auth, - }), - }, - PendingFetchCommandOperation::Ready, + BrowserPausedNavigationDecision::continue_auth(auth) + } + }; + let Some(page_owner) = page_owner_for_pending_auth_navigation(conn, &pending) else { + let restored_request_id = pending.fetch_request_id.clone(); + if !conn.register_pending_fetch_auth_navigation_for_session_owner( + command_session_id, + restored_request_id, + pending, + ) { + tracing::error!( + request_id, + "failed to restore auth-paused navigation after Page owner capture failure" + ); + } + return Some(FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "BrowserHostPausedNavigationDecisionAdmissionFailed: PageNotFound", + ))); + }; + let publication = conn.publish_browser_owner_paused_navigation_auth_decision( + page_owner.clone(), + pending, + decision, + CommandDispatchContext::default(), + ); + Some(match publication { + Ok(pending) => FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::ContinueWithAuth { + state: Box::new(PendingContinueWithAuthState::BrowserOwnerNavigationDecision), + }, + PendingFetchCommandOperation::BrowserOwnerPausedNavigationDecision(pending), + )), + Err(failure) => { + let (error, pending) = failure.into_parts(); + if let Some(pending) = pending { + match pending { + BrowserOwnerPausedNavigationSidecar::Auth(pending) => { + restore_pending_auth_navigation_after_owner_publication_failure( + conn, + &page_owner, + command_session_id, + pending, + ); + } + BrowserOwnerPausedNavigationSidecar::Request(_) => { + tracing::error!( + "continueWithAuth publication returned a request-navigation sidecar" + ); + } + BrowserOwnerPausedNavigationSidecar::Response(_) => { + tracing::error!( + "continueWithAuth publication returned a response-navigation sidecar" + ); + } + } + } + FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + format!("BrowserHostPausedNavigationDecisionAdmissionFailed: {error}"), )) } }) } +fn page_owner_for_pending_auth_navigation( + conn: &mut CdpConnection, + pending: &PendingFetchAuthNavigation, +) -> Option { + let route = conn + .target_session_route_for_target_id(&pending.navigation.frame_id) + .or_else(|| conn.session_route(pending.navigation.navigate_session_id.as_deref()))?; + let mut owner_scope = conn.scoped_none_session_owner_route_override(route); + owner_scope + .conn_mut() + .target_page_residence_identity_for_session(None) +} + +fn restore_pending_auth_navigation_after_owner_publication_failure( + conn: &mut CdpConnection, + page_owner: &TargetPageResidenceIdentity, + fallback_session_id: Option<&str>, + pending: PendingFetchAuthNavigation, +) { + let request_id = pending.fetch_request_id.clone(); + let restored = if let Some(route) = conn.target_page_owner_route_if_current(page_owner) { + let mut owner_scope = conn.scoped_none_session_owner_route_override(route); + owner_scope + .conn_mut() + .register_pending_fetch_auth_navigation_for_session_owner(None, request_id, pending) + } else { + conn.register_pending_fetch_auth_navigation_for_session_owner( + fallback_session_id, + request_id, + pending, + ) + }; + if !restored { + tracing::error!( + target_id = page_owner.target_id(), + "failed to restore auth-paused navigation after Browser Host publication failure" + ); + } +} + fn continued_subresource_request( pending: &PendingSubresourceFetchAuthRequest, ) -> PendingSubresourceFetchRequest { @@ -522,11 +653,21 @@ fn next_chained_subresource_auth_required_event( pub(super) async fn complete_continue_with_auth_command_async( conn: &mut CdpConnection, session_id: Option<&str>, - completed: Option>, + completed: CompletedFetchCommandOperation, state: PendingContinueWithAuthState, out: &mut FetchCommandOutput, ) { match state { + PendingContinueWithAuthState::BrowserOwnerNavigationDecision => { + let Some(completed) = completed.into_browser_owner_paused_navigation_decision() else { + out.push_error(-32000, "Missing Browser Owner completion"); + return; + }; + match completed { + Ok(completed) => out.extend_browser_owner_projection_as_command_response(completed), + Err(error) => out.push_error(-32000, error), + } + } PendingContinueWithAuthState::SubresourceAuthCancel { pending, correlation, @@ -534,7 +675,7 @@ pub(super) async fn complete_continue_with_auth_command_async( complete_subresource_auth_terminal_async( conn, session_id, - completed, + completed.into_page_completion(), *pending, true, correlation, @@ -544,12 +685,20 @@ pub(super) async fn complete_continue_with_auth_command_async( } PendingContinueWithAuthState::SubresourceAuthFail { pending } => { complete_subresource_auth_terminal_async( - conn, session_id, completed, *pending, false, None, out, + conn, + session_id, + completed.into_page_completion(), + *pending, + false, + None, + out, ) .await; } PendingContinueWithAuthState::SubresourceAuthContinue { correlation } => { - if let Err(error) = finish_continue_subresource_auth(conn, session_id, completed) { + if let Err(error) = + finish_continue_subresource_auth(conn, session_id, completed.into_page_completion()) + { correlation.rollback(conn, session_id); out.push_error(-32000, error); return; @@ -634,10 +783,10 @@ async fn complete_subresource_auth_terminal_async( } }; let result = match conn.loaded_page_mut_for_protocol_access(activity_session_id) { - Ok(page) if expose_challenged_response => page + Ok(mut page) if expose_challenged_response => page .finish_cancel_pending_subresource_auth(completion) .map(|_| ()), - Ok(page) => page + Ok(mut page) => page .finish_fail_pending_subresource_auth(completion) .map(|_| ()), Err(message) => { @@ -676,7 +825,7 @@ fn finish_continue_subresource_auth( completed: Option>, ) -> Result<(), String> { let completion = completed.ok_or_else(|| "Missing renderer completion".to_owned())??; - let page = conn.loaded_page_mut_for_protocol_access(session_id)?; + let mut page = conn.loaded_page_mut_for_protocol_access(session_id)?; page.finish_continue_pending_subresource_auth(completion) .map(|_| ()) .map_err(|error| format!("subresource auth continue failed: {error}")) diff --git a/moli-protocol/src/domains/fetch/commands.rs b/moli-protocol/src/domains/fetch/commands.rs index 55ada6f896..43f425c4f6 100644 --- a/moli-protocol/src/domains/fetch/commands.rs +++ b/moli-protocol/src/domains/fetch/commands.rs @@ -1,6 +1,7 @@ use crate::conn::{ - BackgroundNavigationBodyCompletionSink, CapturedBody, CdpConnection, Cmd, DEFAULT_LOADER_ID, - PendingStreamingDocumentResponseNavigation, monotonic_timestamp_seconds, + BrowserOwnerPausedNavigationSidecar, CapturedBody, CdpConnection, Cmd, CommandDispatchContext, + DEFAULT_LOADER_ID, PendingFetchNavigation, TargetPageResidenceIdentity, + monotonic_timestamp_seconds, }; use crate::devtools_runtime::{ DevToolsAuthChallengeAction, DevToolsCommand, DevToolsContinueInterceptedRequestCommand, @@ -10,6 +11,7 @@ use crate::devtools_runtime::{ }; use crate::domains::command_output::CommandOutputPlan; use crate::domains::{activity, network, page}; +use moli_core::browser_host::BrowserPausedNavigationDecision; use moli_core::page::{ CompletedPageCommand, RendererSyntheticResponseBody, SubresourceResourceType, }; @@ -24,6 +26,7 @@ use super::navigation::{ complete_tokened_materialized_navigation_as_background_events_async, load_or_pause_navigation_for_auth_as_background_events_async, }; +use super::navigation_resume::continue_streaming_document_response_in_background; use super::params::{ CloseWebSocketParams, ContinueRequestParams, ContinueResponseParams, DispatchWebSocketMessageParams, FailRequestParams, FulfillRequestParams, @@ -36,8 +39,8 @@ use super::state::{ take_pending_subresource_response_request_for_action_session, }; use super::{ - FetchCommandOutput, FetchCommandTaskStep, PendingFetchCommandDispatch, PendingFetchCommandKind, - PendingFetchCommandOperation, + CompletedFetchCommandOperation, FetchCommandOutput, FetchCommandTaskStep, + PendingFetchCommandDispatch, PendingFetchCommandKind, PendingFetchCommandOperation, }; const BLOCKED_BY_CLIENT_ERROR_TEXT: &str = "net::ERR_BLOCKED_BY_CLIENT"; @@ -55,6 +58,7 @@ pub(super) fn start_devtools_fetch_command( command_id: Option, command_session_id: Option<&str>, command: DevToolsCommand, + admit_main_document_to_browser_owner: bool, ) -> FetchCommandTaskStep { match command { DevToolsCommand::ContinueInterceptedRequest(command) => { @@ -63,6 +67,7 @@ pub(super) fn start_devtools_fetch_command( command_id, command_session_id, &command, + admit_main_document_to_browser_owner, ) } DevToolsCommand::ContinueInterceptedResponse(command) => { @@ -71,6 +76,7 @@ pub(super) fn start_devtools_fetch_command( command_id, command_session_id, &command, + admit_main_document_to_browser_owner, ) } DevToolsCommand::ContinueWithAuth(command) => { @@ -79,6 +85,7 @@ pub(super) fn start_devtools_fetch_command( command_id, command_session_id, &command, + admit_main_document_to_browser_owner, ) } DevToolsCommand::FailInterceptedRequest(command) => { @@ -87,6 +94,7 @@ pub(super) fn start_devtools_fetch_command( command_id, command_session_id, command, + admit_main_document_to_browser_owner, ) } DevToolsCommand::FulfillInterceptedRequest(command) => { @@ -95,6 +103,7 @@ pub(super) fn start_devtools_fetch_command( command_id, command_session_id, command, + admit_main_document_to_browser_owner, ) } _ => FetchCommandTaskStep::Complete(CommandOutputPlan::error( @@ -128,12 +137,13 @@ fn parse_continue_request_url(raw_url: &str) -> Result { } pub(super) enum PendingContinueRequestState { + BrowserOwnerNavigationDecision, + CompatibilityNavigation { + pending: Box, + }, SubresourceFetch { correlation: PreparedSubresourceCorrelation, }, - Navigation { - pending: Box, - }, } pub(super) fn start_continue_request_command( @@ -158,11 +168,12 @@ pub(super) fn start_continue_request_command( )); } }; - start_devtools_fetch_command( + start_devtools_continue_intercepted_request_command( conn, cmd.id, cmd.session_id, - DevToolsCommand::ContinueInterceptedRequest(command), + &command, + true, ) } @@ -214,6 +225,7 @@ fn start_devtools_continue_intercepted_request_command( command_id: Option, command_session_id: Option<&str>, command: &DevToolsContinueInterceptedRequestCommand, + admit_to_browser_owner: bool, ) -> FetchCommandTaskStep { let parsed_url = match parsed_continue_request_url(command) { Ok(parsed_url) => parsed_url, @@ -355,45 +367,96 @@ fn start_devtools_continue_intercepted_request_command( PendingFetchCommandOperation::Page(pending_page), )); } - if let Some(mut pending) = + if let Some(pending) = take_pending_navigation(conn, command_session_id, action_session_id, &request_id) { - if command.intercept_response { - pending.intercept_response = true; - pending.response_stage_url_match_policy = - crate::conn::ResponseStageUrlMatchPolicy::AlreadyMatched; - } - if let Some(parsed) = parsed_url { - pending.navigation.requested_url = parsed; - } - if let Some(method) = command.method.clone() { - pending.navigation.request_method = method; - } - if let Some(body) = command.post_data.clone() { - pending.navigation.set_request_body_text(body); - } - if let Some(headers) = command.headers.clone() { - pending.navigation.request_headers = headers; + if !admit_to_browser_owner { + let mut pending = pending; + apply_continue_request_to_pending_navigation(conn, &mut pending, parsed_url, command); + return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::ContinueRequest { + state: Box::new(PendingContinueRequestState::CompatibilityNavigation { + pending: Box::new(pending), + }), + }, + PendingFetchCommandOperation::Ready, + )); } - pending.request_cookie_report = page::navigation_cookie_access_report( - conn, - &pending.navigation.requested_url, - &pending.navigation.request_method, - None, - pending.navigation.request_load_policy, - None, + let Some(page_owner) = page_owner_for_pending_navigation(conn, &pending) else { + let pending_request_id = pending.fetch_request_id.clone(); + if conn + .register_pending_fetch_navigation_request_for_session_owner( + command_session_id, + pending, + ) + .is_none() + { + tracing::error!( + request_id = %pending_request_id, + "failed to restore paused navigation after Page owner capture failure" + ); + } + return FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "BrowserHostPausedNavigationDecisionAdmissionFailed: PageNotFound", + )); + }; + let decision = BrowserPausedNavigationDecision::continue_request( + parsed_url, + command.method.clone(), + command.post_data.clone(), + command.headers.clone(), + command.intercept_response, ); - return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( - conn, - command_id, - command_session_id, - PendingFetchCommandKind::ContinueRequest { - state: Box::new(PendingContinueRequestState::Navigation { - pending: Box::new(pending), - }), - }, - PendingFetchCommandOperation::Ready, - )); + let publication = conn.publish_browser_owner_paused_navigation_decision( + page_owner.clone(), + pending, + decision, + CommandDispatchContext::default(), + ); + return match publication { + Ok(pending) => FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::ContinueRequest { + state: Box::new(PendingContinueRequestState::BrowserOwnerNavigationDecision), + }, + PendingFetchCommandOperation::BrowserOwnerPausedNavigationDecision(pending), + )), + Err(failure) => { + let (error, pending) = failure.into_parts(); + if let Some(pending) = pending { + match pending { + BrowserOwnerPausedNavigationSidecar::Request(pending) => { + restore_pending_navigation_after_owner_publication_failure( + conn, + &page_owner, + command_session_id, + pending, + ); + } + BrowserOwnerPausedNavigationSidecar::Auth(_) => { + tracing::error!( + "continueRequest publication returned an auth-navigation sidecar" + ); + } + BrowserOwnerPausedNavigationSidecar::Response(_) => { + tracing::error!( + "continueRequest publication returned a response-navigation sidecar" + ); + } + } + } + FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + format!("BrowserHostPausedNavigationDecisionAdmissionFailed: {error}"), + )) + } + }; } pending_request_action_output_plan( conn, @@ -409,12 +472,23 @@ fn start_devtools_continue_intercepted_request_command( pub(super) async fn complete_continue_request_command_async( conn: &mut CdpConnection, session_id: Option<&str>, - completed: Option>, + completed: CompletedFetchCommandOperation, state: PendingContinueRequestState, out: &mut FetchCommandOutput, ) { match state { + PendingContinueRequestState::BrowserOwnerNavigationDecision => { + let Some(completed) = completed.into_browser_owner_paused_navigation_decision() else { + out.push_error(-32000, "Missing Browser Owner completion"); + return; + }; + match completed { + Ok(completed) => out.extend_browser_owner_projection_as_command_response(completed), + Err(error) => out.push_error(-32000, error), + } + } PendingContinueRequestState::SubresourceFetch { correlation } => { + let completed = completed.into_page_completion(); if let Err(error) = finish_continue_subresource_request(conn, session_id, completed) { correlation.rollback(conn, session_id); out.push_error(-32000, error); @@ -422,7 +496,7 @@ pub(super) async fn complete_continue_request_command_async( } emit_devtools_empty_success(out); } - PendingContinueRequestState::Navigation { pending } => { + PendingContinueRequestState::CompatibilityNavigation { pending } => { emit_devtools_empty_success(out); load_or_pause_navigation_for_auth_as_background_events_async( conn, out, *pending, None, None, @@ -432,20 +506,54 @@ pub(super) async fn complete_continue_request_command_async( } } +fn apply_continue_request_to_pending_navigation( + conn: &mut CdpConnection, + pending: &mut PendingFetchNavigation, + parsed_url: Option, + command: &DevToolsContinueInterceptedRequestCommand, +) { + if command.intercept_response { + pending.intercept_response = true; + pending.response_stage_url_match_policy = + crate::conn::ResponseStageUrlMatchPolicy::AlreadyMatched; + } + if let Some(parsed) = parsed_url { + pending.navigation.requested_url = parsed; + } + if let Some(method) = command.method.clone() { + pending.navigation.request_method = method; + } + if let Some(body) = command.post_data.clone() { + pending.navigation.set_request_body_text(body); + } + if let Some(headers) = command.headers.clone() { + pending.navigation.request_headers = headers; + } + pending.request_cookie_report = page::navigation_cookie_access_report( + conn, + &pending.navigation.requested_url, + &pending.navigation.request_method, + None, + pending.navigation.request_load_policy, + None, + ); +} + fn finish_continue_subresource_request( conn: &mut CdpConnection, session_id: Option<&str>, completed: Option>, ) -> Result<(), String> { let completion = completed.ok_or_else(|| "Missing renderer completion".to_owned())??; - let page = conn.loaded_page_mut_for_protocol_access(session_id)?; + let mut page = conn.loaded_page_mut_for_protocol_access(session_id)?; page.finish_continue_pending_subresource_fetch(completion) .map(|_| ()) .map_err(|error| format!("subresource fetch continue failed: {error}")) } pub(super) enum PendingFailRequestState { - Navigation { + BrowserOwnerNavigationDecision, + CompatibilityNavigation { pending: Box, error_text: String, }, @@ -477,12 +585,7 @@ pub(super) fn start_fail_request_command( let error_text = navigation_fail_request_error_text(params.error_reason); let command = build_cdp_fail_intercepted_request_command(conn, cmd, params.request_id, error_text); - start_devtools_fetch_command( - conn, - cmd.id, - cmd.session_id, - DevToolsCommand::FailInterceptedRequest(command), - ) + start_devtools_fail_intercepted_request_command(conn, cmd.id, cmd.session_id, command, true) } fn build_cdp_fail_intercepted_request_command( @@ -505,6 +608,7 @@ fn start_devtools_fail_intercepted_request_command( command_id: Option, command_session_id: Option<&str>, command: DevToolsFailInterceptedRequestCommand, + admit_to_browser_owner: bool, ) -> FetchCommandTaskStep { let validate_request_id = command.context.protocol == DevToolsProtocol::Cdp; let request_id = command.request_id.into_string(); @@ -518,18 +622,91 @@ fn start_devtools_fail_intercepted_request_command( if let Some(pending) = take_pending_navigation(conn, command_session_id, action_session_id, &request_id) { - return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( - conn, - command_id, - command_session_id, - PendingFetchCommandKind::FailRequest { - state: Box::new(PendingFailRequestState::Navigation { - pending: Box::new(pending), - error_text, - }), - }, - PendingFetchCommandOperation::Ready, - )); + if !admit_to_browser_owner { + // Direct typed DevTools commands still execute inside one borrowed + // CdpConnection future. Until that frontend exposes its own + // scheduler-visible task step, publishing and awaiting a Browser + // Host reply here would prevent the Host from borrowing the + // physical executor. Raw/nested CDP uses the owner path below, + // including id-less notifications. + return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::FailRequest { + state: Box::new(PendingFailRequestState::CompatibilityNavigation { + pending: Box::new(pending), + error_text, + }), + }, + PendingFetchCommandOperation::Ready, + )); + } + let Some(page_owner) = page_owner_for_pending_navigation(conn, &pending) else { + let pending_request_id = pending.fetch_request_id.clone(); + if conn + .register_pending_fetch_navigation_request_for_session_owner( + command_session_id, + pending, + ) + .is_none() + { + tracing::error!( + request_id = %pending_request_id, + "failed to restore paused navigation after Page owner capture failure" + ); + } + return FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "BrowserHostPausedNavigationDecisionAdmissionFailed: PageNotFound", + )); + }; + let publication = conn.publish_browser_owner_paused_navigation_decision( + page_owner.clone(), + pending, + BrowserPausedNavigationDecision::fail(error_text), + CommandDispatchContext::default(), + ); + return match publication { + Ok(pending) => FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::FailRequest { + state: Box::new(PendingFailRequestState::BrowserOwnerNavigationDecision), + }, + PendingFetchCommandOperation::BrowserOwnerPausedNavigationDecision(pending), + )), + Err(failure) => { + let (error, pending) = failure.into_parts(); + if let Some(pending) = pending { + match pending { + BrowserOwnerPausedNavigationSidecar::Request(pending) => { + restore_pending_navigation_after_owner_publication_failure( + conn, + &page_owner, + command_session_id, + pending, + ); + } + BrowserOwnerPausedNavigationSidecar::Auth(_) => { + tracing::error!( + "failRequest publication returned an auth-navigation sidecar" + ); + } + BrowserOwnerPausedNavigationSidecar::Response(_) => { + tracing::error!( + "failRequest publication returned a response-navigation sidecar" + ); + } + } + } + FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + format!("BrowserHostPausedNavigationDecisionAdmissionFailed: {error}"), + )) + } + }; } if let Some(pending) = take_pending_subresource_fetch_request_for_action_session( @@ -648,18 +825,82 @@ fn start_devtools_fail_intercepted_request_command( &request_id, ) { - return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( - conn, - command_id, - command_session_id, - PendingFetchCommandKind::FailRequest { - state: Box::new(PendingFailRequestState::ResponseTransfer { - transfer: Box::new(transfer), - error_text, - }), - }, - PendingFetchCommandOperation::Ready, - )); + if !admit_to_browser_owner { + return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::FailRequest { + state: Box::new(PendingFailRequestState::ResponseTransfer { + transfer: Box::new(transfer), + error_text, + }), + }, + PendingFetchCommandOperation::Ready, + )); + } + let Some(page_owner) = page_owner_for_navigation_state(conn, transfer.navigation()) else { + let restored = conn.register_pending_fetch_response_transfer_for_session_owner( + command_session_id, + request_id, + transfer, + ); + if !restored { + tracing::error!( + "failed to restore failed response navigation after Page owner capture failure" + ); + } + return FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "BrowserHostPausedNavigationDecisionAdmissionFailed: PageNotFound", + )); + }; + let publication = conn.publish_browser_owner_paused_navigation_response_decision( + page_owner.clone(), + transfer, + BrowserPausedNavigationDecision::fail(error_text), + CommandDispatchContext::default(), + ); + return match publication { + Ok(pending) => FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::FailRequest { + state: Box::new(PendingFailRequestState::BrowserOwnerNavigationDecision), + }, + PendingFetchCommandOperation::BrowserOwnerPausedNavigationDecision(pending), + )), + Err(failure) => { + let (error, pending) = failure.into_parts(); + if let Some(pending) = pending { + match pending { + BrowserOwnerPausedNavigationSidecar::Response(transfer) => { + restore_pending_response_transfer_after_owner_publication_failure( + conn, + &page_owner, + command_session_id, + *transfer, + ); + } + BrowserOwnerPausedNavigationSidecar::Request(_) => { + tracing::error!( + "failRequest publication returned a request-navigation sidecar" + ); + } + BrowserOwnerPausedNavigationSidecar::Auth(_) => { + tracing::error!( + "failRequest publication returned an auth-navigation sidecar" + ); + } + } + } + FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + format!("BrowserHostPausedNavigationDecisionAdmissionFailed: {error}"), + )) + } + }; } pending_request_action_output_plan(conn, command_session_id, &request_id, validate_request_id) @@ -671,12 +912,22 @@ fn start_devtools_fail_intercepted_request_command( pub(super) async fn complete_fail_request_command_async( conn: &mut CdpConnection, session_id: Option<&str>, - completed: Option>, + completed: CompletedFetchCommandOperation, state: PendingFailRequestState, out: &mut FetchCommandOutput, ) { match state { - PendingFailRequestState::Navigation { + PendingFailRequestState::BrowserOwnerNavigationDecision => { + let Some(completed) = completed.into_browser_owner_paused_navigation_decision() else { + out.push_error(-32000, "Missing Browser Owner completion"); + return; + }; + match completed { + Ok(completed) => out.extend_browser_owner_projection_as_command_response(completed), + Err(error) => out.push_error(-32000, error), + } + } + PendingFailRequestState::CompatibilityNavigation { pending, error_text, } => { @@ -699,7 +950,7 @@ pub(super) async fn complete_fail_request_command_async( .await; } PendingFailRequestState::SubresourceFetch { pending } => { - let Some(completed) = completed else { + let Some(completed) = completed.into_page_completion() else { out.push_error(-32000, "Missing renderer completion"); return; }; @@ -711,7 +962,7 @@ pub(super) async fn complete_fail_request_command_async( } }; match conn.loaded_page_mut_for_protocol_access(session_id) { - Ok(page) => { + Ok(mut page) => { if let Err(error) = page.finish_fail_pending_subresource_fetch(completion) { out.push_error(-32000, format!("subresource fetch fail failed: {error}")); return; @@ -738,7 +989,7 @@ pub(super) async fn complete_fail_request_command_async( out.extend_background_events(events); } PendingFailRequestState::SubresourceResponse { pending } => { - let Some(completed) = completed else { + let Some(completed) = completed.into_page_completion() else { out.push_error(-32000, "Missing renderer completion"); return; }; @@ -750,7 +1001,7 @@ pub(super) async fn complete_fail_request_command_async( } }; match conn.loaded_page_mut_for_protocol_access(session_id) { - Ok(page) => { + Ok(mut page) => { if let Err(error) = page.finish_fail_pending_subresource_response(completion) { out.push_error( -32000, @@ -800,7 +1051,80 @@ pub(super) async fn complete_fail_request_command_async( } } +fn page_owner_for_pending_navigation( + conn: &mut CdpConnection, + pending: &PendingFetchNavigation, +) -> Option { + page_owner_for_navigation_state(conn, &pending.navigation) +} + +fn page_owner_for_navigation_state( + conn: &mut CdpConnection, + navigation: &crate::conn::NavigationDispatchState, +) -> Option { + let route = conn + .target_session_route_for_target_id(&navigation.frame_id) + .or_else(|| conn.session_route(navigation.navigate_session_id.as_deref()))?; + let mut owner_scope = conn.scoped_none_session_owner_route_override(route); + owner_scope + .conn_mut() + .target_page_residence_identity_for_session(None) +} + +fn restore_pending_navigation_after_owner_publication_failure( + conn: &mut CdpConnection, + page_owner: &TargetPageResidenceIdentity, + fallback_session_id: Option<&str>, + pending: PendingFetchNavigation, +) { + let restored = if let Some(route) = conn.target_page_owner_route_if_current(page_owner) { + let mut owner_scope = conn.scoped_none_session_owner_route_override(route); + owner_scope + .conn_mut() + .register_pending_fetch_navigation_request_for_session_owner(None, pending) + } else { + conn.register_pending_fetch_navigation_request_for_session_owner( + fallback_session_id, + pending, + ) + }; + if restored.is_none() { + tracing::error!( + target_id = page_owner.target_id(), + "failed to restore paused navigation after Browser Host publication failure" + ); + } +} + +fn restore_pending_response_transfer_after_owner_publication_failure( + conn: &mut CdpConnection, + page_owner: &TargetPageResidenceIdentity, + fallback_session_id: Option<&str>, + transfer: crate::conn::PausedDocumentTransfer, +) { + let request_id = transfer.fetch_request_id().to_owned(); + let restored = if let Some(route) = conn.target_page_owner_route_if_current(page_owner) { + let mut owner_scope = conn.scoped_none_session_owner_route_override(route); + owner_scope + .conn_mut() + .register_pending_fetch_response_transfer_for_session_owner(None, request_id, transfer) + } else { + conn.register_pending_fetch_response_transfer_for_session_owner( + fallback_session_id, + request_id, + transfer, + ) + }; + if !restored { + tracing::error!( + target_id = page_owner.target_id(), + "failed to restore response-paused navigation after Browser Host publication failure" + ); + } +} + pub(super) enum PendingFulfillRequestState { + BrowserOwnerNavigationDecision, SubresourceFetch { pending: Box, request_id: String, @@ -877,12 +1201,7 @@ pub(super) fn start_fulfill_request_command( body, params.response_phrase, ); - start_devtools_fetch_command( - conn, - cmd.id, - cmd.session_id, - DevToolsCommand::FulfillInterceptedRequest(command), - ) + start_devtools_fulfill_intercepted_request_command(conn, cmd.id, cmd.session_id, command, true) } fn build_cdp_fulfill_intercepted_request_command( @@ -911,12 +1230,13 @@ fn start_devtools_fulfill_intercepted_request_command( command_id: Option, command_session_id: Option<&str>, command: DevToolsFulfillInterceptedRequestCommand, + admit_to_browser_owner: bool, ) -> FetchCommandTaskStep { let validate_request_id = command.context.protocol == DevToolsProtocol::Cdp; let request_id = command.request_id.into_string(); let response_code = command.response_code; let response_headers = command.response_headers; - let decoded_body = command.body.map(RendererSyntheticResponseBody::from_bytes); + let response_body = command.body; let _ = command.response_phrase; let action_session_id = action_session_id_for_devtools_context( @@ -931,6 +1251,7 @@ fn start_devtools_fulfill_intercepted_request_command( &request_id, ) { let pending = pending; + let decoded_body = response_body.map(RendererSyntheticResponseBody::from_bytes); if let Some(continuation) = pending.detached_parser_script_fetch_continuation() { let body = decoded_body .unwrap_or_else(RendererSyntheticResponseBody::empty) @@ -996,6 +1317,7 @@ fn start_devtools_fulfill_intercepted_request_command( action_session_id, &request_id, ) { + let decoded_body = response_body.map(RendererSyntheticResponseBody::from_bytes); let page = match conn.loaded_page_mut_for_protocol_access(command_session_id) { Ok(page) => page, Err(message) if message == "NoDocumentLoaded" => { @@ -1038,20 +1360,91 @@ fn start_devtools_fulfill_intercepted_request_command( if let Some(pending) = take_pending_navigation(conn, command_session_id, action_session_id, &request_id) { - return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( - conn, - command_id, - command_session_id, - PendingFetchCommandKind::FulfillRequest { - state: Box::new(PendingFulfillRequestState::Navigation { - pending: Box::new(pending), - response_code, - response_headers: response_headers.clone(), - decoded_body, - }), - }, - PendingFetchCommandOperation::Ready, - )); + if !admit_to_browser_owner { + return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::FulfillRequest { + state: Box::new(PendingFulfillRequestState::Navigation { + pending: Box::new(pending), + response_code, + response_headers, + decoded_body: response_body.map(RendererSyntheticResponseBody::from_bytes), + }), + }, + PendingFetchCommandOperation::Ready, + )); + } + let Some(page_owner) = page_owner_for_pending_navigation(conn, &pending) else { + let pending_request_id = pending.fetch_request_id.clone(); + if conn + .register_pending_fetch_navigation_request_for_session_owner( + command_session_id, + pending, + ) + .is_none() + { + tracing::error!( + request_id = %pending_request_id, + "failed to restore fulfilled navigation after Page owner capture failure" + ); + } + return FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "BrowserHostPausedNavigationDecisionAdmissionFailed: PageNotFound", + )); + }; + let publication = conn.publish_browser_owner_paused_navigation_decision( + page_owner.clone(), + pending, + BrowserPausedNavigationDecision::fulfill( + response_code, + response_headers, + response_body, + ), + CommandDispatchContext::default(), + ); + return match publication { + Ok(pending) => FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::FulfillRequest { + state: Box::new(PendingFulfillRequestState::BrowserOwnerNavigationDecision), + }, + PendingFetchCommandOperation::BrowserOwnerPausedNavigationDecision(pending), + )), + Err(failure) => { + let (error, pending) = failure.into_parts(); + if let Some(pending) = pending { + match pending { + BrowserOwnerPausedNavigationSidecar::Request(pending) => { + restore_pending_navigation_after_owner_publication_failure( + conn, + &page_owner, + command_session_id, + pending, + ); + } + BrowserOwnerPausedNavigationSidecar::Response(_) => { + tracing::error!( + "fulfillRequest publication returned a response-navigation sidecar" + ); + } + BrowserOwnerPausedNavigationSidecar::Auth(_) => { + tracing::error!( + "fulfillRequest publication returned an auth-navigation sidecar" + ); + } + } + } + FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + format!("BrowserHostPausedNavigationDecisionAdmissionFailed: {error}"), + )) + } + }; } if let Some(transfer) = conn @@ -1060,20 +1453,88 @@ fn start_devtools_fulfill_intercepted_request_command( &request_id, ) { - return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( - conn, - command_id, - command_session_id, - PendingFetchCommandKind::FulfillRequest { - state: Box::new(PendingFulfillRequestState::ResponseTransfer { - transfer: Box::new(transfer), - response_code, - response_headers, - decoded_body, - }), - }, - PendingFetchCommandOperation::Ready, - )); + if !admit_to_browser_owner { + return FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::FulfillRequest { + state: Box::new(PendingFulfillRequestState::ResponseTransfer { + transfer: Box::new(transfer), + response_code, + response_headers, + decoded_body: response_body.map(RendererSyntheticResponseBody::from_bytes), + }), + }, + PendingFetchCommandOperation::Ready, + )); + } + let Some(page_owner) = page_owner_for_navigation_state(conn, transfer.navigation()) else { + let restored = conn.register_pending_fetch_response_transfer_for_session_owner( + command_session_id, + request_id, + transfer, + ); + if !restored { + tracing::error!( + "failed to restore fulfilled response navigation after Page owner capture failure" + ); + } + return FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "BrowserHostPausedNavigationDecisionAdmissionFailed: PageNotFound", + )); + }; + let publication = conn.publish_browser_owner_paused_navigation_response_decision( + page_owner.clone(), + transfer, + BrowserPausedNavigationDecision::fulfill( + response_code, + response_headers, + response_body, + ), + CommandDispatchContext::default(), + ); + return match publication { + Ok(pending) => FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::FulfillRequest { + state: Box::new(PendingFulfillRequestState::BrowserOwnerNavigationDecision), + }, + PendingFetchCommandOperation::BrowserOwnerPausedNavigationDecision(pending), + )), + Err(failure) => { + let (error, pending) = failure.into_parts(); + if let Some(pending) = pending { + match pending { + BrowserOwnerPausedNavigationSidecar::Response(transfer) => { + restore_pending_response_transfer_after_owner_publication_failure( + conn, + &page_owner, + command_session_id, + *transfer, + ); + } + BrowserOwnerPausedNavigationSidecar::Request(_) => { + tracing::error!( + "fulfillRequest publication returned a request-navigation sidecar" + ); + } + BrowserOwnerPausedNavigationSidecar::Auth(_) => { + tracing::error!( + "fulfillRequest publication returned an auth-navigation sidecar" + ); + } + } + } + FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + format!("BrowserHostPausedNavigationDecisionAdmissionFailed: {error}"), + )) + } + }; } pending_request_action_output_plan(conn, command_session_id, &request_id, validate_request_id) @@ -1085,11 +1546,21 @@ fn start_devtools_fulfill_intercepted_request_command( pub(super) async fn complete_fulfill_request_command_async( conn: &mut CdpConnection, session_id: Option<&str>, - completed: Option>, + completed: CompletedFetchCommandOperation, state: PendingFulfillRequestState, out: &mut FetchCommandOutput, ) { match state { + PendingFulfillRequestState::BrowserOwnerNavigationDecision => { + let Some(completed) = completed.into_browser_owner_paused_navigation_decision() else { + out.push_error(-32000, "Missing Browser Owner completion"); + return; + }; + match completed { + Ok(completed) => out.extend_browser_owner_projection_as_command_response(completed), + Err(error) => out.push_error(-32000, error), + } + } PendingFulfillRequestState::SubresourceFetch { pending, request_id, @@ -1097,6 +1568,7 @@ pub(super) async fn complete_fulfill_request_command_async( websocket_socket_id, register_synthetic_websocket, } => { + let completed = completed.into_page_completion(); let Some(completed) = completed else { out.push_error(-32000, "Missing renderer completion"); return; @@ -1109,7 +1581,7 @@ pub(super) async fn complete_fulfill_request_command_async( } }; match conn.loaded_page_mut_for_protocol_access(session_id) { - Ok(page) => { + Ok(mut page) => { if let Err(error) = page.finish_fulfill_pending_subresource_fetch(completion) { out.push_error( -32000, @@ -1147,6 +1619,7 @@ pub(super) async fn complete_fulfill_request_command_async( out.extend_background_events(events); } PendingFulfillRequestState::SubresourceResponse { pending } => { + let completed = completed.into_page_completion(); let Some(completed) = completed else { out.push_error(-32000, "Missing renderer completion"); return; @@ -1159,7 +1632,7 @@ pub(super) async fn complete_fulfill_request_command_async( } }; match conn.loaded_page_mut_for_protocol_access(session_id) { - Ok(page) => { + Ok(mut page) => { if let Err(error) = page.finish_fulfill_pending_subresource_response(completion) { out.push_error( @@ -1415,7 +1888,7 @@ pub(super) fn complete_websocket_page_command( } }; let result = match conn.loaded_page_mut_for_protocol_access(session_id) { - Ok(page) => match operation { + Ok(mut page) => match operation { PendingWebSocketCommandOperation::DispatchText => { page.finish_receive_synthetic_websocket_text(completion) } @@ -1442,6 +1915,7 @@ pub(super) fn complete_websocket_page_command( } pub(super) enum PendingContinueResponseState { + BrowserOwnerNavigationDecision, ResponseTransfer { request_id: String, transfer: Box, @@ -1495,11 +1969,12 @@ pub(super) fn start_continue_response_command( response_headers, params.response_phrase, ); - start_devtools_fetch_command( + start_devtools_continue_intercepted_response_command( conn, cmd.id, cmd.session_id, - DevToolsCommand::ContinueInterceptedResponse(command), + &command, + true, ) } @@ -1537,6 +2012,7 @@ fn start_devtools_continue_intercepted_response_command( command_id: Option, command_session_id: Option<&str>, command: &DevToolsContinueInterceptedResponseCommand, + admit_to_browser_owner: bool, ) -> FetchCommandTaskStep { let request_id = command.request_id.as_str().to_owned(); let response_code = command.response_code; @@ -1554,6 +2030,77 @@ fn start_devtools_continue_intercepted_response_command( { let _ = &command.response_phrase; let transfer_response_headers = response_headers.clone().unwrap_or_default(); + if admit_to_browser_owner { + let Some(page_owner) = page_owner_for_navigation_state(conn, transfer.navigation()) + else { + let restored = conn.register_pending_fetch_response_transfer_for_session_owner( + command_session_id, + request_id, + transfer, + ); + if !restored { + tracing::error!( + "failed to restore paused response navigation after Page owner capture failure" + ); + } + return FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "BrowserHostPausedNavigationDecisionAdmissionFailed: PageNotFound", + )); + }; + let decision = BrowserPausedNavigationDecision::continue_response( + response_code, + transfer_response_headers.clone(), + ); + let publication = conn.publish_browser_owner_paused_navigation_response_decision( + page_owner.clone(), + transfer, + decision, + CommandDispatchContext::default(), + ); + return match publication { + Ok(pending) => FetchCommandTaskStep::Pending(PendingFetchCommandDispatch::new( + conn, + command_id, + command_session_id, + PendingFetchCommandKind::ContinueResponse { + state: Box::new( + PendingContinueResponseState::BrowserOwnerNavigationDecision, + ), + }, + PendingFetchCommandOperation::BrowserOwnerPausedNavigationDecision(pending), + )), + Err(failure) => { + let (error, pending) = failure.into_parts(); + if let Some(pending) = pending { + match pending { + BrowserOwnerPausedNavigationSidecar::Response(transfer) => { + restore_pending_response_transfer_after_owner_publication_failure( + conn, + &page_owner, + command_session_id, + *transfer, + ); + } + BrowserOwnerPausedNavigationSidecar::Request(_) => { + tracing::error!( + "continueResponse publication returned a request-navigation sidecar" + ); + } + BrowserOwnerPausedNavigationSidecar::Auth(_) => { + tracing::error!( + "continueResponse publication returned an auth-navigation sidecar" + ); + } + } + } + FetchCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + format!("BrowserHostPausedNavigationDecisionAdmissionFailed: {error}"), + )) + } + }; + } if let Some(sender) = conn.background_navigation_completion_sender_for_session_owner(command_session_id) { @@ -1731,6 +2278,7 @@ fn start_devtools_continue_intercepted_response_command( command_id, command_session_id, &continue_response_auth_command(command), + false, ) { return step; } @@ -1805,11 +2353,21 @@ async fn continue_response_transfer_inline( pub(super) async fn complete_continue_response_command_async( conn: &mut CdpConnection, session_id: Option<&str>, - completed: Option>, + completed: CompletedFetchCommandOperation, state: PendingContinueResponseState, out: &mut FetchCommandOutput, ) { match state { + PendingContinueResponseState::BrowserOwnerNavigationDecision => { + let Some(completed) = completed.into_browser_owner_paused_navigation_decision() else { + out.push_error(-32000, "Missing Browser Owner completion"); + return; + }; + match completed { + Ok(completed) => out.extend_browser_owner_projection_as_command_response(completed), + Err(error) => out.push_error(-32000, error), + } + } PendingContinueResponseState::ResponseTransfer { request_id, transfer, @@ -1829,6 +2387,7 @@ pub(super) async fn complete_continue_response_command_async( } PendingContinueResponseState::SubresourceResponse { pending } => { let pending = *pending; + let completed = completed.into_page_completion(); let Some(completed) = completed else { out.push_error(-32000, "Missing renderer completion"); return; @@ -1841,7 +2400,7 @@ pub(super) async fn complete_continue_response_command_async( } }; match conn.loaded_page_mut_for_protocol_access(session_id) { - Ok(page) => { + Ok(mut page) => { if let Err(error) = page.finish_continue_pending_subresource_response(completion) { @@ -1875,78 +2434,6 @@ pub(super) async fn complete_continue_response_command_async( } } -fn continue_streaming_document_response_in_background( - conn: &mut CdpConnection, - sender: tokio::sync::mpsc::UnboundedSender, - pending: PendingStreamingDocumentResponseNavigation, - response_code: Option, - response_headers: Vec<(String, String)>, -) { - let PendingStreamingDocumentResponseNavigation { - document_navigation_token, - navigation, - response, - network_observation_journal, - body_progress_source, - prepared_document, - } = pending; - let session_id = navigation.navigate_session_id.clone(); - let none_session_owner_route = session_id - .is_none() - .then(|| conn.none_session_owner_route_override()) - .flatten(); - let cancellation = response.cancellation_handle(); - if response_code.is_none() - && response_headers.is_empty() - && let Some(prepared_document) = prepared_document - { - conn.arm_background_navigation_completion(&document_navigation_token, Some(cancellation)); - tokio::task::spawn_local(async move { - let body_completion_sink = BackgroundNavigationBodyCompletionSink::new( - sender.clone(), - document_navigation_token.clone(), - navigation.clone(), - none_session_owner_route.clone(), - ); - let (engine, navigation_result) = - prepared_document.resume_streaming(response, Some(body_completion_sink)); - let _ = sender.send(page::BackgroundNavigationCompletion::new( - document_navigation_token, - navigation, - none_session_owner_route, - engine, - Ok(navigation_result), - )); - }); - return; - } - let job = conn.background_streaming_response_navigation_load_job_for_navigation( - &navigation, - response, - network_observation_journal, - response_code, - response_headers, - body_progress_source, - ); - conn.arm_background_navigation_completion(&document_navigation_token, Some(cancellation)); - tokio::task::spawn_local(async move { - let body_completion_sink = BackgroundNavigationBodyCompletionSink::new( - sender.clone(), - document_navigation_token.clone(), - navigation.clone(), - none_session_owner_route.clone(), - ); - let (engine, navigation_result) = job.run(Some(body_completion_sink)).await; - let _ = sender.send(page::BackgroundNavigationCompletion::new( - document_navigation_token, - navigation, - none_session_owner_route, - engine, - navigation_result, - )); - }); -} - #[cfg(test)] mod protocol_neutral_tests { use crate::devtools_runtime::{AutomationEvent, DevToolsCommand, DevToolsProtocol}; @@ -2078,6 +2565,7 @@ mod protocol_neutral_tests { cmd.id, cmd.session_id, DevToolsCommand::ContinueInterceptedRequest(command), + false, ); let super::super::FetchCommandTaskStep::Complete(plan) = step else { @@ -2098,11 +2586,7 @@ mod protocol_neutral_tests { assert!( browser_context.assign_auxiliary_session_to_target("TID-chain", "SID-aux".to_owned()) ); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page_owner = conn .target_page_residence_identity_for_session(Some("SID-primary")) @@ -2167,6 +2651,7 @@ mod protocol_neutral_tests { cmd.id, cmd.session_id, DevToolsCommand::ContinueInterceptedRequest(command), + false, ); let super::super::FetchCommandTaskStep::Complete(plan) = step else { @@ -2239,11 +2724,7 @@ mod protocol_neutral_tests { browser_context .assign_auxiliary_session_to_target("TID-response-chain", "SID-aux".to_owned()) ); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page_owner = conn .target_page_residence_identity_for_session(Some("SID-primary")) @@ -2313,6 +2794,7 @@ mod protocol_neutral_tests { cmd.id, cmd.session_id, DevToolsCommand::ContinueInterceptedResponse(command), + false, ); let super::super::FetchCommandTaskStep::Complete(plan) = step else { @@ -2375,11 +2857,7 @@ mod protocol_neutral_tests { let mut browser_context = BrowserContext::new("BID-chain-bidi".to_owned()); browser_context.set_active_target_id("TID-chain-bidi".to_owned()); browser_context.attach_active_session("SID-primary".to_owned()); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page_owner = conn .target_page_residence_identity_for_session(Some("SID-primary")) @@ -2441,6 +2919,7 @@ mod protocol_neutral_tests { cmd.id, cmd.session_id, DevToolsCommand::ContinueInterceptedRequest(command), + false, ); let super::super::FetchCommandTaskStep::Complete(plan) = step else { diff --git a/moli-protocol/src/domains/fetch/frontend_task.rs b/moli-protocol/src/domains/fetch/frontend_task.rs new file mode 100644 index 0000000000..e654db8f98 --- /dev/null +++ b/moli-protocol/src/domains/fetch/frontend_task.rs @@ -0,0 +1,172 @@ +use crate::{ + conn::{ + CdpConnection, CdpSessionRoute, DevToolsCommandDispatchOutcome, + DevToolsCommandExecutionOutput, + }, + devtools_runtime::{DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult}, +}; + +use super::{ + CompletedFetchCommandDispatch, FetchCommandTaskStep, PendingFetchCommandDispatch, + complete_pending_devtools_fetch_execution_output, devtools_fetch_execution_output_from_plan, + devtools_fetch_success_result, fetch_devtools_command_session_ids, + start_devtools_fetch_command, +}; + +/// Scheduler-facing task for one typed Fetch terminal decision. +/// +/// A main-Document request publishes its decision to Browser Owner. A +/// subresource request keeps its Page participant, but both variants expose +/// the wait to the application scheduler instead of borrowing +/// `CdpConnection` across it. +pub enum DevToolsFetchCommandTaskStep { + Pending(Box), + Complete(Box), +} + +/// Move-owned typed Fetch wait with no `&mut CdpConnection` borrow. +#[must_use = "a typed Fetch command must be awaited or explicitly abandoned"] +pub struct PendingDevToolsFetchCommand { + pending: PendingFetchCommandDispatch, + owner_route: Option, + success_result: DevToolsCommandResult, + devtools_context: DevToolsCommandContext, +} + +impl PendingDevToolsFetchCommand { + /// Whether this command owns the same pre-commit renderer publication + /// gate as its paused top-level navigation. + pub fn holds_navigation_renderer_publication_gate(&self) -> bool { + self.pending.holds_navigation_renderer_publication_gate() + } + + pub async fn wait(self) -> CompletedDevToolsFetchCommand { + CompletedDevToolsFetchCommand { + completed: self.pending.wait().await, + owner_route: self.owner_route, + success_result: self.success_result, + devtools_context: self.devtools_context, + } + } +} + +/// Completed typed Fetch participant ready for frontend-only projection. +#[must_use = "a completed typed Fetch command must be projected"] +pub struct CompletedDevToolsFetchCommand { + completed: CompletedFetchCommandDispatch, + owner_route: Option, + success_result: DevToolsCommandResult, + devtools_context: DevToolsCommandContext, +} + +impl CdpConnection { + /// Starts one typed Fetch terminal decision without hiding its Browser or + /// renderer participant inside a borrowed connection future. + /// + /// Once `Pending` is returned, main-Document execution has no direct + /// Protocol fallback. The application scheduler must continue servicing + /// Browser Host turns until the exact task completes. + pub async fn try_start_devtools_fetch_command_task( + &mut self, + command: DevToolsCommand, + ) -> Result { + if !is_typed_fetch_terminal_decision(&command) { + return Err(command); + } + + let devtools_context = command.context().clone(); + let success_result = devtools_fetch_success_result(&command); + let (owner_session_id, owner_route) = + match fetch_devtools_command_session_ids(self, &command) { + Ok(session_ids) => session_ids, + Err(error) => { + return Ok(DevToolsFetchCommandTaskStep::Complete(Box::new( + finish_devtools_fetch_execution_output( + self, + devtools_context, + DevToolsCommandExecutionOutput::new(Err(error)), + ) + .await, + ))); + } + }; + let step = { + let mut route_scope = + self.scoped_optional_none_session_owner_route_override(owner_route.clone()); + start_devtools_fetch_command( + route_scope.conn_mut(), + None, + owner_session_id.as_deref(), + command, + true, + ) + }; + match step { + FetchCommandTaskStep::Complete(plan) => { + let output = devtools_fetch_execution_output_from_plan(plan, success_result); + Ok(DevToolsFetchCommandTaskStep::Complete(Box::new( + finish_devtools_fetch_execution_output(self, devtools_context, output).await, + ))) + } + FetchCommandTaskStep::Pending(pending) => Ok(DevToolsFetchCommandTaskStep::Pending( + Box::new(PendingDevToolsFetchCommand { + pending, + owner_route, + success_result, + devtools_context, + }), + )), + } + } + + /// Applies one completed typed Fetch task and projects only its frontend + /// result/effects. Browser execution has already happened in owner turns. + pub async fn complete_devtools_fetch_command_task( + &mut self, + completed: CompletedDevToolsFetchCommand, + ) -> DevToolsCommandDispatchOutcome { + let CompletedDevToolsFetchCommand { + completed, + owner_route, + success_result, + devtools_context, + } = completed; + let output = { + let mut route_scope = + self.scoped_optional_none_session_owner_route_override(owner_route); + complete_pending_devtools_fetch_execution_output( + route_scope.conn_mut(), + completed, + success_result, + ) + .await + }; + finish_devtools_fetch_execution_output(self, devtools_context, output).await + } +} + +fn is_typed_fetch_terminal_decision(command: &DevToolsCommand) -> bool { + matches!( + command, + DevToolsCommand::ContinueInterceptedRequest(_) + | DevToolsCommand::ContinueInterceptedResponse(_) + | DevToolsCommand::ContinueWithAuth(_) + | DevToolsCommand::FailInterceptedRequest(_) + | DevToolsCommand::FulfillInterceptedRequest(_) + ) +} + +async fn finish_devtools_fetch_execution_output( + conn: &mut CdpConnection, + devtools_context: DevToolsCommandContext, + output: DevToolsCommandExecutionOutput, +) -> DevToolsCommandDispatchOutcome { + let (result, protocol_events, renderer_output_predecessor) = output.into_parts(); + conn.finish_devtools_command_dispatch( + devtools_context, + result, + protocol_events, + renderer_output_predecessor, + ) + .await +} diff --git a/moli-protocol/src/domains/fetch/navigation.rs b/moli-protocol/src/domains/fetch/navigation.rs index 781ebb3e4f..adc7452e01 100644 --- a/moli-protocol/src/domains/fetch/navigation.rs +++ b/moli-protocol/src/domains/fetch/navigation.rs @@ -22,7 +22,7 @@ use super::{ }, }; -fn prepare_navigation_response_stage( +pub(super) fn prepare_navigation_response_stage( conn: &CdpConnection, pending: &mut PendingFetchNavigation, final_url: &Url, @@ -404,7 +404,7 @@ pub(super) async fn complete_tokened_materialized_navigation_into_buffer_async( ) { let Some(token) = token else { if navigation_state.navigate_id.is_some() { - page::push_superseded_navigation_result(out, &navigation_state); + out.push_error_after_messages(-32000, "Navigation aborted"); } else { tracing::warn!( session_id = navigation_state.navigate_session_id.as_deref(), @@ -527,36 +527,18 @@ async fn handle_streaming_response_head_for_navigation_into_buffer_async( return; } - let response_status = response_head.status; - let final_url = response_head.final_url.clone(); - let response_headers = response_head.headers.clone(); - let request_cookie_report = response_head.request_cookie_report.clone(); - let network_observation_journal = response.observation_journal(); - let network_extra_info_available = !network_observation_journal.is_empty(); - let body_progress_source = network::response_stage_main_document_navigation_network_progress( + let (body_progress_source, response_extra_info_events) = streaming_response_stage_extra_info( conn, - &pending.navigation, - pending.request_cookie_report.as_ref(), - ); - let mut response_extra_info_events = Vec::new(); - body_progress_source.emit_response_extra_info_before_pause( - &mut response_extra_info_events, - &pending.navigation.request_method, - &pending.navigation.request_headers, - request_cookie_report.as_ref(), - &response_head.redirect_chain, - response_status, - &response_headers, - &response_head.cookie_set_reports, - network_observation_journal, - network_extra_info_available, + &pending, + response.response(), + response.observation_journal(), ); out.extend_background_events_after_messages(response_extra_info_events); let prepared_document = match conn .prepare_paused_streaming_response_navigation_async( &pending.navigation, response.response(), - network_observation_journal, + response.observation_journal(), body_progress_source.clone(), ) .await @@ -573,6 +555,59 @@ async fn handle_streaming_response_head_for_navigation_into_buffer_async( return; } }; + pause_streaming_raw_response_stage_navigation_into_buffer( + conn, + out, + pending, + response, + body_progress_source, + prepared_document, + ); +} + +pub(super) fn streaming_response_stage_extra_info( + conn: &CdpConnection, + pending: &PendingFetchNavigation, + response_head: &StreamingRawResponse, + network_observation_journal: &NetworkObservationJournal, +) -> ( + network::MainDocumentBodyProgressSource, + Vec, +) { + let network_extra_info_available = !network_observation_journal.is_empty(); + let body_progress_source = network::response_stage_main_document_navigation_network_progress( + conn, + &pending.navigation, + pending.request_cookie_report.as_ref(), + ); + let mut response_extra_info_events = Vec::new(); + body_progress_source.emit_response_extra_info_before_pause( + &mut response_extra_info_events, + &pending.navigation.request_method, + &pending.navigation.request_headers, + response_head.request_cookie_report.as_ref(), + &response_head.redirect_chain, + response_head.status, + &response_head.headers, + &response_head.cookie_set_reports, + network_observation_journal, + network_extra_info_available, + ); + (body_progress_source, response_extra_info_events) +} + +pub(super) fn pause_streaming_raw_response_stage_navigation_into_buffer( + conn: &mut CdpConnection, + out: &mut CommandOutputBuffer, + pending: PendingFetchNavigation, + response: NetworkFetchResult, + body_progress_source: network::MainDocumentBodyProgressSource, + prepared_document: Option, +) { + let response_status = response.response().status; + let final_url = response.response().final_url.clone(); + let response_headers = response.response().headers.clone(); + let request_cookie_report = response.response().request_cookie_report.clone(); let (response, network_observation_journal) = response.into_parts_with_observation_journal(); conn.register_pending_fetch_response_navigation_for_session_owner( pending.navigation.navigate_session_id.as_deref(), @@ -638,7 +673,7 @@ async fn complete_or_pause_response_stage_into_buffer_async( } } -fn pause_data_url_response_stage_navigation_into_buffer( +pub(super) fn pause_data_url_response_stage_navigation_into_buffer( conn: &mut CdpConnection, out: &mut CommandOutputBuffer, pending: PendingFetchNavigation, @@ -672,7 +707,7 @@ fn pause_data_url_response_stage_navigation_into_buffer( ); } -fn pause_buffered_raw_response_stage_navigation_into_buffer( +pub(super) fn pause_buffered_raw_response_stage_navigation_into_buffer( conn: &mut CdpConnection, out: &mut CommandOutputBuffer, pending: PendingFetchNavigation, @@ -833,7 +868,7 @@ fn routable_stage_owner_session_id( fallback_session_id.map(str::to_owned) } -fn register_navigation_auth_required_event( +pub(super) fn register_navigation_auth_required_event( conn: &mut CdpConnection, pending: &PendingFetchNavigation, challenge: FetchAuthChallenge, diff --git a/moli-protocol/src/domains/fetch/navigation_decision.rs b/moli-protocol/src/domains/fetch/navigation_decision.rs new file mode 100644 index 0000000000..d6f9216ae3 --- /dev/null +++ b/moli-protocol/src/domains/fetch/navigation_decision.rs @@ -0,0 +1,642 @@ +use moli_core::browser_host::{ + BrowserPausedNavigationAuthDecision, BrowserPausedNavigationDecision, + BrowserPausedNavigationFulfillDecision, BrowserPausedNavigationResponseDecision, + PageResidenceIdentity, +}; + +use crate::conn::{ + BrowserOwnerPausedNavigationSidecar, CdpConnection, CommandDispatchContext, + DocumentNavigationToken, NavigationDispatchState, PausedDocumentTransfer, + PendingFetchAuthNavigation, PendingFetchNavigation, +}; +use crate::domains::{command_output::CommandOutputPlan, network, page}; + +use super::navigation_resume::{ + CompletedPausedNavigationResumeOwnerTask, PausedNavigationFulfillSource, + PausedNavigationResumeOwnerTaskStep, PendingPausedNavigationResumeOwnerTask, + complete_paused_navigation_resume_owner_task, start_paused_navigation_auth_resume_owner_task, + start_paused_navigation_fulfill_owner_task, start_paused_navigation_response_resume_owner_task, + start_paused_navigation_resume_owner_task, +}; + +struct PausedNavigationDecisionProjection { + navigate_id: Option, + navigate_session_id: Option, + requested_url: String, + command_context: CommandDispatchContext, +} + +enum PendingPausedNavigationDecisionPhase { + Apply { + page_owner: PageResidenceIdentity, + token: DocumentNavigationToken, + navigation: Box, + error_text: String, + projection: PausedNavigationDecisionProjection, + }, + ContinueApply { + page_owner: PageResidenceIdentity, + pending: Box, + decision: moli_core::browser_host::BrowserPausedNavigationContinueDecision, + projection: PausedNavigationDecisionProjection, + }, + ResponseContinueApply { + page_owner: PageResidenceIdentity, + transfer: Box, + decision: BrowserPausedNavigationResponseDecision, + projection: PausedNavigationDecisionProjection, + }, + FulfillApply { + page_owner: PageResidenceIdentity, + source: PausedNavigationFulfillSource, + decision: BrowserPausedNavigationFulfillDecision, + projection: PausedNavigationDecisionProjection, + }, + AuthApply { + page_owner: PageResidenceIdentity, + pending: Box, + decision: BrowserPausedNavigationAuthDecision, + projection: PausedNavigationDecisionProjection, + }, + Resume { + page_owner: PageResidenceIdentity, + pending: Box, + projection: PausedNavigationDecisionProjection, + }, + Navigation { + page_owner: PageResidenceIdentity, + pending: Box, + projection: PausedNavigationDecisionProjection, + }, +} + +enum CompletedPausedNavigationDecisionPhase { + Apply { + page_owner: PageResidenceIdentity, + token: DocumentNavigationToken, + navigation: Box, + error_text: String, + projection: PausedNavigationDecisionProjection, + }, + ContinueApply { + page_owner: PageResidenceIdentity, + pending: Box, + decision: moli_core::browser_host::BrowserPausedNavigationContinueDecision, + projection: PausedNavigationDecisionProjection, + }, + ResponseContinueApply { + page_owner: PageResidenceIdentity, + transfer: Box, + decision: BrowserPausedNavigationResponseDecision, + projection: PausedNavigationDecisionProjection, + }, + FulfillApply { + page_owner: PageResidenceIdentity, + source: PausedNavigationFulfillSource, + decision: BrowserPausedNavigationFulfillDecision, + projection: PausedNavigationDecisionProjection, + }, + AuthApply { + page_owner: PageResidenceIdentity, + pending: Box, + decision: BrowserPausedNavigationAuthDecision, + projection: PausedNavigationDecisionProjection, + }, + Resume { + page_owner: PageResidenceIdentity, + completed: Box, + projection: PausedNavigationDecisionProjection, + }, + Navigation { + page_owner: PageResidenceIdentity, + completed: Box, + projection: PausedNavigationDecisionProjection, + }, +} + +/// One move-owned participant in an actor-selected paused-navigation decision. +/// +/// The initial participant is an explicit handoff from the synchronous Host +/// executor to its async completion lane. Later participants are concrete +/// renderer waits owned by the shared navigation state machine. +pub(crate) struct PendingPausedNavigationDecisionOwnerTask { + phase: PendingPausedNavigationDecisionPhase, +} + +pub(crate) struct CompletedPausedNavigationDecisionOwnerTask { + phase: CompletedPausedNavigationDecisionPhase, +} + +pub(crate) enum PausedNavigationDecisionOwnerTaskStep { + Pending(Box), + Complete(PausedNavigationDecisionOwnerTaskOutput), +} + +pub(crate) struct PausedNavigationDecisionOwnerTaskOutput { + plan: CommandOutputPlan, + command_context: CommandDispatchContext, +} + +impl PausedNavigationDecisionOwnerTaskOutput { + pub(crate) fn into_parts(self) -> (CommandOutputPlan, CommandDispatchContext) { + (self.plan, self.command_context) + } +} + +impl PendingPausedNavigationDecisionOwnerTask { + #[cfg(test)] + pub(crate) fn page_owner_for_test(&self) -> &PageResidenceIdentity { + match &self.phase { + PendingPausedNavigationDecisionPhase::Apply { page_owner, .. } + | PendingPausedNavigationDecisionPhase::ContinueApply { page_owner, .. } + | PendingPausedNavigationDecisionPhase::ResponseContinueApply { page_owner, .. } + | PendingPausedNavigationDecisionPhase::FulfillApply { page_owner, .. } + | PendingPausedNavigationDecisionPhase::AuthApply { page_owner, .. } + | PendingPausedNavigationDecisionPhase::Resume { page_owner, .. } + | PendingPausedNavigationDecisionPhase::Navigation { page_owner, .. } => page_owner, + } + } + + pub(crate) async fn wait(self: Box) -> CompletedPausedNavigationDecisionOwnerTask { + let phase = match self.phase { + PendingPausedNavigationDecisionPhase::Apply { + page_owner, + token, + navigation, + error_text, + projection, + } => CompletedPausedNavigationDecisionPhase::Apply { + page_owner, + token, + navigation, + error_text, + projection, + }, + PendingPausedNavigationDecisionPhase::ContinueApply { + page_owner, + pending, + decision, + projection, + } => CompletedPausedNavigationDecisionPhase::ContinueApply { + page_owner, + pending, + decision, + projection, + }, + PendingPausedNavigationDecisionPhase::ResponseContinueApply { + page_owner, + transfer, + decision, + projection, + } => CompletedPausedNavigationDecisionPhase::ResponseContinueApply { + page_owner, + transfer, + decision, + projection, + }, + PendingPausedNavigationDecisionPhase::FulfillApply { + page_owner, + source, + decision, + projection, + } => CompletedPausedNavigationDecisionPhase::FulfillApply { + page_owner, + source, + decision, + projection, + }, + PendingPausedNavigationDecisionPhase::AuthApply { + page_owner, + pending, + decision, + projection, + } => CompletedPausedNavigationDecisionPhase::AuthApply { + page_owner, + pending, + decision, + projection, + }, + PendingPausedNavigationDecisionPhase::Resume { + page_owner, + pending, + projection, + } => CompletedPausedNavigationDecisionPhase::Resume { + page_owner, + completed: Box::new(pending.wait().await), + projection, + }, + PendingPausedNavigationDecisionPhase::Navigation { + page_owner, + pending, + projection, + } => CompletedPausedNavigationDecisionPhase::Navigation { + page_owner, + completed: Box::new(pending.wait().await), + projection, + }, + }; + CompletedPausedNavigationDecisionOwnerTask { phase } + } +} + +pub(crate) fn start_page_owned_paused_navigation_decision_owner_task( + conn: &CdpConnection, + page_owner: PageResidenceIdentity, + pending: BrowserOwnerPausedNavigationSidecar, + decision: BrowserPausedNavigationDecision, + command_context: CommandDispatchContext, +) -> PausedNavigationDecisionOwnerTaskStep { + let (navigate_id, navigate_session_id, requested_url) = match &pending { + BrowserOwnerPausedNavigationSidecar::Request(pending) => ( + pending.navigation.navigate_id, + pending.navigation.navigate_session_id.clone(), + pending.navigation.requested_url.as_str().to_owned(), + ), + BrowserOwnerPausedNavigationSidecar::Response(transfer) => ( + transfer.navigation().navigate_id, + transfer.navigation().navigate_session_id.clone(), + transfer.navigation().requested_url.as_str().to_owned(), + ), + BrowserOwnerPausedNavigationSidecar::Auth(pending) => ( + pending.navigation.navigate_id, + pending.navigation.navigate_session_id.clone(), + pending.navigation.requested_url.as_str().to_owned(), + ), + }; + let projection = PausedNavigationDecisionProjection { + navigate_id, + navigate_session_id, + requested_url, + command_context, + }; + if conn + .target_page_owner_route_if_current(&page_owner) + .is_none() + { + return finish_with_navigation_abort(projection); + } + let phase = match (pending, decision) { + ( + BrowserOwnerPausedNavigationSidecar::Request(mut pending), + BrowserPausedNavigationDecision::Fail { error_text }, + ) => { + let Some(token) = pending.document_navigation_token else { + return finish_with_navigation_abort(projection); + }; + pending.navigation.navigate_session_id = None; + PendingPausedNavigationDecisionPhase::Apply { + page_owner, + token, + navigation: Box::new(pending.navigation), + error_text, + projection, + } + } + ( + BrowserOwnerPausedNavigationSidecar::Request(pending), + BrowserPausedNavigationDecision::Continue(decision), + ) => PendingPausedNavigationDecisionPhase::ContinueApply { + page_owner, + pending: Box::new(pending), + decision, + projection, + }, + ( + BrowserOwnerPausedNavigationSidecar::Response(transfer), + BrowserPausedNavigationDecision::Fail { error_text }, + ) => { + let (token, navigation, _) = (*transfer).fail(error_text.clone()); + let Some(token) = token else { + return finish_with_navigation_abort(projection); + }; + PendingPausedNavigationDecisionPhase::Apply { + page_owner, + token, + navigation: Box::new(navigation), + error_text, + projection, + } + } + ( + BrowserOwnerPausedNavigationSidecar::Response(transfer), + BrowserPausedNavigationDecision::ContinueResponse(decision), + ) => PendingPausedNavigationDecisionPhase::ResponseContinueApply { + page_owner, + transfer, + decision, + projection, + }, + ( + BrowserOwnerPausedNavigationSidecar::Request(pending), + BrowserPausedNavigationDecision::Fulfill(decision), + ) => PendingPausedNavigationDecisionPhase::FulfillApply { + page_owner, + source: PausedNavigationFulfillSource::Request(Box::new(pending)), + decision, + projection, + }, + ( + BrowserOwnerPausedNavigationSidecar::Response(transfer), + BrowserPausedNavigationDecision::Fulfill(decision), + ) => PendingPausedNavigationDecisionPhase::FulfillApply { + page_owner, + source: PausedNavigationFulfillSource::Response(transfer), + decision, + projection, + }, + ( + BrowserOwnerPausedNavigationSidecar::Auth(pending), + BrowserPausedNavigationDecision::Auth(decision), + ) => PendingPausedNavigationDecisionPhase::AuthApply { + page_owner, + pending: Box::new(pending), + decision, + projection, + }, + _ => { + tracing::error!( + "Browser Host paused-navigation decision did not match its prepared sidecar" + ); + return finish_with_navigation_abort(projection); + } + }; + PausedNavigationDecisionOwnerTaskStep::Pending(Box::new( + PendingPausedNavigationDecisionOwnerTask { phase }, + )) +} + +pub(crate) async fn complete_page_owned_paused_navigation_decision_owner_task( + conn: &mut CdpConnection, + completed: CompletedPausedNavigationDecisionOwnerTask, +) -> PausedNavigationDecisionOwnerTaskStep { + match completed.phase { + CompletedPausedNavigationDecisionPhase::Apply { + page_owner, + token, + navigation, + error_text, + mut projection, + } => { + let Some(owner_route) = conn.target_page_owner_route_if_current(&page_owner) else { + return finish_with_navigation_abort(projection); + }; + let completion = { + let mut owner_scope = conn.scoped_none_session_owner_route_override(owner_route); + let conn = owner_scope.conn_mut(); + let navigation = *navigation; + let materialized = + network::materialize_navigation_load_result(conn, &navigation, Err(error_text)); + page::complete_pending_navigate_command( + conn, + page::CompletedNavigateCommand::materialized( + page::MaterializedNavigationCompletion::new( + token, + navigation, + materialized, + ), + ), + &mut projection.command_context, + ) + .await + }; + continue_navigation_completion(conn, page_owner, completion, projection) + } + CompletedPausedNavigationDecisionPhase::ContinueApply { + page_owner, + pending, + decision, + projection, + } => { + let Some(owner_route) = conn.target_page_owner_route_if_current(&page_owner) else { + return finish_with_navigation_abort(projection); + }; + let step = { + let mut owner_scope = conn.scoped_none_session_owner_route_override(owner_route); + start_paused_navigation_resume_owner_task( + owner_scope.conn_mut(), + *pending, + decision, + ) + }; + continue_resume_step(conn, page_owner, step, projection).await + } + CompletedPausedNavigationDecisionPhase::ResponseContinueApply { + page_owner, + transfer, + decision, + projection, + } => { + let Some(owner_route) = conn.target_page_owner_route_if_current(&page_owner) else { + return finish_with_navigation_abort(projection); + }; + let step = { + let mut owner_scope = conn.scoped_none_session_owner_route_override(owner_route); + start_paused_navigation_response_resume_owner_task( + owner_scope.conn_mut(), + *transfer, + decision, + ) + }; + continue_resume_step(conn, page_owner, step, projection).await + } + CompletedPausedNavigationDecisionPhase::FulfillApply { + page_owner, + source, + decision, + projection, + } => { + let Some(owner_route) = conn.target_page_owner_route_if_current(&page_owner) else { + return finish_with_navigation_abort(projection); + }; + let step = { + let mut owner_scope = conn.scoped_none_session_owner_route_override(owner_route); + start_paused_navigation_fulfill_owner_task(owner_scope.conn_mut(), source, decision) + }; + continue_resume_step(conn, page_owner, step, projection).await + } + CompletedPausedNavigationDecisionPhase::AuthApply { + page_owner, + pending, + decision, + projection, + } => { + let Some(owner_route) = conn.target_page_owner_route_if_current(&page_owner) else { + return finish_with_navigation_abort(projection); + }; + let step = { + let mut owner_scope = conn.scoped_none_session_owner_route_override(owner_route); + start_paused_navigation_auth_resume_owner_task( + owner_scope.conn_mut(), + *pending, + decision, + ) + }; + continue_resume_step(conn, page_owner, step, projection).await + } + CompletedPausedNavigationDecisionPhase::Resume { + page_owner, + completed, + projection, + } => { + let Some(owner_route) = conn.target_page_owner_route_if_current(&page_owner) else { + return finish_with_navigation_abort(projection); + }; + let step = { + let mut owner_scope = conn.scoped_none_session_owner_route_override(owner_route); + complete_paused_navigation_resume_owner_task(owner_scope.conn_mut(), *completed) + }; + continue_resume_step(conn, page_owner, step, projection).await + } + CompletedPausedNavigationDecisionPhase::Navigation { + page_owner, + completed, + mut projection, + } => { + let Some(owner_route) = conn.target_page_owner_route_if_current(&page_owner) else { + return finish_with_navigation_abort(projection); + }; + let completion = { + let mut owner_scope = conn.scoped_none_session_owner_route_override(owner_route); + page::complete_pending_navigate_command( + owner_scope.conn_mut(), + *completed, + &mut projection.command_context, + ) + .await + }; + continue_navigation_completion(conn, page_owner, completion, projection) + } + } +} + +async fn continue_resume_step( + conn: &mut CdpConnection, + page_owner: PageResidenceIdentity, + step: PausedNavigationResumeOwnerTaskStep, + mut projection: PausedNavigationDecisionProjection, +) -> PausedNavigationDecisionOwnerTaskStep { + match step { + PausedNavigationResumeOwnerTaskStep::Pending(pending) => { + PausedNavigationDecisionOwnerTaskStep::Pending(Box::new( + PendingPausedNavigationDecisionOwnerTask { + phase: PendingPausedNavigationDecisionPhase::Resume { + page_owner, + pending, + projection, + }, + }, + )) + } + PausedNavigationResumeOwnerTaskStep::NavigatePending(pending) => { + PausedNavigationDecisionOwnerTaskStep::Pending(Box::new( + PendingPausedNavigationDecisionOwnerTask { + phase: PendingPausedNavigationDecisionPhase::Navigation { + page_owner, + pending, + projection, + }, + }, + )) + } + PausedNavigationResumeOwnerTaskStep::NavigateReady(completed) => { + let Some(owner_route) = conn.target_page_owner_route_if_current(&page_owner) else { + return finish_with_navigation_abort(projection); + }; + let completion = { + let mut owner_scope = conn.scoped_none_session_owner_route_override(owner_route); + page::complete_pending_navigate_command( + owner_scope.conn_mut(), + *completed, + &mut projection.command_context, + ) + .await + }; + continue_navigation_completion(conn, page_owner, completion, projection) + } + PausedNavigationResumeOwnerTaskStep::CommandRejected(plan) => { + PausedNavigationDecisionOwnerTaskStep::Complete( + PausedNavigationDecisionOwnerTaskOutput { + plan, + command_context: projection.command_context, + }, + ) + } + PausedNavigationResumeOwnerTaskStep::Complete(plan) => { + finish_with_navigation_plan(plan, projection) + } + } +} + +fn continue_navigation_completion( + conn: &mut CdpConnection, + previous_page_owner: PageResidenceIdentity, + completion: page::NavigateCommandCompletion, + projection: PausedNavigationDecisionProjection, +) -> PausedNavigationDecisionOwnerTaskStep { + match completion { + page::NavigateCommandCompletion::Pending(pending) => { + let Some(page_owner) = current_page_for_same_slot(conn, &previous_page_owner) else { + return finish_with_navigation_abort(projection); + }; + PausedNavigationDecisionOwnerTaskStep::Pending(Box::new( + PendingPausedNavigationDecisionOwnerTask { + phase: PendingPausedNavigationDecisionPhase::Navigation { + page_owner, + pending, + projection, + }, + }, + )) + } + page::NavigateCommandCompletion::Complete(plan) => { + finish_with_navigation_plan(plan, projection) + } + } +} + +fn current_page_for_same_slot( + conn: &mut CdpConnection, + previous: &PageResidenceIdentity, +) -> Option { + let route = conn.target_page_owner_route_if_same_slot(previous)?; + let mut owner_scope = conn.scoped_none_session_owner_route_override(route); + owner_scope + .conn_mut() + .target_page_residence_identity_for_session(None) +} + +fn finish_with_navigation_abort( + projection: PausedNavigationDecisionProjection, +) -> PausedNavigationDecisionOwnerTaskStep { + finish_with_navigation_plan( + CommandOutputPlan::error(-32000, "Navigation aborted"), + projection, + ) +} + +fn finish_with_navigation_plan( + navigation_plan: CommandOutputPlan, + projection: PausedNavigationDecisionProjection, +) -> PausedNavigationDecisionOwnerTaskStep { + let PausedNavigationDecisionProjection { + navigate_id, + navigate_session_id, + requested_url, + command_context, + } = projection; + let mut plan = CommandOutputPlan::success(); + let navigation_plan = if navigate_id.is_some() { + navigation_plan.into_browser_navigate_background_event_plan( + requested_url.as_str(), + navigate_id, + navigate_session_id.as_deref(), + ) + } else { + navigation_plan.into_background_event_plan(None, navigate_session_id.as_deref()) + }; + plan.extend(navigation_plan); + PausedNavigationDecisionOwnerTaskStep::Complete(PausedNavigationDecisionOwnerTaskOutput { + plan, + command_context, + }) +} diff --git a/moli-protocol/src/domains/fetch/navigation_resume.rs b/moli-protocol/src/domains/fetch/navigation_resume.rs new file mode 100644 index 0000000000..6ff530ed68 --- /dev/null +++ b/moli-protocol/src/domains/fetch/navigation_resume.rs @@ -0,0 +1,1149 @@ +use moli_core::{ + browser_host::{ + BrowserPausedNavigationAuthDecision, BrowserPausedNavigationContinueDecision, + BrowserPausedNavigationFulfillDecision, BrowserPausedNavigationResponseDecision, + }, + page::{SubresourceAuthCredentials, SubresourceAuthScheme}, + runtime::NavigationEngine, +}; +use moli_fetch::{ + NetworkFetchResult, NetworkObservationJournal, RawResponse, StreamingRawResponse, +}; +use moli_web_mime::response_headers_indicate_attachment_download; + +use crate::conn::{ + BackgroundBufferedResponseNavigationLoadJob, BackgroundCapturedResponseNavigationLoadJob, + BackgroundInterceptedNavigationFetchJob, BackgroundInterceptedNavigationFetchMode, + BackgroundInterceptedNavigationFetchResult, BackgroundNavigationBodyCompletionSink, + BackgroundPausedStreamingResponseNavigationPreparationJob, BackgroundProtocolEvent, + BackgroundStreamingResponseCollectionJob, BackgroundStreamingResponseNavigationLoadJob, + CapturedBody, CdpConnection, DocumentBodySource, FetchAuthChallenge, NavigationLoadOutcome, + PausedDocumentTransfer, PausedResponsePreparedDocument, PendingFetchNavigation, + PendingStreamingDocumentResponseNavigation, +}; +use crate::domains::{ + command_output::{CommandOutputBuffer, CommandOutputPlan}, + network, page, +}; + +use super::{ + helpers::{extract_auth_challenge, populate_auth_challenge_origin}, + navigation::{ + pause_buffered_raw_response_stage_navigation_into_buffer, + pause_data_url_response_stage_navigation_into_buffer, + pause_streaming_raw_response_stage_navigation_into_buffer, + prepare_navigation_response_stage, register_navigation_auth_required_event, + streaming_response_stage_extra_info, + }, +}; + +enum PendingPausedNavigationResumePhase { + Fetch { + pending: Box, + should_handle_auth: bool, + prior_network_observation_journal: Option, + job: Box, + }, + CollectAuthChallenge { + pending: Box, + challenge: FetchAuthChallenge, + request_cookie_report: Option, + job: Box, + }, + BuildBuffered { + prefix_events: Vec, + token: crate::conn::DocumentNavigationToken, + state: Box, + job: Box, + }, + BuildCaptured { + prefix_events: Vec, + token: crate::conn::DocumentNavigationToken, + state: Box, + job: Box, + }, + BuildStreaming { + prefix_events: Vec, + token: crate::conn::DocumentNavigationToken, + state: Box, + job: Box, + }, + PrepareResponseStage { + prefix_events: Vec, + pending: Box, + response: Box>, + body_progress_source: network::MainDocumentBodyProgressSource, + job: Box, + }, +} + +enum CompletedPausedNavigationResumePhase { + Fetch { + pending: Box, + should_handle_auth: bool, + prior_network_observation_journal: Option, + result: Box>, + }, + CollectAuthChallenge { + pending: Box, + challenge: FetchAuthChallenge, + request_cookie_report: Option, + result: Box, String>>, + }, + BuildBuffered { + prefix_events: Vec, + token: crate::conn::DocumentNavigationToken, + state: Box, + engine: Box, + navigation: Box>, + }, + BuildCaptured { + prefix_events: Vec, + token: crate::conn::DocumentNavigationToken, + state: Box, + engine: Box, + navigation: Box>, + }, + BuildStreaming { + prefix_events: Vec, + token: crate::conn::DocumentNavigationToken, + state: Box, + engine: Box, + navigation: Box>, + }, + PrepareResponseStage { + prefix_events: Vec, + pending: Box, + response: Box>, + body_progress_source: network::MainDocumentBodyProgressSource, + prepared_document: Box>, + }, +} + +pub(crate) struct PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase, +} + +pub(crate) struct CompletedPausedNavigationResumeOwnerTask { + phase: CompletedPausedNavigationResumePhase, +} + +pub(crate) enum PausedNavigationResumeOwnerTaskStep { + Pending(Box), + NavigatePending(Box), + NavigateReady(Box), + CommandRejected(CommandOutputPlan), + Complete(CommandOutputPlan), +} + +pub(crate) enum PausedNavigationFulfillSource { + Request(Box), + Response(Box), +} + +impl PendingPausedNavigationResumeOwnerTask { + pub(crate) async fn wait(self: Box) -> CompletedPausedNavigationResumeOwnerTask { + let phase = match self.phase { + PendingPausedNavigationResumePhase::Fetch { + pending, + should_handle_auth, + prior_network_observation_journal, + job, + } => CompletedPausedNavigationResumePhase::Fetch { + pending, + should_handle_auth, + prior_network_observation_journal, + result: Box::new(job.run().await), + }, + PendingPausedNavigationResumePhase::CollectAuthChallenge { + pending, + challenge, + request_cookie_report, + job, + } => CompletedPausedNavigationResumePhase::CollectAuthChallenge { + pending, + challenge, + request_cookie_report, + result: Box::new(job.run().await), + }, + PendingPausedNavigationResumePhase::BuildBuffered { + prefix_events, + token, + state, + job, + } => { + let (engine, navigation) = job.run().await; + CompletedPausedNavigationResumePhase::BuildBuffered { + prefix_events, + token, + state, + engine: Box::new(engine), + navigation: Box::new(navigation), + } + } + PendingPausedNavigationResumePhase::BuildCaptured { + prefix_events, + token, + state, + job, + } => { + let (engine, navigation) = job.run().await; + CompletedPausedNavigationResumePhase::BuildCaptured { + prefix_events, + token, + state, + engine: Box::new(engine), + navigation: Box::new(navigation), + } + } + PendingPausedNavigationResumePhase::BuildStreaming { + prefix_events, + token, + state, + job, + } => { + let (engine, navigation) = job.run(None).await; + CompletedPausedNavigationResumePhase::BuildStreaming { + prefix_events, + token, + state, + engine: Box::new(engine), + navigation: Box::new(navigation), + } + } + PendingPausedNavigationResumePhase::PrepareResponseStage { + prefix_events, + pending, + response, + body_progress_source, + job, + } => CompletedPausedNavigationResumePhase::PrepareResponseStage { + prefix_events, + pending, + response, + body_progress_source, + prepared_document: Box::new(job.run().await), + }, + }; + CompletedPausedNavigationResumeOwnerTask { phase } + } +} + +pub(crate) fn start_paused_navigation_resume_owner_task( + conn: &mut CdpConnection, + mut pending: PendingFetchNavigation, + decision: BrowserPausedNavigationContinueDecision, +) -> PausedNavigationResumeOwnerTaskStep { + let (url, method, post_data, headers, intercept_response) = decision.into_parts(); + if intercept_response { + pending.intercept_response = true; + pending.response_stage_url_match_policy = + crate::conn::ResponseStageUrlMatchPolicy::AlreadyMatched; + } + if let Some(url) = url { + pending.navigation.requested_url = url; + } + if let Some(method) = method { + pending.navigation.request_method = method; + } + if let Some(post_data) = post_data { + pending.navigation.set_request_body_text(post_data); + } + if let Some(headers) = headers { + pending.navigation.request_headers = headers; + } + pending.request_cookie_report = page::navigation_cookie_access_report( + conn, + &pending.navigation.requested_url, + &pending.navigation.request_method, + None, + pending.navigation.request_load_policy, + None, + ); + + start_pending_navigation_network_resume(conn, pending, None, None) +} + +pub(crate) fn start_paused_navigation_auth_resume_owner_task( + conn: &mut CdpConnection, + pending: crate::conn::PendingFetchAuthNavigation, + decision: BrowserPausedNavigationAuthDecision, +) -> PausedNavigationResumeOwnerTaskStep { + match decision { + BrowserPausedNavigationAuthDecision::Fail => { + let pending = pending_auth_navigation_into_request(pending, false); + navigation_error( + conn, + pending, + "Fetch auth challenge aborted".to_owned(), + Vec::new(), + ) + } + BrowserPausedNavigationAuthDecision::Cancel => cancel_paused_navigation_auth(conn, pending), + BrowserPausedNavigationAuthDecision::Continue(auth) => { + let prior_network_observation_journal = + pending.auth_response.observation_journal().clone(); + let pending = pending_auth_navigation_into_request(pending, false); + start_pending_navigation_network_resume( + conn, + pending, + Some(auth), + Some(prior_network_observation_journal), + ) + } + } +} + +pub(crate) fn start_paused_navigation_response_resume_owner_task( + conn: &mut CdpConnection, + transfer: PausedDocumentTransfer, + decision: BrowserPausedNavigationResponseDecision, +) -> PausedNavigationResumeOwnerTaskStep { + let (response_code, response_headers) = decision.into_parts(); + if let Some(sender) = conn.background_navigation_completion_sender_for_session_owner(None) { + match transfer.into_pending_streaming_document_response_navigation() { + Ok(pending) => { + continue_streaming_document_response_in_background( + conn, + sender, + pending, + response_code, + response_headers, + ); + // The Fetch acknowledgement is projected by the outer owner + // decision. The original navigation result remains on the + // existing detached completion transport. + return PausedNavigationResumeOwnerTaskStep::Complete(CommandOutputPlan::default()); + } + Err(transfer) => { + return start_non_streaming_response_resume( + conn, + *transfer, + response_code, + response_headers, + ); + } + } + } + start_non_streaming_response_resume(conn, transfer, response_code, response_headers) +} + +pub(crate) fn start_paused_navigation_fulfill_owner_task( + conn: &mut CdpConnection, + source: PausedNavigationFulfillSource, + decision: BrowserPausedNavigationFulfillDecision, +) -> PausedNavigationResumeOwnerTaskStep { + let (response_code, response_headers, response_body) = decision.into_parts(); + let (token, state, final_url, request_cookie_report, body_progress_source) = match source { + PausedNavigationFulfillSource::Request(pending) => { + let pending = *pending; + let final_url = pending.navigation.requested_url.clone(); + ( + pending.document_navigation_token, + pending.navigation, + final_url, + pending.request_cookie_report, + network::MainDocumentBodyProgressSource::default(), + ) + } + PausedNavigationFulfillSource::Response(transfer) => { + (*transfer).into_synthetic_navigation_parts() + } + }; + let Some(token) = token else { + return PausedNavigationResumeOwnerTaskStep::Complete(CommandOutputPlan::error( + -32000, + "Navigation aborted", + )); + }; + let job = conn.background_synthetic_response_navigation_load_job_for_navigation( + &state, + final_url, + response_code, + response_headers, + CapturedBody::from_bytes(response_body.unwrap_or_default()), + request_cookie_report, + body_progress_source, + ); + PausedNavigationResumeOwnerTaskStep::Pending(Box::new(PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase::BuildCaptured { + prefix_events: Vec::new(), + token, + state: Box::new(state), + job: Box::new(job), + }, + })) +} + +pub(crate) fn continue_streaming_document_response_in_background( + conn: &mut CdpConnection, + sender: tokio::sync::mpsc::UnboundedSender, + pending: PendingStreamingDocumentResponseNavigation, + response_code: Option, + response_headers: Vec<(String, String)>, +) { + let PendingStreamingDocumentResponseNavigation { + document_navigation_token, + navigation, + response, + network_observation_journal, + body_progress_source, + prepared_document, + } = pending; + let session_id = navigation.navigate_session_id.clone(); + let cancellation = crate::conn::BackgroundNavigationCancellation::from_fetch_cancel_handle( + response.cancellation_handle(), + ); + let none_session_owner_route = session_id + .is_none() + .then(|| conn.none_session_owner_route_override()) + .flatten(); + if response_code.is_none() + && response_headers.is_empty() + && let Some(prepared_document) = prepared_document + { + conn.record_background_navigation_started_scheduler_event( + &document_navigation_token, + &navigation, + cancellation.clone(), + ); + tokio::task::spawn_local(async move { + let body_completion_sink = BackgroundNavigationBodyCompletionSink::new( + sender.clone(), + document_navigation_token.clone(), + navigation.clone(), + none_session_owner_route.clone(), + ); + let (engine, navigation_result) = + prepared_document.resume_streaming(response, Some(body_completion_sink)); + let _ = sender.send(page::BackgroundNavigationCompletion::new( + document_navigation_token, + navigation, + none_session_owner_route, + engine, + Ok(navigation_result), + )); + }); + return; + } + let job = conn.background_streaming_response_navigation_load_job_for_navigation( + &navigation, + response, + network_observation_journal, + response_code, + response_headers, + body_progress_source, + ); + conn.record_background_navigation_started_scheduler_event( + &document_navigation_token, + &navigation, + cancellation, + ); + tokio::task::spawn_local(async move { + let body_completion_sink = BackgroundNavigationBodyCompletionSink::new( + sender.clone(), + document_navigation_token.clone(), + navigation.clone(), + none_session_owner_route.clone(), + ); + let (engine, navigation_result) = job.run(Some(body_completion_sink)).await; + let _ = sender.send(page::BackgroundNavigationCompletion::new( + document_navigation_token, + navigation, + none_session_owner_route, + engine, + navigation_result, + )); + }); +} + +fn start_non_streaming_response_resume( + conn: &mut CdpConnection, + transfer: PausedDocumentTransfer, + response_code: Option, + response_headers: Vec<(String, String)>, +) -> PausedNavigationResumeOwnerTaskStep { + let request_id = transfer.fetch_request_id().to_owned(); + let (token, state, body) = match transfer.into_pending_navigation_parts() { + Ok(parts) => parts, + Err(transfer) => { + let restored = conn.register_pending_fetch_response_transfer_for_session_owner( + None, request_id, *transfer, + ); + if !restored { + tracing::error!( + "failed to restore active response body stream after owner continue rejection" + ); + } + return PausedNavigationResumeOwnerTaskStep::CommandRejected(CommandOutputPlan::error( + -32000, + "ResponseBodyStreamActive", + )); + } + }; + let Some(token) = token else { + return PausedNavigationResumeOwnerTaskStep::Complete(CommandOutputPlan::error( + -32000, + "Navigation aborted", + )); + }; + let has_response_override = response_code.is_some() || !response_headers.is_empty(); + match body { + DocumentBodySource::BufferedRaw { + response, + network_observation_journal, + .. + } if !has_response_override => { + let job = conn.background_buffered_response_navigation_load_job_for_navigation( + &state, + NetworkFetchResult::with_observation_journal(response, network_observation_journal), + network::MainDocumentBodyProgressSource::default(), + ); + PausedNavigationResumeOwnerTaskStep::Pending(Box::new( + PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase::BuildBuffered { + prefix_events: Vec::new(), + token, + state: Box::new(state), + job: Box::new(job), + }, + }, + )) + } + DocumentBodySource::BufferedRaw { + response, + network_observation_journal, + .. + } => { + let (head, body) = response.into_body(); + let Ok(body) = body.try_into_materialized_bytes() else { + return PausedNavigationResumeOwnerTaskStep::Complete(CommandOutputPlan::error( + -32000, + "BufferedResponseBodyNotMaterialized", + )); + }; + start_captured_response_build( + conn, + token, + state, + head, + crate::conn::CapturedBody::from_bytes(body), + network_observation_journal, + network::MainDocumentBodyProgressSource::default(), + response_code, + response_headers, + ) + } + DocumentBodySource::StreamingRaw { + response, + network_observation_journal, + body_progress_source, + prepared_document, + .. + } => { + if !has_response_override && let Some(prepared_document) = prepared_document { + let (engine, navigation) = prepared_document.resume_streaming(response, None); + return PausedNavigationResumeOwnerTaskStep::NavigateReady(Box::new( + page::CompletedNavigateCommand::loaded( + Vec::new(), + token, + state, + engine, + Ok(navigation), + ), + )); + } + let job = conn.background_streaming_response_navigation_load_job_for_navigation( + &state, + response, + network_observation_journal, + response_code, + response_headers, + body_progress_source, + ); + PausedNavigationResumeOwnerTaskStep::Pending(Box::new( + PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase::BuildStreaming { + prefix_events: Vec::new(), + token, + state: Box::new(state), + job: Box::new(job), + }, + }, + )) + } + DocumentBodySource::CapturedRaw { + head, + body, + network_observation_journal, + body_progress_source, + .. + } => start_captured_response_build( + conn, + token, + state, + head, + body, + network_observation_journal, + body_progress_source, + response_code, + response_headers, + ), + } +} + +#[allow(clippy::too_many_arguments)] +fn start_captured_response_build( + conn: &mut CdpConnection, + token: crate::conn::DocumentNavigationToken, + state: crate::conn::NavigationDispatchState, + head: moli_fetch::ResponseHead, + body: crate::conn::CapturedBody, + network_observation_journal: NetworkObservationJournal, + body_progress_source: network::MainDocumentBodyProgressSource, + response_code: Option, + response_headers: Vec<(String, String)>, +) -> PausedNavigationResumeOwnerTaskStep { + let job = conn.background_captured_response_navigation_load_job_for_navigation( + &state, + head, + body, + network_observation_journal, + body_progress_source, + response_code, + response_headers, + ); + PausedNavigationResumeOwnerTaskStep::Pending(Box::new(PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase::BuildCaptured { + prefix_events: Vec::new(), + token, + state: Box::new(state), + job: Box::new(job), + }, + })) +} + +fn start_pending_navigation_network_resume( + conn: &mut CdpConnection, + pending: PendingFetchNavigation, + auth: Option, + prior_network_observation_journal: Option, +) -> PausedNavigationResumeOwnerTaskStep { + let Some(token) = pending.document_navigation_token.clone() else { + return PausedNavigationResumeOwnerTaskStep::Complete(CommandOutputPlan::error( + -32000, + "Navigation aborted", + )); + }; + // A request-stage Fetch decision may have replaced the method and body + // after the initial request event was emitted. Capture that final request + // before any of the resumed network paths hand ownership to background + // work so Network.getRequestPostData observes the bytes actually sent. + network::record_main_document_request_body(conn, &pending.navigation); + if pending.intercept_response && pending.navigation.requested_url.scheme() == "data" { + let mut output = CommandOutputBuffer::default(); + pause_data_url_response_stage_navigation_into_buffer(conn, &mut output, pending); + return PausedNavigationResumeOwnerTaskStep::Complete(output.into_plan()); + } + + let should_handle_auth = conn.target_fetch_matches_auth_required_for_session_owner( + pending.navigation.navigate_session_id.as_deref(), + &pending.navigation.requested_url, + ) && pending.navigation.requested_url.scheme() != "data"; + if !should_handle_auth && !pending.intercept_response && auth.is_none() { + let state = pending.navigation; + let job = conn.background_navigation_load_job_for_navigation( + &state, + network::MainDocumentBodyProgressSource::default(), + None, + ); + return PausedNavigationResumeOwnerTaskStep::NavigatePending(Box::new( + page::PendingNavigateCommand::load(Vec::new(), token, state, job), + )); + } + + let mode = match auth.as_ref() { + Some(auth) if pending.intercept_response && auth.scheme != SubresourceAuthScheme::Basic => { + return navigation_error( + conn, + pending, + format!( + "Fetch response-stage interception after {:?} authentication is not supported for navigation without buffering", + auth.scheme + ), + Vec::new(), + ); + } + Some(_) if pending.intercept_response => { + BackgroundInterceptedNavigationFetchMode::Streaming + } + Some(_) => BackgroundInterceptedNavigationFetchMode::Buffered, + None if should_handle_auth && !pending.intercept_response => { + BackgroundInterceptedNavigationFetchMode::CollectStreaming + } + None => BackgroundInterceptedNavigationFetchMode::Streaming, + }; + let job = match conn.background_intercepted_navigation_fetch_job_for_navigation( + &pending.navigation, + auth, + mode, + ) { + Ok(job) => job, + Err(error) => return navigation_error(conn, pending, error, Vec::new()), + }; + PausedNavigationResumeOwnerTaskStep::Pending(Box::new(PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase::Fetch { + pending: Box::new(pending), + should_handle_auth, + prior_network_observation_journal, + job: Box::new(job), + }, + })) +} + +pub(crate) fn complete_paused_navigation_resume_owner_task( + conn: &mut CdpConnection, + completed: CompletedPausedNavigationResumeOwnerTask, +) -> PausedNavigationResumeOwnerTaskStep { + match completed.phase { + CompletedPausedNavigationResumePhase::Fetch { + pending, + should_handle_auth, + prior_network_observation_journal, + result, + } => match *result { + Err(error) => navigation_error(conn, *pending, error, Vec::new()), + Ok(BackgroundInterceptedNavigationFetchResult::Buffered(response)) => { + complete_buffered_fetch( + conn, + *pending, + append_prior_network_observations(response, prior_network_observation_journal), + should_handle_auth, + ) + } + Ok(BackgroundInterceptedNavigationFetchResult::Streaming(response)) => { + complete_streaming_fetch( + conn, + *pending, + append_prior_network_observations(response, prior_network_observation_journal), + should_handle_auth, + ) + } + }, + CompletedPausedNavigationResumePhase::CollectAuthChallenge { + pending, + challenge, + request_cookie_report, + result, + } => match *result { + Ok(response) => { + let event = register_navigation_auth_required_event( + conn, + &pending, + challenge, + request_cookie_report, + response, + ); + let mut output = CommandOutputBuffer::default(); + output.extend_background_events_after_messages([event]); + PausedNavigationResumeOwnerTaskStep::Complete(output.into_plan()) + } + Err(error) => navigation_error(conn, *pending, error, Vec::new()), + }, + CompletedPausedNavigationResumePhase::BuildBuffered { + prefix_events, + token, + state, + engine, + navigation, + } + | CompletedPausedNavigationResumePhase::BuildCaptured { + prefix_events, + token, + state, + engine, + navigation, + } + | CompletedPausedNavigationResumePhase::BuildStreaming { + prefix_events, + token, + state, + engine, + navigation, + } => PausedNavigationResumeOwnerTaskStep::NavigateReady(Box::new( + page::CompletedNavigateCommand::loaded( + prefix_events, + token, + *state, + *engine, + *navigation, + ), + )), + CompletedPausedNavigationResumePhase::PrepareResponseStage { + prefix_events, + pending, + response, + body_progress_source, + prepared_document, + } => match *prepared_document { + Ok(prepared_document) => finish_streaming_response_stage_pause( + conn, + prefix_events, + *pending, + *response, + body_progress_source, + Some(prepared_document), + ), + Err(error) => navigation_error(conn, *pending, error, prefix_events), + }, + } +} + +fn split_pending_auth_navigation( + pending: crate::conn::PendingFetchAuthNavigation, + preserve_request_cookie_report: bool, +) -> ( + PendingFetchNavigation, + std::sync::Arc>, +) { + let crate::conn::PendingFetchAuthNavigation { + interception_session_id, + response_stage_request_id, + document_navigation_token, + navigation, + request_cookie_report, + auth_response, + intercept_response, + response_stage_url_match_policy, + .. + } = pending; + ( + PendingFetchNavigation { + fetch_request_id: response_stage_request_id, + interception_session_id, + document_navigation_token, + navigation, + request_cookie_report: if preserve_request_cookie_report { + request_cookie_report + } else { + None + }, + intercept_response, + response_stage_url_match_policy, + auth_required_blocked_intercepts: Vec::new(), + }, + auth_response, + ) +} + +fn pending_auth_navigation_into_request( + pending: crate::conn::PendingFetchAuthNavigation, + preserve_request_cookie_report: bool, +) -> PendingFetchNavigation { + split_pending_auth_navigation(pending, preserve_request_cookie_report).0 +} + +fn cancel_paused_navigation_auth( + conn: &mut CdpConnection, + pending: crate::conn::PendingFetchAuthNavigation, +) -> PausedNavigationResumeOwnerTaskStep { + let (mut pending, response) = split_pending_auth_navigation(pending, true); + let response = match std::sync::Arc::try_unwrap(response) { + Ok(response) => response, + Err(response) => response.as_ref().clone(), + }; + if response + .observation_journal() + .terminal_response_is_failed_proxy_connect() + { + let response_head = response.response(); + let progress = network::response_stage_main_document_navigation_network_progress( + conn, + &pending.navigation, + pending.request_cookie_report.as_ref(), + ); + let mut prefix_events = Vec::new(); + progress.emit_response_without_extra_info_into_background_events( + &mut prefix_events, + &response_head.final_url, + response_head.status, + &response_head.headers, + false, + ); + let Some(token) = pending.document_navigation_token else { + return PausedNavigationResumeOwnerTaskStep::Complete(CommandOutputPlan::error( + -32000, + "Navigation aborted", + )); + }; + let state = pending.navigation; + let navigation = network::materialize_navigation_load_result( + conn, + &state, + Ok(NavigationLoadOutcome::network_failure( + "net::ERR_HTTP_RESPONSE_CODE_FAILURE".to_owned(), + )), + ); + return PausedNavigationResumeOwnerTaskStep::NavigateReady(Box::new( + page::CompletedNavigateCommand::materialized_with_prefix( + prefix_events, + page::MaterializedNavigationCompletion::new(token, state, navigation), + ), + )); + } + if prepare_navigation_response_stage(conn, &mut pending, &response.response().final_url) { + let mut output = CommandOutputBuffer::default(); + pause_buffered_raw_response_stage_navigation_into_buffer( + conn, + &mut output, + pending, + response, + ); + return PausedNavigationResumeOwnerTaskStep::Complete(output.into_plan()); + } + pending.intercept_response = false; + start_buffered_navigation_build(conn, pending, response, Vec::new()) +} + +fn append_prior_network_observations( + response: NetworkFetchResult, + prior: Option, +) -> NetworkFetchResult { + let Some(mut prior) = prior else { + return response; + }; + let (response, current) = response.into_parts_with_observation_journal(); + prior.append(current); + NetworkFetchResult::with_observation_journal(response, prior) +} + +fn complete_buffered_fetch( + conn: &mut CdpConnection, + mut pending: PendingFetchNavigation, + response: NetworkFetchResult, + should_handle_auth: bool, +) -> PausedNavigationResumeOwnerTaskStep { + let response_head = response.response(); + if let Err(error) = conn.ensure_navigation_response_status( + pending.navigation.requested_url.as_str(), + response_head.status, + should_handle_auth, + ) { + return navigation_error(conn, pending, error, Vec::new()); + } + if should_handle_auth + && matches!(response_head.status, 401 | 407) + && let Some(mut challenge) = extract_auth_challenge(&response_head.headers) + { + populate_auth_challenge_origin( + conn, + pending.navigation.navigate_session_id.as_deref(), + &response_head.final_url, + &mut challenge, + ); + let event = register_navigation_auth_required_event( + conn, + &pending, + challenge, + response_head.request_cookie_report.clone(), + response, + ); + let mut output = CommandOutputBuffer::default(); + output.extend_background_events_after_messages([event]); + return PausedNavigationResumeOwnerTaskStep::Complete(output.into_plan()); + } + if prepare_navigation_response_stage(conn, &mut pending, &response_head.final_url) { + let mut output = CommandOutputBuffer::default(); + pause_buffered_raw_response_stage_navigation_into_buffer( + conn, + &mut output, + pending, + response, + ); + return PausedNavigationResumeOwnerTaskStep::Complete(output.into_plan()); + } + start_buffered_navigation_build(conn, pending, response, Vec::new()) +} + +fn complete_streaming_fetch( + conn: &mut CdpConnection, + mut pending: PendingFetchNavigation, + response: NetworkFetchResult, + should_handle_auth: bool, +) -> PausedNavigationResumeOwnerTaskStep { + let response_head = response.response(); + if let Err(error) = conn.ensure_navigation_response_status( + pending.navigation.requested_url.as_str(), + response_head.status, + should_handle_auth, + ) { + return navigation_error(conn, pending, error, Vec::new()); + } + if should_handle_auth + && matches!(response_head.status, 401 | 407) + && let Some(mut challenge) = extract_auth_challenge(&response_head.headers) + { + populate_auth_challenge_origin( + conn, + pending.navigation.navigate_session_id.as_deref(), + &response_head.final_url, + &mut challenge, + ); + let request_cookie_report = response_head.request_cookie_report.clone(); + let job = conn.background_streaming_response_collection_job(response); + return PausedNavigationResumeOwnerTaskStep::Pending(Box::new( + PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase::CollectAuthChallenge { + pending: Box::new(pending), + challenge, + request_cookie_report, + job: Box::new(job), + }, + }, + )); + } + if response_headers_indicate_attachment_download(&response_head.headers) { + return start_streaming_navigation_build(conn, pending, response, Vec::new()); + } + if !prepare_navigation_response_stage(conn, &mut pending, &response_head.final_url) { + return start_streaming_navigation_build(conn, pending, response, Vec::new()); + } + + let (body_progress_source, prefix_events) = streaming_response_stage_extra_info( + conn, + &pending, + response.response(), + response.observation_journal(), + ); + let job = match conn.background_paused_streaming_response_navigation_preparation_job( + &pending.navigation, + response.response(), + response.observation_journal(), + body_progress_source.clone(), + ) { + Ok(Some(job)) => job, + Ok(None) => { + return finish_streaming_response_stage_pause( + conn, + prefix_events, + pending, + response, + body_progress_source, + None, + ); + } + Err(error) => return navigation_error(conn, pending, error, prefix_events), + }; + PausedNavigationResumeOwnerTaskStep::Pending(Box::new(PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase::PrepareResponseStage { + prefix_events, + pending: Box::new(pending), + response: Box::new(response), + body_progress_source, + job: Box::new(job), + }, + })) +} + +fn start_buffered_navigation_build( + conn: &mut CdpConnection, + pending: PendingFetchNavigation, + response: NetworkFetchResult, + prefix_events: Vec, +) -> PausedNavigationResumeOwnerTaskStep { + let Some(token) = pending.document_navigation_token else { + return PausedNavigationResumeOwnerTaskStep::Complete(CommandOutputPlan::error( + -32000, + "Navigation aborted", + )); + }; + let state = pending.navigation; + let job = conn.background_buffered_response_navigation_load_job_for_navigation( + &state, + response, + network::MainDocumentBodyProgressSource::default(), + ); + PausedNavigationResumeOwnerTaskStep::Pending(Box::new(PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase::BuildBuffered { + prefix_events, + token, + state: Box::new(state), + job: Box::new(job), + }, + })) +} + +fn start_streaming_navigation_build( + conn: &mut CdpConnection, + pending: PendingFetchNavigation, + response: NetworkFetchResult, + prefix_events: Vec, +) -> PausedNavigationResumeOwnerTaskStep { + let Some(token) = pending.document_navigation_token else { + return PausedNavigationResumeOwnerTaskStep::Complete(CommandOutputPlan::error( + -32000, + "Navigation aborted", + )); + }; + let state = pending.navigation; + let (response, network_observation_journal) = response.into_parts_with_observation_journal(); + let job = conn.background_streaming_response_navigation_load_job_for_navigation( + &state, + response, + network_observation_journal, + None, + Vec::new(), + network::MainDocumentBodyProgressSource::default(), + ); + PausedNavigationResumeOwnerTaskStep::Pending(Box::new(PendingPausedNavigationResumeOwnerTask { + phase: PendingPausedNavigationResumePhase::BuildStreaming { + prefix_events, + token, + state: Box::new(state), + job: Box::new(job), + }, + })) +} + +fn finish_streaming_response_stage_pause( + conn: &mut CdpConnection, + prefix_events: Vec, + pending: PendingFetchNavigation, + response: NetworkFetchResult, + body_progress_source: network::MainDocumentBodyProgressSource, + prepared_document: Option, +) -> PausedNavigationResumeOwnerTaskStep { + let mut output = CommandOutputBuffer::default(); + output.extend_background_events_after_messages(prefix_events); + pause_streaming_raw_response_stage_navigation_into_buffer( + conn, + &mut output, + pending, + response, + body_progress_source, + prepared_document, + ); + PausedNavigationResumeOwnerTaskStep::Complete(output.into_plan()) +} + +fn navigation_error( + conn: &mut CdpConnection, + pending: PendingFetchNavigation, + error: String, + prefix_events: Vec, +) -> PausedNavigationResumeOwnerTaskStep { + let Some(token) = pending.document_navigation_token else { + return PausedNavigationResumeOwnerTaskStep::Complete(CommandOutputPlan::error( + -32000, + "Navigation aborted", + )); + }; + let state = pending.navigation; + let navigation = network::materialize_navigation_load_result(conn, &state, Err(error)); + PausedNavigationResumeOwnerTaskStep::NavigateReady(Box::new( + page::CompletedNavigateCommand::materialized_with_prefix( + prefix_events, + page::MaterializedNavigationCompletion::new(token, state, navigation), + ), + )) +} diff --git a/moli-protocol/src/domains/fetch/subresource.rs b/moli-protocol/src/domains/fetch/subresource.rs index 4d53602233..921dc655df 100644 --- a/moli-protocol/src/domains/fetch/subresource.rs +++ b/moli-protocol/src/domains/fetch/subresource.rs @@ -692,11 +692,8 @@ mod tests { fn prepared_subresource_fetch_pause_pairs_emit_network_then_fetch_per_item() { let mut conn = CdpConnection::default(); let mut browser_context = BrowserContext::new("BID-1".to_owned()); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + browser_context.set_active_target_id("TID-1"); + conn.insert_browser_context(browser_context); let page_owner = conn .target_page_residence_identity_for_session(None) .expect("active test target should expose a Page residence identity"); @@ -770,11 +767,8 @@ mod tests { fn fetch_pause_does_not_synthesize_cookie_extra_info() { let mut conn = CdpConnection::default(); let mut browser_context = BrowserContext::new("BID-1".to_owned()); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + browser_context.set_active_target_id("TID-1"); + conn.insert_browser_context(browser_context); let page_owner = conn .target_page_residence_identity_for_session(None) .expect("active test target should expose a Page residence identity"); @@ -814,11 +808,8 @@ mod tests { fn prepared_subresource_fetch_pause_does_not_emit_after_page_replacement() { let mut conn = CdpConnection::default(); let mut browser_context = BrowserContext::new("BID-1".to_owned()); - browser_context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(browser_context); + browser_context.set_active_target_id("TID-1"); + conn.insert_browser_context(browser_context); let page_owner = conn .target_page_residence_identity_for_session(None) .expect("active test target should expose a Page residence identity"); @@ -827,7 +818,7 @@ mod tests { .expect("browser context should remain installed") .active_target .runtime_slot - .replace_page_attachment_id_for_test(); + .set_loaded_page_generation(page_owner.loaded_page_generation() + 1); let mut events = Vec::new(); super::emit_subresource_fetch_pause_outputs( @@ -863,16 +854,12 @@ mod tests { fn prepared_subresource_fetch_pause_can_emit_for_background_owner() { let mut conn = CdpConnection::default(); let mut bc = BrowserContext::new("BID-1".to_owned()); - let target = BackgroundTarget::with_url( + bc.background_targets.push(BackgroundTarget::with_url( "TID-background".to_owned(), Some("SID-background".to_owned()), "https://example.test/background".to_owned(), - ); - bc.background_targets.push(target); - conn.browser_context = Some(bc); - conn.runtime_session_owner_slot_mut(Some("SID-background")) - .expect("background test target runtime slot") - .set_page_attachment_id_for_test(1); + )); + conn.insert_browser_context(bc); let page_owner = conn .target_page_residence_identity_for_session(Some("SID-background")) .expect("background test target should expose a Page residence identity"); diff --git a/moli-protocol/src/domains/fetch/tests/basics.rs b/moli-protocol/src/domains/fetch/tests/basics.rs index 0921d01098..9d50e03989 100644 --- a/moli-protocol/src/domains/fetch/tests/basics.rs +++ b/moli-protocol/src/domains/fetch/tests/basics.rs @@ -27,10 +27,10 @@ fn pending_subresource_fetch_with_owner_kind( ) -> PendingSubresourceFetchRequest { PendingSubresourceFetchRequest { residence: crate::conn::PendingSubresourceFetchResidence::InstalledPage( - crate::conn::TargetPageResidenceIdentity::new_for_test( + crate::conn::TargetPageResidenceIdentity::new( "BID-session-fetch".to_owned(), Some("TID-session-fetch".to_owned()), - 1, + 0, ), ), owner_session_id: Some(owner_session_id.to_owned()), @@ -52,10 +52,10 @@ fn pending_subresource_fetch_auth( owner_session_id: &str, ) -> PendingSubresourceFetchAuthRequest { PendingSubresourceFetchAuthRequest { - page_owner: crate::conn::TargetPageResidenceIdentity::new_for_test( + page_owner: crate::conn::TargetPageResidenceIdentity::new( "BID-session-fetch".to_owned(), Some("TID-session-fetch".to_owned()), - 1, + 0, ), owner_session_id: Some(owner_session_id.to_owned()), action_session_id: Some(owner_session_id.to_owned()), @@ -129,7 +129,8 @@ async fn enable_without_browser_context_errors() { #[tokio::test] async fn enable_sets_fetch_flags_for_supported_patterns() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 2, @@ -154,7 +155,7 @@ async fn enable_and_disable_are_session_local_for_same_target() { bc.set_active_target_id("TID-session-fetch".to_owned()); bc.attach_active_session("SID-primary".to_owned()); assert!(bc.assign_auxiliary_session_to_target("TID-session-fetch", "SID-aux".to_owned())); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 210, @@ -246,7 +247,7 @@ async fn disable_drains_only_current_session_pending_subresource_fetches() { bc.set_active_target_id("TID-session-fetch".to_owned()); bc.attach_active_session("SID-primary".to_owned()); assert!(bc.assign_auxiliary_session_to_target("TID-session-fetch", "SID-aux".to_owned())); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 220, @@ -319,7 +320,7 @@ async fn disable_drains_fetch_owned_pending_when_same_session_network_intercept_ let mut bc = BrowserContext::new("BID-session-mixed-fetch-pending".into()); bc.set_active_target_id("TID-session-fetch".to_owned()); bc.attach_active_session("SID-primary".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 230, @@ -398,23 +399,18 @@ async fn disable_drains_fetch_owned_pending_when_same_session_network_intercept_ #[tokio::test(flavor = "multi_thread")] async fn enable_targets_loaded_background_owner_without_promotion() { let mut ctx = TestContext::new(); + let page_url = "data:text/html,fetch background"; let background = BackgroundTarget::with_url( "TID-background".to_owned(), Some("SID-background".to_owned()), - "about:blank".to_owned(), + page_url.to_owned(), ); let mut bc = BrowserContext::new("BID-fetch-bg".to_owned()); bc.set_active_target_id("TID-active".to_owned()); bc.attach_active_session("SID-active".to_owned()); bc.background_targets.push(background); - ctx.conn.browser_context = Some(bc); - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,fetch background", - Some("SID-background"), - ) - .await; - ctx.sent.clear(); + insert_browser_context_with_navigation(&mut ctx, bc, page_url, Some("SID-background")).await; ctx.process_async(json!({ "id": 1201, @@ -447,16 +443,23 @@ async fn enable_targets_loaded_background_owner_without_promotion() { #[tokio::test(flavor = "multi_thread")] async fn pending_fetch_enable_keeps_background_owner_route_across_completion() { let mut ctx = TestContext::new(); + let active_url = "data:text/html,active fetch"; + let background_url = "data:text/html,background fetch"; let background = BackgroundTarget::with_url( "TID-fetch-background".to_owned(), None, - "about:blank".to_owned(), + background_url.to_owned(), ); let mut bc = BrowserContext::new("BID-fetch-owner-route".to_owned()); bc.set_active_target_id("TID-fetch-active".to_owned()); + bc.set_target_url(active_url.to_owned()); bc.background_targets.push(background); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); + ctx.install_navigation_fixture_for_session_owner(active_url, None) + .await; + install_navigation_for_target_without_session(&mut ctx, "TID-fetch-background", background_url) + .await; ctx.install_navigation_fixture_for_session_owner( "data:text/html,active fetch", @@ -543,12 +546,12 @@ async fn enable_targets_inactive_owner_without_activation() { let mut active = BrowserContext::new("BID-active".to_owned()); active.set_active_target_id("TID-active".to_owned()); active.attach_active_session("SID-active".to_owned()); - ctx.conn.browser_context = Some(active); + ctx.conn.insert_browser_context(active); let mut inactive = BrowserContext::new("BID-inactive".to_owned()); inactive.set_active_target_id("TID-inactive".to_owned()); inactive.attach_active_session("SID-inactive".to_owned()); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); ctx.process_async(json!({ "id": 1202, @@ -598,10 +601,11 @@ async fn enable_targets_inactive_owner_without_activation() { #[tokio::test(flavor = "multi_thread")] async fn disable_targets_loaded_background_owner_without_promotion() { let mut ctx = TestContext::new(); + let page_url = "data:text/html,fetch disable background"; let background = BackgroundTarget::with_url( "TID-background".to_owned(), Some("SID-background".to_owned()), - "about:blank".to_owned(), + page_url.to_owned(), ); let mut bc = BrowserContext::new("BID-fetch-disable-bg".to_owned()); @@ -619,13 +623,7 @@ async fn disable_targets_loaded_background_owner_without_promotion() { }], ); }); - ctx.conn.browser_context = Some(bc); - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,fetch disable background", - Some("SID-background"), - ) - .await; - ctx.sent.clear(); + insert_browser_context_with_navigation(&mut ctx, bc, page_url, Some("SID-background")).await; ctx.process_async(json!({ "id": 1203, @@ -647,7 +645,8 @@ async fn disable_targets_loaded_background_owner_without_promotion() { #[tokio::test] async fn enable_without_params_enables_default_fetch_interception() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 12, @@ -664,7 +663,8 @@ async fn enable_without_params_enables_default_fetch_interception() { #[tokio::test] async fn enable_with_invalid_request_stage_errors_and_keeps_fetch_disabled() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 3, @@ -686,7 +686,8 @@ async fn enable_with_invalid_request_stage_errors_and_keeps_fetch_disabled() { #[tokio::test] async fn enable_with_specific_url_pattern_sets_pattern() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 18, @@ -727,7 +728,8 @@ fn url_pattern_matches_supports_wildcards_and_escapes() { #[tokio::test] async fn enable_with_document_resource_type_filter_sets_document_filter() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 14, @@ -753,7 +755,8 @@ async fn enable_with_document_resource_type_filter_sets_document_filter() { #[tokio::test] async fn enable_with_script_resource_type_filter_sets_script_filter() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 15, @@ -779,7 +782,8 @@ async fn enable_with_script_resource_type_filter_sets_script_filter() { #[tokio::test] async fn enable_with_fetch_resource_type_filter_sets_fetch_filter() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 15, @@ -805,7 +809,8 @@ async fn enable_with_fetch_resource_type_filter_sets_fetch_filter() { #[tokio::test] async fn enable_with_xhr_resource_type_filter_sets_xhr_filter() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 16, @@ -831,7 +836,8 @@ async fn enable_with_xhr_resource_type_filter_sets_xhr_filter() { #[tokio::test] async fn enable_with_ping_resource_type_filter_sets_ping_filter() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 18, @@ -857,7 +863,8 @@ async fn enable_with_ping_resource_type_filter_sets_ping_filter() { #[tokio::test] async fn enable_with_csp_violation_report_resource_type_filter_sets_csp_report_filter() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 19, @@ -887,7 +894,8 @@ async fn enable_with_csp_violation_report_resource_type_filter_sets_csp_report_f #[tokio::test] async fn enable_with_websocket_resource_type_filter_sets_websocket_filter() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 20, @@ -913,7 +921,8 @@ async fn enable_with_websocket_resource_type_filter_sets_websocket_filter() { #[tokio::test] async fn enable_with_other_resource_type_filter_sets_other_filter() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 21, @@ -940,7 +949,8 @@ async fn enable_with_other_resource_type_filter_sets_other_filter() { async fn enable_rejects_unimplemented_parser_discovered_resource_type_filters() { for resource_type in ["Stylesheet", "Media", "TextTrack"] { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new(format!("BID-{resource_type}"))); + ctx.conn + .insert_browser_context(BrowserContext::new(format!("BID-{resource_type}"))); ctx.process_async(json!({ "id": 20, @@ -960,7 +970,8 @@ async fn enable_rejects_unimplemented_parser_discovered_resource_type_filters() #[tokio::test] async fn enable_accepts_image_resource_type_filter() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-Image".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-Image".into())); ctx.process_async(json!({ "id": 22, @@ -986,7 +997,8 @@ async fn enable_accepts_image_resource_type_filter() { #[tokio::test] async fn enable_with_response_stage_pattern_sets_response_stage() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 17, @@ -1008,7 +1020,8 @@ async fn enable_with_response_stage_pattern_sets_response_stage() { #[tokio::test] async fn enable_with_multiple_supported_patterns_enables_fetch_interception() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 13, @@ -1079,6 +1092,7 @@ async fn disable_clears_fetch_state() { request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }, request_cookie_report: None, intercept_response: false, @@ -1118,6 +1132,7 @@ async fn disable_clears_fetch_state() { crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }, challenge: FetchAuthChallenge { origin: "http://example.test".to_owned(), @@ -1135,7 +1150,7 @@ async fn disable_clears_fetch_state() { auth_stage_chain: None, }, ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({"id": 5, "method": "Fetch.disable"})) .await; @@ -1164,7 +1179,7 @@ async fn disable_after_enable_resets_pending_requests() { bc.active_target .fetch_owner .register_pending_fetch_request_id_for_test("INT-2".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({"id": 14, "method": "Fetch.disable"})) .await; @@ -1182,7 +1197,8 @@ async fn disable_after_enable_resets_pending_requests() { #[tokio::test] async fn continue_request_validates_request_id_and_pending_state() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 6, @@ -1204,7 +1220,8 @@ async fn continue_request_validates_request_id_and_pending_state() { #[tokio::test] async fn continue_request_with_intercept_response_still_validates_request_id() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 60, @@ -1228,7 +1245,7 @@ async fn continue_response_and_take_response_body_as_stream_validate_like_fetch_ bc.active_target .fetch_owner .register_pending_fetch_request_id_for_test("INT-43".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 16, @@ -1262,7 +1279,7 @@ async fn pending_fetch_request_can_be_consumed_once() { bc.active_target .fetch_owner .register_pending_fetch_request_id_for_test("INT-7".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 8, @@ -1284,7 +1301,8 @@ async fn pending_fetch_request_can_be_consumed_once() { #[tokio::test] async fn continue_with_auth_and_get_response_body_share_request_validation() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 10, @@ -1342,6 +1360,7 @@ async fn continue_with_auth_rejects_invalid_response_without_consuming_pending_a crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }, challenge: FetchAuthChallenge { origin: "http://example.test".to_owned(), @@ -1359,7 +1378,7 @@ async fn continue_with_auth_rejects_invalid_response_without_consuming_pending_a auth_stage_chain: None, }, ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 77, @@ -1417,6 +1436,7 @@ async fn continue_with_auth_unsupported_challenge_preserves_pending_auth_navigat crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }, challenge: FetchAuthChallenge { origin: "http://example.test".to_owned(), @@ -1434,7 +1454,7 @@ async fn continue_with_auth_unsupported_challenge_preserves_pending_auth_navigat auth_stage_chain: None, }, ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 78, @@ -1588,6 +1608,7 @@ fn emit_auth_required_preserves_request_headers_and_post_data_shape() { request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }, request_cookie_report: None, intercept_response: false, diff --git a/moli-protocol/src/domains/fetch/tests/command_correlation.rs b/moli-protocol/src/domains/fetch/tests/command_correlation.rs index 5fa20f0ccb..6aa811c224 100644 --- a/moli-protocol/src/domains/fetch/tests/command_correlation.rs +++ b/moli-protocol/src/domains/fetch/tests/command_correlation.rs @@ -8,26 +8,22 @@ use moli_core::page::SubresourceResourceType; async fn context_with_loaded_fetch_page() -> TestContext { let mut ctx = TestContext::new(); - let page = ctx - .conn - .load_page_via_runtime_async("data:text/html,fetch correlation") - .await - .expect("fetch correlation page should load"); - let mut browser_context = attached_browser_context(); - browser_context - .active_target - .runtime_slot - .replace_loaded_page(Some(page)); - ctx.conn.browser_context = Some(browser_context); + insert_browser_context_with_navigation( + &mut ctx, + attached_browser_context(), + "data:text/html,fetch correlation", + Some("SID-1"), + ) + .await; ctx } fn pending_request( - page_owner: &crate::conn::TargetPageResidenceIdentity, + page_owner: crate::conn::TargetPageResidenceIdentity, internal_id: u64, ) -> PendingSubresourceFetchRequest { PendingSubresourceFetchRequest { - residence: crate::conn::PendingSubresourceFetchResidence::InstalledPage(page_owner.clone()), + residence: crate::conn::PendingSubresourceFetchResidence::InstalledPage(page_owner), owner_session_id: Some("SID-1".to_owned()), action_session_id: Some("SID-1".to_owned()), owner_kind: PendingSubresourceFetchOwnerKind::Fetch, @@ -43,7 +39,7 @@ fn pending_request( } fn pending_auth( - page_owner: &crate::conn::TargetPageResidenceIdentity, + page_owner: crate::conn::TargetPageResidenceIdentity, internal_id: u64, ) -> PendingSubresourceFetchAuthRequest { let pending = pending_request(page_owner, internal_id); @@ -80,7 +76,7 @@ fn pending_auth( } fn pending_response( - page_owner: &crate::conn::TargetPageResidenceIdentity, + page_owner: crate::conn::TargetPageResidenceIdentity, internal_id: u64, ) -> PendingSubresourceFetchResponseRequest { let pending = pending_request(page_owner, internal_id); @@ -155,12 +151,13 @@ async fn continue_request_registers_correlation_before_renderer_completion() { .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("test Page residence should exist"); + let pending = pending_request(page_owner, 61); assert!( ctx.conn .register_pending_subresource_fetch_request_for_session_owner( Some("SID-1"), "INT-61".to_owned(), - pending_request(&page_owner, 61), + pending, ) ); @@ -180,25 +177,26 @@ async fn continue_request_registers_correlation_before_renderer_completion() { } #[tokio::test(flavor = "multi_thread")] -async fn pending_fetch_command_state_is_bound_to_page_attachment() { +async fn pending_fetch_command_state_is_bound_to_loaded_page_generation() { let mut ctx = context_with_loaded_fetch_page().await; - let initial_owner = ctx + let page_owner = ctx .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("initial Page residence should exist"); + let pending = pending_request(page_owner, 71); assert!( ctx.conn .register_pending_subresource_fetch_request_for_session_owner( Some("SID-1"), "INT-collision".to_owned(), - pending_request(&initial_owner, 71), + pending, ) ); ctx.conn .runtime_session_owner_slot_mut(Some("SID-1")) .expect("runtime owner should remain addressable") - .replace_page_attachment_id_for_test(); + .bump_loaded_page_generation(); assert!( ctx.conn .take_pending_subresource_fetch_request_for_action_session_owner( @@ -214,7 +212,7 @@ async fn pending_fetch_command_state_is_bound_to_page_attachment() { .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("replacement Page residence should exist"); - let mut replacement = pending_request(&replacement_owner, 71); + let mut replacement = pending_request(replacement_owner, 71); replacement.network_request_id = "NETWORK-replacement".to_owned(); assert!( ctx.conn @@ -244,12 +242,13 @@ async fn completed_continue_atomically_claims_a_pause_still_pending_publication( .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("test Page residence should exist"); + let pending = pending_request(page_owner.clone(), 74); assert!( ctx.conn .register_pending_subresource_fetch_request_for_session_owner( Some("SID-1"), "INT-pending-completion".to_owned(), - pending_request(&page_owner, 74), + pending, ) ); @@ -287,12 +286,12 @@ async fn completed_continue_atomically_claims_a_pause_still_pending_publication( ctx.conn .runtime_session_owner_slot_mut(Some("SID-1")) .expect("runtime owner should remain addressable") - .replace_page_attachment_id_for_test(); + .bump_loaded_page_generation(); let replacement_owner = ctx .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("replacement Page residence should exist"); - let replacement = pending_request(&replacement_owner, 75); + let replacement = pending_request(replacement_owner, 75); assert!( ctx.conn .register_in_flight_subresource_fetch_request_for_session_owner( @@ -328,12 +327,14 @@ async fn continuation_claim_preserves_state_owned_by_a_different_page_residence( .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("initial Page residence should exist"); + let retired_in_flight = pending_request(retired_owner.clone(), 76); + let retired_pending = pending_request(retired_owner.clone(), 77); assert!( ctx.conn .register_in_flight_subresource_fetch_request_for_session_owner( Some("SID-1"), Some("INT-retired-in-flight".to_owned()), - pending_request(&retired_owner, 76), + retired_in_flight, ) ); assert!( @@ -341,14 +342,14 @@ async fn continuation_claim_preserves_state_owned_by_a_different_page_residence( .register_pending_subresource_fetch_request_for_session_owner( Some("SID-1"), "INT-retired-pending".to_owned(), - pending_request(&retired_owner, 77), + retired_pending, ) ); ctx.conn .runtime_session_owner_slot_mut(Some("SID-1")) .expect("runtime owner should remain addressable") - .replace_page_attachment_id_for_test(); + .bump_loaded_page_generation(); let replacement_owner = ctx .conn .target_page_residence_identity_for_session(Some("SID-1")) @@ -380,7 +381,7 @@ async fn continuation_claim_preserves_state_owned_by_a_different_page_residence( ctx.conn .runtime_session_owner_slot_mut(Some("SID-1")) .expect("runtime owner should remain addressable") - .install_page_attachment_id_for_test(retired_owner.page_attachment_id()); + .set_loaded_page_generation(retired_owner.loaded_page_generation()); let ClaimedSubresourceContinueRequest::InFlight(in_flight) = ctx .conn .claim_subresource_continue_request_for_session_owner( @@ -414,25 +415,26 @@ async fn continuation_claim_preserves_state_owned_by_a_different_page_residence( } #[tokio::test(flavor = "multi_thread")] -async fn pending_fetch_auth_state_is_bound_to_page_attachment() { +async fn pending_fetch_auth_state_is_bound_to_loaded_page_generation() { let mut ctx = context_with_loaded_fetch_page().await; - let initial_owner = ctx + let page_owner = ctx .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("initial Page residence should exist"); + let pending = pending_auth(page_owner, 72); assert!( ctx.conn .register_pending_subresource_fetch_auth_request_for_session_owner( Some("SID-1"), "AUTH-collision".to_owned(), - pending_auth(&initial_owner, 72), + pending, ) ); ctx.conn .runtime_session_owner_slot_mut(Some("SID-1")) .expect("runtime owner should remain addressable") - .replace_page_attachment_id_for_test(); + .bump_loaded_page_generation(); assert!( ctx.conn .take_pending_subresource_fetch_auth_request_for_action_session_owner( @@ -448,7 +450,7 @@ async fn pending_fetch_auth_state_is_bound_to_page_attachment() { .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("replacement Page residence should exist"); - let mut replacement = pending_auth(&replacement_owner, 72); + let mut replacement = pending_auth(replacement_owner, 72); replacement.network_request_id = "NETWORK-auth-replacement".to_owned(); assert!( ctx.conn @@ -472,25 +474,26 @@ async fn pending_fetch_auth_state_is_bound_to_page_attachment() { } #[tokio::test(flavor = "multi_thread")] -async fn pending_fetch_response_state_is_bound_to_page_attachment() { +async fn pending_fetch_response_state_is_bound_to_loaded_page_generation() { let mut ctx = context_with_loaded_fetch_page().await; - let initial_owner = ctx + let page_owner = ctx .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("initial Page residence should exist"); + let pending = pending_response(page_owner, 73); assert!( ctx.conn .register_pending_subresource_fetch_response_request_for_session_owner( Some("SID-1"), "RESPONSE-collision".to_owned(), - pending_response(&initial_owner, 73), + pending, ) ); ctx.conn .runtime_session_owner_slot_mut(Some("SID-1")) .expect("runtime owner should remain addressable") - .replace_page_attachment_id_for_test(); + .bump_loaded_page_generation(); assert!( ctx.conn .take_pending_subresource_fetch_response_request_for_action_session_owner( @@ -506,7 +509,7 @@ async fn pending_fetch_response_state_is_bound_to_page_attachment() { .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("replacement Page residence should exist"); - let mut replacement = pending_response(&replacement_owner, 73); + let mut replacement = pending_response(replacement_owner, 73); replacement.network_request_id = "NETWORK-response-replacement".to_owned(); assert!( ctx.conn @@ -536,12 +539,13 @@ async fn continue_with_auth_registers_retry_correlation_before_renderer_completi .conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("test Page residence should exist"); + let pending = pending_auth(page_owner, 62); assert!( ctx.conn .register_pending_subresource_fetch_auth_request_for_session_owner( Some("SID-1"), "INT-62".to_owned(), - pending_auth(&page_owner, 62), + pending, ) ); diff --git a/moli-protocol/src/domains/fetch/tests/mod.rs b/moli-protocol/src/domains/fetch/tests/mod.rs index fcf89d6966..14276c561e 100644 --- a/moli-protocol/src/domains/fetch/tests/mod.rs +++ b/moli-protocol/src/domains/fetch/tests/mod.rs @@ -12,7 +12,7 @@ use crate::conn::{BackgroundTarget, BrowserContext, NETWORK_ERROR_PAGE_URL}; use crate::domains::page::LOADER_ID; use crate::testing::{ TestContext, wait_until_frame_stopped_loading, wait_until_message, wait_until_messages, - wait_until_scheduler_message, + wait_until_renderer_document_load, wait_until_scheduler_message, }; use axum::{ Router, @@ -43,9 +43,9 @@ async fn with_loaded_http_document( target_id: &str, ) { let mut bc = BrowserContext::new("BID-1".into()); - bc.attach_active_session(session_id.to_owned()); bc.set_active_target_id(target_id.to_owned()); - ctx.conn.browser_context = Some(bc); + bc.attach_active_session(session_id.to_owned()); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(url, Some(session_id)) .await; ctx.conn @@ -69,14 +69,42 @@ async fn with_loaded_http_background_document( ); let mut bc = BrowserContext::new("BID-1".into()); - bc.attach_active_session(active_session_id.to_owned()); bc.set_active_target_id(active_target_id.to_owned()); + bc.attach_active_session(active_session_id.to_owned()); bc.background_targets.push(background); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(url, Some(background_session_id)) .await; } +async fn insert_browser_context_with_navigation( + ctx: &mut TestContext, + browser_context: BrowserContext, + url: &str, + session_id: Option<&str>, +) { + ctx.conn.insert_browser_context(browser_context); + ctx.install_navigation_fixture_for_session_owner(url, session_id) + .await; +} + +async fn install_navigation_for_target_without_session( + ctx: &mut TestContext, + target_id: &str, + url: &str, +) { + let route = ctx + .conn + .target_session_route_for_target_id(target_id) + .expect("fixture Target route"); + let previous = ctx + .conn + .replace_none_session_owner_route_override(Some(route)); + ctx.install_navigation_fixture_for_session_owner(url, None) + .await; + ctx.conn.replace_none_session_owner_route_override(previous); +} + async fn loaded_page_html_for_test(ctx: &mut TestContext) -> String { let page = ctx .conn diff --git a/moli-protocol/src/domains/fetch/tests/navigation_auth.rs b/moli-protocol/src/domains/fetch/tests/navigation_auth.rs index 2c4bb5ba3a..9c4fe1f4fa 100644 --- a/moli-protocol/src/domains/fetch/tests/navigation_auth.rs +++ b/moli-protocol/src/domains/fetch/tests/navigation_auth.rs @@ -7,6 +7,94 @@ use crate::devtools_runtime::{ DevToolsProtocol, DevToolsRequestId, DevToolsSessionId, DevToolsTargetId, }; +async fn spawn_browser_owner_basic_auth_server() -> (String, tokio::task::JoinHandle<()>) { + async fn handler(headers: HeaderMap) -> impl IntoResponse { + let authorization = headers + .get("authorization") + .and_then(|value| value.to_str().ok()); + if authorization != Some("Basic YWxhZGRpbjpvcGVuc2VzYW1l") { + return ( + StatusCode::UNAUTHORIZED, + [ + (WWW_AUTHENTICATE.as_str(), r#"Basic realm="owner-area""#), + (CONTENT_TYPE.as_str(), "text/plain"), + ], + "auth required", + ) + .into_response(); + } + + ( + StatusCode::OK, + [(CONTENT_TYPE.as_str(), "text/html")], + "
owner authorized
", + ) + .into_response() + } + + 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("/auth", get(handler))) + .await + .unwrap(); + }); + (format!("http://{addr}/auth"), server) +} + +async fn prepare_browser_owner_basic_auth_pause( + ctx: &mut TestContext, + url: &str, + command_id: u64, +) -> String { + let mut bc = attached_browser_context(); + bc.active_target + .runtime_slot + .enable_primary_network_events(); + ctx.conn.insert_browser_context(bc); + + ctx.process_async(json!({ + "id": command_id, + "method": "Fetch.enable", + "sessionId": "SID-1", + "params": { "handleAuthRequests": true } + })) + .await; + ctx.expect_result(command_id, json!({}), Some("SID-1")); + + ctx.process_async(json!({ + "id": command_id + 1, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": url } + })) + .await; + let paused = take_main_document_request_pause(ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("request-stage Fetch id") + .to_owned(); + ctx.process_async(json!({ + "id": command_id + 2, + "method": "Fetch.continueRequest", + "sessionId": "SID-1", + "params": { "requestId": request_id } + })) + .await; + ctx.expect_result(command_id + 2, json!({}), Some("SID-1")); + let auth_required = ctx.take_one(); + assert_eq!(auth_required["method"], "Fetch.authRequired"); + assert_eq!(auth_required["params"]["authChallenge"]["scheme"], "basic"); + assert_eq!( + auth_required["params"]["authChallenge"]["realm"], + "owner-area" + ); + auth_required["params"]["requestId"] + .as_str() + .expect("auth-stage Fetch id") + .to_owned() +} + #[tokio::test(flavor = "multi_thread")] async fn continue_with_auth_retries_navigation_with_basic_credentials() { async fn handler(headers: HeaderMap) -> impl IntoResponse { @@ -46,7 +134,7 @@ async fn continue_with_auth_retries_navigation_with_basic_credentials() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_dom_events_for_test(Some("SID-1")); let url = format!("http://{addr}/auth"); @@ -168,6 +256,235 @@ async fn continue_with_auth_retries_navigation_with_basic_credentials() { server.abort(); } +#[tokio::test(flavor = "multi_thread")] +async fn continue_with_auth_exact_owner_survives_frontend_wait_loss() { + let (url, server) = spawn_browser_owner_basic_auth_server().await; + let mut ctx = TestContext::new(); + let request_id = prepare_browser_owner_basic_auth_pause(&mut ctx, &url, 73_100).await; + ctx.take_all(); + + let expected_page = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("auth-paused navigation Page owner"); + let raw = json!({ + "id": 73_103, + "method": "Fetch.continueWithAuth", + "sessionId": "SID-1", + "params": { + "requestId": request_id, + "authChallengeResponse": { + "response": "ProvideCredentials", + "username": "aladdin", + "password": "opensesame" + } + } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("main-Document continueWithAuth must await Browser Host"); + }; + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (_, participant) = dispatch.into_parts(); + let participant = participant.expect("paused navigation auth participant"); + assert_eq!( + participant.paused_navigation_decision_page_owner_for_test(), + Some(&expected_page), + "Browser Host must retain the exact Page captured at auth admission" + ); + drop(frontend_wait); + + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (messages, _) = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + assert!( + messages.iter().all(|message| message["id"] != 73_103), + "frontend loss must drop only the continueWithAuth acknowledgement" + ); + assert!(messages.iter().any(|message| { + message["id"] == 73_101 + && message["sessionId"] == "SID-1" + && message["result"]["loaderId"] == LOADER_ID + })); + assert!( + loaded_page_html_for_test(&mut ctx) + .await + .contains("owner authorized") + ); + + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_continue_with_auth_owner_participant_cannot_load_successor_page() { + let (url, server) = spawn_browser_owner_basic_auth_server().await; + let mut ctx = TestContext::new(); + let request_id = prepare_browser_owner_basic_auth_pause(&mut ctx, &url, 73_200).await; + ctx.take_all(); + + let raw = json!({ + "id": 73_203, + "method": "Fetch.continueWithAuth", + "sessionId": "SID-1", + "params": { + "requestId": request_id, + "authChallengeResponse": { + "response": "ProvideCredentials", + "username": "aladdin", + "password": "opensesame" + } + } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("main-Document continueWithAuth must await Browser Host"); + }; + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (_, participant) = dispatch.into_parts(); + let participant = participant.expect("paused navigation auth participant"); + let captured_generation = participant + .paused_navigation_decision_page_owner_for_test() + .expect("participant Page identity") + .loaded_page_generation(); + let residence = ctx + .conn + .target_page_residence_handle_for_session(Some("SID-1")) + .expect("Page residence handle"); + assert_eq!( + residence.advance_generation_for_test_fixture(), + captured_generation + 1 + ); + + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let _ = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + let completed = frontend_wait.wait().await; + let crate::conn::CdpCommandTaskStep::Complete(outcome) = + ctx.conn.complete_pending_command_dispatch(completed).await + else { + panic!("stale continueWithAuth projection should be terminal"); + }; + let (messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + assert!( + messages + .iter() + .any(|message| { message["id"] == 73_203 && message["result"] == json!({}) }) + ); + assert!(messages.iter().any(|message| { + message["id"] == 73_201 && message["error"]["message"] == "Navigation aborted" + })); + assert!(messages.iter().all(|message| { + message["method"] != "Network.responseReceived" + && message["method"] != "Page.frameNavigated" + })); + assert_eq!(residence.generation(), captured_generation + 1); + + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stopped_browser_host_restores_unmodified_auth_navigation() { + let (url, server) = spawn_browser_owner_basic_auth_server().await; + let mut ctx = TestContext::new(); + let request_id = prepare_browser_owner_basic_auth_pause(&mut ctx, &url, 73_300).await; + ctx.take_all(); + let original = ctx + .conn + .browser_context + .as_ref() + .expect("browser context") + .active_target + .fetch_owner + .pending_fetch_auth_navigation_for_test(&request_id) + .expect("original auth-paused navigation") + .clone(); + ctx.stop_browser_host_for_test(); + + let raw = json!({ + "id": 73_303, + "method": "Fetch.continueWithAuth", + "sessionId": "SID-1", + "params": { + "requestId": request_id, + "authChallengeResponse": { + "response": "ProvideCredentials", + "username": "must-not-apply", + "password": "must-not-apply" + } + } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Complete(outcome) = ctx.conn.start_command_dispatch(&raw) + else { + panic!("stopped Browser Host must reject without a fallback wait"); + }; + let (messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + assert!(messages.iter().any(|message| { + message["id"] == 73_303 + && message["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Browser Host stopped")) + })); + let restored = ctx + .conn + .browser_context + .as_ref() + .expect("browser context") + .active_target + .fetch_owner + .pending_fetch_auth_navigation_for_test(&request_id) + .expect("restored auth-paused navigation"); + assert_eq!(restored.owner_session_id, original.owner_session_id); + assert_eq!(restored.action_session_id, original.action_session_id); + assert_eq!( + restored.interception_session_id, + original.interception_session_id + ); + assert_eq!(restored.owner_kind, original.owner_kind); + assert_eq!(restored.fetch_request_id, original.fetch_request_id); + assert_eq!( + restored.response_stage_request_id, + original.response_stage_request_id + ); + assert_eq!( + restored.navigation.requested_url, + original.navigation.requested_url + ); + assert_eq!( + restored.navigation.request_method, + original.navigation.request_method + ); + assert_eq!( + restored.navigation.request_body, + original.navigation.request_body + ); + assert_eq!(restored.challenge.origin, original.challenge.origin); + assert_eq!(restored.challenge.source, original.challenge.source); + assert_eq!(restored.challenge.scheme, original.challenge.scheme); + assert_eq!(restored.challenge.realm, original.challenge.realm); + assert!(std::sync::Arc::ptr_eq( + &restored.auth_response, + &original.auth_response + )); + + server.abort(); +} + #[tokio::test(flavor = "multi_thread")] async fn devtools_continue_response_credentials_retries_auth_navigation() { async fn handler(headers: HeaderMap) -> impl IntoResponse { @@ -207,7 +524,7 @@ async fn devtools_continue_response_credentials_retries_auth_navigation() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); let url = format!("http://{addr}/auth"); @@ -353,7 +670,7 @@ async fn continue_with_auth_and_intercept_response_pauses_before_authorized_body bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/auth"); ctx.process_async(json!({ @@ -487,7 +804,7 @@ async fn continue_with_non_basic_auth_and_intercept_response_fails_explicitly_wi bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/auth"); ctx.process_async(json!({ @@ -615,7 +932,7 @@ async fn navigation_auth_required_includes_synthesized_cookie_header() { )], ); } - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 72_001, @@ -701,7 +1018,7 @@ async fn continue_with_auth_prefers_supported_navigation_challenge_over_unsuppor bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/auth"); ctx.process_async(json!({ @@ -851,7 +1168,7 @@ async fn run_navigation_cdp_fetch_then_bidi_network_auth_required_terminal( bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); let url = format!("http://{addr}/auth"); diff --git a/moli-protocol/src/domains/fetch/tests/navigation_control.rs b/moli-protocol/src/domains/fetch/tests/navigation_control.rs index f79406a437..772cf243da 100644 --- a/moli-protocol/src/domains/fetch/tests/navigation_control.rs +++ b/moli-protocol/src/domains/fetch/tests/navigation_control.rs @@ -1,6 +1,22 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use super::*; +use crate::devtools_runtime::{ + DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, + DevToolsFulfillInterceptedRequestCommand, DevToolsProtocol, DevToolsRequestId, + DevToolsSessionId, DevToolsTargetId, +}; + +async fn install_loaded_about_blank(ctx: &mut TestContext) { + ctx.conn.insert_browser_context(attached_browser_context()); + ctx.install_navigation_fixture_for_session_owner("about:blank", Some("SID-1")) + .await; + ctx.sent.clear(); + ctx.conn + .runtime_session_owner_slot_mut(Some("SID-1")) + .expect("Fetch fixture target") + .enable_primary_network_events(); +} #[test] fn parse_binary_response_headers_decodes_nul_separated_header_block() { @@ -33,7 +49,7 @@ fn parse_binary_response_headers_rejects_invalid_header_value() { #[tokio::test] async fn continue_request_rejects_invalid_url_without_consuming_pending_navigation() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); ctx.process_async(json!({ "id": 62, @@ -85,7 +101,7 @@ async fn continue_request_rejects_invalid_url_without_consuming_pending_navigati #[tokio::test] async fn continue_request_rejects_invalid_post_data_without_consuming_pending_navigation() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); ctx.process_async(json!({ "id": 65, @@ -152,7 +168,7 @@ async fn request_paused_then_continue_request_resumes_main_document_navigation() }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_dom_events_for_test(Some("SID-1")); let url = format!("http://{addr}/page"); @@ -216,10 +232,239 @@ async fn request_paused_then_continue_request_resumes_main_document_navigation() server.abort(); } +#[tokio::test(flavor = "multi_thread")] +async fn continue_request_exact_owner_survives_frontend_wait_loss() { + async fn handler() -> impl IntoResponse { + ( + [(CONTENT_TYPE.as_str(), "text/html")], + "
owner continue
", + ) + } + + 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("/page", get(handler))) + .await + .unwrap(); + }); + let mut ctx = TestContext::new(); + install_loaded_about_blank(&mut ctx).await; + ctx.process_async(json!({ + "id": 30_200, + "method": "Fetch.enable", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(30_200, json!({}), Some("SID-1")); + ctx.process_async(json!({ + "id": 30_201, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/page") } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("Fetch request id") + .to_owned(); + ctx.take_all(); + + let expected_page = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("paused navigation Page owner"); + let raw = json!({ + "id": 30_202, + "method": "Fetch.continueRequest", + "sessionId": "SID-1", + "params": { "requestId": request_id } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("main-Document continueRequest must await Browser Host"); + }; + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (_, participant) = dispatch.into_parts(); + let participant = participant.expect("paused navigation Continue participant"); + assert_eq!( + participant.paused_navigation_decision_page_owner_for_test(), + Some(&expected_page) + ); + drop(frontend_wait); + + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (messages, _) = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + assert!(messages.iter().all(|message| message["id"] != 30_202)); + assert!(messages.iter().any(|message| { + message["id"] == 30_201 + && message["sessionId"] == "SID-1" + && message["result"]["loaderId"] == LOADER_ID + })); + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_continue_request_owner_participant_cannot_load_successor_page() { + let mut ctx = TestContext::new(); + install_loaded_about_blank(&mut ctx).await; + ctx.process_async(json!({ + "id": 30_210, + "method": "Fetch.enable", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(30_210, json!({}), Some("SID-1")); + ctx.process_async(json!({ + "id": 30_211, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": "http://example.test/stale-owner-continue" } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("Fetch request id") + .to_owned(); + ctx.take_all(); + + let raw = json!({ + "id": 30_212, + "method": "Fetch.continueRequest", + "sessionId": "SID-1", + "params": { "requestId": request_id } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("main-Document continueRequest must await Browser Host"); + }; + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (_, participant) = dispatch.into_parts(); + let participant = participant.expect("paused navigation Continue participant"); + let captured_generation = participant + .paused_navigation_decision_page_owner_for_test() + .expect("participant Page identity") + .loaded_page_generation(); + let residence = ctx + .conn + .target_page_residence_handle_for_session(Some("SID-1")) + .expect("Page residence handle"); + assert_eq!( + residence.advance_generation_for_test_fixture(), + captured_generation + 1 + ); + + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let _ = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + let completed = frontend_wait.wait().await; + let crate::conn::CdpCommandTaskStep::Complete(outcome) = + ctx.conn.complete_pending_command_dispatch(completed).await + else { + panic!("stale continueRequest projection should be terminal"); + }; + let (messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + assert!( + messages + .iter() + .any(|message| { message["id"] == 30_212 && message["result"] == json!({}) }) + ); + assert!(messages.iter().any(|message| { + message["id"] == 30_211 && message["error"]["message"] == "Navigation aborted" + })); + assert!(messages.iter().all(|message| { + message["method"] != "Network.responseReceived" + && message["method"] != "Page.frameNavigated" + })); + assert_eq!(residence.generation(), captured_generation + 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stopped_browser_host_restores_unmodified_continue_request_navigation() { + let mut ctx = TestContext::new(); + install_loaded_about_blank(&mut ctx).await; + ctx.process_async(json!({ + "id": 30_220, + "method": "Fetch.enable", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(30_220, json!({}), Some("SID-1")); + let original_url = "http://example.test/original-owner-continue"; + ctx.process_async(json!({ + "id": 30_221, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": original_url } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("Fetch request id") + .to_owned(); + ctx.take_all(); + ctx.stop_browser_host_for_test(); + + let raw = json!({ + "id": 30_222, + "method": "Fetch.continueRequest", + "sessionId": "SID-1", + "params": { + "requestId": request_id, + "url": "http://example.test/must-not-apply", + "method": "POST", + "postData": BASE64_STANDARD.encode("must-not-apply") + } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Complete(outcome) = ctx.conn.start_command_dispatch(&raw) + else { + panic!("stopped Browser Host must reject without a fallback wait"); + }; + let (messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + assert!(messages.iter().any(|message| { + message["id"] == 30_222 + && message["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Browser Host stopped")) + })); + let pending = ctx + .conn + .browser_context + .as_ref() + .expect("browser context") + .active_target + .fetch_owner + .pending_fetch_navigation_for_test(&request_id) + .expect("restored pending navigation"); + assert_eq!(pending.navigation.requested_url.as_str(), original_url); + assert_eq!(pending.navigation.request_method, "GET"); + assert!(pending.navigation.request_body.is_none()); +} + #[tokio::test] async fn main_document_request_uses_loader_id_as_observed_network_request_id() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); ctx.process_async(json!({ "id": 301, @@ -257,7 +502,7 @@ async fn fail_request_blocked_by_client_maps_main_document_navigation_to_net_err bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 303, @@ -298,10 +543,532 @@ async fn fail_request_blocked_by_client_maps_main_document_navigation_to_net_err ctx.expect_error(304, -32000, "net::ERR_BLOCKED_BY_CLIENT"); } +#[tokio::test(flavor = "multi_thread")] +async fn fail_request_main_document_is_applied_by_exact_browser_owner_participant() { + let mut ctx = TestContext::new(); + install_loaded_about_blank(&mut ctx).await; + + ctx.process_async(json!({ + "id": 30_300, + "method": "Fetch.enable", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(30_300, json!({}), Some("SID-1")); + ctx.process_async(json!({ + "id": 30_301, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": "http://example.test/owner-fail" } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("Fetch request id") + .to_owned(); + ctx.take_all(); + + let expected_page = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("paused navigation Page owner"); + let raw = json!({ + "id": 30_302, + "method": "Fetch.failRequest", + "sessionId": "SID-1", + "params": { "requestId": request_id, "errorReason": "BlockedByClient" } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("main-Document failRequest must await Browser Host"); + }; + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (start_outcome, participant) = dispatch.into_parts(); + let (start_events, start_scheduler_events, start_predecessor) = + start_outcome.into_protocol_event_parts(); + assert!(start_events.is_empty()); + assert!(start_scheduler_events.is_empty()); + assert!(start_predecessor.is_none()); + let participant = participant.expect("paused navigation decision participant"); + assert_eq!( + participant.paused_navigation_decision_page_owner_for_test(), + Some(&expected_page), + "Browser Host must retain the exact Page captured at admission" + ); + + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (host_messages, _) = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + assert!( + host_messages.is_empty(), + "a live frontend receiver owns the final projection" + ); + + let completed = frontend_wait.wait().await; + let crate::conn::CdpCommandTaskStep::Complete(outcome) = + ctx.conn.complete_pending_command_dispatch(completed).await + else { + panic!("Browser Owner failRequest projection should be terminal"); + }; + let (messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + let fail_response = messages + .iter() + .position(|message| message["id"] == 30_302) + .expect("Fetch.failRequest response"); + let loading_failed = messages + .iter() + .position(|message| message["method"] == "Network.loadingFailed") + .expect("navigation loadingFailed event"); + let navigate_response = messages + .iter() + .position(|message| message["id"] == 30_301) + .expect("original Page.navigate response"); + assert!(fail_response < loading_failed); + assert!(loading_failed < navigate_response); + assert_eq!(messages[fail_response]["result"], json!({})); + assert_eq!( + messages[loading_failed]["params"]["errorText"], + "net::ERR_BLOCKED_BY_CLIENT" + ); + assert_eq!( + messages[navigate_response]["error"]["message"], + "net::ERR_BLOCKED_BY_CLIENT" + ); + let facts = ctx.conn.browser_fact_snapshot_for_test(); + let failed_navigation = facts + .iter() + .find(|fact| { + matches!( + fact.fact(), + moli_core::browser_host::BrowserFact::NavigationFailed { + failure: + moli_core::browser_host::BrowserNavigationFailure::Network { + error_text, + }, + .. + } if error_text == "net::ERR_BLOCKED_BY_CLIENT" + ) + }) + .expect("Fetch.failRequest should publish a failed navigation terminal"); + let moli_core::browser_host::BrowserFact::NavigationFailed { previous_page, .. } = + failed_navigation.fact() + else { + unreachable!("the fact was selected as NavigationFailed") + }; + assert_eq!(previous_page.as_ref(), Some(&expected_page)); + assert_eq!( + failed_navigation.page_residence().loaded_page_generation(), + expected_page.loaded_page_generation() + 1 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_fail_request_owner_participant_cannot_invalidate_successor_page() { + let mut ctx = TestContext::new(); + install_loaded_about_blank(&mut ctx).await; + ctx.process_async(json!({ + "id": 30_310, + "method": "Fetch.enable", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(30_310, json!({}), Some("SID-1")); + ctx.process_async(json!({ + "id": 30_311, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": "http://example.test/stale-owner-fail" } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("Fetch request id") + .to_owned(); + ctx.take_all(); + + let raw = json!({ + "id": 30_312, + "method": "Fetch.failRequest", + "sessionId": "SID-1", + "params": { "requestId": request_id, "errorReason": "Aborted" } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("main-Document failRequest must await Browser Host"); + }; + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (_, participant) = dispatch.into_parts(); + let participant = participant.expect("paused navigation decision participant"); + let captured_generation = participant + .paused_navigation_decision_page_owner_for_test() + .expect("participant Page identity") + .loaded_page_generation(); + let residence = ctx + .conn + .target_page_residence_handle_for_session(Some("SID-1")) + .expect("Page residence handle"); + assert_eq!( + residence.advance_generation_for_test_fixture(), + captured_generation + 1 + ); + + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let _ = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + let completed = frontend_wait.wait().await; + let crate::conn::CdpCommandTaskStep::Complete(outcome) = + ctx.conn.complete_pending_command_dispatch(completed).await + else { + panic!("stale failRequest projection should be terminal"); + }; + let (messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + assert!( + messages + .iter() + .any(|message| { message["id"] == 30_312 && message["result"] == json!({}) }) + ); + assert!(messages.iter().any(|message| { + message["id"] == 30_311 && message["error"]["message"] == "Navigation aborted" + })); + assert!( + messages + .iter() + .all(|message| message["method"] != "Network.loadingFailed"), + "stale work must not project a failure through the successor Page" + ); + assert_eq!(residence.generation(), captured_generation + 1); + assert!( + ctx.conn + .browser_context + .as_ref() + .expect("browser context") + .has_loaded_page(), + "stale failure must not discard the successor physical Page" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn dropping_fail_request_frontend_wait_does_not_cancel_browser_decision() { + let mut ctx = TestContext::new(); + install_loaded_about_blank(&mut ctx).await; + ctx.process_async(json!({ + "id": 30_320, + "method": "Fetch.enable", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(30_320, json!({}), Some("SID-1")); + ctx.process_async(json!({ + "id": 30_321, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": "http://example.test/detached-owner-fail" } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("Fetch request id") + .to_owned(); + ctx.take_all(); + + let raw = json!({ + "id": 30_322, + "method": "Fetch.failRequest", + "sessionId": "SID-1", + "params": { "requestId": request_id, "errorReason": "Aborted" } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("main-Document failRequest must await Browser Host"); + }; + drop(frontend_wait); + + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (messages, _) = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + assert!(messages.iter().all(|message| message["id"] != 30_322)); + assert!( + messages + .iter() + .any(|message| { message["id"] == 30_321 && message["error"]["message"] == "Aborted" }) + ); + assert!(messages.iter().any(|message| { + message["method"] == "Network.loadingFailed" && message["params"]["errorText"] == "Aborted" + })); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stopped_browser_host_restores_fail_request_navigation_without_direct_fallback() { + let mut ctx = TestContext::new(); + install_loaded_about_blank(&mut ctx).await; + ctx.process_async(json!({ + "id": 30_330, + "method": "Fetch.enable", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(30_330, json!({}), Some("SID-1")); + ctx.process_async(json!({ + "id": 30_331, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": "http://example.test/stopped-host-owner-fail" } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("Fetch request id") + .to_owned(); + ctx.take_all(); + ctx.stop_browser_host_for_test(); + + let raw = json!({ + "id": 30_332, + "method": "Fetch.failRequest", + "sessionId": "SID-1", + "params": { "requestId": request_id, "errorReason": "Aborted" } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Complete(outcome) = ctx.conn.start_command_dispatch(&raw) + else { + panic!("stopped Browser Host must reject without a fallback wait"); + }; + let (messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + assert!(messages.iter().any(|message| { + message["id"] == 30_332 + && message["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Browser Host stopped")) + })); + let fetch_owner = &ctx + .conn + .browser_context + .as_ref() + .expect("browser context") + .active_target + .fetch_owner; + assert!(fetch_owner.has_pending_fetch_request_id_for_test(&request_id)); + assert!(fetch_owner.has_pending_fetch_navigation_for_test(&request_id)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fulfill_request_main_document_is_selected_by_exact_browser_owner_after_frontend_drop() { + let mut ctx = TestContext::new(); + install_loaded_about_blank(&mut ctx).await; + ctx.process_async(json!({ + "id": 30_340, + "method": "Fetch.enable", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(30_340, json!({}), Some("SID-1")); + ctx.process_async(json!({ + "id": 30_341, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": "http://example.test/owner-fulfill" } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("Fetch request id") + .to_owned(); + ctx.take_all(); + + let expected_page = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("paused navigation Page owner"); + let raw = json!({ + "id": 30_342, + "method": "Fetch.fulfillRequest", + "sessionId": "SID-1", + "params": { + "requestId": request_id, + "responseCode": 201, + "responseHeaders": [{ "name": "content-type", "value": "text/html" }], + "body": "PCFkb2N0eXBlIGh0bWw+PG1haW4+b3duZXItZnVsZmlsbC1maW5pc2hlZDwvbWFpbj4=" + } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("main-Document fulfillRequest must await Browser Host"); + }; + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (_, participant) = dispatch.into_parts(); + let participant = participant.expect("synthetic navigation participant"); + assert_eq!( + participant.paused_navigation_decision_page_owner_for_test(), + Some(&expected_page), + "Browser Host must retain the exact Page captured at fulfill admission" + ); + drop(frontend_wait); + + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (messages, _) = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + assert!(messages.iter().all(|message| message["id"] != 30_342)); + assert!( + messages + .iter() + .any(|message| message["id"] == 30_341 && message["result"]["frameId"] == "TID-1"), + "dropping the Fetch frontend must not cancel the accepted synthetic navigation" + ); + assert!( + loaded_page_html_for_test(&mut ctx) + .await + .contains("owner-fulfill-finished") + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn typed_fulfill_request_exposes_owner_wait_without_borrowing_connection() { + let mut ctx = TestContext::new(); + install_loaded_about_blank(&mut ctx).await; + ctx.process_async(json!({ + "id": 30_350, + "method": "Fetch.enable", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(30_350, json!({}), Some("SID-1")); + ctx.process_async(json!({ + "id": 30_351, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": "http://example.test/typed-owner-fulfill" } + })) + .await; + let paused = take_main_document_request_pause(&mut ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("Fetch request id") + .to_owned(); + ctx.take_all(); + + let expected_page = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("paused navigation Page owner"); + let command = + DevToolsCommand::FulfillInterceptedRequest(DevToolsFulfillInterceptedRequestCommand { + context: DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverBidi, + session_id: Some(DevToolsSessionId::from("SID-1")), + target_id: Some(DevToolsTargetId::from("TID-1")), + browser_context_id: None, + }, + request_id: DevToolsRequestId::from(request_id.as_str()), + response_code: 202, + response_headers: vec![("content-type".to_owned(), "text/html".to_owned())], + body: Some(b"
typed-owner-finished
".to_vec()), + response_phrase: None, + }); + let step = ctx + .conn + .try_start_devtools_fetch_command_task(command) + .await + .expect("typed terminal Fetch command must use its scheduler-facing task"); + let crate::domains::fetch::DevToolsFetchCommandTaskStep::Pending(pending) = step else { + panic!("typed main-Document fulfill must await Browser Host"); + }; + assert!(pending.holds_navigation_renderer_publication_gate()); + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (_, participant) = dispatch.into_parts(); + let participant = participant.expect("typed synthetic navigation participant"); + assert_eq!( + participant.paused_navigation_decision_page_owner_for_test(), + Some(&expected_page) + ); + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (mut messages, _) = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + + let completed = pending.wait().await; + let outcome = ctx + .conn + .complete_devtools_fetch_command_task(completed) + .await; + let ( + result, + scheduler_events, + mut protocol_events, + renderer_output_boundary, + mut post_renderer_output_events, + mut post_response_events, + renderer_output_predecessor, + ) = outcome.into_fenced_complete_parts(); + assert_eq!( + result.expect("typed fulfill should project an empty result"), + DevToolsCommandResult::Empty + ); + assert!(scheduler_events.is_empty()); + protocol_events.append(&mut post_renderer_output_events); + protocol_events.append(&mut post_response_events); + messages.extend( + protocol_events + .into_iter() + .map(crate::conn::BackgroundProtocolEvent::into_protocol_message), + ); + assert!( + messages + .iter() + .any(|message| message["id"] == 30_351 && message["result"]["frameId"] == "TID-1"), + "typed owner completion must settle the original navigation: {messages:?}" + ); + assert!(renderer_output_boundary.is_none()); + assert!(renderer_output_predecessor.is_none()); + assert!( + loaded_page_html_for_test(&mut ctx) + .await + .contains("typed-owner-finished") + ); +} + #[tokio::test(flavor = "multi_thread")] async fn request_paused_then_continue_request_fails_when_network_offline() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); ctx.process_async(json!({ "id": 16630, @@ -395,7 +1162,7 @@ async fn response_stage_document_pattern_pauses_main_document_after_response() { &[("set-cookie".to_owned(), "sid=1; Path=/".to_owned())], ); } - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 33, @@ -516,12 +1283,10 @@ async fn document_url_pattern_only_pauses_matching_main_document() { let plain_url = format!("http://{addr}/plain"); let match_url = format!("http://{addr}/match"); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); - { - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.attach_active_session("SID-1"); - bc.set_active_target_id("TID-1"); - } + let mut bc = BrowserContext::new("BID-1".into()); + bc.set_active_target_id("TID-1"); + bc.attach_active_session("SID-1"); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 394, @@ -588,7 +1353,7 @@ async fn continue_request_with_post_data_marks_network_request_as_having_post_da bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -823,7 +1588,7 @@ async fn continue_request_with_intercept_response_pauses_after_response_until_co bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_dom_events_for_test(Some("SID-1")); let url = format!("http://{addr}/page"); @@ -952,8 +1717,8 @@ async fn intercepted_navigation_start_events_stay_before_network_pause_with_back bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); - let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + ctx.conn.insert_browser_context(bc); + let (sender, mut receiver) = crate::conn::browser_background_output_channel(); ctx.conn.set_background_event_sender(sender); ctx.process_async(json!({ @@ -1010,7 +1775,7 @@ async fn disable_aborts_paused_main_document_navigation() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 78, 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..7d4ec98b97 100644 --- a/moli-protocol/src/domains/fetch/tests/navigation_response_stage.rs +++ b/moli-protocol/src/domains/fetch/tests/navigation_response_stage.rs @@ -1,6 +1,247 @@ use super::*; use base64::Engine as _; +async fn pause_html_navigation_at_response_stage( + ctx: &mut TestContext, + command_id_base: u64, + body: &'static str, +) -> (tokio::task::JoinHandle<()>, String) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 2048]; + let _ = stream.read(&mut request).await.unwrap(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + let _ = stream.shutdown().await; + }); + + let mut browser_context = attached_browser_context(); + browser_context + .active_target + .runtime_slot + .enable_primary_network_events(); + ctx.conn.insert_browser_context(browser_context); + ctx.process_async(json!({ + "id": command_id_base, + "method": "Fetch.enable", + "sessionId": "SID-1", + "params": { + "patterns": [{ + "urlPattern": "*", + "requestStage": "Response", + "resourceType": "Document" + }] + } + })) + .await; + ctx.expect_result(command_id_base, json!({}), Some("SID-1")); + ctx.process_async(json!({ + "id": command_id_base + 1, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/page") } + })) + .await; + let paused = take_main_document_request_pause(ctx).await; + let request_id = paused["params"]["requestId"] + .as_str() + .expect("response-stage request id") + .to_owned(); + (server, request_id) +} + +#[tokio::test(flavor = "multi_thread")] +async fn continue_response_main_document_is_selected_by_exact_browser_owner_after_frontend_drop() { + let mut ctx = TestContext::new(); + let (server, request_id) = pause_html_navigation_at_response_stage( + &mut ctx, + 36_800, + "
response-owner-finished
", + ) + .await; + ctx.take_all(); + + let expected_page = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("response-paused navigation Page owner"); + let raw = json!({ + "id": 36_802, + "method": "Fetch.continueResponse", + "sessionId": "SID-1", + "params": { "requestId": request_id } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("main-Document continueResponse must await Browser Host"); + }; + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (_, participant) = dispatch.into_parts(); + let participant = participant.expect("response-stage navigation participant"); + assert_eq!( + participant.paused_navigation_decision_page_owner_for_test(), + Some(&expected_page), + "Browser Host must retain the exact Page captured at response admission" + ); + drop(frontend_wait); + + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (host_messages, _) = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + assert!(host_messages.iter().all(|message| message["id"] != 36_802)); + let navigation_completed_in_host_turn = + host_messages.iter().any(|message| message["id"] == 36_801); + if !navigation_completed_in_host_turn { + wait_until_message( + &mut ctx, + "SID-1", + "response-stage navigation after frontend drop", + |message| message["id"] == 36_801, + ) + .await; + } + assert!( + navigation_completed_in_host_turn || ctx.sent.iter().any(|message| message["id"] == 36_801), + "dropping the Fetch frontend wait must not stop the accepted Browser action" + ); + assert!( + loaded_page_html_for_test(&mut ctx) + .await + .contains("response-owner-finished") + ); + server.await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stopped_browser_host_restores_continue_response_transfer_without_direct_fallback() { + let mut ctx = TestContext::new(); + let (server, request_id) = pause_html_navigation_at_response_stage( + &mut ctx, + 36_810, + "
must-remain-paused
", + ) + .await; + ctx.take_all(); + ctx.stop_browser_host_for_test(); + + let raw = json!({ + "id": 36_812, + "method": "Fetch.continueResponse", + "sessionId": "SID-1", + "params": { + "requestId": request_id, + "responseCode": 201, + "responseHeaders": [{ "name": "Content-Type", "value": "text/plain" }] + } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Complete(outcome) = ctx.conn.start_command_dispatch(&raw) + else { + panic!("stopped Browser Host must reject without a fallback wait"); + }; + let (messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + assert!(messages.iter().any(|message| { + message["id"] == 36_812 + && message["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Browser Host stopped")) + })); + let fetch_owner = &ctx + .conn + .browser_context + .as_ref() + .expect("browser context") + .active_target + .fetch_owner; + assert!(fetch_owner.has_pending_fetch_request_id_for_test(&request_id)); + assert!(fetch_owner.pending_fetch_response_transfer_is_pending_for_test(&request_id)); + assert!( + ctx.conn + .browser_context + .as_ref() + .and_then(|browser_context| browser_context.loaded_page()) + .is_none(), + "publication failure must not apply response overrides or commit the paused Document" + ); + server.await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fail_response_main_document_is_selected_by_exact_browser_owner_after_frontend_drop() { + let mut ctx = TestContext::new(); + let (server, request_id) = pause_html_navigation_at_response_stage( + &mut ctx, + 36_820, + "
must-not-commit
", + ) + .await; + ctx.take_all(); + + let expected_page = ctx + .conn + .target_page_residence_identity_for_session(Some("SID-1")) + .expect("response-paused navigation Page owner"); + let raw = json!({ + "id": 36_822, + "method": "Fetch.failRequest", + "sessionId": "SID-1", + "params": { "requestId": request_id, "errorReason": "Aborted" } + }) + .to_string(); + let crate::conn::CdpCommandTaskStep::Pending(frontend_wait) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("response-stage failRequest must await Browser Host"); + }; + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let (_, participant) = dispatch.into_parts(); + let participant = participant.expect("response failure participant"); + assert_eq!( + participant.paused_navigation_decision_page_owner_for_test(), + Some(&expected_page), + "Browser Host must retain the exact Page captured at response failure admission" + ); + drop(frontend_wait); + + let dispatch = ctx + .conn + .complete_browser_host_turn(participant.wait().await) + .await; + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (messages, _) = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + assert!(messages.iter().all(|message| message["id"] != 36_822)); + assert!( + messages + .iter() + .any(|message| { message["id"] == 36_821 && message["error"]["message"] == "Aborted" }) + ); + assert!( + ctx.conn + .browser_context + .as_ref() + .and_then(|browser_context| browser_context.loaded_page()) + .is_none(), + "dropping the Fetch frontend must not turn the accepted response failure into a commit" + ); + server.await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] async fn response_stage_pause_happens_before_navigation_body_eof() { let (tail_tx, tail_rx) = tokio::sync::oneshot::channel::<()>(); @@ -31,7 +272,7 @@ async fn response_stage_pause_happens_before_navigation_body_eof() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -231,6 +472,7 @@ async fn assert_empty_http_error_response_stage(ctx: &mut TestContext, url: &str .and_then(|bc| bc.loaded_page()) .expect("browser-owned HTTP error Document should commit"); assert_eq!(page.final_url().as_str(), NETWORK_ERROR_PAGE_URL); + drop(page); assert_eq!( ctx.conn .browser_context @@ -275,7 +517,7 @@ async fn empty_http_error_response_stage_commits_browser_error_document_after_co bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_background_navigation_scheduler_for_test(); let url = format!("http://{addr}/empty-429"); @@ -316,7 +558,7 @@ lateBinding("author-script"); bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let initial_page = ctx .conn .load_page_via_runtime_async("data:text/html,initial") @@ -503,7 +745,7 @@ async fn response_stage_xml_commit_uses_live_configuration_before_first_author_s bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let initial_page = ctx .conn .load_page_via_runtime_async("data:text/html,initial") @@ -694,7 +936,7 @@ async fn response_stage_xml_commit_uses_live_configuration_before_first_author_s #[tokio::test(flavor = "multi_thread")] async fn fulfill_request_commit_uses_configuration_added_while_paused_before_author_script() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let initial_page = ctx .conn .load_page_via_runtime_async("data:text/html,initial") @@ -889,7 +1131,7 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document() bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 364, @@ -969,8 +1211,8 @@ async fn interleaved_response_heads_only_commit_the_current_prepared_document() let superseded = take_response_by_id(&mut ctx, 365); assert_eq!(superseded["error"]["code"], -32000); assert_eq!( - superseded["error"]["message"], - "renderer channel navigation was superseded by a newer navigation" + superseded["error"]["message"], "Navigation aborted", + "Browser Owner must reject the superseded request before its prepared renderer candidate can commit" ); assert_eq!( ctx.conn @@ -1028,7 +1270,7 @@ async fn response_stage_continue_request_rejects_data_url_override_without_consu bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let data_url = "data:text/html,
data response stage
"; ctx.process_async(json!({ @@ -1094,7 +1336,7 @@ async fn response_stage_continue_request_rejects_file_url_override_without_consu bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 395, "method": "Fetch.enable", @@ -1173,8 +1415,8 @@ async fn response_stage_continue_response_streams_network_events_through_backgro bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); - let (background_tx, mut background_rx) = tokio::sync::mpsc::unbounded_channel(); + ctx.conn.insert_browser_context(bc); + let (background_tx, mut background_rx) = crate::conn::browser_background_output_channel(); ctx.conn.set_background_event_sender(background_tx); let url = format!("http://{addr}/page"); @@ -1293,7 +1535,7 @@ async fn continue_response_can_override_status_and_headers() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -1422,7 +1664,7 @@ async fn continue_response_header_override_keeps_streaming_parser_body() { let request = String::from_utf8_lossy(&request); if request.starts_with("GET /gate.js ") { script_requested.store(true, Ordering::SeqCst); - release_tail.notify_waiters(); + release_tail.notify_one(); let body = b"window.__gateScriptRan = true;"; let response = format!( concat!( @@ -1468,7 +1710,7 @@ async fn continue_response_header_override_keeps_streaming_parser_body() { }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -1526,6 +1768,7 @@ async fn continue_response_header_override_keeps_streaming_parser_body() { json!({ "frameId": "TID-1", "loaderId": LOADER_ID }), Some("SID-1"), ); + wait_until_renderer_document_load(&mut ctx, Some("SID-1"), "TID-1", LOADER_ID).await; let page = ctx .conn @@ -1542,6 +1785,7 @@ async fn continue_response_header_override_keeps_streaming_parser_body() { script_requested.load(Ordering::SeqCst), "parser should request the external script before the main body EOF" ); + drop(page); assert!( loaded_page_html_for_test(&mut ctx) .await @@ -1578,7 +1822,7 @@ async fn response_stage_navigation_request_paused_includes_synthesized_cookie_he &[("set-cookie".to_owned(), "sid=nav; Path=/page".to_owned())], ); } - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 3_530, @@ -1664,7 +1908,7 @@ async fn continue_response_with_binary_response_headers_overrides_headers() { }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -1757,7 +2001,7 @@ async fn fail_request_at_response_stage_aborts_navigation() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -1859,7 +2103,7 @@ async fn fulfill_request_at_response_stage_replaces_the_network_candidate_once() bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 369, @@ -1973,7 +2217,7 @@ async fn take_response_body_as_stream_at_response_stage_returns_stream_and_keeps }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -2101,7 +2345,7 @@ async fn continue_response_rejects_while_response_body_stream_is_active() { }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -2224,7 +2468,7 @@ async fn io_close_cancels_active_response_body_stream_and_request_id() { }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -2323,7 +2567,7 @@ async fn get_response_body_does_not_consume_active_response_body_stream() { }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -2477,7 +2721,7 @@ async fn fulfill_request_cancels_active_response_body_stream_and_uses_synthetic_ }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -2580,7 +2824,7 @@ async fn fail_request_cancels_active_response_body_stream_and_fails_navigation() }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -2670,7 +2914,7 @@ async fn clear_browser_cache_cancels_active_response_body_stream_without_stale_r }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -2771,7 +3015,7 @@ async fn fail_request_finishes_pending_navigation_with_error() { }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -2818,7 +3062,7 @@ async fn fulfill_request_completes_navigation_with_synthetic_response() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_dom_events_for_test(Some("SID-1")); @@ -2911,7 +3155,7 @@ async fn fulfill_request_navigation_get_response_body_preserves_binary_bytes() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 44_001, @@ -2989,7 +3233,7 @@ async fn fulfill_request_document_body_uses_phase_one_parser_semantics() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 390, @@ -3059,7 +3303,7 @@ async fn fulfill_request_accepts_binary_response_headers() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 40, @@ -3150,7 +3394,7 @@ async fn continue_request_applies_url_method_headers_and_post_data() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_dom_events_for_test(Some("SID-1")); diff --git a/moli-protocol/src/domains/fetch/tests/navigation_subresource.rs b/moli-protocol/src/domains/fetch/tests/navigation_subresource.rs index a0b460b7f9..3de62b5e99 100644 --- a/moli-protocol/src/domains/fetch/tests/navigation_subresource.rs +++ b/moli-protocol/src/domains/fetch/tests/navigation_subresource.rs @@ -85,7 +85,7 @@ async fn request_stage_navigation_request_paused_includes_synthesized_cookie_hea )], ); } - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 72_101, @@ -154,7 +154,7 @@ fetch('/api', { method: 'POST', body: 'nav-payload' }) bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -376,7 +376,7 @@ document.addEventListener('DOMContentLoaded', () => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -538,7 +538,7 @@ window.addEventListener('load', () => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -699,7 +699,7 @@ window.postMessage('go', '*'); bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -865,7 +865,7 @@ document.body.setAttribute('data-trigger', '1'); bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -1030,7 +1030,7 @@ observer.observe(document.getElementById('target')); bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -1192,7 +1192,7 @@ setTimeout(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -1367,7 +1367,7 @@ const id = setInterval(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -1540,7 +1540,7 @@ queueMicrotask(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -1701,7 +1701,7 @@ Promise.resolve().then(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -1863,7 +1863,7 @@ setTimeout(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let xhr_url = format!("http://{addr}/xhr"); @@ -2043,7 +2043,7 @@ const id = setInterval(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let xhr_url = format!("http://{addr}/xhr"); @@ -2217,7 +2217,7 @@ queueMicrotask(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let xhr_url = format!("http://{addr}/xhr"); @@ -2379,7 +2379,7 @@ Promise.resolve().then(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let xhr_url = format!("http://{addr}/xhr"); @@ -2541,7 +2541,7 @@ requestIdleCallback(deadline => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -2699,7 +2699,7 @@ requestIdleCallback(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let xhr_url = format!("http://{addr}/xhr"); @@ -2863,7 +2863,7 @@ requestAnimationFrame(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); @@ -3019,7 +3019,7 @@ requestAnimationFrame(() => { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); let xhr_url = format!("http://{addr}/xhr"); @@ -3171,7 +3171,7 @@ fetch('/api').catch(() => {}); bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let page_url = format!("http://{addr}/page"); ctx.process_async(json!({ @@ -3242,7 +3242,7 @@ async fn continue_with_auth_default_aborts_pending_navigation() { }); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(attached_browser_context()); + ctx.conn.insert_browser_context(attached_browser_context()); let url = format!("http://{addr}/auth"); ctx.process_async(json!({ @@ -3341,7 +3341,7 @@ async fn continue_with_auth_success_clears_pending_auth_navigation() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/auth"); ctx.process_async(json!({ @@ -3449,7 +3449,7 @@ async fn continue_with_auth_retries_navigation_with_basic_proxy_credentials() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/auth"); ctx.process_async(json!({ @@ -3544,7 +3544,7 @@ async fn continue_with_auth_retries_navigation_with_digest_proxy_credentials() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.conn .set_http_proxy_override_async(Some(proxy_url)) .await; @@ -3688,7 +3688,7 @@ async fn cancel_https_proxy_connect_auth_emits_407_without_extra_info_and_fails_ bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.conn .set_http_proxy_override_async(Some(format!("http://{proxy_addr}"))) .await; @@ -3838,7 +3838,7 @@ async fn continue_with_auth_handles_multi_round_basic_navigation_challenge() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/auth"); ctx.process_async(json!({ @@ -3970,7 +3970,7 @@ async fn continue_with_auth_handles_digest_navigation_challenge() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/auth"); ctx.process_async(json!({ @@ -4101,7 +4101,7 @@ async fn continue_with_auth_handles_multi_round_digest_navigation_challenge() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let url = format!("http://{addr}/auth"); ctx.process_async(json!({ diff --git a/moli-protocol/src/domains/fetch/tests/runtime_auth_response.rs b/moli-protocol/src/domains/fetch/tests/runtime_auth_response.rs index a8ee88f12e..b40ab87a57 100644 --- a/moli-protocol/src/domains/fetch/tests/runtime_auth_response.rs +++ b/moli-protocol/src/domains/fetch/tests/runtime_auth_response.rs @@ -476,7 +476,7 @@ async fn runtime_child_frame_fetch_subresource_interception_uses_child_frame_att .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); @@ -498,16 +498,6 @@ async fn runtime_child_frame_fetch_subresource_interception_uses_child_frame_att .await; ctx.expect_result(36_402, json!({}), Some("SID-1")); enable_runtime_async(&mut ctx, "SID-1", 36_403).await; - wait_until_message( - &mut ctx, - "SID-1", - "child frame navigated before child fetch", - |message| { - message["method"] == json!("Page.frameNavigated") - && message["params"]["frame"]["id"] == json!(child_frame_id) - }, - ) - .await; ctx.process_async(json!({ "id": 36_404, @@ -5189,7 +5179,7 @@ async fn close_aborts_paused_response_stage_runtime_xhr_subresource() { .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); @@ -5525,7 +5515,7 @@ async fn close_aborts_paused_runtime_xhr_auth_subresource() { .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); diff --git a/moli-protocol/src/domains/fetch/tests/runtime_fetch.rs b/moli-protocol/src/domains/fetch/tests/runtime_fetch.rs index cc4fdcc48b..b2898d5fbb 100644 --- a/moli-protocol/src/domains/fetch/tests/runtime_fetch.rs +++ b/moli-protocol/src/domains/fetch/tests/runtime_fetch.rs @@ -1780,7 +1780,7 @@ async fn continue_request_returns_before_delayed_subresource_response() { .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); @@ -5468,7 +5468,7 @@ async fn runtime_fetch_subresource_pauses_until_continue_request_then_resolves_p .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); @@ -5621,7 +5621,7 @@ async fn runtime_fetch_subresource_continue_request_fails_when_network_offline() .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); @@ -5751,7 +5751,7 @@ async fn runtime_fetch_subresource_request_animation_frame_pauses_until_continue .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); @@ -5931,7 +5931,7 @@ async fn runtime_fetch_subresource_queue_microtask_pauses_until_continue_request .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); @@ -7760,7 +7760,7 @@ async fn close_aborts_paused_runtime_fetch_subresource() { .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); diff --git a/moli-protocol/src/domains/fetch/tests/runtime_xhr.rs b/moli-protocol/src/domains/fetch/tests/runtime_xhr.rs index 6e50ffa2b0..c3274b48fe 100644 --- a/moli-protocol/src/domains/fetch/tests/runtime_xhr.rs +++ b/moli-protocol/src/domains/fetch/tests/runtime_xhr.rs @@ -164,7 +164,7 @@ async fn runtime_xhr_subresource_pauses_until_continue_request_then_loads_respon .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); @@ -468,7 +468,7 @@ async fn runtime_xhr_subresource_request_animation_frame_pauses_until_continue_r .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); @@ -629,7 +629,7 @@ async fn runtime_xhr_subresource_queue_microtask_pauses_until_continue_request_t .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; ctx.sent.clear(); diff --git a/moli-protocol/src/domains/input.rs b/moli-protocol/src/domains/input.rs index 3c04d7c132..9d748ef119 100644 --- a/moli-protocol/src/domains/input.rs +++ b/moli-protocol/src/domains/input.rs @@ -1025,7 +1025,8 @@ fn start_page_input_command( }; let page = loaded_page_mut(conn, command_session_id) .ok_or_else(PendingInputCommandStartError::no_document_loaded)?; - let pending = start(page).map_err(PendingInputCommandStartError::renderer_error)?; + let pending = start(&page).map_err(PendingInputCommandStartError::renderer_error)?; + drop(page); Ok(Some(PendingInputCommandDispatch { command_id, session_id: command_session_id.map(str::to_owned), @@ -1508,10 +1509,10 @@ fn completed_page_command_result( } } -pub(super) fn loaded_page_mut<'a>( - conn: &'a mut CdpConnection, +pub(super) fn loaded_page_mut( + conn: &mut CdpConnection, session_id: Option<&str>, -) -> Option<&'a mut Page> { +) -> Option { conn.loaded_page_mut_for_protocol_access(session_id).ok() } @@ -2073,11 +2074,8 @@ mod producer_tests { #[test] fn input_prepared_slot_keeps_download_and_file_chooser_payloads_separate() { let source_document = renderer_document_identity_for_test(1, 1); - let owner = TargetPageResidenceIdentity::new_for_test( - "BID-slot".to_owned(), - Some("TID-slot".to_owned()), - 1, - ); + let owner = + TargetPageResidenceIdentity::new("BID-slot".to_owned(), Some("TID-slot".to_owned()), 1); let mut slot = super::InputPreparedOutputSlot::from_outputs(super::InputPreparedOutputs { download_activations: vec![RendererPendingDownloadActivation { url: "https://example.test/download".to_owned(), @@ -2197,11 +2195,12 @@ mod producer_tests { async fn file_chooser_opened_preserves_typed_automation_sidecar() { let mut conn = CdpConnection::default(); let mut bc = BrowserContext::new("BID-typed".into()); + bc.set_active_target_id("ROOT-typed"); bc.attach_active_session("SID-typed"); - bc.devtools_session_state + bc.devtools_session_state_mut() .page_session_state .page_file_chooser_opened_event_enabled = true; - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); let source_document = renderer_document_identity_for_test(1, 1); bind_renderer_document_for_test(&mut conn, "SID-typed", "ROOT-typed", source_document); let owner = page_residence_identity_for_test(&conn, "SID-typed"); @@ -2253,7 +2252,7 @@ mod producer_tests { let mut bc = BrowserContext::new("BID-root-capture".into()); bc.set_active_target_id("TID-root-capture"); bc.attach_active_session("SID-root-capture"); - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); let source_document = renderer_document_identity_for_test(1, 1); bind_renderer_document_for_test( &mut conn, @@ -2281,10 +2280,10 @@ mod producer_tests { let mut bc = BrowserContext::new("BID-document-collision".into()); bc.set_active_target_id("TID-document-collision"); bc.attach_active_session("SID-document-collision"); - bc.devtools_session_state + bc.devtools_session_state_mut() .page_session_state .page_file_chooser_opened_event_enabled = true; - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); let source_document = renderer_document_identity_for_test(1, 1); bind_renderer_document_for_test( &mut conn, @@ -2351,10 +2350,10 @@ mod producer_tests { let mut bc = BrowserContext::new("BID-page-replacement".into()); bc.set_active_target_id("TID-page-replacement"); bc.attach_active_session("SID-page-replacement"); - bc.devtools_session_state + bc.devtools_session_state_mut() .page_session_state .page_file_chooser_opened_event_enabled = true; - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); let source_document = renderer_document_identity_for_test(1, 1); bind_renderer_document_for_test( &mut conn, @@ -2372,7 +2371,7 @@ mod producer_tests { .expect("source file chooser should prepare"); conn.runtime_session_owner_slot_mut(Some("SID-page-replacement")) .expect("test runtime slot should exist") - .replace_page_attachment_id_for_test(); + .set_loaded_page_generation(owner.loaded_page_generation() + 1); let replacement_document = renderer_document_identity_for_test(2, 2); bind_renderer_document_for_test( &mut conn, @@ -2417,9 +2416,14 @@ mod producer_tests { let mut bc = BrowserContext::new("BID-download".into()); bc.set_active_target_id("FRAME-download"); bc.attach_active_session("SID-download"); - conn.browser_context = Some(bc); - conn.download_behavior - .set_global("deny".to_owned(), None, true); + conn.insert_browser_context(bc); + conn.apply_browser_download_policy_update( + moli_core::browser_host::BrowserDownloadPolicyUpdate::SetGlobal { + behavior: moli_core::browser_host::BrowserDownloadBehavior::Deny, + download_path: None, + }, + ); + conn.set_automation_download_events_enabled_for_browser_context(None, true); let mut out: Vec = Vec::new(); let mut command_context = CommandDispatchContext::default(); let mut prepared = @@ -2464,11 +2468,12 @@ mod producer_tests { async fn file_chooser_drain_consumes_prepared_activations_without_page_readback() { let mut conn = CdpConnection::default(); let mut bc = BrowserContext::new("BID-1".into()); + bc.set_active_target_id("ROOT-1"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .page_session_state .page_file_chooser_opened_event_enabled = true; - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); let source_document = renderer_document_identity_for_test(1, 1); bind_renderer_document_for_test(&mut conn, "SID-1", "ROOT-1", source_document); let owner = page_residence_identity_for_test(&conn, "SID-1"); @@ -2511,11 +2516,12 @@ mod producer_tests { async fn file_chooser_activity_background_events_keep_typed_sidecar() { let mut conn = CdpConnection::default(); let mut bc = BrowserContext::new("BID-context".into()); + bc.set_active_target_id("ROOT-context"); bc.attach_active_session("SID-context"); - bc.devtools_session_state + bc.devtools_session_state_mut() .page_session_state .page_file_chooser_opened_event_enabled = true; - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); let source_document = renderer_document_identity_for_test(1, 1); bind_renderer_document_for_test(&mut conn, "SID-context", "ROOT-context", source_document); let owner = page_residence_identity_for_test(&conn, "SID-context"); diff --git a/moli-protocol/src/domains/input/file_chooser.rs b/moli-protocol/src/domains/input/file_chooser.rs index f772d74431..9e67662acb 100644 --- a/moli-protocol/src/domains/input/file_chooser.rs +++ b/moli-protocol/src/domains/input/file_chooser.rs @@ -15,8 +15,8 @@ use crate::devtools_runtime::{ /// The renderer resolves a live source node to a backend id before this value /// is built. Capture resolves the compact root-frame representation once. /// Apply may survive `document.open()` within the same Page, but a replacement -/// Page attachment replacement retires the prepared activation before a -/// colliding backend id can be observed. +/// Page generation retires the prepared activation before a colliding backend +/// id can be observed. #[derive(Debug, Eq, PartialEq)] pub(super) struct PreparedFileChooserActivation { page_owner: TargetPageResidenceIdentity, @@ -150,7 +150,7 @@ fn trace_stale_activation(session_id: Option<&str>, activation: &PreparedFileCho backend_node_id = activation.backend_node_id, browser_context_id = activation.page_owner.browser_context_id(), target_id = activation.page_owner.target_id(), - page_attachment_id = activation.page_owner.page_attachment_id().get(), + loaded_page_generation = activation.page_owner.loaded_page_generation(), "dropping file chooser produced by a stale Page residence" ); } diff --git a/moli-protocol/src/domains/input/tests.rs b/moli-protocol/src/domains/input/tests.rs index 752c3a2fab..ea856659ba 100644 --- a/moli-protocol/src/domains/input/tests.rs +++ b/moli-protocol/src/domains/input/tests.rs @@ -9,7 +9,7 @@ const INPUT_HIT_Y: u32 = 20; async fn with_loaded_document(ctx: &mut TestContext, html: &str) { let mut bc = BrowserContext::new("BID-I".into()); bc.set_active_target_id("TID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let data_url = format!("data:text/html,{html}"); // Input commands can execute JS and therefore publish concrete renderer // output. Install the fixture through the same Page-owner transaction as diff --git a/moli-protocol/src/domains/inspector.rs b/moli-protocol/src/domains/inspector.rs index 32654d8938..3353308149 100644 --- a/moli-protocol/src/domains/inspector.rs +++ b/moli-protocol/src/domains/inspector.rs @@ -86,7 +86,7 @@ mod tests { .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .runtime_session_state .inspector_enabled ); @@ -99,7 +99,7 @@ mod tests { .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .runtime_session_state .inspector_enabled ); @@ -109,6 +109,7 @@ mod tests { async fn inspector_enable_replays_target_crashed_for_crashed_target() { let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); + bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); bc.active_target .owner_state @@ -196,9 +197,8 @@ mod tests { let bc = ctx.conn.browser_context.as_ref().expect("browser context"); assert_eq!(bc.active_target_id(), Some("TID-active")); assert!( - bc.parked_page_session_state("TID-background") + bc.primary_devtools_session_state_for_target("TID-background") .is_some_and(|state| state - .devtools_session_state .runtime_session_state .inspector_target_crashed_delivered()) ); @@ -257,11 +257,11 @@ mod tests { async fn inspector_enable_and_disable_stage_background_target_session_state() { let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); - bc.attach_active_session("SID-active"); bc.set_active_target_id("TID-A"); + bc.attach_active_session("SID-active"); bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-B".into(), + .push(crate::conn::BackgroundTarget::new_with_frontend_session( + "TID-B", Some("SID-B".into()), crate::conn::TargetIdentityState::new( "about:blank#background".into(), @@ -288,16 +288,14 @@ mod tests { { let bc = ctx.conn.browser_context.as_ref().expect("browser context"); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .inspector_enabled ); - assert!(bc.parked_page_session_state("TID-B").is_some_and(|state| { - state - .devtools_session_state - .runtime_session_state - .inspector_enabled - })); + assert!( + bc.primary_devtools_session_state_for_target("TID-B") + .is_some_and(|state| state.runtime_session_state.inspector_enabled) + ); } ctx.process_async(json!({"id": 5, "method": "Inspector.disable", "sessionId": "SID-B"})) @@ -305,7 +303,7 @@ mod tests { ctx.expect_result(5, json!({}), Some("SID-B")); let bc = ctx.conn.browser_context.as_ref().expect("browser context"); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .inspector_enabled ); diff --git a/moli-protocol/src/domains/io.rs b/moli-protocol/src/domains/io.rs index 7d8e4e8591..acab485df0 100644 --- a/moli-protocol/src/domains/io.rs +++ b/moli-protocol/src/domains/io.rs @@ -235,7 +235,7 @@ fn complete_resolve_blob_command( ) -> CommandOutputPlan { let uuid = completed.and_then(|completed| { conn.loaded_page_mut_for_protocol_access(session_id) - .and_then(|page| { + .and_then(|mut page| { page.finish_resolve_blob_object(completed) .map_err(|error| error.to_string()) }) @@ -257,7 +257,7 @@ fn complete_read_blob_command( let bytes = completed .and_then(|completed| { conn.loaded_page_mut_for_protocol_access(session_id) - .and_then(|page| { + .and_then(|mut page| { page.finish_blob_bytes_for_uuid(completed) .map_err(|error| error.to_string()) }) @@ -548,6 +548,7 @@ mod tests { "about:blank#background".to_owned(), )); ctx.conn.browser_context = Some(bc); + ctx.conn.adopt_direct_browser_context_fixture_attachments(); let handle = ctx .conn diff --git a/moli-protocol/src/domains/log.rs b/moli-protocol/src/domains/log.rs index 3c26b581c3..eab66df101 100644 --- a/moli-protocol/src/domains/log.rs +++ b/moli-protocol/src/domains/log.rs @@ -203,6 +203,7 @@ mod tests { Some("SID-1"), ) .await; + ctx.sent.clear(); } fn loaded_lifecycle_error_contains(ctx: &TestContext, needle: &str) -> bool { @@ -664,11 +665,16 @@ mod tests { ctx.expect_result(1, json!({}), Some("SID-background")); let active = ctx.conn.browser_context.as_ref().expect("browser context"); - assert!(!active.devtools_session_state.page_session_state.log_enabled); + assert!( + !active + .devtools_session_state() + .page_session_state + .log_enabled + ); assert!( active - .parked_page_session_state("TID-background") - .is_some_and(|state| state.devtools_session_state.page_session_state.log_enabled), + .primary_devtools_session_state_for_target("TID-background") + .is_some_and(|state| state.page_session_state.log_enabled), "background target should stage Log.enable" ); } @@ -851,7 +857,7 @@ mod tests { .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .console_output_session_state .log_violation_thresholds; assert_eq!(thresholds.len(), 1); @@ -868,7 +874,7 @@ mod tests { .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .console_output_session_state .log_violation_thresholds .is_empty() diff --git a/moli-protocol/src/domains/network.rs b/moli-protocol/src/domains/network.rs index 221e7667e9..3d540fcab7 100644 --- a/moli-protocol/src/domains/network.rs +++ b/moli-protocol/src/domains/network.rs @@ -357,7 +357,7 @@ fn resolve_network_data_collector_browser_context_ids( .collect::>(); if default_context_ids.is_empty() { let id = conn.default_browser_context_id().to_owned(); - conn.insert_browser_context(conn.new_browser_context(id.clone())); + conn.try_insert_browser_context(conn.new_browser_context(id.clone()))?; default_context_ids.push(id); } resolved.extend( @@ -625,7 +625,7 @@ fn complete_unit_page_network_command( return CommandOutputPlan::error(-32000, "InvalidNetworkCommandCompletion"); } }; - let page = match conn.loaded_page_mut_for_protocol_access(completed.session_id.as_deref()) { + let mut page = match conn.loaded_page_mut_for_protocol_access(completed.session_id.as_deref()) { Ok(page) => page, Err(message) if message == "NoDocumentLoaded" => { return CommandOutputPlan::success(); diff --git a/moli-protocol/src/domains/network/activity.rs b/moli-protocol/src/domains/network/activity.rs index 1c4f363325..b5090cd476 100644 --- a/moli-protocol/src/domains/network/activity.rs +++ b/moli-protocol/src/domains/network/activity.rs @@ -580,10 +580,7 @@ mod tests { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - bc.active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); let page_owner = conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("test target should expose a Page residence identity"); @@ -679,10 +676,7 @@ mod tests { let mut bc = BrowserContext::new("BID-collision".into()); bc.set_active_target_id("TID-collision"); bc.attach_active_session("SID-collision"); - bc.active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); let old_owner = conn .target_page_residence_identity_for_session(Some("SID-collision")) @@ -721,7 +715,7 @@ mod tests { conn.runtime_session_owner_slot_mut(Some("SID-collision")) .expect("runtime owner should remain addressable") - .replace_page_attachment_id_for_test(); + .set_loaded_page_generation(1); let replacement_owner = conn .target_page_residence_identity_for_session(Some("SID-collision")) .expect("replacement Page residence should exist"); @@ -764,10 +758,7 @@ mod tests { bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); assert!(bc.assign_auxiliary_session_to_target("TID-1", "FETCH-SID".to_owned())); - bc.active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); assert!(conn.enable_network_listener_for_session_owner(Some("FETCH-SID"))); let page_owner = conn .target_page_residence_identity_for_session(Some("SID-1")) @@ -829,10 +820,7 @@ mod tests { bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); assert!(bc.assign_auxiliary_session_to_target("TID-1", "FETCH-SID".to_owned())); - bc.active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - conn.browser_context = Some(bc); + conn.insert_browser_context(bc); let page_owner = conn .target_page_residence_identity_for_session(Some("SID-1")) .expect("test target should expose a Page residence identity"); @@ -925,7 +913,7 @@ mod tests { Some("SID-background".to_owned()), page_url.clone(), )); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); for (id, session_id) in [(1, "SID-active"), (2, "SID-background")] { ctx.process_async(serde_json::json!({ @@ -1013,7 +1001,7 @@ mod tests { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.process_async(serde_json::json!({ diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs index 1fabaf9741..c0d58cee9a 100644 --- a/moli-protocol/src/domains/network/agent.rs +++ b/moli-protocol/src/domains/network/agent.rs @@ -1,9 +1,9 @@ use std::collections::{HashMap, HashSet}; -use moli_bounded_buffer::{BoundedByteBuffer, ByteLimits, InsertOutcome}; +use moli_core::browser_host::{BrowserNetworkArtifactStore, BrowserNetworkResponseBody}; use moli_core::page::{ScriptNetworkOutputItem, SubresourceNetworkRequestHandle}; -use crate::conn::{CapturedBody, ConnectionNetworkRequestIdAllocator}; +use crate::conn::CapturedBody; use crate::devtools_runtime::DevToolsNetworkDataType; use super::{ @@ -13,8 +13,7 @@ use super::{ TargetNetworkBacklogRequestIdResolver, TargetNetworkOutputQueue, TargetSubresourcePlanOutput, }; -const RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES: usize = 20_000_000; -const RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES: usize = 2_000_000; +type ConnectionNetworkRequestIdAllocator = BrowserNetworkArtifactStore; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RendererSubresourceTeardownDisposition { @@ -29,6 +28,7 @@ pub struct TargetNetworkAgentState { output_queue: TargetNetworkOutputQueue, active_renderer_subresource_requests: HashMap, + browser_artifacts: BrowserNetworkArtifactStore, artifacts: TargetNetworkArtifacts, } @@ -177,6 +177,27 @@ impl NetworkBacklogPreferredRequestIdBudget { } impl TargetNetworkAgentState { + pub(crate) fn adopt_browser_network_artifact_store( + &mut self, + browser_artifacts: BrowserNetworkArtifactStore, + ) { + let request_ids = self + .artifacts + .body_artifacts + .request_ids() + .map(str::to_owned) + .collect::>(); + browser_artifacts.adopt_entries_from( + &self.browser_artifacts, + request_ids.iter().map(String::as_str), + ); + self.browser_artifacts = browser_artifacts; + } + + pub(crate) fn browser_network_artifact_store(&self) -> BrowserNetworkArtifactStore { + self.browser_artifacts.clone() + } + /// Moves the Document-owned live queue and request correlations into a /// short-lived predecessor state while retaining target/session policy and /// target-scoped body/stream artifacts for the replacement Document. @@ -486,7 +507,8 @@ impl TargetNetworkAgentState { } pub(crate) fn collected_network_data_artifacts(&self) -> Vec { - self.artifacts.collected_network_data_artifacts() + self.artifacts + .collected_network_data_artifacts(&self.browser_artifacts) } pub(crate) fn record_subresource_request_id_for_handle_if_absent( @@ -546,11 +568,6 @@ impl TargetNetworkAgentState { self.artifacts = artifacts; } - #[cfg(test)] - pub(crate) fn allocate_network_request_id(&mut self) -> String { - self.artifacts.allocate_network_request_id() - } - #[cfg(test)] pub(crate) fn record_captured_response_body( &mut self, @@ -558,9 +575,13 @@ impl TargetNetworkAgentState { response_body: String, session_ids: impl IntoIterator>, ) { - self.artifacts - .body_artifacts - .insert(request_id, response_body, session_ids); + self.record_captured_response_body_source_with_collector_scope( + request_id, + CapturedBody::from_string(response_body), + session_ids, + std::iter::empty::(), + false, + ); } pub(crate) fn record_captured_response_body_source_with_collector_scope( @@ -574,6 +595,7 @@ impl TargetNetworkAgentState { self.artifacts .body_artifacts .insert_captured_body_with_collector_scope( + &self.browser_artifacts, request_id, response_body, session_ids, @@ -593,6 +615,7 @@ impl TargetNetworkAgentState { self.artifacts .body_artifacts .insert_request_body_with_collector_scope( + &self.browser_artifacts, request_id, request_body, session_ids, @@ -625,6 +648,7 @@ impl TargetNetworkAgentState { self.artifacts .body_artifacts .insert_pending_with_collector_scope( + &self.browser_artifacts, request_id, session_ids, collector_ids, @@ -643,6 +667,7 @@ impl TargetNetworkAgentState { self.artifacts .body_artifacts .insert_failed_with_collector_scope( + &self.browser_artifacts, request_id, error_text, session_ids, @@ -651,16 +676,16 @@ impl TargetNetworkAgentState { ); } - pub(crate) fn captured_response_body(&self, request_id: &str) -> Option<&CapturedResponseBody> { + pub(crate) fn captured_response_body(&self, request_id: &str) -> Option { self.artifacts .body_artifacts - .captured_response_body(request_id) + .captured_response_body(&self.browser_artifacts, request_id) } - pub(crate) fn captured_request_body(&self, request_id: &str) -> Option<&CapturedRequestBody> { + pub(crate) fn captured_request_body(&self, request_id: &str) -> Option { self.artifacts .body_artifacts - .captured_request_body(request_id) + .captured_request_body(&self.browser_artifacts, request_id) } pub(crate) fn clear_captured_response_bodies(&mut self) { @@ -855,18 +880,6 @@ impl TargetNetworkAgentState { .captured_response_bodies_empty() } - #[cfg(test)] - pub(crate) fn set_next_network_request_sequence_for_test(&mut self, sequence: u64) { - self.artifacts - .request_id_allocator - .set_next_sequence_for_test(sequence); - } - - #[cfg(test)] - pub(crate) fn next_network_request_sequence_for_test(&self) -> u64 { - self.artifacts.request_id_allocator.next_sequence_for_test() - } - #[cfg(test)] pub(crate) fn io_streams_empty(&self) -> bool { self.artifacts.body_artifacts.io_streams_empty() @@ -1021,157 +1034,72 @@ fn update_active_renderer_subresource_requests( #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CapturedResponseBody { - state: CapturedResponseBodyState, - session_ids: HashSet>, - collector_ids: HashSet, - collection_was_gated: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum CapturedResponseBodyState { - Pending, - Ready(CapturedBody), - Failed(String), - Evicted, + artifact: BrowserNetworkResponseBody, + visibility: CapturedNetworkBodyVisibility, } impl CapturedResponseBody { - pub(crate) fn pending_with_collector_scope( - session_ids: impl IntoIterator>, - collector_ids: impl IntoIterator, - collection_was_gated: bool, - ) -> Self { - let collector_ids = collector_ids.into_iter().collect::>(); - Self { - state: CapturedResponseBodyState::Pending, - session_ids: session_ids.into_iter().collect(), - collection_was_gated: collection_was_gated || !collector_ids.is_empty(), - collector_ids, - } - } - - pub(crate) fn from_captured_body_with_collector_scope( - body: CapturedBody, - session_ids: impl IntoIterator>, - collector_ids: impl IntoIterator, - collection_was_gated: bool, - ) -> Self { - let collector_ids = collector_ids.into_iter().collect::>(); - Self { - state: CapturedResponseBodyState::Ready(body), - session_ids: session_ids.into_iter().collect(), - collection_was_gated: collection_was_gated || !collector_ids.is_empty(), - collector_ids, - } - } - - pub(crate) fn failed_with_collector_scope( - error_text: String, - session_ids: impl IntoIterator>, - collector_ids: impl IntoIterator, - collection_was_gated: bool, + fn new( + artifact: BrowserNetworkResponseBody, + visibility: CapturedNetworkBodyVisibility, ) -> Self { - let collector_ids = collector_ids.into_iter().collect::>(); Self { - state: CapturedResponseBodyState::Failed(error_text), - session_ids: session_ids.into_iter().collect(), - collection_was_gated: collection_was_gated || !collector_ids.is_empty(), - collector_ids, + artifact, + visibility, } } #[cfg(test)] pub(crate) fn body(&self) -> String { - match &self.state { - CapturedResponseBodyState::Ready(body) => { - body.materialize_lossy_string().unwrap_or_default() - } - CapturedResponseBodyState::Pending - | CapturedResponseBodyState::Failed(_) - | CapturedResponseBodyState::Evicted => String::new(), - } + self.artifact + .ready_body() + .and_then(|body| body.materialize_lossy_string().ok()) + .unwrap_or_default() } pub(crate) fn body_bytes_limited(&self, limit: usize) -> anyhow::Result> { - match &self.state { - CapturedResponseBodyState::Ready(body) => body.materialize_bytes_limited(limit), - CapturedResponseBodyState::Pending => { - anyhow::bail!("No data found for resource with given identifier") - } - CapturedResponseBodyState::Failed(_) => { - anyhow::bail!("No data found for resource with given identifier") - } - CapturedResponseBodyState::Evicted => { - anyhow::bail!("Request content was evicted from inspector cache") - } - } + self.artifact.body_bytes_limited(limit) } pub(crate) fn is_visible_to_session(&self, session_id: Option<&str>) -> bool { - self.session_ids - .contains(&session_id.map(std::borrow::ToOwned::to_owned)) + self.visibility.is_visible_to_session(session_id) } pub(crate) fn was_collected_by(&self, collector_id: &str) -> bool { - self.collector_ids.contains(collector_id) + self.visibility.was_collected_by(collector_id) } pub(crate) fn collector_ids(&self) -> &HashSet { - &self.collector_ids + self.visibility.collector_ids() } fn collected_network_data_artifact( &self, request_id: &str, ) -> Option { - if self.collector_ids.is_empty() { + if self.visibility.collector_ids.is_empty() { return None; } - let CapturedResponseBodyState::Ready(body) = &self.state else { - return None; - }; + let body = self.artifact.ready_body()?; Some(CollectedNetworkDataArtifact { request_id: request_id.to_owned(), data_type: DevToolsNetworkDataType::Response, body: body.clone(), - collector_ids: sorted_collector_ids(&self.collector_ids), - collection_was_gated: self.collection_was_gated, + collector_ids: sorted_collector_ids(&self.visibility.collector_ids), + collection_was_gated: self.visibility.collection_was_gated, }) } - - pub(crate) fn remove_session_visibility(&mut self, session_id: Option<&str>) -> bool { - self.session_ids - .remove(&session_id.map(std::borrow::ToOwned::to_owned)); - !self.session_ids.is_empty() - } - - fn mark_evicted(&mut self) { - self.state = CapturedResponseBodyState::Evicted; - } } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CapturedRequestBody { body: CapturedBody, - session_ids: HashSet>, - collector_ids: HashSet, - collection_was_gated: bool, + visibility: CapturedNetworkBodyVisibility, } impl CapturedRequestBody { - pub(crate) fn new_with_collector_scope( - body: Vec, - session_ids: impl IntoIterator>, - collector_ids: impl IntoIterator, - collection_was_gated: bool, - ) -> Self { - let collector_ids = collector_ids.into_iter().collect::>(); - Self { - body: CapturedBody::from_bytes(body), - session_ids: session_ids.into_iter().collect(), - collection_was_gated: collection_was_gated || !collector_ids.is_empty(), - collector_ids, - } + fn new(body: CapturedBody, visibility: CapturedNetworkBodyVisibility) -> Self { + Self { body, visibility } } pub(crate) fn body_bytes_limited(&self, limit: usize) -> anyhow::Result> { @@ -1179,276 +1107,150 @@ impl CapturedRequestBody { } pub(crate) fn is_visible_to_session(&self, session_id: Option<&str>) -> bool { - self.session_ids - .contains(&session_id.map(std::borrow::ToOwned::to_owned)) + self.visibility.is_visible_to_session(session_id) } pub(crate) fn was_collected_by(&self, collector_id: &str) -> bool { - self.collector_ids.contains(collector_id) + self.visibility.was_collected_by(collector_id) } pub(crate) fn collector_ids(&self) -> &HashSet { - &self.collector_ids + self.visibility.collector_ids() } fn collected_network_data_artifact( &self, request_id: &str, ) -> Option { - if self.collector_ids.is_empty() { + if self.visibility.collector_ids.is_empty() { return None; } Some(CollectedNetworkDataArtifact { request_id: request_id.to_owned(), data_type: DevToolsNetworkDataType::Request, body: self.body.clone(), - collector_ids: sorted_collector_ids(&self.collector_ids), - collection_was_gated: self.collection_was_gated, + collector_ids: sorted_collector_ids(&self.visibility.collector_ids), + collection_was_gated: self.visibility.collection_was_gated, }) } - - pub(crate) fn remove_session_visibility(&mut self, session_id: Option<&str>) -> bool { - self.session_ids - .remove(&session_id.map(std::borrow::ToOwned::to_owned)); - !self.session_ids.is_empty() - } -} - -fn sorted_collector_ids(collector_ids: &HashSet) -> Vec { - let mut collector_ids = collector_ids.iter().cloned().collect::>(); - collector_ids.sort(); - collector_ids } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct CapturedRequestBodyStore { - bodies: HashMap, +#[derive(Debug, Clone, PartialEq, Eq)] +struct CapturedNetworkBodyVisibility { + session_ids: HashSet>, + collector_ids: HashSet, + collection_was_gated: bool, } -impl CapturedRequestBodyStore { - pub(crate) fn insert_with_collector_scope( - &mut self, - request_id: String, - body: Vec, +impl CapturedNetworkBodyVisibility { + fn new( session_ids: impl IntoIterator>, collector_ids: impl IntoIterator, collection_was_gated: bool, - ) { - self.bodies.insert( - request_id, - CapturedRequestBody::new_with_collector_scope( - body, - session_ids, - collector_ids, - collection_was_gated, - ), - ); + ) -> Self { + let collector_ids = collector_ids.into_iter().collect::>(); + Self { + session_ids: session_ids.into_iter().collect(), + collection_was_gated: collection_was_gated || !collector_ids.is_empty(), + collector_ids, + } } - pub(crate) fn get(&self, request_id: &str) -> Option<&CapturedRequestBody> { - self.bodies.get(request_id) + fn is_visible_to_session(&self, session_id: Option<&str>) -> bool { + self.session_ids + .contains(&session_id.map(std::borrow::ToOwned::to_owned)) } - fn collected_network_data_artifacts(&self) -> Vec { - self.bodies - .iter() - .filter_map(|(request_id, body)| body.collected_network_data_artifact(request_id)) - .collect() + fn was_collected_by(&self, collector_id: &str) -> bool { + self.collector_ids.contains(collector_id) } - pub(crate) fn clear(&mut self) { - self.bodies.clear(); + fn collector_ids(&self) -> &HashSet { + &self.collector_ids } - pub(crate) fn remove_session_visibility(&mut self, session_id: Option<&str>) { - self.bodies - .retain(|_, body| body.remove_session_visibility(session_id)); + fn remove_session_visibility(&mut self, session_id: Option<&str>) -> bool { + self.session_ids + .remove(&session_id.map(std::borrow::ToOwned::to_owned)); + !self.session_ids.is_empty() } } -#[derive(Debug, Clone, PartialEq, Eq)] -struct CapturedResponseBodyStore { - /// Terminal entries without retained payloads. Ready entries live only in - /// `buffered_bodies`, making the bounded buffer the single body owner. - bodies: HashMap, - buffered_bodies: BoundedByteBuffer, +fn sorted_collector_ids(collector_ids: &HashSet) -> Vec { + let mut collector_ids = collector_ids.iter().cloned().collect::>(); + collector_ids.sort(); + collector_ids } -impl Default for CapturedResponseBodyStore { - fn default() -> Self { - Self::with_limits(ByteLimits::new( - RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES, - RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES, - )) - } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct CapturedBodyVisibilityStore { + bodies: HashMap, } -impl CapturedResponseBodyStore { - fn with_limits(limits: ByteLimits) -> Self { - Self { - bodies: HashMap::new(), - buffered_bodies: BoundedByteBuffer::new(limits), - } - } - - #[cfg(test)] - pub(crate) fn insert( +impl CapturedBodyVisibilityStore { + fn insert_with_collector_scope( &mut self, request_id: String, - body: String, - session_ids: impl IntoIterator>, - ) { - self.insert_captured_body_with_collector_scope( - request_id, - CapturedBody::from_string(body), - session_ids, - std::iter::empty::(), - false, - ); - } - - pub(crate) fn insert_captured_body_with_collector_scope( - &mut self, - request_id: String, - body: CapturedBody, session_ids: impl IntoIterator>, collector_ids: impl IntoIterator, collection_was_gated: bool, ) { - let byte_len = body.len(); - let captured = CapturedResponseBody::from_captured_body_with_collector_scope( - body, - session_ids, - collector_ids, - collection_was_gated, - ); - self.bodies.remove(&request_id); - match self.buffered_bodies.insert(request_id, captured, byte_len) { - InsertOutcome::Stored { evicted } => { - for (evicted_request_id, mut evicted_body) in evicted { - evicted_body.mark_evicted(); - self.bodies.insert(evicted_request_id, evicted_body); - } - } - InsertOutcome::Rejected { - key: request_id, - value: mut rejected_body, - } => { - rejected_body.mark_evicted(); - self.bodies.insert(request_id, rejected_body); - } - } - } - - pub(crate) fn insert_pending_with_collector_scope( - &mut self, - request_id: String, - session_ids: impl IntoIterator>, - collector_ids: impl IntoIterator, - collection_was_gated: bool, - ) { - if self.bodies.contains_key(&request_id) - || self.buffered_bodies.contains_key(request_id.as_str()) - { - return; - } self.bodies.insert( request_id, - CapturedResponseBody::pending_with_collector_scope( - session_ids, - collector_ids, - collection_was_gated, - ), + CapturedNetworkBodyVisibility::new(session_ids, collector_ids, collection_was_gated), ); } - pub(crate) fn insert_failed_with_collector_scope( + fn insert_if_absent_with_collector_scope( &mut self, request_id: String, - error_text: String, session_ids: impl IntoIterator>, collector_ids: impl IntoIterator, collection_was_gated: bool, ) { - self.buffered_bodies.remove(&request_id); - self.bodies.insert( - request_id, - CapturedResponseBody::failed_with_collector_scope( - error_text, - session_ids, - collector_ids, - collection_was_gated, - ), - ); + self.bodies.entry(request_id).or_insert_with(|| { + CapturedNetworkBodyVisibility::new(session_ids, collector_ids, collection_was_gated) + }); } - pub(crate) fn get(&self, request_id: &str) -> Option<&CapturedResponseBody> { - self.buffered_bodies - .get(request_id) - .or_else(|| self.bodies.get(request_id)) + fn get(&self, request_id: &str) -> Option<&CapturedNetworkBodyVisibility> { + self.bodies.get(request_id) } - fn collected_network_data_artifacts(&self) -> Vec { - self.buffered_bodies - .iter() - .chain(self.bodies.iter()) - .filter_map(|(request_id, body)| body.collected_network_data_artifact(request_id)) - .collect() + fn request_ids(&self) -> impl Iterator { + self.bodies.keys().map(String::as_str) } - pub(crate) fn clear(&mut self) { + fn clear(&mut self) { self.bodies.clear(); - self.buffered_bodies.clear(); } - #[cfg(test)] - pub(crate) fn is_empty(&self) -> bool { - self.bodies.is_empty() && self.buffered_bodies.is_empty() - } - - #[cfg(test)] - pub(crate) fn contains_key(&self, request_id: &str) -> bool { - self.bodies.contains_key(request_id) || self.buffered_bodies.contains_key(request_id) - } - - pub(crate) fn remove_session_visibility(&mut self, session_id: Option<&str>) { + fn remove_session_visibility(&mut self, session_id: Option<&str>) { self.bodies .retain(|_, body| body.remove_session_visibility(session_id)); + } - let buffered_request_ids = self - .buffered_bodies - .iter() - .map(|(request_id, _)| request_id.clone()) - .collect::>(); - for request_id in buffered_request_ids { - let retain = self - .buffered_bodies - .get_mut(request_id.as_str()) - .is_some_and(|body| body.remove_session_visibility(session_id)); - if !retain { - self.buffered_bodies.remove(request_id.as_str()); - } - } + #[cfg(test)] + fn is_empty(&self) -> bool { + self.bodies.is_empty() } #[cfg(test)] - fn buffered_body_bytes(&self) -> usize { - self.buffered_bodies.used_bytes() + fn contains_key(&self, request_id: &str) -> bool { + self.bodies.contains_key(request_id) } } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TargetNetworkArtifacts { - request_id_allocator: TargetNetworkRequestIdAllocator, body_artifacts: TargetNetworkBodyArtifacts, subresource_network_artifacts: SubresourceNetworkArtifacts, websocket_network_artifacts: WebSocketNetworkArtifacts, } impl TargetNetworkArtifacts { - #[cfg(test)] - pub(crate) fn allocate_network_request_id(&mut self) -> String { - self.request_id_allocator.allocate_request_id() + pub(crate) fn body_request_ids(&self) -> impl Iterator { + self.body_artifacts.request_ids() } pub(crate) fn set_session_observation_cursor_at_counts( @@ -1484,8 +1286,12 @@ impl TargetNetworkArtifacts { self.body_artifacts.clear_captured_response_bodies(); } - pub(crate) fn collected_network_data_artifacts(&self) -> Vec { - self.body_artifacts.collected_network_data_artifacts() + pub(crate) fn collected_network_data_artifacts( + &self, + browser_artifacts: &BrowserNetworkArtifactStore, + ) -> Vec { + self.body_artifacts + .collected_network_data_artifacts(browser_artifacts) } pub(crate) fn clear_websocket_request_ids(&mut self) { @@ -1547,7 +1353,6 @@ impl TargetNetworkArtifacts { } pub(crate) fn reset_all_target_scoped_artifacts(&mut self) { - self.request_id_allocator.reset(); self.body_artifacts.reset_all(); self.subresource_network_artifacts.reset_cursor(); self.subresource_network_artifacts.clear_request_ids(); @@ -1555,89 +1360,51 @@ impl TargetNetworkArtifacts { } } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct TargetNetworkRequestIdAllocator { - next_sequence: u64, -} - -impl TargetNetworkRequestIdAllocator { - #[cfg(test)] - pub(crate) fn allocate_sequence(&mut self) -> u64 { - self.next_sequence = self - .next_sequence - .checked_add(1) - .expect("network request id sequence exhausted"); - self.next_sequence - } - - #[cfg(test)] - pub(crate) fn allocate_request_id(&mut self) -> String { - format!("REQ-{}", self.allocate_sequence()) - } - - pub(crate) fn reset(&mut self) { - self.next_sequence = 0; - } - - #[cfg(test)] - pub(crate) fn set_next_sequence_for_test(&mut self, sequence: u64) { - self.next_sequence = sequence; - } - - #[cfg(test)] - pub(crate) fn next_sequence_for_test(&self) -> u64 { - self.next_sequence - } -} - #[derive(Debug, Clone, Default, PartialEq, Eq)] struct TargetNetworkBodyArtifacts { - captured_request_bodies: CapturedRequestBodyStore, - captured_response_bodies: CapturedResponseBodyStore, + captured_request_bodies: CapturedBodyVisibilityStore, + captured_response_bodies: CapturedBodyVisibilityStore, io_stream_artifacts: TargetIoStreamArtifacts, } impl TargetNetworkBodyArtifacts { - #[cfg(test)] - pub(crate) fn insert( - &mut self, - request_id: String, - response_body: String, - session_ids: impl IntoIterator>, - ) { - self.captured_response_bodies - .insert(request_id, response_body, session_ids); + fn request_ids(&self) -> impl Iterator { + self.captured_request_bodies + .request_ids() + .chain(self.captured_response_bodies.request_ids()) } pub(crate) fn insert_captured_body_with_collector_scope( &mut self, + browser_artifacts: &BrowserNetworkArtifactStore, request_id: String, response_body: CapturedBody, session_ids: impl IntoIterator>, collector_ids: impl IntoIterator, collection_was_gated: bool, ) { - self.captured_response_bodies - .insert_captured_body_with_collector_scope( - request_id, - response_body, - session_ids, - collector_ids, - collection_was_gated, - ); + browser_artifacts.record_response_body(request_id.clone(), response_body); + self.captured_response_bodies.insert_with_collector_scope( + request_id, + session_ids, + collector_ids, + collection_was_gated, + ); } pub(crate) fn insert_request_body_with_collector_scope( &mut self, + browser_artifacts: &BrowserNetworkArtifactStore, request_id: String, request_body: Vec, session_ids: impl IntoIterator>, collector_ids: impl IntoIterator, collection_was_gated: bool, ) { + browser_artifacts + .record_request_body(request_id.clone(), CapturedBody::from_bytes(request_body)); self.captured_request_bodies.insert_with_collector_scope( request_id, - request_body, session_ids, collector_ids, collection_was_gated, @@ -1646,13 +1413,15 @@ impl TargetNetworkBodyArtifacts { pub(crate) fn insert_pending_with_collector_scope( &mut self, + browser_artifacts: &BrowserNetworkArtifactStore, request_id: String, session_ids: impl IntoIterator>, collector_ids: impl IntoIterator, collection_was_gated: bool, ) { + browser_artifacts.record_pending_response_body(request_id.clone()); self.captured_response_bodies - .insert_pending_with_collector_scope( + .insert_if_absent_with_collector_scope( request_id, session_ids, collector_ids, @@ -1662,38 +1431,63 @@ impl TargetNetworkBodyArtifacts { pub(crate) fn insert_failed_with_collector_scope( &mut self, + browser_artifacts: &BrowserNetworkArtifactStore, request_id: String, error_text: String, session_ids: impl IntoIterator>, collector_ids: impl IntoIterator, collection_was_gated: bool, ) { - self.captured_response_bodies - .insert_failed_with_collector_scope( - request_id, - error_text, - session_ids, - collector_ids, - collection_was_gated, - ); + browser_artifacts.record_failed_response_body(request_id.clone(), error_text); + self.captured_response_bodies.insert_with_collector_scope( + request_id, + session_ids, + collector_ids, + collection_was_gated, + ); } - pub(crate) fn captured_response_body(&self, request_id: &str) -> Option<&CapturedResponseBody> { - self.captured_response_bodies.get(request_id) + pub(crate) fn captured_response_body( + &self, + browser_artifacts: &BrowserNetworkArtifactStore, + request_id: &str, + ) -> Option { + let visibility = self.captured_response_bodies.get(request_id)?.clone(); + let artifact = browser_artifacts.response_body(request_id)?; + Some(CapturedResponseBody::new(artifact, visibility)) } - pub(crate) fn captured_request_body(&self, request_id: &str) -> Option<&CapturedRequestBody> { - self.captured_request_bodies.get(request_id) + pub(crate) fn captured_request_body( + &self, + browser_artifacts: &BrowserNetworkArtifactStore, + request_id: &str, + ) -> Option { + let visibility = self.captured_request_bodies.get(request_id)?.clone(); + let body = browser_artifacts.request_body(request_id)?; + Some(CapturedRequestBody::new(body, visibility)) } - pub(crate) fn collected_network_data_artifacts(&self) -> Vec { + pub(crate) fn collected_network_data_artifacts( + &self, + browser_artifacts: &BrowserNetworkArtifactStore, + ) -> Vec { let mut artifacts = self .captured_request_bodies - .collected_network_data_artifacts(); - artifacts.extend( - self.captured_response_bodies - .collected_network_data_artifacts(), - ); + .bodies + .iter() + .filter_map(|(request_id, visibility)| { + let body = browser_artifacts.request_body(request_id)?; + CapturedRequestBody::new(body, visibility.clone()) + .collected_network_data_artifact(request_id) + }) + .collect::>(); + artifacts.extend(self.captured_response_bodies.bodies.iter().filter_map( + |(request_id, visibility)| { + let artifact = browser_artifacts.response_body(request_id)?; + CapturedResponseBody::new(artifact, visibility.clone()) + .collected_network_data_artifact(request_id) + }, + )); artifacts } @@ -1888,6 +1682,7 @@ impl SubresourceNetworkArtifacts { #[cfg(test)] mod tests { + use moli_core::browser_host::BrowserNetworkArtifactStore; use moli_core::page::{ ScriptNetworkOutputItem, SubresourceNetworkRecord, SubresourceNetworkRequestHandle, SubresourceRequestInitiatorType, SubresourceRequestStarted, SubresourceResourceType, @@ -1900,107 +1695,52 @@ mod tests { PendingWebSocketNetworkActivity, PendingWebSocketNetworkActivitySession, }; use super::{ - CapturedResponseBodyStore, NetworkBacklogPreferredRequestId, NetworkBacklogRequestIdPlan, - TargetNetworkAgentState, WebSocketNetworkArtifacts, + ConnectionNetworkRequestIdAllocator, NetworkBacklogPreferredRequestId, + NetworkBacklogRequestIdPlan, TargetNetworkAgentState, WebSocketNetworkArtifacts, }; - use crate::conn::{CapturedBody, CapturedBodyWriter, ConnectionNetworkRequestIdAllocator}; + use crate::conn::CapturedBodyWriter; #[test] - fn response_body_store_defaults_to_one_tenth_of_chromium_desktop_budget() { - let store = CapturedResponseBodyStore::default(); - - assert_eq!( - store.buffered_bodies.limits(), - moli_bounded_buffer::ByteLimits::new(20_000_000, 2_000_000) + fn browser_response_body_outlives_one_frontend_visibility_projection() { + let browser_artifacts = BrowserNetworkArtifactStore::default(); + let mut first_frontend = TargetNetworkAgentState::default(); + first_frontend.adopt_browser_network_artifact_store(browser_artifacts.clone()); + first_frontend.record_captured_response_body( + "REQ-shared".to_owned(), + "host body".to_owned(), + [Some("SID-first".to_owned())], ); - } - #[test] - fn response_body_store_marks_oldest_payload_evicted_when_total_budget_fills() { - let mut store = - CapturedResponseBodyStore::with_limits(moli_bounded_buffer::ByteLimits::new(5, 4)); - store.insert_captured_body_with_collector_scope( - "REQ-first".to_owned(), - CapturedBody::from_string("aa".to_owned()), - [None], - std::iter::empty::(), - false, - ); - store.insert_captured_body_with_collector_scope( - "REQ-second".to_owned(), - CapturedBody::from_string("bb".to_owned()), - [None], - std::iter::empty::(), - false, - ); - store.insert_captured_body_with_collector_scope( - "REQ-third".to_owned(), - CapturedBody::from_string("ccc".to_owned()), - [None], - std::iter::empty::(), - false, - ); - - assert_eq!(store.buffered_body_bytes(), 5); - assert_eq!( - store - .get("REQ-first") - .expect("evicted metadata must remain addressable") - .body_bytes_limited(10) - .expect_err("oldest payload should be evicted") - .to_string(), - "Request content was evicted from inspector cache" + first_frontend.remove_captured_response_body_visibility_for_session(Some("SID-first")); + assert!( + first_frontend + .captured_response_body("REQ-shared") + .is_none() ); assert_eq!( - store - .get("REQ-second") - .expect("second response should remain") - .body_bytes_limited(10) - .expect("second response body should remain readable"), - b"bb" - ); + browser_artifacts + .response_body("REQ-shared") + .expect("Browser Host must retain the raw response artifact") + .body_bytes_limited(32) + .expect("retained response body should remain readable"), + b"host body" + ); + drop(first_frontend); + + let mut second_frontend = TargetNetworkAgentState::default(); + second_frontend.adopt_browser_network_artifact_store(browser_artifacts); + second_frontend + .record_pending_response_body("REQ-shared".to_owned(), [Some("SID-second".to_owned())]); + let projected = second_frontend + .captured_response_body("REQ-shared") + .expect("a later frontend projection should join the Host artifact"); + assert!(projected.is_visible_to_session(Some("SID-second"))); assert_eq!( - store - .get("REQ-third") - .expect("third response should remain") - .body_bytes_limited(10) - .expect("third response body should remain readable"), - b"ccc" - ); - } - - #[test] - fn response_body_store_returns_byte_charge_on_state_and_visibility_removal() { - let mut store = - CapturedResponseBodyStore::with_limits(moli_bounded_buffer::ByteLimits::new(8, 4)); - store.insert_captured_body_with_collector_scope( - "REQ-failed".to_owned(), - CapturedBody::from_string("body".to_owned()), - [None], - std::iter::empty::(), - false, - ); - assert_eq!(store.buffered_body_bytes(), 4); - store.insert_failed_with_collector_scope( - "REQ-failed".to_owned(), - "network failed".to_owned(), - [None], - std::iter::empty::(), - false, - ); - assert_eq!(store.buffered_body_bytes(), 0); - - store.insert_captured_body_with_collector_scope( - "REQ-session".to_owned(), - CapturedBody::from_string("abc".to_owned()), - [Some("SID-1".to_owned())], - std::iter::empty::(), - false, + projected + .body_bytes_limited(32) + .expect("later frontend should read the Host body"), + b"host body" ); - assert_eq!(store.buffered_body_bytes(), 3); - store.remove_session_visibility(Some("SID-1")); - assert_eq!(store.buffered_body_bytes(), 0); - assert!(store.get("REQ-session").is_none()); } fn subresource_record(url: &str) -> SubresourceNetworkRecord { @@ -2350,7 +2090,7 @@ mod tests { "a contextual id already bound to a request handle must not be consumed by an earlier unrelated backlog record" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 1, "only the unrelated record should allocate a new request id" ); @@ -2415,7 +2155,7 @@ mod tests { "REQ-bound" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 0, "bound request id should not consume a new network id" ); @@ -2428,7 +2168,7 @@ mod tests { "REQ-1" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 1, "subsequent lookups should reuse the socket mapping" ); @@ -2600,7 +2340,7 @@ mod tests { "REQ-from-subresource" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 0, "subresource output binding should not allocate a second request id" ); @@ -2818,7 +2558,7 @@ mod tests { "resolving already-bound WebSocket metadata must not consume the preferred id needed by the next ordinary subresource" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 0, "synthetic WebSocket binding and preferred subresource id should not allocate fallback request ids" ); @@ -2906,7 +2646,7 @@ mod tests { "merged prepared tokens must preserve preferred-id availability across already-bound WebSocket metadata" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 0, "merged synthetic WebSocket binding and preferred subresource id should not allocate fallback request ids" ); @@ -3020,7 +2760,7 @@ mod tests { "websocket subresource delivery must reuse the socket request id instead of inventing a second id" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 1, "reusing the existing socket request id should not allocate a second request id" ); @@ -3061,7 +2801,7 @@ mod tests { "visible subresource metadata owns the request id binding in the combined production path" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 0, "preferred subresource binding should not allocate a synthetic WebSocket request id" ); @@ -3107,7 +2847,7 @@ mod tests { "the later ordinary subresource should still consume the preferred request id" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 1, "only the frame-only WebSocket path should allocate a fallback request id" ); @@ -3143,7 +2883,7 @@ mod tests { "frame delivery should reuse the same owner-resolved request id" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 0, "delivery snapshots should not allocate when subresource binding exists" ); @@ -3167,7 +2907,7 @@ mod tests { vec!["REQ-1"], "unbound handshake delivery should allocate a request id" ); - assert_eq!(request_id_allocator.next_sequence_for_test(), 1); + assert_eq!(request_id_allocator.next_request_sequence_for_test(), 1); assert_eq!( websocket_frame_outputs(&pending_combined_snapshot_with_allocator( @@ -3179,7 +2919,7 @@ mod tests { "frame delivery should reuse the request id allocated for the same socket" ); assert_eq!( - request_id_allocator.next_sequence_for_test(), + request_id_allocator.next_request_sequence_for_test(), 1, "frame delivery should not allocate another request id for the same socket" ); diff --git a/moli-protocol/src/domains/network/load_resource.rs b/moli-protocol/src/domains/network/load_resource.rs index cef6154e76..a070362d32 100644 --- a/moli-protocol/src/domains/network/load_resource.rs +++ b/moli-protocol/src/domains/network/load_resource.rs @@ -96,7 +96,7 @@ pub(super) fn complete_network_resource_preparation( }; let preparation = match conn .loaded_page_mut_for_protocol_access(completed.session_id.as_deref()) - .and_then(|page| { + .and_then(|mut page| { page.finish_prepare_network_resource_load(completion) .map_err(|error| error.to_string()) }) { 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..507bad4c0a 100644 --- a/moli-protocol/src/domains/network/main_document_progress/mod.rs +++ b/moli-protocol/src/domains/network/main_document_progress/mod.rs @@ -387,14 +387,14 @@ pub(crate) fn materialize_navigation_failure_preserving_committed_document( conn: &mut CdpConnection, state: &NavigationDispatchState, error_text: String, -) -> MaterializedNavigationLoadOutcome { - MaterializedNavigationLoadOutcome::Failed(materialize_failed_navigation_progress( +) -> MaterializedFailedDocumentProgress { + materialize_failed_navigation_progress( conn, state, error_text, FailedNavigationDocumentPolicy::PreserveCommittedDocument, FailedNavigationResponseMode::ProtocolError, - )) + ) } fn materialize_download_navigation_progress( 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..2f04d45472 100644 --- a/moli-protocol/src/domains/network/main_document_progress/tests.rs +++ b/moli-protocol/src/domains/network/main_document_progress/tests.rs @@ -1,3 +1,4 @@ +use crate::conn::{BrowserBackgroundOutputReceiver, browser_background_output_channel}; use crate::devtools_runtime::{AutomationEvent, DevToolsNetworkResourceType}; use moli_cookie_jar::{ StoredCookieQueryReport, StoredCookieSetRejectionReason, StoredCookieSetStatus, @@ -7,7 +8,6 @@ use moli_fetch::{ NetworkRequestObservation, NetworkResponseObservation, RedirectInfo, }; use serde_json::{Value, json}; -use tokio::sync::mpsc::unbounded_channel; use url::Url; use super::gate::{ @@ -65,9 +65,9 @@ fn completed_progress_context() -> CompletedMainDocumentProgressContext { fn live_progress_source() -> ( MainDocumentLiveNetworkProgressSource, - tokio::sync::mpsc::UnboundedReceiver, + BrowserBackgroundOutputReceiver, ) { - let (sender, receiver) = unbounded_channel(); + let (sender, receiver) = browser_background_output_channel(); ( MainDocumentLiveNetworkProgressSource { sender: Some(sender), @@ -285,7 +285,7 @@ fn pending_document_progress_transfer_keeps_body_unmaterialized() { #[test] fn progress_output_target_serializes_background_targets_consistently() { - let (sender, mut receiver) = unbounded_channel(); + let (sender, mut receiver) = browser_background_output_channel(); let mut background = MainDocumentProgressOutputTarget::background_sender(&sender); background.emit_batch(response_progress_batch_for_output_target()); drop(sender); @@ -344,7 +344,7 @@ fn progress_output_target_serializes_background_targets_consistently() { fn progress_output_target_background_raw_events_stay_sidecar_free_without_classifier_bridge() { let report = StoredCookieQueryReport::default(); - let (sender, mut receiver) = unbounded_channel(); + let (sender, mut receiver) = browser_background_output_channel(); let mut background = MainDocumentProgressOutputTarget::background_sender(&sender); super::emit::emit_request_will_be_sent_extra_info( &mut background, diff --git a/moli-protocol/src/domains/network/response_body.rs b/moli-protocol/src/domains/network/response_body.rs index e9d2ec7d27..aa8287acde 100644 --- a/moli-protocol/src/domains/network/response_body.rs +++ b/moli-protocol/src/domains/network/response_body.rs @@ -215,8 +215,8 @@ pub(crate) fn disown_network_data_result( } enum NetworkDataBody<'a> { - Request(&'a CapturedRequestBody), - Response(&'a CapturedResponseBody), + Request(CapturedRequestBody), + Response(CapturedResponseBody), Collected(&'a CollectedNetworkDataBody), } diff --git a/moli-protocol/src/domains/network/tests/blocked_urls.rs b/moli-protocol/src/domains/network/tests/blocked_urls.rs index b8806af5b5..3309fa6086 100644 --- a/moli-protocol/src/domains/network/tests/blocked_urls.rs +++ b/moli-protocol/src/domains/network/tests/blocked_urls.rs @@ -14,7 +14,8 @@ async fn set_blocked_urls_requires_browser_context() { #[tokio::test(flavor = "multi_thread")] async fn set_blocked_urls_rejects_invalid_params() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 2801, "method": "Network.setBlockedURLs", @@ -26,7 +27,8 @@ async fn set_blocked_urls_rejects_invalid_params() { #[tokio::test(flavor = "multi_thread")] async fn set_blocked_urls_updates_browser_context_state() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 2802, @@ -74,7 +76,7 @@ async fn set_blocked_urls_navigation_fails_with_blocked_by_client() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 2804, @@ -115,7 +117,7 @@ async fn set_blocked_urls_runtime_fetch_emits_loading_failed() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 2806, @@ -247,10 +249,11 @@ async fn background_set_blocked_urls_updates_loaded_owner_page_without_promotion let page_url = format!("http://{addr}/page"); let api_url = format!("http://{addr}/api"); let mut ctx = TestContext::new(); - ctx.conn.browser_context = None; + ctx.conn + .insert_browser_context(BrowserContext::new("BID-selected".to_owned())); ctx.sent.clear(); - let background = BackgroundTarget::new( + let background = BackgroundTarget::new_with_frontend_session( "TID-background".to_owned(), Some("SID-background".to_owned()), TargetIdentityState::about_blank(), @@ -258,7 +261,7 @@ async fn background_set_blocked_urls_updates_loaded_owner_page_without_promotion ); let mut inactive = BrowserContext::new("BID-background".to_owned()); inactive.background_targets.push(background); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-background")) .await; @@ -342,7 +345,10 @@ async fn background_set_blocked_urls_updates_loaded_owner_page_without_promotion .contains("net::ERR_BLOCKED_BY_CLIENT") ); assert!( - ctx.conn.browser_context.is_none(), + ctx.conn + .browser_context + .as_ref() + .is_some_and(|context| context.id == "BID-selected"), "direct background Network policy should not promote the owner" ); @@ -357,7 +363,7 @@ async fn set_blocked_urls_worker_fetch_emits_loading_failed() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 2811, @@ -496,7 +502,7 @@ async fn set_blocked_urls_worker_xhr_emits_loading_failed() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 2816, @@ -631,7 +637,7 @@ async fn set_blocked_urls_worker_websocket_emits_loading_failed() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 2822, @@ -787,7 +793,7 @@ async fn emulate_network_conditions_offline_worker_fetch_emits_loading_failed() bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 2821, @@ -903,7 +909,7 @@ async fn emulate_network_conditions_offline_worker_xhr_emits_loading_failed() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 2826, @@ -1015,7 +1021,7 @@ async fn set_blocked_urls_runtime_xhr_emits_loading_failed() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 2811, @@ -1103,7 +1109,7 @@ async fn set_blocked_urls_runtime_websocket_emits_loading_failed() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 2816, @@ -1217,7 +1223,8 @@ async fn emulate_network_conditions_requires_browser_context() { #[tokio::test(flavor = "multi_thread")] async fn emulate_network_conditions_rejects_invalid_params() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 29, "method": "Network.emulateNetworkConditions", @@ -1229,7 +1236,8 @@ async fn emulate_network_conditions_rejects_invalid_params() { #[tokio::test(flavor = "multi_thread")] async fn emulate_network_conditions_updates_browser_context_state() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 30, @@ -1264,7 +1272,7 @@ async fn emulate_network_conditions_offline_navigation_fails_before_completion_e bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 31, @@ -1306,7 +1314,7 @@ async fn emulate_network_conditions_offline_runtime_fetch_emits_loading_failed() bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 33, @@ -1420,7 +1428,7 @@ globalThis.__lm_after_blocked_parser_script = true; let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 70_010, diff --git a/moli-protocol/src/domains/network/tests/cache.rs b/moli-protocol/src/domains/network/tests/cache.rs index 849f170fad..a7a34f45d9 100644 --- a/moli-protocol/src/domains/network/tests/cache.rs +++ b/moli-protocol/src/domains/network/tests/cache.rs @@ -116,7 +116,7 @@ async fn cached_subresource_reload_emits_served_from_cache_before_response() { let mut browser_context = ctx.conn.new_browser_context("BID-1".to_owned()); browser_context.set_active_target_id("TID-1"); browser_context.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.process_async(json!({ "id": 70, @@ -212,7 +212,7 @@ async fn cached_main_document_navigation_emits_served_from_cache_before_response let mut browser_context = ctx.conn.new_browser_context("BID-1".to_owned()); browser_context.set_active_target_id("TID-1"); browser_context.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.process_async(json!({ "id": 73, @@ -298,7 +298,7 @@ async fn clear_browser_cache_clears_response_body_and_stream_artifacts() { let mut bc = BrowserContext::new("BID-1".into()); bc.record_captured_response_body("REQ-1".to_owned(), "body".to_owned(), [None]); bc.insert_io_stream("STREAM-1".to_owned(), b"payload".to_vec(), 0); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({"id": 4, "method": "Network.clearBrowserCache"})) .await; @@ -330,7 +330,7 @@ async fn clear_browser_cache_clears_configured_disk_http_cache() { fetch_config, )); let browser_context = ctx.conn.new_browser_context("BID-1".into()); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.process_async(json!({"id": 44, "method": "Network.clearBrowserCache"})) .await; @@ -364,7 +364,7 @@ async fn clear_browser_cache_uses_browser_context_http_cache_owner() { )); let mut browser_context = BrowserContext::new("BID-1".into()); browser_context.http_cache_root = Some(cache_dir.clone()); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.process_async(json!({"id": 45, "method": "Network.clearBrowserCache"})) .await; @@ -399,11 +399,13 @@ async fn clear_browser_cache_targets_command_session_browser_context() { .expect("inactive cache body fixture should be written"); let mut active = BrowserContext::new("BID-cache-active".into()); + active.set_active_target_id("TID-cache-active"); active.attach_active_session("SID-cache-active"); active.http_cache_root = Some(active_cache_dir.clone()); active.record_captured_response_body("REQ-active".to_owned(), "active".to_owned(), [None]); let mut inactive = BrowserContext::new("BID-cache-inactive".into()); + inactive.set_active_target_id("TID-cache-inactive"); inactive.attach_active_session("SID-cache-inactive"); inactive.http_cache_root = Some(inactive_cache_dir.clone()); inactive.record_captured_response_body( @@ -413,8 +415,8 @@ async fn clear_browser_cache_targets_command_session_browser_context() { ); let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(active); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(active); + ctx.conn.insert_browser_context(inactive); ctx.process_async(json!({ "id": 46, @@ -514,6 +516,7 @@ async fn clear_browser_cache_keeps_pending_response_navigation_transfer() { request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }, DocumentBodySource::BufferedRaw { requested_url: url.clone(), @@ -536,7 +539,7 @@ async fn clear_browser_cache_keeps_pending_response_navigation_transfer() { network_observation_journal: Default::default(), }, ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({"id": 5, "method": "Network.clearBrowserCache"})) .await; @@ -563,7 +566,8 @@ async fn set_cache_disabled_requires_browser_context() { #[tokio::test(flavor = "multi_thread")] async fn set_cache_disabled_rejects_invalid_params() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 21, @@ -576,7 +580,8 @@ async fn set_cache_disabled_rejects_invalid_params() { #[tokio::test(flavor = "multi_thread")] async fn set_cache_disabled_updates_browser_context_state() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 22, @@ -616,13 +621,14 @@ async fn devtools_set_cache_behavior_global_updates_existing_targets_and_default let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-active".to_owned()); - bc.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - None, - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); - ctx.conn.browser_context = Some(bc); + bc.background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + None, + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + ctx.conn.insert_browser_context(bc); let result = ctx .conn @@ -670,13 +676,14 @@ async fn devtools_set_cache_behavior_contexts_only_updates_requested_targets() { let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-active".to_owned()); - bc.background_targets.push(BackgroundTarget::new( - "TID-background".to_owned(), - None, - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); - ctx.conn.browser_context = Some(bc); + bc.background_targets + .push(BackgroundTarget::new_with_frontend_session( + "TID-background".to_owned(), + None, + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + ctx.conn.insert_browser_context(bc); ctx.conn .execute_devtools_command(crate::devtools_runtime::DevToolsCommand::SetCacheBehavior( @@ -719,7 +726,8 @@ async fn devtools_set_cache_behavior_contexts_only_updates_requested_targets() { #[tokio::test(flavor = "multi_thread")] async fn devtools_set_cache_behavior_rejects_unknown_context() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); let error = ctx .conn @@ -796,7 +804,8 @@ async fn set_bypass_service_worker_requires_browser_context() { #[tokio::test(flavor = "multi_thread")] async fn set_bypass_service_worker_rejects_invalid_params() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 25, "method": "Network.setBypassServiceWorker", @@ -808,7 +817,8 @@ async fn set_bypass_service_worker_rejects_invalid_params() { #[tokio::test(flavor = "multi_thread")] async fn set_bypass_service_worker_updates_browser_context_state() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 26, diff --git a/moli-protocol/src/domains/network/tests/cookies.rs b/moli-protocol/src/domains/network/tests/cookies.rs index 624c709732..9191fee104 100644 --- a/moli-protocol/src/domains/network/tests/cookies.rs +++ b/moli-protocol/src/domains/network/tests/cookies.rs @@ -18,7 +18,8 @@ async fn get_all_cookies_requires_browser_context() { #[tokio::test(flavor = "multi_thread")] async fn network_get_all_cookies_returns_unfiltered_browser_context_cookies() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-GAC".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-GAC".into())); ctx.process_async(json!({ "id": 2_2, @@ -64,7 +65,8 @@ async fn network_get_all_cookies_returns_unfiltered_browser_context_cookies() { #[tokio::test(flavor = "multi_thread")] async fn set_extra_http_headers_replaces_previous_headers() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("NID-A".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("NID-A".into())); ctx.process_async(json!({"id": 3, "method": "Network.setExtraHTTPHeaders", "params": { "headers": { "foo": "bar" }}})) .await; @@ -115,7 +117,7 @@ async fn set_user_agent_override_applies_to_subsequent_navigation_requests() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 30, @@ -173,16 +175,7 @@ async fn background_target_user_agent_override_reaches_replacement_document() { let mut ctx = TestContext::new(); let mut browser_context = BrowserContext::new("BID-default".to_owned()); browser_context.set_active_target_id("TID-initial"); - let renderer_runtime = browser_context.renderer_runtime_owner_access(); - let engine = NavigationEngine::new_with_fetch_config_and_browser_context_access( - ctx.conn.fetch_config().clone(), - renderer_runtime, - OptionalResourceFetchMask::NONE, - true, - ) - .expect("new BrowserContext owner should be live"); - ctx.conn.replace_navigation_engine(engine); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.process_async(json!({ "id": 35, @@ -318,7 +311,7 @@ async fn set_user_agent_override_applies_to_current_page_fetch_requests() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner( "data:text/html,ok", Some("SID-1"), @@ -377,9 +370,97 @@ async fn set_user_agent_override_rejects_invalid_params() { ctx.expect_error(32, -32602, "InvalidParams"); } #[tokio::test(flavor = "multi_thread")] +async fn network_cookie_flow_matches_lightpanda_subset() { + let mut ctx = TestContext::new(); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-S".into())); + + ctx.process_async(json!({ + "id": 3, + "method": "Network.getCookies", + "params": { "urls": ["https://example.com/pancakes"] } + })) + .await; + ctx.expect_result(3, json!({ "cookies": [] }), None); + + ctx.process_async(json!({ + "id": 4, + "method": "Network.setCookie", + "params": { "name": "test3", "value": "valuenot3", "url": "https://car.example.com/defnotpancakes" } + })) + .await; + ctx.expect_result(4, json!({}), None); + + ctx.process_async(json!({ + "id": 5, + "method": "Network.setCookies", + "params": { + "cookies": [ + { "name": "test3", "value": "value3", "url": "https://car.example.com/pan/cakes" }, + { "name": "test4", "value": "value4", "domain": "example.com", "path": "/mango" } + ] + } + })) + .await; + ctx.expect_result(5, json!({}), None); + + ctx.process_async(json!({ + "id": 6, + "method": "Network.getCookies", + "params": { "urls": ["https://car.example.com/pan/cakes"] } + })) + .await; + ctx.expect_result( + 6, + json!({ + "cookies": [ + { "name": "test3", "value": "value3", "domain": "car.example.com", "path": "/", "size": 11, "secure": true } + ] + }), + None, + ); + + ctx.process_async(json!({ + "id": 7, + "method": "Network.deleteCookies", + "params": { "name": "test3", "domain": "car.example.com" } + })) + .await; + ctx.expect_result(7, json!({}), None); + + ctx.process_async(json!({ + "id": 8, + "method": "Storage.getCookies", + "params": { "browserContextId": "BID-S" } + })) + .await; + ctx.expect_result( + 8, + json!({ + "cookies": [ + { "name": "test4", "value": "value4", "domain": ".example.com", "path": "/mango", "size": 11 } + ] + }), + None, + ); + + ctx.process_async(json!({ "id": 9, "method": "Network.clearBrowserCookies" })) + .await; + ctx.expect_result(9, json!({}), None); + + ctx.process_async(json!({ + "id": 10, + "method": "Storage.getCookies", + "params": { "browserContextId": "BID-S" } + })) + .await; + ctx.expect_result(10, json!({ "cookies": [] }), None); +} +#[tokio::test(flavor = "multi_thread")] async fn network_get_cookies_omits_same_site_for_unspecified_cookie() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N-U".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N-U".into())); ctx.process_async(json!({ "id": 101, @@ -409,7 +490,8 @@ async fn network_get_cookies_omits_same_site_for_unspecified_cookie() { #[tokio::test(flavor = "multi_thread")] async fn network_set_cookie_returns_success_and_cookie_report() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N1".into())); ctx.process_async(json!({ "id": 40, @@ -439,7 +521,8 @@ async fn network_set_cookie_returns_success_and_cookie_report() { #[tokio::test(flavor = "multi_thread")] async fn network_set_cookie_reports_secure_access_warning_for_localhost_http() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N1W".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N1W".into())); ctx.process_async(json!({ "id": 42, @@ -468,7 +551,8 @@ async fn network_set_cookie_reports_secure_access_warning_for_localhost_http() { #[tokio::test(flavor = "multi_thread")] async fn network_set_cookie_returns_rejected_cookie_report_without_protocol_error() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N2".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N2".into())); ctx.process_async(json!({ "id": 41, @@ -501,7 +585,8 @@ async fn network_set_cookie_returns_rejected_cookie_report_without_protocol_erro #[tokio::test(flavor = "multi_thread")] async fn network_set_cookie_reports_structured_facade_rejections_without_protocol_error() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N3".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N3".into())); ctx.process_async(json!({ "id": 43, @@ -535,7 +620,8 @@ async fn network_set_cookie_reports_structured_facade_rejections_without_protoco #[tokio::test(flavor = "multi_thread")] async fn network_set_cookie_accepts_leading_dot_domain() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N3-DOT".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N3-DOT".into())); ctx.process_async(json!({ "id": 431, @@ -569,7 +655,8 @@ async fn network_set_cookie_accepts_leading_dot_domain() { #[tokio::test(flavor = "multi_thread")] async fn network_set_cookie_reports_invalid_url_as_cookie_rejection() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N4".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N4".into())); ctx.process_async(json!({ "id": 44, @@ -601,7 +688,8 @@ async fn network_set_cookie_reports_invalid_url_as_cookie_rejection() { #[tokio::test(flavor = "multi_thread")] async fn network_set_cookie_reports_structured_name_value_rejections_without_protocol_error() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N5".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N5".into())); ctx.process_async(json!({ "id": 45, @@ -635,7 +723,7 @@ async fn network_set_cookie_uses_browser_context_default_cookie_url_when_missing let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-N6".into()); bc.set_target_url("https://example.com/path".into()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 46, @@ -684,7 +772,8 @@ async fn network_set_cookie_uses_browser_context_default_cookie_url_when_missing #[tokio::test(flavor = "multi_thread")] async fn network_set_cookie_reports_missing_cookie_url_when_no_default_scope_exists() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N7".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N7".into())); ctx.process_async(json!({ "id": 48, @@ -715,7 +804,8 @@ async fn network_set_cookie_reports_missing_cookie_url_when_no_default_scope_exi #[tokio::test(flavor = "multi_thread")] async fn network_set_cookie_keeps_cookie_store_available_after_lock_holder_panic() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-N-store-panic".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-N-store-panic".into())); let cookie_store = ctx .conn .browser_context diff --git a/moli-protocol/src/domains/network/tests/load_resource.rs b/moli-protocol/src/domains/network/tests/load_resource.rs index d0918a70b1..5d55d91e62 100644 --- a/moli-protocol/src/domains/network/tests/load_resource.rs +++ b/moli-protocol/src/domains/network/tests/load_resource.rs @@ -9,7 +9,7 @@ async fn install_loaded_page(ctx: &mut TestContext, page_url: &str) { let mut browser_context = ctx.conn.new_browser_context("BID-1".to_owned()); browser_context.set_active_target_id(TARGET_ID); browser_context.attach_active_session(SESSION_ID); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.install_navigation_fixture_for_session_owner(page_url, Some(SESSION_ID)) .await; ctx.sent.clear(); diff --git a/moli-protocol/src/domains/network/tests/mod.rs b/moli-protocol/src/domains/network/tests/mod.rs index 2a763fbbe3..3051cc9fa3 100644 --- a/moli-protocol/src/domains/network/tests/mod.rs +++ b/moli-protocol/src/domains/network/tests/mod.rs @@ -26,7 +26,6 @@ use crate::{ }, domains::page::LOADER_ID, }; -use moli_core::{OptionalResourceFetchMask, runtime::NavigationEngine}; use moli_fetch::{FetchConfig, RawResponse, ResponseHead}; use parking_lot::Mutex; use serde_json::json; diff --git a/moli-protocol/src/domains/network/tests/navigation.rs b/moli-protocol/src/domains/network/tests/navigation.rs index 1da2d3963d..bb270de66f 100644 --- a/moli-protocol/src/domains/network/tests/navigation.rs +++ b/moli-protocol/src/domains/network/tests/navigation.rs @@ -39,7 +39,7 @@ async fn async_dispatch_updates_live_request_overrides_for_current_page() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner( "data:text/html,ok", Some("SID-1"), @@ -122,7 +122,7 @@ async fn async_dispatch_offline_runtime_fetch_emits_loading_failed() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner( "data:text/html,offline", Some("SID-1"), @@ -205,7 +205,7 @@ async fn main_document_navigation_emits_network_events_and_captures_response_bod let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_dom_events_for_test(Some("SID-1")); @@ -425,7 +425,7 @@ async fn main_document_navigation_applies_extra_http_headers() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 1, @@ -514,7 +514,7 @@ async fn main_document_navigation_events_include_synthesized_cookie_header() { &[("set-cookie".to_owned(), "sid=1; Path=/page".to_owned())], ); } - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 4_1, @@ -610,7 +610,7 @@ async fn main_document_navigation_deduplicates_manual_cookie_header_when_store_s &[("set-cookie".to_owned(), "sid=store; Path=/page".to_owned())], ); } - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 4_3, @@ -711,7 +711,7 @@ async fn main_document_navigation_redirect_emits_second_request_with_redirect_re )], ); } - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_dom_events_for_test(Some("SID-1")); @@ -985,7 +985,7 @@ async fn main_document_critical_client_hint_restart_matches_chromium_network_cha let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.process_async(json!({ @@ -1159,7 +1159,7 @@ async fn main_document_multi_hop_redirect_preserves_cookie_downgrade_report() { )], ); } - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 3_1, @@ -1258,7 +1258,7 @@ async fn browser_initiated_redirect_does_not_fabricate_renderer_navigation_reque let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 1, @@ -1328,7 +1328,7 @@ async fn main_document_request_cookie_report_is_snapshotted_before_response_sets let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 401, @@ -1389,7 +1389,7 @@ async fn first_main_document_transport_failure_commits_error_document() { bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); assert!(bc.assign_auxiliary_session_to_target("TID-1", "SID-2".to_owned())); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.enable_page_events_for_test(Some("SID-2")); @@ -1626,7 +1626,7 @@ async fn main_document_navigation_failure_emits_one_failed_and_finished_terminal let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.process_async(json!({ diff --git a/moli-protocol/src/domains/network/tests/response_body.rs b/moli-protocol/src/domains/network/tests/response_body.rs index 98d622a977..12e59f4656 100644 --- a/moli-protocol/src/domains/network/tests/response_body.rs +++ b/moli-protocol/src/domains/network/tests/response_body.rs @@ -40,7 +40,7 @@ async fn get_response_body_respects_recorded_session_visibility() { "aux-only body".to_owned(), [Some("SID-aux".to_owned())], ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_280, @@ -79,7 +79,7 @@ async fn get_response_body_requires_calling_session_network_listener() { "shared body".to_owned(), [Some("SID-primary".to_owned()), Some("SID-aux".to_owned())], ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_282, @@ -120,7 +120,7 @@ async fn get_response_body_reports_pending_body_as_existing_without_data() { .runtime_slot .enable_primary_network_events(); bc.record_pending_response_body("REQ-pending".to_owned(), [None::]); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_281, @@ -147,7 +147,7 @@ async fn get_response_body_ready_body_replaces_pending_body() { "ready body".to_owned(), [None::], ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_282, @@ -172,7 +172,7 @@ async fn get_response_body_rejects_bodies_over_materialization_limit() { .runtime_slot .enable_primary_network_events(); bc.record_captured_response_body("REQ-large".to_owned(), "hello".to_owned(), [None::]); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_282, @@ -226,7 +226,7 @@ async fn get_response_body_base64_encodes_non_utf8_captured_bytes() { CapturedBody::from_bytes(vec![0x00, 0xff, b'a']), [None::], ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_283, @@ -269,7 +269,7 @@ async fn get_request_post_data_matches_chromium_errors_and_binary_encoding() { std::iter::empty::(), false, ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_284, @@ -334,7 +334,7 @@ async fn get_response_body_returns_partial_body_after_staged_loading_failed() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let handle = SubresourceNetworkRequestHandle::new(41); let document_url = Url::parse("https://example.test/page").unwrap(); @@ -465,7 +465,7 @@ async fn get_request_post_data_respects_recorded_session_visibility() { std::iter::empty::(), false, ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_288, @@ -521,6 +521,7 @@ async fn get_request_post_data_returns_main_document_navigation_post_body() { request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }; let mut events = Vec::new(); @@ -610,6 +611,7 @@ async fn get_request_post_data_uses_text_projection_while_bidi_collector_keeps_t request_load_policy: crate::conn::NavigationRequestLoadPolicy::DocumentInitiated, timestamp: 0.0, source_document_security: Default::default(), + post_commit_target_activation: None, }; let mut events = Vec::new(); @@ -659,7 +661,7 @@ async fn get_network_data_returns_bidi_response_body_bytes() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let (result, _) = ctx .conn @@ -998,7 +1000,7 @@ async fn network_data_collectors_gate_get_data_disown_and_remove() { "pre collector body".to_owned(), [Some("bidi-session-1".to_owned())], ); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); for (collector, max_encoded_data_size) in [ ("collector-ok", 1000), @@ -1309,7 +1311,7 @@ async fn network_data_collector_body_persists_after_target_artifact_cleanup() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1".to_owned()); bc.attach_active_session("bidi-session-1".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let collector_id = crate::devtools_runtime::DevToolsNetworkDataCollectorId::from("collector-persist"); @@ -1464,7 +1466,7 @@ async fn network_data_explicit_collector_prefers_collected_body_over_stale_targe let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1".to_owned()); bc.attach_active_session("bidi-session-1".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let collector_id = crate::devtools_runtime::DevToolsNetworkDataCollectorId::from("collector-shadow"); @@ -1546,7 +1548,7 @@ async fn network_data_explicit_collector_rejects_unconfigured_data_type() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1".to_owned()); bc.attach_active_session("bidi-session-1".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let collector_id = crate::devtools_runtime::DevToolsNetworkDataCollectorId::from("collector-request-only"); @@ -1610,7 +1612,7 @@ async fn network_data_without_collector_requires_matching_collected_data_type() let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1".to_owned()); bc.attach_active_session("bidi-session-1".to_owned()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let (result, _) = ctx .conn @@ -1687,7 +1689,7 @@ async fn network_data_collector_membership_uses_recorded_target_scope() { None, "about:blank".to_owned(), )); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); for (collector, target_id) in [ ("collector-active", "TID-active"), @@ -1810,7 +1812,7 @@ async fn network_data_collector_gated_body_without_match_is_not_readable() { None, "about:blank".to_owned(), )); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let (result, _) = ctx .conn @@ -1889,7 +1891,7 @@ async fn get_network_data_reports_unimplemented_or_missing_data_with_bidi_errors let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); bc.record_pending_response_body("REQ-pending".to_owned(), [None::]); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); for (request_id, data_type, collector, expected_kind) in [ ( @@ -1945,7 +1947,8 @@ async fn get_network_data_reports_unimplemented_or_missing_data_with_bidi_errors #[tokio::test(flavor = "multi_thread")] async fn get_response_body_rejects_invalid_params() { let mut ctx = TestContext::new(); - ctx.conn.browser_context = Some(BrowserContext::new("BID-1".into())); + ctx.conn + .insert_browser_context(BrowserContext::new("BID-1".into())); ctx.process_async(json!({ "id": 3, @@ -1988,7 +1991,7 @@ async fn main_document_navigation_get_response_body_preserves_binary_bytes() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_284, @@ -2078,7 +2081,7 @@ fetch('/binary') let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 7_287, @@ -2205,7 +2208,7 @@ fetch("/upload", {method: "POST", body: formData}) let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); let (result, _) = ctx .conn diff --git a/moli-protocol/src/domains/network/tests/runtime.rs b/moli-protocol/src/domains/network/tests/runtime.rs index 7606b98b0a..a29fe86728 100644 --- a/moli-protocol/src/domains/network/tests/runtime.rs +++ b/moli-protocol/src/domains/network/tests/runtime.rs @@ -82,7 +82,7 @@ async fn runtime_capture_without_network_listener_does_not_advance_subresource_c let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; @@ -149,7 +149,7 @@ async fn runtime_script_uses_document_referer_and_script_cdp_initiator() { let mut bc = BrowserContext::new("BID-1".into()); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(Some("SID-1")); ctx.process_async(json!({ @@ -248,9 +248,9 @@ async fn runtime_fetch_post_body_is_available_by_network_request_id() { let page_url = format!("http://{addr}/page"); let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); - bc.attach_active_session("SID-1".to_owned()); bc.set_active_target_id("TID-1".to_owned()); - ctx.conn.browser_context = Some(bc); + bc.attach_active_session("SID-1".to_owned()); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.sent.clear(); @@ -337,8 +337,8 @@ async fn runtime_form_post_navigation_body_is_available_by_network_request_id() let page_url = format!("http://{addr}/page"); let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); - bc.attach_active_session("SID-1".to_owned()); bc.set_active_target_id("TID-1".to_owned()); + bc.attach_active_session("SID-1".to_owned()); ctx.conn.browser_context = Some(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; @@ -422,8 +422,8 @@ async fn websocket_runtime_activity_emits_cdp_websocket_events_without_payload() let socket_literal = serde_json::to_string(&socket_url).unwrap(); let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); - bc.attach_active_session("SID-1".to_owned()); bc.set_active_target_id("TID-1".to_owned()); + bc.attach_active_session("SID-1".to_owned()); ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; @@ -567,9 +567,9 @@ async fn rejected_websocket_handshake_emits_frame_error_then_closed() { let socket_literal = serde_json::to_string(&socket_url).unwrap(); let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); - bc.attach_active_session("SID-1".to_owned()); bc.set_active_target_id("TID-1".to_owned()); - ctx.conn.browser_context = Some(bc); + bc.attach_active_session("SID-1".to_owned()); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.sent.clear(); @@ -716,7 +716,7 @@ document.write('", ) .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"}), @@ -523,7 +518,7 @@ async fn deferred_heap_profiler_state_follows_renderer_owned_document_navigation .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .inspector_session_state; assert!( inspector_state.v8_state.is_some(), @@ -834,12 +829,13 @@ async fn heap_profiler_moli_reset_idle_engine_only_resets_without_loaded_page() "loaded targets should not be reset by the idle-engine diagnostic: {loaded_response:?}" ); - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .reset_active_target_slot_to_empty_async() + ctx.process_async(json!({"id": 206_818, "method": "Page.close"})) .await; + assert_eq!( + take_response_by_id(&mut ctx, 206_818)["result"], + json!({}), + "the production Target termination transaction should retire the loaded target" + ); ctx.process_async(json!({"id": 206_819, "method": "HeapProfiler.moliResetIdleEngine"})) .await; diff --git a/moli-protocol/src/domains/runtime/tests/evaluate.rs b/moli-protocol/src/domains/runtime/tests/evaluate.rs index 0c94b7e4b5..9d96f54e6f 100644 --- a/moli-protocol/src/domains/runtime/tests/evaluate.rs +++ b/moli-protocol/src/domains/runtime/tests/evaluate.rs @@ -168,9 +168,13 @@ async fn emitted_isolated_world_unique_context_id_selects_that_realm() { #[tokio::test] async fn emitted_child_frame_unique_context_id_selects_that_realm() { let mut ctx = TestContext::new(); - with_loaded_document_async( - &mut ctx, - r#"parent"#, + let mut browser_context = BrowserContext::new("BID-1".into()); + browser_context.set_active_target_id("TID-1".to_owned()); + ctx.conn.insert_browser_context(browser_context); + ctx.enable_page_events_for_test(None); + ctx.install_navigation_fixture_for_session_owner( + r#"data:text/html,parent"#, + None, ) .await; @@ -181,6 +185,7 @@ async fn emitted_child_frame_unique_context_id_selects_that_realm() { .as_str() .map(str::to_owned) .expect("loaded iframe should appear in Page.getFrameTree"); + crate::testing::wait_until_frame_stopped_loading(&mut ctx, &child_frame_id).await; ctx.process_async(json!({ "id": 21, "method": "Page.createIsolatedWorld", @@ -315,6 +320,131 @@ async fn enable_with_page_can_complete_through_pending_command_task() { ); } +#[tokio::test] +async fn node_result_normalization_is_an_owned_runtime_command_phase() { + let mut ctx = TestContext::new(); + with_loaded_document_async(&mut ctx, "node").await; + + let raw = json!({ + "id": 11_003, + "method": "Runtime.evaluate", + "params": { + "expression": "document.body", + "awaitPromise": true + } + }) + .to_string(); + let first = match ctx.conn.start_command_dispatch(&raw) { + CdpCommandTaskStep::Pending(pending) => *pending, + CdpCommandTaskStep::Complete(_) => { + panic!("Runtime.evaluate should first wait for its Inspector dispatch") + } + }; + assert!( + !first.owns_runtime_protocol_message_normalization_for_test(), + "the initial participant should still be the renderer Inspector dispatch" + ); + + let second = match ctx + .conn + .complete_pending_command_dispatch(first.wait().await) + .await + { + CdpCommandTaskStep::Pending(pending) => *pending, + CdpCommandTaskStep::Complete(_) => { + panic!("DOM remote-object normalization must be exposed as a second participant") + } + }; + assert!( + second.owns_runtime_protocol_message_normalization_for_test(), + "the command must own its exact-Page DOM-node normalization wait" + ); + + let (messages, scheduler_events) = + complete_pending_command_task_for_test(&mut ctx, second).await; + assert!(scheduler_events.is_empty()); + let response = messages + .iter() + .find(|message| message["id"] == json!(11_003)) + .expect("Runtime.evaluate should retain its command response"); + assert_eq!(response["result"]["result"]["subtype"], json!("node")); +} + +#[tokio::test] +async fn stale_owned_runtime_normalization_does_not_enter_replacement_page() { + let mut ctx = TestContext::new(); + with_loaded_document_async(&mut ctx, "old").await; + let old_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(None) + .expect("the old Page should have an exact renderer attachment"); + + let raw = json!({ + "id": 11_004, + "method": "Runtime.evaluate", + "params": { + "expression": "document.body", + "awaitPromise": true + } + }) + .to_string(); + let first = match ctx.conn.start_command_dispatch(&raw) { + CdpCommandTaskStep::Pending(pending) => *pending, + CdpCommandTaskStep::Complete(_) => panic!("Runtime.evaluate should dispatch to Inspector"), + }; + let normalization = match ctx + .conn + .complete_pending_command_dispatch(first.wait().await) + .await + { + CdpCommandTaskStep::Pending(pending) => *pending, + CdpCommandTaskStep::Complete(_) => panic!("node normalization should remain pending"), + }; + assert!(normalization.owns_runtime_protocol_message_normalization_for_test()); + let completed_normalization = normalization.wait().await; + + let replacement = ctx + .conn + .load_page_via_runtime_async("data:text/html,replacement") + .await + .expect("replacement Page should load"); + let old_page = ctx + .conn + .runtime_session_owner_slot_mut(None) + .expect("the target should remain resident") + .clear_loaded_page_for_test_fixture(); + drop(old_page); + ctx.conn + .runtime_session_owner_slot_mut(None) + .expect("the replacement target should remain resident") + .set_loaded_page_for_test(replacement); + let replacement_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(None) + .expect("the replacement Page should have an exact renderer attachment"); + assert_ne!(old_attachment, replacement_attachment); + + let step = ctx + .conn + .complete_pending_command_dispatch(completed_normalization) + .await; + let (messages, scheduler_events) = complete_command_task_step_for_test(&mut ctx, step).await; + assert!(scheduler_events.is_empty()); + assert!( + messages + .iter() + .any(|message| message["id"] == json!(11_004) && message.get("error").is_some()), + "stale Runtime output should terminate the command with an error: {messages:?}" + ); + assert_eq!( + ctx.conn + .current_renderer_agent_attachment_id_for_session_owner(None), + Some(replacement_attachment), + "stale normalization must leave the successor attachment untouched" + ); + assert!(!ctx.conn.has_pending_inspector_awaits()); +} + #[tokio::test] async fn loaded_page_runtime_enable_projection_waits_for_v8_success() { let mut ctx = TestContext::new(); @@ -331,7 +461,7 @@ async fn loaded_page_runtime_enable_projection_waits_for_v8_success() { .browser_context .as_ref() .expect("browser context should exist") - .devtools_session_state + .devtools_session_state() .runtime_session_state .runtime_frontend_enabled, "protocol Runtime.enabled projection must not flip before V8 Runtime.enable succeeds" @@ -353,7 +483,7 @@ async fn loaded_page_runtime_enable_projection_waits_for_v8_success() { .browser_context .as_ref() .expect("browser context should exist") - .devtools_session_state + .devtools_session_state() .runtime_session_state .runtime_frontend_enabled, "protocol Runtime.enabled projection should flip after V8 Runtime.enable succeeds" @@ -517,11 +647,6 @@ async fn loaded_page_runtime_agent_state_commands_dispatch_after_runtime_enable( 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"); enable_runtime_and_take_execution_context_id_async(&mut ctx, 11_016).await; for (id, method, params) in [ @@ -556,7 +681,7 @@ async fn runtime_agent_configuration_is_restored_on_replacement_page_isolate() { .expect("browser context should exist"); assert!( browser_context - .devtools_session_state + .devtools_session_state() .inspector_session_state .v8_state .is_some(), @@ -607,11 +732,6 @@ async fn runtime_agent_configuration_is_restored_on_replacement_page_isolate() { 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"); ctx.process_async(json!({"id": 11_023, "method": "Runtime.enable"})) .await; @@ -627,14 +747,14 @@ async fn opaque_reattach_state_wins_over_conflicting_runtime_listener_configurat .expect("browser context should exist"); assert!( browser_context - .devtools_session_state + .devtools_session_state() .inspector_session_state .v8_state .is_some(), "successful Runtime.disable must persist the disabled V8 agent cookie" ); browser_context - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; @@ -1709,7 +1829,7 @@ async fn enable_and_disable_update_browser_context_runtime_flag() { .browser_context .as_ref() .expect("browser context should exist") - .devtools_session_state + .devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); @@ -1722,7 +1842,7 @@ async fn enable_and_disable_update_browser_context_runtime_flag() { .browser_context .as_ref() .expect("browser context should exist") - .devtools_session_state + .devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); @@ -1735,7 +1855,7 @@ async fn enable_and_disable_update_browser_context_runtime_flag() { .browser_context .as_ref() .expect("browser context should exist") - .devtools_session_state + .devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); @@ -1765,8 +1885,8 @@ async fn evaluate_rejects_default_context_while_main_document_navigation_is_pend browser_context.set_active_target_id("TID-1"); browser_context.attach_active_session("SID-1"); browser_context.set_target_url("data:text/html,previous".to_owned()); - browser_context - .start_document_navigation_for_active_target("PENDING-LOADER".to_owned()) + ctx.conn + .start_document_navigation_for_session_owner(Some("SID-1"), "PENDING-LOADER".to_owned()) .expect("active navigation should start"); ctx.sent.clear(); @@ -1815,10 +1935,7 @@ async fn evaluate_started_before_pending_navigation_can_complete() { .to_string(), ); ctx.conn - .browser_context - .as_mut() - .expect("browser context should exist") - .start_document_navigation_for_active_target("PENDING-LOADER".to_owned()) + .start_document_navigation_for_session_owner(Some("SID-1"), "PENDING-LOADER".to_owned()) .expect("active navigation should start"); let (messages, _scheduler_events) = complete_command_task_step_for_test(&mut ctx, step).await; @@ -1851,8 +1968,8 @@ async fn evaluate_rejects_auxiliary_session_while_main_document_navigation_is_pe "auxiliary session should attach to the active target" ); browser_context.set_target_url("data:text/html,previous".to_owned()); - browser_context - .start_document_navigation_for_active_target("PENDING-LOADER".to_owned()) + ctx.conn + .start_document_navigation_for_session_owner(Some("SID-1"), "PENDING-LOADER".to_owned()) .expect("active navigation should start"); assert!( ctx.conn @@ -1890,8 +2007,8 @@ async fn dom_get_document_rejects_while_main_document_navigation_is_pending() { browser_context.set_active_target_id("TID-1"); browser_context.attach_active_session("SID-1"); browser_context.set_target_url("data:text/html,previous".to_owned()); - browser_context - .start_document_navigation_for_active_target("PENDING-LOADER".to_owned()) + ctx.conn + .start_document_navigation_for_session_owner(Some("SID-1"), "PENDING-LOADER".to_owned()) .expect("active navigation should start"); ctx.sent.clear(); @@ -1917,31 +2034,32 @@ async fn document_navigation_gate_is_scoped_to_background_target_owner() { browser_context.set_active_target_id("TID-active"); browser_context.attach_active_session("SID-active"); browser_context - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; browser_context.background_targets.push(background_target); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.install_navigation_fixture_for_session_owner( - "data:text/html,active", + "data:text/html,active", Some("SID-active"), ) .await; ctx.install_navigation_fixture_for_session_owner( - "data:text/html,background", + "data:text/html,background", Some("SID-background"), ) .await; - let browser_context = ctx.conn.browser_context.as_mut().expect("browser context"); - browser_context.mutate_parked_page_session_state("TID-background", |state| { - state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled = true; - }); - browser_context - .start_document_navigation_for_target( - "TID-background", + ctx.conn + .browser_context + .as_mut() + .expect("browser context fixture") + .primary_devtools_session_state_for_target_mut("TID-background") + .expect("background Target frontend state") + .runtime_session_state + .runtime_frontend_enabled = true; + ctx.conn + .start_document_navigation_for_session_owner( + Some("SID-background"), "PENDING-BACKGROUND-LOADER".to_owned(), ) .expect("background document navigation should start"); @@ -2292,16 +2410,16 @@ async fn page_navigate_network_failure_commits_error_document() { ); let old_document_token = ctx .conn - .browser_context - .as_mut() - .expect("browser context") - .start_document_navigation_for_active_target("LOADER-before-network-error".to_owned()) + .start_document_navigation_for_session_owner( + Some("SID-1"), + "LOADER-before-network-error".to_owned(), + ) .expect("loaded document token"); ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .commit_document_navigation_if_matches(&old_document_token); + .commit_document_navigation_for_session_owner_if_matches( + Some("SID-1"), + &old_document_token, + ); let before_target_page = ctx .conn .target_page_residence_identity_for_session(Some("SID-1")) @@ -2419,12 +2537,12 @@ async fn page_navigate_network_failure_commits_error_document() { .and_then(BrowserContext::loaded_page) .expect("network error Document should remain loaded"); assert_eq!(page.final_url().as_str(), NETWORK_ERROR_PAGE_URL); + drop(page); assert!( - !ctx.conn - .browser_context - .as_ref() - .expect("browser context") - .accepts_document_body_completion_event(&old_document_token), + !ctx.conn.accepts_document_body_completion_for_session_owner( + Some("SID-1"), + &old_document_token, + ), "the retired document generation must not accept late completion" ); let error_document_loader_id = ctx @@ -2441,7 +2559,7 @@ async fn page_navigate_network_failure_commits_error_document() { frame_id: "TID-1".to_owned(), session_id: Some("SID-1".to_owned()), request_id: Some(stale_request_id.to_owned()), - loader_id: old_document_token.loader_id.clone(), + loader_id: old_document_token.loader_id().to_owned(), request_announced: true, requested_url: url::Url::parse("https://stale.example.test/old-body").unwrap(), request_method: "GET".to_owned(), @@ -2454,6 +2572,7 @@ async fn page_navigate_network_failure_commits_error_document() { "http://127.0.0.1".to_owned(), "InsecureScheme".to_owned(), ), + post_commit_target_activation: None, }, None, Ok(crate::conn::CapturedBody::from_string( @@ -2465,11 +2584,13 @@ async fn page_navigate_network_failure_commits_error_document() { vec![("content-type".to_owned(), "text/plain".to_owned())], false, ); - let (stale_completion_messages, stale_completion_scheduler_events) = ctx + let (stale_completion_outcome, stale_completion_disposition) = ctx .conn .drain_background_navigation_completion_turn_async(stale_body_completion) - .await - .into_parts(); + .await; + assert!(stale_completion_disposition.is_terminal()); + let (stale_completion_messages, stale_completion_scheduler_events) = + stale_completion_outcome.into_parts(); assert!(stale_completion_messages.is_empty()); assert!(stale_completion_scheduler_events.is_empty()); assert_eq!( @@ -2501,7 +2622,7 @@ async fn page_navigate_network_failure_commits_error_document() { 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" + "current master installs navigation Documents as a new target Page generation" ); assert_ne!( ctx.conn @@ -3023,8 +3144,11 @@ async fn call_function_context_resolution_can_complete_through_pending_command_d .conn .try_start_pending_command_dispatch(&isolated_raw) .expect("Runtime.callFunctionOn with isolated executionContextId should start pending"); - let (isolated_messages, _) = - super::complete_pending_command_task_for_test(&mut ctx, isolated_pending).await; + let (isolated_messages, _) = ctx + .complete_command_task_step_for_test(CdpCommandTaskStep::Pending(Box::new( + isolated_pending, + ))) + .await; let isolated_response = isolated_messages .iter() .find(|message| message["id"] == json!(3_025)) @@ -3044,8 +3168,9 @@ async fn call_function_context_resolution_can_complete_through_pending_command_d .conn .try_start_pending_command_dispatch(&default_raw) .expect("Runtime.callFunctionOn without objectId should start pending"); - let (default_messages, _) = - super::complete_pending_command_task_for_test(&mut ctx, default_pending).await; + let (default_messages, _) = ctx + .complete_command_task_step_for_test(CdpCommandTaskStep::Pending(Box::new(default_pending))) + .await; let default_response = default_messages .iter() .find(|message| message["id"] == json!(3_026)) @@ -6611,7 +6736,7 @@ async fn runtime_evaluate_dom_parser_query_apis_reuse_existing_detached_node_ide #[tokio::test] async fn registered_named_world_object_handles_remain_callable_after_navigation() { let mut ctx = TestContext::new(); - let (background_tx, mut background_rx) = tokio::sync::mpsc::unbounded_channel(); + let (background_tx, mut background_rx) = crate::conn::browser_background_output_channel(); ctx.conn.set_background_event_sender(background_tx); with_loaded_document_async(&mut ctx, "before").await; let bc = ctx @@ -6621,7 +6746,7 @@ async fn registered_named_world_object_handles_remain_callable_after_navigation( .expect("browser context should exist"); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; @@ -6711,7 +6836,7 @@ async fn registered_named_world_object_handles_remain_callable_after_navigation( let mut other_context = crate::conn::BrowserContext::new("BID-2".into()); other_context.set_active_target_id("TID-2"); other_context.attach_active_session("SID-2"); - ctx.conn.inactive_browser_contexts.push(other_context); + ctx.conn.insert_browser_context(other_context); assert!( ctx.conn.activate_browser_context_by_id_async("BID-2").await, "test setup should switch the active context away from the pending Runtime.callFunctionOn owner" @@ -6763,7 +6888,7 @@ async fn call_function_on_rejects_object_id_known_to_different_target_owner() { let mut other_context = crate::conn::BrowserContext::new("BID-2".into()); other_context.set_active_target_id("TID-2"); other_context.attach_active_session("SID-2"); - ctx.conn.inactive_browser_contexts.push(other_context); + ctx.conn.insert_browser_context(other_context); ctx.process_async(json!({ "id": 513, @@ -6949,7 +7074,7 @@ async fn call_function_on_rejects_dom_resolve_node_object_id_from_different_targ let mut other_context = crate::conn::BrowserContext::new("BID-2".into()); other_context.set_active_target_id("TID-2"); other_context.attach_active_session("SID-2"); - ctx.conn.inactive_browser_contexts.push(other_context); + ctx.conn.insert_browser_context(other_context); ctx.process_async(json!({ "id": 518, diff --git a/moli-protocol/src/domains/runtime/tests/inspector.rs b/moli-protocol/src/domains/runtime/tests/inspector.rs index 03d74de1d7..20c457d03c 100644 --- a/moli-protocol/src/domains/runtime/tests/inspector.rs +++ b/moli-protocol/src/domains/runtime/tests/inspector.rs @@ -11,7 +11,7 @@ async fn isolated_inspector_context_lookup_does_not_issue_internal_runtime_enabl .expect("browser context should exist"); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; let utility_context_id = create_isolated_world_async(&mut ctx, 14, "utility").await; @@ -46,7 +46,7 @@ async fn isolated_inspector_context_lookup_does_not_collide_with_pending_inspect .expect("browser context should exist"); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; let utility_context_id = create_isolated_world_async(&mut ctx, 15, "utility").await; @@ -83,7 +83,7 @@ async fn runtime_evaluate_materializes_default_context_via_inspector_hook() { .expect("browser context should exist"); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; ctx.sent.clear(); @@ -135,7 +135,7 @@ async fn pending_inspector_await_does_not_block_default_context_evaluate() { .expect("browser context should exist"); bc.set_active_target_id("TID-1"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; ctx.conn diff --git a/moli-protocol/src/domains/runtime/tests/mod.rs b/moli-protocol/src/domains/runtime/tests/mod.rs index 5ec7fa13be..93cf0ef298 100644 --- a/moli-protocol/src/domains/runtime/tests/mod.rs +++ b/moli-protocol/src/domains/runtime/tests/mod.rs @@ -43,13 +43,13 @@ fn load_shared_worker_target(ctx: &mut TestContext, session_id: &str) { ); target.attach_session(session_id.to_owned()); bc.insert_shared_worker_target(target); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); } fn load_dedicated_worker_target(ctx: &mut TestContext, session_id: &str) { let browser_context_id = "BID-dedicated".to_owned(); let mut bc = BrowserContext::new(browser_context_id.clone()); let mut target = crate::conn::DedicatedWorkerTargetState::new( - crate::conn::TargetPageResidenceIdentity::new_for_test(browser_context_id, None, 1), + crate::conn::TargetPageResidenceIdentity::new(browser_context_id, None, 0), moli_core::RendererOwnerLocalHostId::new_for_testing(1), 82, "TID-dedicated-worker".to_owned(), @@ -140,23 +140,19 @@ async fn push_loaded_runtime_frontend_enabled_background_context_async( session_id: &str, html: &str, ) { - let page = ctx - .conn - .load_page_via_runtime_async(&format!("data:text/html,{html}")) - .await - .expect("test background page should load"); let mut background_context = crate::conn::BrowserContext::new(browser_context_id.to_owned()); - let _ = background_context - .active_target - .runtime_slot - .replace_loaded_page(Some(page)); background_context.set_active_target_id(target_id.to_owned()); background_context.attach_active_session(session_id.to_owned()); background_context - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; - ctx.conn.inactive_browser_contexts.push(background_context); + ctx.conn.insert_browser_context(background_context); + ctx.install_navigation_fixture_for_session_owner( + &format!("data:text/html,{html}"), + Some(session_id), + ) + .await; } async fn with_loaded_runtime_frontend_enabled_background_target_async( ctx: &mut TestContext, @@ -176,13 +172,13 @@ async fn with_loaded_runtime_frontend_enabled_background_target_async( browser_context.set_active_target_id(active_target_id.to_owned()); browser_context.attach_active_session(active_session_id.to_owned()); browser_context.background_targets.push(background_target); - browser_context.mutate_parked_page_session_state(background_target_id, |state| { - state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled = true; - }); - ctx.conn.browser_context = Some(browser_context); + browser_context.adopt_background_target_fixture_attachments(); + browser_context + .primary_devtools_session_state_for_target_mut(background_target_id) + .expect("background Target frontend state") + .runtime_session_state + .runtime_frontend_enabled = true; + ctx.conn.insert_browser_context(browser_context); ctx.install_navigation_fixture_for_session_owner( &format!("data:text/html,{html}"), Some(background_session_id), diff --git a/moli-protocol/src/domains/runtime/tests/navigation.rs b/moli-protocol/src/domains/runtime/tests/navigation.rs index 7a088df9d7..e9413f9d6e 100644 --- a/moli-protocol/src/domains/runtime/tests/navigation.rs +++ b/moli-protocol/src/domains/runtime/tests/navigation.rs @@ -9,7 +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 (background_tx, _) = crate::conn::browser_background_output_channel(); let (completion_tx, _) = tokio::sync::mpsc::unbounded_channel::(); ctx.conn.set_background_event_sender(background_tx); @@ -427,18 +427,36 @@ async fn evaluate_history_api_navigation_emits_navigated_within_document() { ); ctx.sent.clear(); - ctx.process_async(json!({ + let page_before_traversal = ctx + .conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")); + let raw = json!({ "id": 37, "method": "Page.navigateToHistoryEntry", "sessionId": "SID-1", "params": { "entryId": initial_entry_id } - })) - .await; - take_response_by_id(&mut ctx, 37); - // Chromium acknowledges Page.navigateToHistoryEntry before the history - // traversal task emits Page.navigatedWithinDocument. Observe the real - // renderer publication instead of making the command helper drain a later - // Page turn synchronously. + }) + .to_string(); + let CdpCommandTaskStep::Pending(frontend_wait) = ctx.conn.start_command_dispatch(&raw) else { + panic!("same-Document history traversal should await Browser Owner admission"); + }; + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + + let dispatch = ctx.start_one_ready_browser_host_turn_for_test(); + let host_outcome = ctx.finish_browser_host_turn_for_test(dispatch).await; + let (host_messages, _scheduler_events) = ctx + .route_completed_command_outcome_for_test(host_outcome) + .await; + assert!( + host_messages + .iter() + .all(|message| message["id"] != json!(37)), + "Browser Host must retain the live frontend response projection" + ); + // Deliberately leave the command receiver unpolled while renderer output + // publishes the same-Document traversal. Chromium may acknowledge this + // command before Page.navigatedWithinDocument, but a slow frontend must not + // become the browser action's progress predecessor. wait_until_message( &mut ctx, Some("SID-1"), @@ -451,6 +469,26 @@ async fn evaluate_history_api_navigation_emits_navigated_within_document() { }, ) .await; + assert_eq!( + ctx.conn + .renderer_page_residence_identity_for_session_owner(Some("SID-1")), + page_before_traversal, + "Core classification must preserve the exact Document residence" + ); + + let completed = frontend_wait.wait().await; + let CdpCommandTaskStep::Complete(outcome) = + ctx.conn.complete_pending_command_dispatch(completed).await + else { + panic!("frontend should receive the terminal history projection"); + }; + let (messages, _scheduler_events) = ctx.route_completed_command_outcome_for_test(outcome).await; + let response = messages + .iter() + .find(|message| message["id"] == json!(37)) + .expect("same-Document history response should retain command correlation"); + assert_eq!(response["sessionId"], json!("SID-1")); + assert_eq!(response["result"], json!({})); ctx.process_async(json!({ "id": 38, diff --git a/moli-protocol/src/domains/runtime/tests/objects.rs b/moli-protocol/src/domains/runtime/tests/objects.rs index 77912ef922..c2c7c395b4 100644 --- a/moli-protocol/src/domains/runtime/tests/objects.rs +++ b/moli-protocol/src/domains/runtime/tests/objects.rs @@ -628,7 +628,7 @@ async fn runtime_disable_clears_background_child_default_context_emission_cursor browser_context.set_active_target_id("TID-active".to_owned()); browser_context.attach_active_session("SID-active".to_owned()); browser_context.background_targets.push(background_target); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); ctx.install_navigation_fixture_for_session_owner( &format!("data:text/html,{html}"), Some("SID-background"), diff --git a/moli-protocol/src/domains/runtime/tests/service_worker.rs b/moli-protocol/src/domains/runtime/tests/service_worker.rs index 372c703f11..cff95fc12f 100644 --- a/moli-protocol/src/domains/runtime/tests/service_worker.rs +++ b/moli-protocol/src/domains/runtime/tests/service_worker.rs @@ -16,13 +16,13 @@ fn load_service_worker_target(ctx: &mut TestContext, session_id: &str) { let mut target = service_worker_target(); target.attach_session(session_id.to_owned()); bc.insert_service_worker_target(target); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); } fn load_unattached_service_worker_target(ctx: &mut TestContext) { let mut bc = BrowserContext::new("BID-service".to_owned()); bc.insert_service_worker_target(service_worker_target()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); } fn service_worker_target() -> ServiceWorkerTargetState { diff --git a/moli-protocol/src/domains/security.rs b/moli-protocol/src/domains/security.rs index 231a107f63..2b9089c871 100644 --- a/moli-protocol/src/domains/security.rs +++ b/moli-protocol/src/domains/security.rs @@ -142,8 +142,10 @@ mod tests { async fn set_ignore_certificate_errors_is_scoped_to_active_browser_context() { let mut ctx = TestContext::new(); let mut first = BrowserContext::new("BID-1".into()); + first.set_active_target_id("TID-1"); first.attach_active_session("SID-1"); let mut second = BrowserContext::new("BID-2".into()); + second.set_active_target_id("TID-2"); second.attach_active_session("SID-2"); ctx.conn.insert_browser_context(first); ctx.conn.insert_browser_context(second); diff --git a/moli-protocol/src/domains/storage/ops.rs b/moli-protocol/src/domains/storage/ops.rs index 0c47de5437..a563a31368 100644 --- a/moli-protocol/src/domains/storage/ops.rs +++ b/moli-protocol/src/domains/storage/ops.rs @@ -770,7 +770,8 @@ fn complete_get_storage_key_for_top_frame_command( Err(error) => return CommandOutputPlan::error(-32000, error), }; let mut route_scope = owner_scope.enter(conn); - let Some(page) = loaded_page_mut_for_session(route_scope.conn_mut(), owner_scope.session_id()) + let Some(mut page) = + loaded_page_mut_for_session(route_scope.conn_mut(), owner_scope.session_id()) else { return CommandOutputPlan::error(-32000, "NoFrameForGivenId"); }; @@ -791,7 +792,8 @@ fn complete_get_storage_key_for_frame_command( Err(error) => return CommandOutputPlan::error(-32000, error), }; let mut route_scope = owner_scope.enter(conn); - let Some(page) = loaded_page_mut_for_session(route_scope.conn_mut(), owner_scope.session_id()) + let Some(mut page) = + loaded_page_mut_for_session(route_scope.conn_mut(), owner_scope.session_id()) else { return CommandOutputPlan::error(-32000, "NoFrameForGivenId"); }; @@ -878,7 +880,7 @@ fn complete_set_cookies_result( )); }; let owner = { - let Some(page) = browser_context.active_target.runtime_slot.loaded_page_mut() else { + let Some(mut page) = browser_context.active_target.runtime_slot.loaded_page_mut() else { return Err(DevToolsError::new( DevToolsErrorKind::Internal, "NoDocumentLoaded", @@ -899,10 +901,10 @@ fn complete_set_cookies_result( Ok(set_cookie_reports_result(&reports)) } -fn loaded_page_mut_for_session<'a>( - conn: &'a mut CdpConnection, +fn loaded_page_mut_for_session( + conn: &mut CdpConnection, session_id: Option<&str>, -) -> Option<&'a mut moli_core::page::Page> { +) -> Option { conn.loaded_page_mut_for_protocol_access(session_id).ok() } diff --git a/moli-protocol/src/domains/storage/tests.rs b/moli-protocol/src/domains/storage/tests.rs index 369a9f4413..880bbe3178 100644 --- a/moli-protocol/src/domains/storage/tests.rs +++ b/moli-protocol/src/domains/storage/tests.rs @@ -392,7 +392,7 @@ async fn storage_get_storage_key_for_frame_returns_top_frame_origin() { bc.set_active_target_id("TID-SK-TOP"); bc.set_target_url("https://top.example/app".into()); bc.set_target_security_origin("https://top.example".into()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 1, @@ -421,7 +421,7 @@ async fn storage_get_storage_key_for_top_frame_uses_loaded_page_storage_key() { bc.set_target_url("https://stale-target-url.example/app".into()); bc.set_target_security_origin("https://stale-target-url.example".into()); bc.set_target_secure_context_type("Secure".into()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, None) .await; @@ -457,7 +457,7 @@ async fn storage_get_storage_key_for_frame_returns_child_frame_inherited_origin( bc.set_target_url(page_url.clone()); bc.set_target_security_origin(top_origin.clone()); bc.set_target_secure_context_type("Secure".into()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, None) .await; @@ -505,7 +505,7 @@ async fn storage_get_storage_key_for_credentialless_child_uses_page_nonce() { bc.set_target_url(page_url.clone()); bc.set_target_security_origin(top_origin.clone()); bc.set_target_secure_context_type("Secure".into()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, None) .await; @@ -651,7 +651,7 @@ async fn storage_get_storage_key_for_frame_returns_child_frame_partition_key() { bc.set_target_url(page_url.clone()); bc.set_target_security_origin(top_origin); bc.set_target_secure_context_type("Secure".into()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(None); ctx.install_navigation_fixture_for_session_owner(&page_url, None) @@ -708,7 +708,7 @@ async fn storage_get_storage_key_for_frame_rejects_opaque_child_frame() { bc.set_target_url(page_url.clone()); bc.set_target_security_origin(top_origin); bc.set_target_secure_context_type("Secure".into()); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.enable_page_events_for_test(None); ctx.install_navigation_fixture_for_session_owner(&page_url, None) @@ -2343,7 +2343,7 @@ async fn storage_get_usage_and_quota_reports_indexed_db_usage_for_origin() { let mut ctx = TestContext::new(); let mut browser_context = BrowserContext::new("BID-usage-indexeddb".into()); browser_context.set_active_target_id("TID-usage-indexeddb"); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); let url = Url::parse("https://idb-usage.example/app").unwrap(); ctx.install_buffered_navigation_fixture_for_session_owner( url.clone(), @@ -2355,7 +2355,7 @@ async fn storage_get_usage_and_quota_reports_indexed_db_usage_for_origin() { install_storage_test_completion_binding(&mut ctx).await; { - let page = ctx + let mut page = ctx .conn .browser_context .as_mut() @@ -2595,6 +2595,7 @@ async fn storage_get_usage_and_quota_targets_command_session_browser_context() { let storage_key = first_party_storage_key_for_origin(&origin); let mut active = BrowserContext::new("BID-usage-active".into()); + active.set_active_target_id("TID-usage-active"); active.attach_active_session("SID-usage-active"); { let mut store = active.web_storage_store_for_test().lock(); @@ -2602,6 +2603,7 @@ async fn storage_get_usage_and_quota_targets_command_session_browser_context() { } let mut inactive = BrowserContext::new("BID-usage-inactive".into()); + inactive.set_active_target_id("TID-usage-inactive"); inactive.attach_active_session("SID-usage-inactive"); { let mut store = inactive.web_storage_store_for_test().lock(); @@ -2691,8 +2693,10 @@ async fn storage_override_quota_for_origin_targets_command_session_browser_conte let mut ctx = TestContext::new(); let mut active = BrowserContext::new("BID-quota-active".into()); + active.set_active_target_id("TID-quota-active"); active.attach_active_session("SID-quota-active"); let mut inactive = BrowserContext::new("BID-quota-inactive".into()); + inactive.set_active_target_id("TID-quota-inactive"); inactive.attach_active_session("SID-quota-inactive"); ctx.conn.browser_context = Some(active); @@ -2831,6 +2835,7 @@ async fn storage_clear_data_for_origin_targets_command_session_browser_context() let storage_key = first_party_storage_key_for_origin(&origin); let mut active = BrowserContext::new("BID-session-clear-active".into()); + active.set_active_target_id("TID-session-clear-active"); active.attach_active_session("SID-session-clear-active"); { let mut store = active.web_storage_store_for_test().lock(); @@ -2838,6 +2843,7 @@ async fn storage_clear_data_for_origin_targets_command_session_browser_context() } let mut inactive = BrowserContext::new("BID-session-clear-inactive".into()); + inactive.set_active_target_id("TID-session-clear-inactive"); inactive.attach_active_session("SID-session-clear-inactive"); { let mut store = inactive.web_storage_store_for_test().lock(); @@ -2951,7 +2957,7 @@ async fn storage_clear_data_for_origin_clears_indexed_db_backend() { let mut ctx = TestContext::new(); let mut browser_context = BrowserContext::new("BID-origin-indexeddb".into()); browser_context.set_active_target_id("TID-origin-indexeddb"); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); let url = Url::parse("https://idb-clear.example/app").unwrap(); ctx.install_buffered_navigation_fixture_for_session_owner( url.clone(), @@ -2963,7 +2969,7 @@ async fn storage_clear_data_for_origin_clears_indexed_db_backend() { install_storage_test_completion_binding(&mut ctx).await; { - let page = ctx + let mut page = ctx .conn .browser_context .as_mut() @@ -3021,7 +3027,7 @@ async fn storage_clear_data_for_origin_clears_indexed_db_backend() { ctx.expect_result(12_529, json!({}), None); { - let page = ctx + let mut page = ctx .conn .browser_context .as_mut() diff --git a/moli-protocol/src/domains/target.rs b/moli-protocol/src/domains/target.rs index 8de2473786..cce6217519 100644 --- a/moli-protocol/src/domains/target.rs +++ b/moli-protocol/src/domains/target.rs @@ -1,5 +1,7 @@ use serde::Deserialize; +use moli_core::browser_host::BrowserTargetStateSnapshot; + use crate::conn::{ BackgroundProtocolEvent, BrowserContext, CdpConnection, Cmd, TargetHandlerAccessMode, }; @@ -13,29 +15,31 @@ use crate::domains::command_output::CommandOutputPlan; use super::page; -mod activation; mod attachment; -mod auto_attach; mod browser_context; mod browser_context_disposal; -mod closing; -mod creation; +mod browser_context_disposal_owner_adapter; mod events; -mod info; -mod popup; -#[cfg(test)] -mod protocol_neutral_tests; +mod lifecycle; #[cfg(test)] mod tests; mod worker_target; +pub(crate) use browser_context_disposal::{ + BrowserContextDisposalOwnerTaskStep, CompletedBrowserContextDisposalOwnerTask, + PendingBrowserContextDisposalOwnerTask, complete_browser_context_disposal_owner_task, + start_browser_context_disposal_owner_task, +}; +pub use browser_context_disposal_owner_adapter::{ + CompletedDevToolsBrowserOwnerContextDisposalCommand, + DevToolsBrowserOwnerContextDisposalCommandTaskStep, + PendingDevToolsBrowserOwnerContextDisposalCommand, +}; + pub(in crate::domains) use browser_context::devtools_client_window_info_for_target; -pub(crate) use popup::{ +pub(crate) use lifecycle::{ PopupTargetCreation, PopupTargetOpenerIdentity, complete_popup_target_activation_action_async, - complete_popup_target_navigation_owner_action_async, create_popup_target_from_renderer_output_background_events_async, - emit_target_info_changed_for_session_owner_background_event, - start_initial_document_target_url_navigation_if_needed_background_events_async, }; pub(crate) fn popup_activation_creates_new_target( conn: &CdpConnection, @@ -63,7 +67,7 @@ pub(in crate::domains) use worker_target::{ dedicated_worker_target_lifecycle_prepared_outputs_for_event, project_worker_target_output_async, release_failed_dedicated_worker_target_after_debugger_resume, - retire_dedicated_worker_targets_for_replaced_page_async, + retire_dedicated_worker_targets_for_replaced_page, service_worker_target_lifecycle_prepared_outputs_for_event, shared_worker_target_lifecycle_prepared_outputs_for_event, }; @@ -210,7 +214,7 @@ async fn clear_detached_target_owner_fetch_state_background_events_async( if let Some(pending_page_command) = pending_page_command { match pending_page_command.wait().await { Ok(completion) => match conn.loaded_page_mut_for_protocol_access(session_id) { - Ok(page) => { + Ok(mut page) => { if let Err(error) = page.finish_set_fetch_subresource_interception(completion) { tracing::warn!( ?session_id, @@ -287,7 +291,7 @@ impl CdpConnection { let _ = self .detach_runtime_inspector_session_for_session_owner_async(None) .await; - auto_attach::release_attached_sessions_for_root_frontend_async( + lifecycle::release_attached_sessions_for_root_frontend_async( self, &mut side_effects, &mut command_context, @@ -307,7 +311,7 @@ impl CdpConnection { enum PendingTargetCommandKind { AttachToTarget { attached_session_id: String, - target_info: DevToolsTargetInfo, + target_snapshot: BrowserTargetStateSnapshot, initial_document: Option>, }, ActivateTarget { @@ -316,11 +320,12 @@ enum PendingTargetCommandKind { SetAutoAttach { auto_attach: bool, owner_session_id: Option, + owner_was_enabled: bool, legacy_disable_all: bool, }, CreateTarget { response_plan: CommandOutputPlan, - creation_commit: creation::TargetCreationCommit, + protocol_events: lifecycle::CreatedTargetProtocolEvents, initial_document_route: Option, initial_document: Option>, }, @@ -332,18 +337,19 @@ enum PendingTargetCommandKind { command: DevToolsCloseTargetCommand, }, DisposeBrowserContext { - browser_context_id: String, + pending: crate::conn::PendingBrowserOwnerContextDisposalCommand, }, - SendMessageToTarget { + SendMessageToTargetStart { message: String, target_session_id: Option, }, + SendMessageToTargetContinuation(attachment::PendingSendMessageToTargetCommand), } enum CompletedTargetCommandKind { AttachToTarget { attached_session_id: String, - target_info: DevToolsTargetInfo, + target_snapshot: BrowserTargetStateSnapshot, initial_document: Option< Result< Box, @@ -357,11 +363,12 @@ enum CompletedTargetCommandKind { SetAutoAttach { auto_attach: bool, owner_session_id: Option, + owner_was_enabled: bool, legacy_disable_all: bool, }, CreateTarget { response_plan: CommandOutputPlan, - creation_commit: creation::TargetCreationCommit, + protocol_events: lifecycle::CreatedTargetProtocolEvents, initial_document_route: Option, initial_document: Option< Result< @@ -378,12 +385,13 @@ enum CompletedTargetCommandKind { command: DevToolsCloseTargetCommand, }, DisposeBrowserContext { - browser_context_id: String, + completion: Result, }, - SendMessageToTarget { + SendMessageToTargetStart { message: String, target_session_id: Option, }, + SendMessageToTargetContinuation(attachment::CompletedSendMessageToTargetCommand), } impl PendingTargetCommandDispatch { @@ -391,11 +399,11 @@ impl PendingTargetCommandDispatch { let kind = match *self.kind { PendingTargetCommandKind::AttachToTarget { attached_session_id, - target_info, + target_snapshot, initial_document, } => CompletedTargetCommandKind::AttachToTarget { attached_session_id, - target_info, + target_snapshot, initial_document: match initial_document { Some(pending) => Some(pending.wait().await.map(Box::new)), None => None, @@ -407,20 +415,22 @@ impl PendingTargetCommandDispatch { PendingTargetCommandKind::SetAutoAttach { auto_attach, owner_session_id, + owner_was_enabled, legacy_disable_all, } => CompletedTargetCommandKind::SetAutoAttach { auto_attach, owner_session_id, + owner_was_enabled, legacy_disable_all, }, PendingTargetCommandKind::CreateTarget { response_plan, - creation_commit, + protocol_events, initial_document_route, initial_document, } => CompletedTargetCommandKind::CreateTarget { response_plan, - creation_commit, + protocol_events, initial_document_route, initial_document: match initial_document { Some(pending) => Some(pending.wait().await.map(Box::new)), @@ -437,16 +447,23 @@ impl PendingTargetCommandDispatch { PendingTargetCommandKind::CloseTarget { command } => { CompletedTargetCommandKind::CloseTarget { command } } - PendingTargetCommandKind::DisposeBrowserContext { browser_context_id } => { - CompletedTargetCommandKind::DisposeBrowserContext { browser_context_id } + PendingTargetCommandKind::DisposeBrowserContext { pending } => { + CompletedTargetCommandKind::DisposeBrowserContext { + completion: pending.wait().await, + } } - PendingTargetCommandKind::SendMessageToTarget { + PendingTargetCommandKind::SendMessageToTargetStart { message, target_session_id, - } => CompletedTargetCommandKind::SendMessageToTarget { + } => CompletedTargetCommandKind::SendMessageToTargetStart { message, target_session_id, }, + PendingTargetCommandKind::SendMessageToTargetContinuation(pending) => { + CompletedTargetCommandKind::SendMessageToTargetContinuation( + Box::pin(pending.wait()).await, + ) + } }; CompletedTargetCommandDispatch { command_id: self.command_id, @@ -469,6 +486,7 @@ impl CompletedTargetCommandDispatch { pub(crate) fn try_start_target_command_dispatch( conn: &mut CdpConnection, cmd: &Cmd<'_>, + command_context: &crate::conn::CommandDispatchContext, ) -> Option { let action = cmd.parse_action::(); if let Some(action) = action @@ -486,7 +504,7 @@ pub(crate) fn try_start_target_command_dispatch( Some(TargetAction::CreateBrowserContext) => Some(TargetCommandTaskStep::Complete( browser_context::create_browser_context(conn, cmd), )), - Some(TargetAction::CreateTarget) => Some(creation::start_create_target_command(conn, cmd)), + Some(TargetAction::CreateTarget) => Some(lifecycle::start_create_target_command(conn, cmd)), Some(TargetAction::AttachToTarget) => { Some(attachment::start_attach_to_target_command(conn, cmd)) } @@ -494,27 +512,27 @@ pub(crate) fn try_start_target_command_dispatch( attachment::attach_to_browser_target_command(conn, cmd), )), Some(TargetAction::GetTargetInfo) => Some(TargetCommandTaskStep::Complete( - info::get_target_info(conn, cmd), + lifecycle::get_target_info(conn, cmd), )), Some(TargetAction::SetDiscoverTargets) => Some(TargetCommandTaskStep::Complete( set_discover_targets(conn, cmd), )), Some(TargetAction::ActivateTarget) => { - Some(activation::start_activate_target_command(conn, cmd)) + Some(lifecycle::start_activate_target_command(conn, cmd)) } Some(TargetAction::SetAutoAttach) => { - Some(auto_attach::start_set_auto_attach_command(conn, cmd)) + Some(lifecycle::start_set_auto_attach_command(conn, cmd)) } Some(TargetAction::AutoAttachRelated) => Some(TargetCommandTaskStep::Complete( - auto_attach::auto_attach_related(conn, cmd), + lifecycle::auto_attach_related(conn, cmd), )), Some(TargetAction::DetachFromTarget) => { Some(attachment::start_detach_from_target_command(cmd)) } - Some(TargetAction::CloseTarget) => Some(closing::start_close_target_command(conn, cmd)), - Some(TargetAction::DisposeBrowserContext) => { - Some(browser_context::start_dispose_browser_context_command(cmd)) - } + Some(TargetAction::CloseTarget) => Some(lifecycle::start_close_target_command(conn, cmd)), + Some(TargetAction::DisposeBrowserContext) => Some( + browser_context::start_dispose_browser_context_command(conn, cmd, command_context), + ), Some(TargetAction::SendMessageToTarget) => { Some(attachment::start_send_message_to_target_command(cmd)) } @@ -529,14 +547,14 @@ fn start_devtools_target_command( command: DevToolsCommand, ) -> TargetCommandTaskStep { match command { - DevToolsCommand::CreateTarget(command) => creation::start_devtools_create_target_command( + DevToolsCommand::CreateTarget(command) => lifecycle::start_devtools_create_target_command( conn, command_id, command_session_id, command, ), DevToolsCommand::CloseTarget(command) => { - closing::start_devtools_close_target_command(command_id, command_session_id, command) + lifecycle::start_devtools_close_target_command(command_id, command_session_id, command) } DevToolsCommand::ActivateTarget(command) => { pending_activate_target_command(command_id, command_session_id, command) @@ -578,7 +596,7 @@ fn start_devtools_target_command( )), ), DevToolsCommand::GetTargetInfo(command) => TargetCommandTaskStep::Complete( - info::start_devtools_get_target_info_command(conn, command), + lifecycle::start_devtools_get_target_info_command(conn, command), ), _ => target_command_error(-32000, "UnsupportedDevToolsCommand"), } @@ -593,7 +611,7 @@ pub(crate) fn execute_immediate_devtools_target_command_with_protocol_events( ) { match command { DevToolsCommand::CreateTarget(command) => { - let result = creation::execute_devtools_create_target_command(conn, command) + let result = lifecycle::execute_devtools_create_target_command(conn, command) .map(|execution| DevToolsCommandResult::CreateTarget(execution.result)); (result, Vec::new()) } @@ -624,7 +642,7 @@ pub(crate) fn execute_immediate_devtools_target_command_with_protocol_events( Vec::new(), ), DevToolsCommand::GetTargetInfo(command) => ( - info::execute_devtools_get_target_info_command(conn, command) + lifecycle::execute_devtools_get_target_info_command(conn, command) .map(DevToolsCommandResult::GetTargetInfo), Vec::new(), ), @@ -646,27 +664,28 @@ pub(crate) async fn execute_devtools_create_target_command_async_with_protocol_e Vec, Option, ) { - let execution = match creation::execute_devtools_create_target_command(conn, command) { + let execution = match lifecycle::execute_devtools_create_target_command(conn, command) { Ok(execution) => execution, Err(error) => return (Err(error), Vec::new(), None), }; let result = execution.result; - let creation_commit = execution.commit; let mut protocol_events = Vec::new(); let (initial_document_events, renderer_output_predecessor) = conn .ensure_created_target_initial_document_page(&result.target_id) .await; protocol_events.extend(initial_document_events); - if let Some(activation) = creation_commit.activation() { + if let Some(activation) = execution.protocol_events.activation() { protocol_events.extend( conn.complete_staged_target_activation_async(activation) .await .into_protocol_events(), ); } - if let Err(error) = - creation::emit_target_creation_protocol_events(conn, creation_commit, &mut protocol_events) - { + if let Err(error) = lifecycle::emit_created_target_protocol_events( + conn, + execution.protocol_events, + &mut protocol_events, + ) { return (Err(error), Vec::new(), renderer_output_predecessor); } ( @@ -687,7 +706,7 @@ pub(crate) async fn execute_devtools_target_command_async_with_protocol_events( DevToolsCommand::CloseTarget(command) => { let mut command_context = crate::conn::CommandDispatchContext::default(); let mut side_effects = events::TargetProtocolSideEffects::default(); - let result = closing::execute_devtools_close_target_command_async( + let result = lifecycle::execute_devtools_close_target_command_async( conn, command, &mut side_effects, @@ -700,7 +719,7 @@ pub(crate) async fn execute_devtools_target_command_async_with_protocol_events( (result, protocol_events) } DevToolsCommand::ActivateTarget(command) => { - match activation::execute_devtools_activate_target_command_async(conn, command).await { + match lifecycle::execute_devtools_activate_target_command_async(conn, command).await { Ok(events) => (Ok(DevToolsCommandResult::Empty), events), Err(error) => (Err(error), Vec::new()), } @@ -734,26 +753,28 @@ pub(crate) async fn execute_devtools_target_command_async_with_protocol_events( } } -async fn target_creation_response_plan_after_initial_document( +async fn created_target_response_plan_after_initial_document( conn: &mut CdpConnection, response_plan: CommandOutputPlan, - creation_commit: creation::TargetCreationCommit, - activation_events: Vec, + protocol_events: lifecycle::CreatedTargetProtocolEvents, ) -> CommandOutputPlan { - let target_id = creation_commit.page_target_id().to_owned(); + let target_id = protocol_events.target_id().to_owned(); let mut plan = CommandOutputPlan::default(); - let mut events = activation_events; + let mut events = if let Some(activation) = protocol_events.activation() { + conn.complete_staged_target_activation_async(activation) + .await + .into_protocol_events() + } else { + Vec::new() + }; if let Err(error) = - creation::emit_target_creation_protocol_events(conn, creation_commit, &mut events) + lifecycle::emit_created_target_protocol_events(conn, protocol_events, &mut events) { return CommandOutputPlan::from_devtools_error(error); } - popup::start_target_url_navigation_if_allowed_background_events_async( - conn, - &mut events, - &target_id, - ) - .await; + if let Err(error) = lifecycle::publish_target_url_navigation_if_allowed(conn, &target_id) { + return CommandOutputPlan::error(-32000, error.to_string()); + } for event in events { plan.push_background_event(event); } @@ -769,7 +790,7 @@ pub(crate) async fn complete_pending_target_command( match completed.kind { CompletedTargetCommandKind::AttachToTarget { attached_session_id, - target_info, + target_snapshot, initial_document, } => { return TargetCommandTaskStep::Complete( @@ -777,7 +798,7 @@ pub(crate) async fn complete_pending_target_command( conn, completed.session_id.as_deref(), attached_session_id, - target_info, + target_snapshot, initial_document, ) .await, @@ -785,19 +806,21 @@ pub(crate) async fn complete_pending_target_command( } CompletedTargetCommandKind::ActivateTarget { command } => { return TargetCommandTaskStep::Complete( - activation::complete_activate_target_command_async(conn, command).await, + lifecycle::complete_activate_target_command_async(conn, command).await, ); } CompletedTargetCommandKind::SetAutoAttach { auto_attach, owner_session_id, + owner_was_enabled, legacy_disable_all, } => { return TargetCommandTaskStep::Complete( - auto_attach::complete_set_auto_attach_command_async( + lifecycle::complete_set_auto_attach_command_async( conn, auto_attach, owner_session_id.as_deref(), + owner_was_enabled, legacy_disable_all, command_context, ) @@ -806,17 +829,10 @@ pub(crate) async fn complete_pending_target_command( } CompletedTargetCommandKind::CreateTarget { response_plan, - creation_commit, + protocol_events, initial_document_route, initial_document, } => { - let activation_events = if let Some(activation) = creation_commit.activation() { - conn.complete_staged_target_activation_async(activation) - .await - .into_protocol_events() - } else { - Vec::new() - }; match initial_document { Some(Ok(completed_initial_document)) => { let completed_initial_document = *completed_initial_document; @@ -856,11 +872,10 @@ pub(crate) async fn complete_pending_target_command( None => {} } TargetCommandTaskStep::Complete( - target_creation_response_plan_after_initial_document( + created_target_response_plan_after_initial_document( conn, response_plan, - creation_commit, - activation_events, + protocol_events, ) .await, ) @@ -882,32 +897,43 @@ pub(crate) async fn complete_pending_target_command( } CompletedTargetCommandKind::CloseTarget { command } => { return TargetCommandTaskStep::Complete( - closing::complete_close_target_command_async(conn, command, command_context).await, + lifecycle::complete_close_target_command_async(conn, command, command_context) + .await, ); } - CompletedTargetCommandKind::DisposeBrowserContext { browser_context_id } => { - return TargetCommandTaskStep::Complete( - browser_context::complete_dispose_browser_context_command_async( - conn, - browser_context_id, - command_context, - ) - .await, - ); + CompletedTargetCommandKind::DisposeBrowserContext { completion } => { + TargetCommandTaskStep::Complete(match completion { + Ok(completed) => { + let (plan, detached) = completed.into_parts(); + command_context.absorb_detached_participant_projection(detached); + plan + } + Err(error) => CommandOutputPlan::error_without_session(-32000, error), + }) } - CompletedTargetCommandKind::SendMessageToTarget { + CompletedTargetCommandKind::SendMessageToTargetStart { message, target_session_id, } => { - return TargetCommandTaskStep::Complete( - attachment::complete_send_message_to_target_command_async( - conn, - command_context, - message, - target_session_id, - ) - .await, - ); + return attachment::complete_send_message_to_target_command_start_async( + conn, + completed.command_id, + completed.session_id.as_deref(), + command_context, + message, + target_session_id, + ) + .await; + } + CompletedTargetCommandKind::SendMessageToTargetContinuation(continuation) => { + return attachment::complete_send_message_to_target_command_continuation_async( + conn, + completed.command_id, + completed.session_id.as_deref(), + command_context, + continuation, + ) + .await; } } } @@ -929,6 +955,7 @@ fn pending_set_auto_attach_command( session_id: Option<&str>, auto_attach: bool, owner_session_id: Option<&str>, + owner_was_enabled: bool, legacy_disable_all: bool, ) -> TargetCommandTaskStep { TargetCommandTaskStep::Pending(PendingTargetCommandDispatch { @@ -937,6 +964,7 @@ fn pending_set_auto_attach_command( kind: Box::new(PendingTargetCommandKind::SetAutoAttach { auto_attach, owner_session_id: owner_session_id.map(str::to_owned), + owner_was_enabled, legacy_disable_all, }), }) @@ -973,12 +1001,12 @@ fn pending_close_target_command( fn pending_dispose_browser_context_command( command_id: Option, session_id: Option<&str>, - browser_context_id: String, + pending: crate::conn::PendingBrowserOwnerContextDisposalCommand, ) -> TargetCommandTaskStep { TargetCommandTaskStep::Pending(PendingTargetCommandDispatch { command_id, session_id: session_id.map(str::to_owned), - kind: Box::new(PendingTargetCommandKind::DisposeBrowserContext { browser_context_id }), + kind: Box::new(PendingTargetCommandKind::DisposeBrowserContext { pending }), }) } @@ -991,13 +1019,27 @@ fn pending_send_message_to_target_command( TargetCommandTaskStep::Pending(PendingTargetCommandDispatch { command_id, session_id: session_id.map(str::to_owned), - kind: Box::new(PendingTargetCommandKind::SendMessageToTarget { + kind: Box::new(PendingTargetCommandKind::SendMessageToTargetStart { message, target_session_id, }), }) } +fn pending_send_message_to_target_continuation( + command_id: Option, + session_id: Option<&str>, + pending: attachment::PendingSendMessageToTargetCommand, +) -> TargetCommandTaskStep { + TargetCommandTaskStep::Pending(PendingTargetCommandDispatch { + command_id, + session_id: session_id.map(str::to_owned), + kind: Box::new(PendingTargetCommandKind::SendMessageToTargetContinuation( + pending, + )), + }) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SetDiscoverTargetsParams { @@ -1109,7 +1151,7 @@ mod devtools_runtime_entry_tests { AutomationEvent, DevToolsActivateTargetCommand, DevToolsCloseTargetCommand, DevToolsCommand, DevToolsCommandContext, DevToolsCreateTargetCommand, DevToolsGetClientWindowsCommand, DevToolsGetTargetInfoCommand, DevToolsGetTargetsCommand, - DevToolsProtocol, DevToolsTargetId, DevToolsTargetKind, + DevToolsProtocol, DevToolsTargetId, }; use serde_json::{Value, json}; @@ -1215,22 +1257,26 @@ mod devtools_runtime_entry_tests { #[tokio::test] async fn attach_to_target_completion_plan_preserves_typed_attached_sidecar() { let mut conn = CdpConnection::new(); + let mut browser_context = BrowserContext::new("BID-child".to_owned()); + browser_context.set_active_target_id("TID-child"); + conn.insert_browser_context(browser_context); + assert!( + conn.browser_context + .as_mut() + .expect("browser context") + .assign_session_to_target("TID-child", "SID-child".to_owned()) + ); + let target_snapshot = conn + .capture_browser_top_level_target_snapshot() + .expect("exact Target should snapshot") + .target("TID-child") + .expect("snapshotted Target") + .clone(); let plan = attachment::complete_attach_to_target_command_async( &mut conn, Some("SID-parent"), "SID-child".to_owned(), - DevToolsTargetInfo { - target_id: Some(DevToolsTargetId::from("TID-child")), - kind: DevToolsTargetKind::Page, - title: String::new(), - url: "about:blank".to_owned(), - attached: true, - opener_id: None, - opener_frame_id: None, - can_access_opener: false, - browser_context_id: None, - moli_popup_id: None, - }, + target_snapshot, None, ) .await; @@ -1261,12 +1307,96 @@ mod devtools_runtime_entry_tests { } #[tokio::test] - async fn devtools_target_legacy_close_drains_runtime_ready_events_without_serializing_them() { + async fn attach_completion_stale_drops_reused_public_target_id() { + let mut conn = CdpConnection::new(); + let mut browser_context = BrowserContext::new("BID-stale-attach".to_owned()); + browser_context.set_active_target_id("TID-active"); + conn.insert_browser_context(browser_context); + let predecessor = crate::conn::BackgroundTarget::with_url( + "TID-reused".to_owned(), + None, + "about:blank#predecessor".to_owned(), + ); + conn.register_background_target_projection( + "BID-stale-attach", + "TID-reused", + move |browser_context, target_handle, page_residence, session_storage_access| { + let mut target = predecessor; + target.replace_target_handle(target_handle); + target.replace_page_residence_handle(page_residence); + target.bind_session_storage_access(session_storage_access); + browser_context.background_targets.push(target); + }, + ) + .expect("predecessor should register"); + let stale = conn + .capture_browser_top_level_target_snapshot() + .expect("predecessor should snapshot") + .target("TID-reused") + .expect("predecessor snapshot") + .clone(); + assert!( + conn.browser_context + .as_mut() + .expect("browser context") + .assign_session_to_target("TID-reused", "SID-stale".to_owned()) + ); + + conn.rollback_staged_background_target_projection("BID-stale-attach", "TID-reused") + .expect("predecessor should retire"); + let successor = crate::conn::BackgroundTarget::with_url( + "TID-reused".to_owned(), + None, + "about:blank#successor".to_owned(), + ); + conn.register_background_target_projection( + "BID-stale-attach", + "TID-reused", + move |browser_context, target_handle, page_residence, session_storage_access| { + let mut target = successor; + target.replace_target_handle(target_handle); + target.replace_page_residence_handle(page_residence); + target.bind_session_storage_access(session_storage_access); + browser_context.background_targets.push(target); + }, + ) + .expect("successor should reuse the public id"); + + let plan = attachment::complete_attach_to_target_command_async( + &mut conn, + None, + "SID-stale".to_owned(), + stale, + None, + ) + .await; + let mut messages = Vec::new(); + plan.emit_into(&mut messages, Some(501), None); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["id"], json!(501)); + assert_eq!(messages[0]["error"]["code"], json!(-31998)); + assert!( + !conn + .browser_context + .as_ref() + .expect("browser context") + .devtools_target_info("TID-reused") + .expect("successor target info") + .attached, + "stale attach completion must not bind or announce the successor" + ); + } + + #[tokio::test] + async fn devtools_target_frontend_close_keeps_runtime_ready_events_typed() { let mut conn = CdpConnection::new(); + let (_browser_host, browser_host_handle) = + moli_core::browser_host::BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(browser_host_handle); let mut browser_context = BrowserContext::new("BID-runtime-ready-close".to_owned()); browser_context.set_active_target_id("TID-runtime-ready-close"); browser_context.attach_active_session("SID-runtime-ready-close"); - conn.browser_context = Some(browser_context); + conn.insert_browser_context(browser_context); let page = conn .load_page_via_runtime_async("data:text/html,

runtime ready close

") .await diff --git a/moli-protocol/src/domains/target/activation.rs b/moli-protocol/src/domains/target/activation.rs deleted file mode 100644 index 194d95ffc4..0000000000 --- a/moli-protocol/src/domains/target/activation.rs +++ /dev/null @@ -1,167 +0,0 @@ -use serde::Deserialize; - -use crate::devtools_runtime::DevToolsTargetId; - -use super::*; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct ActivateTargetParams { - pub(super) 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), - ) -} - -pub(super) 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(events) => { - let mut plan = CommandOutputPlan::default(); - plan.extend_background_events(events); - plan.push_success(); - plan - } - 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 = conn - .primary_page_target_id_for_tab_target_id(command.target_id.as_str()) - .map(str::to_owned) - .unwrap_or_else(|| 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(Vec::new()); - } - if bc.has_dedicated_worker_target(&target_id) { - restore_previously_active_browser_context( - conn, - previously_active_browser_context_id.as_deref(), - ); - return Ok(Vec::new()); - } - if bc.has_service_worker_target(&target_id) { - restore_previously_active_browser_context( - conn, - previously_active_browser_context_id.as_deref(), - ); - return Ok(Vec::new()); - } - 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", - )); - } - let protocol_events = if !matches!( - bc.active_target_identity(), - Some((ref active_target_id, _)) if active_target_id == &target_id - ) && bc.background_target(&target_id).is_some() - { - match conn - .promote_background_target_to_active_for_connection_async(&target_id) - .await - { - Ok(Some(activation)) => activation.into_protocol_events(), - Ok(None) => { - 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", - )); - } else { - Vec::new() - }; - - restore_previously_active_browser_context( - conn, - previously_active_browser_context_id.as_deref(), - ); - Ok(protocol_events) -} diff --git a/moli-protocol/src/domains/target/attachment.rs b/moli-protocol/src/domains/target/attachment.rs index 38cd8b36b0..8d6372924f 100644 --- a/moli-protocol/src/domains/target/attachment.rs +++ b/moli-protocol/src/domains/target/attachment.rs @@ -2,7 +2,6 @@ use serde::Deserialize; use serde_json::json; use crate::conn::{BackgroundProtocolEvent, PreparedTargetAttach}; -use crate::devtools_runtime::DevToolsTargetInfo; use super::*; @@ -136,19 +135,52 @@ fn do_attach(conn: &mut CdpConnection, cmd: &Cmd<'_>, target_id: &str) -> Target return TargetCommandTaskStep::Complete(attach_browser_target(conn, cmd.session_id)); } let attach_from_browser_session = conn.is_browser_session_id(cmd.session_id); - if conn - .primary_page_target_id_for_tab_target_id(target_id) - .is_some() + let browser_snapshot = match conn.capture_browser_top_level_target_snapshot() { + Ok(snapshot) => snapshot, + Err(error) => { + return TargetCommandTaskStep::Complete(CommandOutputPlan::from_devtools_error(error)); + } + }; + if let Some(page_target_id) = conn + .page_target_id_for_tab_target_id(target_id) + .map(str::to_owned) { + let Some(page_snapshot) = browser_snapshot.target(&page_target_id) else { + return target_command_error_without_session(-31998, "UnknownTargetId"); + }; + if let Err(error) = conn.project_top_level_target_snapshot(page_snapshot) { + return TargetCommandTaskStep::Complete(CommandOutputPlan::from_devtools_error(error)); + } return do_attach_tab_target(conn, cmd.session_id, target_id, attach_from_browser_session); } + if browser_snapshot.contexts().is_empty() { + return target_command_error_without_session(-31998, "BrowserContextNotLoaded"); + } + let any_target = browser_snapshot + .contexts() + .iter() + .any(|context| !context.targets().is_empty()) + || conn.browser_contexts().any(|context| { + context.has_any_shared_worker_targets() + || context.has_any_dedicated_worker_targets() + || context.has_any_service_worker_targets() + }); + if !any_target { + return target_command_error_without_session(-31998, "TargetNotLoaded"); + } + let top_level_target_snapshot = browser_snapshot.target(target_id).cloned(); let restore_browser_context_id = attach_from_browser_session.then(|| previously_active_browser_context_id(conn)); - if let Err(message) = select_browser_context_for_target(conn, target_id) { + let selected = if let Some(snapshot) = top_level_target_snapshot.as_ref() { + conn.activate_browser_context_by_id(snapshot.browser_context_id()) + } else { + select_browser_context_for_target(conn, target_id).is_ok() + }; + if !selected { if let Some(restore_browser_context_id) = restore_browser_context_id.as_ref() { restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); } - return target_command_error_without_session(-31998, message); + return target_command_error_without_session(-31998, "UnknownTargetId"); } let bc = match conn.browser_context.as_ref() { Some(bc) => bc, @@ -171,6 +203,18 @@ fn do_attach(conn: &mut CdpConnection, cmd: &Cmd<'_>, target_id: &str) -> Target if bc.has_service_worker_target(target_id) { return do_attach_service_worker_target(conn, cmd.session_id, target_id); } + let Some(target_snapshot) = top_level_target_snapshot else { + if let Some(restore_browser_context_id) = restore_browser_context_id.as_ref() { + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + } + return target_command_error_without_session(-31998, "UnknownTargetId"); + }; + if let Err(error) = conn.project_top_level_target_snapshot(&target_snapshot) { + if let Some(restore_browser_context_id) = restore_browser_context_id.as_ref() { + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + } + return TargetCommandTaskStep::Complete(CommandOutputPlan::from_devtools_error(error)); + } if !bc.has_active_target() && bc.background_targets.is_empty() { if let Some(restore_browser_context_id) = restore_browser_context_id.as_ref() { restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); @@ -186,8 +230,7 @@ fn do_attach(conn: &mut CdpConnection, cmd: &Cmd<'_>, target_id: &str) -> Target .as_ref() .is_some_and(|(_, session_id)| session_id.is_some()) } else if bc.background_target(target_id).is_some() { - bc.background_target(target_id) - .is_some_and(|target| target.has_session()) + bc.primary_session_id_for_target(target_id).is_some() } else { if let Some(restore_browser_context_id) = restore_browser_context_id.as_ref() { restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); @@ -222,10 +265,6 @@ fn do_attach(conn: &mut CdpConnection, cmd: &Cmd<'_>, target_id: &str) -> Target return target_command_error_without_session(-32000, message); } }; - let bc = conn.browser_context.as_ref().unwrap(); - let target_info = bc - .devtools_target_info(target_id) - .expect("attached target must be addressable"); if let Some(restore_browser_context_id) = restore_browser_context_id.as_ref() { restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); } @@ -234,7 +273,7 @@ fn do_attach(conn: &mut CdpConnection, cmd: &Cmd<'_>, target_id: &str) -> Target session_id: cmd.session_id.map(str::to_owned), kind: Box::new(PendingTargetCommandKind::AttachToTarget { attached_session_id: session_id, - target_info, + target_snapshot, initial_document, }), }) @@ -267,7 +306,7 @@ pub(super) async fn complete_attach_to_target_command_async( conn: &mut CdpConnection, command_session_id: Option<&str>, attached_session_id: String, - target_info: DevToolsTargetInfo, + target_snapshot: moli_core::browser_host::BrowserTargetStateSnapshot, initial_document: Option< Result< Box, @@ -280,6 +319,11 @@ pub(super) async fn complete_attach_to_target_command_async( command_session_id.map(str::to_owned), false, ); + if !conn.browser_target_state_snapshot_is_current(&target_snapshot) { + conn.rollback_prepared_attach_session_without_event_async(&prepared_session) + .await; + return CommandOutputPlan::error_without_session(-31998, "UnknownTargetId"); + } match initial_document { Some(Ok(completed_initial_document)) => { let completed_initial_document = *completed_initial_document; @@ -311,20 +355,25 @@ pub(super) async fn complete_attach_to_target_command_async( "target attach renderer binding state apply failed" ); } - if let Some(message) = super::transient_no_page_devtools_target_info_error(conn, &target_info) { + if !conn.browser_target_state_snapshot_is_current(&target_snapshot) { conn.rollback_prepared_attach_session_without_event_async(&prepared_session) .await; - return CommandOutputPlan::error_without_session(-32000, message); + return CommandOutputPlan::error_without_session(-31998, "UnknownTargetId"); } - let Some(attached_target_id) = target_info - .target_id - .as_ref() - .map(|target_id| target_id.as_str().to_owned()) - else { + let target_info = match conn.project_top_level_target_snapshot(&target_snapshot) { + Ok(target_info) => target_info, + Err(error) => { + conn.rollback_prepared_attach_session_without_event_async(&prepared_session) + .await; + return CommandOutputPlan::from_devtools_error(error); + } + }; + if let Some(message) = super::transient_no_page_devtools_target_info_error(conn, &target_info) { conn.rollback_prepared_attach_session_without_event_async(&prepared_session) .await; - return CommandOutputPlan::error_without_session(-32000, "MissingTargetId"); - }; + return CommandOutputPlan::error_without_session(-32000, message); + } + let attached_target_id = target_snapshot.target_id().to_owned(); let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( &attached_target_id, target_info, @@ -406,58 +455,188 @@ pub(super) fn start_send_message_to_target_command(cmd: &Cmd<'_>) -> TargetComma ) } -pub(super) async fn complete_send_message_to_target_command_async( +pub(super) struct PendingSendMessageToTargetCommand { + pending: Box, + nested_command_context: crate::conn::CommandDispatchContext, + nested_output_session_id: Option, + target_session_id: Option, + restore_browser_context_id: Option, +} + +pub(super) struct CompletedSendMessageToTargetCommand { + completed: Box, + nested_command_context: crate::conn::CommandDispatchContext, + nested_output_session_id: Option, + target_session_id: Option, + restore_browser_context_id: Option, +} + +impl PendingSendMessageToTargetCommand { + pub(super) async fn wait(self) -> CompletedSendMessageToTargetCommand { + CompletedSendMessageToTargetCommand { + completed: Box::new(self.pending.wait().await), + nested_command_context: self.nested_command_context, + nested_output_session_id: self.nested_output_session_id, + target_session_id: self.target_session_id, + restore_browser_context_id: self.restore_browser_context_id, + } + } +} + +pub(super) async fn complete_send_message_to_target_command_start_async( conn: &mut CdpConnection, + outer_command_id: Option, + outer_session_id: Option<&str>, command_context: &mut crate::conn::CommandDispatchContext, message: String, target_session_id: Option, -) -> CommandOutputPlan { - let previously_active_browser_context_id = previously_active_browser_context_id(conn); +) -> TargetCommandTaskStep { + let restore_browser_context_id = previously_active_browser_context_id(conn); let mut out = TargetCommandOutput::default(); - send_message_to_target_inner_async( + if !select_send_message_to_target_owner(conn, &mut out, target_session_id.as_deref()) { + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + return TargetCommandTaskStep::Complete(out.into_plan()); + } + + let mut nested_command_context = crate::conn::CommandDispatchContext::default(); + nested_command_context.set_terminal_response_delivery_override( + moli_page_types::RendererInspectorResponseDelivery::CommandReply, + ); + let (nested_output_session_id, step) = + conn.start_raw_command_dispatch_with_context(message, &mut nested_command_context); + advance_send_message_to_target_command( conn, + outer_command_id, + outer_session_id, command_context, - &mut out, - SendMessageParams { - message, - session_id: target_session_id, - }, + nested_command_context, + nested_output_session_id, + target_session_id, + restore_browser_context_id, + step, ) - .await; - restore_previously_active_browser_context( + .await +} + +pub(super) async fn complete_send_message_to_target_command_continuation_async( + conn: &mut CdpConnection, + outer_command_id: Option, + outer_session_id: Option<&str>, + command_context: &mut crate::conn::CommandDispatchContext, + continuation: CompletedSendMessageToTargetCommand, +) -> TargetCommandTaskStep { + let CompletedSendMessageToTargetCommand { + completed, + mut nested_command_context, + nested_output_session_id, + target_session_id, + restore_browser_context_id, + } = continuation; + let step = + Box::pin(conn.complete_pending_command_dispatch_with_context( + *completed, + &mut nested_command_context, + )) + .await; + advance_send_message_to_target_command( conn, - previously_active_browser_context_id.as_deref(), - ); - out.into_plan() + outer_command_id, + outer_session_id, + command_context, + nested_command_context, + nested_output_session_id, + target_session_id, + restore_browser_context_id, + step, + ) + .await } -async fn send_message_to_target_inner_async( +#[allow(clippy::too_many_arguments)] +async fn advance_send_message_to_target_command( conn: &mut CdpConnection, + outer_command_id: Option, + outer_session_id: Option<&str>, command_context: &mut crate::conn::CommandDispatchContext, + nested_command_context: crate::conn::CommandDispatchContext, + nested_output_session_id: Option, + target_session_id: Option, + restore_browser_context_id: Option, + step: crate::conn::CdpCommandTaskStep, +) -> TargetCommandTaskStep { + let outcome = match step { + crate::conn::CdpCommandTaskStep::Pending(mut pending) => { + // Publish nested scheduler work before yielding the outer Target + // command. Most importantly, Browser Host may now select a nested + // Page.navigate while this exact participant waits. + conn.extend_scheduler_events(pending.take_scheduler_events()); + return super::pending_send_message_to_target_continuation( + outer_command_id, + outer_session_id, + PendingSendMessageToTargetCommand { + pending, + nested_command_context, + nested_output_session_id, + target_session_id, + restore_browser_context_id, + }, + ); + } + crate::conn::CdpCommandTaskStep::Complete(outcome) => outcome, + }; + let nested_outcome = conn + .settle_direct_command_turn_outcome( + nested_output_session_id.as_deref(), + nested_command_context, + Vec::new(), + outcome, + ) + .await; + let mut out = TargetCommandOutput::default(); + append_send_message_to_target_outcome( + conn, + command_context, + &mut out, + target_session_id.as_deref(), + nested_outcome, + ); + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + TargetCommandTaskStep::Complete(out.into_plan()) +} + +fn select_send_message_to_target_owner( + conn: &mut CdpConnection, out: &mut TargetCommandOutput, - params: SendMessageParams, -) { - if let Some(session_id) = params.session_id.as_deref() + target_session_id: Option<&str>, +) -> bool { + if let Some(session_id) = target_session_id && !conn.activate_browser_context_for_session(session_id) { out.push_error(-31998, "InvalidSessionId"); - return; + return false; } let Some(bc) = conn.browser_context.as_ref() else { out.push_error(-31998, "BrowserContextNotLoaded"); - return; + return false; }; let Some((_, current_session_id)) = bc.active_target_identity() else { out.push_error(-31998, "TargetNotLoaded"); - return; + return false; }; - if current_session_id.as_deref() != params.session_id.as_deref() { + if current_session_id.as_deref() != target_session_id { out.push_error(-31998, "InvalidSessionId"); - return; + return false; } + true +} - let nested_outcome = - Box::pin(conn.process_message_with_command_reply_turn_outcome_async(¶ms.message)).await; +fn append_send_message_to_target_outcome( + conn: &mut CdpConnection, + command_context: &mut crate::conn::CommandDispatchContext, + out: &mut TargetCommandOutput, + target_session_id: Option<&str>, + nested_outcome: crate::conn::CdpRendererOwnerTurnOutcome, +) { let ( nested_events, post_renderer_output_events, @@ -471,7 +650,7 @@ async fn send_message_to_target_inner_async( } conn.extend_scheduler_events(nested_scheduler_events); out.push_success(); - if let Some(session_id) = params.session_id.as_deref() { + if let Some(session_id) = target_session_id { for nested in nested_events { out.background_events_mut().push( BackgroundProtocolEvent::target_received_message_from_target(session_id, nested), @@ -578,7 +757,7 @@ async fn detach_from_target_inner_async( } if let Some(session_id) = params.session_id.as_deref() { - super::auto_attach::detach_attached_sessions_for_owner_async( + super::lifecycle::detach_attached_sessions_for_owner_async( conn, out.target_events_mut(), Some(session_id), @@ -651,7 +830,7 @@ async fn detach_from_target_inner_async( let _ = conn .detach_runtime_inspector_session_for_session_owner_async(Some(session_id)) .await; - super::auto_attach::clear_emulated_media_for_detached_session_best_effort(conn, session_id) + super::lifecycle::clear_emulated_media_for_detached_session_best_effort(conn, session_id) .await; super::clear_detached_target_fetch_state_background_events_async( conn, @@ -784,10 +963,9 @@ async fn detach_from_target_inner_async( } if let Some(session_id) = params.session_id.as_deref() { let background_target_id = conn.browser_context.as_ref().and_then(|bc| { - bc.background_targets - .iter() - .find(|target| target.is_session(session_id)) - .map(|target| target.target_id().to_owned()) + bc.primary_attachment_target_id_for_session(session_id) + .filter(|target_id| !bc.is_active_target(target_id)) + .map(str::to_owned) }); if let Some(target_id) = background_target_id.as_deref() { if let Some(requested_target_id) = params.target_id.as_deref() diff --git a/moli-protocol/src/domains/target/auto_attach.rs b/moli-protocol/src/domains/target/auto_attach.rs deleted file mode 100644 index 0d84c2aac7..0000000000 --- a/moli-protocol/src/domains/target/auto_attach.rs +++ /dev/null @@ -1,1415 +0,0 @@ -use serde::Deserialize; - -use crate::conn::{CdpTargetFilter, CdpTargetFilterEntry, PreparedTargetAttach}; - -use super::*; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct AutoAttachParams { - auto_attach: bool, - #[serde(rename = "waitForDebuggerOnStart")] - wait_for_debugger_on_start: bool, - flatten: Option, - 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_exact_target( - conn: &CdpConnection, - owner_session_id: Option<&str>, - target_id: &str, -) -> bool { - let attached_sessions = conn.attached_sessions_for_target(target_id); - conn.auto_attached_sessions_for_owner(owner_session_id) - .iter() - .any(|session_id| attached_sessions.contains(session_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 cmd - .session_id - .is_some_and(|session_id| conn.is_browser_session_id(Some(session_id))) - && params.flatten != Some(true) - { - return super::target_command_error( - -32602, - "Only flatten protocol is supported with browser level auto-attach", - ); - } - 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") - && super::browser_level_auto_attach_owner_session_allowed(conn, cmd.session_id); - 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, - legacy_disable_all, - ) -} - -pub(super) async fn complete_set_auto_attach_command_async( - conn: &mut CdpConnection, - auto_attach: bool, - owner_session_id: Option<&str>, - 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, - 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>, - 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 - && 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 { - 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") - && super::browser_level_auto_attach_owner_session_allowed(conn, owner_session_id); - // 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() - .filter(|target| { - !owner_already_auto_attached_to_exact_target( - conn, - owner_session_id, - &target.target_id, - ) - }) - .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() - .filter(|target| { - !owner_already_auto_attached_to_exact_target( - conn, - owner_session_id, - &target.target_id, - ) - }) - .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, - ) && !owner_already_auto_attached_to_exact_target( - conn, - owner_session_id, - &target.target_id, - ) - }) - .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(Some(activation)) => { - out.extend_background_events(activation.into_protocol_events()); - } - Ok(None) => {} - 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 - .primary_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" - ); -} diff --git a/moli-protocol/src/domains/target/browser_context.rs b/moli-protocol/src/domains/target/browser_context.rs index 5a9aaa2650..083265c81b 100644 --- a/moli-protocol/src/domains/target/browser_context.rs +++ b/moli-protocol/src/domains/target/browser_context.rs @@ -163,7 +163,7 @@ fn devtools_service_worker_log_target_ids( if browser_context_filter.is_some_and(|filter| browser_context.id != filter) { continue; } - for target_info in browser_context.devtools_target_infos() { + for target_info in browser_context.devtools_worker_target_infos() { if target_info.kind != DevToolsTargetKind::ServiceWorker { continue; } @@ -312,7 +312,7 @@ pub(super) fn execute_devtools_create_browser_context_command( normalize_proxy_bypass_list_for_loader(command.proxy_bypass_list.as_deref()); browser_context.proxy_autoconfig_url = command.proxy_autoconfig_url; browser_context.proxy_socks_version = command.proxy_socks_version; - conn.insert_browser_context(browser_context); + conn.try_insert_browser_context(browser_context)?; Ok(DevToolsCreateBrowserContextResult { browser_context_id: crate::devtools_runtime::DevToolsBrowserContextId::from(id), }) @@ -361,18 +361,17 @@ pub(super) async fn execute_devtools_remove_browser_context_command_async( ); } - let mut protocol_events = Vec::new(); - if should_emit_internal_lifecycle { - protocol_events.extend(target_destroyed_automation_events_for_browser_context( - conn, - &browser_context_id, - )); - } + let prefix_events = if should_emit_internal_lifecycle { + target_destroyed_automation_events_for_browser_context(conn, &browser_context_id) + } else { + Vec::new() + }; let mut side_effects = events::TargetProtocolSideEffects::default(); let mut command_context = crate::conn::CommandDispatchContext::default(); if let Err(error) = super::browser_context_disposal::execute_browser_context_disposal_async( conn, browser_context_id, + prefix_events, &mut side_effects, &mut command_context, ) @@ -380,6 +379,7 @@ pub(super) async fn execute_devtools_remove_browser_context_command_async( { return (Err(error), Vec::new()); } + let mut protocol_events = Vec::new(); protocol_events.extend(side_effects.into_background_events()); protocol_events.extend(command_context.take_protocol_events()); (Ok(DevToolsCommandResult::Empty), protocol_events) @@ -402,39 +402,41 @@ pub(super) fn devtools_browser_target_info() -> DevToolsTargetInfo { } } -fn devtools_target_infos( +pub(in crate::domains) fn devtools_target_infos( conn: &CdpConnection, root: Option<&DevToolsTargetId>, filter: Option<&[DevToolsTargetFilterEntry]>, ) -> Result, DevToolsError> { let mut targets = Vec::new(); - for browser_context in conn.browser_contexts() { - for mut target_info in browser_context.devtools_target_infos() { - if let Some(message) = - super::transient_no_page_devtools_target_info_error(conn, &target_info) - { - return Err(DevToolsError::new(DevToolsErrorKind::Internal, message)); - } - if let Some(target_id) = target_info.target_id.as_ref() { - target_info.moli_popup_id = browser_context.target_popup_id(target_id.as_str()); - } - if let Some(tab_target_info) = conn.tab_target_info_for_page_target_info(&target_info) - && target_info_matches_root(&tab_target_info, root) - && target_filter_allows_info(filter, &tab_target_info) - { - targets.push(tab_target_info); - } - if !target_info_matches_root(&target_info, root) - || !target_filter_allows_info(filter, &target_info) - { - continue; - } - targets.push(target_info); + for target_info in current_devtools_target_infos(conn)? { + if let Some(message) = + super::transient_no_page_devtools_target_info_error(conn, &target_info) + { + return Err(DevToolsError::new(DevToolsErrorKind::Internal, message)); + } + if let Some(tab_target_info) = conn.tab_target_info_for_page_target_info(&target_info) + && target_info_matches_root(&tab_target_info, root) + && target_filter_allows_info(filter, &tab_target_info) + { + targets.push(tab_target_info); + } + if !target_info_matches_root(&target_info, root) + || !target_filter_allows_info(filter, &target_info) + { + continue; } + targets.push(target_info); } Ok(targets) } +pub(in crate::domains) fn current_devtools_target_infos( + conn: &CdpConnection, +) -> Result, DevToolsError> { + let snapshot = conn.capture_browser_top_level_target_snapshot()?; + conn.project_devtools_target_infos_from_browser_snapshot(&snapshot) +} + pub(in crate::domains) fn devtools_target_infos_for_discovery( conn: &CdpConnection, filter: Option<&[DevToolsTargetFilterEntry]>, @@ -486,7 +488,7 @@ pub(in crate::domains) fn target_filter_allows_type( false } -fn target_destroyed_automation_events_for_browser_context( +pub(super) fn target_destroyed_automation_events_for_browser_context( conn: &CdpConnection, browser_context_id: &str, ) -> Vec { @@ -574,7 +576,11 @@ struct DisposeBcParams { browser_context_id: Option, } -pub(super) fn start_dispose_browser_context_command(cmd: &Cmd<'_>) -> TargetCommandTaskStep { +pub(super) fn start_dispose_browser_context_command( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, + command_context: &crate::conn::CommandDispatchContext, +) -> TargetCommandTaskStep { let params: DisposeBcParams = match cmd.get_params() { Ok(Some(p)) => p, _ => { @@ -587,28 +593,26 @@ pub(super) fn start_dispose_browser_context_command(cmd: &Cmd<'_>) -> TargetComm return super::target_command_error(-32602, "InvalidParams"); } }; - pending_dispose_browser_context_command(cmd.id, cmd.session_id, wanted_id) -} - -pub(super) async fn complete_dispose_browser_context_command_async( - conn: &mut CdpConnection, - wanted_id: String, - command_context: &mut crate::conn::CommandDispatchContext, -) -> CommandOutputPlan { - let mut side_effects = events::TargetProtocolSideEffects::default(); - match super::browser_context_disposal::execute_browser_context_disposal_async( - conn, - wanted_id, - &mut side_effects, - command_context, - ) - .await - { - Ok(()) => { - let mut plan = CommandOutputPlan::success(); - plan.extend(side_effects.into_plan()); - plan - } - Err(error) => CommandOutputPlan::from_devtools_error(error), + let Some(browser_context_handle) = conn + .browser_context_by_id(&wanted_id) + .map(|context| context.browser_context_handle().clone()) + else { + return TargetCommandTaskStep::Complete(CommandOutputPlan::from_devtools_error( + DevToolsError::new( + DevToolsErrorKind::Internal, + format!("Failed to find context with id {wanted_id}"), + ), + )); + }; + match conn.publish_browser_owner_context_disposal_command( + browser_context_handle, + Vec::new(), + command_context.detached_participant_context(), + ) { + Ok(pending) => pending_dispose_browser_context_command(cmd.id, cmd.session_id, pending), + Err(error) => TargetCommandTaskStep::Complete(CommandOutputPlan::error_without_session( + -32000, + format!("BrowserHostContextDisposalAdmissionFailed: {error}"), + )), } } diff --git a/moli-protocol/src/domains/target/browser_context_disposal.rs b/moli-protocol/src/domains/target/browser_context_disposal.rs index c4267b33c9..6ba34f23f1 100644 --- a/moli-protocol/src/domains/target/browser_context_disposal.rs +++ b/moli-protocol/src/domains/target/browser_context_disposal.rs @@ -1,7 +1,20 @@ -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; -use crate::conn::{CdpConnection, CommandDispatchContext, PreparedTargetHostClosure}; +use moli_core::{ + browser_host::{ + BrowserContextDisposalReservation, BrowserContextHandle, BrowserPageOwnerKey, + BrowserTargetTerminationRequest, PageResidenceIdentity, + }, + page::{Page, RendererPageLifetimeOwner}, + runtime::RendererBrowserContextRuntimeOwner, +}; + +use crate::conn::{ + BackgroundProtocolEvent, BrowserTargetCloseStart, BrowserTargetTerminationProjection, + CdpConnection, CommandDispatchContext, PendingBrowserTargetClose, PreparedTargetHostClosure, +}; use crate::devtools_runtime::{DevToolsError, DevToolsErrorKind, DevToolsTargetKind}; +use crate::domains::{command_output::CommandOutputPlan, page::FetchCancellationOwnerTaskStep}; use super::{events, worker_target}; @@ -10,285 +23,738 @@ const INSPECTOR_DETACHED_REASON: &str = "Render process gone."; struct PageTargetDisposal { target_id: String, + page_owner: PageResidenceIdentity, + termination: Option, fetch_owner_session_id: Option>, host_closure: PreparedTargetHostClosure, } -struct BrowserContextDisposal { +struct PageFetchDisposal { + page_owner: PageResidenceIdentity, + projection_session_id: Option, + pending_navigations: Vec, + pending_auth_navigations: Vec, + pending_response_navigations: Vec, +} + +struct BrowserContextDisposalOwnerState { + reservation: Option, browser_context_id: String, - page_targets: Vec, + page_targets: VecDeque, + fetch_disposals: VecDeque, inspector_session_ids: Vec, - pending_inspector_session_owners: Vec>, + worker_targets_prepared: bool, + active_page_target: Option, + prefix_events: Vec, + all_page_target_facts_projected: bool, + events: Vec, + command_context: CommandDispatchContext, } -impl BrowserContextDisposal { - fn prepare(conn: &CdpConnection, browser_context_id: &str) -> Result { - let Some(browser_context) = conn.browser_context_by_id(browser_context_id) else { - return Err(browser_context_not_found(browser_context_id)); - }; +enum PendingBrowserContextDisposalParticipant { + FetchCancellation(Box), + TargetClose(Box), + RuntimeCleanup(BrowserContextRuntimeCleanup), +} - let active_page_target_id = browser_context.active_target_id().map(str::to_owned); - let mut page_target_ids = browser_context - .background_targets - .iter() - .rev() - .map(|target| target.target_id().to_owned()) - .collect::>(); - if let Some(active_page_target_id) = active_page_target_id { - page_target_ids.push(active_page_target_id); +struct BrowserContextRuntimeCleanup { + pages: Vec, + owners: Vec, + runtime_owner: RendererBrowserContextRuntimeOwner, +} + +impl BrowserContextRuntimeCleanup { + fn new( + pages: Vec, + owners: Vec, + mut runtime_owner: RendererBrowserContextRuntimeOwner, + ) -> Self { + // Close producer admission in the exact terminal owner turn. Page + // owners may then finish asynchronously, but no new work can enter + // the retired Context while this participant is detached. + runtime_owner.terminate_renderer_producers_for_owner_shutdown(); + Self { + pages, + owners, + runtime_owner, } + } - let target_ids = browser_context - .devtools_target_infos() - .into_iter() - .filter_map(|target_info| { - target_info - .target_id - .map(|target_id| (target_info.kind, target_id.into_string())) - }) - .collect::>(); - - let page_targets = page_target_ids - .into_iter() - .map(|target_id| { - let session_ids = target_session_ids(conn, browser_context, &target_id); - let fetch_owner_session_id = - page_fetch_owner_session_id(browser_context, &target_id, &session_ids); - PageTargetDisposal { - fetch_owner_session_id, - host_closure: conn.prepare_target_host_closure(&target_id), - target_id, - } - }) - .collect(); - - let mut seen_sessions = HashSet::new(); - let inspector_session_ids = target_ids - .into_iter() - .filter(|(kind, _)| { - matches!( - kind, - DevToolsTargetKind::Page - | DevToolsTargetKind::SharedWorker - | DevToolsTargetKind::ServiceWorker - ) - }) - .flat_map(|(_, target_id)| target_session_ids(conn, browser_context, &target_id)) - .filter(|session_id| seen_sessions.insert(session_id.clone())) - .collect::>(); - - let mut pending_inspector_session_owners = browser_context - .has_active_target() - .then_some(None) - .into_iter() - .collect::>(); - pending_inspector_session_owners.extend(inspector_session_ids.iter().cloned().map(Some)); - - Ok(Self { - browser_context_id: browser_context_id.to_owned(), - page_targets, - inspector_session_ids, - pending_inspector_session_owners, - }) + async fn wait(mut self) { + for owner in self.owners { + let _ = owner.close_async().await; + } + for page in self.pages { + let _ = page.close_async().await; + } + self.runtime_owner.shutdown_network_and_join(); } } -fn target_session_ids( - conn: &CdpConnection, - browser_context: &crate::conn::BrowserContext, - target_id: &str, -) -> Vec { - let mut session_ids = browser_context.devtools_session_ids_for_target(target_id); - session_ids.extend(conn.attached_sessions_for_target(target_id)); - session_ids.sort(); - session_ids.dedup(); - session_ids +enum CompletedBrowserContextDisposalParticipant { + FetchCancellation(Box), + TargetClose(Box), + RuntimeCleanup, } -fn page_fetch_owner_session_id( - browser_context: &crate::conn::BrowserContext, - target_id: &str, - session_ids: &[String], -) -> Option> { - if browser_context.is_active_target(target_id) { - return Some(browser_context.active_session_id_owned()); - } - browser_context - .background_target(target_id) - .and_then(|target| target.session_id().map(str::to_owned)) - .or_else(|| session_ids.first().cloned()) - .map(Some) +/// One exact renderer/resource participant in whole-Context disposal. +/// +/// The state owns the Core disposal reservation. Dropping a frontend reply +/// does not drop this value: Browser Host continues the chain until Context +/// removal commits or the reservation is explicitly rolled back. +pub(crate) struct PendingBrowserContextDisposalOwnerTask { + state: BrowserContextDisposalOwnerState, + participant: PendingBrowserContextDisposalParticipant, } -pub(super) async fn execute_browser_context_disposal_async( - conn: &mut CdpConnection, - browser_context_id: String, - out: &mut events::TargetProtocolSideEffects, - command_context: &mut CommandDispatchContext, -) -> Result<(), DevToolsError> { - if !conn.has_browser_context_id(&browser_context_id) { - return Err(browser_context_not_found(&browser_context_id)); - } +pub(crate) struct CompletedBrowserContextDisposalOwnerTask { + state: BrowserContextDisposalOwnerState, + participant: CompletedBrowserContextDisposalParticipant, +} - let restore_browser_context_id = super::previously_active_browser_context_id(conn); - if !conn - .activate_browser_context_by_id_async(&browser_context_id) - .await - { - return Err(browser_context_not_found(&browser_context_id)); +pub(crate) enum BrowserContextDisposalOwnerTaskStep { + Pending(Box), + Complete(BrowserContextDisposalOwnerTaskOutput), +} + +pub(crate) struct BrowserContextDisposalOwnerTaskOutput { + plan: CommandOutputPlan, + command_context: CommandDispatchContext, +} + +impl BrowserContextDisposalOwnerTaskOutput { + fn new(plan: CommandOutputPlan, command_context: CommandDispatchContext) -> Self { + Self { + plan, + command_context, + } } - let disposal = BrowserContextDisposal::prepare(conn, &browser_context_id)?; - fail_target_pending_work(conn, &disposal, out, command_context).await; - out.extend_background_events(command_context.take_protocol_events()); + pub(crate) fn into_parts(self) -> (CommandOutputPlan, CommandDispatchContext) { + (self.plan, self.command_context) + } +} - for session_id in &disposal.inspector_session_ids { - out.background_events_mut() - .push(events::inspector_detached_event( - session_id, - INSPECTOR_DETACHED_REASON, - )); +impl PendingBrowserContextDisposalOwnerTask { + pub(crate) async fn wait(self: Box) -> CompletedBrowserContextDisposalOwnerTask { + let Self { state, participant } = *self; + let participant = match participant { + PendingBrowserContextDisposalParticipant::FetchCancellation(pending) => { + CompletedBrowserContextDisposalParticipant::FetchCancellation(Box::new( + pending.wait().await, + )) + } + PendingBrowserContextDisposalParticipant::TargetClose(pending) => { + CompletedBrowserContextDisposalParticipant::TargetClose(Box::new( + (*pending).wait().await, + )) + } + PendingBrowserContextDisposalParticipant::RuntimeCleanup(cleanup) => { + cleanup.wait().await; + CompletedBrowserContextDisposalParticipant::RuntimeCleanup + } + }; + CompletedBrowserContextDisposalOwnerTask { state, participant } } +} - out.extend_background_events( - worker_target::close_browser_context_worker_targets_for_dispose_async( - conn, - &disposal.browser_context_id, - DISPOSE_REASON, - ) - .await, +/// Starts disposal after Browser Host has selected the exact Context input. +/// +/// All frontend parsing and response routing happened before publication. The +/// first mutation is the Core reservation, which prevents new Target/Page +/// work from entering while cleanup participants are outstanding. +pub(crate) fn start_browser_context_disposal_owner_task( + conn: &mut CdpConnection, + browser_context_handle: BrowserContextHandle, + prefix_events: Vec, + mut command_context: CommandDispatchContext, +) -> BrowserContextDisposalOwnerTaskStep { + let reservation = match conn.begin_browser_context_disposal(&browser_context_handle) { + Ok(reservation) => reservation, + Err(error) => { + return BrowserContextDisposalOwnerTaskStep::Complete( + BrowserContextDisposalOwnerTaskOutput::new( + CommandOutputPlan::from_devtools_error(DevToolsError::new( + DevToolsErrorKind::Internal, + error.to_string(), + )), + command_context, + ), + ); + } + }; + + let preparation = prepare_browser_context_disposal( + conn, + &browser_context_handle, + prefix_events, + &mut command_context, ); + let (page_targets, fetch_disposals, inspector_session_ids, prefix_events, events) = + match preparation { + Ok(preparation) => preparation, + Err(error) => { + let _ = conn.rollback_browser_context_disposal(reservation); + return BrowserContextDisposalOwnerTaskStep::Complete( + BrowserContextDisposalOwnerTaskOutput::new( + CommandOutputPlan::from_devtools_error(error), + command_context, + ), + ); + } + }; - for page_target in disposal.page_targets { - close_page_target(conn, out, page_target).await; - } + drive_browser_context_disposal_owner_task( + conn, + BrowserContextDisposalOwnerState { + reservation: Some(reservation), + browser_context_id: browser_context_handle.browser_context_id().to_owned(), + page_targets: page_targets.into(), + fetch_disposals: fetch_disposals.into(), + inspector_session_ids, + worker_targets_prepared: false, + active_page_target: None, + prefix_events, + all_page_target_facts_projected: true, + events, + command_context, + }, + ) +} - conn.download_behavior - .clear_browser_context(disposal.browser_context_id.as_str()); - conn.permission_overrides.retain(|entry| { - entry.browser_context_id.as_deref() != Some(disposal.browser_context_id.as_str()) - }); - - let removed = conn - .remove_browser_context_by_id_restoring_active_async( - &disposal.browser_context_id, - restore_browser_context_id.as_deref(), - ) - .await; - if let Some(mut removed) = removed { - removed.close_all_pages_async().await; +pub(crate) async fn complete_browser_context_disposal_owner_task( + conn: &mut CdpConnection, + completed: CompletedBrowserContextDisposalOwnerTask, +) -> BrowserContextDisposalOwnerTaskStep { + let CompletedBrowserContextDisposalOwnerTask { + mut state, + participant, + } = completed; + match participant { + CompletedBrowserContextDisposalParticipant::FetchCancellation(completed) => { + match crate::domains::page::complete_pending_fetch_state_cancellation(conn, *completed) + .await + { + FetchCancellationOwnerTaskStep::Pending(pending) => { + return BrowserContextDisposalOwnerTaskStep::Pending(Box::new( + PendingBrowserContextDisposalOwnerTask { + state, + participant: + PendingBrowserContextDisposalParticipant::FetchCancellation(pending), + }, + )); + } + FetchCancellationOwnerTaskStep::Complete(output) => { + merge_fetch_cancellation_output(&mut state, output); + } + } + } + CompletedBrowserContextDisposalParticipant::TargetClose(completed) => { + match conn.continue_browser_target_close(*completed) { + BrowserTargetCloseStart::Pending(pending) => { + return BrowserContextDisposalOwnerTaskStep::Pending(Box::new( + PendingBrowserContextDisposalOwnerTask { + state, + participant: PendingBrowserContextDisposalParticipant::TargetClose( + pending, + ), + }, + )); + } + BrowserTargetCloseStart::Complete(projection) => { + finish_active_page_target_disposal(conn, &mut state, projection); + } + } + } + CompletedBrowserContextDisposalParticipant::RuntimeCleanup => { + conn.release_idle_navigation_engine_memory_after_target_close(); + return BrowserContextDisposalOwnerTaskStep::Complete(finish_disposal_success(state)); + } } - conn.release_idle_navigation_engine_memory_after_target_close(); - Ok(()) + drive_browser_context_disposal_owner_task(conn, state) } -async fn fail_target_pending_work( +type BrowserContextDisposalPreparation = ( + Vec, + Vec, + Vec, + Vec, + Vec, +); + +fn prepare_browser_context_disposal( conn: &mut CdpConnection, - disposal: &BrowserContextDisposal, - out: &mut events::TargetProtocolSideEffects, + browser_context_handle: &BrowserContextHandle, + prefix_events: Vec, command_context: &mut CommandDispatchContext, -) { - for session_id in &disposal.pending_inspector_session_owners { +) -> Result { + let browser_context_id = browser_context_handle.browser_context_id(); + let Some(browser_context) = conn.browser_context_by_id(browser_context_id) else { + return Err(browser_context_not_found(browser_context_id)); + }; + if browser_context.browser_context_handle() != browser_context_handle { + return Err(browser_context_not_found(browser_context_id)); + } + + let active_page_target_id = browser_context.active_target_id().map(str::to_owned); + let mut page_target_ids = browser_context + .background_targets + .iter() + .rev() + .map(|target| target.target_id().to_owned()) + .collect::>(); + if let Some(active_page_target_id) = active_page_target_id.as_ref() { + page_target_ids.push(active_page_target_id.clone()); + } + + let target_ids = browser_context + .devtools_target_infos() + .into_iter() + .filter_map(|target_info| { + target_info + .target_id + .map(|target_id| (target_info.kind, target_id.into_string())) + }) + .collect::>(); + + let mut page_targets = Vec::with_capacity(page_target_ids.len()); + for target_id in page_target_ids { + let owner = BrowserPageOwnerKey::new(browser_context_id, target_id.as_str()); + let termination = conn + .capture_browser_target_termination_for_owner( + &owner, + crate::conn::BrowserTargetTerminationProjectionKind::TargetClose, + ) + .ok_or_else(|| browser_context_not_found(browser_context_id))?; + let browser_context = conn + .browser_context_by_id(browser_context_id) + .ok_or_else(|| browser_context_not_found(browser_context_id))?; + let session_ids = target_session_ids(conn, browser_context, &target_id); + let fetch_owner_session_id = + page_fetch_owner_session_id(browser_context, &target_id, &session_ids); + page_targets.push(PageTargetDisposal { + target_id: target_id.clone(), + page_owner: termination.page().clone(), + termination: Some(termination), + fetch_owner_session_id, + host_closure: conn.prepare_target_host_closure(&target_id), + }); + } + + let mut seen_sessions = HashSet::new(); + let inspector_session_ids = target_ids + .into_iter() + .filter(|(kind, _)| { + matches!( + kind, + DevToolsTargetKind::Page + | DevToolsTargetKind::SharedWorker + | DevToolsTargetKind::ServiceWorker + ) + }) + .flat_map(|(_, target_id)| { + conn.browser_context_by_id(browser_context_id) + .map(|browser_context| target_session_ids(conn, browser_context, &target_id)) + .unwrap_or_default() + }) + .filter(|session_id| seen_sessions.insert(session_id.clone())) + .collect::>(); + + let mut events = Vec::new(); + if let Some(active_target_id) = active_page_target_id.as_deref() + && let Some(active_page) = page_targets + .iter() + .find(|target| target.target_id == active_target_id) + && let Some(route) = conn.target_page_owner_route_if_current(&active_page.page_owner) + { + let mut owner_scope = conn.scoped_none_session_owner_route_override(route); + owner_scope + .conn_mut() + .fail_pending_inspector_awaits_for_session_owner_background_events_into( + &mut events, + command_context.protocol_events_mut(), + None, + DISPOSE_REASON, + ); + } + for session_id in &inspector_session_ids { conn.fail_pending_inspector_awaits_for_session_owner_background_events_into( - out.background_events_mut(), + &mut events, command_context.protocol_events_mut(), - session_id.as_deref(), + Some(session_id), DISPOSE_REASON, ); } - for page_target in &disposal.page_targets { - if let Some(session_id) = &page_target.fetch_owner_session_id { - fail_pending_navigations_for_disposed_target_async( + + let mut fetch_disposals = Vec::new(); + for page_target in &page_targets { + let Some(projection_session_id) = page_target.fetch_owner_session_id.clone() else { + continue; + }; + let Some(route) = conn.target_page_owner_route_if_current(&page_target.page_owner) else { + continue; + }; + let pending = { + let mut owner_scope = conn.scoped_none_session_owner_route_override(route); + crate::domains::page::take_pending_fetch_state(owner_scope.conn_mut(), None) + }; + let ( + pending_navigations, + pending_auth_navigations, + pending_response_navigations, + _pending_subresource_fetches, + _pending_subresource_auths, + _pending_subresource_responses, + ) = pending; + fetch_disposals.push(PageFetchDisposal { + page_owner: page_target.page_owner.clone(), + projection_session_id, + pending_navigations, + pending_auth_navigations, + pending_response_navigations, + }); + } + + Ok(( + page_targets, + fetch_disposals, + inspector_session_ids, + prefix_events, + events, + )) +} + +fn drive_browser_context_disposal_owner_task( + conn: &mut CdpConnection, + mut state: BrowserContextDisposalOwnerState, +) -> BrowserContextDisposalOwnerTaskStep { + loop { + if let Some(fetch) = state.fetch_disposals.pop_front() { + let step = crate::domains::page::start_pending_fetch_state_cancellation( conn, - out.background_events_mut(), - session_id.as_deref(), - ) - .await; + Some(fetch.page_owner), + fetch.projection_session_id, + DISPOSE_REASON.to_owned(), + fetch.pending_navigations, + fetch.pending_auth_navigations, + fetch.pending_response_navigations, + Vec::new(), + Vec::new(), + Vec::new(), + ); + match step { + FetchCancellationOwnerTaskStep::Pending(pending) => { + return BrowserContextDisposalOwnerTaskStep::Pending(Box::new( + PendingBrowserContextDisposalOwnerTask { + state, + participant: + PendingBrowserContextDisposalParticipant::FetchCancellation(pending), + }, + )); + } + FetchCancellationOwnerTaskStep::Complete(output) => { + merge_fetch_cancellation_output(&mut state, output); + continue; + } + } + } + + if !state.worker_targets_prepared { + state + .events + .extend(state.command_context.take_protocol_events()); + for session_id in &state.inspector_session_ids { + state.events.push(events::inspector_detached_event( + session_id, + INSPECTOR_DETACHED_REASON, + )); + } + let worker_outputs = worker_target::prepare_browser_context_worker_targets_for_dispose( + conn, + &state.browser_context_id, + DISPOSE_REASON, + ); + state.events.extend( + worker_target::browser_context_worker_target_removal_background_events( + conn, + worker_outputs, + ), + ); + state.worker_targets_prepared = true; + } + + if let Some(mut page_target) = state.page_targets.pop_front() { + let mut termination_events = Vec::new(); + let Some(termination) = page_target.termination.take() else { + state.all_page_target_facts_projected = false; + tracing::error!( + target_id = page_target.target_id, + "prepared Context disposal Target lost its termination capability" + ); + continue; + }; + let step = conn.start_browser_target_close_for_context_disposal( + termination, + &mut termination_events, + DISPOSE_REASON, + ); + state.events.extend(termination_events); + match step { + Some(BrowserTargetCloseStart::Pending(pending)) => { + state.active_page_target = Some(page_target); + return BrowserContextDisposalOwnerTaskStep::Pending(Box::new( + PendingBrowserContextDisposalOwnerTask { + state, + participant: PendingBrowserContextDisposalParticipant::TargetClose( + pending, + ), + }, + )); + } + Some(BrowserTargetCloseStart::Complete(projection)) => { + state.active_page_target = Some(page_target); + finish_active_page_target_disposal(conn, &mut state, projection); + continue; + } + None => { + state.all_page_target_facts_projected = false; + tracing::debug!( + target_id = page_target.target_id, + "BrowserContext disposal skipped a Target already retired by another owner turn" + ); + continue; + } + } + } + + conn.apply_browser_download_policy_update( + moli_core::browser_host::BrowserDownloadPolicyUpdate::RemoveBrowserContext { + browser_context_id: state.browser_context_id.clone(), + }, + ); + conn.clear_automation_download_events_for_browser_context( + state.browser_context_id.as_str(), + ); + let mut permission_overrides = conn + .browser_host_policy_snapshot() + .permission_overrides() + .to_vec(); + permission_overrides.retain(|entry| { + entry.browser_context_id.as_deref() != Some(state.browser_context_id.as_str()) + }); + conn.apply_browser_host_policy_update( + moli_core::browser_host::BrowserHostPolicyUpdate::ReplacePermissionOverrides( + permission_overrides, + ), + ); + let Some(reservation) = state.reservation.take() else { + return BrowserContextDisposalOwnerTaskStep::Complete(finish_disposal_error( + state, + DevToolsError::new( + DevToolsErrorKind::Internal, + "BrowserContextDisposalReservationMissing", + ), + )); + }; + let (mut removed, retired_renderer_page_owners, renderer_runtime_owner) = + match conn.remove_browser_context_for_disposal(&reservation) { + Ok(removed) => removed, + Err(error) => { + let _ = conn.rollback_browser_context_disposal(reservation); + return BrowserContextDisposalOwnerTaskStep::Complete(finish_disposal_error( + state, + error.into(), + )); + } + }; + drop(reservation); + let residual_pages = removed.take_residual_pages_for_browser_context_disposal(); + drop(removed); + let cleanup = BrowserContextRuntimeCleanup::new( + residual_pages, + retired_renderer_page_owners, + renderer_runtime_owner, + ); + if !cleanup.pages.is_empty() || !cleanup.owners.is_empty() { + tracing::warn!( + browser_context_id = state.browser_context_id, + physical_page_count = cleanup.pages.len(), + core_owner_count = cleanup.owners.len(), + "BrowserContext removal retained renderer Pages outside the normal Target termination chain" + ); } + return BrowserContextDisposalOwnerTaskStep::Pending(Box::new( + PendingBrowserContextDisposalOwnerTask { + state, + participant: PendingBrowserContextDisposalParticipant::RuntimeCleanup(cleanup), + }, + )); } } -async fn fail_pending_navigations_for_disposed_target_async( - conn: &mut CdpConnection, - out: &mut Vec, - session_id: Option<&str>, +fn merge_fetch_cancellation_output( + state: &mut BrowserContextDisposalOwnerState, + output: crate::domains::page::FetchCancellationOwnerTaskOutput, ) { - let ( - pending_navigations, - pending_auth_navigations, - pending_response_navigations, - _pending_subresource_fetches, - _pending_subresource_auths, - _pending_subresource_responses, - ) = crate::domains::page::take_pending_fetch_state(conn, session_id); - - // Chromium closes an incognito BrowserContext as a renderer-lifetime - // boundary. Pending main-resource navigations still receive their - // protocol terminal before the target detaches, but fetch/XHR promises - // disappear with the renderer realm and do not synthesize - // Network.loadingFailed. In particular, disposal must not join concrete - // output cursors from every Page stream merely to report subresource - // cancellation. - let _ = crate::domains::page::fail_pending_fetch_state_background_events_async( - conn, - out, - session_id, - DISPOSE_REASON, - pending_navigations, - pending_auth_navigations, - pending_response_navigations, - Vec::new(), - Vec::new(), - Vec::new(), - ) - .await; + let (events, predecessor) = output.into_parts(); + state.events.extend(events); + if let Some(predecessor) = predecessor { + state + .command_context + .set_renderer_output_predecessor(predecessor); + } } -async fn close_page_target( +fn finish_active_page_target_disposal( conn: &mut CdpConnection, - out: &mut events::TargetProtocolSideEffects, - page_target: PageTargetDisposal, + state: &mut BrowserContextDisposalOwnerState, + projection: BrowserTargetTerminationProjection, ) { - let target_id = page_target.target_id; - let is_active_target = conn - .browser_context - .as_ref() - .is_some_and(|browser_context| browser_context.is_active_target(&target_id)); - let closed = if is_active_target { - conn.close_active_page_target_for_target_close_async(out.background_events_mut()) - .await - } else { - conn.close_background_page_target_for_target_close_async( - &target_id, - out.background_events_mut(), - DISPOSE_REASON, - ) - .await + let Some(page_target) = state.active_page_target.take() else { + tracing::error!("BrowserContext disposal lost its active Target projection state"); + return; }; - let Some(closed) = closed else { - tracing::warn!( - target_id, - "browser context disposal could not close a prepared page target" + let BrowserTargetTerminationProjection::Closed { + closed, + browser_fact, + } = projection + else { + tracing::error!( + target_id = page_target.target_id, + "BrowserContext disposal Target close projected a crash" ); return; }; + let target_id = page_target.target_id; let (target_detached_info_deltas, target_destroyed_deltas) = page_target.host_closure.into_parts(); - out.extend_background_events( - conn.prepared_target_host_deltas_event_plan(target_detached_info_deltas), - ); - out.extend_background_events(conn.detach_target_closure_cleanup_event_plan( + let project_bidi_lifecycle = conn.webdriver_bidi_target_lifecycle_projection_enabled(); + let mut terminal_events = if project_bidi_lifecycle { + conn.prepared_top_level_target_host_deltas_event_plan(target_detached_info_deltas) + } else { + conn.prepared_target_host_deltas_event_plan(target_detached_info_deltas) + }; + terminal_events.extend(conn.detach_target_closure_cleanup_event_plan( closed.into_detach_cleanup_plan(Some(INSPECTOR_DETACHED_REASON)), None, )); - out.extend_background_events(conn.detach_closed_top_level_target_sessions_event_plan( + terminal_events.extend(conn.detach_closed_top_level_target_sessions_event_plan( &target_id, Some(INSPECTOR_DETACHED_REASON), )); - out.extend_background_events( - conn.prepared_target_host_deltas_event_plan(target_destroyed_deltas), + if project_bidi_lifecycle { + terminal_events + .extend(conn.prepared_top_level_target_host_deltas_event_plan(target_destroyed_deltas)); + } else { + terminal_events + .extend(conn.prepared_target_host_deltas_event_plan(target_destroyed_deltas)); + } + if let Some(browser_fact) = browser_fact { + tracing::trace!( + browser_fact_sequence = browser_fact.envelope().sequence().get(), + target_id, + "projecting BrowserContext disposal Target close from exact Browser fact" + ); + state.events.extend(terminal_events); + } else { + state.all_page_target_facts_projected = false; + tracing::error!( + target_id, + "suppressing BrowserContext disposal Target events without an exact Browser fact" + ); + } +} + +fn finish_disposal_success( + mut state: BrowserContextDisposalOwnerState, +) -> BrowserContextDisposalOwnerTaskOutput { + let mut plan = CommandOutputPlan::success(); + if state.all_page_target_facts_projected { + plan.extend_background_events(std::mem::take(&mut state.prefix_events)); + } else { + tracing::error!( + browser_context_id = state.browser_context_id, + "suppressing frozen BrowserContext lifecycle events because a Target fact was missing" + ); + } + plan.extend_background_events(state.events); + BrowserContextDisposalOwnerTaskOutput::new(plan, state.command_context) +} + +fn finish_disposal_error( + state: BrowserContextDisposalOwnerState, + error: DevToolsError, +) -> BrowserContextDisposalOwnerTaskOutput { + let mut plan = CommandOutputPlan::from_devtools_error(error); + plan.extend_background_events(state.events); + BrowserContextDisposalOwnerTaskOutput::new(plan, state.command_context) +} + +fn target_session_ids( + conn: &CdpConnection, + browser_context: &crate::conn::BrowserContext, + target_id: &str, +) -> Vec { + let mut session_ids = browser_context.devtools_session_ids_for_target(target_id); + session_ids.extend(conn.attached_sessions_for_target(target_id)); + session_ids.sort(); + session_ids.dedup(); + session_ids +} + +fn page_fetch_owner_session_id( + browser_context: &crate::conn::BrowserContext, + target_id: &str, + session_ids: &[String], +) -> Option> { + if browser_context.is_active_target(target_id) { + return Some(browser_context.active_session_id_owned()); + } + browser_context + .primary_session_id_for_target(target_id) + .map(str::to_owned) + .or_else(|| session_ids.first().cloned()) + .map(Some) +} + +/// Compatibility drain for direct `CdpConnection` callers that have not yet +/// moved their application loop to the Browser Host frontend adapter. +/// Production raw CDP/BiDi scheduling publishes the same owner task instead. +pub(super) async fn execute_browser_context_disposal_async( + conn: &mut CdpConnection, + browser_context_id: String, + prefix_events: Vec, + out: &mut events::TargetProtocolSideEffects, + command_context: &mut CommandDispatchContext, +) -> Result<(), DevToolsError> { + let Some(handle) = conn + .browser_context_by_id(&browser_context_id) + .map(|context| context.browser_context_handle().clone()) + else { + return Err(browser_context_not_found(&browser_context_id)); + }; + let mut step = start_browser_context_disposal_owner_task( + conn, + handle, + prefix_events, + std::mem::take(command_context), ); + loop { + match step { + BrowserContextDisposalOwnerTaskStep::Pending(pending) => { + step = + complete_browser_context_disposal_owner_task(conn, pending.wait().await).await; + } + BrowserContextDisposalOwnerTaskStep::Complete(output) => { + let (plan, returned_command_context) = output.into_parts(); + *command_context = returned_command_context; + let (status, events) = plan.into_command_status_and_background_events(); + out.extend_background_events(events); + return status.unwrap_or(Ok(())); + } + } + } } fn browser_context_not_found(browser_context_id: &str) -> DevToolsError { diff --git a/moli-protocol/src/domains/target/browser_context_disposal_owner_adapter.rs b/moli-protocol/src/domains/target/browser_context_disposal_owner_adapter.rs new file mode 100644 index 0000000000..e4fb04ed1e --- /dev/null +++ b/moli-protocol/src/domains/target/browser_context_disposal_owner_adapter.rs @@ -0,0 +1,218 @@ +use crate::{ + conn::{ + CdpConnection, CommandDispatchContext, CompletedBrowserOwnerContextDisposalCommand, + PendingBrowserOwnerContextDisposalCommand, + }, + devtools_runtime::{ + DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, DevToolsError, + DevToolsErrorKind, DevToolsProtocol, + }, +}; + +/// Direct BiDi/Classic frontend start result for one Browser-owned Context +/// disposal. Once pending is returned, no direct Protocol execution fallback +/// exists; the application loop must keep servicing Browser Host turns. +pub enum DevToolsBrowserOwnerContextDisposalCommandTaskStep { + Pending(Box), + Complete(Box), +} + +#[must_use = "a Browser Owner Context-disposal result must be awaited or explicitly abandoned"] +pub struct PendingDevToolsBrowserOwnerContextDisposalCommand { + pending: PendingBrowserOwnerContextDisposalCommand, + devtools_context: DevToolsCommandContext, +} + +impl PendingDevToolsBrowserOwnerContextDisposalCommand { + pub async fn wait(self) -> CompletedDevToolsBrowserOwnerContextDisposalCommand { + CompletedDevToolsBrowserOwnerContextDisposalCommand { + completed: self.pending.wait().await, + devtools_context: self.devtools_context, + } + } +} + +#[must_use = "a completed Browser Owner Context disposal must be projected"] +pub struct CompletedDevToolsBrowserOwnerContextDisposalCommand { + completed: Result, + devtools_context: DevToolsCommandContext, +} + +impl CdpConnection { + /// Admits a typed `RemoveBrowserContext` command to the shared Browser + /// Owner FIFO using the exact Context capability current at publication. + pub async fn try_start_devtools_browser_owner_context_disposal_command( + &mut self, + command: DevToolsCommand, + ) -> Result { + let DevToolsCommand::RemoveBrowserContext(command) = command else { + return Err(command); + }; + let devtools_context = command.context; + let browser_context_id = command.browser_context_id.into_string(); + if browser_context_id == self.default_browser_context_id() { + return Ok(self + .complete_direct_context_disposal_start_error( + devtools_context, + DevToolsError::new( + DevToolsErrorKind::InvalidArgument, + "DefaultBrowserContextCannotBeRemoved", + ), + ) + .await); + } + let Some(browser_context_handle) = self + .browser_context_by_id(&browser_context_id) + .map(|context| context.browser_context_handle().clone()) + else { + return Ok(self + .complete_direct_context_disposal_start_error( + devtools_context, + DevToolsError::new(DevToolsErrorKind::NoSuchTarget, "UnknownBrowserContextId"), + ) + .await); + }; + let prefix_events = if devtools_context.protocol == DevToolsProtocol::WebDriverBidi { + super::browser_context::target_destroyed_automation_events_for_browser_context( + self, + &browser_context_id, + ) + } else { + Vec::new() + }; + match self.publish_browser_owner_context_disposal_command( + browser_context_handle, + prefix_events, + CommandDispatchContext::default(), + ) { + Ok(pending) => Ok(DevToolsBrowserOwnerContextDisposalCommandTaskStep::Pending( + Box::new(PendingDevToolsBrowserOwnerContextDisposalCommand { + pending, + devtools_context, + }), + )), + Err(error) => Ok(self + .complete_direct_context_disposal_start_error( + devtools_context, + DevToolsError::new(DevToolsErrorKind::Internal, error.to_string()), + ) + .await), + } + } + + async fn complete_direct_context_disposal_start_error( + &mut self, + devtools_context: DevToolsCommandContext, + error: DevToolsError, + ) -> DevToolsBrowserOwnerContextDisposalCommandTaskStep { + DevToolsBrowserOwnerContextDisposalCommandTaskStep::Complete(Box::new( + self.finish_devtools_command_dispatch(devtools_context, Err(error), Vec::new(), None) + .await, + )) + } + + /// Projects the terminal neutral owner result into the typed frontend. + /// This method never advances Browser work; it only consumes the sidecar + /// response and its ordered protocol effects. + pub async fn complete_devtools_browser_owner_context_disposal_command( + &mut self, + completed: CompletedDevToolsBrowserOwnerContextDisposalCommand, + ) -> crate::conn::DevToolsCommandDispatchOutcome { + let CompletedDevToolsBrowserOwnerContextDisposalCommand { + completed, + devtools_context, + } = completed; + let (result, command_context) = match completed { + Ok(completed) => { + let (plan, mut command_context) = completed.into_parts(); + let (status, events) = plan.into_command_status_and_background_events(); + command_context.protocol_events_mut().extend(events); + ( + status + .unwrap_or(Ok(())) + .map(|()| DevToolsCommandResult::Empty), + command_context, + ) + } + Err(message) => ( + Err(DevToolsError::new(DevToolsErrorKind::Internal, message)), + CommandDispatchContext::default(), + ), + }; + self.finish_devtools_command_dispatch_with_projection_context( + devtools_context, + result, + command_context, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use moli_core::browser_host::BrowserHostActor; + + use crate::{ + conn::BrowserContext, + devtools_runtime::{ + DevToolsBrowserContextId, DevToolsRemoveBrowserContextCommand, DevToolsSessionId, + }, + }; + + use super::*; + + #[tokio::test] + async fn typed_context_disposal_waits_for_host_selection_and_projects_bidi_prefix() { + let mut conn = CdpConnection::new(); + conn.install_default_browser_target(); + let mut browser_context = BrowserContext::new("BID-owner-dispose".to_owned()); + browser_context.set_active_target_id("TID-owner-dispose".to_owned()); + browser_context.attach_active_session("SID-owner-dispose-target".to_owned()); + conn.insert_browser_context(browser_context); + let (mut browser_host, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + + let command = DevToolsCommand::RemoveBrowserContext(DevToolsRemoveBrowserContextCommand { + context: DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverBidi, + session_id: Some(DevToolsSessionId::from("SID-owner-dispose-command")), + target_id: None, + browser_context_id: None, + }, + browser_context_id: DevToolsBrowserContextId::from("BID-owner-dispose"), + }); + let step = conn + .try_start_devtools_browser_owner_context_disposal_command(command) + .await + .expect("RemoveBrowserContext must use the owner adapter"); + let DevToolsBrowserOwnerContextDisposalCommandTaskStep::Pending(pending) = step else { + panic!("known user Context disposal should await Browser Host"); + }; + assert!(conn.has_browser_context_id("BID-owner-dispose")); + assert_eq!(browser_host.ready_len(), 1); + + let dispatch = browser_host + .complete_next_turn(&mut conn) + .expect("queued typed Context disposal turn"); + let host_outcome = conn.finish_browser_host_turn_for_test(dispatch).await; + assert!(host_outcome.into_parts().0.is_empty()); + let completed = pending.wait().await; + let outcome = conn + .complete_devtools_browser_owner_context_disposal_command(completed) + .await; + let (result, scheduler_events, protocol_events, predecessor) = + outcome.into_complete_parts(); + + assert!(matches!(result, Ok(DevToolsCommandResult::Empty))); + assert!(scheduler_events.is_empty()); + assert!(predecessor.is_none()); + assert!( + protocol_events.iter().cloned().any(|event| matches!( + event.into_parts().1, + Some(crate::devtools_runtime::AutomationEvent::TargetDestroyed(_)) + )), + "BiDi target-destroyed projection must remain ahead of disposal side effects" + ); + assert!(!conn.has_browser_context_id("BID-owner-dispose")); + } +} diff --git a/moli-protocol/src/domains/target/closing.rs b/moli-protocol/src/domains/target/closing.rs deleted file mode 100644 index 1ded28a380..0000000000 --- a/moli-protocol/src/domains/target/closing.rs +++ /dev/null @@ -1,275 +0,0 @@ -use serde::Deserialize; - -use crate::devtools_runtime::{DevToolsCloseTargetResult, DevToolsTargetId}; - -use super::*; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub(super) struct CloseTargetParams { - pub(super) 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"); - } - }; - if !conn.target_handler_may_close_target(cmd.session_id, ¶ms.target_id) { - return super::target_command_error(-32000, "Not allowed"); - } - let command = build_cdp_close_target_command(cmd, params); - super::start_devtools_target_command( - conn, - cmd.id, - cmd.session_id, - DevToolsCommand::CloseTarget(command), - ) -} - -pub(super) 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 - .primary_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); -} diff --git a/moli-protocol/src/domains/target/creation.rs b/moli-protocol/src/domains/target/creation.rs deleted file mode 100644 index 790e8cc363..0000000000 --- a/moli-protocol/src/domains/target/creation.rs +++ /dev/null @@ -1,523 +0,0 @@ -use serde::Deserialize; - -use crate::conn::{PreparedTargetAttach, TargetActivationTransition, TargetAttachSessionCommit}; -use crate::devtools_runtime::{ - DevToolsBrowserContextId, DevToolsCreateTargetResult, DevToolsTargetId, -}; - -use super::*; - -pub(super) struct DevToolsCreateTargetExecution { - pub(super) result: DevToolsCreateTargetResult, - pub(super) commit: TargetCreationCommit, -} - -pub(super) struct TargetCreationCommit { - page_target_id: String, - tab_target_id: String, - activation: Option, - attached_tab_sessions: Vec, - attached_sessions: Vec, -} - -impl TargetCreationCommit { - pub(super) fn page_target_id(&self) -> &str { - &self.page_target_id - } - - pub(super) fn activation(&self) -> Option<&TargetActivationTransition> { - self.activation.as_ref() - } -} - -#[derive(Clone, Copy)] -enum CreateTargetResultHost { - Page, - Tab, -} - -pub(super) fn emit_target_creation_protocol_events( - conn: &mut CdpConnection, - events: TargetCreationCommit, - 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.page_target_id) - .is_some() - }) - .ok_or_else(|| DevToolsError::new(DevToolsErrorKind::NoSuchTarget, "UnknownTargetId"))?; - let target_info = bc - .devtools_target_info(&events.page_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.page_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.page_target_id, - target_info, - events.attached_sessions, - )); - for event in event_plan { - out.push_target_background_event(event); - } - Ok(()) -} - -pub(super) 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")] -pub(super) struct CreateTargetParams { - #[serde(default = "default_blank")] - pub(super) url: String, - pub(super) browser_context_id: Option, - pub(super) for_tab: Option, - pub(super) background: Option, - pub(super) focus: 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, - for_tab: None, - background: None, - focus: None, - }, - Err(e) => { - return super::target_command_error(-32602, e); - } - }; - let result_host = if params.for_tab.unwrap_or(false) { - CreateTargetResultHost::Tab - } else { - CreateTargetResultHost::Page - }; - let command = match build_cdp_create_target_command(cmd, params) { - Ok(command) => command, - Err(message) => return super::target_command_error(-32602, message), - }; - start_devtools_create_target_command_with_result_host( - conn, - cmd.id, - cmd.session_id, - command, - result_host, - ) -} - -pub(super) fn build_cdp_create_target_command( - cmd: &Cmd<'_>, - params: CreateTargetParams, -) -> Result { - let should_focus = params.focus.unwrap_or(!params.background.unwrap_or(false)); - let create_in_background = params.background.unwrap_or(false) || params.focus == Some(false); - if should_focus && create_in_background { - return Err("Can't focus a target in the background. Use background=false instead."); - } - Ok(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: !create_in_background, - }) -} - -pub(super) fn start_devtools_create_target_command( - conn: &mut CdpConnection, - command_id: Option, - command_session_id: Option<&str>, - command: DevToolsCreateTargetCommand, -) -> TargetCommandTaskStep { - start_devtools_create_target_command_with_result_host( - conn, - command_id, - command_session_id, - command, - CreateTargetResultHost::Page, - ) -} - -fn start_devtools_create_target_command_with_result_host( - conn: &mut CdpConnection, - command_id: Option, - command_session_id: Option<&str>, - command: DevToolsCreateTargetCommand, - result_host: CreateTargetResultHost, -) -> TargetCommandTaskStep { - let mut plan = CommandOutputPlan::default(); - let execution = execute_devtools_create_target_command(conn, command); - let (created_target_id, creation_commit) = match execution { - Ok(execution) => { - let target_id = execution.result.target_id.clone(); - let response_target_id = match result_host { - CreateTargetResultHost::Page => execution.result.target_id, - CreateTargetResultHost::Tab => { - DevToolsTargetId::from(execution.commit.tab_target_id.as_str()) - } - }; - plan.extend(CommandOutputPlan::from_devtools_result( - DevToolsCommandResult::CreateTarget(DevToolsCreateTargetResult { - target_id: response_target_id, - }), - )); - (target_id, execution.commit) - } - 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, - creation_commit, - 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_target_creation_protocol_events(conn, creation_commit, &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 (has_active_target, previous_active_target_id) = { - let browser_context = conn - .browser_context - .as_ref() - .expect("browser context must exist before target creation"); - ( - browser_context.active_target_identity().is_some() - && !browser_context - .active_target_is_unclaimed_default_placeholder(default_target_id), - browser_context.active_target_id_owned(), - ) - }; - 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 activation = activating_created_target - .then(|| TargetActivationTransition::new(target_id.clone(), previous_active_target_id)); - 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); - if !creating_background_target { - conn.notify_target_host_activated(&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), - }, - commit: TargetCreationCommit { - page_target_id: target_id, - tab_target_id, - activation, - 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() - } -} - -pub(super) 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() -} - -pub(super) 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", - )) - } -} diff --git a/moli-protocol/src/domains/target/info.rs b/moli-protocol/src/domains/target/info.rs deleted file mode 100644 index ee8612066d..0000000000 --- a/moli-protocol/src/domains/target/info.rs +++ /dev/null @@ -1,126 +0,0 @@ -use serde::Deserialize; - -use crate::devtools_runtime::{ - DevToolsGetTargetInfoCommand, DevToolsGetTargetInfoResult, DevToolsTargetId, -}; - -use super::*; - -#[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); - } - }; - if !conn.target_handler_may_get_target_info(cmd.session_id, params.target_id.as_deref()) { - return CommandOutputPlan::error(-32000, "Not allowed"); - } - 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 owner_target_id = command - .context - .target_id - .as_ref() - .map(|target_id| target_id.as_str().to_owned()) - .or_else(|| { - conn.non_browser_target_id_for_session( - command - .context - .session_id - .as_ref() - .map(|session_id| session_id.as_str()), - ) - }); - let wanted = command - .target_id - .as_ref() - .map(|target_id| target_id.as_str()) - .or(owner_target_id.as_deref()); - let Some(wanted) = wanted else { - return Ok(DevToolsGetTargetInfoResult { - target_info: super::browser_context::devtools_browser_target_info(), - }); - }; - 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 }) -} diff --git a/moli-protocol/src/domains/target/lifecycle.rs b/moli-protocol/src/domains/target/lifecycle.rs new file mode 100644 index 0000000000..77b1e95317 --- /dev/null +++ b/moli-protocol/src/domains/target/lifecycle.rs @@ -0,0 +1,3432 @@ +use moli_core::browser_host::{ + BrowserAuxiliaryNavigationKind, BrowserInitialEmptyDocumentSeed, BrowserPageOwnerKey, + BrowserTargetCreationMetadata, +}; +use serde::Deserialize; + +use crate::conn::{ + BackgroundProtocolEvent, BrowserTargetCreatedFactProjection, CdpTargetFilter, + CdpTargetFilterEntry, PopupTargetActivationAction, PreparedTargetAttach, + TargetActivationTransition, 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, + activation: Option, + browser_fact: Option, + attached_tab_sessions: Vec, + attached_sessions: Vec, +} + +impl CreatedTargetProtocolEvents { + pub(super) fn target_id(&self) -> &str { + &self.target_id + } + + pub(super) fn activation(&self) -> Option<&TargetActivationTransition> { + self.activation.as_ref() + } +} + +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(); + let project_bidi_lifecycle = conn.webdriver_bidi_target_lifecycle_projection_enabled(); + 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)); + } + let creation_fact_projected = match events.browser_fact.as_ref() { + Some(browser_fact) if conn.target_created_fact_matches_current_target(browser_fact) => { + tracing::trace!( + browser_fact_sequence = browser_fact.envelope().sequence().get(), + target_id = events.target_id, + "projecting live Target creation from exact Browser fact" + ); + true + } + Some(browser_fact) => { + tracing::error!( + browser_fact_sequence = browser_fact.envelope().sequence().get(), + target_id = events.target_id, + "suppressing stale Target creation projection after Target instance replacement" + ); + false + } + None => { + tracing::error!( + target_id = events.target_id, + "suppressing Target creation projection without an exact Browser fact" + ); + false + } + }; + if has_discovery && creation_fact_projected { + let mut event_plan = conn.target_created_event_plan(&events.target_id); + if project_bidi_lifecycle { + event_plan = event_plan.without_target_lifecycle_automation_sidecars(); + } + for event in event_plan { + out.push_target_background_event(event); + } + } + if creation_fact_projected && project_bidi_lifecycle { + out.push_target_background_event(BackgroundProtocolEvent::automation_only( + events::target_created_automation_event(target_info.clone()), + )); + } + 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, + for_tab: Option, + background: Option, + focus: Option, +} + +#[derive(Clone, Copy)] +enum CreateTargetResultHost { + Page, + Tab, +} + +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, + for_tab: None, + background: None, + focus: None, + }, + Err(e) => { + return super::target_command_error(-32602, e); + } + }; + let result_host = if params.for_tab.unwrap_or(false) { + CreateTargetResultHost::Tab + } else { + CreateTargetResultHost::Page + }; + let command = match build_cdp_create_target_command(cmd, params) { + Ok(command) => command, + Err(message) => return super::target_command_error(-32602, message), + }; + start_devtools_create_target_command_with_result_host( + conn, + cmd.id, + cmd.session_id, + command, + result_host, + ) +} + +fn build_cdp_create_target_command( + cmd: &Cmd<'_>, + params: CreateTargetParams, +) -> Result { + let should_focus = params.focus.unwrap_or(!params.background.unwrap_or(false)); + let create_in_background = params.background.unwrap_or(false) || params.focus == Some(false); + if should_focus && create_in_background { + return Err("Can't focus a target in the background. Use background=false instead."); + } + Ok(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: !create_in_background, + }) +} + +pub(super) fn start_devtools_create_target_command( + conn: &mut CdpConnection, + command_id: Option, + command_session_id: Option<&str>, + command: DevToolsCreateTargetCommand, +) -> TargetCommandTaskStep { + start_devtools_create_target_command_with_result_host( + conn, + command_id, + command_session_id, + command, + CreateTargetResultHost::Page, + ) +} + +fn start_devtools_create_target_command_with_result_host( + conn: &mut CdpConnection, + command_id: Option, + command_session_id: Option<&str>, + command: DevToolsCreateTargetCommand, + result_host: CreateTargetResultHost, +) -> 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(); + let response_target_id = match result_host { + CreateTargetResultHost::Page => execution.result.target_id, + CreateTargetResultHost::Tab => { + let Some(tab_target_id) = + conn.tab_target_id_for_page_target_id(target_id.as_str()) + else { + return TargetCommandTaskStep::Complete( + CommandOutputPlan::from_devtools_error(DevToolsError::new( + DevToolsErrorKind::Internal, + "MissingTabTargetId", + )), + ); + }; + DevToolsTargetId::from(tab_target_id) + } + }; + plan.extend(CommandOutputPlan::from_devtools_result( + DevToolsCommandResult::CreateTarget(DevToolsCreateTargetResult { + target_id: response_target_id, + }), + )); + (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); + } + conn.debug_assert_browser_context_topology_projection(); + if conn.registered_browser_context_count() == 0 { + let id = conn.gen_bc_id(); + conn.try_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_id, + has_active_target, + replacing_default_placeholder, + previous_active_target_id, + ) = { + let browser_context = conn + .browser_context + .as_ref() + .expect("browser context must exist before target creation"); + let replacing_default_placeholder = + browser_context.active_target_is_unclaimed_default_placeholder(default_target_id); + ( + browser_context.id.clone(), + browser_context.active_target_identity().is_some() && !replacing_default_placeholder, + replacing_default_placeholder, + browser_context.active_target_id_owned(), + ) + }; + 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 activation = activating_created_target + .then(|| TargetActivationTransition::new(target_id.clone(), previous_active_target_id)); + let initial_empty_document_url = create_target_initial_empty_document_url(&command.url); + let creation_metadata = BrowserTargetCreationMetadata::with_initial_empty_document( + BrowserInitialEmptyDocumentSeed::new(initial_empty_document_url.clone()), + ); + let target_projection = if creating_background_target { + conn.register_background_target_with_creation_metadata_projection( + &browser_context_id, + &target_id, + creation_metadata, + |bc, target_handle, page_residence, session_storage_access| { + bc.stage_background_target_with_browser_handles( + target_handle, + page_residence, + session_storage_access, + auto_attached_background_session_id.clone(), + command.url.clone(), + None, + ); + }, + ) + } else if replacing_default_placeholder { + conn.replace_active_target_with_creation_metadata_projection( + default_target_id, + &target_id, + creation_metadata, + |bc, target_handle, page_residence, session_storage_access| { + bc.bind_new_active_target_registration( + target_handle, + page_residence, + session_storage_access, + ); + bc.set_target_url(command.url.clone()); + bc.mark_active_initial_document_page_build_pending(); + bc.active_target.owner_state.target_crash_state.clear(); + }, + ) + } else { + conn.register_active_target_with_creation_metadata_projection( + &target_id, + creation_metadata, + |bc, target_handle, page_residence, session_storage_access| { + if activating_created_target { + bc.stage_active_target_demoting_current_with_browser_handles( + target_handle, + page_residence, + session_storage_access, + None, + command.url.clone(), + ); + } else { + bc.bind_new_active_target_registration( + target_handle, + page_residence, + session_storage_access, + ); + bc.set_target_url(command.url.clone()); + bc.mark_active_initial_document_page_build_pending(); + bc.active_target.owner_state.target_crash_state.clear(); + } + }, + ) + }; + let browser_fact = match target_projection { + Ok(registration) => registration.into_browser_fact(), + Err(error) => { + restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); + return Err(error.into()); + } + }; + 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 Browser Core must + // replace that bookkeeping entry when the requested URL commits. + conn.mark_next_navigation_history_replace_initial_empty_document_for_owner( + &BrowserPageOwnerKey::new(browser_context_id, target_id.clone()), + ); + } + let tab_target_id = conn.register_top_level_page_target(&target_id); + if !creating_background_target { + conn.notify_target_host_activated(&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, + activation, + browser_fact, + 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.try_insert_browser_context( + conn.new_browser_context(wanted_id.as_str().to_owned()), + )?; + } + match conn.try_activate_browser_context_by_id(wanted_id.as_str()) { + Ok(_) => return Ok(()), + Err(error) if !error.is_unknown_context() => return Err(error.into()), + Err(_) => {} + } + 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", + )); + }; + match conn.try_activate_browser_context_by_id(browser_context_id) { + Ok(_) => Ok(()), + Err(error) if !error.is_unknown_context() => Err(error.into()), + Err(_) => 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, + disposition: moli_core::page::RendererPopupDisposition, + 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, + disposition: moli_core::page::RendererPopupDisposition, + session_storage_store: Option, + initial_empty_document_storage_key: Option, + ) -> Self { + Self { + browser_context_id, + popup_id, + url, + target_name, + opener, + can_access_opener, + disposition, + 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, + disposition, + 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 activation = (disposition == moli_core::page::RendererPopupDisposition::Foreground) + .then(|| { + PopupTargetActivationAction::capture_after_navigation( + conn, + &browser_context_id, + &existing_target_id, + url.clone(), + BrowserAuxiliaryNavigationKind::NamedTargetReuse, + ) + }) + .flatten(); + let transition_published = if let Some(activation) = activation { + conn.publish_popup_target_activation_action(activation); + true + } else { + match conn.publish_renderer_auxiliary_navigation( + &browser_context_id, + &existing_target_id, + url, + BrowserAuxiliaryNavigationKind::NamedTargetReuse, + ) { + Ok(true) => true, + Ok(false) => { + tracing::debug!( + browser_context_id, + target_id = existing_target_id, + "dropping named auxiliary Target transition after its Page retired" + ); + false + } + Err(error) => { + tracing::error!( + %error, + browser_context_id, + target_id = existing_target_id, + "named auxiliary Target transition could not enter Browser Host" + ); + false + } + } + }; + return (transition_published + && 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 wait_for_debugger_on_start = auto_attach_page_owners.iter().any(|owner_session_id| { + conn.auto_attach_owner_waits_for_debugger_on_start(owner_session_id.as_deref()) + }); + 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 mut initial_document_seed = BrowserInitialEmptyDocumentSeed::new("about:blank"); + if let Some(creator) = popup_creator.clone() { + initial_document_seed = initial_document_seed.with_creator(creator); + } + if let Some(storage_key) = initial_empty_document_storage_key.clone() { + initial_document_seed = initial_document_seed.with_storage_key(storage_key); + } + let mut creation_metadata = + BrowserTargetCreationMetadata::with_initial_empty_document(initial_document_seed); + if let Some(session_storage_store) = session_storage_store.as_ref() { + creation_metadata = + creation_metadata.with_session_storage_store(session_storage_store.clone()); + } + + let registration = conn.register_background_target_with_creation_metadata_projection( + &browser_context_id, + &target_id, + creation_metadata, + |browser_context, target_handle, page_residence, session_storage_access| { + browser_context.stage_popup_background_target( + target_handle, + page_residence, + session_storage_access, + auto_attached_background_session_id.clone(), + url, + popup_creator, + ); + 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 browser_fact = match registration { + Ok(registration) => registration.into_browser_fact(), + Err(error) => { + tracing::warn!( + browser_context_id, + target_id, + error = %error, + "popup Target registration projection was rejected" + ); + return None; + } + }; + + 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(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; + }; + let creation_fact_projected = match browser_fact.as_ref() { + Some(browser_fact) if conn.target_created_fact_matches_current_target(browser_fact) => { + tracing::trace!( + browser_fact_sequence = browser_fact.envelope().sequence().get(), + target_id, + "projecting popup Target creation from exact Browser fact" + ); + true + } + Some(browser_fact) => { + tracing::error!( + browser_fact_sequence = browser_fact.envelope().sequence().get(), + target_id, + "suppressing stale popup Target creation projection" + ); + false + } + None => { + tracing::error!( + target_id, + "suppressing popup Target creation projection without an exact Browser fact" + ); + false + } + }; + if creation_fact_projected { + let project_bidi_lifecycle = conn.webdriver_bidi_target_lifecycle_projection_enabled(); + if conn.has_any_target_discovery() { + if project_bidi_lifecycle { + for event in conn + .target_created_event_plan(&target_id) + .without_target_lifecycle_automation_sidecars() + { + out.push(event); + } + } else { + push_target_created_events(conn, out, &target_id); + } + } + // Chromium's BiDi mapper keeps a target observer alive independently + // of whether that frontend subscribed to `Target.targetCreated`. + // Preserve that separation while the BiDi projector is installed: + // CDP discovery controls only the CDP notification, while the accepted + // auxiliary browsing-context action publishes one typed lifecycle + // projection. This is especially important for popup creation that + // settles after the causing Runtime command response. + if project_bidi_lifecycle { + 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, + ); + let navigate_initial_document = !wait_for_debugger_on_start; + let activation = (disposition == moli_core::page::RendererPopupDisposition::Foreground) + .then(|| { + if navigate_initial_document { + PopupTargetActivationAction::capture_after_navigation( + conn, + &browser_context_id, + &target_id, + requested_url.clone(), + BrowserAuxiliaryNavigationKind::InitialDocument, + ) + } else { + PopupTargetActivationAction::capture(conn, &browser_context_id, &target_id) + } + }) + .flatten(); + if let Some(activation) = activation { + conn.publish_popup_target_activation_action(activation); + } else if navigate_initial_document { + match conn.publish_renderer_auxiliary_navigation( + &browser_context_id, + &target_id, + requested_url, + BrowserAuxiliaryNavigationKind::InitialDocument, + ) { + Ok(true) => {} + Ok(false) => tracing::debug!( + browser_context_id, + target_id, + "dropping initial auxiliary navigation after its Page retired" + ), + Err(error) => tracing::error!( + %error, + browser_context_id, + target_id, + "initial auxiliary navigation could not enter Browser Host" + ), + } + } + Some(target_id) +} + +pub(crate) async fn complete_popup_target_activation_action_async( + conn: &mut CdpConnection, + action: PopupTargetActivationAction, +) -> crate::conn::CdpTurnOutcome { + let browser_context_id = action.browser_context_id().to_owned(); + let target_id = action.target_id().to_owned(); + let input = action.into_browser_owner_input(); + if let Err(error) = conn.publish_browser_owner_input(input) { + tracing::error!( + %error, + browser_context_id, + target_id, + "popup Target activation could not enter Browser Host" + ); + } + crate::conn::CdpTurnOutcome::new_with_protocol_events(Vec::new(), conn.take_scheduler_events()) +} + +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 + }) +} +pub(super) 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) fn publish_target_url_navigation_if_allowed( + conn: &mut CdpConnection, + target_id: &str, +) -> Result { + if conn.auto_attach_wait_for_debugger_on_start { + return Ok(false); + } + let Some(route) = conn.target_session_route_for_target_id(target_id) else { + return Ok(false); + }; + { + let mut route_scope = conn.scoped_none_session_owner_route_override(route); + route_scope + .conn_mut() + .publish_initial_target_navigation_for_session_owner(None) + } +} + +#[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"); + } + }; + if !conn.target_handler_may_close_target(cmd.session_id, ¶ms.target_id) { + return super::target_command_error(-32000, "Not allowed"); + } + 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 session_id = conn + .browser_context + .as_ref() + .and_then(|browser_context| browser_context.primary_session_id_for_target(&target_id)) + .map(str::to_owned); + let termination = conn + .capture_browser_target_termination_for_target( + &target_id, + crate::conn::BrowserTargetTerminationProjectionKind::TargetClose, + ) + .ok_or_else(|| { + DevToolsError::new(DevToolsErrorKind::NoSuchTarget, "UnknownTargetId") + })?; + 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( + conn, + command_context, + renderer_output_predecessor, + termination, + )?; + 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 termination = conn + .capture_browser_target_termination_for_target( + &target_id, + crate::conn::BrowserTargetTerminationProjectionKind::TargetClose, + ) + .ok_or_else(|| DevToolsError::new(DevToolsErrorKind::NoSuchTarget, "UnknownTargetId"))?; + settle_target_close_after_pending_fetches( + conn, + command_context, + renderer_output_predecessor, + termination, + )?; + Ok(DevToolsCloseTargetResult { success: true }) +} + +/// Admits `Target.closeTarget` after preserving any final renderer +/// publication produced by pending-fetch cancellation. +/// +/// The frontend result and the Browser Host action are independent outputs of +/// this command turn. A paused request can first produce a terminal renderer +/// record; the command completion fence projects that exact record before the +/// application loop can select the independently queued Browser Host turn. +fn settle_target_close_after_pending_fetches( + conn: &mut CdpConnection, + command_context: &mut crate::conn::CommandDispatchContext, + renderer_output_predecessor: Option, + termination: moli_core::browser_host::BrowserTargetTerminationRequest, +) -> Result<(), DevToolsError> { + if let Some(predecessor) = renderer_output_predecessor { + command_context.set_renderer_output_predecessor(predecessor); + } + + let input = moli_core::browser_host::BrowserOwnerInput::target_termination(termination); + conn.publish_browser_owner_input(input).map_err(|error| { + DevToolsError::new( + DevToolsErrorKind::Internal, + format!("BrowserHostTargetCloseAdmissionFailed: {error}"), + ) + }) +} + +#[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); + } + }; + if !conn.target_handler_may_get_target_info(cmd.session_id, params.target_id.as_deref()) { + return CommandOutputPlan::error(-32000, "Not allowed"); + } + 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 owner_target_id = command + .context + .target_id + .as_ref() + .map(|target_id| target_id.as_str().to_owned()) + .or_else(|| { + conn.non_browser_target_id_for_session( + command + .context + .session_id + .as_ref() + .map(|session_id| session_id.as_str()), + ) + }); + let wanted = command + .target_id + .as_ref() + .map(|target_id| target_id.as_str()) + .or(owner_target_id.as_deref()); + let Some(wanted) = wanted else { + return Ok(DevToolsGetTargetInfoResult { + target_info: super::browser_context::devtools_browser_target_info(), + }); + }; + if wanted == super::browser_context::DEVTOOLS_BROWSER_TARGET_ID { + return Ok(DevToolsGetTargetInfoResult { + target_info: super::browser_context::devtools_browser_target_info(), + }); + } + if conn.registered_browser_context_count() == 0 { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "BrowserContextNotLoaded", + )); + } + let target_infos = super::browser_context::current_devtools_target_infos(conn)?; + if target_infos.is_empty() { + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "TargetNotLoaded", + )); + } + if let Some(page_target_id) = conn.page_target_id_for_tab_target_id(wanted) + && target_infos.iter().any(|target_info| { + target_info + .target_id + .as_ref() + .is_some_and(|target_id| target_id.as_str() == page_target_id) + }) + && let Some(target_info) = conn.tab_target_info(wanted) + { + return Ok(DevToolsGetTargetInfoResult { target_info }); + } + let target_info = target_infos + .into_iter() + .find(|target_info| { + target_info + .target_id + .as_ref() + .is_some_and(|target_id| target_id.as_str() == 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, + flatten: Option, + 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_exact_target( + conn: &CdpConnection, + owner_session_id: Option<&str>, + target_id: &str, +) -> bool { + let attached_sessions = conn.attached_sessions_for_target(target_id); + conn.auto_attached_sessions_for_owner(owner_session_id) + .iter() + .any(|session_id| attached_sessions.contains(session_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 cmd + .session_id + .is_some_and(|session_id| conn.is_browser_session_id(Some(session_id))) + && params.flatten != Some(true) + { + return super::target_command_error( + -32602, + "Only flatten protocol is supported with browser level auto-attach", + ); + } + 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") + && super::browser_level_auto_attach_owner_session_allowed(conn, cmd.session_id); + 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 = conn + .capture_browser_top_level_target_snapshot() + .ok() + .and_then(|snapshot| { + snapshot + .contexts() + .iter() + .find(|context| context.is_selected()) + .cloned() + }); + let result = set_auto_attach_inner_async( + conn, + &mut side_effects, + auto_attach, + owner_session_id, + owner_was_enabled, + legacy_disable_all, + command_context, + ) + .await; + if let Some(restore_browser_context) = restore_browser_context.as_ref() + && conn.browser_context_target_snapshot_is_current(restore_browser_context) + { + restore_previously_active_browser_context( + conn, + Some(restore_browser_context.browser_context_id()), + ); + } + // 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(); + match result { + Ok(()) => plan.push_success(), + Err(error) => plan.extend(CommandOutputPlan::from_devtools_error(error)), + } + 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, +) -> Result<(), DevToolsError> { + if !auto_attach && !legacy_disable_all { + detach_auto_attached_sessions_for_owner_async(conn, out, owner_session_id, command_context) + .await; + return Ok(()); + } + if auto_attach + && 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 Ok(()); + } + + let browser_snapshot = conn.capture_browser_top_level_target_snapshot()?; + for context_snapshot in browser_snapshot.contexts() { + if !conn.browser_context_target_snapshot_is_current(context_snapshot) { + continue; + } + let context_id = context_snapshot.browser_context_id().to_owned(); + if !conn.activate_browser_context_by_id_async(&context_id).await + || !conn.browser_context_target_snapshot_is_current(context_snapshot) + { + continue; + } + if auto_attach { + 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") + && super::browser_level_auto_attach_owner_session_allowed(conn, owner_session_id); + // 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_targets = { + let bc = conn + .browser_context + .as_ref() + .expect("browser context must exist when attaching existing targets"); + if !attach_page_targets { + Vec::new() + } else { + context_snapshot + .targets() + .iter() + .filter(|snapshot| conn.browser_target_state_snapshot_is_current(snapshot)) + .filter(|snapshot| { + let target_has_primary_session = + if bc.active_target_id() == Some(snapshot.target_id()) { + bc.has_active_session() + } else { + bc.primary_session_id_for_target(snapshot.target_id()) + .is_some() + }; + should_auto_attach_page_target_for_owner( + conn, + owner_session_id, + snapshot.target_id(), + target_has_primary_session, + ) + }) + .cloned() + .collect() + } + }; + let pending_attach_tab_targets = { + if !attach_tab_targets { + Vec::new() + } else { + context_snapshot + .targets() + .iter() + .filter(|snapshot| conn.browser_target_state_snapshot_is_current(snapshot)) + .filter_map(|snapshot| { + let tab_target_id = + conn.tab_target_id_for_page_target_id(snapshot.target_id())?; + should_auto_attach_tab_target_for_owner( + conn, + owner_session_id, + tab_target_id, + ) + .then(|| (snapshot.clone(), tab_target_id.to_owned())) + }) + .collect() + } + }; + 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() + .filter(|target| { + !owner_already_auto_attached_to_exact_target( + conn, + owner_session_id, + &target.target_id, + ) + }) + .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() + .filter(|target| { + !owner_already_auto_attached_to_exact_target( + conn, + owner_session_id, + &target.target_id, + ) + }) + .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, + ) && !owner_already_auto_attached_to_exact_target( + conn, + owner_session_id, + &target.target_id, + ) + }) + .map(|target| target.target_id.clone()) + .collect::>() + } else { + Vec::new() + } + }; + let attached_targets = pending_attach_targets + .into_iter() + .map(|target_snapshot| (target_snapshot, conn.gen_session_id())) + .collect::>(); + let attached_tab_targets = pending_attach_tab_targets + .into_iter() + .map(|(target_snapshot, tab_target_id)| { + (target_snapshot, tab_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 = { + 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 { + let target_id = bc + .background_targets + .iter() + .rposition(|target| { + bc.primary_session_id_for_target(target.target_id()) + .is_none() + && target.has_loaded_page() + }) + .map(|index| bc.background_targets[index].target_id().to_owned()) + .or_else(|| { + bc.background_targets + .iter() + .find(|target| { + bc.primary_session_id_for_target(target.target_id()) + .is_none() + }) + .map(|target| target.target_id().to_owned()) + }); + target_id.and_then(|target_id| context_snapshot.target(&target_id).cloned()) + } + }; + let mut exact_attached_targets = Vec::with_capacity(attached_targets.len()); + for (target_snapshot, session_id) in attached_targets { + if conn.browser_target_state_snapshot_is_current(&target_snapshot) + && conn.prepare_auto_attached_page_session_binding( + target_snapshot.target_id(), + session_id.clone(), + ) + { + exact_attached_targets.push((target_snapshot, session_id)); + } + } + let attached_targets = exact_attached_targets; + { + 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" + ); + } + } + let mut exact_attached_tab_targets = Vec::with_capacity(attached_tab_targets.len()); + for (target_snapshot, tab_target_id, session_id) in attached_tab_targets { + if conn.browser_target_state_snapshot_is_current(&target_snapshot) + && conn.prepare_auto_attached_tab_session_binding( + &tab_target_id, + session_id.clone(), + owner_session_id, + ) + { + exact_attached_tab_targets.push((target_snapshot, tab_target_id, session_id)); + } + } + let attached_tab_targets = exact_attached_tab_targets; + if let Some(promote_target) = promote_target + && conn.browser_target_state_snapshot_is_current(&promote_target) + { + let promote_target_id = promote_target.target_id().to_owned(); + match conn + .promote_background_target_to_active_for_connection_async(&promote_target_id) + .await + { + Ok(Some(activation)) => { + out.extend_background_events(activation.into_protocol_events()); + } + Ok(None) => {} + Err(error) => { + tracing::warn!( + target_id = promote_target_id, + error = %error, + "same-context Target promotion was rejected during auto-attach" + ); + } + } + } + let attached_target_ids = attached_targets + .iter() + .map(|(target_snapshot, session_id)| { + (target_snapshot.target_id().to_owned(), session_id.clone()) + }) + .collect::>(); + ensure_initial_document_for_attached_page_targets_async(conn, &attached_target_ids) + .await; + for (target_snapshot, session_id) in &attached_targets { + if !conn.browser_target_state_snapshot_is_current(target_snapshot) { + continue; + } + 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_snapshot.target_id(), + session_id = session_id.as_str(), + "failed to apply renderer binding state during target auto-attach" + ); + } + } + for (target_snapshot, target_id, session_id) in attached_tab_targets { + let prepared_session = conn.prepare_auto_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + waiting_for_debugger, + ); + if !conn.browser_target_state_snapshot_is_current(&target_snapshot) { + conn.rollback_prepared_attach_session_without_event_async(&prepared_session) + .await; + continue; + } + let Some(ti) = conn.tab_target_info(&target_id) else { + conn.rollback_prepared_attach_session_without_event_async(&prepared_session) + .await; + continue; + }; + let event_plan = conn.commit_prepared_attach_event_plan(PreparedTargetAttach::new( + &target_id, + ti, + [prepared_session], + )); + out.extend_background_events(event_plan); + } + for (target_snapshot, session_id) in attached_targets { + let prepared_session = conn.prepare_auto_attach_session_commit( + session_id, + owner_session_id.map(str::to_owned), + waiting_for_debugger, + ); + if !conn.browser_target_state_snapshot_is_current(&target_snapshot) { + conn.rollback_prepared_attach_session_without_event_async(&prepared_session) + .await; + continue; + } + let target_id = target_snapshot.target_id().to_owned(); + let ti = match conn.project_top_level_target_snapshot(&target_snapshot) { + Ok(target_info) => target_info, + Err(error) => { + warn_target_protocol_side_effect_failure( + &target_id, + "project_attached_to_target", + &error.message, + ); + conn.rollback_prepared_attach_session_without_event_async( + &prepared_session, + ) + .await; + continue; + } + }; + 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, + [prepared_session], + )); + 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); + } + Ok(()) +} + +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 target_snapshot = match conn.capture_browser_top_level_target_snapshot() { + Ok(snapshot) => snapshot.target(&page_target_id).cloned(), + Err(error) => { + warn_target_protocol_side_effect_failure( + &page_target_id, + "capture_tab_child_auto_attach_snapshot", + &error.message, + ); + return; + } + }; + let Some(target_snapshot) = target_snapshot else { + return; + }; + if !conn + .activate_browser_context_by_id_async(target_snapshot.browser_context_id()) + .await + || !conn.browser_target_state_snapshot_is_current(&target_snapshot) + { + 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.primary_session_id_for_target(&page_target_id).is_some() + } + }; + 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, + ); + if !conn.browser_target_state_snapshot_is_current(&target_snapshot) { + conn.rollback_prepared_attach_session_without_event_async(&prepared_session) + .await; + return; + } + let target_info = match conn.project_top_level_target_snapshot(&target_snapshot) { + Ok(target_info) => target_info, + Err(error) => { + warn_target_protocol_side_effect_failure( + &page_target_id, + "project_tab_child_auto_attach_snapshot", + &error.message, + ); + 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(events) => { + let mut plan = CommandOutputPlan::default(); + plan.extend_background_events(events); + plan.push_success(); + plan + } + 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 target_id = conn + .page_target_id_for_tab_target_id(&target_id) + .map(str::to_owned) + .unwrap_or(target_id); + 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(Vec::new()); + } + if bc.has_dedicated_worker_target(&target_id) { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Ok(Vec::new()); + } + if bc.has_service_worker_target(&target_id) { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Ok(Vec::new()); + } + 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", + )); + } + let protocol_events = if !matches!( + bc.active_target_identity(), + Some((ref active_target_id, _)) if active_target_id == &target_id + ) && bc.background_target(&target_id).is_some() + { + let promoted = conn + .promote_background_target_to_active_for_connection_async(&target_id) + .await; + match promoted { + Ok(Some(activation)) => activation.into_protocol_events(), + Ok(None) => { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Err(DevToolsError::new( + DevToolsErrorKind::NoSuchTarget, + "UnknownTargetId", + )); + } + Err(error) => { + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + return Err(error.into()); + } + } + } 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", + )); + } else { + Vec::new() + }; + + restore_previously_active_browser_context( + conn, + previously_active_browser_context_id.as_deref(), + ); + Ok(protocol_events) +} + +#[cfg(test)] +mod protocol_neutral_tests { + use crate::devtools_runtime::DevToolsProtocol; + use moli_core::{ + PageId, RendererOutputCursor, RendererOutputFence, RendererOutputStreamIdentity, + browser_host::BrowserHostActor, + }; + use serde_json::Value; + + use crate::conn::{ + BrowserContext, BrowserTargetTerminationProjectionKind, CdpConnection, Cmd, + CommandDispatchContext, + }; + + use super::{ + ActivateTargetParams, CloseTargetParams, CreateTargetParams, + build_cdp_activate_target_command, build_cdp_close_target_command, + build_cdp_create_target_command, settle_target_close_after_pending_fetches, + }; + + #[test] + fn target_close_with_renderer_predecessor_rejects_without_browser_host() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let target_id = conn + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id) + .expect("default Target") + .to_owned(); + let termination = conn + .capture_browser_target_termination_for_target( + &target_id, + BrowserTargetTerminationProjectionKind::TargetClose, + ) + .expect("live Target should expose exact termination authority"); + let stream = + RendererOutputStreamIdentity::new_page_for_protocol_test(PageId::new_for_testing(61)); + let predecessor = + RendererOutputFence::new_for_test(RendererOutputCursor::new_for_test(stream, 7)); + let predecessor_cursor = predecessor.cursor(); + let mut command_context = CommandDispatchContext::default(); + + let error = settle_target_close_after_pending_fetches( + &mut conn, + &mut command_context, + Some(predecessor), + termination, + ) + .expect_err("Target.closeTarget requires a live Browser Host"); + + assert_eq!( + error.kind, + crate::devtools_runtime::DevToolsErrorKind::Internal + ); + assert!( + error + .message + .contains("BrowserHostTargetCloseAdmissionFailed") + ); + assert_eq!( + command_context + .take_renderer_output_predecessor() + .map(|predecessor| predecessor.cursor()), + Some(predecessor_cursor), + "Host rejection must not erase the terminal renderer fence" + ); + assert!(conn.take_scheduler_events().is_empty()); + assert_eq!( + conn.browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(target_id.as_str()), + "rejected Target.closeTarget admission must leave the Target live" + ); + } + + #[test] + fn target_close_with_renderer_predecessor_publishes_directly_to_browser_host() { + let mut conn = CdpConnection::default(); + conn.install_default_browser_target(); + let target_id = conn + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id) + .expect("default Target") + .to_owned(); + let termination = conn + .capture_browser_target_termination_for_target( + &target_id, + BrowserTargetTerminationProjectionKind::TargetClose, + ) + .expect("live Target should expose exact termination authority"); + let (browser_host, handle) = BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(handle); + let stream = + RendererOutputStreamIdentity::new_page_for_protocol_test(PageId::new_for_testing(62)); + let predecessor = + RendererOutputFence::new_for_test(RendererOutputCursor::new_for_test(stream, 8)); + let predecessor_cursor = predecessor.cursor(); + let mut command_context = CommandDispatchContext::default(); + + settle_target_close_after_pending_fetches( + &mut conn, + &mut command_context, + Some(predecessor), + termination, + ) + .expect("live Browser Host should accept Target.closeTarget"); + + assert_eq!( + command_context + .take_renderer_output_predecessor() + .map(|predecessor| predecessor.cursor()), + Some(predecessor_cursor) + ); + assert_eq!(browser_host.ready_len(), 1); + assert!(conn.take_scheduler_events().is_empty()); + assert_eq!( + conn.browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(target_id.as_str()), + "publishing the Host input cannot execute Target.closeTarget" + ); + } + + #[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()), + for_tab: None, + background: None, + focus: None, + }, + ) + .expect("valid createTarget parameters"); + + 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/popup.rs b/moli-protocol/src/domains/target/popup.rs deleted file mode 100644 index 4c7dd684cb..0000000000 --- a/moli-protocol/src/domains/target/popup.rs +++ /dev/null @@ -1,627 +0,0 @@ -use crate::conn::{ - PopupTargetActivationAction, PopupTargetNavigationKind, PopupTargetNavigationOwnerAction, - PreparedTargetAttach, -}; - -use super::creation::{ - push_target_created_events, top_level_page_auto_attach_owner_sessions, - top_level_tab_auto_attach_owner_sessions, -}; -use super::*; - -#[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, - disposition: moli_core::page::RendererPopupDisposition, - 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, - disposition: moli_core::page::RendererPopupDisposition, - session_storage_store: Option, - initial_empty_document_storage_key: Option, - ) -> Self { - Self { - browser_context_id, - popup_id, - url, - target_name, - opener, - can_access_opener, - disposition, - 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, - disposition, - 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 activation = (disposition == moli_core::page::RendererPopupDisposition::Foreground) - .then(|| { - PopupTargetActivationAction::capture(conn, &browser_context_id, &existing_target_id) - }) - .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); - } - if let Some(activation) = activation { - conn.publish_popup_target_activation_action(activation); - } - } - 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, - session_storage_store, - initial_empty_document_storage_key, - ); - 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(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 !conn.target_has_waiting_for_debugger_session(&target_id) - && let Some(navigation) = PopupTargetNavigationOwnerAction::capture( - conn, - &browser_context_id, - &target_id, - requested_url, - PopupTargetNavigationKind::InitialDocument, - ) - { - conn.publish_popup_target_navigation_owner_action(navigation); - } - if disposition == moli_core::page::RendererPopupDisposition::Foreground - && let Some(activation) = - PopupTargetActivationAction::capture(conn, &browser_context_id, &target_id) - { - conn.publish_popup_target_activation_action(activation); - } - 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.target_has_waiting_for_debugger_session(target_id) { - 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 !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; - }; - 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 (owner_scope, browser_context_id, target_id, url, kind) = action.into_parts(); - let mut route_scope = owner_scope.enter(conn); - let conn = route_scope.conn_mut(); - let target_is_current = conn.target_owner_identity_for_session(None).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()) - }, - ); - if !target_is_current || !popup_target_has_loaded_page(conn, &browser_context_id, &target_id) { - tracing::debug!( - browser_context_id, - target_id, - url, - ?kind, - "dropping popup navigation after its exact target owner retired" - ); - return crate::conn::CdpTurnOutcome::new_with_protocol_events( - Vec::new(), - conn.take_scheduler_events(), - ); - } - - let mut protocol_events = Vec::new(); - match kind { - PopupTargetNavigationKind::InitialDocument => { - if !conn.runtime_session_owner_should_start_initial_document_navigation(None) { - return crate::conn::CdpTurnOutcome::new_with_protocol_events( - Vec::new(), - conn.take_scheduler_events(), - ); - } - } - PopupTargetNavigationKind::NamedTargetReuse => {} - } - crate::domains::page::navigate_session_owner_from_renderer_background_events_async( - conn, - &mut protocol_events, - None, - &url, - ) - .await; - if kind == PopupTargetNavigationKind::InitialDocument { - emit_target_info_changed_for_target_background_event( - conn, - &mut protocol_events, - &browser_context_id, - &target_id, - ); - } - crate::conn::CdpTurnOutcome::new_with_protocol_events( - protocol_events, - conn.take_scheduler_events(), - ) -} - -pub(crate) async fn complete_popup_target_activation_action_async( - conn: &mut CdpConnection, - action: PopupTargetActivationAction, -) -> crate::conn::CdpTurnOutcome { - let (owner_scope, browser_context_id, target_id) = action.into_parts(); - let target_is_current = { - let mut route_scope = owner_scope.enter(conn); - let conn = route_scope.conn_mut(); - conn.target_owner_identity_for_session(None).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()) - }, - ) && popup_target_has_loaded_page(conn, &browser_context_id, &target_id) - }; - if !target_is_current { - tracing::debug!( - browser_context_id, - target_id, - "dropping popup activation after its exact target owner retired" - ); - return crate::conn::CdpTurnOutcome::new_with_protocol_events( - Vec::new(), - conn.take_scheduler_events(), - ); - } - let protocol_events = - match activate_popup_target_async(conn, &browser_context_id, &target_id).await { - Ok(events) => events, - Err(error) => { - tracing::debug!( - browser_context_id, - target_id, - %error, - "popup target could not be activated" - ); - Vec::new() - } - }; - crate::conn::CdpTurnOutcome::new_with_protocol_events( - protocol_events, - conn.take_scheduler_events(), - ) -} - -async fn activate_popup_target_async( - conn: &mut CdpConnection, - browser_context_id: &str, - target_id: &str, -) -> Result, String> { - let restore_browser_context_id = previously_active_browser_context_id(conn); - let result = if let Err(message) = select_browser_context_for_target(conn, target_id) { - Err(message.to_owned()) - } else if conn - .browser_context - .as_ref() - .is_none_or(|browser_context| browser_context.id != browser_context_id) - { - Err("PopupTargetBrowserContextChanged".to_owned()) - } else if conn - .browser_context - .as_ref() - .is_some_and(|browser_context| browser_context.is_active_target(target_id)) - { - Ok(Vec::new()) - } else { - match conn - .promote_background_target_to_active_for_connection_async(target_id) - .await - { - Ok(Some(activation)) => Ok(activation.into_protocol_events()), - Ok(None) => Err("PopupTargetUnavailable".to_owned()), - Err(message) => Err(message), - } - }; - restore_previously_active_browser_context(conn, restore_browser_context_id.as_deref()); - result -} - -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), - ); -} diff --git a/moli-protocol/src/domains/target/protocol_neutral_tests.rs b/moli-protocol/src/domains/target/protocol_neutral_tests.rs deleted file mode 100644 index a2d577d3e0..0000000000 --- a/moli-protocol/src/domains/target/protocol_neutral_tests.rs +++ /dev/null @@ -1,115 +0,0 @@ -use crate::devtools_runtime::DevToolsProtocol; -use serde_json::Value; - -use crate::conn::Cmd; - -use super::activation::{ActivateTargetParams, build_cdp_activate_target_command}; -use super::closing::{CloseTargetParams, build_cdp_close_target_command}; -use super::creation::{CreateTargetParams, 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()), - for_tab: None, - background: None, - focus: None, - }, - ) - .expect("valid target disposition"); - - 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/test_support.rs b/moli-protocol/src/domains/target/tests/test_support.rs index 31f8677633..89b417f08f 100644 --- a/moli-protocol/src/domains/target/tests/test_support.rs +++ b/moli-protocol/src/domains/target/tests/test_support.rs @@ -5,13 +5,35 @@ use moli_shared_worker::SharedWorkerInstanceId; // Helper: quickly set up a browser context with a given id, optional // target_id, optional session_id, and optional url. pub(super) fn load_bc(ctx: &mut TestContext, bc_id: &str) { - ctx.conn.browser_context = Some(BrowserContext::new(bc_id.into())); + ctx.conn + .insert_browser_context(BrowserContext::new(bc_id.into())); } pub(super) fn load_bc_with_target(ctx: &mut TestContext, bc_id: &str, target_id: &str) { let mut bc = BrowserContext::new(bc_id.into()); bc.set_active_target_id(target_id); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); +} + +/// Installs a fixture Target that genuinely owns a pending initial empty +/// Document. Tests that expect attachment to materialize a Page must use this +/// instead of synthesizing only a target id. +pub(super) fn load_bc_with_pending_initial_target( + ctx: &mut TestContext, + bc_id: &str, + target_id: &str, +) { + let mut bc = BrowserContext::new(bc_id.into()); + bc.set_active_target_id(target_id); + bc.set_target_url("about:blank".to_owned()); + bc.mark_active_initial_document_page_build_pending(); + ctx.conn.insert_browser_context(bc); + ctx.conn + .register_target_initial_empty_document_for_test( + &moli_core::browser_host::BrowserPageOwnerKey::new(bc_id, target_id), + moli_core::browser_host::BrowserInitialEmptyDocumentSeed::new("about:blank"), + ) + .expect("registered Target should accept test metadata"); } pub(super) fn tab_id_for_page(ctx: &TestContext, page_target_id: &str) -> String { @@ -27,28 +49,62 @@ pub(super) fn push_background_target( url: &str, session_id: Option<&str>, ) { - let bc = ctx + let primary_session_id = session_id.map(str::to_owned); + let browser_context_id = ctx .conn .browser_context - .as_mut() - .expect("browser context must exist before adding background target"); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - target_id.to_owned(), - session_id.map(str::to_owned), - crate::conn::TargetIdentityState::new( - url.to_owned(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); + .as_ref() + .expect("browser context must exist before adding background target") + .id + .clone(); + let target = crate::conn::BackgroundTarget::new_with_frontend_session( + target_id.to_owned(), + None, + crate::conn::TargetIdentityState::new( + url.to_owned(), + crate::conn::URL_BASE.into(), + "Secure".into(), + ), + crate::conn::TargetPageSlot::empty_for_test_fixture(), + ); + ctx.conn + .register_background_target_projection( + &browser_context_id, + target_id, + move |browser_context, target_handle, page_residence, session_storage_access| { + let mut target = target; + target.replace_target_handle(target_handle.clone()); + target.replace_page_residence_handle(page_residence); + target.bind_session_storage_access(session_storage_access); + browser_context + .register_top_level_target_attachment(target_handle, primary_session_id); + browser_context.background_targets.push(target); + }, + ) + .expect("background Target projection should register"); +} + +pub(super) async fn install_navigation_for_target_without_session( + ctx: &mut TestContext, + target_id: &str, + url: &str, +) { + let route = ctx + .conn + .target_session_route_for_target_id(target_id) + .expect("fixture Target route"); + let previous = ctx + .conn + .replace_none_session_owner_route_override(Some(route)); + ctx.install_navigation_fixture_for_session_owner(url, None) + .await; + ctx.conn.replace_none_session_owner_route_override(previous); } -pub(super) fn loaded_page_for_target<'a>( - browser_context: &'a BrowserContext, +pub(super) fn loaded_page_for_target( + browser_context: &BrowserContext, target_id: &str, -) -> Option<&'a moli_core::page::Page> { +) -> Option { if browser_context.is_active_target(target_id) { browser_context.loaded_page() } else { @@ -160,7 +216,7 @@ pub(super) async fn load_bc_with_titled_page_async( .conn .browser_context .as_mut() - .expect("loaded Target fixture must retain its BrowserContext owner"); + .expect("loaded Target fixture BrowserContext must remain selected"); bc.set_target_url(url); let _ = bc .active_target @@ -173,10 +229,10 @@ pub(super) async fn load_bc_with_titled_page_async( // replaced this initial fixture. let page_owner = ctx .conn - .target_page_residence_identity_for_session(None) - .expect("loaded Target fixture must have an exact Page owner"); + .target_page_owner_key_for_session(None) + .expect("loaded Target fixture must have an exact target owner"); ctx.conn - .bind_renderer_page_output_owner(renderer_page, page_owner); + .bind_renderer_page_output_target(renderer_page, page_owner); } pub(super) fn enable_root_target_discovery_for_test(ctx: &mut TestContext) { diff --git a/moli-protocol/src/domains/target/tests/tests_background_auto_attach.rs b/moli-protocol/src/domains/target/tests/tests_background_auto_attach.rs index bf0cceac80..bffc36289f 100644 --- a/moli-protocol/src/domains/target/tests/tests_background_auto_attach.rs +++ b/moli-protocol/src/domains/target/tests/tests_background_auto_attach.rs @@ -15,16 +15,27 @@ async fn set_auto_attach_true_ensures_existing_background_initial_document_befor .as_mut() .unwrap() .attach_active_session("SID-active"); - { - let bc = ctx.conn.browser_context.as_mut().expect("browser context"); - bc.stage_background_target( - "TID-background-pending".to_owned(), - None, - "about:blank#background".to_owned(), - None, - None, - ); - } + ctx.conn + .register_background_target_with_creation_metadata_projection( + "BID-9", + "TID-background-pending", + moli_core::browser_host::BrowserTargetCreationMetadata::with_initial_empty_document( + moli_core::browser_host::BrowserInitialEmptyDocumentSeed::new( + "about:blank#background", + ), + ), + |bc, target_handle, page_residence, session_storage_access| { + bc.stage_background_target_with_browser_handles( + target_handle, + page_residence, + session_storage_access, + None, + "about:blank#background".to_owned(), + None, + ); + }, + ) + .expect("background Target projection should register"); assert_eq!( ctx.conn .browser_context @@ -100,20 +111,12 @@ async fn set_auto_attach_true_still_reports_transient_no_page_target_like_chromi .as_mut() .unwrap() .attach_active_session("SID-active"); - { - let bc = ctx.conn.browser_context.as_mut().expect("browser context"); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-background-in-transit".to_owned(), - None, - crate::conn::TargetIdentityState::new( - "https://example.test/in-transit".to_owned(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); - } + push_background_target( + &mut ctx, + "TID-background-in-transit", + "https://example.test/in-transit", + None, + ); ctx.process_async(json!({ "id": 10332, @@ -198,8 +201,7 @@ async fn runtime_add_binding_on_auto_attached_background_target_session_routes_w assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert_eq!(bc.active_session_id(), Some("SID-active")); assert_eq!( - bc.background_target(&second_target_id) - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target(&second_target_id), Some(session_id.as_str()) ); assert!( 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..8d7f8c96e1 100644 --- a/moli-protocol/src/domains/target/tests/tests_background_staging.rs +++ b/moli-protocol/src/domains/target/tests/tests_background_staging.rs @@ -156,9 +156,8 @@ async fn same_context_background_session_can_stage_its_own_pre_document_state_be .background_target(&second_target_id) .expect("staged background target"); let staged_devtools_state = active - .parked_page_session_state(staged.target_id()) - .expect("staged page session state") - .devtools_session_state + .primary_devtools_session_state_for_target(staged.target_id()) + .expect("staged Target frontend state") .runtime_bindings .as_slice(); assert_eq!(staged_devtools_state.len(), 1); @@ -178,7 +177,7 @@ async fn same_context_background_session_can_stage_its_own_pre_document_state_be script_id ); assert!( - active.devtools_session_state.runtime_bindings.is_empty(), + active.devtools_session_state().runtime_bindings.is_empty(), "active target DevTools session must not inherit staged target binding" ); assert!( @@ -354,9 +353,8 @@ async fn same_context_background_session_can_stage_its_own_utility_pre_document_ .background_target(&second_target_id) .expect("staged background target"); let staged_devtools_state = active - .parked_page_session_state(staged.target_id()) - .expect("staged page session state") - .devtools_session_state + .primary_devtools_session_state_for_target(staged.target_id()) + .expect("staged Target frontend state") .runtime_bindings .as_slice(); assert_eq!(staged_devtools_state.len(), 1); @@ -383,7 +381,7 @@ async fn same_context_background_session_can_stage_its_own_utility_pre_document_ script_id ); assert!( - active.devtools_session_state.runtime_bindings.is_empty(), + active.devtools_session_state().runtime_bindings.is_empty(), "active target DevTools session must not inherit staged target binding" ); assert!( @@ -608,8 +606,8 @@ async fn same_context_background_session_can_remove_its_own_binding_before_promo .background_target(&second_target_id) .expect("staged background target"); let staged_bindings_empty = active - .parked_page_session_state(staged.target_id()) - .is_none_or(|state| state.devtools_session_state.runtime_bindings.is_empty()); + .primary_devtools_session_state_for_target(staged.target_id()) + .is_none_or(|state| state.runtime_bindings.is_empty()); assert!( staged_bindings_empty, "removed binding should be cleared from parked DevTools session" @@ -766,9 +764,8 @@ async fn same_context_background_session_can_remove_its_own_preload_before_promo .background_target(&second_target_id) .expect("staged background target"); let staged_bindings = &active - .parked_page_session_state(staged.target_id()) - .expect("staged page session state") - .devtools_session_state + .primary_devtools_session_state_for_target(staged.target_id()) + .expect("staged Target frontend state") .runtime_bindings; assert_eq!(staged_bindings.len(), 1); assert!( @@ -918,8 +915,8 @@ async fn same_context_background_session_can_remove_its_own_utility_binding_befo .background_target(&second_target_id) .expect("staged background target"); let staged_bindings_empty = active - .parked_page_session_state(staged.target_id()) - .is_none_or(|state| state.devtools_session_state.runtime_bindings.is_empty()); + .primary_devtools_session_state_for_target(staged.target_id()) + .is_none_or(|state| state.runtime_bindings.is_empty()); assert!( staged_bindings_empty, "removed utility binding should be cleared from parked DevTools session" @@ -1101,9 +1098,8 @@ async fn same_context_background_session_can_remove_its_own_utility_preload_befo .background_target(&second_target_id) .expect("staged background target"); let staged_bindings = &active - .parked_page_session_state(staged.target_id()) - .expect("staged page session state") - .devtools_session_state + .primary_devtools_session_state_for_target(staged.target_id()) + .expect("staged Target frontend state") .runtime_bindings; assert_eq!(staged_bindings.len(), 1); assert_eq!( @@ -3487,33 +3483,19 @@ async fn same_context_background_session_can_stage_its_own_page_settings_before_ .as_ref() .expect("active browser context"); let staged = active - .parked_page_session_state(&second_target_id) - .expect("staged page settings for background target"); - assert!( - staged - .devtools_session_state - .page_session_state - .page_bypass_csp_enabled - ); + .primary_devtools_session_state_for_target(&second_target_id) + .expect("staged frontend settings for background target"); + assert!(staged.page_session_state.page_bypass_csp_enabled); assert_eq!( - staged - .devtools_session_state - .page_session_state - .page_font_families - .get("standard"), + staged.page_session_state.page_font_families.get("standard"), Some(&json!("Georgia")) ); assert_eq!( - staged - .devtools_session_state - .page_session_state - .page_font_families - .get("fixed"), + staged.page_session_state.page_font_families.get("fixed"), Some(&json!("Fira Code")) ); assert!( staged - .devtools_session_state .page_session_state .page_intercept_file_chooser_dialog_enabled ); @@ -3535,13 +3517,13 @@ async fn same_context_background_session_can_stage_its_own_page_settings_before_ assert_eq!(active.active_target_id(), Some(second_target_id.as_str())); assert!( active - .devtools_session_state + .devtools_session_state() .page_session_state .page_bypass_csp_enabled ); assert_eq!( active - .devtools_session_state + .devtools_session_state() .page_session_state .page_font_families .get("standard"), @@ -3549,7 +3531,7 @@ async fn same_context_background_session_can_stage_its_own_page_settings_before_ ); assert_eq!( active - .devtools_session_state + .devtools_session_state() .page_session_state .page_font_families .get("fixed"), @@ -3557,7 +3539,7 @@ async fn same_context_background_session_can_stage_its_own_page_settings_before_ ); assert!( active - .devtools_session_state + .devtools_session_state() .page_session_state .page_intercept_file_chooser_dialog_enabled ); @@ -4300,19 +4282,14 @@ async fn same_context_background_session_can_stage_its_own_lifecycle_events_befo .expect("active browser context"); assert!( !active - .devtools_session_state + .devtools_session_state() .page_session_state .page_lifecycle_events ); let staged = active - .parked_page_session_state(&second_target_id) - .expect("second target should have staged parked page session state"); - assert!( - staged - .devtools_session_state - .page_session_state - .page_lifecycle_events - ); + .primary_devtools_session_state_for_target(&second_target_id) + .expect("second target should have staged frontend state"); + assert!(staged.page_session_state.page_lifecycle_events); } ctx.process_async(json!({ @@ -4478,7 +4455,7 @@ async fn same_context_background_session_can_disable_its_own_lifecycle_events_be .expect("active browser context"); assert!( !active - .devtools_session_state + .devtools_session_state() .page_session_state .page_lifecycle_events ); @@ -4616,19 +4593,14 @@ async fn same_context_background_session_can_stage_its_own_runtime_enable_before .expect("active browser context"); assert!( !active - .devtools_session_state + .devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); let staged = active - .parked_page_session_state(&second_target_id) - .expect("second target should have staged parked page session state"); - assert!( - staged - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled - ); + .primary_devtools_session_state_for_target(&second_target_id) + .expect("second target should have staged frontend state"); + assert!(staged.runtime_session_state.runtime_frontend_enabled); } ctx.process_async(json!({ @@ -4843,11 +4815,8 @@ async fn same_context_loaded_background_session_runtime_enable_replays_context_w "direct Runtime.enable should not promote the loaded background target" ); assert!( - bc.parked_page_session_state(&second_target_id) - .is_some_and(|state| state - .devtools_session_state - .runtime_session_state - .runtime_frontend_enabled), + bc.primary_devtools_session_state_for_target(&second_target_id) + .is_some_and(|state| state.runtime_session_state.runtime_frontend_enabled), "Runtime.enable should be staged on the background target owner" ); assert!( @@ -5118,6 +5087,10 @@ async fn same_context_named_popup_reuse_navigates_and_promotes_loaded_owner() { let response = take_response_by_id(&mut ctx, 1041949446); assert_eq!(response["result"]["result"]["type"], json!("boolean")); assert_eq!(response["result"]["result"]["value"], json!(true)); + ctx.wait_for_only_background_navigation_gate_to_settle( + "named background popup navigation disposal and replay", + ) + .await; ctx.wait_until_scheduler_state( "named popup navigation commit and foreground activation", |conn| { @@ -5544,7 +5517,7 @@ async fn same_context_background_session_can_disable_its_own_runtime_before_prom .expect("active browser context"); assert!( !active - .devtools_session_state + .devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); @@ -5692,19 +5665,14 @@ async fn same_context_background_session_can_stage_its_own_inspector_enable_befo ); assert!( !active - .devtools_session_state + .devtools_session_state() .runtime_session_state .inspector_enabled ); let staged = active - .parked_page_session_state(&second_target_id) - .expect("second target should have staged parked page session state"); - assert!( - staged - .devtools_session_state - .runtime_session_state - .inspector_enabled - ); + .primary_devtools_session_state_for_target(&second_target_id) + .expect("second target should have staged frontend state"); + assert!(staged.runtime_session_state.inspector_enabled); } assert!( !ctx.sent.iter().any(|message| { @@ -5835,7 +5803,7 @@ async fn same_context_background_session_can_disable_its_own_inspector_before_pr .expect("active browser context"); assert!( !active - .devtools_session_state + .devtools_session_state() .runtime_session_state .inspector_enabled ); diff --git a/moli-protocol/src/domains/target/tests/tests_browser_context.rs b/moli-protocol/src/domains/target/tests/tests_browser_context.rs index a13e4c7450..5c50ad6ab6 100644 --- a/moli-protocol/src/domains/target/tests/tests_browser_context.rs +++ b/moli-protocol/src/domains/target/tests/tests_browser_context.rs @@ -89,20 +89,29 @@ async fn dispose_browser_context_wrong_id() { async fn dispose_browser_context_success() { let mut ctx = TestContext::new(); load_bc(&mut ctx, "BID-20"); - ctx.conn.download_behavior.set_browser_context( - "BID-20".into(), - "allow".into(), - Some("/tmp/downloads".into()), - true, + ctx.conn.apply_browser_download_policy_update( + moli_core::browser_host::BrowserDownloadPolicyUpdate::SetBrowserContext { + browser_context_id: "BID-20".into(), + behavior: moli_core::browser_host::BrowserDownloadBehavior::Allow, + download_path: Some("/tmp/downloads".into()), + }, ); + ctx.conn + .set_automation_download_events_enabled_for_browser_context(Some("BID-20"), true); ctx.process_async(json!({"id": 9, "method": "Target.disposeBrowserContext", "params": {"browserContextId": "BID-20"}})) .await; ctx.expect_result(9, json!({}), None); assert!(ctx.conn.browser_context.is_none()); - assert_eq!( - ctx.conn.download_behavior, - crate::conn::BrowserDownloadBehavior::default() + assert!( + ctx.conn + .browser_download_policy_snapshot() + .browser_context_override("BID-20") + .is_none() + ); + assert!( + !ctx.conn + .automation_download_events_enabled_for_browser_context(Some("BID-20")) ); } @@ -112,10 +121,10 @@ async fn dispose_browser_context_emits_detached_events_for_attached_target() { let mut bc = BrowserContext::new("BID-20".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-000000000A"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({"id": 10, "method": "Target.disposeBrowserContext", "params": {"browserContextId": "BID-20"}})) @@ -784,13 +793,13 @@ async fn dispose_browser_context_aborts_paused_request_stage_navigation() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 20, @@ -869,7 +878,7 @@ async fn dispose_browser_context_aborts_root_session_navigation_without_target_s bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 23, @@ -957,7 +966,7 @@ async fn dispose_browser_context_aborts_paused_runtime_fetch_subresource() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.conn diff --git a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/browser_target.rs b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/browser_target.rs index 0374a139bc..d6c0eb8914 100644 --- a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/browser_target.rs +++ b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/browser_target.rs @@ -108,14 +108,18 @@ async fn rust_cdp_capability_browser_download_behavior_contract() { })) .await; ctx.expect_result(102_005, json!({}), None); - assert_eq!(ctx.conn.download_behavior.behavior, "allowAndName"); + let policy = ctx.conn.browser_download_policy_snapshot(); + assert_eq!(policy.global().behavior().as_str(), "allowAndName"); assert_eq!( - ctx.conn.download_behavior.download_path.as_deref(), + policy.global().download_path(), Some("/tmp/moli-chromium-import-downloads") ); - assert!(!ctx.conn.download_behavior.automation_events_enabled); + assert!( + !ctx.conn + .automation_download_events_enabled_for_browser_context(None) + ); assert_eq!( - ctx.conn.download_behavior.browser_event_session_ids(), + ctx.conn.browser_download_event_session_ids_for_test(), vec![None] ); } @@ -228,7 +232,10 @@ async fn rust_cdp_puppeteer_browser_target_session_contract() { .await; ctx.expect_result(102_104, json!({}), Some(&browser_session_id)); assert_eq!( - ctx.conn.download_behavior.download_path.as_deref(), + ctx.conn + .browser_download_policy_snapshot() + .global() + .download_path(), Some("/tmp/moli-puppeteer-downloads") ); } diff --git a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/p1_target_contexts.rs b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/p1_target_contexts.rs index f7be678806..e213b4cb04 100644 --- a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/p1_target_contexts.rs +++ b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/p1_target_contexts.rs @@ -282,12 +282,18 @@ async fn rust_cdp_chromium_target_dispose_active_context_promotes_remaining_cont async fn rust_cdp_chromium_target_dispose_context_clears_context_scoped_download_behavior() { let mut ctx = TestContext::new_with_target_discovery(false); let browser_context_id = create_browser_context(&mut ctx, 261_016).await; - ctx.conn.download_behavior.set_browser_context( - browser_context_id.clone(), - "allow".into(), - Some("/tmp/moli-target-contexts".into()), - true, + ctx.conn.apply_browser_download_policy_update( + moli_core::browser_host::BrowserDownloadPolicyUpdate::SetBrowserContext { + browser_context_id: browser_context_id.clone(), + behavior: moli_core::browser_host::BrowserDownloadBehavior::Allow, + download_path: Some("/tmp/moli-target-contexts".into()), + }, ); + ctx.conn + .set_automation_download_events_enabled_for_browser_context( + Some(&browser_context_id), + true, + ); ctx.process_async(json!({ "id": 261_017, @@ -297,9 +303,15 @@ async fn rust_cdp_chromium_target_dispose_context_clears_context_scoped_download .await; ctx.expect_result(261_017, json!({}), None); - assert_eq!( - ctx.conn.download_behavior, - crate::conn::BrowserDownloadBehavior::default() + assert!( + ctx.conn + .browser_download_policy_snapshot() + .browser_context_override(&browser_context_id) + .is_none() + ); + assert!( + !ctx.conn + .automation_download_events_enabled_for_browser_context(Some(&browser_context_id)) ); } @@ -614,9 +626,12 @@ async fn rust_cdp_chromium_target_named_popup_reuse_keeps_browser_context_id() { } })) .await; - let messages = ctx.take_all(); - - let changed = event(&messages, "Target.targetInfoChanged"); + let changed = ctx + .wait_for_scheduler_message("named popup successor targetInfoChanged", |message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["url"] == json!("https://example.com/second") + }) + .await; assert_eq!( changed["params"]["targetInfo"]["browserContextId"], "BID-named-context" 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..5447ae333f 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 @@ -719,6 +719,7 @@ async fn rust_cdp_chromium_target_window_open_auto_attached_popup_materializes_i }) .expect("window.open lifecycle should have loaded the popup document"); assert_eq!(popup_page.final_url().as_str(), popup_url); + drop(popup_page); ctx.process_async(json!({ "id": 260_212, @@ -853,24 +854,31 @@ async fn rust_cdp_chromium_target_window_open_waiting_popup_routes_initial_docum ctx.expect_result(260_218, json!({}), Some(popup_session_id)); ctx.sent.clear(); - ctx.process_async(json!({ - "id": 260_219, - "method": "Runtime.runIfWaitingForDebugger", - "sessionId": popup_session_id - })) - .await; + let scheduler_events = ctx + .process_command_only_async(json!({ + "id": 260_219, + "method": "Runtime.runIfWaitingForDebugger", + "sessionId": popup_session_id + })) + .await; + assert!( + scheduler_events.is_empty(), + "debugger resume must not publish Protocol navigation work: {scheduler_events:?}" + ); take_response_by_id(&mut ctx, 260_219); + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + assert!( + ctx.sent + .iter() + .all(|message| message["method"] != json!("Fetch.requestPaused")), + "frontend completion must not execute the initial Target navigation" + ); let paused = ctx - .sent - .iter() - .find(|message| message["method"] == json!("Fetch.requestPaused")) - .cloned() - .unwrap_or_else(|| { - panic!( - "Runtime.runIfWaitingForDebugger should start the popup document navigation: {:?}", - ctx.sent - ) - }); + .wait_for_scheduler_message( + "Runtime.runIfWaitingForDebugger initial Target Fetch pause", + |message| message["method"] == json!("Fetch.requestPaused"), + ) + .await; assert!( ctx.sent.iter().any(|message| { message["method"] == json!("Network.requestWillBeSent") @@ -1072,6 +1080,14 @@ async fn rust_cdp_chromium_target_window_open_self_does_not_create_popup_target( "window.open('data:text/html,
self
', '_self') === null", ) .await; + ctx.wait_until_scheduler_state("window.open _self Browser Owner navigation", |conn| { + conn.browser_context + .as_ref() + .is_some_and(|browser_context| { + browser_context.target_url() == "data:text/html,
self
" + }) + }) + .await; assert!( !messages @@ -1109,12 +1125,24 @@ async fn rust_cdp_chromium_target_window_open_named_target_reuses_existing_targe .expect("named target id") .to_owned(); - let second = open_popup_from_runtime( + let mut second = open_popup_from_runtime( &mut ctx, 260_027, "window.open('https://example.com/two', 'named') !== null", ) .await; + if !second.iter().any(|message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["url"] == json!("https://example.com/two") + }) { + second.push( + ctx.wait_for_scheduler_message("named popup successor targetInfoChanged", |message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["url"] == json!("https://example.com/two") + }) + .await, + ); + } assert!( !second @@ -1122,7 +1150,13 @@ async fn rust_cdp_chromium_target_window_open_named_target_reuses_existing_targe .any(|message| message["method"] == "Target.targetCreated"), "{second:?}" ); - let changed = event(&second, "Target.targetInfoChanged"); + let changed = second + .iter() + .find(|message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["url"] == json!("https://example.com/two") + }) + .expect("named popup successor targetInfoChanged"); assert_eq!(changed["params"]["targetInfo"]["targetId"], target_id); assert_eq!( changed["params"]["targetInfo"]["url"], @@ -1149,17 +1183,29 @@ async fn rust_cdp_chromium_target_info_changed_is_emitted_for_named_popup_reuse( "window.open('https://example.com/first', 'reuse') !== null", ) .await; - let second = open_popup_from_runtime( + let mut second = open_popup_from_runtime( &mut ctx, 260_029, "window.open('https://example.com/second', 'reuse') !== null", ) .await; + if !second.iter().any(|message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["url"] == json!("https://example.com/second") + }) { + second.push( + ctx.wait_for_scheduler_message("reused popup successor targetInfoChanged", |message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["url"] == json!("https://example.com/second") + }) + .await, + ); + } - assert_eq!( - event(&second, "Target.targetInfoChanged")["params"]["targetInfo"]["url"], - "https://example.com/second" - ); + assert!(second.iter().any(|message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["url"] == json!("https://example.com/second") + })); } // Chromium source: @@ -1186,6 +1232,21 @@ async fn rust_cdp_chromium_target_resetting_opener_clears_popup_opener_reference .expect("popup target id") .to_owned(); + ctx.wait_until_scheduler_state("foreground popup activation before opener reset", |conn| { + conn.browser_context_by_id("BID-popup-reset") + .is_some_and(|browser_context| { + browser_context.active_target_id() == Some(target_id.as_str()) + }) + }) + .await; + assert!( + ctx.conn + .promote_background_target_to_active_for_connection_async("TID-reset-opener") + .await + .expect("opener reactivation should succeed") + .is_some() + ); + ctx.conn .browser_context .as_mut() diff --git a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/page_dom.rs b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/page_dom.rs index cef22a130c..ec5934e13d 100644 --- a/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/page_dom.rs +++ b/moli-protocol/src/domains/target/tests/tests_cdp_chromium_imports/page_dom.rs @@ -193,16 +193,18 @@ async fn rust_cdp_capability_page_set_download_behavior_alias_contract() { .expect("attached page target should have a browser context"); let settings = ctx .conn - .download_behavior - .effective_for_browser_context(Some(browser_context_id.as_str())); - assert_eq!(settings.behavior, "allow"); - assert_eq!( - settings.download_path.as_deref(), - Some("/tmp/moli-page-downloads") + .effective_browser_download_policy(Some(browser_context_id.as_str())); + assert_eq!(settings.behavior().as_str(), "allow"); + assert_eq!(settings.download_path(), Some("/tmp/moli-page-downloads")); + assert!( + !ctx.conn + .automation_download_events_enabled_for_browser_context(Some( + browser_context_id.as_str() + )) ); - assert!(!settings.automation_events_enabled); assert!( - !ctx.conn.download_behavior.automation_events_enabled, + !ctx.conn + .automation_download_events_enabled_for_browser_context(None), "Page.setDownloadBehavior must not enable Browser download events" ); } diff --git a/moli-protocol/src/domains/target/tests/tests_control_plane.rs b/moli-protocol/src/domains/target/tests/tests_control_plane.rs index 5581eccb49..3eb962dfd9 100644 --- a/moli-protocol/src/domains/target/tests/tests_control_plane.rs +++ b/moli-protocol/src/domains/target/tests/tests_control_plane.rs @@ -51,8 +51,7 @@ async fn activate_target_promotes_background_target_when_active_target_has_no_lo assert_eq!(bc.active_target_id(), Some(second_target_id.as_str())); assert_eq!(bc.target_url(), "about:blank#second"); assert_eq!( - bc.background_target("TID-000000000A") - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target("TID-000000000A"), Some("SID-active") ); } @@ -639,7 +638,7 @@ async fn get_target_info_for_inactive_target_keeps_previously_active_context() { load_bc_with_target(&mut ctx, "BID-A", "TID-A"); let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B"); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); ctx.process_async(json!({ "id": 111, @@ -670,24 +669,33 @@ async fn get_target_info_for_inactive_target_keeps_previously_active_context() { } #[tokio::test(flavor = "multi_thread")] -async fn send_message_to_target_error_restores_previously_active_context() { +async fn send_message_to_target_nested_error_restores_previously_active_context() { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-A", "TID-A"); let mut inactive = BrowserContext::new("BID-B".into()); + inactive.set_active_target_id("TID-B"); inactive.attach_active_session("SID-B"); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); ctx.process_async(json!({ "id": 1501, "method": "Target.sendMessageToTarget", "params": { - "message": "{\"id\":1,\"method\":\"Target.getTargetInfo\"}", + "message": "{\"id\":1,\"method\":\"Runtime.evaluate\",\"params\":{\"expression\":\"1\"}}", "sessionId": "SID-B" } })) .await; - ctx.expect_error(1501, -31998, "TargetNotLoaded"); + ctx.expect_result(1501, json!({}), None); + let event = ctx.take_one(); + assert_eq!(event["method"], "Target.receivedMessageFromTarget"); + assert_eq!(event["params"]["sessionId"], "SID-B"); + let nested: serde_json::Value = + serde_json::from_str(event["params"]["message"].as_str().expect("nested message")) + .expect("nested error should be valid JSON"); + assert_eq!(nested["error"]["code"], json!(-32000)); + assert_eq!(nested["error"]["message"], json!("NoDocumentLoaded")); assert_eq!( ctx.conn.browser_context.as_ref().map(|bc| bc.id.as_str()), Some("BID-A"), @@ -702,7 +710,7 @@ async fn detach_from_target_error_restores_previously_active_context() { let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B"); inactive.attach_active_session("SID-B"); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); ctx.process_async(json!({ "id": 1502, @@ -1084,21 +1092,7 @@ async fn production_default_target_is_the_initial_browser_page_target() { async fn set_auto_attach_true_attaches_existing_background_targets() { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-9", "TID-000000000E"); - ctx.conn - .browser_context - .as_mut() - .unwrap() - .background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#bg".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); + push_background_target(&mut ctx, "TID-000000000F", "about:blank#bg", None); ctx.process_async(json!({ "id": 1701, @@ -1129,11 +1123,7 @@ async fn set_auto_attach_true_attaches_existing_background_targets() { let bc = ctx.conn.browser_context.as_ref().unwrap(); assert_eq!(bc.active_target_id(), Some("TID-000000000F")); assert!(bc.has_active_session()); - assert!( - bc.background_target("TID-000000000E") - .and_then(|target| target.session_id()) - .is_some() - ); + assert!(bc.primary_session_id_for_target("TID-000000000E").is_some()); } #[tokio::test(flavor = "multi_thread")] @@ -1141,21 +1131,7 @@ async fn set_auto_attach_promotes_existing_background_target_when_active_target_ { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-9", "TID-000000000E"); - ctx.conn - .browser_context - .as_mut() - .unwrap() - .background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#bg".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); + push_background_target(&mut ctx, "TID-000000000F", "about:blank#bg", None); ctx.process_async(json!({ "id": 17015, @@ -1185,8 +1161,7 @@ async fn set_auto_attach_promotes_existing_background_target_when_active_target_ assert_eq!(bc.active_target_id(), Some("TID-000000000F")); assert_eq!(bc.active_session_id(), Some(session_id.as_str())); assert_eq!( - bc.background_target("TID-000000000E") - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target("TID-000000000E"), Some("SID-1"), "the old active target should be demoted into the background with its newly attached session", ); @@ -1229,29 +1204,8 @@ async fn set_auto_attach_promotes_existing_background_target_when_active_target_ async fn set_auto_attach_sweep_chain_promotes_multiple_existing_background_targets_into_runtime() { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-9", "TID-000000000E"); - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#second".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-0000000010".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#third".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); + push_background_target(&mut ctx, "TID-000000000F", "about:blank#second", None); + push_background_target(&mut ctx, "TID-0000000010", "about:blank#third", None); ctx.process_async(json!({ "id": 17018, @@ -1362,41 +1316,19 @@ async fn set_auto_attach_sweep_chain_promotes_multiple_existing_background_targe async fn set_auto_attach_prefers_existing_background_target_with_parked_loaded_runtime() { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-9", "TID-000000000E"); - { - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#metadata-only".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); - bc.stage_active_target_demoting_current( - "TID-0000000010".into(), - None, - "about:blank#parked".into(), - Some("about:blank".into()), - ); - } - ctx.install_navigation_fixture_for_session_owner( - "data:text/html,parked
parked runtime
", + push_background_target( + &mut ctx, + "TID-000000000F", + "about:blank#metadata-only", None, + ); + push_background_target(&mut ctx, "TID-0000000010", "about:blank#parked", None); + install_navigation_for_target_without_session( + &mut ctx, + "TID-0000000010", + "data:text/html,parked
parked runtime
", ) .await; - assert!( - ctx.conn - .browser_context - .as_mut() - .unwrap() - .promote_background_target_to_active_slot_async("TID-000000000E") - .await - .expect("restoring the original active target should succeed"), - "the original active target should remain parked during fixture setup" - ); ctx.process_async(json!({ "id": 17023, @@ -1437,13 +1369,11 @@ async fn set_auto_attach_prefers_existing_background_target_with_parked_loaded_r assert_eq!(bc.active_target_id(), Some("TID-0000000010")); assert_eq!(bc.active_session_id(), Some(parked_session_id.as_str())); assert_eq!( - bc.background_target("TID-000000000F") - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target("TID-000000000F"), Some(metadata_session_id.as_str()) ); assert_eq!( - bc.background_target("TID-000000000E") - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target("TID-000000000E"), Some("SID-1") ); @@ -1469,21 +1399,7 @@ async fn set_auto_attach_prefers_existing_background_target_with_parked_loaded_r async fn activate_target_promotes_set_auto_attach_background_session_into_page_runtime() { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-9", "TID-000000000E"); - ctx.conn - .browser_context - .as_mut() - .unwrap() - .background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#bg".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); + push_background_target(&mut ctx, "TID-000000000F", "about:blank#bg", None); ctx.process_async(json!({ "id": 17011, @@ -1553,30 +1469,23 @@ async fn activate_target_chain_switches_between_multiple_attached_background_tar { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-9", "TID-000000000E"); - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.attach_active_session("SID-active"); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - Some("SID-second".into()), - crate::conn::TargetIdentityState::new( - "about:blank#second".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-0000000010".into(), - Some("SID-third".into()), - crate::conn::TargetIdentityState::new( - "about:blank#third".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); + ctx.conn + .browser_context + .as_mut() + .unwrap() + .attach_active_session("SID-active"); + push_background_target( + &mut ctx, + "TID-000000000F", + "about:blank#second", + Some("SID-second"), + ); + push_background_target( + &mut ctx, + "TID-0000000010", + "about:blank#third", + Some("SID-third"), + ); ctx.process_async(json!({ "id": 17030, @@ -1590,8 +1499,7 @@ async fn activate_target_chain_switches_between_multiple_attached_background_tar assert_eq!(bc.active_target_id(), Some("TID-000000000F")); assert_eq!(bc.active_session_id(), Some("SID-second")); assert_eq!( - bc.background_target("TID-000000000E") - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target("TID-000000000E"), Some("SID-active"), ); @@ -1607,8 +1515,7 @@ async fn activate_target_chain_switches_between_multiple_attached_background_tar assert_eq!(bc.active_target_id(), Some("TID-0000000010")); assert_eq!(bc.active_session_id(), Some("SID-third")); assert_eq!( - bc.background_target("TID-000000000F") - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target("TID-000000000F"), Some("SID-second"), ); @@ -1652,21 +1559,7 @@ async fn set_auto_attach_false_detaches_existing_background_targets() { .as_mut() .unwrap() .attach_active_session("SID-1"); - ctx.conn - .browser_context - .as_mut() - .unwrap() - .background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - Some("SID-bg".into()), - crate::conn::TargetIdentityState::new( - "about:blank#bg".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); + push_background_target(&mut ctx, "TID-000000000F", "about:blank#bg", Some("SID-bg")); ctx.conn.auto_attach = true; ctx.process_async(json!({ @@ -1697,11 +1590,7 @@ async fn set_auto_attach_false_detaches_existing_background_targets() { ); let bc = ctx.conn.browser_context.as_ref().unwrap(); assert!(!bc.has_active_session()); - assert_eq!( - bc.background_target("TID-000000000F") - .and_then(|target| target.session_id()), - None - ); + assert_eq!(bc.primary_session_id_for_target("TID-000000000F"), None); } #[tokio::test(flavor = "multi_thread")] @@ -1710,7 +1599,7 @@ async fn set_auto_attach_restores_previously_active_context_after_sweeping_conte load_bc_with_target(&mut ctx, "BID-A", "TID-A"); let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B"); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); ctx.process_async(json!({ "id": 181, diff --git a/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/cleanup_isolation.rs b/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/cleanup_isolation.rs index c63bc99b3d..48c0601182 100644 --- a/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/cleanup_isolation.rs +++ b/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/cleanup_isolation.rs @@ -231,7 +231,7 @@ async fn patchright_over_cdp_auto_attach_sweep_rehydrates_existing_targets_witho ); assert!( first_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -256,7 +256,7 @@ async fn patchright_over_cdp_auto_attach_sweep_rehydrates_existing_targets_witho ); assert!( second_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -581,7 +581,7 @@ async fn patchright_over_cdp_auto_attach_sweep_cleanup_stays_isolated_per_browse .expect("first browser context should still exist"); assert!( !first_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -607,7 +607,7 @@ async fn patchright_over_cdp_auto_attach_sweep_cleanup_stays_isolated_per_browse .expect("second browser context should still exist"); assert!( second_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -1087,7 +1087,7 @@ async fn patchright_over_cdp_auto_attach_sweep_page_binding_cleanup_with_same_na .expect("first browser context should still exist"); assert!( !first_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -1104,7 +1104,7 @@ async fn patchright_over_cdp_auto_attach_sweep_page_binding_cleanup_with_same_na .expect("second browser context should still exist"); assert!( second_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -1496,7 +1496,7 @@ async fn patchright_over_cdp_auto_attach_sweep_page_binding_rejection_with_same_ .expect("first browser context should still exist"); assert!( !first_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() @@ -1514,7 +1514,7 @@ async fn patchright_over_cdp_auto_attach_sweep_page_binding_rejection_with_same_ .expect("second browser context should still exist"); assert!( second_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() @@ -2068,7 +2068,7 @@ async fn patchright_over_cdp_auto_attach_sweep_handle_page_binding_cleanup_with_ .expect("first browser context should still exist"); assert!( !first_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -2085,7 +2085,7 @@ async fn patchright_over_cdp_auto_attach_sweep_handle_page_binding_cleanup_with_ .expect("second browser context should still exist"); assert!( second_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -2547,7 +2547,7 @@ async fn run_patchright_over_cdp_auto_attach_sweep_handle_page_binding_rejection .expect("first browser context should still exist"); assert!( !first_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -2564,7 +2564,7 @@ async fn run_patchright_over_cdp_auto_attach_sweep_handle_page_binding_rejection .expect("second browser context should still exist"); assert!( second_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { diff --git a/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/preload_runtime.rs b/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/preload_runtime.rs index 9f91a47dc3..66e0ed7a16 100644 --- a/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/preload_runtime.rs +++ b/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/preload_runtime.rs @@ -200,7 +200,7 @@ async fn patchright_over_cdp_utility_world_binding_and_preload_stay_isolated_per ); assert!( first_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -584,7 +584,7 @@ async fn patchright_over_cdp_existing_utility_world_binding_install_by_execution .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .all(|binding| binding.name != "utilityBindingById"), @@ -786,7 +786,7 @@ async fn patchright_over_cdp_pre_document_remove_binding_prevents_first_navigati .browser_context .as_ref() .expect("browser context") - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .all(|binding| binding.name != "temporaryPreDocumentBinding"), diff --git a/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/replacement_targets/crpage.rs b/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/replacement_targets/crpage.rs index 868a03b20c..1dc96a1c73 100644 --- a/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/replacement_targets/crpage.rs +++ b/moli-protocol/src/domains/target/tests/tests_patchright/utility_world/replacement_targets/crpage.rs @@ -317,7 +317,7 @@ async fn patchright_over_cdp_utility_world_init_persists_across_targets_without_ ); assert!( active - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { diff --git a/moli-protocol/src/domains/target/tests/tests_patchright_runtimeless.rs b/moli-protocol/src/domains/target/tests/tests_patchright_runtimeless.rs index b0f9f34fdc..241aeb1bc5 100644 --- a/moli-protocol/src/domains/target/tests/tests_patchright_runtimeless.rs +++ b/moli-protocol/src/domains/target/tests/tests_patchright_runtimeless.rs @@ -221,7 +221,7 @@ async fn patchright_over_cdp_switching_back_to_first_context_keeps_older_page_ru ); assert!( first_context - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -687,7 +687,7 @@ async fn patchright_over_cdp_utility_world_init_persists_across_detach_and_reatt ); assert!( active - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -880,7 +880,7 @@ async fn patchright_over_cdp_utility_world_init_persists_when_auto_attach_reatta ); assert!( active - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { 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..545c4ad6b6 100644 --- a/moli-protocol/src/domains/target/tests/tests_playwright_cdp.rs +++ b/moli-protocol/src/domains/target/tests/tests_playwright_cdp.rs @@ -6144,13 +6144,12 @@ async fn playwright_over_cdp_script_execution_disabled_blocks_page_scripts_but_n .as_ref() .expect("active browser context"); assert!(active.script_execution_disabled); - let script_runs = active + let page = active .active_target .runtime_slot .loaded_page() - .expect("loaded page should exist") - .script_execution() - .runs(); + .expect("loaded page should exist"); + let script_runs = page.script_execution().runs(); assert!( script_runs.iter().any(|run| matches!( run.outcome(), @@ -6158,6 +6157,7 @@ async fn playwright_over_cdp_script_execution_disabled_blocks_page_scripts_but_n )), "expected at least one skipped page script when script execution is disabled" ); + drop(page); ctx.process_async(json!({ "id": 247, diff --git a/moli-protocol/src/domains/target/tests/tests_same_context_bindings.rs b/moli-protocol/src/domains/target/tests/tests_same_context_bindings.rs index 4001620102..6e2668323d 100644 --- a/moli-protocol/src/domains/target/tests/tests_same_context_bindings.rs +++ b/moli-protocol/src/domains/target/tests/tests_same_context_bindings.rs @@ -822,7 +822,7 @@ async fn same_context_targets_remove_only_their_own_utility_binding_definition_a .expect("active browser context"); assert!( active - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -834,8 +834,8 @@ async fn same_context_targets_remove_only_their_own_utility_binding_definition_a .background_target("TID-000000000UB") .expect("first target should remain staged"); let staged_bindings = active - .parked_page_session_state(staged.target_id()) - .map(|state| state.devtools_session_state.runtime_bindings.as_slice()) + .primary_devtools_session_state_for_target(staged.target_id()) + .map(|state| state.runtime_bindings.as_slice()) .unwrap_or(&[]); assert!(!staged_bindings.iter().any(|binding| { binding.name == "sharedUtilityBinding" @@ -1012,7 +1012,7 @@ async fn same_context_targets_remove_only_their_own_main_world_binding_definitio .expect("active browser context"); assert!( active - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() .any(|binding| { @@ -1023,8 +1023,8 @@ async fn same_context_targets_remove_only_their_own_main_world_binding_definitio .background_target("TID-000000000MB") .expect("first target should remain staged"); let staged_bindings = active - .parked_page_session_state(staged.target_id()) - .map(|state| state.devtools_session_state.runtime_bindings.as_slice()) + .primary_devtools_session_state_for_target(staged.target_id()) + .map(|state| state.runtime_bindings.as_slice()) .unwrap_or(&[]); assert!(!staged_bindings.iter().any(|binding| { binding.name == "sharedMainBinding" && binding.execution_context_name.is_none() @@ -1233,7 +1233,7 @@ async fn same_context_targets_remove_only_their_own_dual_world_binding_definitio .expect("active browser context"); assert!( active - .devtools_session_state + .devtools_session_state() .runtime_bindings .iter() @@ -1243,8 +1243,8 @@ async fn same_context_targets_remove_only_their_own_dual_world_binding_definitio .background_target("TID-000000000DB") .expect("first target should remain staged"); let staged_bindings = active - .parked_page_session_state(staged.target_id()) - .map(|state| state.devtools_session_state.runtime_bindings.as_slice()) + .primary_devtools_session_state_for_target(staged.target_id()) + .map(|state| state.runtime_bindings.as_slice()) .unwrap_or(&[]); assert_eq!( staged_bindings @@ -2659,29 +2659,8 @@ async fn same_context_targets_restore_only_their_own_utility_pre_document_bindin { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-9", "TID-000000000E"); - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#second".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-0000000010".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#third".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); + push_background_target(&mut ctx, "TID-000000000F", "about:blank#second", None); + push_background_target(&mut ctx, "TID-0000000010", "about:blank#third", None); ctx.process_async(json!({ "id": 10456, diff --git a/moli-protocol/src/domains/target/tests/tests_same_context_overrides.rs b/moli-protocol/src/domains/target/tests/tests_same_context_overrides.rs index 8f554ee10b..48efce7fc4 100644 --- a/moli-protocol/src/domains/target/tests/tests_same_context_overrides.rs +++ b/moli-protocol/src/domains/target/tests/tests_same_context_overrides.rs @@ -933,12 +933,18 @@ async fn same_context_targets_restore_their_own_proxy_loader_overrides_after_swi active.http_proxy_override = Some("http://proxy-a.test:8080".into()); active.http_no_proxy_override = Some("localhost,127.0.0.1".into()); } - ctx.conn.invalidate_resource_runtime_async().await; + ctx.conn.invalidate_resource_runtime(); ctx.conn .ensure_resource_request_client() .expect("loader for target A"); - assert_eq!(ctx.conn.http_proxy(), Some("http://proxy-a.test:8080")); - assert_eq!(ctx.conn.http_no_proxy(), Some("localhost,127.0.0.1")); + assert_eq!( + ctx.conn.http_proxy().as_deref(), + Some("http://proxy-a.test:8080") + ); + assert_eq!( + ctx.conn.http_no_proxy().as_deref(), + Some("localhost,127.0.0.1") + ); ctx.process_async(json!({ "id": 1041770, @@ -1001,12 +1007,18 @@ async fn same_context_targets_restore_their_own_proxy_loader_overrides_after_swi active.http_proxy_override = Some("http://proxy-b.test:8080".into()); active.http_no_proxy_override = Some("::1,.example.com".into()); } - ctx.conn.invalidate_resource_runtime_async().await; + ctx.conn.invalidate_resource_runtime(); ctx.conn .ensure_resource_request_client() .expect("loader for target B"); - assert_eq!(ctx.conn.http_proxy(), Some("http://proxy-b.test:8080")); - assert_eq!(ctx.conn.http_no_proxy(), Some("::1,.example.com")); + assert_eq!( + ctx.conn.http_proxy().as_deref(), + Some("http://proxy-b.test:8080") + ); + assert_eq!( + ctx.conn.http_no_proxy().as_deref(), + Some("::1,.example.com") + ); ctx.process_async(json!({ "id": 1041773, @@ -1035,8 +1047,14 @@ async fn same_context_targets_restore_their_own_proxy_loader_overrides_after_swi ctx.conn .ensure_resource_request_client() .expect("restored loader for target A"); - assert_eq!(ctx.conn.http_proxy(), Some("http://proxy-a.test:8080")); - assert_eq!(ctx.conn.http_no_proxy(), Some("localhost,127.0.0.1")); + assert_eq!( + ctx.conn.http_proxy().as_deref(), + Some("http://proxy-a.test:8080") + ); + assert_eq!( + ctx.conn.http_no_proxy().as_deref(), + Some("localhost,127.0.0.1") + ); } #[tokio::test(flavor = "multi_thread")] @@ -1189,15 +1207,18 @@ async fn same_context_targets_restore_their_own_proxy_loader_overrides_after_clo active.http_proxy_override = Some("http://proxy-close-a.test:8080".into()); active.http_no_proxy_override = Some("localhost,127.0.0.1".into()); } - ctx.conn.invalidate_resource_runtime_async().await; + ctx.conn.invalidate_resource_runtime(); ctx.conn .ensure_resource_request_client() .expect("loader for target A"); assert_eq!( - ctx.conn.http_proxy(), + ctx.conn.http_proxy().as_deref(), Some("http://proxy-close-a.test:8080") ); - assert_eq!(ctx.conn.http_no_proxy(), Some("localhost,127.0.0.1")); + assert_eq!( + ctx.conn.http_no_proxy().as_deref(), + Some("localhost,127.0.0.1") + ); ctx.process_async(json!({ "id": 1041860, @@ -1259,15 +1280,18 @@ async fn same_context_targets_restore_their_own_proxy_loader_overrides_after_clo active.http_proxy_override = Some("http://proxy-close-b.test:8080".into()); active.http_no_proxy_override = Some("::1,.example.com".into()); } - ctx.conn.invalidate_resource_runtime_async().await; + ctx.conn.invalidate_resource_runtime(); ctx.conn .ensure_resource_request_client() .expect("loader for target B"); assert_eq!( - ctx.conn.http_proxy(), + ctx.conn.http_proxy().as_deref(), Some("http://proxy-close-b.test:8080") ); - assert_eq!(ctx.conn.http_no_proxy(), Some("::1,.example.com")); + assert_eq!( + ctx.conn.http_no_proxy().as_deref(), + Some("::1,.example.com") + ); ctx.process_async(json!({ "id": 1041863, @@ -1298,8 +1322,11 @@ async fn same_context_targets_restore_their_own_proxy_loader_overrides_after_clo .ensure_resource_request_client() .expect("restored loader after close promotion"); assert_eq!( - ctx.conn.http_proxy(), + ctx.conn.http_proxy().as_deref(), Some("http://proxy-close-a.test:8080") ); - assert_eq!(ctx.conn.http_no_proxy(), Some("localhost,127.0.0.1")); + assert_eq!( + ctx.conn.http_no_proxy().as_deref(), + Some("localhost,127.0.0.1") + ); } diff --git a/moli-protocol/src/domains/target/tests/tests_same_context_state.rs b/moli-protocol/src/domains/target/tests/tests_same_context_state.rs index 9cdbca6c12..50225283d1 100644 --- a/moli-protocol/src/domains/target/tests/tests_same_context_state.rs +++ b/moli-protocol/src/domains/target/tests/tests_same_context_state.rs @@ -192,12 +192,7 @@ async fn same_context_targets_restore_their_own_script_execution_disabled_after_ #[tokio::test(flavor = "multi_thread")] async fn same_context_targets_restore_their_own_search_results_after_switching() { let mut ctx = TestContext::new(); - load_bc(&mut ctx, "BID-9B"); - ctx.conn - .browser_context - .as_mut() - .unwrap() - .set_active_target_id("TID-000000000A"); + load_bc_with_target(&mut ctx, "BID-9B", "TID-000000000A"); ctx.conn .browser_context .as_mut() @@ -411,7 +406,7 @@ async fn same_context_targets_restore_their_own_crash_state_after_switching() { .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .record_inspector_target_crashed(); @@ -623,17 +618,17 @@ async fn same_context_targets_restore_their_own_domain_enablement_after_switchin .expect("active browser context"); assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert!( - bc.devtools_session_state + bc.devtools_session_state() .page_session_state .page_lifecycle_events ); assert!( - bc.devtools_session_state + bc.devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); assert!( - bc.devtools_session_state + bc.devtools_session_state() .runtime_session_state .inspector_enabled ); @@ -671,17 +666,17 @@ async fn same_context_targets_restore_their_own_domain_enablement_after_switchin .expect("active browser context"); assert_eq!(bc.active_target_id(), Some(second_target_id.as_str())); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .page_session_state .page_lifecycle_events ); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .inspector_enabled ); @@ -857,7 +852,6 @@ async fn same_context_targets_restore_their_own_page_attachment_id_and_request_c bc.active_target .runtime_slot .set_page_attachment_id_for_test(11); - bc.set_next_network_request_sequence_for_test(41); bc.set_subresource_network_emitted_record_count_for_test(12); bc.active_target .runtime_slot @@ -901,7 +895,6 @@ async fn same_context_targets_restore_their_own_page_attachment_id_and_request_c bc.active_target .runtime_slot .set_page_attachment_id_for_test(23); - bc.set_next_network_request_sequence_for_test(71); bc.set_subresource_network_emitted_record_count_for_test(8); bc.active_target .runtime_slot @@ -921,7 +914,6 @@ async fn same_context_targets_restore_their_own_page_attachment_id_and_request_c .map(crate::conn::TargetPageAttachmentId::get), Some(11) ); - assert_eq!(bc.next_network_request_sequence_for_test(), 41); assert_eq!(bc.subresource_network_emitted_record_count_for_test(), 12); assert_eq!( bc.active_target @@ -950,7 +942,6 @@ async fn same_context_targets_restore_their_own_page_attachment_id_and_request_c .map(crate::conn::TargetPageAttachmentId::get), Some(23) ); - assert_eq!(bc.next_network_request_sequence_for_test(), 71); assert_eq!(bc.subresource_network_emitted_record_count_for_test(), 8); assert_eq!( bc.active_target @@ -1075,12 +1066,7 @@ async fn same_context_targets_restore_their_own_security_identity_after_switchin async fn same_context_targets_restore_their_own_search_results_after_session_scoped_owner_activity() { let mut ctx = TestContext::new(); - load_bc(&mut ctx, "BID-9B-SESSION"); - ctx.conn - .browser_context - .as_mut() - .unwrap() - .set_active_target_id("TID-000000000AS"); + load_bc_with_target(&mut ctx, "BID-9B-SESSION", "TID-000000000AS"); ctx.conn .browser_context .as_mut() @@ -1279,7 +1265,7 @@ async fn same_context_targets_restore_their_own_crash_state_after_session_scoped .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .record_inspector_target_crashed(); @@ -1531,9 +1517,9 @@ async fn same_context_targets_restore_their_own_domain_enablement_after_session_ .as_ref() .expect("active browser context"); assert_eq!(bc.active_target_id(), Some("TID-000000000DS")); - assert!(bc.devtools_session_state.page_session_state.page_lifecycle_events); - assert!(bc.devtools_session_state.runtime_session_state.runtime_frontend_enabled); - assert!(bc.devtools_session_state.runtime_session_state.inspector_enabled); + assert!(bc.devtools_session_state().page_session_state.page_lifecycle_events); + assert!(bc.devtools_session_state().runtime_session_state.runtime_frontend_enabled); + assert!(bc.devtools_session_state().runtime_session_state.inspector_enabled); assert!( bc.active_target .runtime_slot @@ -1569,9 +1555,9 @@ async fn same_context_targets_restore_their_own_domain_enablement_after_session_ .as_ref() .expect("active browser context after session-scoped promotion"); assert_eq!(bc.active_target_id(), Some(second_target_id.as_str())); - assert!(!bc.devtools_session_state.page_session_state.page_lifecycle_events); - assert!(bc.devtools_session_state.runtime_session_state.runtime_frontend_enabled); - assert!(!bc.devtools_session_state.runtime_session_state.inspector_enabled); + assert!(!bc.devtools_session_state().page_session_state.page_lifecycle_events); + assert!(bc.devtools_session_state().runtime_session_state.runtime_frontend_enabled); + assert!(!bc.devtools_session_state().runtime_session_state.inspector_enabled); assert!( bc.active_target .runtime_slot @@ -1607,9 +1593,9 @@ async fn same_context_targets_restore_their_own_domain_enablement_after_session_ .as_ref() .expect("active browser context after restoring first target"); assert_eq!(bc.active_target_id(), Some("TID-000000000DS")); - assert!(bc.devtools_session_state.page_session_state.page_lifecycle_events); - assert!(bc.devtools_session_state.runtime_session_state.runtime_frontend_enabled); - assert!(bc.devtools_session_state.runtime_session_state.inspector_enabled); + assert!(bc.devtools_session_state().page_session_state.page_lifecycle_events); + assert!(bc.devtools_session_state().runtime_session_state.runtime_frontend_enabled); + assert!(bc.devtools_session_state().runtime_session_state.inspector_enabled); assert!( bc.active_target .runtime_slot 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..2f925689c6 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 @@ -174,19 +174,12 @@ async fn get_target_info_reports_background_target_in_same_browser_context() { async fn popup_target_diagnostics_report_distinct_page_vm_document_isolates() { let mut ctx = TestContext::new(); ctx.conn.auto_attach = true; - let browser_context = ctx + let mut browser_context = ctx .conn .new_browser_context("BID-popup-diagnostics".to_owned()); + browser_context.set_active_target_id("TID-popup-opener"); + browser_context.attach_active_session("SID-popup-opener"); ctx.conn.insert_browser_context(browser_context); - { - let browser_context = ctx - .conn - .browser_context - .as_mut() - .expect("browser context should be active"); - browser_context.set_active_target_id("TID-popup-opener"); - browser_context.attach_active_session("SID-popup-opener"); - } let opener_page = ctx .conn @@ -358,8 +351,7 @@ async fn attach_to_target_keeps_background_target_parked_when_active_target_has_ assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert_eq!(bc.active_session_id(), Some("SID-active")); assert_eq!( - bc.background_target(&second_target_id) - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target(&second_target_id), Some(session_id.as_str()) ); assert_eq!( @@ -1343,31 +1335,8 @@ async fn activate_target_chain_restores_multiple_set_auto_attach_background_load .as_mut() .unwrap() .attach_active_session("SID-active"); - { - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#second".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-0000000010".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#third".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); - } + push_background_target(&mut ctx, "TID-000000000F", "about:blank#second", None); + push_background_target(&mut ctx, "TID-0000000010", "about:blank#third", None); ctx.process_async(json!({ "id": 1835, @@ -1544,31 +1513,8 @@ async fn close_target_restores_loaded_runtime_to_previous_set_auto_attach_target .as_mut() .unwrap() .attach_active_session("SID-active"); - { - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#second".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); - bc.background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-0000000010".into(), - None, - crate::conn::TargetIdentityState::new( - "about:blank#third".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); - } + push_background_target(&mut ctx, "TID-000000000F", "about:blank#second", None); + push_background_target(&mut ctx, "TID-0000000010", "about:blank#third", None); ctx.process_async(json!({ "id": 1846, @@ -1771,8 +1717,10 @@ async fn detach_from_target_clears_background_target_session() { .expect("active browser context"); assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert!( - bc.background_target(&second_target_id) - .is_some_and(|target| target.session_id().is_none()), + bc.background_target(&second_target_id).is_some() + && bc + .primary_session_id_for_target(&second_target_id) + .is_none(), "detaching a parked target session should clear the parked session binding without promoting it", ); assert!( @@ -1800,21 +1748,12 @@ async fn page_command_on_background_target_session_routes_without_promoting_load .as_mut() .unwrap() .attach_active_session("SID-active"); - ctx.conn - .browser_context - .as_mut() - .unwrap() - .background_targets - .push(crate::conn::BackgroundTarget::new( - "TID-000000000F".into(), - Some("SID-bg".into()), - crate::conn::TargetIdentityState::new( - "about:blank#second".into(), - crate::conn::URL_BASE.into(), - "Secure".into(), - ), - crate::conn::TargetPageSlot::empty_for_test_fixture(), - )); + push_background_target( + &mut ctx, + "TID-000000000F", + "about:blank#second", + Some("SID-bg"), + ); ctx.process_async(json!({ "id": 12, @@ -1838,8 +1777,7 @@ async fn page_command_on_background_target_session_routes_without_promoting_load assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert_eq!(bc.active_session_id(), Some("SID-active")); assert_eq!( - bc.background_target("TID-000000000F") - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target("TID-000000000F"), Some("SID-bg") ); assert!( @@ -1891,13 +1829,13 @@ async fn close_target_aborts_paused_request_stage_navigation() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 20, @@ -1990,7 +1928,7 @@ async fn close_target_aborts_paused_runtime_fetch_subresource() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.conn @@ -2123,7 +2061,7 @@ async fn close_target_aborts_paused_response_stage_runtime_xhr_subresource() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.conn @@ -2291,7 +2229,7 @@ async fn close_target_aborts_paused_runtime_xhr_auth_subresource() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.conn diff --git a/moli-protocol/src/domains/target/tests/tests_target_attachment.rs b/moli-protocol/src/domains/target/tests/tests_target_attachment.rs index 4b2c52f0a6..14afa2f877 100644 --- a/moli-protocol/src/domains/target/tests/tests_target_attachment.rs +++ b/moli-protocol/src/domains/target/tests/tests_target_attachment.rs @@ -616,16 +616,27 @@ async fn attach_to_target_ensures_pending_background_initial_document_before_att "active", ) .await; - { - let bc = ctx.conn.browser_context.as_mut().expect("browser context"); - bc.stage_background_target( - "TID-background-pending".to_owned(), - None, - "about:blank#direct-attach".to_owned(), - None, - None, - ); - } + ctx.conn + .register_background_target_with_creation_metadata_projection( + "BID-9", + "TID-background-pending", + moli_core::browser_host::BrowserTargetCreationMetadata::with_initial_empty_document( + moli_core::browser_host::BrowserInitialEmptyDocumentSeed::new( + "about:blank#direct-attach", + ), + ), + |bc, target_handle, page_residence, session_storage_access| { + bc.stage_background_target_with_browser_handles( + target_handle, + page_residence, + session_storage_access, + None, + "about:blank#direct-attach".to_owned(), + None, + ); + }, + ) + .expect("background Target projection should register"); assert_eq!( ctx.conn .browser_context @@ -692,7 +703,7 @@ async fn attach_to_target_existing_session_creates_distinct_auxiliary_session() // Chromium TargetHandler::AttachToTarget calls Session::Attach on every // invocation, even when the host already has another attached session. let mut ctx = TestContext::new(); - load_bc_with_target(&mut ctx, "BID-9", "TID-000000000B"); + load_bc_with_pending_initial_target(&mut ctx, "BID-9", "TID-000000000B"); ctx.conn .browser_context .as_mut() @@ -767,7 +778,10 @@ async fn attach_to_target_keeps_background_target_parked() { assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert_eq!(bc.background_targets.len(), 1); assert_eq!(bc.background_targets[0].target_id(), "TID-000000000B"); - assert_eq!(bc.background_targets[0].session_id(), Some("SID-1")); + assert_eq!( + bc.primary_session_id_for_target("TID-000000000B"), + Some("SID-1") + ); } #[tokio::test(flavor = "multi_thread")] @@ -963,7 +977,7 @@ async fn attach_to_browser_target_emits_browser_attached_event() { #[tokio::test(flavor = "multi_thread")] async fn attach_to_browser_target_does_not_reuse_page_session() { let mut ctx = TestContext::new(); - load_bc(&mut ctx, "BID-9"); + load_bc_with_target(&mut ctx, "BID-9", "TID-page"); ctx.conn .browser_context .as_mut() @@ -1037,7 +1051,7 @@ async fn detach_browser_target_session_cascades_owned_target_sessions() { #[tokio::test(flavor = "multi_thread")] async fn detach_auxiliary_page_session_cascades_owned_target_sessions() { let mut ctx = TestContext::new(); - load_bc_with_target(&mut ctx, "BID-aux-cascade", "TID-page"); + load_bc_with_pending_initial_target(&mut ctx, "BID-aux-cascade", "TID-page"); ctx.process_async(json!({"id": 151, "method": "Target.attachToBrowserTarget"})) .await; @@ -1104,7 +1118,7 @@ async fn detach_auxiliary_page_session_cascades_owned_target_sessions() { #[tokio::test(flavor = "multi_thread")] async fn root_frontend_release_preserves_private_browser_owned_page_session() { let mut ctx = TestContext::new(); - load_bc_with_target(&mut ctx, "BID-root-release", "TID-root-release"); + load_bc_with_pending_initial_target(&mut ctx, "BID-root-release", "TID-root-release"); ctx.process_async(json!({"id": 18, "method": "Target.attachToBrowserTarget"})) .await; @@ -1266,7 +1280,7 @@ async fn session_route_finds_browser_active_auxiliary_background_and_inactive_se inactive.attach_active_session("SID-inactive"); inactive .background_targets - .push(crate::conn::BackgroundTarget::new( + .push(crate::conn::BackgroundTarget::new_with_frontend_session( "TID-000000000D".to_owned(), Some("SID-inactive-background".to_owned()), crate::conn::TargetIdentityState::about_blank(), @@ -1286,7 +1300,7 @@ async fn session_route_finds_browser_active_auxiliary_background_and_inactive_se ); inactive_shared_worker.attach_session("SID-shared-inactive".to_owned()); inactive.insert_shared_worker_target(inactive_shared_worker); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); assert_eq!( ctx.conn.session_route(Some("SID-browser")), @@ -1361,12 +1375,10 @@ async fn session_route_finds_browser_active_auxiliary_background_and_inactive_se Some("SID-inactive-background"), |bc, target_id| { assert_eq!(bc.id, "BID-B"); - bc.mutate_parked_page_session_state(target_id, |state| { - state - .devtools_session_state - .console_output_session_state - .console_enabled = true; - }); + bc.primary_devtools_session_state_for_target_mut(target_id) + .expect("background Target frontend state") + .console_output_session_state + .console_enabled = true; target_id.to_owned() } ), @@ -1382,14 +1394,8 @@ async fn session_route_finds_browser_active_auxiliary_background_and_inactive_se Some("SID-inactive-background"), |bc, target_id| { assert_eq!(bc.id, "BID-B"); - let mut console_enabled = false; - bc.mutate_parked_page_session_state(target_id, |state| { - console_enabled = state - .devtools_session_state - .console_output_session_state - .console_enabled; - }); - console_enabled + bc.primary_devtools_session_state_for_target(target_id) + .is_some_and(|state| state.console_output_session_state.console_enabled) } ), Some(true) @@ -1533,13 +1539,13 @@ async fn detach_from_target() { ctx.expect_result(11, json!({ "sessionId": sid }), None); let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.devtools_session_state + bc.devtools_session_state_mut() .page_session_state .page_lifecycle_events = true; - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; bc.active_target @@ -1567,17 +1573,17 @@ async fn detach_from_target() { let bc = ctx.conn.browser_context.as_ref().unwrap(); assert!(!bc.has_active_session()); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .page_session_state .page_lifecycle_events ); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .inspector_enabled ); @@ -1937,7 +1943,7 @@ async fn detach_from_target_aborts_paused_request_stage_navigation() { bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 40, @@ -2007,13 +2013,13 @@ async fn set_auto_attach_false_detaches_existing_target() { load_bc_with_target(&mut ctx, "BID-9", "TID-000000000D"); let bc = ctx.conn.browser_context.as_mut().unwrap(); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .page_session_state .page_lifecycle_events = true; - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; bc.active_target @@ -2073,17 +2079,17 @@ async fn set_auto_attach_false_detaches_existing_target() { assert!(!bc.has_active_session()); assert_eq!(bc.active_target_id(), Some("TID-000000000D")); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .page_session_state .page_lifecycle_events ); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .inspector_enabled ); @@ -2752,10 +2758,6 @@ async fn non_browser_auto_attach_owners_do_not_replay_existing_shared_worker_tar ctx.conn .prepare_auto_attached_page_session_binding("TID-page", "SID-page".to_owned()) ); - ctx.conn - .runtime_session_owner_slot_mut(Some("SID-page")) - .expect("page owner runtime slot") - .set_page_attachment_id_for_test(1); let owner_page = ctx .conn .target_page_residence_identity_for_session(Some("SID-page")) diff --git a/moli-protocol/src/domains/target/tests/tests_target_basics.rs b/moli-protocol/src/domains/target/tests/tests_target_basics.rs index 67f60de3e7..19d138c5a2 100644 --- a/moli-protocol/src/domains/target/tests/tests_target_basics.rs +++ b/moli-protocol/src/domains/target/tests/tests_target_basics.rs @@ -47,7 +47,7 @@ async fn close_active_target_fails_only_active_owner_pending_awaits() { Some("SID-bg-await".to_owned()), "about:blank#bg-await".to_owned(), )); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.conn .register_pending_inspector_await(1041201, Some("SID-active-await")); ctx.conn @@ -141,7 +141,7 @@ async fn attach_to_target_selects_inactive_browser_context() { load_bc_with_target(&mut ctx, "BID-A", "TID-A"); let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B"); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); ctx.process_async(json!({ "id": 6, @@ -183,7 +183,7 @@ async fn attach_to_target_creates_auxiliary_session_and_keeps_target_context_act let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-B"); inactive.attach_active_session("SID-B"); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); ctx.process_async(json!({ "id": 61, @@ -248,13 +248,13 @@ async fn dispose_browser_context_aborts_paused_request_stage_navigation() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 20, @@ -339,7 +339,7 @@ async fn dispose_browser_context_aborts_paused_runtime_fetch_subresource() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.conn @@ -428,8 +428,7 @@ async fn create_target_for_inactive_browser_context_keeps_previously_active_cont let mut ctx = TestContext::new(); load_bc(&mut ctx, "BID-A"); ctx.conn - .inactive_browser_contexts - .push(BrowserContext::new("BID-B".into())); + .insert_browser_context(BrowserContext::new("BID-B".into())); ctx.process_async(json!({ "id": 1010, @@ -493,8 +492,7 @@ async fn create_target_with_auto_attach_attaches_second_target_in_same_browser_c let bc = ctx.conn.browser_context.as_ref().unwrap(); assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert_eq!( - bc.background_target(&second_target_id) - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target(&second_target_id), Some(session_id.as_str()) ); } @@ -560,8 +558,7 @@ async fn page_command_on_auto_attached_background_target_session_routes_without_ assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert_eq!(bc.active_session_id(), Some("SID-active")); assert_eq!( - bc.background_target(&second_target_id) - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target(&second_target_id), Some(session_id.as_str()) ); assert!( @@ -637,8 +634,7 @@ async fn page_bring_to_front_promotes_background_session_explicitly() { assert_eq!(bc.active_target_id(), Some(second_target_id.as_str())); assert_eq!(bc.active_session_id(), Some(session_id.as_str())); assert_eq!( - bc.background_target("TID-000000000A") - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target("TID-000000000A"), Some("SID-active") ); } @@ -656,7 +652,7 @@ async fn page_bring_to_front_on_inactive_context_restores_previous_context() { let mut inactive = BrowserContext::new("BID-B".into()); inactive.set_active_target_id("TID-active-b".to_owned()); inactive.attach_active_session("SID-active-b".to_owned()); - ctx.conn.inactive_browser_contexts.push(inactive); + ctx.conn.insert_browser_context(inactive); ctx.process_async(json!({ "id": 1023, @@ -735,8 +731,7 @@ async fn page_navigate_on_auto_attached_background_target_session_routes_without assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert_eq!(bc.active_session_id(), Some("SID-active")); assert_eq!( - bc.background_target(&second_target_id) - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target(&second_target_id), Some(session_id.as_str()) ); assert!( @@ -863,7 +858,10 @@ async fn page_stop_loading_aborts_background_pending_fetch_without_promotion() { let background = bc .background_target(&second_target_id) .expect("background target should remain parked"); - assert_eq!(background.session_id(), Some(session_id.as_str())); + assert_eq!( + bc.primary_session_id_for_target(background.target_id()), + Some(session_id.as_str()) + ); assert!( bc.parked_fetch_state(&second_target_id) .is_none_or(|state| state.is_empty()) @@ -1076,8 +1074,7 @@ async fn runtime_remove_binding_on_background_target_session_routes_without_prom assert_eq!(bc.active_target_id(), Some("TID-000000000A")); assert_eq!(bc.active_session_id(), Some("SID-active")); assert_eq!( - bc.background_target(&second_target_id) - .and_then(|target| target.session_id()), + bc.primary_session_id_for_target(&second_target_id), Some(session_id.as_str()) ); assert!( @@ -1625,7 +1622,7 @@ async fn same_context_targets_do_not_replay_bare_isolated_worlds_after_switching .active_target .runtime_slot .replace_loaded_page(Some(first_page)); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; } @@ -1719,7 +1716,7 @@ async fn same_context_targets_do_not_replay_bare_isolated_worlds_after_switching .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; @@ -1756,7 +1753,7 @@ async fn same_context_targets_do_not_replay_bare_isolated_worlds_after_switching .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; let target_a_replay_url = @@ -1820,7 +1817,7 @@ async fn same_context_targets_do_not_replay_bare_isolated_worlds_after_switching .browser_context .as_mut() .unwrap() - .devtools_session_state + .devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; 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..ab81bfc094 100644 --- a/moli-protocol/src/domains/target/tests/tests_target_creation.rs +++ b/moli-protocol/src/domains/target/tests/tests_target_creation.rs @@ -1,4 +1,5 @@ use super::*; +use moli_core::browser_host::BrowserPageOwnerKey; fn stored_cookie(name: &str, value: &str) -> moli_cookie_jar::StoredCookie { moli_cookie_jar::StoredCookie { @@ -287,7 +288,32 @@ async fn set_discover_targets_catchall_reports_tab_and_page() { .as_str() .expect("created target id") .to_owned(); - let tab_target_id = tab_id_for_page(&ctx, &page_target_id); + let tab_target_id = crate::conn::CdpConnection::derived_tab_target_id(&page_target_id); + assert!( + !ctx.sent + .iter() + .any(|message| message["method"] == json!("Target.targetCreated")), + "a live occurrence must not bypass the disabled discovery subscription" + ); + let creation_sequence = ctx + .conn + .browser_fact_snapshot_for_test() + .into_iter() + .find(|fact| { + fact.target_id().as_str() == page_target_id + && matches!( + fact.fact(), + moli_core::browser_host::BrowserFact::TargetCreated + ) + }) + .expect("created Target should have one retained Browser occurrence") + .sequence() + .get(); + let projected_before_discovery = ctx + .conn + .last_projected_browser_fact_sequence_for_test() + .expect("Target registration should claim its occurrence without discovery"); + assert!(projected_before_discovery >= creation_sequence); ctx.sent.clear(); ctx.process_async(json!({ @@ -311,6 +337,11 @@ async fn set_discover_targets_catchall_reports_tab_and_page() { && message["params"]["targetInfo"]["targetId"] == json!(page_target_id) }); assert_eq!(page_created["params"]["targetInfo"]["type"], json!("page")); + assert_eq!( + ctx.conn.last_projected_browser_fact_sequence_for_test(), + Some(projected_before_discovery), + "Target.setDiscoverTargets is a current-state resnapshot, not a second occurrence claim" + ); } #[tokio::test(flavor = "multi_thread")] @@ -1074,7 +1105,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( enable_root_target_discovery_for_test(&mut ctx); let mut browser_context = ctx.conn.new_browser_context("BID-popup-storage".to_owned()); browser_context.set_active_target_id("TID-popup-storage-opener"); - ctx.conn.browser_context = Some(browser_context); + ctx.conn.insert_browser_context(browser_context); let opener_url = format!("http://{addr}/opener"); let page = ctx .conn @@ -1254,11 +1285,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( ctx.sent.clear(); ctx.wait_until_scheduler_state("cross-origin popup navigation starts", |conn| { - 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)) - }) + conn.has_pending_document_navigation_for_target_id(&cross_origin_target_id) }) .await; let request_started = tokio::time::timeout( @@ -1286,9 +1313,7 @@ async fn window_open_hands_off_session_storage_snapshot_and_initial_storage_key( .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))), + .has_pending_document_navigation_for_target_id(&cross_origin_target_id), "attaching a DevTools session must not replace the target-owned navigation" ); ctx.sent.clear(); @@ -1457,6 +1482,21 @@ async fn resetting_opener_target_clears_live_opener_but_keeps_frame_attribution( json!("TID-opener-reset") ); + ctx.wait_until_scheduler_state("foreground popup activation before opener reset", |conn| { + conn.browser_context_by_id("BID-popup-reset") + .is_some_and(|browser_context| { + browser_context.active_target_id() == Some(popup_target_id.as_str()) + }) + }) + .await; + assert!( + ctx.conn + .promote_background_target_to_active_for_connection_async("TID-opener-reset") + .await + .expect("opener reactivation should succeed") + .is_some() + ); + ctx.conn .browser_context .as_mut() @@ -1519,10 +1559,12 @@ async fn popup_initial_empty_document_record_captures_creator_identity() { .as_str() .expect("popup target id"); - let browser_context = ctx.conn.browser_context.as_ref().unwrap(); - let initial = browser_context - .parked_target_owner_state(popup_target_id) - .and_then(|owner_state| owner_state.initial_empty_document_state()) + let initial = ctx + .conn + .target_initial_empty_document_for_owner(&BrowserPageOwnerKey::new( + "BID-popup-creator", + popup_target_id, + )) .expect("popup target should record initial empty document"); let creator = initial .creator() @@ -1603,10 +1645,12 @@ async fn popup_initial_empty_document_frame_tree_inherits_opener_origin() { assert_eq!(frame["securityOrigin"], json!("https://opener.example")); assert_eq!(frame["secureContextType"], json!("Secure")); - let browser_context = ctx.conn.browser_context.as_ref().unwrap(); - let initial = browser_context - .parked_target_owner_state(&popup_target_id) - .and_then(|owner_state| owner_state.initial_empty_document_state()) + let initial = ctx + .conn + .target_initial_empty_document_for_owner(&BrowserPageOwnerKey::new( + "BID-popup-about-blank-origin", + &popup_target_id, + )) .expect("popup target should still record initial empty document"); assert!(initial.is_on_initial_empty_document()); } @@ -1630,6 +1674,14 @@ async fn window_open_self_navigates_current_target_without_popup_target() { } })) .await; + ctx.wait_until_scheduler_state("window.open _self Browser Owner navigation", |conn| { + conn.browser_context + .as_ref() + .is_some_and(|browser_context| { + browser_context.target_url() == "data:text/html,
self target
" + }) + }) + .await; let sent = ctx.take_all(); assert!( @@ -1691,6 +1743,17 @@ async fn call_function_on_window_open_self_navigates_current_target_without_popu } })) .await; + ctx.wait_until_scheduler_state( + "Runtime.callFunctionOn window.open _self Browser Owner navigation", + |conn| { + conn.browser_context + .as_ref() + .is_some_and(|browser_context| { + browser_context.target_url() == "data:text/html,
call self target
" + }) + }, + ) + .await; let sent = ctx.take_all(); assert!( @@ -1882,6 +1945,19 @@ async fn window_open_named_target_reused_in_same_command_emits_one_page_event() })) .await; + let popup_target_id = ctx + .sent + .iter() + .find(|message| message["method"] == json!("Target.targetCreated")) + .and_then(|message| message["params"]["targetInfo"]["targetId"].as_str()) + .expect("same-command named popup target id") + .to_owned(); + ctx.wait_until_scheduler_state("same-command named popup successor navigation", |conn| { + conn.browser_context_by_id("BID-popup-name-same-command") + .and_then(|browser_context| loaded_page_for_target(browser_context, &popup_target_id)) + .is_some_and(|page| page.final_url().as_str() == "https://example.com/second-popup") + }) + .await; let sent = ctx.take_all(); let window_open_events = sent .iter() @@ -1906,8 +1982,11 @@ async fn window_open_named_target_reused_in_same_command_emits_one_page_event() let browser_context = ctx.conn.browser_context.as_ref().unwrap(); assert_eq!(browser_context.background_targets.len(), 1); assert_eq!( - browser_context.background_targets[0].target_url(), - "https://example.com/second-popup" + loaded_page_for_target(browser_context, &popup_target_id) + .expect("named popup should keep its successor Document") + .final_url() + .as_str(), + "https://example.com/second-popup", ); } @@ -1976,7 +2055,86 @@ async fn named_popup_reuse_with_catchall_discovery_only_changes_the_page_target_ })) .await; + crate::testing::wait_until_scheduler_message( + &mut ctx, + "named popup successor metadata", + |message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["targetId"] == json!(page_target_id) + && message["params"]["targetInfo"]["url"] + == json!("https://example.com/second-popup") + }, + ) + .await; + let second_sent = ctx.take_all(); + let response_position = second_sent + .iter() + .position(|message| message["id"] == json!(153)) + .unwrap_or_else(|| panic!("missing second window.open response: {second_sent:?}")); + let first_second_url_change_position = second_sent + .iter() + .position(|message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["url"] + == json!("https://example.com/second-popup") + }) + .unwrap_or_else(|| panic!("missing committed popup metadata change: {second_sent:?}")); + assert!( + response_position < first_second_url_change_position, + "Chromium returns window.open before the named Target's successor Document commits: {second_sent:?}", + ); + assert!( + second_sent.iter().all(|message| { + message["method"] != json!("Target.targetInfoChanged") + || message["params"]["targetInfo"]["targetId"] != json!(tab_target_id) + }), + "document navigation must not mirror Page targetInfoChanged onto the stable Tab host: {second_sent:?}" + ); + assert!( + second_sent.iter().any(|message| { + message["method"] == json!("Target.targetInfoChanged") + && message["params"]["targetInfo"]["targetId"] == json!(page_target_id) + && message["params"]["targetInfo"]["type"] == json!("page") + && message["params"]["targetInfo"]["url"] + == json!("https://example.com/second-popup") + }), + "catch-all discovery should report page targetInfoChanged: {second_sent:?}" + ); + let second_url_facts = ctx + .conn + .browser_fact_snapshot_for_test() + .into_iter() + .filter(|envelope| { + envelope.target_id().as_str() == page_target_id + && matches!( + envelope.fact(), + moli_core::browser_host::BrowserFact::TargetMetadataChanged { + transition, + } if transition.url() == "https://example.com/second-popup" + ) + }) + .collect::>(); + assert_eq!( + second_url_facts.len(), + 1, + "named reuse only queues navigation; the successor Document commit owns the metadata fact", + ); + let metadata_transition = match second_url_facts[0].fact() { + moli_core::browser_host::BrowserFact::TargetMetadataChanged { transition } => transition, + _ => unreachable!("filtered Target metadata fact"), + }; + assert_eq!( + metadata_transition.url(), + "https://example.com/second-popup" + ); + assert_eq!( + metadata_transition + .navigation() + .expect("navigation metadata transition") + .target_id(), + page_target_id + ); assert!( second_sent.iter().all(|message| { message["method"] != json!("Target.targetInfoChanged") @@ -2092,6 +2250,13 @@ async fn anchor_left_click_promotes_blank_target_to_foreground() { let sent = ctx.take_all(); let popup_target_id = popup_target_id_for_url(&sent, POPUP_URL); + ctx.wait_until_scheduler_state("foreground anchor popup activation", |conn| { + conn.browser_context_by_id("BID-anchor-foreground") + .is_some_and(|browser_context| { + browser_context.active_target_id() == Some(popup_target_id.as_str()) + }) + }) + .await; let browser_context = ctx .conn .browser_context_by_id("BID-anchor-foreground") @@ -3033,15 +3198,27 @@ async fn incomplete_popup_rollback_clears_tab_page_sessions_and_target_graph() { let page_target_id = "TID-popup-rollback"; let tab_target_id = ctx.conn.register_top_level_page_target(page_target_id); + ctx.conn + .register_background_target_with_creation_metadata_projection( + "BID-popup-rollback", + page_target_id, + moli_core::browser_host::BrowserTargetCreationMetadata::with_initial_empty_document( + moli_core::browser_host::BrowserInitialEmptyDocumentSeed::new("about:blank"), + ), + |browser_context, target_handle, page_residence, session_storage_access| { + browser_context.stage_background_target_with_browser_handles( + target_handle, + page_residence, + session_storage_access, + Some("SID-popup-page-primary".to_owned()), + "about:blank".to_owned(), + None, + ); + }, + ) + .expect("background Target projection should register"); { let browser_context = ctx.conn.browser_context.as_mut().expect("browser context"); - browser_context.stage_background_target( - page_target_id.to_owned(), - Some("SID-popup-page-primary".to_owned()), - "about:blank".to_owned(), - Some("about:blank".to_owned()), - None, - ); assert!(browser_context.assign_auto_attached_session_to_target( page_target_id, "SID-popup-page-aux".to_owned() @@ -3061,6 +3238,18 @@ async fn incomplete_popup_rollback_clears_tab_page_sessions_and_target_graph() { ctx.conn .register_auto_attached_session("SID-popup-page-aux".to_owned(), None); + assert!( + lifecycle::ensure_popup_initial_document_page_async(&mut ctx.conn, page_target_id).await, + "rollback regression requires a committed renderer Page owner" + ); + let page_owner = BrowserPageOwnerKey::new("BID-popup-rollback", page_target_id); + let renderer_page_id = ctx + .conn + .browser_host_state() + .navigation_owner() + .renderer_page_id_for_owner(&page_owner) + .expect("popup initial Document must transfer its renderer Page into Core"); + assert!(ctx.conn.session_route(Some("SID-popup-tab")).is_some()); assert!( ctx.conn @@ -3070,7 +3259,7 @@ async fn incomplete_popup_rollback_clears_tab_page_sessions_and_target_graph() { assert!(ctx.conn.session_route(Some("SID-popup-page-aux")).is_some()); assert_eq!(ctx.conn.tab_target_count(), 1); - popup::rollback_incomplete_popup_target_async( + lifecycle::rollback_incomplete_popup_target_async( &mut ctx.conn, Some("BID-popup-rollback"), page_target_id, @@ -3081,7 +3270,16 @@ async fn incomplete_popup_rollback_clears_tab_page_sessions_and_target_graph() { assert_eq!(ctx.conn.session_route(Some("SID-popup-page-primary")), None); assert_eq!(ctx.conn.session_route(Some("SID-popup-page-aux")), None); assert!(ctx.conn.auto_attached_sessions_for_owner(None).is_empty()); - assert_eq!(ctx.conn.tab_target_count(), 0); + assert_eq!(ctx.conn.top_level_target_graph_len(), 0); + assert_eq!( + ctx.conn + .browser_host_state() + .navigation_owner() + .renderer_page_id_for_owner(&page_owner), + None, + "popup rollback must retire the exact Core-owned renderer Page" + ); + assert_ne!(renderer_page_id, 0); assert!( ctx.conn .browser_context @@ -3098,83 +3296,36 @@ async fn incomplete_popup_rollback_clears_tab_page_sessions_and_target_graph() { } #[tokio::test(flavor = "multi_thread")] -async fn incomplete_active_popup_rollback_clears_active_slot_sessions_and_target_graph() { +async fn incomplete_popup_rollback_rejects_active_target() { let mut ctx = TestContext::new_with_target_discovery(false); - load_bc(&mut ctx, "BID-active-popup-rollback"); - let page_target_id = "TID-active-popup-rollback"; - let tab_target_id = ctx.conn.register_top_level_page_target(page_target_id); - { - let browser_context = ctx.conn.browser_context.as_mut().expect("browser context"); - browser_context.set_active_target_id(page_target_id); - browser_context.set_target_url("about:blank".to_owned()); - browser_context.begin_active_target_initial_empty_document("about:blank".to_owned()); - browser_context.attach_active_session("SID-active-popup-page-primary"); - assert!(browser_context.assign_auto_attached_session_to_target( - page_target_id, - "SID-active-popup-page-aux".to_owned() - )); - } - assert!(ctx.conn.assign_session_to_tab_target( - &tab_target_id, - "SID-active-popup-tab".to_owned(), - false - )); - ctx.conn - .register_auto_attached_session("SID-active-popup-tab".to_owned(), None); - ctx.conn - .register_auto_attached_session("SID-active-popup-page-primary".to_owned(), None); - ctx.conn - .register_auto_attached_session("SID-active-popup-page-aux".to_owned(), None); - - assert!( - ctx.conn - .session_route(Some("SID-active-popup-tab")) - .is_some() - ); - assert!( - ctx.conn - .session_route(Some("SID-active-popup-page-primary")) - .is_some() - ); - assert!( - ctx.conn - .session_route(Some("SID-active-popup-page-aux")) - .is_some() - ); - assert_eq!(ctx.conn.tab_target_count(), 1); + load_bc_with_target(&mut ctx, "BID-active-popup-rollback", page_target_id); - popup::rollback_incomplete_popup_target_async( + lifecycle::rollback_incomplete_popup_target_async( &mut ctx.conn, Some("BID-active-popup-rollback"), page_target_id, ) .await; - assert_eq!(ctx.conn.session_route(Some("SID-active-popup-tab")), None); assert_eq!( ctx.conn - .session_route(Some("SID-active-popup-page-primary")), - None - ); - assert_eq!( - ctx.conn.session_route(Some("SID-active-popup-page-aux")), - None + .browser_context + .as_ref() + .and_then(BrowserContext::active_target_id), + Some(page_target_id) ); - assert!(ctx.conn.auto_attached_sessions_for_owner(None).is_empty()); - assert_eq!(ctx.conn.tab_target_count(), 0); - assert_eq!( + assert!( ctx.conn .browser_context .as_ref() - .expect("browser context") - .active_target_id(), - None + .and_then(BrowserContext::active_target_handle) + .is_some_and(moli_core::browser_host::BrowserTargetHandle::is_live) ); assert!( ctx.conn - .primary_page_target_id_for_tab_target_id(&tab_target_id) - .is_none() + .target_session_route_for_target_id(page_target_id) + .is_some() ); } @@ -3225,7 +3376,7 @@ async fn unannounced_page_session_cleanup_clears_route_and_auto_attached_owner_i } #[tokio::test(flavor = "multi_thread")] -async fn create_target_without_debugger_wait_starts_requested_url_navigation() { +async fn create_target_without_debugger_wait_publishes_requested_url_to_browser_owner() { let mut ctx = TestContext::new(); ctx.process_async(json!({ "id": 9008, @@ -3238,16 +3389,21 @@ async fn create_target_without_debugger_wait_starts_requested_url_navigation() { .await; ctx.take_all(); - ctx.process_async(json!({ - "id": 9009, - "method": "Target.createTarget", - "params": { - "url": "data:text/html,created-target-ready" - } - })) - .await; + let scheduler_events = ctx + .process_command_only_async(json!({ + "id": 9009, + "method": "Target.createTarget", + "params": { + "url": "data:text/html,created-target-ready" + } + })) + .await; + assert!( + scheduler_events.is_empty(), + "Target.createTarget must not recreate Protocol navigation work: {scheduler_events:?}" + ); - let messages = ctx.take_all(); + let messages = ctx.sent.clone(); let attached = messages .iter() .find(|message| message["method"] == "Target.attachedToTarget") @@ -3265,6 +3421,25 @@ async fn create_target_without_debugger_wait_starts_requested_url_navigation() { }), "Target.createTarget response should retain the created target id: {messages:?}" ); + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + assert!( + ctx.conn + .runtime_session_owner_slot(Some(session_id)) + .ok() + .and_then(|slot| slot.loaded_page()) + .is_some_and(|page| moli_url::is_about_blank(page.final_url())), + "frontend completion must leave the requested URL for a later Browser Host turn" + ); + + let requested_url = "data:text/html,created-target-ready"; + ctx.wait_until_scheduler_state("created Target requested URL commit", |conn| { + conn.runtime_session_owner_slot(Some(session_id)) + .ok() + .and_then(|slot| slot.loaded_page()) + .is_some_and(|page| page.final_url().as_str() == requested_url) + }) + .await; + ctx.sent.clear(); ctx.process_async(json!({ "id": 9010, diff --git a/moli-protocol/src/domains/target/tests/tests_target_management.rs b/moli-protocol/src/domains/target/tests/tests_target_management.rs index a21120dd1d..1ed87ad3a7 100644 --- a/moli-protocol/src/domains/target/tests/tests_target_management.rs +++ b/moli-protocol/src/domains/target/tests/tests_target_management.rs @@ -1,5 +1,4 @@ use super::*; -use crate::conn::ParkedPageSessionState; /// cdp.target: closeTarget – no browser context #[tokio::test(flavor = "multi_thread")] @@ -39,13 +38,13 @@ async fn close_target_success() { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-9", "TID-000000000A"); let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.devtools_session_state + bc.devtools_session_state_mut() .page_session_state .page_lifecycle_events = true; - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .runtime_frontend_enabled = true; - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; bc.active_target @@ -67,7 +66,6 @@ async fn close_target_success() { .push_extra_header(("X-Test".into(), "1".into())); bc.set_target_security_origin("https://old.example".into()); bc.set_target_secure_context_type("InsecureScheme".into()); - bc.set_next_network_request_sequence_for_test(41); bc.set_subresource_network_emitted_record_count_for_test(12); bc.set_next_io_stream_sequence_for_test(7); bc.active_target @@ -90,7 +88,6 @@ async fn close_target_success() { assert!(!bc.active_target.owner_state.target_crash_state.is_crashed()); assert_eq!(bc.target_security_origin(), crate::conn::URL_BASE); assert_eq!(bc.target_secure_context_type(), "Secure"); - assert_eq!(bc.next_network_request_sequence_for_test(), 0); assert_eq!(bc.subresource_network_emitted_record_count_for_test(), 0); assert_eq!(bc.next_io_stream_sequence_for_test(), 0); assert_eq!( @@ -100,17 +97,17 @@ async fn close_target_success() { 0 ); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .page_session_state .page_lifecycle_events ); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .runtime_frontend_enabled ); assert!( - !bc.devtools_session_state + !bc.devtools_session_state() .runtime_session_state .inspector_enabled ); @@ -342,7 +339,7 @@ async fn close_target_emits_detached_events() { let bc = ctx.conn.browser_context.as_mut().unwrap(); bc.attach_active_session(session_id.clone()); assert!(bc.assign_auxiliary_session_to_target("TID-000000000A", "SID-aux".into())); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; @@ -389,7 +386,7 @@ async fn close_target_without_inspector_enabled_emits_inspector_detached_event() let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 29, @@ -423,7 +420,7 @@ async fn close_target_invalidates_runtime_context_and_object_without_active_page "about:blank".to_owned(), ); bc.background_targets.push(background_target); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner( "data:text/html,active", Some("SID-active"), @@ -562,19 +559,10 @@ async fn close_background_target_emits_detached_events_and_clears_attached_sessi ); let bc = ctx.conn.browser_context.as_mut().unwrap(); assert!(bc.assign_auxiliary_session_to_target("TID-000000000B", "SID-aux".into())); - bc.replace_parked_page_session_state( - "TID-000000000B".into(), - ParkedPageSessionState { - devtools_session_state: crate::conn::DevToolsSessionState { - runtime_session_state: crate::conn::TargetRuntimeSessionState { - inspector_enabled: true, - ..Default::default() - }, - ..Default::default() - }, - ..Default::default() - }, - ); + bc.primary_devtools_session_state_for_target_mut("TID-000000000B") + .expect("background Target frontend state") + .runtime_session_state + .inspector_enabled = true; ctx.process_async(json!({ "id": 121, @@ -633,13 +621,13 @@ async fn close_target_aborts_paused_request_stage_navigation() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - bc.devtools_session_state + bc.devtools_session_state_mut() .runtime_session_state .inspector_enabled = true; bc.active_target .runtime_slot .enable_primary_network_events(); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.process_async(json!({ "id": 20, @@ -732,7 +720,7 @@ async fn close_target_aborts_paused_runtime_fetch_subresource() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.conn @@ -865,7 +853,7 @@ async fn close_target_aborts_paused_response_stage_runtime_xhr_subresource() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.conn @@ -1033,7 +1021,7 @@ async fn close_target_aborts_paused_runtime_xhr_auth_subresource() { let mut bc = BrowserContext::new("BID-9".into()); bc.set_active_target_id("TID-000000000A"); bc.attach_active_session("SID-1"); - ctx.conn.browser_context = Some(bc); + ctx.conn.insert_browser_context(bc); ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) .await; ctx.conn @@ -1265,17 +1253,12 @@ async fn get_targets_reports_pending_initial_document_page_target_info() { "
loaded
", ) .await; - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .stage_background_target( - "TID-pending-targets".to_owned(), - None, - "about:blank#pending-targets".to_owned(), - None, - None, - ); + push_background_target( + &mut ctx, + "TID-pending-targets", + "about:blank#pending-targets", + None, + ); ctx.process_async(json!({"id": 20120, "method": "Target.getTargets"})) .await; @@ -1362,17 +1345,12 @@ async fn get_target_info_reports_pending_initial_document_page_target_info() { "
loaded
", ) .await; - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .stage_background_target( - "TID-pending-info".to_owned(), - None, - "about:blank#pending-info".to_owned(), - None, - None, - ); + push_background_target( + &mut ctx, + "TID-pending-info", + "about:blank#pending-info", + None, + ); ctx.process_async(json!({"id": 20121, "method": "Target.getTargetInfo", "params": {"targetId": "TID-pending-info"}})) @@ -1436,17 +1414,12 @@ async fn set_discover_targets_reports_pending_initial_document_page_target_creat ) .await; ctx.conn.set_root_target_discovery_enabled(false); - ctx.conn - .browser_context - .as_mut() - .expect("browser context") - .stage_background_target( - "TID-pending-discover".to_owned(), - None, - "about:blank#pending-discover".to_owned(), - None, - None, - ); + push_background_target( + &mut ctx, + "TID-pending-discover", + "about:blank#pending-discover", + None, + ); ctx.process_async(json!({ "id": 20122, @@ -1536,6 +1509,38 @@ async fn send_message_to_target_wraps_nested_result_in_received_message_event() assert_eq!(nested["result"]["browserContextIds"], json!(["BID-9"])); } +#[tokio::test(flavor = "multi_thread")] +async fn send_message_to_target_wraps_nested_parse_error_in_received_message_event() { + let mut ctx = TestContext::new(); + load_bc_with_target(&mut ctx, "BID-9", "TID-000000000A"); + ctx.conn + .browser_context + .as_mut() + .unwrap() + .attach_active_session("SID-9"); + + ctx.process_async(json!({ + "id": 1400, + "method": "Target.sendMessageToTarget", + "params": { + "message": "{", + "sessionId": "SID-9" + } + })) + .await; + + ctx.expect_result(1400, json!({}), None); + let event = ctx.take_one(); + assert_eq!(event["method"], "Target.receivedMessageFromTarget"); + assert_eq!(event["params"]["sessionId"], "SID-9"); + let nested: serde_json::Value = + serde_json::from_str(event["params"]["message"].as_str().expect("nested message")) + .expect("nested parse error should be valid JSON"); + assert_eq!(nested["id"], json!(null)); + assert_eq!(nested["error"]["code"], json!(-32700)); + assert_eq!(nested["error"]["message"], json!("Parse error")); +} + #[tokio::test(flavor = "multi_thread")] async fn async_send_message_to_target_wraps_nested_page_navigation() { let mut ctx = TestContext::new(); @@ -1584,7 +1589,7 @@ async fn async_send_message_to_target_wraps_nested_page_navigation() { } #[tokio::test(flavor = "multi_thread")] -async fn send_message_to_target_preserves_nested_scheduler_events() { +async fn send_message_to_target_exposes_nested_navigation_to_browser_owner() { let mut ctx = TestContext::new(); load_bc_with_target(&mut ctx, "BID-9", "TID-000000000A"); ctx.conn @@ -1593,6 +1598,7 @@ async fn send_message_to_target_preserves_nested_scheduler_events() { .unwrap() .attach_active_session("SID-9"); + let url = "data:text/html,nested scheduler event"; let raw = json!({ "id": 1402, "method": "Target.sendMessageToTarget", @@ -1601,7 +1607,7 @@ async fn send_message_to_target_preserves_nested_scheduler_events() { "id": 7702, "method": "Page.navigate", "params": { - "url": "data:text/html,nested scheduler event" + "url": url } })) .expect("nested message json"), @@ -1610,43 +1616,39 @@ async fn send_message_to_target_preserves_nested_scheduler_events() { }) .to_string(); - let outcome = ctx.conn.process_message_with_turn_outcome_async(&raw).await; - let (messages, scheduler_events) = ctx.route_completed_command_outcome_for_test(outcome).await; + let crate::conn::CdpCommandTaskStep::Pending(outer_start) = + ctx.conn.start_command_dispatch(&raw) + else { + panic!("Target.sendMessageToTarget should expose its start participant"); + }; + let completed_outer_start = outer_start.wait().await; + let crate::conn::CdpCommandTaskStep::Pending(nested_owner_wait) = ctx + .conn + .complete_pending_command_dispatch(completed_outer_start) + .await + else { + panic!("nested Page.navigate should wait for Browser Owner selection"); + }; - assert!( - messages - .iter() - .any(|message| message["id"] == json!(1402) && message["result"] == json!({})), - "outer Target.sendMessageToTarget response should be present: {messages:?}" - ); - assert!( - messages.iter().any(|message| { - if message["method"] != json!("Target.receivedMessageFromTarget") { - return false; - } - let Some(nested_raw) = message["params"]["message"].as_str() else { - return false; - }; - let Ok(nested) = serde_json::from_str::(nested_raw) else { - return false; - }; - nested["id"] == json!(7702) && nested.get("result").is_some() - }), - "nested Page.navigate result should be wrapped in a Target event: {messages:?}" - ); - assert!( - scheduler_events.iter().any(|event| matches!( - event, - crate::conn::CdpSchedulerEvent::ProtocolWorkPublished { work } - if work.kind() - == crate::domains::activity::ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction - )), - "nested Page.navigate deferred scheduler event should be returned to the outer turn: {scheduler_events:?}" + assert_eq!(ctx.browser_host_ready_len_for_test(), 1); + assert_ne!( + ctx.conn + .runtime_session_owner_target_url(Some("SID-9")) + .as_deref(), + Some(url), + "the nested frontend adapter must not start navigation before owner selection" ); + ctx.complete_one_ready_scheduler_input_for_test().await; + assert_eq!(ctx.browser_host_ready_len_for_test(), 0); + let completed = + tokio::time::timeout(std::time::Duration::from_secs(1), nested_owner_wait.wait()) + .await + .expect("Browser Owner selection should release the exact nested start participant"); + drop(completed); } #[tokio::test(flavor = "multi_thread")] -async fn send_message_to_target_without_target_errors() { +async fn send_message_to_target_without_target_rejects_unbound_session() { let mut ctx = TestContext::new(); load_bc(&mut ctx, "BID-9"); ctx.conn @@ -1665,5 +1667,5 @@ async fn send_message_to_target_without_target_errors() { })) .await; - ctx.expect_error(15, -31998, "TargetNotLoaded"); + ctx.expect_error(15, -31998, "InvalidSessionId"); } diff --git a/moli-protocol/src/domains/target/worker_target.rs b/moli-protocol/src/domains/target/worker_target.rs index 3da1456ac0..051f29be22 100644 --- a/moli-protocol/src/domains/target/worker_target.rs +++ b/moli-protocol/src/domains/target/worker_target.rs @@ -962,18 +962,6 @@ fn shared_worker_auto_attach_owner_sessions(conn: &CdpConnection) -> Vec Vec> { - conn.auto_attach_owner_sessions_for_target_type("service_worker") - .into_iter() - .filter(|owner_session_id| { - super::browser_level_auto_attach_owner_session_allowed( - conn, - owner_session_id.as_deref(), - ) - }) - .collect() -} - fn dedicated_worker_auto_attach_owner_sessions( conn: &CdpConnection, owner_page: &TargetPageResidenceIdentity, @@ -1185,12 +1173,16 @@ pub(in crate::domains) fn release_failed_dedicated_worker_target_after_debugger_ renderer_instance_id, DedicatedWorkerRetirementCause::OwnerRetired, ); - Some(commit_failed_dedicated_worker_retirement_sync( - conn, outputs, - )) + Some(commit_dedicated_worker_retirement_sync(conn, outputs)) } -pub(in crate::domains) async fn retire_dedicated_worker_targets_for_replaced_page_async( +/// Retires every DedicatedWorker owned by an exact, already-replaced Page. +/// +/// Preparation and commit deliberately remain in one owner apply turn. The +/// remaining protocol worker/session state has no asynchronous teardown +/// operation, so routing this through the generic async target cleanup path +/// would create a false scheduler boundary after Page replacement. +pub(in crate::domains) fn retire_dedicated_worker_targets_for_replaced_page( conn: &mut CdpConnection, replaced_page_owner: &TargetPageResidenceIdentity, ) -> Vec { @@ -1214,17 +1206,7 @@ pub(in crate::domains) async fn retire_dedicated_worker_targets_for_replaced_pag renderer_instance_id, DedicatedWorkerRetirementCause::OwnerRetired, ); - for output in outputs.worker_target_lifecycle_outputs { - match commit_dedicated_worker_retirement_output_async(conn, output).await { - Ok(output_events) => events.extend(output_events), - Err(output) => { - debug_assert!( - false, - "Page replacement DedicatedWorker retirement contained non-terminal output: {output:?}" - ); - } - } - } + events.extend(commit_dedicated_worker_retirement_sync(conn, outputs)); } events } @@ -1283,7 +1265,11 @@ async fn commit_dedicated_worker_retirement_output_async( Ok(events) } -fn commit_failed_dedicated_worker_retirement_sync( +/// Commits a prepared terminal retirement without an async binding-cleanup +/// round trip. Removing the protocol worker target state first makes every +/// prepared session detach a pure frontend projection while preserving the observable +/// `targetInfoChanged -> detachedFromTarget -> targetDestroyed` order. +fn commit_dedicated_worker_retirement_sync( conn: &mut CdpConnection, outputs: TargetPreparedOutputs, ) -> Vec { @@ -1350,7 +1336,7 @@ fn commit_failed_dedicated_worker_retirement_sync( output => { debug_assert!( false, - "failed DedicatedWorker retirement contained non-terminal output: {output:?}" + "synchronous DedicatedWorker retirement contained non-terminal output: {output:?}" ); } } @@ -1620,7 +1606,8 @@ fn register_service_worker_target_with_active_run( } let target_id = conn.gen_target_id(); let should_emit_created = conn.has_any_target_discovery(); - let mut auto_attach_owners = service_worker_auto_attach_owner_sessions(conn) + let mut auto_attach_owners = conn + .auto_attach_owner_sessions_for_target_type("service_worker") .into_iter() .map(|owner_session_id| { let waiting_for_debugger = @@ -2235,11 +2222,11 @@ fn remove_service_worker_target_with_reason( outputs } -pub(super) async fn close_browser_context_worker_targets_for_dispose_async( +pub(super) fn prepare_browser_context_worker_targets_for_dispose( conn: &mut CdpConnection, browser_context_id: &str, reason: &'static str, -) -> Vec { +) -> TargetPreparedOutputs { let Some((renderer_runtime, shared_worker_ids, service_worker_ids)) = conn .browser_context_by_id(browser_context_id) .map(|context| { @@ -2258,7 +2245,7 @@ pub(super) async fn close_browser_context_worker_targets_for_dispose_async( ) }) else { - return Vec::new(); + return TargetPreparedOutputs::default(); }; let mut outputs = TargetPreparedOutputs::default(); @@ -2288,23 +2275,122 @@ pub(super) async fn close_browser_context_worker_targets_for_dispose_async( )); } - worker_target_removal_background_events_async(conn, outputs).await + outputs } -async fn worker_target_removal_background_events_async( +/// Projects the closed SharedWorker/ServiceWorker targets prepared by whole +/// BrowserContext disposal without introducing a synthetic async boundary. +/// +/// The preparation routine above emits only the variants matched here. None +/// requires renderer execution: workers have already been stopped and their +/// exact attachment/version retirement capabilities carry the remaining +/// connection-local cleanup. +pub(super) fn browser_context_worker_target_removal_background_events( conn: &mut CdpConnection, outputs: TargetPreparedOutputs, ) -> Vec { - let mut command_context = crate::conn::CommandDispatchContext::default(); - let mut prepared_outputs = - ProtocolOutputPayloads::from_slot(TargetPreparedOutputSlot::from_outputs(outputs)); - emit_target_lifecycle_events( - conn, - &mut ProtocolOutputProjectionContext::new(None, &mut command_context), - Some(&mut prepared_outputs), - ) - .await; - command_context.take_protocol_events() + let mut side_effects = events::TargetProtocolSideEffects::default(); + for output in outputs.worker_target_lifecycle_outputs { + match output { + WorkerTargetLifecycleOutput::SharedWorkerAttachmentEvents { attachment, events } => { + if attachment.is_current() { + side_effects.extend_background_events(events); + } + } + WorkerTargetLifecycleOutput::ServiceWorkerVersionEvents { version, events } => { + if version.is_current() { + side_effects.extend_background_events(events); + } + } + WorkerTargetLifecycleOutput::ServiceWorkerAttachmentEvents { attachment, events } => { + if attachment.is_current() { + side_effects.extend_background_events(events); + } + } + WorkerTargetLifecycleOutput::SharedWorkerDetached { + retirement, + cleanup_plan, + } => { + if !retirement.is_current() { + continue; + } + if cleanup_plan.target_id() != retirement.identity().target_id() + || cleanup_plan.session_id() != retirement.identity().session_id() + { + tracing::warn!( + target_id = cleanup_plan.target_id(), + session_id = cleanup_plan.session_id(), + "shared-worker disposal attachment retirement did not match its cleanup plan" + ); + continue; + } + side_effects.extend_background_events( + conn.detach_worker_session_with_binding_cleanup_event_plan(cleanup_plan), + ); + retirement.retire(); + } + WorkerTargetLifecycleOutput::ServiceWorkerDetached { + retirement, + cleanup_plan, + } => { + if !retirement.is_current() { + continue; + } + if cleanup_plan.target_id() != retirement.identity().target_id() + || cleanup_plan.session_id() != retirement.identity().session_id() + { + tracing::warn!( + target_id = cleanup_plan.target_id(), + session_id = cleanup_plan.session_id(), + "service-worker disposal attachment retirement did not match its cleanup plan" + ); + continue; + } + side_effects.extend_background_events( + conn.detach_worker_session_with_binding_cleanup_event_plan(cleanup_plan), + ); + retirement.retire(); + } + WorkerTargetLifecycleOutput::SharedWorkerDestroyed { target_delta } => { + side_effects.extend_background_events( + conn.prepared_target_host_delta_event_plan(target_delta), + ); + } + WorkerTargetLifecycleOutput::ServiceWorkerRunRetired { retirement } => { + if retirement.is_current() { + retirement.retire(); + } + } + WorkerTargetLifecycleOutput::ServiceWorkerDestroyed { + retirement, + target_delta, + } => { + if !retirement.is_current() { + continue; + } + if let Some(target_delta) = target_delta { + if target_delta.target_id() != retirement.identity().target_id() { + tracing::warn!( + target_id = target_delta.target_id(), + "service-worker disposal retirement did not match its Target delta" + ); + continue; + } + side_effects.extend_background_events( + conn.prepared_target_host_delta_event_plan(target_delta), + ); + } + retirement.retire(); + } + unexpected => { + tracing::error!( + ?unexpected, + "BrowserContext worker disposal produced an unsupported lifecycle output" + ); + } + } + } + side_effects.into_background_events() } pub(super) async fn close_shared_worker_target_for_target_close_async( @@ -3711,16 +3797,10 @@ mod tests { let mut conn = CdpConnection::default(); let mut context = BrowserContext::new("BID-1".to_owned()); context.set_active_target_id("TID-page"); - let page_attachment_id = context - .active_target - .runtime_slot - .set_page_attachment_id_for_test(1); - let owner_page = TargetPageResidenceIdentity::new( - "BID-1".to_owned(), - Some("TID-page".to_owned()), - page_attachment_id, - ); - conn.browser_context = Some(context); + conn.insert_browser_context(context); + let owner_page = conn + .target_page_residence_identity_for_session(None) + .expect("dedicated worker fixture Page must be registered in Browser Core"); let owner_renderer_page = RendererPageResidenceIdentity::new( moli_core::RendererOwnerLocalHostId::new_for_testing(17), moli_core::PageId::new_for_testing(23), @@ -4644,7 +4724,7 @@ mod tests { } #[tokio::test] - async fn page_replacement_retires_only_its_owned_dedicated_workers() { + async fn page_replacement_retires_only_its_owned_dedicated_workers_synchronously() { let (mut conn, owner_page, owner_renderer_page) = dedicated_worker_fixture(); conn.set_target_discovery_for_owner(None, CdpTargetFilter::default_target_discovery()); enable_dedicated_worker_auto_attach_for_owner_page(&mut conn, false); @@ -4673,7 +4753,7 @@ mod tests { let _ = drain_target_lifecycle_events_for_test(&mut conn, loaded).await; let retained_target_id = "TID-other-worker".to_owned(); - let retained_owner = TargetPageResidenceIdentity::new_for_test( + let retained_owner = TargetPageResidenceIdentity::new( "BID-1".to_owned(), Some("TID-other-page".to_owned()), 7, @@ -4696,10 +4776,11 @@ mod tests { .unwrap() .active_target .runtime_slot - .replace_page_attachment_id_for_test(); - let messages = protocol_messages( - &retire_dedicated_worker_targets_for_replaced_page_async(&mut conn, &owner_page).await, - ); + .bump_loaded_page_generation(); + let messages = protocol_messages(&retire_dedicated_worker_targets_for_replaced_page( + &mut conn, + &owner_page, + )); assert_eq!( messages diff --git a/moli-protocol/src/lib.rs b/moli-protocol/src/lib.rs index c1b1020c59..a9da929ebb 100644 --- a/moli-protocol/src/lib.rs +++ b/moli-protocol/src/lib.rs @@ -7,6 +7,7 @@ mod cdp_projection; pub mod conn; +mod devtools_host_adapter; pub mod devtools_runtime; pub mod domains; #[cfg(feature = "test-support")] @@ -15,35 +16,54 @@ pub mod test_support; pub mod testing; pub mod version; +pub use devtools_host_adapter::{ + DevToolsCommandDispatch, DevToolsHostAdapter, DevToolsHostControl, DevToolsHostView, + DevToolsSchedulerProjection, +}; pub use devtools_runtime::*; pub use conn::{ - BackgroundCommandResponsePayload, BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection, - CdpInitialStoragePartition, CdpRendererCommandAccess, CdpRendererCommandReplacement, - CdpRendererCommandReplayDispatch, CdpRendererOwnerTurnOutcome, CdpSchedulerEvent, - CdpTargetHostLifecycleDelta, CdpTargetHostLifecycleObserver, CdpTurnOutcome, + BackgroundCommandResponsePayload, BackgroundEventSender, BackgroundNavigationCancellation, + BackgroundNavigationGateKey, BackgroundOutputClosed, BackgroundProtocolEvent, + BrowserBackgroundOutputReceiver, BrowserHostTurnDispatch, BrowserHostTurnExecutorOwner, + 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, + CompletedBrowserHostTurn, CompletedCdpCommandDispatch, + CompletedDeferredMainDocumentLoadCompletion, CompletedRuntimeProtocolMessageDispatch, + DEFAULT_CDP_PAGE_TARGET_ID, DEFAULT_CDP_TAB_TARGET_ID, DeferredMainDocumentLoadCompletionOutputAction, - DeferredMainDocumentLoadCompletionOutputInterest, DeferredMainDocumentLoadObservationId, - DeferredMainDocumentLoadPredecessorCandidate, DevToolsCommandDispatchOutcome, - DevToolsDocumentLifecycleWaitKey, DevToolsDocumentLifecycleWaitState, - DevToolsDocumentNavigationState, DevToolsPageResidenceIdentity, ParsedCdpCommand, + DeferredMainDocumentLoadCompletionOutputInterest, DeferredMainDocumentLoadCompletionReadiness, + DeferredMainDocumentLoadObservationId, DeferredMainDocumentLoadPredecessorCandidate, + DevToolsCommandDispatchOutcome, DevToolsDocumentLifecycleWaitKey, + DevToolsDocumentLifecycleWaitState, DevToolsDocumentNavigationState, + DevToolsPageResidenceIdentity, ParsedCdpCommand, PendingBrowserHostTurn, PendingCdpCommandDispatch, PendingDeferredMainDocumentLoadCompletion, - PendingRuntimeProtocolMessageDispatch, + PendingRuntimeProtocolMessageDispatch, browser_background_output_channel, }; pub use domains::activity::{ ProtocolSchedulerWork, ProtocolSchedulerWorkKind, ProtocolWorkPublishSequence, RuntimeCommandOutputBarrierCompletion, RuntimeCommandOutputBarrierPermit, RuntimeCommandOutputBarrierTerminal, RuntimeCommandOutputBarriers, }; +pub use domains::fetch::{ + CompletedDevToolsFetchCommand, DevToolsFetchCommandTaskStep, PendingDevToolsFetchCommand, +}; pub use domains::page::{ - BackgroundNavigationCompletion, CompletedPageScreencastCapture, + BackgroundNavigationCompletion, BackgroundNavigationParticipantCompletion, + BackgroundNavigationTurnDisposition, CompletedDevToolsBrowserOwnerNavigationCommand, + CompletedPageScreencastCapture, DevToolsBrowserOwnerNavigationCommandTaskStep, PageScreencastCaptureCompletion, PageScreencastCaptureStart, PageScreencastRegistration, - PageScreencastSubscriptionStatus, PendingPageScreencastCapture, build_default_raster_pdf, + PageScreencastSubscriptionStatus, PendingDevToolsBrowserOwnerNavigationCommand, + PendingPageScreencastCapture, build_default_raster_pdf, }; pub use domains::runtime::{ CompletedDevToolsRuntimeCommandDispatch, DevToolsRuntimeCommandTaskStep, PendingDevToolsRuntimeCommandDispatch, }; +pub use domains::target::{ + CompletedDevToolsBrowserOwnerContextDisposalCommand, + DevToolsBrowserOwnerContextDisposalCommandTaskStep, + PendingDevToolsBrowserOwnerContextDisposalCommand, +}; diff --git a/moli-protocol/src/test_support.rs b/moli-protocol/src/test_support.rs index f147819a28..965c92f6a8 100644 --- a/moli-protocol/src/test_support.rs +++ b/moli-protocol/src/test_support.rs @@ -9,69 +9,34 @@ use moli_core::{RendererDocumentLifecycleIdentity, RendererOutputResidenceIdentity}; use crate::{ - DeferredMainDocumentLoadCompletionOutputInterest, DeferredMainDocumentLoadObservationId, - ProtocolSchedulerWork, - conn::{CdpConnection, DocumentNavigationToken, RendererPageResidenceIdentity}, + BackgroundNavigationGateKey, DeferredMainDocumentLoadCompletionOutputInterest, + DeferredMainDocumentLoadCompletionReadiness, DeferredMainDocumentLoadObservationId, + ProtocolSchedulerWork, conn::RendererPageResidenceIdentity, }; -/// Opaque exact-token fixture for scheduler tests that need a real -/// target-owned background navigation request. -pub struct BackgroundNavigationRequestFixture { - token: DocumentNavigationToken, - cancellation: moli_fetch::FetchCancelHandle, -} - -impl BackgroundNavigationRequestFixture { - pub fn is_cancelled(&self) -> bool { - self.cancellation.is_cancelled() - } - - pub fn target_id(&self) -> &str { - &self.token.target_id - } -} - -pub fn arm_background_navigation_request( - conn: &mut CdpConnection, - loader_id: &str, -) -> BackgroundNavigationRequestFixture { - conn.install_default_browser_target(); - let target_id = conn - .browser_context - .as_ref() - .and_then(|context| context.active_target_id()) - .expect("the default browser target must have an active target") - .to_owned(); - arm_background_navigation_request_for_target(conn, &target_id, loader_id) -} - -pub fn arm_background_navigation_request_for_target( - conn: &mut CdpConnection, - target_id: &str, - loader_id: &str, -) -> BackgroundNavigationRequestFixture { - let token = conn - .browser_context - .as_mut() - .and_then(|context| { - context.start_document_navigation_for_target(target_id, loader_id.to_owned()) - }) - .expect("the active target must accept a navigation request fixture"); - let cancellation = conn - .document_navigation_cancellation_handle(&token) - .expect("the target-owned request must expose its cancellation handle"); - assert!(conn.arm_background_navigation_completion(&token, None)); - BackgroundNavigationRequestFixture { - token, - cancellation, - } +/// Constructs the exact key carried by a synthetic background-navigation +/// 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, + ) } -pub fn settle_background_navigation_request( - conn: &mut CdpConnection, - fixture: &BackgroundNavigationRequestFixture, -) -> bool { - conn.settle_background_navigation_completion(&fixture.token) +/// Constructs an already-reached exact load readiness probe for adapter-only +/// scheduling fixtures. +pub fn reached_deferred_main_document_load_readiness() -> DeferredMainDocumentLoadCompletionReadiness +{ + DeferredMainDocumentLoadCompletionReadiness::reached_for_test_support() } /// Constructs one nonzero deferred-load observation identity. @@ -110,21 +75,3 @@ pub fn root_frame_stopped_loading_work( loader_id, ) } - -pub fn root_frame_stopped_loading_work_for_target( - publish_sequence: u64, - session_ids: Vec>, - browser_context_id: String, - target_id: String, - frame_id: String, - loader_id: String, -) -> ProtocolSchedulerWork { - ProtocolSchedulerWork::root_frame_stopped_loading_for_target_test_support( - publish_sequence, - session_ids, - browser_context_id, - target_id, - frame_id, - loader_id, - ) -} diff --git a/moli-protocol/src/testing.rs b/moli-protocol/src/testing.rs index 977e3a02b1..3eff206098 100644 --- a/moli-protocol/src/testing.rs +++ b/moli-protocol/src/testing.rs @@ -4,23 +4,27 @@ //! 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; +#[cfg(test)] +use super::conn::RendererCommandDescriptor; use super::conn::{ - BackgroundProtocolEvent, CdpCommandTaskStep, CdpConnection, CdpInitialStoragePartition, - CdpSchedulerEvent, CommandDispatchContext, CommandResponseFlushPermit, - LoadedNavigationRendererAttachmentCommit, ParsedCdpCommand, PendingCdpCommandDispatch, - RuntimeInspectorResponseReady, + BackgroundEventSender, BackgroundNavigationGateKey, BackgroundProtocolEvent, + BrowserBackgroundOutputReceiver, CdpCommandTaskStep, CdpConnection, CdpInitialStoragePartition, + CdpRendererOwnerTurnOutcome, CdpSchedulerEvent, CommandDispatchContext, + CommandResponseFlushPermit, LoadedNavigationRendererAttachmentCommit, ParsedCdpCommand, + PendingCdpCommandDispatch, RuntimeInspectorResponseReady, browser_background_output_channel, }; use crate::devtools_runtime::{DevToolsCommand, DevToolsCommandResult, DevToolsError}; use crate::domains::activity::{ - ProtocolSchedulerWork, ProtocolSchedulerWorkKind, RuntimeCommandOutputBarrierCompletion, + ProtocolSchedulerWork, RuntimeCommandOutputBarrierCompletion, RuntimeCommandOutputBarrierPermit, RuntimeCommandOutputBarriers, }; use moli_core::{ - LayoutPolicy, OptionalResourceFetchMask, RendererOutputFence, RendererOutputStreamControl, - RendererOutputStreamIdentity, RendererOutputTransportMessage, runtime::NavigationRuntimeConfig, + LayoutPolicy, OptionalResourceFetchMask, RendererOutputFence, RendererOutputItem, + RendererOutputStreamControl, RendererOutputStreamIdentity, RendererOutputTransportMessage, + RendererProtocolObservation, browser_host::BrowserHostActor, runtime::NavigationRuntimeConfig, }; use moli_fetch::FetchConfig; #[cfg(test)] @@ -42,18 +46,22 @@ pub struct TestContext { pub conn: CdpConnection, pub sent: Vec, pending_runtime_deferred_replies: VecDeque, + browser_host_actor: BrowserHostActor, pending_protocol_scheduler_work: VecDeque, runtime_command_output_barriers: RuntimeCommandOutputBarriers, runtime_inspector_response_ready_rx: tokio::sync::mpsc::UnboundedReceiver, renderer_publication_rx: moli_core::RendererOutputTransportReceiver, - background_event_tx: tokio::sync::mpsc::UnboundedSender, - background_event_rx: tokio::sync::mpsc::UnboundedReceiver, + background_event_tx: BackgroundEventSender, + background_event_rx: BrowserBackgroundOutputReceiver, background_navigation_completion_tx: tokio::sync::mpsc::UnboundedSender, background_navigation_completion_rx: tokio::sync::mpsc::UnboundedReceiver, background_navigation_scheduler_enabled: bool, + background_navigation_gate: HashSet, + pending_navigation_renderer_publications: VecDeque, + blocked_navigation_renderer_streams: HashSet, } struct PendingTestRuntimeDeferredReply { @@ -81,6 +89,7 @@ impl PendingTestRuntimeDeferredReply { } enum TestSchedulerWork { + BrowserOwnerTurn, ProtocolEvents(Vec), SchedulerEvents(Vec), BackgroundEvent(BackgroundProtocolEvent), @@ -91,6 +100,38 @@ enum TestSchedulerWork { CancelRuntimeOutputBarrier(RuntimeCommandOutputBarrierPermit), } +fn renderer_publication_stream( + message: &RendererOutputTransportMessage, +) -> Option { + match message { + RendererOutputTransportMessage::Publication(publication) => { + Some(publication.cursor().stream()) + } + RendererOutputTransportMessage::StreamControl(_) + | RendererOutputTransportMessage::PageReservationReleased { .. } + | RendererOutputTransportMessage::CursorLeaseDeclared { .. } + | RendererOutputTransportMessage::CursorLeaseReleased { .. } => None, + } +} + +fn renderer_main_document_commit_stream( + message: &RendererOutputTransportMessage, +) -> Option { + let RendererOutputTransportMessage::Publication(publication) = message else { + return None; + }; + publication + .records() + .iter() + .any(|record| { + matches!( + record.item(), + RendererOutputItem::Observation(RendererProtocolObservation::MainDocumentCommit(_)) + ) + }) + .then(|| publication.cursor().stream()) +} + #[must_use = "the held test command response must be released exactly once"] pub(crate) struct TestCommandResponseFlushPermit { response_flush: CommandResponseFlushPermit, @@ -105,6 +146,7 @@ enum TestSchedulerTurnOutcome { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TestSchedulerInputKind { + BrowserOwnerInput, BackgroundEvent, BackgroundNavigationCompletion, RuntimeDeferredReply, @@ -140,6 +182,8 @@ pub(crate) fn real_layout_test_connection() -> CdpConnection { } impl TestContext { + const INACTIVE_FIXTURE_SELECTED_CONTEXT_ID: &'static str = "BID-test-context-selected-fixture"; + /// Build the default CDP test harness used by older internal unit tests. /// /// Historically those tests treated `Target.targetCreated` as a baseline @@ -202,11 +246,14 @@ impl TestContext { } pub fn from_conn(mut conn: CdpConnection) -> Self { + let (browser_host_actor, browser_host_handle) = + BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(browser_host_handle); let (renderer_publication_tx, renderer_publication_rx) = moli_core::renderer_output_transport_channel(); let (runtime_inspector_response_ready_tx, runtime_inspector_response_ready_rx) = tokio::sync::mpsc::unbounded_channel(); - let (background_event_tx, background_event_rx) = tokio::sync::mpsc::unbounded_channel(); + let (background_event_tx, background_event_rx) = browser_background_output_channel(); let (background_navigation_completion_tx, background_navigation_completion_rx) = tokio::sync::mpsc::unbounded_channel(); conn.set_renderer_publication_sender(renderer_publication_tx); @@ -215,6 +262,7 @@ impl TestContext { conn, sent: Vec::new(), pending_runtime_deferred_replies: VecDeque::new(), + browser_host_actor, pending_protocol_scheduler_work: VecDeque::new(), runtime_command_output_barriers: RuntimeCommandOutputBarriers::default(), runtime_inspector_response_ready_rx, @@ -224,6 +272,9 @@ impl TestContext { background_navigation_completion_tx, background_navigation_completion_rx, background_navigation_scheduler_enabled: false, + background_navigation_gate: HashSet::new(), + pending_navigation_renderer_publications: VecDeque::new(), + blocked_navigation_renderer_streams: HashSet::new(), } } @@ -247,6 +298,37 @@ impl TestContext { self.background_navigation_scheduler_enabled = true; } + /// Registers an inactive BrowserContext through the same Core-owned + /// topology transaction as production while retaining a stable unrelated + /// selected Context for assertions about non-promotion. + pub(crate) fn insert_inactive_browser_context_fixture( + &mut self, + browser_context: crate::conn::BrowserContext, + ) { + let browser_context_id = browser_context.id.clone(); + if self.conn.browser_context.is_none() { + self.conn + .insert_browser_context(crate::conn::BrowserContext::new( + Self::INACTIVE_FIXTURE_SELECTED_CONTEXT_ID.to_owned(), + )); + } + self.conn.insert_browser_context(browser_context); + assert!( + self.conn + .inactive_browser_contexts + .iter() + .any(|context| context.id == browser_context_id), + "fixture BrowserContext must remain inactive after Core registration" + ); + } + + pub(crate) fn inactive_browser_context_fixture_remains_unselected(&self) -> bool { + self.conn + .browser_context + .as_ref() + .is_some_and(|context| context.id == Self::INACTIVE_FIXTURE_SELECTED_CONTEXT_ID) + } + /// Loads and installs one production-shaped navigation fixture for the /// exact Page target addressed by `session_id`. /// @@ -261,6 +343,7 @@ impl TestContext { raw_url: &str, session_id: Option<&str>, ) { + self.conn.adopt_direct_browser_context_fixture_attachments(); let navigation = self .conn .load_navigation_via_runtime_for_session_owner_async(session_id, raw_url) @@ -312,50 +395,74 @@ impl TestContext { .expect("navigation fixture requires an installed browser context"); let target_id = target_id.expect("navigation fixture requires an exact target"); let renderer_output_predecessor = navigation.renderer_output_predecessor; + let post_response_continuation = navigation + .page + .take_committed_document_post_response_continuation(); + let main_document_commit = navigation + .main_document_commit + .clone() + .expect("navigation fixture must freeze its main Document commit"); + let navigation_token = self + .conn + .start_document_navigation_for_session_owner( + session_id, + main_document_commit.loader_id.clone(), + ) + .expect("navigation fixture target must admit its Browser-owned navigation"); + let renderer_agent_candidate = self + .conn + .prepare_renderer_agent_candidate_for_session_owner( + session_id, + &navigation_token, + &mut navigation.page, + ) + .expect("navigation fixture must prepare its exact renderer attachment"); let navigation_engine = navigation.navigation_engine.take(); let page_creation_artifacts = navigation.page_creation_artifacts; let final_url = navigation.final_url; - let main_document_commit = navigation - .main_document_commit - .expect("navigation fixture must retain its frozen Document commit identity"); - let page_commit = self + let replacement = self .conn - .commit_loaded_navigation_page_for_session_owner_async( + .commit_loaded_page_replacement_for_session_owner_async( session_id, + &navigation_token, navigation.page, - LoadedNavigationRendererAttachmentCommit::Prepare(None), &final_url, + &main_document_commit, + LoadedNavigationRendererAttachmentCommit::Prepare(Some(renderer_agent_candidate)), ) .await - .expect("navigation fixture target must remain installed") - .expect("navigation fixture Page commit must succeed"); - assert!( - page_commit - .committed_document_post_response_continuation - .is_none(), - "lifecycle-target fixture must not retain a DocumentCommit response gate" - ); - let _ = self + .expect("navigation fixture replacement owner must remain current") + .expect("navigation fixture Page replacement must commit"); + let renderer_page = self .conn - .commit_loaded_navigation_target_identity_for_session_owner( - session_id, - &main_document_commit, - &final_url, - ); + .renderer_page_residence_identity_for_session_owner(session_id) + .expect("navigation fixture must install an exact renderer Page"); + let page_owner = self + .conn + .target_page_owner_key_for_session(session_id) + .expect("navigation fixture must retain its exact target owner"); + assert_eq!( + replacement.owner(), + &page_owner, + "navigation fixture replacement must preserve its exact target owner" + ); + self.conn + .bind_renderer_page_output_target(renderer_page, page_owner.clone()); let (binding, _) = self .conn .bind_renderer_document_lifecycle_for_session_owner( session_id, page_creation_artifacts, - None, - target_id, - crate::domains::page::LOADER_ID.to_owned(), + Some(navigation_token), + target_id.clone(), + main_document_commit.loader_id.clone(), ); let binding = binding.expect("navigation fixture must install its exact renderer Document binding"); if let Some(navigation_engine) = navigation_engine { self.conn - .adopt_loaded_navigation_engine_for_session_owner(session_id, navigation_engine); + .adopt_loaded_navigation_engine_for_target_owner(page_owner, navigation_engine) + .expect("navigation fixture engine owner must remain exact"); } assert_eq!( self.conn @@ -363,6 +470,54 @@ impl TestContext { Some(binding.renderer_document_identity()), "navigation fixture must retain its exact renderer Document binding" ); + let primary_session_id = self + .conn + .runtime_session_owner_primary_session_id(session_id) + .or_else(|| session_id.map(str::to_owned)); + let finished = self + .conn + .finish_renderer_document_navigation_for_session_owner( + session_id, + binding + .navigation + .as_ref() + .expect("navigation fixture lifecycle binding must retain its token"), + ) + .expect("navigation fixture renderer channel must finish its exact transition"); + assert!( + finished + .renderer_call_replacements + .as_ref() + .is_none_or(|replacements| replacements.is_empty()), + "navigation setup fixture cannot own pending renderer command replays" + ); + if !finished.released_output.is_empty() { + let mut events = Vec::new(); + crate::domains::runtime::push_routed_renderer_runtime_inspector_message_batch_background_events( + &mut self.conn, + &mut events, + finished.released_output, + primary_session_id.as_deref(), + ); + let mut work = VecDeque::new(); + self.route_protocol_events_like_scheduler(events, &mut work) + .await; + self.route_test_scheduler_work_queue(&mut work).await; + } + self.conn + .clear_document_navigation_protocol_tail_for_session_owner_if_loader_matches( + session_id, + &binding.loader_id, + ); + // The fixture has no frontend response to flush. Once the Page and its + // output route are installed, release the same post-commit parser + // continuation that production releases at its response boundary. + // Otherwise DocumentCommit is visible but parser/DCL/load remain + // parked forever and the fixture represents no reachable browser + // state. + if let Some(continuation) = post_response_continuation { + continuation.release(); + } if let Some(predecessor) = renderer_output_predecessor { // Production does not expose a completed navigation response until // the Page-creation cursor has crossed ordered protocol ingress. @@ -372,6 +527,17 @@ impl TestContext { self.route_renderer_output_predecessor_before_command_response(predecessor) .await; } + let loader_id = binding.loader_id.clone(); + let description = format!("navigation fixture load for {target_id}/{loader_id}"); + self.wait_until_scheduler_state(&description, |conn| { + conn.renderer_document_lifecycle_authoritative_state_for_session_owner(session_id) + .is_some_and(|(current, snapshot)| { + current.frame_id == target_id + && current.loader_id == loader_id + && snapshot.load.is_some() + }) + }) + .await; } /// Feed a JSON-serialisable message through the async CDP entrypoint and @@ -379,6 +545,7 @@ impl TestContext { /// waiting for later external lifecycle input must use the event-wait /// helpers, which consume one renderer wake or runtime reply at a time. pub async fn process_async(&mut self, msg: impl serde::Serialize) { + self.conn.adopt_direct_browser_context_fixture_attachments(); let command = ParsedCdpCommand::from_serializable(msg) .expect("test message must be a valid serialisable CDP command"); let command_id = command.request().id(); @@ -403,6 +570,7 @@ impl TestContext { /// that command's response is routed. This mirrors Chromium's synchronous /// DevTools test client without draining unrelated page work to idle. pub async fn process_and_wait_for_response_async(&mut self, msg: impl serde::Serialize) { + self.conn.adopt_direct_browser_context_fixture_attachments(); let command = ParsedCdpCommand::from_serializable(msg) .expect("test message must be a valid serialisable CDP command"); let command_id = command.request().id(); @@ -501,6 +669,51 @@ impl TestContext { } } + /// Waits for the one exact background navigation already admitted by the + /// fixture to reach its terminal completion input. + /// + /// A committed Page residence is intentionally visible before retired + /// Page disposal and renderer replay settle. Tests that issue a command to + /// the successor Page must therefore wait on the navigation gate, not use + /// the installed Page as an implicit completion signal. + #[cfg(test)] + pub(crate) async fn wait_for_only_background_navigation_gate_to_settle( + &mut self, + description: &str, + ) { + assert_eq!( + self.background_navigation_gate.len(), + 1, + "{description} requires one exact admitted background navigation gate" + ); + let gate_key = self + .background_navigation_gate + .iter() + .next() + .expect("one background navigation gate") + .clone(); + let waited = tokio::time::timeout(TEST_SCHEDULER_INPUT_TIMEOUT, async { + while self.background_navigation_gate.contains(&gate_key) { + 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} terminal input; gate={gate_key:?}; sent={:?}; diagnostics={}", + self.sent, + self.conn.moli_memory_diagnostics() + ); + } + } + /// Feed one command through the async CDP entrypoint without completing /// deferred protocol residences afterwards, returning the scheduler events /// that production would handle after the command response turn. @@ -668,7 +881,9 @@ impl TestContext { description: &str, ) { let waited = tokio::time::timeout(TEST_SCHEDULER_INPUT_TIMEOUT, async { - while !self.pending_protocol_scheduler_work.is_empty() { + while self.browser_host_actor.has_ready_input() + || !self.pending_protocol_scheduler_work.is_empty() + { assert!( matches!( Box::pin(self.wait_for_one_test_scheduler_turn()).await, @@ -681,8 +896,9 @@ impl TestContext { .await; if waited.is_err() { panic!( - "timed out waiting for {description}; pending protocol work={:?}", - self.pending_protocol_scheduler_work + "timed out waiting for {description}; pending Browser Owner inputs={}; pending protocol work={:?}", + self.browser_host_actor.ready_len(), + self.pending_protocol_scheduler_work, ); } } @@ -692,6 +908,7 @@ impl TestContext { command: &ParsedCdpCommand, drain_after_command: bool, ) -> Vec { + let browser_owner_ready_before = self.browser_host_actor.ready_len(); 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(); @@ -724,6 +941,10 @@ impl TestContext { if drain_after_command { Box::pin(self.route_test_scheduler_causal_batch(protocol_events, scheduler_events)) .await; + Box::pin( + self.route_new_browser_owner_inputs_after_frontend_turn(browser_owner_ready_before), + ) + .await; if completed { if let Some(runtime_output_barrier) = runtime_output_barrier { Box::pin(self.release_runtime_output_barrier_like_scheduler( @@ -767,6 +988,24 @@ impl TestContext { } } + async fn route_new_browser_owner_inputs_after_frontend_turn(&mut self, ready_before: usize) { + let newly_ready = self + .browser_host_actor + .ready_len() + .saturating_sub(ready_before); + if newly_ready == 0 { + return; + } + let mut work = VecDeque::with_capacity(newly_ready); + for _ in 0..newly_ready { + // Production receives mailbox wake independently of the frontend + // completion. Route the response first, then preserve one later + // concrete owner turn for each input published by that command. + work.push_back(TestSchedulerWork::BrowserOwnerTurn); + } + Box::pin(self.route_test_scheduler_work_queue(&mut work)).await; + } + fn admit_runtime_output_barrier( &mut self, command: &ParsedCdpCommand, @@ -864,6 +1103,14 @@ impl TestContext { return false; } CdpCommandTaskStep::Pending(pending) => { + while self.browser_host_actor.has_ready_input() { + // Production polls Browser Host independently while a + // frontend command waits. In particular, Phase 4 + // Page.navigate admission cannot resolve until the + // actor selects and starts its queued Browser command. + let mut owner_work = VecDeque::from([TestSchedulerWork::BrowserOwnerTurn]); + Box::pin(self.route_test_scheduler_work_queue(&mut owner_work)).await; + } let completed = pending.wait().await; step = self .conn @@ -887,19 +1134,73 @@ impl TestContext { async fn route_renderer_output_predecessor_before_command_response( &mut self, predecessor: RendererOutputFence, + ) { + Box::pin(self.route_renderer_output_predecessor(predecessor, false)).await; + } + + async fn route_navigation_renderer_output_boundary(&mut self, boundary: RendererOutputFence) { + Box::pin(self.route_renderer_output_predecessor(boundary, true)).await; + } + + async fn route_renderer_output_predecessor( + &mut self, + predecessor: RendererOutputFence, + releases_navigation_stream: bool, ) { let mut observed_transport = Vec::new(); let cursor = predecessor.cursor(); + let predecessor_stream = cursor.stream(); + if releases_navigation_stream { + self.blocked_navigation_renderer_streams + .remove(&predecessor_stream); + } let projected = tokio::time::timeout(TEST_SCHEDULER_INPUT_TIMEOUT, async { while !self.conn.renderer_output_cursor_is_projected(cursor) { - let publication = self - .renderer_publication_rx - .recv() - .await - .expect("renderer output transport closed before command predecessor"); + // A command may address the replacement Page after Browser + // Core has installed it but before the navigation's remaining + // participants have published the renderer insertion + // boundary. Production keeps driving the independent Browser + // Owner lane in that interval. The stateful test scheduler + // must do the same instead of waiting only on the renderer + // transport while that exact stream is deliberately parked. + if self + .blocked_navigation_renderer_streams + .contains(&predecessor_stream) + && matches!( + Box::pin(self.wait_for_one_test_scheduler_turn()).await, + TestSchedulerTurnOutcome::Processed(_) + ) + { + continue; + } + let buffered_position = self + .pending_navigation_renderer_publications + .iter() + .position(|publication| { + renderer_publication_stream(publication) == Some(predecessor_stream) + && !self + .blocked_navigation_renderer_streams + .contains(&predecessor_stream) + }); + let publication = match buffered_position { + Some(position) => self + .pending_navigation_renderer_publications + .remove(position) + .expect("buffered renderer publication position should remain valid"), + None => self + .renderer_publication_rx + .recv() + .await + .expect("renderer output transport closed before command predecessor"), + }; observed_transport.push(format!("{publication:?}")); - let mut work = - VecDeque::from([TestSchedulerWork::RendererPublication(publication)]); + let mut work = VecDeque::new(); + Box::pin(self.ingest_renderer_publication_with_navigation_release( + publication, + &mut work, + releases_navigation_stream.then_some(predecessor_stream), + )) + .await; Box::pin(self.route_test_scheduler_work_queue(&mut work)).await; } }) @@ -955,7 +1256,7 @@ impl TestContext { /// outcome into a message-only vector. pub(crate) async fn route_completed_command_outcome_for_test( &mut self, - outcome: crate::CdpRendererOwnerTurnOutcome, + outcome: impl Into, ) -> (Vec, Vec) { let sent_start = self.sent.len(); let ( @@ -965,7 +1266,7 @@ impl TestContext { post_response_events, scheduler_events, renderer_output_predecessor, - ) = outcome.into_renderer_owner_turn_parts(); + ) = outcome.into().into_renderer_owner_turn_parts(); if let Some(predecessor) = renderer_output_predecessor { Box::pin(self.route_renderer_output_predecessor_before_command_response(predecessor)) .await; @@ -1096,6 +1397,50 @@ impl TestContext { .await; while let Some(item) = work.pop_front() { match item { + TestSchedulerWork::BrowserOwnerTurn => { + let dispatch = self + .browser_host_actor + .complete_next_turn(&mut self.conn) + .expect("ready Browser Host marker must select one exact turn"); + let outcome = self.conn.finish_browser_host_turn_for_test(dispatch).await; + let ( + before_renderer_output, + mut post_renderer_output, + renderer_output_boundary, + mut post_response_events, + scheduler_events, + renderer_output_predecessor, + ) = outcome.into_renderer_owner_turn_parts(); + if let Some(predecessor) = renderer_output_predecessor { + Box::pin( + self.route_renderer_output_predecessor_before_command_response( + predecessor, + ), + ) + .await; + } + if !before_renderer_output.is_empty() { + Box::pin( + self.route_protocol_events_like_scheduler(before_renderer_output, work), + ) + .await; + } + if let Some(boundary) = renderer_output_boundary { + Box::pin(self.route_navigation_renderer_output_boundary(boundary)).await; + } else { + assert!( + post_renderer_output.is_empty(), + "post-renderer output requires an exact boundary" + ); + } + post_renderer_output.append(&mut post_response_events); + if !post_renderer_output.is_empty() { + work.push_back(TestSchedulerWork::ProtocolEvents(post_renderer_output)); + } + if !scheduler_events.is_empty() { + work.push_back(TestSchedulerWork::SchedulerEvents(scheduler_events)); + } + } TestSchedulerWork::ProtocolEvents(events) => { Box::pin(self.route_protocol_events_like_scheduler(events, work)).await; } @@ -1174,10 +1519,16 @@ impl TestContext { Box::pin(self.route_protocol_events_like_scheduler(prefix, work)).await; } - let outcome = self + let gate_key = completion.background_navigation_gate_key(); + let (outcome, disposition) = self .conn .drain_background_navigation_completion_turn_async(completion) .await; + if disposition.is_terminal() + && let Some(gate_key) = gate_key.as_ref() + { + self.background_navigation_gate.remove(gate_key); + } let ( mut completion_prefix, mut completion_suffix, @@ -1198,8 +1549,7 @@ impl TestContext { .await; } if let Some(boundary) = renderer_output_boundary { - Box::pin(self.route_renderer_output_predecessor_before_command_response(boundary)) - .await; + Box::pin(self.route_navigation_renderer_output_boundary(boundary)).await; } completion_suffix.append(&mut post_response_events); while let Ok(event) = self.background_event_rx.try_recv() { @@ -1211,11 +1561,32 @@ impl TestContext { if !scheduler_events.is_empty() { work.push_back(TestSchedulerWork::SchedulerEvents(scheduler_events)); } + if disposition.is_terminal() { + if self.background_navigation_gate.is_empty() { + self.blocked_navigation_renderer_streams.clear(); + } + let mut retained = VecDeque::new(); + while let Some(publication) = self.pending_navigation_renderer_publications.pop_front() + { + let blocked = renderer_publication_stream(&publication).is_some_and(|stream| { + self.blocked_navigation_renderer_streams.contains(&stream) + }); + if blocked { + retained.push_back(publication); + } else { + work.push_back(TestSchedulerWork::RendererPublication(publication)); + } + } + self.pending_navigation_renderer_publications = retained; + } } 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 self.browser_host_actor.has_ready_input() { + work.push_back(TestSchedulerWork::BrowserOwnerTurn); + TestSchedulerInputKind::BrowserOwnerInput + } else if self.background_navigation_scheduler_enabled && let Ok(completion) = self.background_navigation_completion_rx.try_recv() { work.push_back(TestSchedulerWork::BackgroundNavigationCompletion( @@ -1234,7 +1605,9 @@ impl TestContext { TestSchedulerInputKind::RuntimeDeferredReply } Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { - if let Ok(publication) = self.renderer_publication_rx.try_recv() { + if self.background_navigation_gate.is_empty() + && let Ok(publication) = self.renderer_publication_rx.try_recv() + { work.push_back(TestSchedulerWork::RendererPublication(publication)); TestSchedulerInputKind::RendererPublication } else { @@ -1245,7 +1618,9 @@ impl TestContext { return TestSchedulerTurnOutcome::Idle; } } - } else if let Ok(publication) = self.renderer_publication_rx.try_recv() { + } else if self.background_navigation_gate.is_empty() + && let Ok(publication) = self.renderer_publication_rx.try_recv() + { work.push_back(TestSchedulerWork::RendererPublication(publication)); TestSchedulerInputKind::RendererPublication } else if let Ok(response) = self.runtime_inspector_response_ready_rx.try_recv() { @@ -1315,6 +1690,7 @@ impl TestContext { let mut work = VecDeque::new(); let background_navigation_scheduler_enabled = self.background_navigation_scheduler_enabled; + let navigation_gate_open = !self.background_navigation_gate.is_empty(); let input_kind = if !self.pending_runtime_deferred_replies.is_empty() { tokio::select! { biased; @@ -1339,7 +1715,7 @@ impl TestContext { work.push_back(TestSchedulerWork::BackgroundEvent(event)); TestSchedulerInputKind::BackgroundEvent } - maybe_publication = self.renderer_publication_rx.recv() => { + maybe_publication = self.renderer_publication_rx.recv(), if !navigation_gate_open => { let Some(publication) = maybe_publication else { return TestSchedulerTurnOutcome::Idle; }; @@ -1364,7 +1740,7 @@ impl TestContext { work.push_back(TestSchedulerWork::BackgroundEvent(event)); TestSchedulerInputKind::BackgroundEvent } - maybe_publication = self.renderer_publication_rx.recv() => { + maybe_publication = self.renderer_publication_rx.recv(), if !navigation_gate_open => { let Some(publication) = maybe_publication else { return TestSchedulerTurnOutcome::Idle; }; @@ -1484,67 +1860,43 @@ impl TestContext { scheduler_events: Vec, work: &mut VecDeque, ) { - let mut queue = VecDeque::new(); - enqueue_scheduler_events_like_scheduler(&mut queue, scheduler_events); - while let Some(TestDeferredSchedulerWork(protocol_work)) = queue.pop_front() { - self.pending_protocol_scheduler_work - .push_back(protocol_work); - let scheduler_events = self.conn.take_scheduler_events(); - if !scheduler_events.is_empty() { - work.push_back(TestSchedulerWork::SchedulerEvents(scheduler_events)); + for event in scheduler_events { + match event { + CdpSchedulerEvent::ProtocolWorkPublished { work } => { + self.pending_protocol_scheduler_work.push_back(work); + } + CdpSchedulerEvent::BackgroundNavigationStarted { key, .. } => { + self.background_navigation_gate.insert(key); + } + CdpSchedulerEvent::PageScreencastStarted { .. } => {} } } + let scheduler_events = self.conn.take_scheduler_events(); + if !scheduler_events.is_empty() { + work.push_back(TestSchedulerWork::SchedulerEvents(scheduler_events)); + } } async fn route_ready_protocol_scheduler_work_for_test_context( &mut self, work: &mut VecDeque, ) { - loop { - let selected_index = match self.pending_protocol_scheduler_work.front() { - Some(front) if front.is_ready() => 0, - Some(front) - if front.kind() == ProtocolSchedulerWorkKind::MainDocumentLoadOwnerAction => - { - let Some(index) = - self.pending_protocol_scheduler_work - .iter() - .position(|candidate| { - candidate.is_ready() - && candidate.is_top_level_location_navigation_owner_action() - }) - 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. - index - } - Some(_) | 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. + if !self.background_navigation_gate.is_empty() { + return; + } + while let Some(ready) = self + .pending_protocol_scheduler_work + .front() + .map(ProtocolSchedulerWork::is_ready) + { + if !ready { return; } let protocol_work = self .pending_protocol_scheduler_work - .remove(selected_index) + .pop_front() .expect("ready protocol work must remain resident"); + let browser_owner_ready_before = self.browser_host_actor.ready_len(); let (events, scheduler_events) = self .conn .complete_ready_protocol_scheduler_work_turn(protocol_work) @@ -1556,6 +1908,16 @@ impl TestContext { if !scheduler_events.is_empty() { work.push_back(TestSchedulerWork::SchedulerEvents(scheduler_events)); } + let newly_ready_browser_owner_inputs = self + .browser_host_actor + .ready_len() + .saturating_sub(browser_owner_ready_before); + for _ in 0..newly_ready_browser_owner_inputs { + // Production receives the same mailbox wake independently of + // protocol residence completion. Preserve that extra turn in + // the stateful fixture instead of executing recursively here. + work.push_back(TestSchedulerWork::BrowserOwnerTurn); + } } } @@ -1563,19 +1925,67 @@ impl TestContext { &mut self, publication: RendererOutputTransportMessage, work: &mut VecDeque, + ) { + Box::pin(self.ingest_renderer_publication_with_navigation_release(publication, work, None)) + .await; + } + + fn navigation_renderer_publication_must_wait( + &mut self, + publication: &RendererOutputTransportMessage, + released_navigation_stream: Option, + ) -> bool { + let Some(stream) = renderer_publication_stream(publication) else { + return false; + }; + if released_navigation_stream == Some(stream) { + self.blocked_navigation_renderer_streams.remove(&stream); + return false; + } + if self.background_navigation_gate.is_empty() { + return false; + } + if renderer_main_document_commit_stream(publication) == Some(stream) { + self.blocked_navigation_renderer_streams.insert(stream); + } + self.blocked_navigation_renderer_streams.contains(&stream) + } + + async fn ingest_renderer_publication_with_navigation_release( + &mut self, + publication: RendererOutputTransportMessage, + work: &mut VecDeque, + released_navigation_stream: Option, ) { let scheduler_events = self.conn.take_scheduler_events(); if !scheduler_events.is_empty() { + if let Some(released_navigation_stream) = released_navigation_stream { + Box::pin(self.route_scheduler_events_for_test_context(scheduler_events, work)) + .await; + Box::pin(self.route_ready_protocol_scheduler_work_for_test_context(work)).await; + Box::pin(self.ingest_renderer_publication_with_navigation_release( + publication, + work, + Some(released_navigation_stream), + )) + .await; + return; + } work.push_front(TestSchedulerWork::RendererPublication(publication)); work.push_front(TestSchedulerWork::SchedulerEvents(scheduler_events)); return; } - // Load-relative scheduling belongs to the adapter-level - // `CdpScheduler`, which captures this publication first and delays - // only its already-projected protocol batch. This protocol-only - // harness must not park the raw publication: doing so prevents the - // ordered ingress from admitting later records in the same renderer - // stream and can deadlock sequence N behind output produced at N + 1. + // A prepared Document can publish its commit facts before the + // independently transported terminal navigation participant arrives. + // Preserve the concrete transport record until Browser state commits; + // projecting it against the previous frame/loader would discard the + // exact MainDocumentCommit observation. + if self.navigation_renderer_publication_must_wait(&publication, released_navigation_stream) + { + self.pending_navigation_renderer_publications + .push_back(publication); + return; + } let outcome = self .conn .ingest_renderer_output_turn_async( @@ -1814,6 +2224,36 @@ impl TestContext { let _ = Box::pin(self.run_one_ready_test_scheduler_turn()).await; } + #[cfg(test)] + pub(crate) fn browser_host_ready_len_for_test(&self) -> usize { + self.browser_host_actor.ready_len() + } + + #[cfg(test)] + pub(crate) fn stop_browser_host_for_test(&mut self) { + let (closed_replacement, replacement_handle) = + BrowserHostActor::new(self.conn.browser_host_state()); + drop(replacement_handle); + self.browser_host_actor = closed_replacement; + } + + #[cfg(test)] + pub(crate) fn start_one_ready_browser_host_turn_for_test( + &mut self, + ) -> crate::conn::BrowserHostTurnDispatch { + self.browser_host_actor + .complete_next_turn(&mut self.conn) + .expect("test expected one ready Browser Host turn") + } + + #[cfg(test)] + pub(crate) async fn finish_browser_host_turn_for_test( + &mut self, + dispatch: crate::conn::BrowserHostTurnDispatch, + ) -> CdpRendererOwnerTurnOutcome { + self.conn.finish_browser_host_turn_for_test(dispatch).await + } + // ── Internal ────────────────────────────────────────────────────────────── fn find_and_remove(&mut self, expected: &Value, kind: &str) { @@ -1886,15 +2326,18 @@ async fn drain_scheduler_events_like_scheduler_with_materializer( ) { let mut queue = VecDeque::new(); enqueue_scheduler_events_like_scheduler(&mut queue, scheduler_events); - while let Some(TestDeferredSchedulerWork(protocol_work)) = queue.pop_front() { - assert!( - protocol_work.is_ready(), - "the stateless compatibility materializer cannot own pending protocol work; use TestContext or the production CdpScheduler" - ); - let (events, nested_scheduler_events) = conn - .complete_ready_protocol_scheduler_work_turn(protocol_work) - .await - .into_protocol_event_parts(); + while let Some(deferred_work) = queue.pop_front() { + let outcome = match deferred_work { + TestDeferredSchedulerWork::Protocol(protocol_work) => { + assert!( + protocol_work.is_ready(), + "the stateless compatibility materializer cannot own pending protocol work; use TestContext or the production CdpScheduler" + ); + conn.complete_ready_protocol_scheduler_work_turn(protocol_work) + .await + } + }; + let (events, nested_scheduler_events) = outcome.into_protocol_event_parts(); out.extend(events.into_iter().map(materialize_event)); enqueue_scheduler_events_like_scheduler(&mut queue, nested_scheduler_events); enqueue_scheduler_events_like_scheduler(&mut queue, conn.take_scheduler_events()); @@ -1912,7 +2355,9 @@ fn protocol_event_into_internal_message(event: BackgroundProtocolEvent) -> Value /// It preserves scheduler-event FIFO and concrete work residence, but does not /// model adapter client-turn predecessors. Tests that claim scheduling or /// ordering behavior must use the production `CdpScheduler` instead. -struct TestDeferredSchedulerWork(ProtocolSchedulerWork); +enum TestDeferredSchedulerWork { + Protocol(ProtocolSchedulerWork), +} #[cfg(test)] fn enqueue_scheduler_events_like_scheduler( @@ -1922,8 +2367,9 @@ fn enqueue_scheduler_events_like_scheduler( for event in events { match event { CdpSchedulerEvent::ProtocolWorkPublished { work } => { - queue.push_back(TestDeferredSchedulerWork(work)); + queue.push_back(TestDeferredSchedulerWork::Protocol(work)); } + CdpSchedulerEvent::BackgroundNavigationStarted { .. } => {} CdpSchedulerEvent::PageScreencastStarted { .. } => {} } } @@ -2147,15 +2593,19 @@ mod tests { let mut conn = CdpConnection::new(); let (publication_tx, publication_rx) = moli_core::renderer_output_transport_channel(); let (runtime_response_tx, runtime_response_rx) = tokio::sync::mpsc::unbounded_channel(); - let (background_event_tx, background_event_rx) = tokio::sync::mpsc::unbounded_channel(); + let (background_event_tx, background_event_rx) = browser_background_output_channel(); let (background_navigation_completion_tx, background_navigation_completion_rx) = tokio::sync::mpsc::unbounded_channel(); + let (browser_host_actor, browser_host_handle) = + BrowserHostActor::new(conn.browser_host_state()); + conn.install_browser_host_handle(browser_host_handle); conn.set_renderer_publication_sender(publication_tx.clone()); conn.set_runtime_inspector_response_ready_sender(runtime_response_tx); let mut ctx = TestContext { conn, sent: Vec::new(), pending_runtime_deferred_replies: VecDeque::new(), + browser_host_actor, pending_protocol_scheduler_work: VecDeque::new(), runtime_command_output_barriers: RuntimeCommandOutputBarriers::default(), runtime_inspector_response_ready_rx: runtime_response_rx, @@ -2165,6 +2615,9 @@ mod tests { background_navigation_completion_tx, background_navigation_completion_rx, background_navigation_scheduler_enabled: false, + background_navigation_gate: HashSet::new(), + pending_navigation_renderer_publications: VecDeque::new(), + blocked_navigation_renderer_streams: HashSet::new(), }; let opened = |page_id| { RendererOutputTransportMessage::from(RendererOutputStreamControl::Opened { @@ -2207,4 +2660,558 @@ mod tests { ) if stream == second_stream )); } + + #[tokio::test(flavor = "multi_thread")] + async fn background_navigation_commit_participants_resume_as_separate_inputs() { + let mut ctx = TestContext::new(); + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_async(json!({ + "id": 1, + "method": "Target.createTarget", + "params": { "url": "about:blank" } + })) + .await; + let initial_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(None) + .expect("initial target should expose its renderer attachment"); + let prepared_replay = ctx + .conn + .try_register_renderer_call_for_session_owner( + None, + 70, + Some(initial_attachment), + RendererCommandDescriptor::from_synthesized_payload( + json!({ + "id": 70, + "method": "Console.clearMessages", + "params": {}, + }) + .to_string(), + ) + .expect("test replay command should be valid"), + ) + .expect("test replay command should register"); + let (_, stale_replay_sender, replay_response_receiver) = + prepared_replay.into_parts(); + let replay_response_receiver = replay_response_receiver + .expect("command-reply replay should retain its local receiver"); + ctx.enable_background_navigation_scheduler_for_test(); + + ctx.process_async(json!({ + "id": 2, + "method": "Page.navigate", + "params": { + "url": "data:text/html,%3Cmain%3Estaged-background-commit%3C/main%3E" + } + })) + .await; + + let lifecycle = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("background navigation load should complete") + .expect("background navigation channel should remain open"); + let replacement_engine_marker = "Moli/Page-Disposal-Adoption-Test"; + let replacement_engine = ctx + .conn + .navigation_engine_with_user_agent_marker_for_test(replacement_engine_marker); + let lifecycle = lifecycle + .with_navigation_engine_for_test(replacement_engine) + .expect("fixture should replace the lifecycle engine"); + assert_eq!(lifecycle.kind(), "lifecycle"); + let gate_key = lifecycle + .background_navigation_gate_key() + .expect("lifecycle completion should retain its exact gate key"); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(lifecycle) + .await; + assert!( + !disposition.is_terminal(), + "network completion must register commit configuration instead of draining inline" + ); + + let configured = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("prepared Document configuration should complete") + .expect("background navigation channel should remain open"); + assert_eq!(configured.kind(), "participant"); + assert_eq!( + configured.background_navigation_gate_key().as_ref(), + Some(&gate_key) + ); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(configured) + .await; + assert!( + !disposition.is_terminal(), + "configuration completion must register renderer Document commit separately" + ); + + let committed = loop { + let completion = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("prepared renderer Document commit should complete") + .expect("background navigation channel should remain open"); + if completion.kind() == "main_document_body" { + let (_outcome, body_disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(completion) + .await; + assert!(body_disposition.is_terminal()); + continue; + } + break completion; + }; + assert_eq!(committed.kind(), "participant"); + assert_eq!( + committed.background_navigation_gate_key().as_ref(), + Some(&gate_key) + ); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(committed) + .await; + assert!( + !disposition.is_terminal(), + "renderer commit must keep the gate open while its Inspector replay runs" + ); + assert_eq!( + ctx.conn + .active_navigation_engine_user_agent_for_test(), + replacement_engine_marker, + "exact Target engine adoption must commit before retired Page disposal waits" + ); + assert!( + stale_replay_sender + .send(json!({ "id": 1, "result": {} })) + .is_err(), + "Document replacement must retire the old renderer response lease" + ); + + let disposed = loop { + let completion = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("retired Page disposal should complete") + .expect("background navigation channel should remain open"); + if completion.kind() == "main_document_body" { + let (_outcome, body_disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(completion) + .await; + assert!(body_disposition.is_terminal()); + continue; + } + break completion; + }; + assert_eq!(disposed.kind(), "participant"); + assert_eq!( + disposed.background_navigation_gate_key().as_ref(), + Some(&gate_key) + ); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(disposed) + .await; + assert!( + !disposition.is_terminal(), + "retired Page disposal must apply before the old renderer replay" + ); + + let replayed = loop { + let completion = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("renderer Inspector replay should complete") + .expect("background navigation channel should remain open"); + if completion.kind() == "main_document_body" { + let (_outcome, body_disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(completion) + .await; + assert!(body_disposition.is_terminal()); + continue; + } + break completion; + }; + assert_eq!(replayed.kind(), "participant"); + assert_eq!( + replayed.background_navigation_gate_key().as_ref(), + Some(&gate_key) + ); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(replayed) + .await; + assert!( + disposition.is_terminal(), + "the exact navigation gate may settle only after replay applies" + ); + + let replay_response = replay_response_receiver + .await + .expect("replay should complete the original frontend response lease"); + let response = replay_response + .output + .protocol_response(replay_response.call_id) + .expect("Console.clearMessages replay should produce a response"); + assert_eq!(response["result"], json!({})); + }) + .await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn superseded_generic_loaded_restore_cannot_install_stale_page() { + let mut ctx = TestContext::new(); + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_async(json!({ + "id": 1, + "method": "Target.createTarget", + "params": { "url": "about:blank" } + })) + .await; + let initial_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(None) + .expect("initial target should expose its renderer attachment"); + ctx.enable_background_navigation_scheduler_for_test(); + + ctx.process_async(json!({ + "id": 2, + "method": "Page.navigate", + "params": { + "url": "data:text/html,%3Cmain%3Estale-restore%3C/main%3E" + } + })) + .await; + let lifecycle = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("first background navigation should complete") + .expect("background navigation channel should remain open") + .commit_response_ready_as_loaded_for_test(&mut ctx.conn) + .await + .expect("fixture should expose a response-ready navigation"); + let first_gate_key = lifecycle + .background_navigation_gate_key() + .expect("first lifecycle completion should retain its exact gate key"); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(lifecycle) + .await; + assert!(!disposition.is_terminal()); + assert_eq!( + ctx.conn + .current_renderer_agent_attachment_id_for_session_owner(None), + Some(initial_attachment) + ); + + // A later owner input may supersede this navigation while the + // new Page is held exclusively by its move-owned restore wait. + ctx.process_async(json!({ + "id": 3, + "method": "Page.navigate", + "params": { + "url": "data:text/html,%3Cmain%3Esuccessor%3C/main%3E" + } + })) + .await; + + let mut unrelated_completions = Vec::new(); + let restored = loop { + let completion = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("first Page restore should complete") + .expect("background navigation channel should remain open"); + if completion.kind() == "main_document_body" { + let (_outcome, body_disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(completion) + .await; + assert!(body_disposition.is_terminal()); + continue; + } + if completion.kind() == "participant" + && completion.background_navigation_gate_key().as_ref() + == Some(&first_gate_key) + { + break completion; + } + unrelated_completions.push(completion); + }; + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(restored) + .await; + assert!( + !disposition.is_terminal(), + "a superseded restore must publish its rejected Page disposal" + ); + assert_eq!( + ctx.conn + .current_renderer_agent_attachment_id_for_session_owner(None), + Some(initial_attachment), + "stale restore completion must not replace the current renderer attachment" + ); + + let disposed = loop { + let completion = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("superseded Page disposal should complete") + .expect("background navigation channel should remain open"); + if completion.kind() == "main_document_body" { + let (_outcome, body_disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(completion) + .await; + assert!(body_disposition.is_terminal()); + continue; + } + if completion.kind() == "participant" + && completion.background_navigation_gate_key().as_ref() + == Some(&first_gate_key) + { + break completion; + } + unrelated_completions.push(completion); + }; + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(disposed) + .await; + assert!( + disposition.is_terminal(), + "rejected Page disposal must settle without installing or replaying the stale Page" + ); + assert_eq!( + ctx.conn + .current_renderer_agent_attachment_id_for_session_owner(None), + Some(initial_attachment), + "late stale Page disposal must not mutate the current renderer attachment" + ); + drop(unrelated_completions); + }) + .await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn background_generic_loaded_navigation_tail_resumes_as_participant_input() { + let mut ctx = TestContext::new(); + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_async(json!({ + "id": 1, + "method": "Target.createTarget", + "params": { "url": "about:blank" } + })) + .await; + let initial_attachment = ctx + .conn + .current_renderer_agent_attachment_id_for_session_owner(None) + .expect("initial target should expose its renderer attachment"); + let prepared_replay = ctx + .conn + .try_register_renderer_call_for_session_owner( + None, + 71, + Some(initial_attachment), + RendererCommandDescriptor::from_synthesized_payload( + json!({ + "id": 71, + "method": "Console.clearMessages", + "params": {}, + }) + .to_string(), + ) + .expect("test replay command should be valid"), + ) + .expect("test replay command should register"); + let (_, stale_replay_sender, replay_response_receiver) = + prepared_replay.into_parts(); + let replay_response_receiver = replay_response_receiver + .expect("command-reply replay should retain its local receiver"); + ctx.enable_background_navigation_scheduler_for_test(); + + ctx.process_async(json!({ + "id": 2, + "method": "Page.navigate", + "params": { + "url": "data:text/html,%3Cmain%3Egeneric-loaded-tail%3C/main%3E" + } + })) + .await; + + let lifecycle = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("background navigation load should complete") + .expect("background navigation channel should remain open") + .commit_response_ready_as_loaded_for_test(&mut ctx.conn) + .await + .expect("fixture should expose a response-ready navigation"); + assert_eq!(lifecycle.kind(), "lifecycle"); + let gate_key = lifecycle + .background_navigation_gate_key() + .expect("lifecycle completion should retain its exact gate key"); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(lifecycle) + .await; + assert!( + !disposition.is_terminal(), + "generic Loaded apply must publish its Page restore instead of draining it inline" + ); + assert_eq!( + ctx.conn + .current_renderer_agent_attachment_id_for_session_owner(None), + Some(initial_attachment), + "the move-owned restore wait must not install the replacement Page" + ); + + let restored = loop { + let completion = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("generic Loaded Page restore should complete") + .expect("background navigation channel should remain open"); + if completion.kind() == "main_document_body" { + let (_outcome, body_disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(completion) + .await; + assert!(body_disposition.is_terminal()); + continue; + } + break completion; + }; + assert_eq!(restored.kind(), "participant"); + assert_eq!( + restored.background_navigation_gate_key().as_ref(), + Some(&gate_key) + ); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(restored) + .await; + assert!( + !disposition.is_terminal(), + "Page restore apply must publish retired Page disposal before settling the gate" + ); + assert_ne!( + ctx.conn + .current_renderer_agent_attachment_id_for_session_owner(None), + Some(initial_attachment), + "the replacement Page must be installed only when restore completion applies" + ); + assert!( + stale_replay_sender + .send(json!({ "id": 1, "result": {} })) + .is_err(), + "restore completion apply must retire the old renderer response lease" + ); + + let disposed = loop { + let completion = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("generic Loaded retired Page disposal should complete") + .expect("background navigation channel should remain open"); + if completion.kind() == "main_document_body" { + let (_outcome, body_disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(completion) + .await; + assert!(body_disposition.is_terminal()); + continue; + } + break completion; + }; + assert_eq!(disposed.kind(), "participant"); + assert_eq!( + disposed.background_navigation_gate_key().as_ref(), + Some(&gate_key) + ); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(disposed) + .await; + assert!( + !disposition.is_terminal(), + "generic Loaded Page disposal must apply before Inspector replay" + ); + + let replayed = loop { + let completion = tokio::time::timeout( + TEST_SCHEDULER_INPUT_TIMEOUT, + ctx.background_navigation_completion_rx.recv(), + ) + .await + .expect("generic Loaded Inspector replay should complete") + .expect("background navigation channel should remain open"); + if completion.kind() == "main_document_body" { + let (_outcome, body_disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(completion) + .await; + assert!(body_disposition.is_terminal()); + continue; + } + break completion; + }; + assert_eq!(replayed.kind(), "participant"); + assert_eq!( + replayed.background_navigation_gate_key().as_ref(), + Some(&gate_key) + ); + let (_outcome, disposition) = ctx + .conn + .drain_background_navigation_completion_turn_async(replayed) + .await; + assert!( + disposition.is_terminal(), + "the generic Loaded gate may settle only after its replay applies" + ); + + let replay_response = replay_response_receiver + .await + .expect("replay should complete the original frontend response lease"); + let response = replay_response + .output + .protocol_response(replay_response.call_id) + .expect("Console.clearMessages replay should produce a response"); + assert_eq!(response["result"], json!({})); + }) + .await; + } } diff --git a/moli-renderer-v8/src/lib.rs b/moli-renderer-v8/src/lib.rs index c96534611b..46f45df85c 100644 --- a/moli-renderer-v8/src/lib.rs +++ b/moli-renderer-v8/src/lib.rs @@ -270,12 +270,12 @@ pub use runtime::{ RendererOutputTransportMessage, RendererOutputTransportReceiver, RendererOutputTransportSendError, RendererOutputTransportSender, RendererOwnerAction, RendererOwnerCommand, RendererOwnerHandle, RendererOwnerLocalHostId, RendererOwnerReply, - RendererOwnerResourceActivitySource, RendererOwnerRuntimeActivitySource, RendererPageCommand, - RendererPageCommandPending, RendererPageCommandPostResponseContinuation, - RendererPageCookieFacadeSnapshotReply, RendererPageCreationArtifacts, - RendererPageCreationDiagnostics, RendererPageCreationPhase, RendererPageCreationProgress, - RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, RendererPageDumpOptions, - RendererPageDumpStripOptions, RendererPageHandle, RendererPageReply, + RendererOwnerResourceActivitySource, RendererOwnerRuntimeActivitySource, + RendererPageClientHandle, RendererPageCommand, RendererPageCommandPending, + RendererPageCommandPostResponseContinuation, RendererPageCookieFacadeSnapshotReply, + RendererPageCreationArtifacts, RendererPageCreationDiagnostics, RendererPageCreationPhase, + RendererPageCreationProgress, RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, + RendererPageDumpOptions, RendererPageDumpStripOptions, RendererPageHandle, RendererPageReply, RendererPageReservationToken, RendererPageState, RendererPageTestingHandle, RendererPageView, RendererPendingDownloadActivation, RendererPendingDownloadResponse, RendererPendingFileChooserActivation, RendererPendingJavaScriptDialog, diff --git a/moli-renderer-v8/src/runtime/document_lifecycle.rs b/moli-renderer-v8/src/runtime/document_lifecycle.rs index 6bd5eac52c..247caa6eb4 100644 --- a/moli-renderer-v8/src/runtime/document_lifecycle.rs +++ b/moli-renderer-v8/src/runtime/document_lifecycle.rs @@ -62,6 +62,11 @@ impl RendererDocumentToken { pub const fn successor_for_testing(self) -> Self { Self::with_id(self.page_id, self.lifecycle_document_id.successor()) } + + #[doc(hidden)] + pub const fn lifecycle_document_id_for_diagnostics(self) -> u64 { + self.lifecycle_document_id.0 + } } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -1113,12 +1118,156 @@ fn trace_lifecycle_transition(event: &RendererDocumentLifecycleEvent) { kind = ?event.kind, "renderer document lifecycle transition" ); + if moli_trace::browser_owner_trace_enabled() { + let identity = RendererDocumentLifecycleIdentity { + frame: event.frame, + document: event.document, + epoch: event.epoch, + }; + let (kind, reason, last_reached) = machine_trace_lifecycle_kind(event.kind); + moli_trace::emit_browser_owner_trace_record( + &moli_trace::BrowserOwnerTraceRecord::new( + "renderer_lifecycle_reached", + "lifecycle", + "renderer-document", + "renderer-output", + ) + .with_document_lifecycle_identity(Some(machine_trace_document(identity))) + .with_renderer_lifecycle( + Some(event.sequence), + Some(kind), + reason, + last_reached, + ), + ); + if moli_trace::browser_owner_human_trace_enabled() { + trace_lifecycle_transition_human(event, identity); + } + } +} + +fn trace_lifecycle_transition_human( + event: &RendererDocumentLifecycleEvent, + identity: RendererDocumentLifecycleIdentity, +) { + tracing::info!( + target: "moli_browser_owner", + browser_instance_id = ?Option::::None, + browser_context_id = ?Option::<&str>::None, + target_id = ?Option::<&str>::None, + page_residence_generation = ?Option::::None, + navigation_request_id = ?Option::::None, + renderer_agent_attachment_id = ?Option::::None, + document_lifecycle_identity = ?Some(identity), + browser_action_id = ?Option::::None, + browser_fact_sequence = ?Option::::None, + source = "lifecycle", + navigation_origin = ?Option::<&str>::None, + owner_state_before = "renderer-document", + owner_state_after = "renderer-output", + frontend_projection_sequence = ?Option::::None, + renderer_lifecycle_sequence = event.sequence, + stage = "renderer_lifecycle_reached", + kind = ?event.kind, + "browser navigation owner trace" + ); +} + +pub(super) fn machine_trace_document( + identity: RendererDocumentLifecycleIdentity, +) -> moli_trace::BrowserOwnerTraceDocument { + moli_trace::BrowserOwnerTraceDocument::new( + identity.document.page_id.as_u64(), + identity.document.lifecycle_document_id.0, + identity.epoch.0, + ) +} + +fn machine_trace_lifecycle_kind( + kind: RendererDocumentLifecycleEventKind, +) -> (&'static str, Option<&'static str>, Option<&'static str>) { + match kind { + RendererDocumentLifecycleEventKind::Started { reason } => { + ("started", Some(machine_trace_start_reason(reason)), None) + } + RendererDocumentLifecycleEventKind::Milestone(milestone) => { + (machine_trace_milestone(milestone), None, None) + } + RendererDocumentLifecycleEventKind::Terminated { + last_reached, + reason, + } => ( + "terminated", + Some(machine_trace_termination_reason(reason)), + last_reached.map(machine_trace_milestone), + ), + } +} + +fn machine_trace_start_reason(reason: RendererLifecycleStartReason) -> &'static str { + match reason { + RendererLifecycleStartReason::InitialDocument => "initial-document", + RendererLifecycleStartReason::CrossDocumentCommit => "cross-document-commit", + RendererLifecycleStartReason::ExplicitDocumentOpen => "explicit-document-open", + RendererLifecycleStartReason::JavascriptDocumentReplacement => { + "javascript-document-replacement" + } + } +} + +fn machine_trace_termination_reason(reason: RendererDocumentTerminationReason) -> &'static str { + match reason { + RendererDocumentTerminationReason::SupersededByCrossDocumentNavigation => { + "superseded-by-cross-document-navigation" + } + RendererDocumentTerminationReason::RestartedByDocumentOpen => "restarted-by-document-open", + RendererDocumentTerminationReason::ReplacedByJavascriptResult => { + "replaced-by-javascript-result" + } + RendererDocumentTerminationReason::MainResourceLoadFailed => "main-resource-load-failed", + RendererDocumentTerminationReason::Stopped => "stopped", + RendererDocumentTerminationReason::Detached => "detached", + } +} + +fn machine_trace_milestone(milestone: RendererDocumentLifecycleMilestone) -> &'static str { + match milestone { + RendererDocumentLifecycleMilestone::DomContentLoaded => "dom-content-loaded", + RendererDocumentLifecycleMilestone::Load => "load", + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn machine_trace_lifecycle_labels_are_stable() { + assert_eq!( + machine_trace_lifecycle_kind(RendererDocumentLifecycleEventKind::Started { + reason: RendererLifecycleStartReason::CrossDocumentCommit, + }), + ("started", Some("cross-document-commit"), None) + ); + assert_eq!( + machine_trace_lifecycle_kind(RendererDocumentLifecycleEventKind::Milestone( + RendererDocumentLifecycleMilestone::DomContentLoaded, + )), + ("dom-content-loaded", None, None) + ); + assert_eq!( + machine_trace_lifecycle_kind(RendererDocumentLifecycleEventKind::Terminated { + last_reached: Some(RendererDocumentLifecycleMilestone::Load), + reason: RendererDocumentTerminationReason::SupersededByCrossDocumentNavigation, + }), + ( + "terminated", + Some("superseded-by-cross-document-navigation"), + Some("load"), + ) + ); + } + fn journal() -> RendererDocumentLifecycleJournal { RendererDocumentLifecycleJournal::new_initial_at(PageId::new_for_testing(7), 10) } diff --git a/moli-renderer-v8/src/runtime/mod.rs b/moli-renderer-v8/src/runtime/mod.rs index 8972bd1fcc..f913e7fd76 100644 --- a/moli-renderer-v8/src/runtime/mod.rs +++ b/moli-renderer-v8/src/runtime/mod.rs @@ -286,7 +286,8 @@ pub use self::owner::{ }; pub use self::owner_local::RendererPageTestingHandle; pub use self::owner_local::{ - RendererPageCommandPending, RendererPageHandle, RendererRuntimeInspectorSessionDetachGuard, + RendererPageClientHandle, RendererPageCommandPending, RendererPageHandle, + RendererRuntimeInspectorSessionDetachGuard, }; pub(crate) use self::owner_local_store::RendererPageToken; pub use self::page::{JsRuntime, JsRuntimeOwner, PendingHtmlPage, PreparedRendererDocument}; diff --git a/moli-renderer-v8/src/runtime/owner_local.rs b/moli-renderer-v8/src/runtime/owner_local.rs index e24e774b63..8f232ec16c 100644 --- a/moli-renderer-v8/src/runtime/owner_local.rs +++ b/moli-renderer-v8/src/runtime/owner_local.rs @@ -1,4 +1,4 @@ -use std::{marker::PhantomData, rc::Rc, sync::Arc}; +use std::{marker::PhantomData, ops::Deref, rc::Rc, sync::Arc}; use super::owner_local_store::{ RendererPageToken, has_current_render_runtime_owner_local_store, @@ -64,13 +64,17 @@ impl RendererAttachedPage { ( RendererPageHandle { local_executor, - render_runtime, - token: Some(self.token), - devtools_agent_token: self.devtools_agent_token, + client: RendererPageClientHandle { + render_runtime, + token: self.token, + devtools_agent_token: self.devtools_agent_token, + javascript_dialog_broker: self.javascript_dialog_broker, + devtools_target: self.devtools_target, + script_execution_control: self.script_execution_control, + _not_send: PhantomData, + }, page_context_cancel_tx: self.page_context_cancel_tx, - javascript_dialog_broker: self.javascript_dialog_broker, - devtools_target: self.devtools_target, - script_execution_control: self.script_execution_control, + open: true, committed_document_post_response_continuation: self .committed_document_post_response_continuation, _not_send: PhantomData, @@ -85,15 +89,27 @@ impl RendererAttachedPage { pub struct RendererPageHandle { local_executor: JsLocalExecutor, + client: RendererPageClientHandle, + page_context_cancel_tx: RendererPageContextCancelSender, + open: bool, + committed_document_post_response_continuation: + Option, + _not_send: PhantomData>, +} + +/// Cloneable command capability for one exact renderer Page. +/// +/// This handle can submit work and observe stable Page identity, but dropping +/// it never removes the Page from the renderer owner. The unique +/// [`RendererPageHandle`] remains the lifetime authority. +#[derive(Clone)] +pub struct RendererPageClientHandle { render_runtime: RenderRuntimeHandle, - token: Option, + token: RendererPageToken, devtools_agent_token: RendererDevToolsAgentToken, - page_context_cancel_tx: RendererPageContextCancelSender, javascript_dialog_broker: RendererJavaScriptDialogBroker, devtools_target: crate::devtools::target::RendererDevToolsTargetHandle, script_execution_control: crate::script_execution_control::RendererScriptExecutionControl, - committed_document_post_response_continuation: - Option, _not_send: PhantomData>, } @@ -137,9 +153,17 @@ impl Drop for RendererRuntimeInspectorSessionDetachGuard { } impl RendererPageHandle { + #[doc(hidden)] + pub fn take_committed_document_post_response_continuation( + &mut self, + ) -> Option { + self.committed_document_post_response_continuation.take() + } +} + +impl RendererPageClientHandle { fn token(&self) -> RendererPageToken { self.token - .expect("renderer page handle should remain open while in use") } pub fn page_id(&self) -> u64 { @@ -165,13 +189,6 @@ impl RendererPageHandle { self.devtools_target.crash_from_io(); } - #[doc(hidden)] - pub fn take_committed_document_post_response_continuation( - &mut self, - ) -> Option { - self.committed_document_post_response_continuation.take() - } - pub fn take_pending_modal_javascript_dialogs(&self) -> Vec { self.javascript_dialog_broker.take_pending() } @@ -506,11 +523,18 @@ impl RendererPageHandle { )), } } +} + +impl RendererPageHandle { + pub fn client_handle(&self) -> RendererPageClientHandle { + self.client.clone() + } pub async fn close_async(&mut self) -> Result<()> { - let Some(token) = self.token else { + if !self.open { return Ok(()); - }; + } + let token = self.client.token(); let terminated_active_execution = self .devtools_target .close("Inspector target closed with its Page handle"); @@ -527,7 +551,7 @@ impl RendererPageHandle { && has_current_render_runtime_owner_local_store() { remove_page(token); - self.token = None; + self.open = false; tracing::debug!("renderer page handle closed on owner lane"); return Ok(()); } @@ -540,7 +564,7 @@ impl RendererPageHandle { .await? { RendererOwnerReply::PageRemoved => { - self.token = None; + self.open = false; tracing::debug!("renderer page handle closed through owner command"); Ok(()) } @@ -551,6 +575,14 @@ impl RendererPageHandle { } } +impl Deref for RendererPageHandle { + type Target = RendererPageClientHandle; + + fn deref(&self) -> &Self::Target { + &self.client + } +} + impl RendererPageCommandPending { pub async fn wait(self) -> Result { let RendererPageCommandPending { @@ -615,9 +647,11 @@ impl RendererPageCommandPending { impl Drop for RendererPageHandle { fn drop(&mut self) { - let Some(token) = self.token.take() else { + if !self.open { return; - }; + } + self.open = false; + let token = self.client.token(); self.devtools_target .detach_page(token.page_id, "Inspector Page handle was dropped"); self.javascript_dialog_broker.dismiss_pending(); @@ -650,6 +684,10 @@ impl Drop for RendererPageHandle { impl RendererPageTestingHandle { pub fn new_for_testing(handle: &RendererPageHandle) -> Self { + Self::new_for_client_for_testing(&handle.client) + } + + pub fn new_for_client_for_testing(handle: &RendererPageClientHandle) -> Self { Self { render_runtime: handle.render_runtime.clone(), token: handle.token(), diff --git a/moli-renderer-v8/src/runtime/page_surface.rs b/moli-renderer-v8/src/runtime/page_surface.rs index ff1470777d..c7d9940892 100644 --- a/moli-renderer-v8/src/runtime/page_surface.rs +++ b/moli-renderer-v8/src/runtime/page_surface.rs @@ -10,7 +10,7 @@ use crate::protocol_types::{ use crate::types::{ScriptObservableOutput, ScriptObservableOutputItem}; use anyhow::bail; pub use moli_page_types::{ - DevToolsSessionKey, DocumentNodeObjectSnapshot, DocumentNodeSnapshot, + BrowserActionId, DevToolsSessionKey, DocumentNodeObjectSnapshot, DocumentNodeSnapshot, RendererAgentAttachmentId, RendererDevToolsAgentToken, RendererDomDebuggerEventListenerBreakpoint, RendererDomDebuggerXhrBreakpoint, RendererInspectorProtocolConfiguration, RendererInspectorProtocolConfigurationCommand, @@ -305,6 +305,7 @@ impl RendererDocumentSourcedSameDocumentNavigation { /// to remain current. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RendererDocumentSourcedTopLevelLocationNavigation { + browser_action_id: BrowserActionId, source_document: RendererDocumentLifecycleIdentity, request: Box, runtime_command_cause: Option, @@ -349,7 +350,9 @@ impl RendererDocumentSourcedTopLevelLocationNavigation { browser_navigation_kind: moli_fetch::BrowserNavigationRequestKind, runtime_command_cause: Option, ) -> Self { - Self { + let browser_action_id = BrowserActionId::allocate(); + let navigation = Self { + browser_action_id, source_document, request: Box::new(RendererTopLevelNavigationRequest { url, @@ -359,7 +362,30 @@ impl RendererDocumentSourcedTopLevelLocationNavigation { browser_navigation_kind, }), runtime_command_cause, + }; + if moli_trace::browser_owner_trace_enabled() { + moli_trace::emit_browser_owner_trace_record( + &moli_trace::BrowserOwnerTraceRecord::new( + "renderer_intent_published", + "renderer-intent", + "renderer-task", + "renderer-output", + ) + .with_document_lifecycle_identity(Some( + super::document_lifecycle::machine_trace_document(source_document), + )) + .with_browser_action_id(Some(browser_action_id.get())) + .with_navigation_origin(Some("renderer-intent")), + ); + if moli_trace::browser_owner_human_trace_enabled() { + trace_top_level_location_navigation_human(source_document, browser_action_id); + } } + navigation + } + + pub fn browser_action_id(&self) -> BrowserActionId { + self.browser_action_id } pub fn source_document(&self) -> RendererDocumentLifecycleIdentity { @@ -391,6 +417,62 @@ impl RendererDocumentSourcedTopLevelLocationNavigation { } } +fn trace_top_level_location_navigation_human( + source_document: RendererDocumentLifecycleIdentity, + browser_action_id: BrowserActionId, +) { + tracing::info!( + target: "moli_browser_owner", + browser_instance_id = ?Option::::None, + browser_context_id = ?Option::<&str>::None, + target_id = ?Option::<&str>::None, + page_residence_generation = ?Option::::None, + navigation_request_id = ?Option::::None, + renderer_agent_attachment_id = ?Option::::None, + document_lifecycle_identity = ?Some(source_document), + browser_action_id = browser_action_id.get(), + browser_fact_sequence = ?Option::::None, + source = "renderer-intent", + navigation_origin = "renderer-intent", + owner_state_before = "renderer-task", + owner_state_after = "renderer-output", + frontend_projection_sequence = ?Option::::None, + renderer_lifecycle_sequence = ?Option::::None, + stage = "renderer_intent_published", + "browser navigation owner trace" + ); +} + +#[cfg(test)] +mod top_level_location_navigation_identity_tests { + use super::*; + + fn source_document() -> RendererDocumentLifecycleIdentity { + let page_id = PageId::new_for_testing(17); + RendererDocumentLifecycleIdentity { + frame: RendererFrameToken { page_id }, + document: RendererDocumentToken::new_for_testing(page_id, 3), + epoch: RendererLifecycleEpoch(5), + } + } + + #[test] + fn action_identity_is_allocated_once_and_survives_transport_clone() { + let first = RendererDocumentSourcedTopLevelLocationNavigation::new( + source_document(), + "https://first.test/".to_owned(), + ); + let transported = first.clone(); + let second = RendererDocumentSourcedTopLevelLocationNavigation::new( + source_document(), + "https://first.test/".to_owned(), + ); + + assert_eq!(first.browser_action_id(), transported.browser_action_id()); + assert_ne!(first.browser_action_id(), second.browser_action_id()); + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RendererDragDataItem { pub mime_type: String, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/shared_worker_client_event.rs b/moli-renderer-v8/src/runtime/page_vm/tests/shared_worker_client_event.rs index b93b810e79..747a3e0589 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/shared_worker_client_event.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/shared_worker_client_event.rs @@ -374,16 +374,9 @@ fn shared_worker_error_rejects_a_real_page_vm_replacement() { run_page_vm_large_stack_async_test( "shared-worker-client-event-page-vm-replacement", || async move { - let (base_url, server) = spawn_path_response_http_server(vec![( - "/replacement.html", - "HTTP/1.1 200 OK", - "replacement".to_owned(), - Duration::ZERO, - )]) - .await; let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()) .expect("loader"); - let document_url = Url::parse(&format!("{base_url}/initial.html")).unwrap(); + let document_url = Url::parse("https://shared-worker-turn.test/initial.html").unwrap(); let (page_vm, _resource_source, mut page_wake_rx) = page_vm_with_bound_task_sources_and_owner_wake(&loader, document_url); let local_executor = page_vm.local_executor.clone(); @@ -416,7 +409,11 @@ fn shared_worker_error_rejects_a_real_page_vm_replacement() { .await?; let retired_root = page_vm.document_lifecycle.identity().document; - let replacement_url = format!("{base_url}/replacement.html"); + // This test owns the stale PageVm event boundary, not HTTP + // phase-one scheduling. Use a materialized response so one + // owner turn deterministically commits the replacement. + let replacement_url = + "data:text/html,%3C!doctype%20html%3E%3Cbody%3Ereplacement%3C/body%3E"; page_vm .vm_mut() .eval(&format!("location.href = {replacement_url:?}; 'queued'"))?; @@ -485,9 +482,6 @@ Promise.resolve().then(() => { }) .await .expect("SharedWorker PageVm replacement should reject the retired event"); - server - .await - .expect("SharedWorker replacement server should finish"); }, ); } diff --git a/moli-renderer-v8/src/runtime/tests.rs b/moli-renderer-v8/src/runtime/tests.rs index 5066bda7d1..d652d82c97 100644 --- a/moli-renderer-v8/src/runtime/tests.rs +++ b/moli-renderer-v8/src/runtime/tests.rs @@ -16497,7 +16497,6 @@ document.addEventListener("DOMContentLoaded", () => { // lifecycle/action tail may already be queued here. Keep that concrete // FIFO intact; the page and record filters below ignore creation output // that is unrelated to this witness. - let observed = tokio::time::timeout(Duration::from_secs(2), async { let mut observed = Vec::new(); while observed.len() < 2 { diff --git a/moli-trace/Cargo.toml b/moli-trace/Cargo.toml index 09b3d14e02..a8fc8899f4 100644 --- a/moli-trace/Cargo.toml +++ b/moli-trace/Cargo.toml @@ -5,6 +5,9 @@ version = "0.1.0" edition = "2024" [dependencies] +parking_lot = "0.12" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" [lints] workspace = true diff --git a/moli-trace/src/browser_owner.rs b/moli-trace/src/browser_owner.rs new file mode 100644 index 0000000000..3a9ff0c3d0 --- /dev/null +++ b/moli-trace/src/browser_owner.rs @@ -0,0 +1,203 @@ +use std::{ + fs::OpenOptions, + io::{BufWriter, Write}, + sync::OnceLock, +}; + +use parking_lot::Mutex; +use serde::Serialize; + +use crate::ENV_BROWSER_OWNER_TRACE_JSONL; + +const BROWSER_OWNER_TRACE_SCHEMA_VERSION: u8 = 1; + +/// Protocol-neutral renderer Document identity written to the Browser Owner +/// machine trace. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct BrowserOwnerTraceDocument { + renderer_page_id: u64, + document_generation: u64, + lifecycle_epoch: u64, +} + +impl BrowserOwnerTraceDocument { + pub fn new(renderer_page_id: u64, document_generation: u64, lifecycle_epoch: u64) -> Self { + Self { + renderer_page_id, + document_generation, + lifecycle_epoch, + } + } +} + +/// Stable, machine-readable projection of one Browser Owner trace record. +/// +/// This schema deliberately contains no URL, cookie, authorization, body, +/// frontend session, or wire command payload. Optional owner fields remain +/// explicit `null` values so an incomplete correlation cannot be mistaken for +/// a complete Browser fact. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct BrowserOwnerTraceRecord<'a> { + schema_version: u8, + browser_instance_id: Option, + browser_context_id: Option<&'a str>, + target_id: Option<&'a str>, + page_residence_generation: Option, + navigation_request_id: Option, + renderer_agent_attachment_id: Option, + document_lifecycle_identity: Option, + browser_action_id: Option, + browser_fact_sequence: Option, + source: &'a str, + navigation_origin: Option<&'a str>, + owner_state_before: &'a str, + owner_state_after: &'a str, + frontend_projection_sequence: Option, + renderer_lifecycle_sequence: Option, + renderer_lifecycle_kind: Option<&'a str>, + renderer_lifecycle_reason: Option<&'a str>, + renderer_lifecycle_last_reached: Option<&'a str>, + stage: &'a str, +} + +impl<'a> BrowserOwnerTraceRecord<'a> { + pub fn new( + stage: &'a str, + source: &'a str, + owner_state_before: &'a str, + owner_state_after: &'a str, + ) -> Self { + Self { + schema_version: BROWSER_OWNER_TRACE_SCHEMA_VERSION, + browser_instance_id: None, + browser_context_id: None, + target_id: None, + page_residence_generation: None, + navigation_request_id: None, + renderer_agent_attachment_id: None, + document_lifecycle_identity: None, + browser_action_id: None, + browser_fact_sequence: None, + source, + navigation_origin: None, + owner_state_before, + owner_state_after, + frontend_projection_sequence: None, + renderer_lifecycle_sequence: None, + renderer_lifecycle_kind: None, + renderer_lifecycle_reason: None, + renderer_lifecycle_last_reached: None, + stage, + } + } + + pub fn with_browser_instance_id(mut self, value: Option) -> Self { + self.browser_instance_id = value; + self + } + + pub fn with_browser_context_id(mut self, value: Option<&'a str>) -> Self { + self.browser_context_id = value; + self + } + + pub fn with_target_id(mut self, value: Option<&'a str>) -> Self { + self.target_id = value; + self + } + + pub fn with_page_residence_generation(mut self, value: Option) -> Self { + self.page_residence_generation = value; + self + } + + pub fn with_navigation_request_id(mut self, value: Option) -> Self { + self.navigation_request_id = value; + self + } + + pub fn with_renderer_agent_attachment_id(mut self, value: Option) -> Self { + self.renderer_agent_attachment_id = value; + self + } + + pub fn with_document_lifecycle_identity( + mut self, + value: Option, + ) -> Self { + self.document_lifecycle_identity = value; + self + } + + pub fn with_browser_action_id(mut self, value: Option) -> Self { + self.browser_action_id = value; + self + } + + pub fn with_browser_fact_sequence(mut self, value: Option) -> Self { + self.browser_fact_sequence = value; + self + } + + pub fn with_navigation_origin(mut self, value: Option<&'a str>) -> Self { + self.navigation_origin = value; + self + } + + pub fn with_frontend_projection_sequence(mut self, value: Option) -> Self { + self.frontend_projection_sequence = value; + self + } + + pub fn with_renderer_lifecycle( + mut self, + sequence: Option, + kind: Option<&'a str>, + reason: Option<&'a str>, + last_reached: Option<&'a str>, + ) -> Self { + self.renderer_lifecycle_sequence = sequence; + self.renderer_lifecycle_kind = kind; + self.renderer_lifecycle_reason = reason; + self.renderer_lifecycle_last_reached = last_reached; + self + } +} + +/// Appends one complete JSON object followed by one newline when an explicit +/// machine-trace path is configured. +/// +/// Diagnostics failures are intentionally non-fatal to browser execution. +/// The benchmark consumer treats a missing or truncated trace as its own gate +/// failure instead of allowing observability I/O to become execution authority. +pub fn emit_browser_owner_trace_record(record: &BrowserOwnerTraceRecord<'_>) { + let Some(serialized) = serde_json::to_vec(record).ok() else { + return; + }; + let Some(sink) = browser_owner_trace_jsonl_sink() else { + return; + }; + let mut writer = sink.lock(); + if writer.write_all(&serialized).is_err() || writer.write_all(b"\n").is_err() { + return; + } + let _ = writer.flush(); +} + +fn browser_owner_trace_jsonl_sink() -> Option<&'static Mutex>> { + static SINK: OnceLock>>> = OnceLock::new(); + SINK.get_or_init(|| { + let path = std::env::var_os(ENV_BROWSER_OWNER_TRACE_JSONL)?; + if path.is_empty() { + return None; + } + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .ok() + .map(BufWriter::new) + .map(Mutex::new) + }) + .as_ref() +} diff --git a/moli-trace/src/flags.rs b/moli-trace/src/flags.rs index 9d0822a0dd..ba85d00a4f 100644 --- a/moli-trace/src/flags.rs +++ b/moli-trace/src/flags.rs @@ -1,6 +1,8 @@ use std::sync::OnceLock; pub const ENV_CDP_NAV_TIMING: &str = "MOLI_CDP_NAV_TIMING"; +pub const ENV_BROWSER_OWNER_TRACE: &str = "MOLI_BROWSER_OWNER_TRACE"; +pub const ENV_BROWSER_OWNER_TRACE_JSONL: &str = "MOLI_BROWSER_OWNER_TRACE_JSONL"; pub const ENV_CDP_RUNTIME_TRACE: &str = "MOLI_CDP_RUNTIME_TRACE"; pub const ENV_CMD_PROBE: &str = "MOLI_CMD_PROBE"; pub const ENV_V8_EXCEPTION_PROBE: &str = "MOLI_V8_EXCEPTION_PROBE"; @@ -19,6 +21,19 @@ pub fn cdp_nav_timing_enabled() -> bool { *FLAG.get_or_init(|| env_flag_present(ENV_CDP_NAV_TIMING)) } +pub fn browser_owner_trace_enabled() -> bool { + static FLAG: OnceLock = OnceLock::new(); + *FLAG.get_or_init(|| { + browser_owner_human_trace_enabled() + || std::env::var_os(ENV_BROWSER_OWNER_TRACE_JSONL).is_some_and(|path| !path.is_empty()) + }) +} + +pub fn browser_owner_human_trace_enabled() -> bool { + static FLAG: OnceLock = OnceLock::new(); + *FLAG.get_or_init(|| env_flag_present(ENV_BROWSER_OWNER_TRACE)) +} + pub fn cdp_runtime_trace_enabled() -> bool { static FLAG: OnceLock = OnceLock::new(); *FLAG.get_or_init(|| env_flag_present(ENV_CDP_RUNTIME_TRACE)) diff --git a/moli-trace/src/lib.rs b/moli-trace/src/lib.rs index fc60aa37c6..fb231b3614 100644 --- a/moli-trace/src/lib.rs +++ b/moli-trace/src/lib.rs @@ -3,10 +3,14 @@ //! These flags are intentionally process-global and cached after first read: //! they are diagnostics controls, not dynamic runtime configuration. +mod browser_owner; mod dom_binding; mod flags; mod promise_hook; +pub use browser_owner::{ + BrowserOwnerTraceDocument, BrowserOwnerTraceRecord, emit_browser_owner_trace_record, +}; pub use dom_binding::{ DomBindingOperationStats, record_dom_binding_operation, take_dom_binding_operation_stats, }; diff --git a/moli-trace/src/tests.rs b/moli-trace/src/tests.rs index 45b2a283fb..05e7422ada 100644 --- a/moli-trace/src/tests.rs +++ b/moli-trace/src/tests.rs @@ -2,6 +2,33 @@ use std::time::Duration; use super::*; +#[test] +fn browser_owner_machine_trace_schema_is_explicit_and_redacted() { + let record = BrowserOwnerTraceRecord::new( + "page_replacement_committed", + "network", + "commit-ready", + "page-resident", + ) + .with_browser_instance_id(Some(7)) + .with_browser_context_id(Some("context-1")) + .with_target_id(Some("target-1")) + .with_page_residence_generation(Some(3)) + .with_navigation_request_id(Some(11)) + .with_document_lifecycle_identity(Some(BrowserOwnerTraceDocument::new(5, 2, 1))) + .with_browser_action_id(Some(13)) + .with_navigation_origin(Some("frontend-command")); + + let value = serde_json::to_value(record).expect("trace record should serialize"); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["browser_instance_id"], 7); + assert_eq!(value["document_lifecycle_identity"]["renderer_page_id"], 5); + assert!(value.get("session_id").is_none()); + assert!(value.get("url").is_none()); + assert!(value.get("body").is_none()); + assert!(value.get("authorization").is_none()); +} + #[test] fn dom_binding_stats_are_empty_when_flag_disabled() { record_dom_binding_operation("appendChild", Duration::from_micros(7));