mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(inspector): address protocol review findings
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md
|
||||
2026-08-23-cross-realm-cdp-inspector.md: 2b4860a84982b0ed6bf749c6b408dbda9a16416e
|
||||
2026-08-23-cross-realm-cdp-inspector.zh.md: 64e52fe9d57d7c30e9c16358f2b1b7345f815849
|
||||
2026-08-23-cross-realm-cdp-inspector.md: e6e1d48da4f7b2dfab2772e24cb49711e3e50f5a
|
||||
2026-08-23-cross-realm-cdp-inspector.zh.md: 9d67868caf9b4e5e0eb06583facd608b71468b22
|
||||
|
||||
@@ -28,7 +28,7 @@ Chrome DevTools consumes one page-type target. Runtime methods route by executio
|
||||
|
||||
Both MessagePort and WebSocket carriers use the same JSON value set and discriminated frames. A source identifies one logical producer and one connection generation, declares capabilities and topics, sends an initial replacement, then appends sequence-numbered batches. The Worker rejects malformed, oversized, stale-generation, and undeclared-topic frames before reading domain fields.
|
||||
|
||||
Delivery is ordered and best-effort. Producers never wait for an acknowledgement on an application path. A bounded producer queue reports dropped prefixes through sequence gaps; the Worker requests a new snapshot after an unexplained gap. Domain stores retain bounded state and explicitly close unfinished operations when a source disconnects.
|
||||
Delivery is ordered and best-effort. Producers never wait for an acknowledgement on an application path. A bounded producer queue reports dropped prefixes through sequence gaps; the Host MessagePort carrier permits one append batch in flight and sends the next after the Worker acknowledges consumption. The Worker requests a new snapshot after an unexplained gap. Domain stores retain bounded state and explicitly close unfinished operations when a source disconnects.
|
||||
|
||||
Runtime frames use closed command and result unions instead of method strings with untyped parameter records. Every request carries a source id, source generation, DevTools Runtime session id, request id, and command. Every result repeats those identities and the command discriminant. Console lifecycle/events, chunked source reads, and non-CDP semantic queries have separate correlated frame families. RemoteObject values, previews, property descriptors, call arguments, exceptions, Console events, debugger frames, scripts, and errors have dedicated exact decoders.
|
||||
|
||||
@@ -38,7 +38,7 @@ Runtime frames use closed command and result unions instead of method strings wi
|
||||
|
||||
The Client Runtime subset covers `Runtime.evaluate`, `Runtime.getProperties`, `Runtime.callFunctionOn`, `Runtime.awaitPromise`, `Runtime.releaseObject`, `Runtime.releaseObjectGroup`, and `Runtime.globalLexicalScopeNames`. The Client executes commands in its page realm and retains live objects in a table isolated by DevTools Runtime session. It returns opaque handles and JSON-safe metadata; the Worker validates the result and assigns a connection-local CDP object id. An object argument may be used only by the same Client source generation and DevTools session. Closing the source, disabling Runtime, closing DevTools, releasing an object, or releasing an object group removes the corresponding handles.
|
||||
|
||||
JavaScript exceptions are successful Runtime responses carrying `exceptionDetails`; transport failures use a separate error union. Finite command deadlines, object counts, property counts, source bytes, and frame bytes bound retained or returned state.
|
||||
JavaScript exceptions are successful Runtime responses carrying `exceptionDetails`; transport failures use a separate error union. A Worker deadline sends request-scoped cancellation to the Client. Handles allocated for a response remain provisional until the Worker acknowledges that response, so cancellation and late responses cannot leave unreachable objects. Finite command deadlines, object counts, property counts, source bytes, and frame bytes bound retained or returned state.
|
||||
|
||||
The Client Console observer preserves the original page call and asynchronously emits one event per enabled DevTools session. Each session serializes arguments into its own `console` object group, so disconnect, Runtime disable, or `Runtime.discardConsoleEntries` can release one connection without invalidating another. Context and Fiber arguments use the same semantic reference and DOM reverse mapping as evaluation results.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Chrome DevTools 消费一个 page 类型 target。Runtime 方法按 execution co
|
||||
|
||||
MessagePort 与 WebSocket carrier 使用同一组 JSON 值和判别联合帧。source 标识一个逻辑 producer 和一个连接 generation,声明 capability 与 topic,发送初始 replace,再追加带 sequence 的 batch。Worker 在读取 domain 字段前拒绝畸形、超限、旧 generation 和未声明 topic 的帧。
|
||||
|
||||
投递有序且尽力而为。producer 不在应用路径上等待 acknowledgement。有界 producer 队列通过 sequence gap 报告被丢弃的前缀;无法解释的 gap 会让 Worker 请求新 snapshot。domain store 只保留有界状态,并在 source 断开时明确关闭未完成操作。
|
||||
投递有序且尽力而为。producer 不在应用路径上等待 acknowledgement。有界 producer 队列通过 sequence gap 报告被丢弃的前缀;Host MessagePort carrier 同时只允许一个 append batch 在途,并在 Worker 确认消费后发送下一批。无法解释的 gap 会让 Worker 请求新 snapshot。domain store 只保留有界状态,并在 source 断开时明确关闭未完成操作。
|
||||
|
||||
Runtime 帧使用封闭的 command 与 result 联合,而不是 method 字符串加无类型 parameter record。每个 request 携带 source id、source generation、DevTools Runtime session id、request id 和 command;每个 result 重复这些身份与 command 判别符。Console lifecycle/event、分块 source 读取和非 CDP 语义查询使用各自独立的关联帧。RemoteObject value、preview、property descriptor、call argument、exception、Console event、debugger frame、script 与 error 都有独立的精确 decoder。
|
||||
|
||||
@@ -38,7 +38,7 @@ Runtime 帧使用封闭的 command 与 result 联合,而不是 method 字符
|
||||
|
||||
Client Runtime 子集包括 `Runtime.evaluate`、`Runtime.getProperties`、`Runtime.callFunctionOn`、`Runtime.awaitPromise`、`Runtime.releaseObject`、`Runtime.releaseObjectGroup` 和 `Runtime.globalLexicalScopeNames`。Client 在页面 realm 中执行命令,并在按 DevTools Runtime session 隔离的表中保留实时对象。Client 只返回不透明 handle 与 JSON-safe metadata;Worker 验证结果并分配连接私有的 CDP object id。对象参数只能由同一 Client source generation 与 DevTools session 使用。source 断开、Runtime disable、DevTools 关闭、释放对象或释放 object group 都会移除对应 handle。
|
||||
|
||||
JavaScript exception 是携带 `exceptionDetails` 的成功 Runtime response;transport failure 使用独立的 error 联合。有限的命令 deadline、对象数、属性数、source 字节数与帧字节数约束保留或返回的状态。
|
||||
JavaScript exception 是携带 `exceptionDetails` 的成功 Runtime response;transport failure 使用独立的 error 联合。Worker deadline 会向 Client 发送 request-scoped cancellation。response 分配的 handle 在 Worker 确认该 response 前保持 provisional,因此 cancellation 和 late response 不会留下无法访问的对象。有限的命令 deadline、对象数、属性数、source 字节数与帧字节数约束保留或返回的状态。
|
||||
|
||||
Client Console observer 保持原始页面调用行为,并为每个已启用的 DevTools session 异步发出一份 event。每个 session 把 argument 序列化到自己的 `console` object group,因此断联、Runtime disable 或 `Runtime.discardConsoleEntries` 可以释放一条连接而不使其他连接失效。Context 与 Fiber argument 使用和求值结果相同的语义引用及 DOM 反向映射。
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md
|
||||
2026-08-26-inspector-execution-realms-and-protocol-planes.md: 67f37add1a788a450643a362fac24426ff80a43e
|
||||
2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: be4dd947fd999d0b9c09f7b4c821390b5b3b1245
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md
|
||||
2026-08-26-inspector-execution-realms-and-protocol-planes.md: e8bff0661d2d0c86c216b0a18e2feb7a2c786709
|
||||
2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: 2db6307d1bfc536ac5e8b0f5d6f03e4cfe334989
|
||||
+18
-26
@@ -1,20 +1,20 @@
|
||||
# Agent Note: Inspector execution realms and protocol planes
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Inspector package executes code in three JavaScript environments: the browser Client, the Host Node main thread, and an Inspector Worker thread. Its current source tree mixes execution ownership with feature names: browser and Host producers have unrelated layouts, Worker-executed Client and Host backends sit under a generic backend directory, and one protocol directory combines transport frames, Cordis data, network observations, and CDP-oriented Runtime values. A file path therefore does not establish where code runs or which identifiers it may own.
|
||||
The Inspector package executes code in three JavaScript environments: the browser Client, the Host Node main thread, and an Inspector Worker thread. Without execution-oriented directories, feature names alone do not establish where code runs or which identifiers it may own.
|
||||
|
||||
This ambiguity is risky because Host and Client support intentionally differs while their architecture must remain comparable. Host Runtime and Debugger delegate to Node's inspector protocol; Client Runtime and Console simulate the same backend semantics over an internal bridge. If their files, interfaces, and unsupported operations diverge structurally, each new protocol method encourages a second routing model. Likewise, consumers that only need the Cordis runtime tree must not inherit debugger activation, Chrome connection state, or CDP identifiers.
|
||||
|
||||
The [cross-realm CDP inspector decision](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md) continues to own Worker, transport, Runtime, debugger, and security behavior. The [Cordis runtime tree inspection decision](../../implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md) continues to own Cordis tree semantics, object routing, and DOM projection. This proposal owns source placement, dependency direction, and the separation between domain data, backend semantics, internal transport, and Chrome CDP state.
|
||||
The [cross-realm CDP inspector decision](2026-08-23-cross-realm-cdp-inspector.md) owns Worker, transport, Runtime, debugger, and security behavior. The [Cordis runtime tree inspection decision](2026-08-24-cordis-runtime-tree-inspection.md) owns Cordis tree semantics, object routing, and DOM projection. This decision owns source placement, dependency direction, and the separation between domain data, backend semantics, internal transport, and Chrome CDP state.
|
||||
|
||||
## Proposal
|
||||
## Decision
|
||||
|
||||
Top-level source directories will identify execution ownership. `client/` will contain only browser Client code, `host/` only Host Node-main-thread code, `worker/` only Worker-thread code, and `shared/` code that is safe in every environment. A module that executes in the Worker on behalf of a Client still belongs under `worker/`, not `client/`.
|
||||
Top-level source directories identify execution ownership. `client/` contains only browser Client code, `host/` only Host Node-main-thread code, `worker/` only Worker-thread code, and `shared/` code that is safe in every environment. A module that executes in the Worker on behalf of a Client belongs under `worker/`, not `client/`.
|
||||
|
||||
The repository-required `src/index.ts` and `src/invariant.ts` discovery entries are the only root-level source exceptions. They expose the Host package entry and its service type or register the invariant companion, contain no Inspector runtime implementation, and remain at fixed paths for repository tooling.
|
||||
|
||||
@@ -26,9 +26,9 @@ src/
|
||||
worker/ Worker transport, repositories, realm backends, and CDP endpoint
|
||||
```
|
||||
|
||||
`client/` and `host/` will have the same relative directories and filenames. Their common roles are plugin entry, bridge lifecycle and RPC, Cordis and network inspection, and CDP-oriented Runtime, Console, Debugger, Sources, Profiler, and HeapProfiler adapters. Support may differ: an unavailable operation remains in the corresponding mirrored module and returns the shared capability-unavailable or typed-unsupported result. Mirroring standardizes where a capability is implemented; it does not claim equal engine support.
|
||||
`client/` and `host/` have the same relative directories and filenames. Their common roles are plugin entry, bridge lifecycle and RPC, Cordis and network inspection, and CDP-oriented Runtime, Console, Debugger, Sources, Profiler, and HeapProfiler adapters. Support may differ: an unavailable operation remains in the corresponding mirrored module and returns the shared capability-unavailable or typed-unsupported result. Mirroring standardizes where a capability is implemented; it does not claim equal engine support.
|
||||
|
||||
Worker-side realm adapters will use the same rule under `worker/realms/client/` and `worker/realms/host/`. These adapters normalize Client simulation and Node inspector behavior behind shared CDP-oriented backend interfaces. They do not own Chrome wire messages or connection-local CDP identifiers.
|
||||
Worker-side realm adapters use the same rule under `worker/realms/client/` and `worker/realms/host/`. These adapters normalize Client simulation and Node inspector behavior behind shared CDP-oriented backend interfaces. They do not own Chrome wire messages or connection-local CDP identifiers.
|
||||
|
||||
## Execution ownership
|
||||
|
||||
@@ -62,13 +62,16 @@ Top-level `client/` and `host/` import `shared/` but never each other or `worker
|
||||
|
||||
The package remains one `@deepseek-ai/dsh-experimental-inspector` package with explicit Client and Host compiler faces. Directory separation is an execution and dependency rule, not a package split.
|
||||
|
||||
## Migration order
|
||||
## Verification
|
||||
|
||||
First, the current Cordis implementation and its semantic types move into `shared/cordis/`. The current protocol directory then separates into `shared/bridge/`, `shared/cdp/`, and `shared/network/` while preserving validated frame discriminants and limits.
|
||||
|
||||
Next, top-level Client and Host files move into exact mirrored paths. Missing support is represented explicitly so the mirrors remain complete. Worker Client and Node backends then move to mirrored `worker/realms/client/` and `worker/realms/host/` directories, with Node renamed to Host at the architectural interface.
|
||||
|
||||
Finally, source transport and routing move under `worker/bridge/`, Cordis, network, and query repositories under `worker/inspection/`, and Chrome endpoint and domains under `worker/cdp/`. Imports, explicit compiler-face file lists, package entries, and focused tests change with each owning layer. The final tree has no generic top-level `protocol/` or `cordis/` directory and no generic `worker/backends/` directory.
|
||||
- Every runtime implementation has an unambiguous execution owner through `shared/`, `client/`, `host/`, or `worker/`; only the repository-required package and invariant forwarding entries remain at the source root.
|
||||
- Top-level Client and Host trees, and Worker Client and Host realm trees, have identical relative implementation paths; unequal capability support is explicit and typed.
|
||||
- Cordis and network readers are usable without importing debugger, source, transport, or CDP session modules.
|
||||
- Internal messages contain source-level identities and validated domain values but no Chrome connection-local ids.
|
||||
- Normalized realm backend interfaces support Host delegation and Client simulation without either implementation constructing Chrome CDP messages.
|
||||
- Only Worker CDP modules allocate Chrome ids and own DevTools connection enable, object, script, node, and call-frame state.
|
||||
- Host Runtime and debugging, Client Runtime and Console, Network capture, Cordis Elements projection, disconnect retention, and semantic query behavior have focused coverage.
|
||||
- Compiler faces, import checks, and the structural layout test reject environment leaks and Client/Host mirror drift.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -82,18 +85,7 @@ Finally, source transport and routing move under `worker/bridge/`, Cordis, netwo
|
||||
|
||||
**Split Client, Host, protocol, and Worker into separate packages.** Rejected for the experimental phase. The deployment unit remains one Client/Host Cordis plugin, and package boundaries would add build and release coordination without improving the required execution separation.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Every runtime implementation has an unambiguous execution owner through `shared/`, `client/`, `host/`, or `worker/`; only the repository-required package and invariant forwarding entries remain at the source root.
|
||||
- Top-level Client and Host trees, and Worker Client and Host realm trees, have identical relative implementation paths; unequal capability support is explicit and typed.
|
||||
- Cordis and network readers can be used without importing debugger, source, transport, or CDP session modules.
|
||||
- Internal messages contain source-level identities and validated domain values but no Chrome connection-local ids.
|
||||
- Normalized realm backend interfaces support Host delegation and Client simulation without either implementation constructing Chrome CDP messages.
|
||||
- Only Worker CDP modules allocate Chrome ids and own DevTools connection enable, object, script, node, and call-frame state.
|
||||
- Existing Host Runtime and debugging, Client Runtime and Console, Network capture, Cordis Elements projection, disconnect retention, and semantic query behavior remain covered after the move.
|
||||
- Compiler faces, import checks, and a structural test reject environment leaks and Client/Host mirror drift.
|
||||
|
||||
## Risks
|
||||
## Consequences
|
||||
|
||||
Exact mirroring adds small adapter files for unsupported capabilities. Those files are intentional compatibility points between implementations, but they must stay thin and must not manufacture fake behavior.
|
||||
|
||||
@@ -101,4 +93,4 @@ Moving types without changing behavior can still expose hidden dependency cycles
|
||||
|
||||
`shared/cdp/` can become a second copy of the Chrome protocol if normalized types are added indiscriminately. A shared type belongs there only when both realm implementations or a common Worker projector consume it; Chrome session bookkeeping and wire-only fields remain under `worker/cdp/`.
|
||||
|
||||
The migration may temporarily leave the package uncompilable between local move steps. The completed change must restore both compiler faces and behavior tests before review.
|
||||
Explicit Client and Host compiler faces and focused behavior tests add maintenance work, but they keep environment leaks and mirror drift visible.
|
||||
+18
-26
@@ -1,20 +1,20 @@
|
||||
# Agent Note: Inspector 执行环境与协议平面
|
||||
|
||||
Status: proposed
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-26-inspector-execution-realms-and-protocol-planes.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Inspector 包的代码运行在三个 JavaScript 环境中:浏览器 Client、Host Node 主线程和 Inspector Worker thread。当前源码树把执行归属与功能名称混在一起:浏览器和 Host producer 使用互不对应的目录结构,代表 Client 与 Host 的 Worker 代码位于笼统的 backend 目录,而一个 protocol 目录同时包含 transport frame、Cordis 数据、network observation 与面向 CDP 的 Runtime value。因此,文件路径无法说明代码在哪里运行,也无法说明它可以持有哪些标识符。
|
||||
Inspector 包的代码运行在三个 JavaScript 环境中:浏览器 Client、Host Node 主线程和 Inspector Worker thread。只按功能命名目录时,文件路径无法说明代码在哪里运行,也无法说明它可以持有哪些标识符。
|
||||
|
||||
这种含糊会带来风险,因为 Host 与 Client 的支持能力有意不同,但架构必须保持可比较。Host Runtime 与 Debugger 委托 Node inspector protocol;Client Runtime 与 Console 通过内部 bridge 模拟同一套 backend 语义。如果两边的文件、接口与 unsupported operation 在结构上分叉,每增加一种协议方法都容易产生第二套路由模型。同样,只需要 Cordis 运行时树的消费方不应继承 debugger activation、Chrome 连接状态或 CDP 标识符。
|
||||
|
||||
现有的[跨 realm CDP Inspector 决策](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md)继续负责 Worker、transport、Runtime、debugger 与安全行为。[Cordis 运行时树检查决策](../../implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md)继续负责 Cordis 树语义、对象路由与 DOM projection。本提案只负责源码位置、依赖方向,以及领域数据、backend 语义、内部 transport 和 Chrome CDP 状态之间的分隔。
|
||||
现有的[跨 realm CDP Inspector 决策](2026-08-23-cross-realm-cdp-inspector.zh.md)负责 Worker、transport、Runtime、debugger 与安全行为。[Cordis 运行时树检查决策](2026-08-24-cordis-runtime-tree-inspection.zh.md)负责 Cordis 树语义、对象路由与 DOM projection。本决策负责源码位置、依赖方向,以及领域数据、backend 语义、内部 transport 和 Chrome CDP 状态之间的分隔。
|
||||
|
||||
## Proposal
|
||||
## Decision
|
||||
|
||||
顶层源码目录将标识执行归属。`client/` 只包含浏览器 Client 代码,`host/` 只包含 Host Node 主线程代码,`worker/` 只包含 Worker thread 代码,`shared/` 只包含在所有环境中都安全的代码。即使某个模块代表 Client,只要它实际在 Worker 中执行,就仍属于 `worker/`,而不是 `client/`。
|
||||
顶层源码目录标识执行归属。`client/` 只包含浏览器 Client 代码,`host/` 只包含 Host Node 主线程代码,`worker/` 只包含 Worker thread 代码,`shared/` 只包含在所有环境中都安全的代码。即使某个模块代表 Client,只要它实际在 Worker 中执行,就仍属于 `worker/`,而不是 `client/`。
|
||||
|
||||
仓库要求的 `src/index.ts` 与 `src/invariant.ts` 发现入口是仅有的源码根目录例外。它们暴露 Host package entry 及其 service type,或注册 invariant companion,不包含 Inspector 运行时实现,并为仓库工具保留在固定路径。
|
||||
|
||||
@@ -26,9 +26,9 @@ src/
|
||||
worker/ Worker transport, repositories, realm backends, and CDP endpoint
|
||||
```
|
||||
|
||||
`client/` 与 `host/` 将拥有相同的相对目录和文件名。共同角色包括 plugin entry、bridge lifecycle 与 RPC、Cordis 和 network inspection,以及面向 CDP 的 Runtime、Console、Debugger、Sources、Profiler 和 HeapProfiler adapter。支持程度可以不同:不可用的操作仍保留在对应的镜像模块中,并返回共享的 capability-unavailable 或类型化 unsupported 结果。镜像结构统一的是能力实现位置,而不是宣称两个引擎支持相同功能。
|
||||
`client/` 与 `host/` 拥有相同的相对目录和文件名。共同角色包括 plugin entry、bridge lifecycle 与 RPC、Cordis 和 network inspection,以及面向 CDP 的 Runtime、Console、Debugger、Sources、Profiler 和 HeapProfiler adapter。支持程度可以不同:不可用的操作仍保留在对应的镜像模块中,并返回共享的 capability-unavailable 或类型化 unsupported 结果。镜像结构统一的是能力实现位置,而不是宣称两个引擎支持相同功能。
|
||||
|
||||
Worker 侧 realm adapter 在 `worker/realms/client/` 与 `worker/realms/host/` 下遵守相同规则。这些 adapter 通过共享的面向 CDP backend 接口,规范化 Client 模拟行为与 Node inspector 行为。它们不拥有 Chrome wire message 或连接局部的 CDP 标识符。
|
||||
Worker 侧 realm adapter 在 `worker/realms/client/` 与 `worker/realms/host/` 下遵守相同规则。这些 adapter 通过共享的面向 CDP backend 接口规范化 Client 模拟行为与 Node inspector 行为。它们不拥有 Chrome wire message 或连接局部的 CDP 标识符。
|
||||
|
||||
## Execution ownership
|
||||
|
||||
@@ -62,13 +62,16 @@ Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模
|
||||
|
||||
本能力继续保留在同一个 `@deepseek-ai/dsh-experimental-inspector` 包中,并使用显式 Client 与 Host compiler face。目录分隔是执行与依赖规则,不是拆包方案。
|
||||
|
||||
## Migration order
|
||||
## Verification
|
||||
|
||||
首先把现有 Cordis 实现及其语义类型移动到 `shared/cordis/`。随后把当前 protocol 目录拆成 `shared/bridge/`、`shared/cdp/` 与 `shared/network/`,同时保留已验证的 frame discriminant 与限制。
|
||||
|
||||
接下来把顶层 Client 与 Host 文件移动到严格镜像的路径。缺失支持使用显式表示,使镜像保持完整。然后把 Worker Client 与 Node backend 移动到镜像的 `worker/realms/client/` 和 `worker/realms/host/` 目录,并在架构接口上把 Node 命名统一为 Host。
|
||||
|
||||
最后把 source transport 与 routing 移到 `worker/bridge/`,Cordis、network 与 query repository 移到 `worker/inspection/`,Chrome endpoint 与 domain 移到 `worker/cdp/`。imports、显式 compiler-face file list、package entry 与聚焦测试随所属层一起修改。最终源码树不保留笼统的顶层 `protocol/`、`cordis/` 或 `worker/backends/` 目录。
|
||||
- 每个运行时实现都通过 `shared/`、`client/`、`host/` 或 `worker/` 拥有明确的执行 owner;只有仓库要求的 package 与 invariant 转发入口留在源码根目录。
|
||||
- 顶层 Client/Host 树与 Worker Client/Host realm 树分别拥有相同的相对实现路径;不同能力支持使用显式类型表示。
|
||||
- Cordis 与 network reader 无需导入 debugger、source、transport 或 CDP session 模块即可使用。
|
||||
- 内部 message 包含 source 层 identity 与已验证领域值,但不包含 Chrome 连接局部 id。
|
||||
- 规范化 realm backend interface 同时支持 Host 委托与 Client 模拟,且两种实现都不构造 Chrome CDP message。
|
||||
- 只有 Worker CDP 模块分配 Chrome id,并持有 DevTools 连接的 enable、object、script、node 与 call-frame 状态。
|
||||
- Host Runtime 与 debugging、Client Runtime 与 Console、Network capture、Cordis Elements projection、断联保留与语义 query 行为均有聚焦测试覆盖。
|
||||
- compiler face、import check 与结构测试能够拒绝环境泄漏和 Client/Host 镜像漂移。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -82,18 +85,7 @@ Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模
|
||||
|
||||
**把 Client、Host、protocol 与 Worker 拆成多个包。** 实验阶段拒绝。部署单元仍是一个 Client/Host Cordis plugin;包边界会增加构建和发布协作,却不能改善所需的执行环境分隔。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 每个运行时实现都通过 `shared/`、`client/`、`host/` 或 `worker/` 拥有明确的执行 owner;只有仓库要求的 package 与 invariant 转发入口留在源码根目录。
|
||||
- 顶层 Client/Host 树与 Worker Client/Host realm 树分别拥有相同的相对实现路径;不同能力支持使用显式类型表示。
|
||||
- Cordis 与 network reader 无需导入 debugger、source、transport 或 CDP session 模块即可使用。
|
||||
- 内部 message 包含 source 层 identity 与已验证领域值,但不包含 Chrome 连接局部 id。
|
||||
- 规范化 realm backend interface 同时支持 Host 委托与 Client 模拟,且两种实现都不构造 Chrome CDP message。
|
||||
- 只有 Worker CDP 模块分配 Chrome id,并持有 DevTools 连接的 enable、object、script、node 与 call-frame 状态。
|
||||
- 移动后继续覆盖现有 Host Runtime 与 debugging、Client Runtime 与 Console、Network capture、Cordis Elements projection、断联保留与语义 query 行为。
|
||||
- compiler face、import check 与结构测试能够拒绝环境泄漏和 Client/Host 镜像漂移。
|
||||
|
||||
## Risks
|
||||
## Consequences
|
||||
|
||||
严格镜像会为不支持的能力增加小型 adapter 文件。这些文件是两个实现之间有意保留的兼容点,但必须保持轻薄,也不能制造虚假行为。
|
||||
|
||||
@@ -101,4 +93,4 @@ Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模
|
||||
|
||||
如果不加约束地添加规范化类型,`shared/cdp/` 可能变成第二份 Chrome protocol。只有两个 realm 实现或公共 Worker projector 会消费的类型才属于这里;Chrome session bookkeeping 与 wire-only field 保留在 `worker/cdp/`。
|
||||
|
||||
本地迁移步骤之间可能暂时无法编译。完整变更必须在 review 前恢复两个 compiler face 与行为测试。
|
||||
显式 Client/Host compiler face 与聚焦行为测试增加了维护工作,但会持续暴露环境泄漏和镜像结构漂移。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md
|
||||
README.md: 86357b3a91763571ffe0cf9eea389b2b21c84d77
|
||||
README.zh.md: ce030fa3873f4cd488855d5c963a27eac0df9c8e
|
||||
README.md: 09f8b2901e8a8d5c6e8264d2e1be680d3b968178
|
||||
README.zh.md: 510ca42362a3c7e779a672b721ec9bc43ede07b5
|
||||
|
||||
@@ -111,7 +111,7 @@ Fetch capture is on by default and records the complete URL, all request and res
|
||||
|
||||
The configured body limits bound retention rather than select fields: capture keeps the prefix and marks the result truncated. `Network.getRequestPostData` and `Network.getResponseBody` read the Worker's retained bytes. `Network.streamResourceContent` returns the buffered prefix and adds later response bytes to `Network.dataReceived` for that DevTools connection, which drives live Response and EventStream views. Direct Undici Client/Dispatcher calls and fetch references retained before plugin activation are outside this observer.
|
||||
|
||||
After response headers arrive, a caller-side abort ends clone capture as a retained, possibly truncated response rather than a failed request. A fetch rejection before response headers remains a failed request.
|
||||
After response headers arrive, bytes captured before a caller-side abort remain available through `Network.getResponseBody`, while the request ends with `Network.loadingFailed { canceled: true }`. A fetch rejection before response headers follows the same canceled failure path.
|
||||
|
||||
<a id="security"></a>
|
||||
## Security
|
||||
|
||||
@@ -111,7 +111,7 @@ fetch 采集默认开启,记录完整 URL、全部请求与响应 headers、
|
||||
|
||||
配置的 body 上限限制保留量,而不选择字段:采集保留前缀并标记 truncated。`Network.getRequestPostData` 与 `Network.getResponseBody` 读取 Worker 保留的字节。`Network.streamResourceContent` 返回已缓冲的前缀,并仅为发起调用的 DevTools 连接把后续 response 字节附加到 `Network.dataReceived`,以驱动实时 Response 与 EventStream 视图。直接调用 Undici Client/Dispatcher,以及插件激活前保存的 fetch 引用,不在观察范围内。
|
||||
|
||||
response headers 到达后,调用方 abort 会结束 clone 采集,并保留一份可能 truncated 的 response,而不会把整个请求标记为失败。response headers 到达前发生的 fetch rejection 仍然是失败请求。
|
||||
response headers 到达后,如果调用方 abort,已采集的字节仍可通过 `Network.getResponseBody` 读取,同时请求以 `Network.loadingFailed { canceled: true }` 结束。response headers 到达前发生的 fetch rejection 也走相同的取消失败路径。
|
||||
|
||||
<a id="security"></a>
|
||||
## 安全
|
||||
|
||||
@@ -3,18 +3,29 @@
|
||||
import type {
|
||||
ClientConsoleDisableFrame,
|
||||
ClientConsoleEnableFrame,
|
||||
ClientRuntimeCancelFrame,
|
||||
ClientRuntimeRequestFrame,
|
||||
ClientRuntimeResponseAcknowledgedFrame,
|
||||
ClientRuntimeSessionClosedFrame,
|
||||
} from '../../shared/bridge/messages/runtime/index.ts'
|
||||
import type { ClientSourceRequestFrame, ClientSourceSessionClosedFrame } from '../../shared/bridge/messages/sources/index.ts'
|
||||
import type { SourceAcceptedFrame, SourceRejectedFrame, SourceResnapshotFrame, WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts'
|
||||
import type {
|
||||
SourceAcceptedFrame,
|
||||
SourceAppendAcknowledgedFrame,
|
||||
SourceRejectedFrame,
|
||||
SourceResnapshotFrame,
|
||||
WorkerToSourceFrame,
|
||||
} from '../../shared/bridge/messages/observation.ts'
|
||||
|
||||
/** Operations invoked for each Worker-to-Client frame family. */
|
||||
export interface ClientBridgeFrameHandlers {
|
||||
accepted(frame: SourceAcceptedFrame): void
|
||||
acknowledged(frame: SourceAppendAcknowledgedFrame): void
|
||||
resnapshot(frame: SourceResnapshotFrame): void
|
||||
rejected(frame: SourceRejectedFrame): void
|
||||
runtime(frame: ClientRuntimeRequestFrame): void
|
||||
runtimeCanceled(frame: ClientRuntimeCancelFrame): void
|
||||
runtimeAcknowledged(frame: ClientRuntimeResponseAcknowledgedFrame): void
|
||||
runtimeClosed(frame: ClientRuntimeSessionClosedFrame): void
|
||||
consoleEnabled(frame: ClientConsoleEnableFrame): void
|
||||
consoleDisabled(frame: ClientConsoleDisableFrame): void
|
||||
@@ -32,6 +43,9 @@ export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: Client
|
||||
case 'source/accepted':
|
||||
handlers.accepted(frame)
|
||||
return
|
||||
case 'source/append-acknowledged':
|
||||
handlers.acknowledged(frame)
|
||||
return
|
||||
case 'source/resnapshot':
|
||||
handlers.resnapshot(frame)
|
||||
return
|
||||
@@ -41,6 +55,12 @@ export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: Client
|
||||
case 'client-runtime/request':
|
||||
handlers.runtime(frame)
|
||||
return
|
||||
case 'client-runtime/cancel':
|
||||
handlers.runtimeCanceled(frame)
|
||||
return
|
||||
case 'client-runtime/response-acknowledged':
|
||||
handlers.runtimeAcknowledged(frame)
|
||||
return
|
||||
case 'client-runtime/session-closed':
|
||||
handlers.runtimeClosed(frame)
|
||||
return
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/** Client observation and Runtime endpoint over the Inspector Worker's ingest WebSocket. */
|
||||
|
||||
import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts'
|
||||
import type { InspectorSourceGeneration } from '../../shared/bridge/ids.ts'
|
||||
import type {
|
||||
ClientRuntimeRequestId,
|
||||
ClientRuntimeSessionId,
|
||||
InspectorSourceGeneration,
|
||||
} from '../../shared/bridge/ids.ts'
|
||||
import { isJsonValue, jsonByteLength } from '../../shared/json.ts'
|
||||
import {
|
||||
INSPECTOR_PROTOCOL_VERSION,
|
||||
@@ -34,6 +38,10 @@ export class ClientInspectorSource extends InspectorSourceConnection {
|
||||
private accepted = false
|
||||
private closed = false
|
||||
private readonly runtime: ClientRuntimeExecutor
|
||||
private readonly runtimeRequests = new Map<ClientRuntimeRequestId, {
|
||||
readonly controller: AbortController
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
}>()
|
||||
private readonly console: ClientConsoleObserver
|
||||
protected readonly queries: ClientBridgeRpc
|
||||
private readonly lifecycle: ClientBridgeLifecycle
|
||||
@@ -92,6 +100,7 @@ export class ClientInspectorSource extends InspectorSourceConnection {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.console.close()
|
||||
this.cancelRuntimeRequests()
|
||||
this.runtime.reset()
|
||||
this.queries.close('Inspector Client source closed')
|
||||
this.lifecycle.close()
|
||||
@@ -116,6 +125,7 @@ export class ClientInspectorSource extends InspectorSourceConnection {
|
||||
private connect(): void {
|
||||
if (this.closed) return
|
||||
this.console.reset()
|
||||
this.cancelRuntimeRequests()
|
||||
this.runtime.reset()
|
||||
this.queries.disconnect('Inspector Client source reconnecting')
|
||||
const source = this.realmSource.connect(this.sourceCatalog !== undefined)
|
||||
@@ -153,6 +163,7 @@ export class ClientInspectorSource extends InspectorSourceConnection {
|
||||
this.queries.connectSocket(source, socket)
|
||||
this.publisher.accept(socket)
|
||||
},
|
||||
acknowledged: () => {},
|
||||
resnapshot: () => { this.publisher.replace(socket) },
|
||||
rejected: (rejected) => {
|
||||
console.error(`[inspector] Client source rejected: ${rejected.message}`)
|
||||
@@ -164,7 +175,12 @@ export class ClientInspectorSource extends InspectorSourceConnection {
|
||||
socket.close(1011, 'Client Runtime transport failed')
|
||||
})
|
||||
},
|
||||
runtimeCanceled: (canceled) => { this.cancelRuntime(canceled.sessionId, canceled.requestId) },
|
||||
runtimeAcknowledged: (acknowledged) => {
|
||||
this.acknowledgeRuntime(acknowledged.sessionId, acknowledged.requestId)
|
||||
},
|
||||
runtimeClosed: (closed) => {
|
||||
this.cancelRuntimeSession(closed.sessionId)
|
||||
this.console.disable(closed.sessionId)
|
||||
this.runtime.closeSession(closed.sessionId)
|
||||
},
|
||||
@@ -189,6 +205,7 @@ export class ClientInspectorSource extends InspectorSourceConnection {
|
||||
this.accepted = false
|
||||
this.publisher.disconnect(socket)
|
||||
this.console.reset()
|
||||
this.cancelRuntimeRequests()
|
||||
this.runtime.reset()
|
||||
this.queries.disconnect('Inspector Client source disconnected')
|
||||
this.lifecycle.reconnect(() => { this.connect() })
|
||||
@@ -203,11 +220,50 @@ export class ClientInspectorSource extends InspectorSourceConnection {
|
||||
generation: InspectorSourceGeneration,
|
||||
frame: Extract<ReturnType<typeof parseWorkerSourceFrame>, { t: 'client-runtime/request' }>,
|
||||
): Promise<void> {
|
||||
const response = await this.runtime.execute(frame)
|
||||
if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) return
|
||||
const controller = new AbortController()
|
||||
const operation = { controller, sessionId: frame.sessionId }
|
||||
this.runtimeRequests.set(frame.requestId, operation)
|
||||
const response = await this.runtime.execute(frame, controller.signal, true)
|
||||
if (this.runtimeRequests.get(frame.requestId) !== operation) return
|
||||
if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) {
|
||||
this.cancelRuntime(frame.sessionId, frame.requestId)
|
||||
return
|
||||
}
|
||||
socket.send(JSON.stringify(response))
|
||||
}
|
||||
|
||||
private acknowledgeRuntime(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void {
|
||||
const operation = this.runtimeRequests.get(requestId)
|
||||
if (operation === undefined || operation.sessionId !== sessionId) return
|
||||
this.runtimeRequests.delete(requestId)
|
||||
this.runtime.acknowledge(sessionId, requestId)
|
||||
}
|
||||
|
||||
private cancelRuntime(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void {
|
||||
const operation = this.runtimeRequests.get(requestId)
|
||||
if (operation === undefined || operation.sessionId !== sessionId) return
|
||||
this.runtimeRequests.delete(requestId)
|
||||
operation.controller.abort()
|
||||
this.runtime.cancel(sessionId, requestId)
|
||||
}
|
||||
|
||||
private cancelRuntimeSession(sessionId: ClientRuntimeSessionId): void {
|
||||
for (const [requestId, operation] of this.runtimeRequests) {
|
||||
if (operation.sessionId !== sessionId) continue
|
||||
operation.controller.abort()
|
||||
this.runtime.cancel(sessionId, requestId)
|
||||
this.runtimeRequests.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private cancelRuntimeRequests(): void {
|
||||
for (const [requestId, operation] of this.runtimeRequests) {
|
||||
operation.controller.abort()
|
||||
this.runtime.cancel(operation.sessionId, requestId)
|
||||
}
|
||||
this.runtimeRequests.clear()
|
||||
}
|
||||
|
||||
private async executeSourceRequest(
|
||||
socket: WebSocket,
|
||||
generation: InspectorSourceGeneration,
|
||||
|
||||
@@ -12,7 +12,11 @@ import type {
|
||||
ClientRuntimeResult,
|
||||
ClientRuntimeRemoteObject,
|
||||
} from '../../shared/bridge/messages/runtime/index.ts'
|
||||
import type { ClientRemoteObjectHandle, ClientRuntimeSessionId } from '../../shared/bridge/ids.ts'
|
||||
import type {
|
||||
ClientRemoteObjectHandle,
|
||||
ClientRuntimeRequestId,
|
||||
ClientRuntimeSessionId,
|
||||
} from '../../shared/bridge/ids.ts'
|
||||
import { isJsonValue, jsonByteLength } from '../../shared/json.ts'
|
||||
import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts'
|
||||
import { ClientRuntimeExecutionError } from './errors.ts'
|
||||
@@ -42,6 +46,11 @@ export interface ClientRuntimeLimits {
|
||||
/** Executes Runtime requests while isolating object handles by DevTools session. */
|
||||
export class ClientRuntimeExecutor {
|
||||
private readonly sessions = new Map<ClientRuntimeSessionId, ClientRuntimeSession>()
|
||||
private readonly responseAllocations = new Map<ClientRuntimeRequestId, {
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
readonly session: ClientRuntimeSession
|
||||
readonly allocation: ClientObjectAllocation
|
||||
}>()
|
||||
|
||||
constructor(
|
||||
private readonly limits: ClientRuntimeLimits,
|
||||
@@ -51,13 +60,22 @@ export class ClientRuntimeExecutor {
|
||||
/**
|
||||
* Execute one request and preserve its source, generation, session, and request identities.
|
||||
* @param frame - Validated command envelope from the Worker.
|
||||
* @param signal - Optional cancellation for an operation awaiting user code.
|
||||
* @param deferObjectCommit - Keep new object handles provisional until {@link acknowledge}.
|
||||
* @returns A success or transport-error response for the same request.
|
||||
*/
|
||||
async execute(frame: ClientRuntimeRequestFrame): Promise<ClientRuntimeResponseFrame> {
|
||||
async execute(
|
||||
frame: ClientRuntimeRequestFrame,
|
||||
signal?: AbortSignal,
|
||||
deferObjectCommit = false,
|
||||
): Promise<ClientRuntimeResponseFrame> {
|
||||
const session = this.session(frame.sessionId)
|
||||
const allocation = session.beginAllocation()
|
||||
try {
|
||||
const result = await session.execute(frame.command, allocation)
|
||||
const result = await session.execute(frame.command, allocation, signal)
|
||||
if (signal?.aborted === true) {
|
||||
throw new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled')
|
||||
}
|
||||
const response = responseFrame(frame, { ok: true, result })
|
||||
if (!isJsonValue(response) || jsonByteLength(response) > this.limits.maxResponseBytes) {
|
||||
session.rollback(allocation)
|
||||
@@ -66,7 +84,18 @@ export class ClientRuntimeExecutor {
|
||||
error: { code: 'result-too-large', message: 'Client Runtime result exceeds the source-frame byte limit' },
|
||||
})
|
||||
}
|
||||
session.commitAllocation(allocation)
|
||||
if (deferObjectCommit) {
|
||||
if (this.responseAllocations.has(frame.requestId)) {
|
||||
session.rollback(allocation)
|
||||
return responseFrame(frame, {
|
||||
ok: false,
|
||||
error: { code: 'invalid-request', message: 'Client Runtime request id is already pending' },
|
||||
})
|
||||
}
|
||||
this.responseAllocations.set(frame.requestId, { sessionId: frame.sessionId, session, allocation })
|
||||
} else {
|
||||
session.commitAllocation(allocation)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
session.rollback(allocation)
|
||||
@@ -74,11 +103,38 @@ export class ClientRuntimeExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit handles after the Worker accepts one Runtime response.
|
||||
* @param sessionId - Session that owns the response.
|
||||
* @param requestId - Correlation id acknowledged by the Worker.
|
||||
*/
|
||||
acknowledge(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void {
|
||||
const pending = this.responseAllocations.get(requestId)
|
||||
if (pending === undefined || pending.sessionId !== sessionId) return
|
||||
this.responseAllocations.delete(requestId)
|
||||
pending.session.commitAllocation(pending.allocation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back handles from a canceled or otherwise unaccepted Runtime response.
|
||||
* @param sessionId - Session that owns the response.
|
||||
* @param requestId - Correlation id rejected by the Worker.
|
||||
*/
|
||||
cancel(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void {
|
||||
const pending = this.responseAllocations.get(requestId)
|
||||
if (pending === undefined || pending.sessionId !== sessionId) return
|
||||
this.responseAllocations.delete(requestId)
|
||||
pending.session.rollback(pending.allocation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all values retained for one closed DevTools connection.
|
||||
* @param sessionId - Runtime session owned by that DevTools connection.
|
||||
*/
|
||||
closeSession(sessionId: ClientRuntimeSessionId): void {
|
||||
for (const [requestId, pending] of this.responseAllocations) {
|
||||
if (pending.sessionId === sessionId) this.responseAllocations.delete(requestId)
|
||||
}
|
||||
this.sessions.get(sessionId)?.close()
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
@@ -170,6 +226,7 @@ export class ClientRuntimeExecutor {
|
||||
|
||||
/** Release all sessions when a source generation ends or reconnects. */
|
||||
reset(): void {
|
||||
this.responseAllocations.clear()
|
||||
for (const session of this.sessions.values()) session.close()
|
||||
this.sessions.clear()
|
||||
}
|
||||
@@ -211,18 +268,22 @@ class ClientRuntimeSession {
|
||||
this.objects.rollback(allocation)
|
||||
}
|
||||
|
||||
async execute(command: ClientRuntimeCommand, allocation: ClientObjectAllocation): Promise<ClientRuntimeResult> {
|
||||
async execute(
|
||||
command: ClientRuntimeCommand,
|
||||
allocation: ClientObjectAllocation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientRuntimeResult> {
|
||||
switch (command.op) {
|
||||
case 'evaluate':
|
||||
return { op: command.op, completion: await this.evaluate(command, allocation) }
|
||||
return { op: command.op, completion: await this.evaluate(command, allocation, signal) }
|
||||
case 'get-properties': {
|
||||
const result = getClientProperties(this.objects, command, this.maxProperties, allocation)
|
||||
return { op: command.op, ...result }
|
||||
}
|
||||
case 'call-function':
|
||||
return { op: command.op, completion: await this.callFunction(command, allocation) }
|
||||
return { op: command.op, completion: await this.callFunction(command, allocation, signal) }
|
||||
case 'await-promise':
|
||||
return { op: command.op, completion: await this.awaitPromise(command, allocation) }
|
||||
return { op: command.op, completion: await this.awaitPromise(command, allocation, signal) }
|
||||
case 'release-object':
|
||||
this.objects.release(command.handle)
|
||||
return { op: command.op }
|
||||
@@ -274,11 +335,12 @@ class ClientRuntimeSession {
|
||||
private async evaluate(
|
||||
command: Extract<ClientRuntimeCommand, { op: 'evaluate' }>,
|
||||
allocation: ClientObjectAllocation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientRuntimeCompletion> {
|
||||
let value: unknown
|
||||
try {
|
||||
value = globalThis.eval(command.expression) as unknown
|
||||
if (command.awaitPromise === true) value = await awaitWithTimeout(value, command.timeoutMs)
|
||||
if (command.awaitPromise === true) value = await awaitWithCancellation(value, signal, command.timeoutMs)
|
||||
} catch (error) {
|
||||
if (error instanceof ClientRuntimeExecutionError) throw error
|
||||
return this.exception(error, command.objectGroup, allocation)
|
||||
@@ -295,6 +357,7 @@ class ClientRuntimeSession {
|
||||
private async callFunction(
|
||||
command: Extract<ClientRuntimeCommand, { op: 'call-function' }>,
|
||||
allocation: ClientObjectAllocation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientRuntimeCompletion> {
|
||||
const receiver = command.receiver === undefined ? globalThis : this.objects.get(command.receiver)
|
||||
const inheritedGroup = command.receiver === undefined ? undefined : this.objects.group(command.receiver)
|
||||
@@ -305,8 +368,9 @@ class ClientRuntimeSession {
|
||||
const fn = globalThis.eval(`(${command.functionDeclaration}\n)`) as unknown
|
||||
if (typeof fn !== 'function') throw new TypeError('functionDeclaration did not evaluate to a function')
|
||||
value = Reflect.apply(fn, receiver, args)
|
||||
if (command.awaitPromise === true) value = await value
|
||||
if (command.awaitPromise === true) value = await awaitWithCancellation(value, signal)
|
||||
} catch (error) {
|
||||
if (error instanceof ClientRuntimeExecutionError) throw error
|
||||
return this.exception(error, group, allocation)
|
||||
}
|
||||
return this.completion(value, allocation, group, command.generatePreview, command.returnByValue)
|
||||
@@ -315,11 +379,12 @@ class ClientRuntimeSession {
|
||||
private async awaitPromise(
|
||||
command: Extract<ClientRuntimeCommand, { op: 'await-promise' }>,
|
||||
allocation: ClientObjectAllocation,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientRuntimeCompletion> {
|
||||
const group = this.objects.group(command.promise)
|
||||
let value: unknown
|
||||
try {
|
||||
value = await this.objects.get(command.promise)
|
||||
value = await awaitWithCancellation(this.objects.get(command.promise), signal)
|
||||
} catch (error) {
|
||||
if (error instanceof ClientRuntimeExecutionError) throw error
|
||||
return this.exception(error, group, allocation)
|
||||
@@ -401,20 +466,33 @@ function clientUrl(): { readonly url?: string } {
|
||||
return typeof href === 'string' ? { url: href } : {}
|
||||
}
|
||||
|
||||
async function awaitWithTimeout(value: unknown, timeoutMs: number | undefined): Promise<unknown> {
|
||||
if (timeoutMs === undefined) return await value
|
||||
async function awaitWithCancellation(
|
||||
value: unknown,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs?: number,
|
||||
): Promise<unknown> {
|
||||
if (signal?.aborted === true) throw new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled')
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let onAbort: (() => void) | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve(value),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
const limits: Promise<never>[] = []
|
||||
if (timeoutMs !== undefined) {
|
||||
limits.push(new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new ClientRuntimeExecutionError('timeout', `Client evaluation exceeded ${String(timeoutMs)}ms`))
|
||||
}, timeoutMs)
|
||||
}),
|
||||
])
|
||||
}))
|
||||
}
|
||||
if (signal !== undefined) {
|
||||
limits.push(new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () => { reject(new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled')) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
}))
|
||||
}
|
||||
return await Promise.race([Promise.resolve(value), ...limits])
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,15 +47,38 @@ export function apply(ctx: Context): void {
|
||||
const bootstrap = parseInspectorClientBootstrap(injected)
|
||||
ctx.effect(() => {
|
||||
const source = startInspectorClient(bootstrap)
|
||||
const disposeCordis = publishCordisTree(ctx, source, {
|
||||
maxNodes: bootstrap.maxCordisNodes,
|
||||
maxBytes: bootstrap.maxFrameBytes - 4_096,
|
||||
})
|
||||
const disposeService = ctx.provide('inspector', createInspectorService(source))
|
||||
return () => {
|
||||
disposeService()
|
||||
disposeCordis()
|
||||
source.close()
|
||||
const disposers: Array<() => unknown> = []
|
||||
try {
|
||||
disposers.push(publishCordisTree(ctx, source, {
|
||||
maxNodes: bootstrap.maxCordisNodes,
|
||||
maxBytes: bootstrap.maxFrameBytes - 4_096,
|
||||
}))
|
||||
disposers.push(ctx.provide('inspector', createInspectorService(source)))
|
||||
} catch (error) {
|
||||
try {
|
||||
disposeInspectorClient(source, disposers)
|
||||
} catch (cleanupError) {
|
||||
ctx.logger.error('experimental-inspector: Client initialization rollback failed', cleanupError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return () => { disposeInspectorClient(source, disposers) }
|
||||
}, 'experimental-inspector: Client source')
|
||||
}
|
||||
|
||||
function disposeInspectorClient(source: ReturnType<typeof startInspectorClient>, disposers: readonly (() => unknown)[]): void {
|
||||
const failures: unknown[] = []
|
||||
for (const dispose of [...disposers].reverse()) {
|
||||
try {
|
||||
dispose()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
source.close()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'experimental-inspector: Client disposal failed')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/** Dispatch of validated Worker frames accepted by the Host MessagePort. */
|
||||
|
||||
import type { SourceAcceptedFrame, SourceRejectedFrame, SourceResnapshotFrame, WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts'
|
||||
import type {
|
||||
SourceAcceptedFrame,
|
||||
SourceAppendAcknowledgedFrame,
|
||||
SourceRejectedFrame,
|
||||
SourceResnapshotFrame,
|
||||
WorkerToSourceFrame,
|
||||
} from '../../shared/bridge/messages/observation.ts'
|
||||
import { rejectConsoleBridgeCommand } from '../cdp/console.ts'
|
||||
import { rejectRuntimeBridgeCommand } from '../cdp/runtime.ts'
|
||||
import { rejectSourcesBridgeCommand } from '../cdp/sources.ts'
|
||||
@@ -8,6 +14,7 @@ import { rejectSourcesBridgeCommand } from '../cdp/sources.ts'
|
||||
/** Operations invoked for source-lifecycle frames addressed to the Host. */
|
||||
export interface HostBridgeFrameHandlers {
|
||||
accepted(frame: SourceAcceptedFrame): void
|
||||
acknowledged(frame: SourceAppendAcknowledgedFrame): void
|
||||
resnapshot(frame: SourceResnapshotFrame): void
|
||||
rejected(frame: SourceRejectedFrame): void
|
||||
}
|
||||
@@ -22,6 +29,9 @@ export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: HostBr
|
||||
case 'source/accepted':
|
||||
handlers.accepted(frame)
|
||||
return
|
||||
case 'source/append-acknowledged':
|
||||
handlers.acknowledged(frame)
|
||||
return
|
||||
case 'source/resnapshot':
|
||||
handlers.resnapshot(frame)
|
||||
return
|
||||
@@ -30,6 +40,9 @@ export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: HostBr
|
||||
return
|
||||
case 'client-runtime/request':
|
||||
return rejectRuntimeBridgeCommand(frame.command)
|
||||
case 'client-runtime/cancel':
|
||||
case 'client-runtime/response-acknowledged':
|
||||
return
|
||||
case 'client-console/enable':
|
||||
case 'client-console/disable':
|
||||
return rejectConsoleBridgeCommand(frame.t)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/obs
|
||||
export class HostBridgePublisher implements InspectorStatePublisher {
|
||||
private readonly records: InspectorSourceBuffer
|
||||
private flushScheduled = false
|
||||
private inFlightNextSequence: number | undefined
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
@@ -34,23 +35,39 @@ export class HostBridgePublisher implements InspectorStatePublisher {
|
||||
|
||||
/** Send the retained state as a complete source replacement. */
|
||||
replace(): void {
|
||||
this.inFlightNextSequence = undefined
|
||||
this.port.postMessage(this.records.replacement(this.source.sourceId, this.source.generation))
|
||||
this.scheduleFlush()
|
||||
}
|
||||
|
||||
/** Flush every currently queued observation batch. */
|
||||
/** Send one queued batch when no earlier MessagePort batch awaits acknowledgement. */
|
||||
flush(): void {
|
||||
let frame = this.records.takeBatch(this.source.sourceId, this.source.generation)
|
||||
while (frame !== undefined) {
|
||||
this.port.postMessage(frame)
|
||||
frame = this.records.takeBatch(this.source.sourceId, this.source.generation)
|
||||
}
|
||||
if (this.closed || this.inFlightNextSequence !== undefined) return
|
||||
const frame = this.records.takeBatch(this.source.sourceId, this.source.generation)
|
||||
if (frame === undefined) return
|
||||
this.port.postMessage(frame)
|
||||
this.inFlightNextSequence = frame.firstSequence + frame.records.length
|
||||
}
|
||||
|
||||
/** Flush pending observations and reject later publication. */
|
||||
/**
|
||||
* Release one in-flight batch and schedule the next bounded transfer.
|
||||
* @param nextSequence - First sequence expected by the Worker after the accepted batch.
|
||||
*/
|
||||
acknowledge(nextSequence: number): void {
|
||||
if (this.closed || this.inFlightNextSequence === undefined) return
|
||||
if (nextSequence !== this.inFlightNextSequence) {
|
||||
throw new Error('inspector: Host source acknowledgement does not match the in-flight batch')
|
||||
}
|
||||
this.inFlightNextSequence = undefined
|
||||
this.scheduleFlush()
|
||||
}
|
||||
|
||||
/** Send at most one final batch, discard later queued observations, and reject publication. */
|
||||
close(): void {
|
||||
if (this.closed) return
|
||||
this.flush()
|
||||
this.closed = true
|
||||
this.records.discardPending()
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
|
||||
@@ -81,6 +81,7 @@ export class HostInspectorSource extends InspectorSourceConnection {
|
||||
&& (frame.sourceId !== this.source.sourceId || frame.generation !== this.source.generation)) return
|
||||
dispatchBridgeFrame(frame, {
|
||||
accepted: () => { this.queries.connectPort(this.source) },
|
||||
acknowledged: (acknowledged) => { this.publisher.acknowledge(acknowledged.nextSequence) },
|
||||
resnapshot: () => { this.publisher.replace() },
|
||||
rejected: (rejected) => { this.queries.disconnect(`Inspector Host source rejected: ${rejected.message}`) },
|
||||
})
|
||||
|
||||
@@ -120,6 +120,14 @@ export function installFetchObserver(
|
||||
controller.signal,
|
||||
(data) => { publisher.publish('fetch/response-body-chunk', { requestId, data }) },
|
||||
).then((outcome) => {
|
||||
if (request.signal.aborted && outcome.captureError !== undefined) {
|
||||
publisher.publish('fetch/error', {
|
||||
requestId,
|
||||
message: outcome.captureError,
|
||||
canceled: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
publisher.publish('fetch/end', {
|
||||
requestId,
|
||||
capturedBytes: outcome.capturedBytes,
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise<voi
|
||||
disposers.push(ctx.on('webserver/index-inject', (table: IndexInjection[]) => {
|
||||
table.push({ kind: 'global', name: '__DSH_INSPECTOR__', value: handle.endpoint.client })
|
||||
}))
|
||||
console.log(`dsh inspector: ${handle.endpoint.devtoolsFrontendUrl}`)
|
||||
ctx.logger.info(`dsh inspector: ${handle.endpoint.devtoolsFrontendUrl}`)
|
||||
} catch (error) {
|
||||
await disposeInspector(handle, disposers).catch((cleanupError: unknown) => {
|
||||
ctx.logger.error('experimental-inspector: initialization rollback failed', cleanupError)
|
||||
|
||||
@@ -42,6 +42,7 @@ export class InspectorSourceBuffer {
|
||||
|
||||
/**
|
||||
* Validate and enqueue one observation, dropping the oldest prefix as needed.
|
||||
* A record larger than one transport frame is dropped after consuming its sequence number.
|
||||
* @param topic - Declared domain topic.
|
||||
* @param payload - Lossless JSON payload.
|
||||
* @param monotonicMs - Finite source-clock timestamp.
|
||||
@@ -97,11 +98,9 @@ export class InspectorSourceBuffer {
|
||||
if (this.queue.length === 0) return undefined
|
||||
const batch: QueuedRecord[] = []
|
||||
let batchBytes = SOURCE_FRAME_OVERHEAD_BYTES
|
||||
const first = this.queue[0]
|
||||
if (first === undefined) throw new Error('inspector: non-empty source queue has no first record')
|
||||
const first = this.queue[0] as QueuedRecord
|
||||
while (batch.length < this.options.maxRecordsPerFrame && this.queue.length > 0) {
|
||||
const candidate = this.queue[0]
|
||||
if (candidate === undefined) break
|
||||
const candidate = this.queue[0] as QueuedRecord
|
||||
if (candidate.sequence !== first.sequence + batch.length) break
|
||||
if (batch.length > 0 && batchBytes + candidate.bytes > this.options.maxFrameBytes) break
|
||||
this.queue.shift()
|
||||
@@ -123,6 +122,12 @@ export class InspectorSourceBuffer {
|
||||
return frame
|
||||
}
|
||||
|
||||
/** Discard observations that have not entered a transport frame. */
|
||||
discardPending(): void {
|
||||
this.queue.length = 0
|
||||
this.queuedBytes = 0
|
||||
}
|
||||
|
||||
private record(topic: string, payload: InspectorJsonValue, monotonicMs: number): InspectorRecordInput {
|
||||
if (topic.length === 0 || topic.length > 128) {
|
||||
throw new Error('inspector: topic must contain 1 to 128 characters')
|
||||
@@ -144,8 +149,7 @@ export class InspectorSourceBuffer {
|
||||
this.queue.push({ sequence, bytes, record })
|
||||
this.queuedBytes += bytes
|
||||
while (this.queue.length > this.options.maxQueuedRecords || this.queuedBytes > this.options.maxQueuedBytes) {
|
||||
const dropped = this.queue.shift()
|
||||
if (dropped === undefined) break
|
||||
const dropped = this.queue.shift() as QueuedRecord
|
||||
this.queuedBytes -= dropped.bytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
parseClientConsoleControlFrame,
|
||||
parseClientConsoleEventFrame,
|
||||
parseClientRuntimeCapability,
|
||||
parseClientRuntimeCancelFrame,
|
||||
parseClientRuntimeRequestFrame,
|
||||
parseClientRuntimeResponseAcknowledgedFrame,
|
||||
parseClientRuntimeResponseFrame,
|
||||
parseClientRuntimeSessionClosedFrame,
|
||||
type ClientConsoleCapability,
|
||||
@@ -17,7 +19,9 @@ import {
|
||||
type ClientConsoleEnableFrame,
|
||||
type ClientConsoleEventFrame,
|
||||
type ClientRuntimeCapability,
|
||||
type ClientRuntimeCancelFrame,
|
||||
type ClientRuntimeRequestFrame,
|
||||
type ClientRuntimeResponseAcknowledgedFrame,
|
||||
type ClientRuntimeResponseFrame,
|
||||
type ClientRuntimeSessionClosedFrame,
|
||||
} from './runtime/index.ts'
|
||||
@@ -114,6 +118,15 @@ export interface SourceAcceptedFrame {
|
||||
readonly generation: InspectorSourceGeneration
|
||||
}
|
||||
|
||||
/** Worker acknowledgement that releases one Host MessagePort batch credit. */
|
||||
export interface SourceAppendAcknowledgedFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'source/append-acknowledged'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly nextSequence: number
|
||||
}
|
||||
|
||||
/** Worker request for a complete source-state replacement. */
|
||||
export interface SourceResnapshotFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
@@ -135,11 +148,14 @@ export interface SourceRejectedFrame {
|
||||
/** Every Worker-to-source control frame. */
|
||||
export type WorkerToSourceFrame =
|
||||
| SourceAcceptedFrame
|
||||
| SourceAppendAcknowledgedFrame
|
||||
| SourceResnapshotFrame
|
||||
| SourceRejectedFrame
|
||||
| ClientConsoleEnableFrame
|
||||
| ClientConsoleDisableFrame
|
||||
| ClientRuntimeCancelFrame
|
||||
| ClientRuntimeRequestFrame
|
||||
| ClientRuntimeResponseAcknowledgedFrame
|
||||
| ClientRuntimeSessionClosedFrame
|
||||
| ClientSourceRequestFrame
|
||||
| ClientSourceSessionClosedFrame
|
||||
@@ -165,6 +181,10 @@ export function parseWorkerSourceFrame(value: unknown): WorkerToSourceFrame {
|
||||
return { v: INSPECTOR_PROTOCOL_VERSION, t: 'source/rejected', code: value.code, message: value.message }
|
||||
}
|
||||
if (value.t === 'client-runtime/request') return parseClientRuntimeRequestFrame(value)
|
||||
if (value.t === 'client-runtime/cancel') return parseClientRuntimeCancelFrame(value)
|
||||
if (value.t === 'client-runtime/response-acknowledged') {
|
||||
return parseClientRuntimeResponseAcknowledgedFrame(value)
|
||||
}
|
||||
if (value.t === 'client-runtime/session-closed') return parseClientRuntimeSessionClosedFrame(value)
|
||||
if (value.t === 'client-sources/request') return parseClientSourceRequestFrame(value)
|
||||
if (value.t === 'client-sources/session-closed') return parseClientSourceSessionClosedFrame(value)
|
||||
@@ -180,6 +200,14 @@ export function parseWorkerSourceFrame(value: unknown): WorkerToSourceFrame {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation'], 'source/accepted frame')
|
||||
return { ...common, t: 'source/accepted' }
|
||||
}
|
||||
if (value.t === 'source/append-acknowledged') {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'nextSequence'], 'source append acknowledgement')
|
||||
return {
|
||||
...common,
|
||||
t: 'source/append-acknowledged',
|
||||
nextSequence: natural(value.nextSequence, 'nextSequence'),
|
||||
}
|
||||
}
|
||||
if (value.t === 'source/resnapshot'
|
||||
&& typeof value.reason === 'string') {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'expectedSequence', 'reason'], 'source/resnapshot frame')
|
||||
|
||||
@@ -30,6 +30,26 @@ export interface ClientRuntimeRequestFrame {
|
||||
readonly command: ClientRuntimeCommand
|
||||
}
|
||||
|
||||
/** Worker cancellation of one outstanding Client Runtime request. */
|
||||
export interface ClientRuntimeCancelFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'client-runtime/cancel'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
readonly requestId: ClientRuntimeRequestId
|
||||
}
|
||||
|
||||
/** Worker acknowledgement that commits one successful Client Runtime response. */
|
||||
export interface ClientRuntimeResponseAcknowledgedFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
readonly t: 'client-runtime/response-acknowledged'
|
||||
readonly sourceId: InspectorSourceId
|
||||
readonly generation: InspectorSourceGeneration
|
||||
readonly sessionId: ClientRuntimeSessionId
|
||||
readonly requestId: ClientRuntimeRequestId
|
||||
}
|
||||
|
||||
/** Client response to one typed Runtime request. */
|
||||
export interface ClientRuntimeResponseFrame {
|
||||
readonly v: typeof INSPECTOR_PROTOCOL_VERSION
|
||||
@@ -86,6 +106,48 @@ export function parseClientRuntimeRequestFrame(value: Record<string, unknown>):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and rebuild one Worker-to-Client Runtime cancellation.
|
||||
* @param value - Untrusted cancellation frame.
|
||||
* @returns The validated cancellation frame.
|
||||
*/
|
||||
export function parseClientRuntimeCancelFrame(value: Record<string, unknown>): ClientRuntimeCancelFrame {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId'], 'Client Runtime cancellation')
|
||||
if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/cancel') {
|
||||
throw new Error('inspector protocol: invalid Client Runtime cancellation envelope')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-runtime/cancel',
|
||||
sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
|
||||
sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'),
|
||||
requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and rebuild one Worker acknowledgement for a Client Runtime response.
|
||||
* @param value - Untrusted acknowledgement frame.
|
||||
* @returns The validated acknowledgement frame.
|
||||
*/
|
||||
export function parseClientRuntimeResponseAcknowledgedFrame(
|
||||
value: Record<string, unknown>,
|
||||
): ClientRuntimeResponseAcknowledgedFrame {
|
||||
exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId'], 'Client Runtime response acknowledgement')
|
||||
if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/response-acknowledged') {
|
||||
throw new Error('inspector protocol: invalid Client Runtime response acknowledgement envelope')
|
||||
}
|
||||
return {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-runtime/response-acknowledged',
|
||||
sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'),
|
||||
generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'),
|
||||
sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'),
|
||||
requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and rebuild one Client-to-Worker Runtime response.
|
||||
* @param value - Untrusted response frame.
|
||||
|
||||
@@ -16,9 +16,15 @@ export type RuntimeCallArgument<Handle extends string> =
|
||||
| { readonly kind: 'object'; readonly handle: Handle }
|
||||
| { readonly kind: 'undefined' }
|
||||
|
||||
/** Backend-local selector for a native execution context within one realm. */
|
||||
export type RuntimeExecutionContext =
|
||||
| { readonly kind: 'numeric'; readonly id: number }
|
||||
| { readonly kind: 'unique'; readonly id: string }
|
||||
|
||||
/** Engine-independent evaluation options supported by Runtime backends. */
|
||||
export interface RuntimeEvaluateRequest {
|
||||
readonly expression: string
|
||||
readonly context?: RuntimeExecutionContext
|
||||
readonly objectGroup?: string
|
||||
readonly includeCommandLineAPI?: boolean
|
||||
readonly silent?: boolean
|
||||
@@ -46,6 +52,7 @@ export interface RuntimeGetPropertiesRequest<Handle extends string> {
|
||||
/** Function invocation request within one inspected realm. */
|
||||
export interface RuntimeCallFunctionRequest<Handle extends string> {
|
||||
readonly functionDeclaration: string
|
||||
readonly context?: RuntimeExecutionContext
|
||||
readonly receiver?: Handle
|
||||
readonly arguments?: readonly RuntimeCallArgument<Handle>[]
|
||||
readonly objectGroup?: string
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RuntimeCallFrameEvaluationRequest,
|
||||
RuntimeEvaluateRequest,
|
||||
RuntimeGetPropertiesRequest,
|
||||
RuntimeExecutionContext,
|
||||
RuntimeProperties,
|
||||
RuntimeScript,
|
||||
} from './index.ts'
|
||||
@@ -63,8 +64,12 @@ export interface RuntimeBackend {
|
||||
awaitPromise(
|
||||
request: RuntimeAwaitPromiseRequest<RuntimeBackendObjectHandle>,
|
||||
): Promise<RuntimeCompletion<RuntimeBackendObjectHandle>>
|
||||
/** @returns Names visible in the realm's global lexical scope. */
|
||||
globalLexicalScopeNames(): Promise<readonly string[]>
|
||||
/**
|
||||
* Read names visible in one backend execution context's global lexical scope.
|
||||
* @param context - Native sub-context selector, or the realm default when omitted.
|
||||
* @returns Names visible in the selected global lexical scope.
|
||||
*/
|
||||
globalLexicalScopeNames(context?: RuntimeExecutionContext): Promise<readonly string[]>
|
||||
/**
|
||||
* Release one backend object reference.
|
||||
* @param handle - Handle owned by this realm session.
|
||||
|
||||
@@ -48,10 +48,11 @@ export class CordisTreeCollector {
|
||||
* @returns A detached JSON snapshot whose retained objects replace the prior generation atomically.
|
||||
*/
|
||||
snapshot(): CordisTreeSnapshot {
|
||||
const tree = collectContexts(this.root)
|
||||
const collected = collectContexts(this.root)
|
||||
const tree = collected.root
|
||||
const objects = this.objects.begin()
|
||||
let nodeCount = 0
|
||||
let truncated = false
|
||||
let truncated = collected.truncated
|
||||
|
||||
const contextNode = (info: ContextInfo): MutableContextNode | undefined => {
|
||||
if (nodeCount >= this.limits.maxNodes) {
|
||||
@@ -82,8 +83,7 @@ export class CordisTreeCollector {
|
||||
return undefined
|
||||
}
|
||||
nodeCount++
|
||||
const context = contextNode(owned)
|
||||
if (context === undefined) throw new Error('inspector: reserved Fiber Context was not collected')
|
||||
const context = contextNode(owned) as MutableContextNode
|
||||
return {
|
||||
kind: 'fiber',
|
||||
objectHandle: objects.retain(fiber).handle,
|
||||
@@ -120,10 +120,14 @@ export class CordisTreeCollector {
|
||||
}
|
||||
}
|
||||
|
||||
function collectContexts(root: Context): ContextInfo {
|
||||
function collectContexts(root: Context): { readonly root: ContextInfo; readonly truncated: boolean } {
|
||||
const contexts = new Map<Context, ContextInfo>()
|
||||
let truncated = false
|
||||
const ensure = (candidate: unknown, depth = 0): ContextInfo | undefined => {
|
||||
if (depth > 100) return undefined
|
||||
if (depth > 100) {
|
||||
truncated = true
|
||||
return undefined
|
||||
}
|
||||
const value = unwrapContext(candidate)
|
||||
if (!Context.is(value)) return undefined
|
||||
const existing = contexts.get(value)
|
||||
@@ -142,8 +146,7 @@ function collectContexts(root: Context): ContextInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
const rootInfo = ensure(root)
|
||||
if (rootInfo === undefined) throw new Error('inspector: Cordis root context is not reachable')
|
||||
const rootInfo = ensure(root) as ContextInfo
|
||||
for (const runtime of root.registry.values()) {
|
||||
for (const fiber of runtime.fibers) {
|
||||
if (fiber.uid === null) continue
|
||||
@@ -158,7 +161,7 @@ function collectContexts(root: Context): ContextInfo {
|
||||
for (const info of contexts.values()) {
|
||||
info.children.sort((left, right) => order(left) - order(right))
|
||||
}
|
||||
return rootInfo
|
||||
return { root: rootInfo, truncated }
|
||||
}
|
||||
|
||||
function describeContext(value: Context): ContextInfo {
|
||||
|
||||
@@ -146,8 +146,7 @@ function parseNode(value: unknown, state: ParseState, depth: number): CordisRunt
|
||||
if (record.kind === 'context') {
|
||||
return { kind: 'context', children: record.children.map(child => parseNode(child, state, depth + 1)) }
|
||||
}
|
||||
if (record.kind !== 'fiber'
|
||||
|| !Number.isSafeInteger(record.uid)
|
||||
if (!Number.isSafeInteger(record.uid)
|
||||
|| (record.uid as number) < 1
|
||||
|| record.children.length !== 1) {
|
||||
throw new Error('inspector protocol: invalid Cordis runtime Fiber')
|
||||
|
||||
@@ -69,20 +69,10 @@ function projectContext(node: Extract<CordisTreeNode, { kind: 'context' }>): Cor
|
||||
}
|
||||
|
||||
function projectNode(node: CordisTreeNode): CordisRuntimeNode {
|
||||
switch (node.kind) {
|
||||
case 'context':
|
||||
return projectContext(node)
|
||||
case 'fiber':
|
||||
return {
|
||||
kind: 'fiber',
|
||||
uid: node.uid,
|
||||
children: [projectContext(node.children[0])],
|
||||
}
|
||||
default:
|
||||
return assertNever(node)
|
||||
if (node.kind === 'context') return projectContext(node)
|
||||
return {
|
||||
kind: 'fiber',
|
||||
uid: node.uid,
|
||||
children: [projectContext(node.children[0])],
|
||||
}
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected Cordis tree node: ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
@@ -251,6 +251,13 @@ export class InspectorSourceRegistry {
|
||||
state.expectedSequence = frame.firstSequence + frame.records.length
|
||||
for (const consumer of this.consumers) consumer.append(state.source, records)
|
||||
this.count(state, frame.records)
|
||||
connection.send({
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'source/append-acknowledged',
|
||||
sourceId: state.source.sourceId,
|
||||
generation: state.source.generation,
|
||||
nextSequence: state.expectedSequence,
|
||||
})
|
||||
this.notifyStatus()
|
||||
}
|
||||
|
||||
|
||||
@@ -154,8 +154,10 @@ export class ClientRuntimeRouter {
|
||||
const requestId = inspectorId<'ClientRuntimeRequestId'>(randomUUID(), 'requestId')
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId)
|
||||
reject(new Error(`Client Runtime ${command.op} timed out after ${String(this.timeoutMs)}ms`))
|
||||
const pending = this.pending.get(requestId)
|
||||
if (pending === undefined) return
|
||||
this.cancelClientResponse(target.source, sessionId, requestId)
|
||||
this.rejectPending(requestId, new Error(`Client Runtime ${command.op} timed out after ${String(this.timeoutMs)}ms`))
|
||||
}, this.timeoutMs)
|
||||
timer.unref()
|
||||
this.pending.set(requestId, { target, sessionId, op: command.op, resolve, reject, timer })
|
||||
@@ -278,28 +280,78 @@ export class ClientRuntimeRouter {
|
||||
|
||||
private settle(source: InspectorSourceDescriptor, frame: ClientRuntimeResponseFrame): void {
|
||||
const pending = this.pending.get(frame.requestId)
|
||||
if (pending === undefined) return
|
||||
if (pending === undefined) {
|
||||
this.cancelClientResponse(source, frame.sessionId, frame.requestId)
|
||||
return
|
||||
}
|
||||
if (pending.target.source.sourceId !== source.sourceId
|
||||
|| pending.target.source.generation !== source.generation
|
||||
|| pending.sessionId !== frame.sessionId) {
|
||||
this.cancelClientResponse(source, frame.sessionId, frame.requestId)
|
||||
this.cancelClientResponse(pending.target.source, pending.sessionId, frame.requestId)
|
||||
this.rejectPending(frame.requestId, new Error('Client Runtime response correlation mismatch'))
|
||||
return
|
||||
}
|
||||
if (!frame.outcome.ok) {
|
||||
this.acknowledgeClientResponse(source, frame.sessionId, frame.requestId)
|
||||
this.rejectPending(frame.requestId, new ClientRuntimeRemoteError(frame.outcome.error.code, frame.outcome.error.message))
|
||||
return
|
||||
}
|
||||
if (frame.outcome.result.op !== pending.op) {
|
||||
this.cancelClientResponse(source, frame.sessionId, frame.requestId)
|
||||
this.rejectPending(frame.requestId, new Error(
|
||||
`Client Runtime response op ${frame.outcome.result.op} does not match ${pending.op}`,
|
||||
))
|
||||
return
|
||||
}
|
||||
if (!this.acknowledgeClientResponse(source, frame.sessionId, frame.requestId)) {
|
||||
this.rejectPending(frame.requestId, new Error('Client execution context disconnected before acknowledgement'))
|
||||
return
|
||||
}
|
||||
clearTimeout(pending.timer)
|
||||
this.pending.delete(frame.requestId)
|
||||
pending.resolve(frame.outcome.result)
|
||||
}
|
||||
|
||||
private acknowledgeClientResponse(
|
||||
source: InspectorSourceDescriptor,
|
||||
sessionId: ClientRuntimeSessionId,
|
||||
requestId: ClientRuntimeRequestId,
|
||||
): boolean {
|
||||
try {
|
||||
return this.sources.send(source, {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-runtime/response-acknowledged',
|
||||
sourceId: source.sourceId,
|
||||
generation: source.generation,
|
||||
sessionId,
|
||||
requestId,
|
||||
})
|
||||
} catch {
|
||||
// A failed acknowledgement rejects the Worker request; source teardown releases Client handles.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private cancelClientResponse(
|
||||
source: InspectorSourceDescriptor,
|
||||
sessionId: ClientRuntimeSessionId,
|
||||
requestId: ClientRuntimeRequestId,
|
||||
): void {
|
||||
try {
|
||||
this.sources.send(source, {
|
||||
v: INSPECTOR_PROTOCOL_VERSION,
|
||||
t: 'client-runtime/cancel',
|
||||
sourceId: source.sourceId,
|
||||
generation: source.generation,
|
||||
sessionId,
|
||||
requestId,
|
||||
})
|
||||
} catch {
|
||||
// Cancellation settlement does not depend on delivery to a source that may be closing.
|
||||
}
|
||||
}
|
||||
|
||||
private rejectPending(requestId: ClientRuntimeRequestId, error: Error): void {
|
||||
const pending = this.pending.get(requestId)
|
||||
if (pending === undefined) return
|
||||
|
||||
@@ -194,7 +194,7 @@ export function parseReleaseObjectGroup(params: Readonly<Record<string, unknown>
|
||||
* @returns The validated context selector.
|
||||
*/
|
||||
export function parseGlobalLexicalScopeNames(params: Readonly<Record<string, unknown>>): CdpExecutionContextSelector {
|
||||
exactKeys(params, ['executionContextId', 'uniqueContextId'], 'Runtime.globalLexicalScopeNames params')
|
||||
exactKeys(params, ['executionContextId'], 'Runtime.globalLexicalScopeNames params')
|
||||
return parseContextSelector(params, 'executionContextId')
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts'
|
||||
import type { InspectorRealmId, RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts'
|
||||
import type { RuntimeCallArgument, RuntimeCompletion, RuntimeRemoteObject } from '../../../../shared/cdp/index.ts'
|
||||
import type { RuntimeExecutionContext } from '../../../../shared/cdp/operations.ts'
|
||||
import type { RuntimeBackend } from '../../../../shared/cdp/realm.ts'
|
||||
import { cdpError, respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../protocol.ts'
|
||||
import type { InspectorRealmSession } from '../../../inspection/realm.ts'
|
||||
@@ -228,7 +229,10 @@ export class RuntimeDomainSession {
|
||||
private async evaluate(params: Readonly<Record<string, unknown>>): Promise<object> {
|
||||
const parsed = parseEvaluate(params)
|
||||
const realm = this.realmFromSelector(parsed, 'contextId')
|
||||
const completion = await runtimeBackend(realm).evaluate(parsed.request)
|
||||
const completion = await runtimeBackend(realm).evaluate({
|
||||
...parsed.request,
|
||||
...this.backendContext(realm, parsed, 'contextId'),
|
||||
})
|
||||
return this.objects.completion(realm, completion, parsed.request.objectGroup)
|
||||
}
|
||||
|
||||
@@ -260,6 +264,7 @@ export class RuntimeDomainSession {
|
||||
const group = parsed.request.objectGroup ?? receiver?.group
|
||||
const completion = await runtimeBackend(realm).callFunction({
|
||||
...parsed.request,
|
||||
...this.backendContext(realm, parsed, 'executionContextId'),
|
||||
...(receiver === undefined ? {} : { receiver: receiver.handle }),
|
||||
arguments: parsed.arguments.map(argument => this.routeArgument(realm, argument)),
|
||||
})
|
||||
@@ -309,7 +314,8 @@ export class RuntimeDomainSession {
|
||||
private async globalLexicalScopeNames(params: Readonly<Record<string, unknown>>): Promise<object> {
|
||||
const parsed = parseGlobalLexicalScopeNames(params)
|
||||
const realm = this.realmFromSelector(parsed, 'executionContextId')
|
||||
return { names: await runtimeBackend(realm).globalLexicalScopeNames() }
|
||||
const context = this.backendContext(realm, parsed, 'executionContextId').context
|
||||
return { names: await runtimeBackend(realm).globalLexicalScopeNames(context) }
|
||||
}
|
||||
|
||||
private async discardConsoleEntries(): Promise<object> {
|
||||
@@ -349,6 +355,19 @@ export class RuntimeDomainSession {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private backendContext(
|
||||
realm: InspectorRealmSession,
|
||||
params: CdpExecutionContextSelector,
|
||||
numericKey: 'contextId' | 'executionContextId',
|
||||
): { readonly context?: RuntimeExecutionContext } {
|
||||
if (realm.context.kind !== 'native') return {}
|
||||
const numeric = params[numericKey]
|
||||
if (typeof numeric === 'number') return { context: { kind: 'numeric', id: numeric } }
|
||||
return params.uniqueContextId === undefined
|
||||
? {}
|
||||
: { context: { kind: 'unique', id: params.uniqueContextId } }
|
||||
}
|
||||
|
||||
private routeArgument(
|
||||
realm: InspectorRealmSession,
|
||||
argument: CdpCallArgument,
|
||||
|
||||
@@ -145,8 +145,7 @@ export class NetworkStore implements InspectorRecordConsumer {
|
||||
for (const event of this.journal) {
|
||||
replay.push(event)
|
||||
if (event.type !== 'response-received' || event.mimeType !== 'text/event-stream') continue
|
||||
const request = this.requests.get(event.requestKey)
|
||||
if (request === undefined) continue
|
||||
const request = this.requests.get(event.requestKey) as CapturedRequest
|
||||
const messages = new InspectorEventSourceParser().push(Buffer.concat(request.responseBody))
|
||||
let eventId = 0
|
||||
for (const message of messages) {
|
||||
@@ -301,18 +300,23 @@ export class NetworkStore implements InspectorRecordConsumer {
|
||||
})
|
||||
return
|
||||
}
|
||||
case 'fetch/error':
|
||||
case 'fetch/error': {
|
||||
if (request.completed) return
|
||||
const errorText = stringField(payload, 'message')
|
||||
if (request.responseSeen) {
|
||||
request.responseBodyTruncated = true
|
||||
request.responseCaptureError = errorText
|
||||
}
|
||||
this.complete(request, {
|
||||
type: 'request-failed',
|
||||
requestKey: key,
|
||||
requestId: request.requestId,
|
||||
timestampMs,
|
||||
errorText: stringField(payload, 'message'),
|
||||
errorText,
|
||||
canceled: booleanField(payload, 'canceled'),
|
||||
})
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,10 +363,8 @@ export class NetworkStore implements InspectorRecordConsumer {
|
||||
|
||||
private enforceRetention(): void {
|
||||
while (this.requests.size > this.options.maxRetainedRequests || this.journalBytes > this.options.maxJournalBytes) {
|
||||
const key = this.completed.shift() ?? this.oldestActiveRequestKey()
|
||||
if (key === undefined) return
|
||||
const request = this.requests.get(key)
|
||||
if (request === undefined) continue
|
||||
const key = (this.completed.shift() ?? this.requests.keys().next().value) as string
|
||||
const request = this.requests.get(key) as CapturedRequest
|
||||
if (!request.completed) {
|
||||
request.completed = true
|
||||
this.publish({
|
||||
@@ -378,21 +380,12 @@ export class NetworkStore implements InspectorRecordConsumer {
|
||||
}
|
||||
}
|
||||
|
||||
private oldestActiveRequestKey(): string | undefined {
|
||||
for (const request of this.requests.values()) {
|
||||
if (!request.completed) return request.key
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private evictCompletedFor(bytes: number, protectedKey: string): void {
|
||||
while (this.journalBytes + bytes > this.options.maxJournalBytes) {
|
||||
const index = this.completed.findIndex(key => key !== protectedKey)
|
||||
if (index === -1) return
|
||||
const [key] = this.completed.splice(index, 1)
|
||||
if (key === undefined) return
|
||||
const request = this.requests.get(key)
|
||||
if (request !== undefined) this.evict(request)
|
||||
const key = this.completed.splice(index, 1)[0] as string
|
||||
this.evict(this.requests.get(key) as CapturedRequest)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,12 @@ export class ClientRuntimeBackend implements RuntimeBackend {
|
||||
|
||||
async evaluate(request: Parameters<RuntimeBackend['evaluate']>[0]): ReturnType<RuntimeBackend['evaluate']> {
|
||||
assertClientEvaluationOptions(request)
|
||||
const { throwOnSideEffect: _throwOnSideEffect, serializationOptions: _serializationOptions, ...supported } = request
|
||||
const {
|
||||
context: _context,
|
||||
throwOnSideEffect: _throwOnSideEffect,
|
||||
serializationOptions: _serializationOptions,
|
||||
...supported
|
||||
} = request
|
||||
return clientCompletion(
|
||||
expectResult(await this.request({ op: 'evaluate', ...supported }), 'evaluate'),
|
||||
scriptKey => this.scriptIds.toRuntime(scriptKey),
|
||||
@@ -74,6 +79,7 @@ export class ClientRuntimeBackend implements RuntimeBackend {
|
||||
assertClientCallOptions(request)
|
||||
const {
|
||||
receiver,
|
||||
context: _context,
|
||||
arguments: args,
|
||||
throwOnSideEffect: _throwOnSideEffect,
|
||||
serializationOptions: _serializationOptions,
|
||||
@@ -102,7 +108,8 @@ export class ClientRuntimeBackend implements RuntimeBackend {
|
||||
)
|
||||
}
|
||||
|
||||
async globalLexicalScopeNames(): Promise<readonly string[]> {
|
||||
async globalLexicalScopeNames(context?: Parameters<RuntimeBackend['globalLexicalScopeNames']>[0]): Promise<readonly string[]> {
|
||||
if (context !== undefined) throw new Error('Client Runtime does not support native execution contexts')
|
||||
return expectResult(await this.request({ op: 'global-lexical-scope-names' }), 'global-lexical-scope-names').names
|
||||
}
|
||||
|
||||
@@ -140,6 +147,7 @@ function expectResult<Operation extends ClientRuntimeResult['op']>(
|
||||
}
|
||||
|
||||
function assertClientEvaluationOptions(request: Parameters<RuntimeBackend['evaluate']>[0]): void {
|
||||
if (request.context !== undefined) throw new Error('Client Runtime does not support native execution contexts')
|
||||
if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect')
|
||||
if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions')
|
||||
if (request.disableBreaks === true) throw new Error('Client Runtime does not support disableBreaks')
|
||||
@@ -152,6 +160,7 @@ function assertClientEvaluationOptions(request: Parameters<RuntimeBackend['evalu
|
||||
}
|
||||
|
||||
function assertClientCallOptions(request: Parameters<RuntimeBackend['callFunction']>[0]): void {
|
||||
if (request.context !== undefined) throw new Error('Client Runtime does not support native execution contexts')
|
||||
if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect')
|
||||
if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions')
|
||||
if (request.userGesture === true) throw new Error('Client Runtime does not support userGesture')
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
RuntimePropertyDescriptor,
|
||||
RuntimeRemoteObject,
|
||||
RuntimeRemoteObjectDescriptor,
|
||||
RuntimeExecutionContext,
|
||||
RuntimeStackTrace,
|
||||
} from '../../../shared/cdp/index.ts'
|
||||
import type { HostInspectorNotification, HostInspectorSession } from './bridge.ts'
|
||||
@@ -43,6 +44,7 @@ export class HostRuntimeBackend implements RuntimeBackend {
|
||||
async evaluate(request: Parameters<RuntimeBackend['evaluate']>[0]): ReturnType<RuntimeBackend['evaluate']> {
|
||||
return this.completion(await this.target.request('Runtime.evaluate', {
|
||||
expression: request.expression,
|
||||
...nativeContext(request.context, 'contextId'),
|
||||
...optionalNativeField('objectGroup', request.objectGroup),
|
||||
...optionalNativeField('includeCommandLineAPI', request.includeCommandLineAPI),
|
||||
...optionalNativeField('silent', request.silent),
|
||||
@@ -72,13 +74,15 @@ export class HostRuntimeBackend implements RuntimeBackend {
|
||||
|
||||
async callFunction(request: Parameters<RuntimeBackend['callFunction']>[0]): ReturnType<RuntimeBackend['callFunction']> {
|
||||
const receiver = request.receiver
|
||||
const contextId = receiver === undefined ? this.defaultContextId : undefined
|
||||
if (receiver === undefined && contextId === undefined) {
|
||||
const context = receiver === undefined
|
||||
? nativeContext(request.context ?? defaultContext(this.defaultContextId), 'executionContextId')
|
||||
: undefined
|
||||
if (receiver === undefined && context === undefined) {
|
||||
throw new Error('Host Runtime default execution context is unavailable')
|
||||
}
|
||||
return this.completion(await this.target.request('Runtime.callFunctionOn', {
|
||||
functionDeclaration: request.functionDeclaration,
|
||||
...(receiver === undefined ? { executionContextId: contextId } : { objectId: receiver }),
|
||||
...(receiver === undefined ? context : { objectId: receiver }),
|
||||
...(request.arguments === undefined ? {} : { arguments: request.arguments.map(toNativeArgument) }),
|
||||
...optionalNativeField('objectGroup', request.objectGroup),
|
||||
...optionalNativeField('silent', request.silent),
|
||||
@@ -99,9 +103,9 @@ export class HostRuntimeBackend implements RuntimeBackend {
|
||||
}))
|
||||
}
|
||||
|
||||
async globalLexicalScopeNames(): Promise<readonly string[]> {
|
||||
async globalLexicalScopeNames(context?: RuntimeExecutionContext): Promise<readonly string[]> {
|
||||
const response = await this.target.request('Runtime.globalLexicalScopeNames', {
|
||||
...optionalNativeField('executionContextId', this.defaultContextId),
|
||||
...nativeContext(context ?? defaultContext(this.defaultContextId), 'executionContextId'),
|
||||
})
|
||||
if (!Array.isArray(response.names) || !response.names.every(name => typeof name === 'string')) {
|
||||
throw new Error('Host Runtime returned invalid lexical scope names')
|
||||
@@ -303,6 +307,18 @@ export class HostRuntimeBackend implements RuntimeBackend {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultContext(contextId: number | undefined): RuntimeExecutionContext | undefined {
|
||||
return contextId === undefined ? undefined : { kind: 'numeric', id: contextId }
|
||||
}
|
||||
|
||||
function nativeContext(
|
||||
context: RuntimeExecutionContext | undefined,
|
||||
numericKey: 'contextId' | 'executionContextId',
|
||||
): Readonly<Record<string, number | string>> | undefined {
|
||||
if (context === undefined) return undefined
|
||||
return context.kind === 'numeric' ? { [numericKey]: context.id } : { uniqueContextId: context.id }
|
||||
}
|
||||
|
||||
function toNativeArgument(value: RuntimeCallArgument<RuntimeBackendObjectHandle>): Readonly<Record<string, unknown>> {
|
||||
switch (value.kind) {
|
||||
case 'value': return { value: value.value }
|
||||
|
||||
@@ -162,6 +162,55 @@ describe('Client Runtime executor', () => {
|
||||
.toMatchObject({ descriptor: { value: true } })
|
||||
})
|
||||
|
||||
it('rolls back a canceled function call instead of returning its cancellation as a JavaScript exception', async () => {
|
||||
const runtime = new ClientRuntimeExecutor({
|
||||
maxObjectsPerSession: 1,
|
||||
maxPropertiesPerResult: 100,
|
||||
maxResponseBytes: 32_768,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = runtime.execute(frame({
|
||||
op: 'call-function',
|
||||
functionDeclaration: 'function () { return new Promise(() => {}) }',
|
||||
awaitPromise: true,
|
||||
}), controller.signal)
|
||||
controller.abort()
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ outcome: { ok: false, error: { code: 'timeout' } } })
|
||||
await expect(runtime.execute(frame({
|
||||
op: 'evaluate',
|
||||
expression: '({ retainedAfterCancellation: true })',
|
||||
}))).resolves.toMatchObject({ outcome: { ok: true } })
|
||||
})
|
||||
|
||||
it('keeps response handles provisional until the Worker accepts or cancels them', async () => {
|
||||
const runtime = new ClientRuntimeExecutor({
|
||||
maxObjectsPerSession: 2,
|
||||
maxPropertiesPerResult: 100,
|
||||
maxResponseBytes: 32_768,
|
||||
})
|
||||
const canceledFrame = frame({ op: 'evaluate', expression: '({ canceled: true })' })
|
||||
const canceled = success(await runtime.execute(canceledFrame, undefined, true), 'evaluate')
|
||||
const canceledHandle = canceled.completion.result.object?.handle
|
||||
if (canceledHandle === undefined) throw new Error('deferred response did not retain an object')
|
||||
runtime.cancel(canceledFrame.sessionId, canceledFrame.requestId)
|
||||
expect((await runtime.execute(frame({ op: 'get-properties', handle: canceledHandle }))).outcome)
|
||||
.toMatchObject({ ok: false, error: { code: 'object-not-found' } })
|
||||
|
||||
const acceptedFrame = frame({ op: 'evaluate', expression: '({ accepted: true })' })
|
||||
const accepted = success(await runtime.execute(acceptedFrame, undefined, true), 'evaluate')
|
||||
const acceptedHandle = accepted.completion.result.object?.handle
|
||||
if (acceptedHandle === undefined) throw new Error('deferred response did not retain an object')
|
||||
runtime.acknowledge(acceptedFrame.sessionId, acceptedFrame.requestId)
|
||||
const properties = success(await runtime.execute(frame({
|
||||
op: 'get-properties',
|
||||
handle: acceptedHandle,
|
||||
ownProperties: true,
|
||||
})), 'get-properties')
|
||||
expect(properties.properties.find(property => property.name === 'accepted')?.value)
|
||||
.toMatchObject({ descriptor: { value: true } })
|
||||
})
|
||||
|
||||
it('rejects oversized by-value results before they enter the source transport', async () => {
|
||||
const runtime = new ClientRuntimeExecutor({
|
||||
maxObjectsPerSession: 100,
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/** Validation and projection of the shared Cordis tree representations. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseCordisRuntimeTree } from '../src/shared/cordis/model.ts'
|
||||
import {
|
||||
identifyRealmObject,
|
||||
RealmObjectRegistry,
|
||||
realmObjectExpression,
|
||||
} from '../src/shared/cordis/object-registry.ts'
|
||||
import { parseInspectorObjectReference } from '../src/shared/cordis/object-reference.ts'
|
||||
import { projectCordisRuntimeTree } from '../src/shared/cordis/projector.ts'
|
||||
import { parseCordisTreeSnapshot, type CordisTreeSnapshot } from '../src/shared/cordis/snapshot.ts'
|
||||
|
||||
describe('Cordis runtime tree model', () => {
|
||||
it('parses connected and disconnected realms and rejects duplicate source identities', () => {
|
||||
const tree = {
|
||||
schemaVersion: 0,
|
||||
host: realm('host-1', 'host', { state: 'connected' }),
|
||||
clients: [realm('client-1', 'client', { state: 'disconnected', reason: 'offline' })],
|
||||
}
|
||||
expect(parseCordisRuntimeTree(tree)).toEqual(tree)
|
||||
expect(parseCordisRuntimeTree({ schemaVersion: 0, host: null, clients: [] }).host).toBeNull()
|
||||
expect(() => parseCordisRuntimeTree({
|
||||
...tree,
|
||||
clients: [realm('host-1', 'client', { state: 'connected' })],
|
||||
})).toThrow('repeats a sourceId')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ schemaVersion: 1, host: null, clients: [] }, 'invalid Cordis runtime tree'],
|
||||
[{ schemaVersion: 0, host: null, clients: {} }, 'invalid Cordis runtime tree'],
|
||||
[{ schemaVersion: 0, host: realm('host-1', 'client', { state: 'connected' }), clients: [] }, 'invalid host Cordis runtime source'],
|
||||
[{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'connected' }, { source: { sourceId: 'host-1', kind: 'host', label: '' } }), clients: [] }, 'invalid host Cordis runtime source'],
|
||||
[{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'connected' }, { source: { sourceId: 'host-1', kind: 'host', label: 'x'.repeat(257) } }), clients: [] }, 'invalid host Cordis runtime source'],
|
||||
[{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'connected' }, { revision: 0 }), clients: [] }, 'invalid Cordis runtime realm header'],
|
||||
[{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'connected' }, { truncated: 'no' }), clients: [] }, 'invalid Cordis runtime realm header'],
|
||||
[{ schemaVersion: 0, host: realm('host-1', 'host', null), clients: [] }, 'connection must be an object'],
|
||||
[{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'disconnected', reason: 1 }), clients: [] }, 'invalid Cordis runtime connection'],
|
||||
[{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'unknown' }), clients: [] }, 'invalid Cordis runtime connection'],
|
||||
])('rejects malformed runtime tree headers %#', (value, message) => {
|
||||
expect(() => parseCordisRuntimeTree(value)).toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects malformed runtime nodes, duplicate Fiber ids, and excessive depth', () => {
|
||||
const withRoot = (root: unknown): unknown => ({
|
||||
schemaVersion: 0,
|
||||
host: realm('host-1', 'host', { state: 'connected' }, { root }),
|
||||
clients: [],
|
||||
})
|
||||
const fiber = (uid: unknown, children: unknown[] = [{ kind: 'context', children: [] }]): unknown => ({
|
||||
kind: 'fiber',
|
||||
uid,
|
||||
children,
|
||||
})
|
||||
const invalid = [
|
||||
[fiber(1), 'root must be a Context'],
|
||||
[null, 'known kind'],
|
||||
[{ kind: 'unknown', children: [] }, 'known kind'],
|
||||
[{ kind: 'context', children: {} }, 'children must be an array'],
|
||||
[{ kind: 'context', children: [fiber(0)] }, 'invalid Cordis runtime Fiber'],
|
||||
[{ kind: 'context', children: [fiber(1, [])] }, 'invalid Cordis runtime Fiber'],
|
||||
[{ kind: 'context', children: [fiber(1, [fiber(2)])] }, 'Fiber child must be a Context'],
|
||||
[{ kind: 'context', children: [fiber(1), fiber(1)] }, 'repeats a Fiber uid'],
|
||||
] as const
|
||||
for (const [root, message] of invalid) expect(() => parseCordisRuntimeTree(withRoot(root))).toThrow(message)
|
||||
|
||||
let deep: unknown = { kind: 'context', children: [] }
|
||||
for (let depth = 0; depth < 258; depth++) deep = { kind: 'context', children: [deep] }
|
||||
expect(() => parseCordisRuntimeTree(withRoot(deep))).toThrow('depth limit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis snapshot model', () => {
|
||||
it('parses a complete Context/Fiber tree and its object references', () => {
|
||||
const snapshot = routedSnapshot()
|
||||
expect(parseCordisTreeSnapshot(snapshot, 10)).toEqual(snapshot)
|
||||
expect(parseInspectorObjectReference({ registryId: 'registry-1', handle: 'context-1' })).toEqual({
|
||||
registryId: 'registry-1',
|
||||
handle: 'context-1',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...routedSnapshot(), schemaVersion: 1 }, 'invalid Cordis tree header'],
|
||||
[{ ...routedSnapshot(), revision: 0 }, 'invalid Cordis tree header'],
|
||||
[{ ...routedSnapshot(), truncated: 'no' }, 'invalid Cordis tree header'],
|
||||
[{ ...routedSnapshot(), root: routedFiber(1, 'fiber-root', routedContext('fiber-child')) }, 'root must be a Context'],
|
||||
[{ ...routedSnapshot(), root: null }, 'known kind'],
|
||||
[{ ...routedSnapshot(), root: { kind: 'unknown', objectHandle: 'bad', children: [] } }, 'known kind'],
|
||||
[{ ...routedSnapshot(), root: { kind: 'context', objectHandle: 'bad', children: {} } }, 'children must be an array'],
|
||||
[{ ...routedSnapshot(), root: routedContext('same', [routedContext('same')]) }, 'repeats an object handle'],
|
||||
[{ ...routedSnapshot(), root: routedContext('root', [routedFiber(0, 'fiber', routedContext('child'))]) }, 'positive safe integer'],
|
||||
[{ ...routedSnapshot(), root: routedContext('root', [routedFiber(1, 'fiber', routedContext('child'), [])]) }, 'exactly one Context'],
|
||||
[{ ...routedSnapshot(), root: routedContext('root', [
|
||||
routedFiber(1, 'fiber-1', routedContext('child-1')),
|
||||
routedFiber(1, 'fiber-2', routedContext('child-2')),
|
||||
]) }, 'repeats a Fiber uid'],
|
||||
[{ ...routedSnapshot(), root: routedContext('root', [
|
||||
routedFiber(1, 'fiber-1', routedContext('unused'), [routedFiber(2, 'fiber-2', routedContext('child'))]),
|
||||
]) }, 'Fiber child must be a Context'],
|
||||
])('rejects malformed routed snapshots %#', (value, message) => {
|
||||
expect(() => parseCordisTreeSnapshot(value, 10)).toThrow(message)
|
||||
})
|
||||
|
||||
it('enforces node and depth limits', () => {
|
||||
expect(() => parseCordisTreeSnapshot(routedSnapshot(), 1)).toThrow('exceeds 1 nodes')
|
||||
let deep: unknown = routedContext('leaf')
|
||||
for (let depth = 0; depth < 258; depth++) deep = routedContext(`depth-${String(depth)}`, [deep])
|
||||
expect(() => parseCordisTreeSnapshot({ ...routedSnapshot(), root: deep }, 1_000)).toThrow('depth limit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis runtime projection', () => {
|
||||
it('removes routing fields from context-only and Fiber nodes in disconnected Client trees', () => {
|
||||
const projected = projectCordisRuntimeTree({
|
||||
host: null,
|
||||
clients: [{
|
||||
source: { sourceId: 'client-1', kind: 'client', label: 'Client' },
|
||||
connection: { state: 'disconnected', reason: 'offline' },
|
||||
snapshot: routedSnapshot(routedContext('root', [
|
||||
routedContext('nested'),
|
||||
routedFiber(1, 'fiber', routedContext('owned')),
|
||||
])) as unknown as CordisTreeSnapshot,
|
||||
}],
|
||||
})
|
||||
|
||||
expect(projected).toEqual({
|
||||
schemaVersion: 0,
|
||||
host: null,
|
||||
clients: [{
|
||||
source: { sourceId: 'client-1', kind: 'client', label: 'Client' },
|
||||
connection: { state: 'disconnected', reason: 'offline' },
|
||||
revision: 1,
|
||||
truncated: false,
|
||||
root: {
|
||||
kind: 'context',
|
||||
children: [
|
||||
{ kind: 'context', children: [] },
|
||||
{ kind: 'fiber', uid: 1, children: [{ kind: 'context', children: [] }] },
|
||||
],
|
||||
},
|
||||
}],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cordis object registry', () => {
|
||||
it('retains stable identities, recognizes wrappers, and rolls generations atomically', () => {
|
||||
const registry = new RealmObjectRegistry()
|
||||
const value = {}
|
||||
const first = registry.begin()
|
||||
const reference = first.retain(value)
|
||||
expect(first.retain(value)).toEqual(reference)
|
||||
first.commit()
|
||||
first.commit()
|
||||
expect(registry.resolve(reference.handle)).toBe(value)
|
||||
expect(registry.identify(value)).toEqual(reference)
|
||||
expect(identifyRealmObject(value)).toEqual(reference)
|
||||
expect(globalThis.eval(realmObjectExpression(reference))).toBe(value)
|
||||
|
||||
const wrapper = Object.create(value) as { then?: unknown }
|
||||
wrapper.then = undefined
|
||||
expect(registry.identify(wrapper)).toEqual(reference)
|
||||
let deepWrapper: object = value
|
||||
for (let depth = 0; depth < 10; depth++) {
|
||||
deepWrapper = Object.assign(Object.create(deepWrapper) as object, { then: undefined })
|
||||
}
|
||||
expect(registry.identify(deepWrapper)).toBeUndefined()
|
||||
expect(registry.identify(null)).toBeUndefined()
|
||||
expect(registry.identify(Object.create(value) as object)).toBeUndefined()
|
||||
expect(registry.identify(new Proxy({}, { ownKeys: () => { throw new Error('blocked') } }))).toBeUndefined()
|
||||
expect(identifyRealmObject({})).toBeUndefined()
|
||||
|
||||
expect(() => first.retain({})).toThrow('already committed')
|
||||
expect(() => { first.release(reference.handle) }).toThrow('already committed')
|
||||
const second = registry.begin()
|
||||
second.release(reference.handle)
|
||||
second.commit()
|
||||
expect(registry.resolve(reference.handle)).toBeUndefined()
|
||||
registry.close()
|
||||
registry.close()
|
||||
expect(() => registry.begin()).toThrow('registry is disposed')
|
||||
})
|
||||
})
|
||||
|
||||
function realm(
|
||||
sourceId: string,
|
||||
kind: 'host' | 'client',
|
||||
connection: unknown,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
source: { sourceId, kind, label: sourceId },
|
||||
connection,
|
||||
revision: 1,
|
||||
truncated: false,
|
||||
root: { kind: 'context', children: [{ kind: 'fiber', uid: 1, children: [{ kind: 'context', children: [] }] }] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function routedContext(objectHandle: string, children: unknown[] = []): Record<string, unknown> {
|
||||
return { kind: 'context', objectHandle, children }
|
||||
}
|
||||
|
||||
function routedFiber(
|
||||
uid: unknown,
|
||||
objectHandle: string,
|
||||
context: unknown,
|
||||
children: unknown[] = [context],
|
||||
): Record<string, unknown> {
|
||||
return { kind: 'fiber', uid, objectHandle, children }
|
||||
}
|
||||
|
||||
function routedSnapshot(root: unknown = routedContext('context-1', [
|
||||
routedFiber(1, 'fiber-1', routedContext('context-2')),
|
||||
])): Record<string, unknown> {
|
||||
return {
|
||||
schemaVersion: 0,
|
||||
revision: 1,
|
||||
objectRegistryId: 'registry-1',
|
||||
root,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import WebSocket, { type RawData } from 'ws'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CordisTreeCollector } from '../src/shared/cordis/collector.ts'
|
||||
import { observeCordisTree } from '../src/shared/cordis/observer.ts'
|
||||
import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
|
||||
import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts'
|
||||
import { parseCordisTreeSnapshot, type CordisTreeNode } from '../src/shared/cordis/snapshot.ts'
|
||||
import { inspectorId } from '../src/shared/bridge/ids.ts'
|
||||
import type { InspectorJsonValue } from '../src/shared/json.ts'
|
||||
import { jsonByteLength } from '../src/shared/json.ts'
|
||||
import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts'
|
||||
import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts'
|
||||
import { InspectorClientFixture } from './fixtures/client-source.host.ts'
|
||||
@@ -133,6 +135,106 @@ describe('Cordis tree inspection', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('marks snapshots truncated when a Context ancestry exceeds the traversal limit', async () => {
|
||||
const root = new Context()
|
||||
let context = root
|
||||
for (let depth = 0; depth < 102; depth++) context = context.isolate(`depth-${String(depth)}`)
|
||||
const fiber = context.plugin({ name: 'deep-child', apply() {} })
|
||||
await fiber.await()
|
||||
const collector = new CordisTreeCollector(root, { maxNodes: 1_000, maxBytes: 1024 * 1024 })
|
||||
|
||||
expect(collector.snapshot().truncated).toBe(true)
|
||||
|
||||
collector.close()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('bounds snapshots by node count and encoded byte size', async () => {
|
||||
const root = new Context()
|
||||
const parent = root.isolate('parent')
|
||||
const child = parent.isolate('child')
|
||||
const fiber = child.plugin({ name: 'bounded-child', apply() {} })
|
||||
await fiber.await()
|
||||
const completeCollector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 })
|
||||
const complete = completeCollector.snapshot()
|
||||
const rootOnlyBytes = jsonByteLength({
|
||||
...complete,
|
||||
objectRegistryId: 'x'.repeat(complete.objectRegistryId.length),
|
||||
root: { ...complete.root, children: [] },
|
||||
truncated: true,
|
||||
})
|
||||
completeCollector.close()
|
||||
|
||||
const nodeBound = new CordisTreeCollector(root, { maxNodes: 1, maxBytes: 64 * 1_024 })
|
||||
expect(nodeBound.snapshot()).toMatchObject({ truncated: true, root: { children: [] } })
|
||||
nodeBound.close()
|
||||
|
||||
const directRoot = new Context()
|
||||
const directFiber = directRoot.plugin({ name: 'direct-child', apply() {} })
|
||||
await directFiber.await()
|
||||
const fiberBound = new CordisTreeCollector(directRoot, { maxNodes: 2, maxBytes: 64 * 1_024 })
|
||||
expect(fiberBound.snapshot()).toMatchObject({ truncated: true, root: { children: [] } })
|
||||
fiberBound.close()
|
||||
|
||||
const byteBound = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: rootOnlyBytes })
|
||||
expect(byteBound.snapshot()).toMatchObject({ truncated: true, root: { children: [] } })
|
||||
byteBound.close()
|
||||
|
||||
const impossible = new CordisTreeCollector(root, { maxNodes: 0, maxBytes: 1 })
|
||||
expect(() => impossible.snapshot()).toThrow('maxNodes cannot retain the root Context')
|
||||
impossible.close()
|
||||
|
||||
const rootTooLarge = new CordisTreeCollector(new Context(), { maxNodes: 2, maxBytes: 1 })
|
||||
expect(() => rootTooLarge.snapshot()).toThrow('Cordis root exceeds the source-frame byte limit')
|
||||
rootTooLarge.close()
|
||||
await directFiber.dispose()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('coalesces Cordis notifications and ignores a queued publication after disposal', async () => {
|
||||
const root = new Context()
|
||||
const listener = vi.fn()
|
||||
const dispose = observeCordisTree(root, listener, { maxNodes: 100, maxBytes: 64 * 1_024 })
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
|
||||
root.emit('internal/plugin', root.fiber)
|
||||
root.emit('internal/plugin', root.fiber)
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
|
||||
root.emit('internal/plugin', root.fiber)
|
||||
dispose()
|
||||
dispose()
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores disposed Fibers and non-Context listener owners while unwrapping Cordis shadows', async () => {
|
||||
const root = new Context()
|
||||
const fiber = root.plugin({ name: 'temporarily-disposed', apply() {} })
|
||||
await fiber.await()
|
||||
const runtimeFiber = fiber.ctx.fiber
|
||||
const uidDescriptor = Object.getOwnPropertyDescriptor(runtimeFiber, 'uid')
|
||||
Object.defineProperty(runtimeFiber, 'uid', { ...uidDescriptor, value: null })
|
||||
const hooks = root.events._hooks as unknown as Record<PropertyKey, Array<{ ctx: unknown }> | undefined>
|
||||
const probe = Symbol('inspector-collector-probe')
|
||||
const empty = Symbol('inspector-collector-empty')
|
||||
const shadow = Object.create(root) as object
|
||||
Object.defineProperty(shadow, Symbol.for('cordis.shadow'), { value: true })
|
||||
hooks[probe] = [{ ctx: {} }, { ctx: shadow }, { ctx: runtimeFiber.ctx }]
|
||||
hooks[empty] = undefined
|
||||
const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 })
|
||||
try {
|
||||
expect(collector.snapshot().root.kind).toBe('context')
|
||||
} finally {
|
||||
collector.close()
|
||||
Reflect.deleteProperty(hooks, probe)
|
||||
Reflect.deleteProperty(hooks, empty)
|
||||
if (uidDescriptor !== undefined) Object.defineProperty(runtimeFiber, 'uid', uidDescriptor)
|
||||
await fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('freezes a disconnected snapshot and replaces it with the reconnect generation', () => {
|
||||
const root = new Context()
|
||||
const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 })
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('full fetch observer', () => {
|
||||
afterEach(async () => {
|
||||
await observer?.stop()
|
||||
observer = undefined
|
||||
vi.restoreAllMocks()
|
||||
if (originalDescriptor === undefined) Reflect.deleteProperty(globalThis, 'fetch')
|
||||
else Object.defineProperty(globalThis, 'fetch', originalDescriptor)
|
||||
})
|
||||
@@ -79,7 +80,7 @@ describe('full fetch observer', () => {
|
||||
expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 4, responseBodyTruncated: true })
|
||||
})
|
||||
|
||||
it('retains a truncated response when the caller cancels after response headers', async () => {
|
||||
it('retains captured bytes and reports cancellation after response headers', async () => {
|
||||
const records: InspectorRecordInput[] = []
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn(async (request: Request) => new Response(new ReadableStream<Uint8Array>({
|
||||
@@ -103,15 +104,14 @@ describe('full fetch observer', () => {
|
||||
const response = await fetch('https://example.test/cancel-body', { signal: abort.signal })
|
||||
abort.abort()
|
||||
await expect(response.text()).rejects.toThrow()
|
||||
await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) })
|
||||
await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/error')).toBe(true) })
|
||||
|
||||
expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('first')
|
||||
expect(payload(records, 'fetch/end')).toMatchObject({
|
||||
capturedBytes: 5,
|
||||
responseBodyTruncated: true,
|
||||
responseCaptureError: 'AbortError: aborted',
|
||||
expect(payload(records, 'fetch/error')).toMatchObject({
|
||||
message: 'AbortError: aborted',
|
||||
canceled: true,
|
||||
})
|
||||
expect(records.some(record => record.topic === 'fetch/error')).toBe(false)
|
||||
expect(records.some(record => record.topic === 'fetch/end')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a fetch rejected before response headers as a canceled request', async () => {
|
||||
@@ -140,6 +140,257 @@ describe('full fetch observer', () => {
|
||||
expect(records.some(record => record.topic === 'fetch/response')).toBe(false)
|
||||
expect(records.some(record => record.topic === 'fetch/end')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports non-cancellation fetch failures without manufacturing a canceled flag', async () => {
|
||||
const records: InspectorRecordInput[] = []
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn(() => Promise.reject(new Error('connection failed'))),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
observer = installFetchObserver({
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
|
||||
records.push({ topic, payload, monotonicMs })
|
||||
},
|
||||
}, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
|
||||
|
||||
await expect(fetch('https://example.test/failure')).rejects.toThrow('connection failed')
|
||||
expect(payload(records, 'fetch/error')).toMatchObject({ message: 'Error: connection failed', canceled: false })
|
||||
})
|
||||
|
||||
it('records request and response clone failures without replacing the caller response', async () => {
|
||||
const records: InspectorRecordInput[] = []
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn(() => Promise.resolve(new Response('response'))),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const requestClone = vi.spyOn(Request.prototype, 'clone').mockImplementationOnce(() => {
|
||||
throw new Error('request clone failed')
|
||||
})
|
||||
const responseClone = vi.spyOn(Response.prototype, 'clone').mockImplementationOnce(() => {
|
||||
throw new Error('response clone failed')
|
||||
})
|
||||
observer = installFetchObserver({
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
|
||||
records.push({ topic, payload, monotonicMs })
|
||||
},
|
||||
}, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
|
||||
|
||||
const response = await fetch('https://example.test/clone-failure', { method: 'POST', body: 'request' })
|
||||
expect(await response.text()).toBe('response')
|
||||
expect(payload(records, 'fetch/request-body-end')).toMatchObject({ captureError: 'Error: request clone failed' })
|
||||
expect(payload(records, 'fetch/end')).toMatchObject({ responseCaptureError: 'Error: response clone failed' })
|
||||
requestClone.mockRestore()
|
||||
responseClone.mockRestore()
|
||||
})
|
||||
|
||||
it('handles responses without bodies and keeps stop idempotent when fetch is replaced', async () => {
|
||||
const records: InspectorRecordInput[] = []
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
observer = installFetchObserver({
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
|
||||
records.push({ topic, payload, monotonicMs })
|
||||
},
|
||||
}, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
|
||||
const replacement = vi.fn<typeof fetch>()
|
||||
|
||||
await fetch('https://example.test/no-content')
|
||||
await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) })
|
||||
Object.defineProperty(globalThis, 'fetch', { value: replacement, writable: true, configurable: true })
|
||||
const firstStop = observer.stop()
|
||||
expect(observer.stop()).toBe(firstStop)
|
||||
await firstStop
|
||||
expect(globalThis.fetch).toBe(replacement)
|
||||
})
|
||||
|
||||
it('rejects installation without a callable global fetch', () => {
|
||||
Object.defineProperty(globalThis, 'fetch', { value: undefined, writable: true, configurable: true })
|
||||
expect(() => installFetchObserver({ publish: vi.fn() }, {
|
||||
maxRequestBodyBytes: 1,
|
||||
maxResponseBodyBytes: 1,
|
||||
maxChunkBytes: 1,
|
||||
})).toThrow('globalThis.fetch is unavailable')
|
||||
})
|
||||
|
||||
it('rejects an accessor fetch property', () => {
|
||||
const nativeFetch = globalThis.fetch
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
get: () => nativeFetch,
|
||||
})
|
||||
expect(() => installFetchObserver({ publish: vi.fn() }, {
|
||||
maxRequestBodyBytes: 1,
|
||||
maxResponseBodyBytes: 1,
|
||||
maxChunkBytes: 1,
|
||||
})).toThrow('globalThis.fetch is an accessor')
|
||||
})
|
||||
|
||||
it('contains publisher failures from asynchronous body completion', async () => {
|
||||
let endAttempted = false
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn(() => Promise.resolve(new Response('response'))),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
observer = installFetchObserver({
|
||||
publish(topic: string): void {
|
||||
if (topic !== 'fetch/end') return
|
||||
endAttempted = true
|
||||
throw new Error('publisher closed')
|
||||
},
|
||||
}, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
|
||||
|
||||
await fetch('https://example.test/publisher-failure')
|
||||
await vi.waitFor(() => { expect(endAttempted).toBe(true) })
|
||||
await expect(observer.stop()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('cancels an active clone reader when the observer stops', async () => {
|
||||
const records: InspectorRecordInput[] = []
|
||||
let settleRead: ((value: ReadableStreamReadResult<Uint8Array>) => void) | undefined
|
||||
const reader = {
|
||||
read: vi.fn(async () => await new Promise<ReadableStreamReadResult<Uint8Array>>((resolve) => {
|
||||
settleRead = resolve
|
||||
})),
|
||||
cancel: vi.fn(() => {
|
||||
settleRead?.({ done: true, value: undefined })
|
||||
return Promise.reject(new Error('cancel already observed'))
|
||||
}),
|
||||
releaseLock: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn(() => Promise.resolve(new Response('caller response'))),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
vi.spyOn(Response.prototype, 'clone').mockReturnValueOnce({
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response)
|
||||
observer = installFetchObserver({
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
|
||||
records.push({ topic, payload, monotonicMs })
|
||||
},
|
||||
}, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
|
||||
|
||||
await fetch('https://example.test/pending-body')
|
||||
await observer.stop()
|
||||
expect(reader.cancel).toHaveBeenCalled()
|
||||
expect(payload(records, 'fetch/end')).toMatchObject({
|
||||
responseCaptureError: 'inspector stopped during body capture',
|
||||
})
|
||||
})
|
||||
|
||||
it('contains a rejected reader cancellation after reaching the body limit', async () => {
|
||||
const records: InspectorRecordInput[] = []
|
||||
const reader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({ done: false, value: Buffer.from('oversized') })
|
||||
.mockResolvedValue({ done: true, value: undefined }),
|
||||
cancel: vi.fn(() => Promise.reject(new Error('cancel failed'))),
|
||||
releaseLock: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn(() => Promise.resolve(new Response('caller response'))),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
vi.spyOn(Response.prototype, 'clone').mockReturnValueOnce({
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Response)
|
||||
observer = installFetchObserver({
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
|
||||
records.push({ topic, payload, monotonicMs })
|
||||
},
|
||||
}, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1, maxChunkBytes: 1 })
|
||||
|
||||
await fetch('https://example.test/body-limit')
|
||||
await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) })
|
||||
expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 1, responseBodyTruncated: true })
|
||||
expect(reader.cancel).toHaveBeenCalledWith('inspector body capture limit reached')
|
||||
})
|
||||
|
||||
it('renders non-Error rejection values without allowing hostile coercion to escape', async () => {
|
||||
const records: InspectorRecordInput[] = []
|
||||
const plainFailure: unknown = 'plain failure'
|
||||
const unrenderable = { toString: () => { throw new Error('cannot stringify') } }
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn()
|
||||
.mockImplementationOnce(async () => { throw plainFailure })
|
||||
.mockImplementationOnce(async () => { throw unrenderable }),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
observer = installFetchObserver({
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
|
||||
records.push({ topic, payload, monotonicMs })
|
||||
},
|
||||
}, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
|
||||
|
||||
await expect(fetch('https://example.test/plain-failure')).rejects.toBe('plain failure')
|
||||
await expect(fetch('https://example.test/unrenderable-failure')).rejects.toBe(unrenderable)
|
||||
expect(records.filter(record => record.topic === 'fetch/error').map(record => record.payload))
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ message: 'plain failure', canceled: false }),
|
||||
expect.objectContaining({ message: 'unrenderable fetch error', canceled: false }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('restores an inherited fetch without leaving an own property', async () => {
|
||||
const prototype = Object.getPrototypeOf(globalThis) as object
|
||||
const inheritedDescriptor = Object.getOwnPropertyDescriptor(prototype, 'fetch')
|
||||
const nativeFetch = originalDescriptor?.value as typeof fetch
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
Object.defineProperty(prototype, 'fetch', { value: nativeFetch, writable: true, configurable: true })
|
||||
try {
|
||||
observer = installFetchObserver({ publish: vi.fn() }, {
|
||||
maxRequestBodyBytes: 1_024,
|
||||
maxResponseBodyBytes: 1_024,
|
||||
maxChunkBytes: 4,
|
||||
})
|
||||
await observer.stop()
|
||||
expect(Object.hasOwn(globalThis, 'fetch')).toBe(false)
|
||||
} finally {
|
||||
if (inheritedDescriptor === undefined) Reflect.deleteProperty(prototype, 'fetch')
|
||||
else Object.defineProperty(prototype, 'fetch', inheritedDescriptor)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports request clone read errors and non-abort DOM failures', async () => {
|
||||
const records: InspectorRecordInput[] = []
|
||||
const requestReadFailure: unknown = 'request read failed'
|
||||
const reader = {
|
||||
read: vi.fn(async () => { throw requestReadFailure }),
|
||||
cancel: vi.fn(() => Promise.resolve()),
|
||||
releaseLock: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockRejectedValueOnce(new DOMException('network failed', 'NetworkError')),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
vi.spyOn(Request.prototype, 'clone').mockReturnValueOnce({
|
||||
body: { getReader: () => reader },
|
||||
} as unknown as Request)
|
||||
observer = installFetchObserver({
|
||||
publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) {
|
||||
records.push({ topic, payload, monotonicMs })
|
||||
},
|
||||
}, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 })
|
||||
|
||||
await fetch('https://example.test/request-read-failure')
|
||||
await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/request-body-end')).toBe(true) })
|
||||
expect(payload(records, 'fetch/request-body-end')).toMatchObject({ captureError: 'request read failed' })
|
||||
await expect(fetch('https://example.test/network-failure')).rejects.toThrow('network failed')
|
||||
expect(records.filter(record => record.topic === 'fetch/error').at(-1)?.payload)
|
||||
.toMatchObject({ canceled: false })
|
||||
})
|
||||
})
|
||||
|
||||
function payload(records: readonly InspectorRecordInput[], topic: string): Record<string, unknown> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Host-driven integration over an isolated Client fixture. */
|
||||
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { createContext, runInContext } from 'node:vm'
|
||||
import WebSocket, { type RawData } from 'ws'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
|
||||
@@ -250,6 +251,62 @@ describe('experimental Inspector real Worker', () => {
|
||||
expect((await secondCdp.call('Runtime.getProperties', { objectId: disabledObjectId })).error).toBeDefined()
|
||||
})
|
||||
|
||||
it('cancels Client Runtime work when the Worker deadline expires', async () => {
|
||||
inspector = await startInspector({ port: 0, captureFetch: false, clientRuntimeTimeoutMs: 20 })
|
||||
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Timeout Client' })
|
||||
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||
await cdp.call('Runtime.enable')
|
||||
const contextId = await clientContext(cdp)
|
||||
|
||||
const timedOut = await cdp.call('Runtime.evaluate', {
|
||||
expression: 'new Promise(() => {})',
|
||||
contextId,
|
||||
awaitPromise: true,
|
||||
})
|
||||
expect(timedOut.error?.message).toContain('timed out after 20ms')
|
||||
expect((await cdp.call('Runtime.evaluate', {
|
||||
expression: '42',
|
||||
contextId,
|
||||
returnByValue: true,
|
||||
})).result?.result).toMatchObject({ type: 'number', value: 42 })
|
||||
})
|
||||
|
||||
it('preserves native Host execution-context selectors', async () => {
|
||||
inspector = await startInspector({ port: 0, captureFetch: false })
|
||||
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||
await cdp.call('Runtime.enable')
|
||||
const context = createContext({}, { name: 'Inspector VM Context' })
|
||||
runInContext('globalThis.vmMarker = "selected-vm"; let vmLexicalMarker = 1', context)
|
||||
|
||||
let contextId: number | undefined
|
||||
let uniqueContextId: string | undefined
|
||||
await vi.waitFor(() => {
|
||||
const created = runtimeContexts(cdp!).find(candidate => candidate.name === 'Inspector VM Context')
|
||||
contextId = created?.id as number | undefined
|
||||
uniqueContextId = created?.uniqueId as string | undefined
|
||||
expect(contextId).toBeTypeOf('number')
|
||||
expect(uniqueContextId).toBeTypeOf('string')
|
||||
})
|
||||
const evaluated = await cdp.call('Runtime.evaluate', {
|
||||
expression: 'globalThis.vmMarker',
|
||||
contextId,
|
||||
returnByValue: true,
|
||||
})
|
||||
expect(evaluated.result?.result).toMatchObject({ type: 'string', value: 'selected-vm' })
|
||||
expect((await cdp.call('Runtime.evaluate', {
|
||||
expression: 'globalThis.vmMarker',
|
||||
uniqueContextId,
|
||||
returnByValue: true,
|
||||
})).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' })
|
||||
expect((await cdp.call('Runtime.callFunctionOn', {
|
||||
executionContextId: contextId,
|
||||
functionDeclaration: 'function () { return globalThis.vmMarker }',
|
||||
returnByValue: true,
|
||||
})).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' })
|
||||
expect((await cdp.call('Runtime.globalLexicalScopeNames', { executionContextId: contextId })).result?.names)
|
||||
.toContain('vmLexicalMarker')
|
||||
})
|
||||
|
||||
it('uses the same Runtime value model for Host and Client realms', async () => {
|
||||
inspector = await startInspector({ port: 0, captureFetch: false })
|
||||
client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Compatibility Client' })
|
||||
@@ -397,7 +454,7 @@ describe('experimental Inspector real Worker', () => {
|
||||
callFrameId: 'client:unsupported-frame',
|
||||
expression: '1',
|
||||
})).error?.message).toContain('Client native debugging is unavailable')
|
||||
})
|
||||
}, 15_000)
|
||||
|
||||
it('projects full Host fetch data through the Network domain', async () => {
|
||||
server = createServer((request, response) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NetworkStore } from '../src/worker/inspection/network-store.ts'
|
||||
import { inspectorId } from '../src/shared/bridge/ids.ts'
|
||||
import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts'
|
||||
import type { IngestedInspectorRecord } from '../src/worker/bridge/hub.ts'
|
||||
import type { InspectorJsonValue } from '../src/shared/json.ts'
|
||||
|
||||
const source: InspectorSourceDescriptor = {
|
||||
sourceId: inspectorId<'InspectorSourceId'>('host-network', 'sourceId'),
|
||||
@@ -161,6 +162,201 @@ describe('Inspector Network domain', () => {
|
||||
expect(replay).toHaveBeenNthCalledWith(2, 'Network.responseReceived', expect.any(Object))
|
||||
expect(replay).toHaveBeenNthCalledWith(3, 'Network.loadingFinished', expect.any(Object))
|
||||
})
|
||||
|
||||
it('retains partial response bytes while reporting a post-header cancellation as failed', () => {
|
||||
const sendEvent = vi.fn()
|
||||
const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
|
||||
const network = new NetworkDomain(store)
|
||||
network.enable({ sendEvent })
|
||||
const records = requestRecords('canceled', 'partial')
|
||||
store.append(source, [
|
||||
...records.slice(0, 3),
|
||||
{
|
||||
sequence: 4,
|
||||
monotonicMs: 4,
|
||||
topic: 'fetch/error',
|
||||
payload: { requestId: 'canceled', message: 'AbortError: aborted', canceled: true },
|
||||
},
|
||||
])
|
||||
|
||||
expect(sendEvent).toHaveBeenCalledWith('Network.loadingFailed', expect.objectContaining({
|
||||
requestId: requestId('canceled'),
|
||||
type: 'Fetch',
|
||||
errorText: 'AbortError: aborted',
|
||||
canceled: true,
|
||||
}))
|
||||
expect(network.handle('Network.getResponseBody', { requestId: requestId('canceled') }, { sendEvent: vi.fn() }))
|
||||
.toMatchObject({
|
||||
body: Buffer.from('partial').toString('base64'),
|
||||
dshInspectorTruncated: true,
|
||||
dshInspectorCaptureError: 'AbortError: aborted',
|
||||
})
|
||||
})
|
||||
|
||||
it('retains request capture metadata and isolates malformed observations', () => {
|
||||
const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
|
||||
const observed: unknown[] = []
|
||||
store.subscribe(() => { throw new Error('broken observer') })
|
||||
const unsubscribe = store.subscribe((event) => { observed.push(event) })
|
||||
const start = requestRecords('metadata', 'response')[0]!
|
||||
store.append(source, [
|
||||
{ ...start, topic: 'ignored/topic' },
|
||||
{ ...start, payload: null },
|
||||
start,
|
||||
start,
|
||||
{ sequence: 2, monotonicMs: 2, topic: 'fetch/request-body-chunk', payload: { requestId: 'metadata', data: Buffer.from('body').toString('base64') } },
|
||||
{ sequence: 3, monotonicMs: 3, topic: 'fetch/request-body-end', payload: { requestId: 'metadata', truncated: true, captureError: 'request capture failed' } },
|
||||
])
|
||||
expect(store.requestBody(requestId('metadata'))).toMatchObject({
|
||||
bytes: Buffer.from('body'),
|
||||
truncated: true,
|
||||
captureError: 'request capture failed',
|
||||
complete: false,
|
||||
})
|
||||
expect(() => store.responseBody(requestId('metadata'))).toThrow('response headers have not arrived')
|
||||
|
||||
store.append(source, [
|
||||
requestRecords('metadata', 'response')[1]!,
|
||||
requestRecords('metadata', 'response')[2]!,
|
||||
{
|
||||
sequence: 4,
|
||||
monotonicMs: 4,
|
||||
topic: 'fetch/end',
|
||||
payload: {
|
||||
requestId: 'metadata',
|
||||
capturedBytes: 8,
|
||||
responseBodyTruncated: true,
|
||||
responseCaptureError: 'response capture failed',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequence: 5,
|
||||
monotonicMs: 5,
|
||||
topic: 'fetch/error',
|
||||
payload: { requestId: 'metadata', message: 'late failure', canceled: false },
|
||||
},
|
||||
])
|
||||
expect(store.responseBody(requestId('metadata'))).toMatchObject({
|
||||
bytes: Buffer.from('response'),
|
||||
truncated: true,
|
||||
captureError: 'response capture failed',
|
||||
complete: true,
|
||||
})
|
||||
expect(observed).toHaveLength(4)
|
||||
unsubscribe()
|
||||
store.dispose()
|
||||
expect(() => store.requestBody(requestId('metadata'))).toThrow('No resource with given identifier')
|
||||
expect(() => store.requestBody(1)).toThrow('Network requestId must be a string')
|
||||
})
|
||||
|
||||
it('closes only active requests from the selected source and supports replacement', () => {
|
||||
const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
|
||||
const observed: Array<{ type: string; requestId?: string }> = []
|
||||
store.subscribe((event) => { observed.push(event) })
|
||||
const clientSource: InspectorSourceDescriptor = {
|
||||
...source,
|
||||
sourceId: inspectorId<'InspectorSourceId'>('other-network', 'sourceId'),
|
||||
generation: inspectorId<'InspectorSourceGeneration'>('other-generation', 'generation'),
|
||||
kind: 'client',
|
||||
}
|
||||
store.append(source, requestRecords('complete', 'done'))
|
||||
store.append(source, requestRecords('active', 'partial').slice(0, 3))
|
||||
store.append(clientSource, requestRecords('other', 'partial').slice(0, 3))
|
||||
|
||||
store.close(source, 'source closed')
|
||||
expect(observed.filter(event => event.type === 'request-failed')).toEqual([
|
||||
expect.objectContaining({ requestId: requestId('active') }),
|
||||
])
|
||||
store.close(source, 'source closed again')
|
||||
store.replace(clientSource, [])
|
||||
expect(observed.filter(event => event.type === 'request-failed')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('rejects malformed fetch fields without losing later valid records', () => {
|
||||
const store = new NetworkStore({ maxRetainedRequests: 20, maxJournalBytes: 1_024 })
|
||||
const validStart = requestRecords('valid', 'ok')[0]!
|
||||
const malformed: IngestedInspectorRecord[] = [
|
||||
{ ...validStart, payload: null },
|
||||
{ ...validStart, payload: { ...validStart.payload as object, requestId: 1 } },
|
||||
{ ...validStart, payload: { ...validStart.payload as object, wallTimeMs: Number.POSITIVE_INFINITY } },
|
||||
{ ...validStart, payload: { ...validStart.payload as object, headers: {} } },
|
||||
{ ...validStart, payload: { ...validStart.payload as object, headers: [[1, 'value']] } },
|
||||
{ ...validStart, payload: { ...validStart.payload as object, hasBody: 'yes' } },
|
||||
]
|
||||
store.append(source, [...malformed, validStart])
|
||||
const invalidPayloads: InspectorJsonValue[] = [
|
||||
{ requestId: 'valid', data: '' },
|
||||
{ requestId: 'valid', data: 'abc' },
|
||||
{ requestId: 'valid', data: '!!!!' },
|
||||
{ requestId: 'valid', data: 'ZE==' },
|
||||
]
|
||||
store.append(source, invalidPayloads.map((payload, index) => ({
|
||||
sequence: index + 2,
|
||||
monotonicMs: index + 2,
|
||||
topic: 'fetch/request-body-chunk',
|
||||
payload,
|
||||
})))
|
||||
store.append(source, [
|
||||
{ sequence: 10, monotonicMs: 10, topic: 'fetch/request-body-end', payload: { requestId: 'valid', truncated: 'yes' } },
|
||||
{ sequence: 11, monotonicMs: 11, topic: 'fetch/request-body-end', payload: { requestId: 'valid', truncated: false, captureError: 1 } },
|
||||
{ sequence: 12, monotonicMs: 12, topic: 'fetch/response', payload: { requestId: 'valid', url: 'https://example.test', status: '200', statusText: 'OK', headers: [], mimeType: 'text/plain' } },
|
||||
{ sequence: 13, monotonicMs: 13, topic: 'fetch/response', payload: { requestId: 'valid', url: 'https://example.test', status: 200, statusText: 'OK', headers: [['bad']], mimeType: 'text/plain' } },
|
||||
requestRecords('valid', 'ok')[1]!,
|
||||
requestRecords('valid', 'ok')[2]!,
|
||||
requestRecords('valid', 'ok')[3]!,
|
||||
requestRecords('valid', 'ok')[3]!,
|
||||
])
|
||||
|
||||
expect(store.responseBody(requestId('valid')).bytes).toEqual(Buffer.from('ok'))
|
||||
|
||||
const failedStart = requestRecords('failed-before-response', '')[0]!
|
||||
store.append(source, [failedStart, {
|
||||
sequence: 20,
|
||||
monotonicMs: 20,
|
||||
topic: 'fetch/error',
|
||||
payload: { requestId: 'failed-before-response', message: 'connection failed', canceled: false },
|
||||
}])
|
||||
})
|
||||
|
||||
it('tracks zero-byte truncation and evicts a completed request before an active request', () => {
|
||||
const store = new NetworkStore({ maxRetainedRequests: 1, maxJournalBytes: 1 })
|
||||
store.append(source, requestRecords('completed', 'a'))
|
||||
const active = requestRecords('active', 'bc')
|
||||
store.append(source, [
|
||||
active[0]!,
|
||||
{
|
||||
sequence: 2,
|
||||
monotonicMs: 2,
|
||||
topic: 'fetch/request-body-chunk',
|
||||
payload: { requestId: 'active', data: Buffer.from('x').toString('base64') },
|
||||
},
|
||||
active[1]!,
|
||||
active[2]!,
|
||||
])
|
||||
|
||||
expect(() => store.requestBody(requestId('completed'))).toThrow('No resource with given identifier')
|
||||
expect(store.responseBody(requestId('active'))).toMatchObject({
|
||||
bytes: Buffer.alloc(0),
|
||||
truncated: true,
|
||||
complete: false,
|
||||
})
|
||||
|
||||
store.append(source, [{
|
||||
sequence: 4,
|
||||
monotonicMs: 4,
|
||||
topic: 'fetch/request-body-chunk',
|
||||
payload: { requestId: 'active', data: Buffer.from('d').toString('base64') },
|
||||
}])
|
||||
expect(store.requestBody(requestId('active'))).toMatchObject({ bytes: Buffer.from('x'), truncated: true })
|
||||
})
|
||||
|
||||
it('rejects a non-list header field without dropping the active request', () => {
|
||||
const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
|
||||
const start = requestRecords('headers', 'ok')[0]!
|
||||
store.append(source, [{ ...start, payload: { ...start.payload as object, headers: null } }, start])
|
||||
|
||||
expect(store.requestBody(requestId('headers')).complete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function requestRecords(localId: string, body: string): IngestedInspectorRecord[] {
|
||||
|
||||
@@ -179,6 +179,61 @@ describe('experimental Inspector Client plugin', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('cancels an outstanding Client Runtime operation without sending a late response', async () => {
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
globalThis.__DSH_INSPECTOR__ = bootstrap
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
const socket = FakeWebSocket.sockets[0]!
|
||||
socket.open()
|
||||
const open = JSON.parse(socket.sent[0]!) as {
|
||||
source: { sourceId: string; generation: string }
|
||||
}
|
||||
socket.receive({
|
||||
v: 0,
|
||||
t: 'source/accepted',
|
||||
sourceId: open.source.sourceId,
|
||||
generation: open.source.generation,
|
||||
})
|
||||
socket.receive({
|
||||
v: 0,
|
||||
t: 'client-runtime/request',
|
||||
sourceId: open.source.sourceId,
|
||||
generation: open.source.generation,
|
||||
sessionId: 'devtools-cancel',
|
||||
requestId: 'runtime-cancel',
|
||||
command: { op: 'evaluate', expression: 'new Promise(() => {})', awaitPromise: true },
|
||||
})
|
||||
socket.receive({
|
||||
v: 0,
|
||||
t: 'client-runtime/cancel',
|
||||
sourceId: open.source.sourceId,
|
||||
generation: open.source.generation,
|
||||
sessionId: 'devtools-cancel',
|
||||
requestId: 'runtime-cancel',
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(socket.sent.map(value => JSON.parse(value) as { requestId?: string })
|
||||
.some(frame => frame.requestId === 'runtime-cancel')).toBe(false)
|
||||
|
||||
socket.receive({
|
||||
v: 0,
|
||||
t: 'client-runtime/request',
|
||||
sourceId: open.source.sourceId,
|
||||
generation: open.source.generation,
|
||||
sessionId: 'devtools-cancel',
|
||||
requestId: 'runtime-after-cancel',
|
||||
command: { op: 'evaluate', expression: '42', returnByValue: true },
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(socket.sent.map(value => JSON.parse(value) as { requestId?: string })
|
||||
.some(frame => frame.requestId === 'runtime-after-cancel')).toBe(true)
|
||||
})
|
||||
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not report queue loss again after a replacement absorbs it', async () => {
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
globalThis.__DSH_INSPECTOR__ = { ...bootstrap, maxQueuedRecords: 1 }
|
||||
@@ -299,4 +354,20 @@ describe('experimental Inspector Client plugin', () => {
|
||||
await expect(fiber).rejects.toThrow('Host bootstrap is missing')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('closes the Client source when a later plugin registration fails', async () => {
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
globalThis.__DSH_INSPECTOR__ = bootstrap
|
||||
const ctx = new Context()
|
||||
ctx.provide('inspector', {
|
||||
publish: () => undefined,
|
||||
cordis: { getTree: () => Promise.reject(new Error('unused test service')) },
|
||||
})
|
||||
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await expect(fiber.await()).rejects.toThrow('service "inspector" has been registered')
|
||||
expect(FakeWebSocket.sockets).toHaveLength(1)
|
||||
expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,8 +22,8 @@ describe('experimental Inspector Host plugin', () => {
|
||||
})
|
||||
|
||||
it('starts the Worker, provides ctx.inspector, injects Client bootstrap, and disposes', async () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
context = new Context()
|
||||
const log = vi.spyOn(context.logger, 'info').mockImplementation(() => undefined)
|
||||
context.provide('webServer', {} as WebServer)
|
||||
const fiber = context.plugin(
|
||||
{ name, inject: [...inject], Config, apply },
|
||||
|
||||
@@ -124,6 +124,15 @@ describe('Inspector source protocol', () => {
|
||||
...request,
|
||||
command: { ...request.command, unversionedExtension: true },
|
||||
})).toThrow('unknown field')
|
||||
|
||||
expect(parseWorkerSourceFrame({
|
||||
v: 0,
|
||||
t: 'client-runtime/response-acknowledged',
|
||||
sourceId: 'client-1',
|
||||
generation: 'g-1',
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-1',
|
||||
})).toMatchObject({ t: 'client-runtime/response-acknowledged', requestId: 'request-1' })
|
||||
})
|
||||
|
||||
it('rejects invalid RemoteObject representations', () => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/** Shared JSON and exact-field validation behavior. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { inspectorId } from '../src/shared/identity.ts'
|
||||
import { isJsonValue, isPlainObject, jsonByteLength, requireJsonObject } from '../src/shared/json.ts'
|
||||
import {
|
||||
exactKeys,
|
||||
exactObject,
|
||||
optionalBoolean,
|
||||
optionalNonNegativeNumber,
|
||||
optionalString,
|
||||
wireId,
|
||||
} from '../src/shared/validation.ts'
|
||||
|
||||
describe('Inspector JSON values', () => {
|
||||
it('accepts every lossless JSON category and measures UTF-8 bytes', () => {
|
||||
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, { value: '好' })
|
||||
expect([null, 'text', true, 1, [1, 'two'], { nested: [false] }, nullPrototype].every(isJsonValue)).toBe(true)
|
||||
expect(jsonByteLength({ value: '好' })).toBe(Buffer.byteLength('{"value":"好"}'))
|
||||
expect(isPlainObject({})).toBe(true)
|
||||
expect(isPlainObject(nullPrototype)).toBe(true)
|
||||
expect(requireJsonObject({ value: 1 }, 'payload')).toEqual({ value: 1 })
|
||||
})
|
||||
|
||||
it('rejects lossy primitives, cycles, exotic arrays, and accessor objects', () => {
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
const arrayWithField = [1]
|
||||
Reflect.set(arrayWithField, 'extra', true)
|
||||
const inheritedArray = Object.setPrototypeOf([1], null) as unknown
|
||||
const symbolObject = { [Symbol('field')]: true }
|
||||
const hidden = {}
|
||||
Object.defineProperty(hidden, 'value', { value: 1, enumerable: false })
|
||||
const accessor = {}
|
||||
Object.defineProperty(accessor, 'value', { get: () => 1, enumerable: true })
|
||||
const rejected = [
|
||||
undefined, () => undefined, Number.NaN, -0, cyclic, arrayWithField,
|
||||
inheritedArray, new Date(), symbolObject, hidden, accessor,
|
||||
]
|
||||
for (const value of rejected) {
|
||||
expect(isJsonValue(value)).toBe(false)
|
||||
}
|
||||
expect(() => requireJsonObject([], 'payload')).toThrow('payload must be a JSON object')
|
||||
expect(() => requireJsonObject(cyclic, 'payload')).toThrow('payload must be a JSON object')
|
||||
expect(isPlainObject(null)).toBe(false)
|
||||
expect(isPlainObject([])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inspector exact-field readers', () => {
|
||||
it('accepts declared fields and optional values', () => {
|
||||
const record = { text: 'value', enabled: true, timeout: 0 }
|
||||
expect(exactObject(record, ['text', 'enabled', 'timeout'], 'record')).toBe(record)
|
||||
expect(() => { exactKeys(record, ['text', 'enabled', 'timeout'], 'record') }).not.toThrow()
|
||||
expect(optionalString(record, 'text')).toEqual({ text: 'value' })
|
||||
expect(optionalBoolean(record, 'enabled')).toEqual({ enabled: true })
|
||||
expect(optionalNonNegativeNumber(record, 'timeout')).toEqual({ timeout: 0 })
|
||||
expect(optionalString({}, 'text')).toEqual({})
|
||||
expect(optionalBoolean({}, 'enabled')).toEqual({})
|
||||
expect(optionalNonNegativeNumber({}, 'timeout')).toEqual({})
|
||||
expect(wireId<'ProbeId'>('probe', 'probeId')).toBe('probe')
|
||||
expect(inspectorId<'ProbeId'>('probe', 'probeId')).toBe('probe')
|
||||
})
|
||||
|
||||
it('rejects unknown, symbolic, and wrongly typed fields', () => {
|
||||
expect(() => exactObject([], [], 'record')).toThrow('record must be an object')
|
||||
expect(() => { exactKeys({ extra: true }, [], 'record') }).toThrow('unknown field')
|
||||
expect(() => { exactKeys({ [Symbol('extra')]: true }, [], 'record') }).toThrow('unknown field')
|
||||
expect(() => wireId<'ProbeId'>(1, 'probeId')).toThrow('probeId must be a string')
|
||||
expect(() => inspectorId<'ProbeId'>('', 'probeId')).toThrow('1 to 256 characters')
|
||||
expect(() => inspectorId<'ProbeId'>('x'.repeat(257), 'probeId')).toThrow('1 to 256 characters')
|
||||
expect(() => optionalString({ text: 1 }, 'text')).toThrow('text must be a string')
|
||||
expect(() => optionalBoolean({ enabled: 1 }, 'enabled')).toThrow('enabled must be a boolean')
|
||||
for (const timeout of ['1', Number.NaN, -1]) {
|
||||
expect(() => optionalNonNegativeNumber({ timeout }, 'timeout')).toThrow('non-negative finite number')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,25 +1,41 @@
|
||||
/** Worker-side source buffer behavior. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MessageChannel } from 'node:worker_threads'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { HostBridgePublisher } from '../src/host/bridge/publisher.ts'
|
||||
import { inspectorId } from '../src/shared/bridge/ids.ts'
|
||||
import { InspectorSourceBuffer } from '../src/shared/bridge/buffer.ts'
|
||||
import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../src/shared/bridge/buffer.ts'
|
||||
import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts'
|
||||
|
||||
const sourceId = inspectorId<'InspectorSourceId'>('source-buffer-test', 'sourceId')
|
||||
const generation = inspectorId<'InspectorSourceGeneration'>('generation-buffer-test', 'generation')
|
||||
const source: InspectorSourceDescriptor = {
|
||||
sourceId,
|
||||
generation,
|
||||
kind: 'host',
|
||||
label: 'Host',
|
||||
timeOriginMs: performance.timeOrigin,
|
||||
capabilities: [],
|
||||
}
|
||||
|
||||
function buffer(maxQueuedRecords = 2): InspectorSourceBuffer {
|
||||
function buffer(
|
||||
maxQueuedRecords = 2,
|
||||
overrides: Partial<InspectorSourceBufferOptions> = {},
|
||||
): InspectorSourceBuffer {
|
||||
return new InspectorSourceBuffer({
|
||||
topics: ['*'],
|
||||
maxQueuedRecords,
|
||||
maxQueuedBytes: 32_768,
|
||||
maxRecordsPerFrame: 8,
|
||||
maxFrameBytes: 32_768,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('Inspector source buffer', () => {
|
||||
it('absorbs pre-replacement queue loss exactly once', () => {
|
||||
const records = buffer(1)
|
||||
expect(records.replacement(sourceId, generation)).toMatchObject({ nextSequence: 1, records: [] })
|
||||
records.publish('test/event', { ordinal: 1 }, 1)
|
||||
records.publish('test/event', { ordinal: 2 }, 2)
|
||||
|
||||
@@ -38,6 +54,106 @@ describe('Inspector source buffer', () => {
|
||||
const records = buffer()
|
||||
|
||||
expect(() => { records.publish('', {}, 1) }).toThrow('topic must contain 1 to 128 characters')
|
||||
expect(() => { records.publish('x'.repeat(129), {}, 1) }).toThrow('topic must contain 1 to 128 characters')
|
||||
expect(() => { buffer(2, { topics: ['declared'] }).publish('undeclared', {}, 1) })
|
||||
.toThrow('source does not declare topic')
|
||||
expect(() => { records.publish('test/event', {}, Number.NaN) }).toThrow('monotonicMs must be finite')
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
expect(() => { records.publish('test/event', cyclic as never, 1) }).toThrow('lossless JSON data')
|
||||
})
|
||||
|
||||
it('rejects oversized retained state without replacing the previous value', () => {
|
||||
const records = buffer(4, { maxFrameBytes: 4_300 })
|
||||
records.setState('state', { value: 'kept' }, 1)
|
||||
expect(() => { records.setState('state', { value: 'x'.repeat(1_000) }, 2) })
|
||||
.toThrow('source state exceeds the source-frame byte limit')
|
||||
expect(() => { records.setState('other', { value: 'x'.repeat(1_000) }, 3) })
|
||||
.toThrow('source state exceeds the source-frame byte limit')
|
||||
expect(records.replacement(sourceId, generation).records).toEqual([
|
||||
{ topic: 'state', payload: { value: 'kept' }, monotonicMs: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('splits frames at record, byte, and sequence gaps and discards pending records', () => {
|
||||
const records = buffer(10, { maxRecordsPerFrame: 2, maxFrameBytes: 4_300 })
|
||||
expect(records.hasPending).toBe(false)
|
||||
records.publish('test/event', { value: 'a'.repeat(40) }, 1)
|
||||
records.publish('test/event', { value: 'x'.repeat(1_000) }, 2)
|
||||
records.publish('test/event', { value: 'b'.repeat(40) }, 3)
|
||||
expect(records.hasPending).toBe(true)
|
||||
|
||||
expect(records.takeBatch(sourceId, generation)).toMatchObject({ firstSequence: 1, records: [{ monotonicMs: 1 }] })
|
||||
expect(records.takeBatch(sourceId, generation)).toMatchObject({
|
||||
firstSequence: 3,
|
||||
droppedBefore: 1,
|
||||
records: [{ monotonicMs: 3 }],
|
||||
})
|
||||
expect(records.takeBatch(sourceId, generation)).toBeUndefined()
|
||||
|
||||
records.publish('test/event', { ordinal: 4 }, 4)
|
||||
records.discardPending()
|
||||
expect(records.hasPending).toBe(false)
|
||||
|
||||
const byteSplit = buffer(10, { maxFrameBytes: 4_300 })
|
||||
byteSplit.publish('test/event', { value: 'a'.repeat(100) }, 1)
|
||||
byteSplit.publish('test/event', { value: 'b'.repeat(100) }, 2)
|
||||
expect(byteSplit.takeBatch(sourceId, generation)?.records).toHaveLength(1)
|
||||
expect(byteSplit.takeBatch(sourceId, generation)?.records).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops queued records against the byte limit independently of the item limit', () => {
|
||||
const records = buffer(10, { maxQueuedBytes: 120 })
|
||||
records.publish('test/event', { value: 'a'.repeat(40) }, 1)
|
||||
records.publish('test/event', { value: 'b'.repeat(40) }, 2)
|
||||
|
||||
expect(records.takeBatch(sourceId, generation)).toMatchObject({
|
||||
firstSequence: 2,
|
||||
droppedBefore: 1,
|
||||
records: [{ monotonicMs: 2 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps at most one Host MessagePort observation batch in flight', async () => {
|
||||
const channel = new MessageChannel()
|
||||
const messages: unknown[] = []
|
||||
channel.port2.on('message', (message) => { messages.push(message) })
|
||||
channel.port2.start()
|
||||
const publisher = new HostBridgePublisher(channel.port1, source, {
|
||||
topics: ['*'],
|
||||
maxQueuedRecords: 2,
|
||||
maxQueuedBytes: 32_768,
|
||||
maxRecordsPerFrame: 1,
|
||||
maxFrameBytes: 32_768,
|
||||
})
|
||||
try {
|
||||
publisher.publish('test/event', { ordinal: 1 })
|
||||
publisher.flush()
|
||||
publisher.publish('test/event', { ordinal: 2 })
|
||||
publisher.publish('test/event', { ordinal: 3 })
|
||||
await vi.waitFor(() => { expect(messages).toHaveLength(1) })
|
||||
const first = messages[0] as { firstSequence: number; records: Array<{ payload: unknown }> }
|
||||
expect(first.records).toHaveLength(1)
|
||||
expect(first.records[0]?.payload).toEqual({ ordinal: 1 })
|
||||
|
||||
publisher.acknowledge(first.firstSequence + first.records.length)
|
||||
await vi.waitFor(() => { expect(messages).toHaveLength(2) })
|
||||
const second = messages[1] as { firstSequence: number; droppedBefore: number; records: Array<{ payload: unknown }> }
|
||||
expect(second).toMatchObject({
|
||||
firstSequence: 2,
|
||||
droppedBefore: 0,
|
||||
records: [{ payload: { ordinal: 2 } }],
|
||||
})
|
||||
publisher.acknowledge(second.firstSequence + second.records.length)
|
||||
await vi.waitFor(() => { expect(messages).toHaveLength(3) })
|
||||
expect(messages[2]).toMatchObject({
|
||||
firstSequence: 3,
|
||||
records: [{ payload: { ordinal: 3 } }],
|
||||
})
|
||||
} finally {
|
||||
publisher.close()
|
||||
channel.port1.close()
|
||||
channel.port2.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+20
-6
@@ -251,12 +251,26 @@ export default defineConfig({
|
||||
// coverage lane exists.
|
||||
'packages/experimental/webworker-runtime/src/**',
|
||||
'packages/experimental/webworker-packer/src/*',
|
||||
// Inspector behavior spans a Node Worker, the Host isolate, and a real
|
||||
// browser realm. Its focused specs cover pure logic, while its Worker,
|
||||
// Debugger, Chromium, and Loader suites run the assembled paths that
|
||||
// the parent Vitest process cannot attribute. TODO(inspector): remove
|
||||
// when the coverage lane can merge cross-realm V8 coverage.
|
||||
'packages/experimental/inspector/src/**',
|
||||
// Inspector execution adapters run in a Node Worker, the Host native
|
||||
// inspector session, or a browser realm, outside attributable parent
|
||||
// Vitest coverage.
|
||||
'packages/experimental/inspector/src/client/**',
|
||||
'packages/experimental/inspector/src/host/bridge/**',
|
||||
'packages/experimental/inspector/src/host/cdp/**',
|
||||
'packages/experimental/inspector/src/worker/bridge/**',
|
||||
'packages/experimental/inspector/src/worker/cdp/**',
|
||||
'packages/experimental/inspector/src/worker/realms/**',
|
||||
'packages/experimental/inspector/src/worker/{entry,server}.ts',
|
||||
// Keep already-complete Inspector modules under the per-file gate and
|
||||
// enumerate the remaining direct-test debt instead of exempting src/**.
|
||||
// TODO(inspector): close these branch gaps and remove the entries.
|
||||
'packages/experimental/inspector/src/host/plugin.ts',
|
||||
'packages/experimental/inspector/src/shared/bridge/{control-codec,rpc}.ts',
|
||||
'packages/experimental/inspector/src/shared/bridge/messages/observation.ts',
|
||||
'packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts',
|
||||
'packages/experimental/inspector/src/shared/bridge/messages/runtime/{command-codec,console-frames,frames,value-codec}.ts',
|
||||
'packages/experimental/inspector/src/shared/bridge/messages/sources/{codec,frames}.ts',
|
||||
'packages/experimental/inspector/src/worker/inspection/{cordis-store,query-router,realm-store}.ts',
|
||||
'packages/client/modules/src/client/system.ts',
|
||||
'packages/client/hmr/src/client/index.ts',
|
||||
// Web config-tree boot round: the new host-side web-transport halves
|
||||
|
||||
Reference in New Issue
Block a user