From 5a5d5de948f8689f5758a2d826ac03fa9debc1b2 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 27 Aug 2026 06:47:09 +0800
Subject: [PATCH] fix(inspector): address protocol review findings
---
...-08-23-cross-realm-cdp-inspector.i18n.yaml | 4 +-
.../2026-08-23-cross-realm-cdp-inspector.md | 4 +-
...2026-08-23-cross-realm-cdp-inspector.zh.md | 4 +-
...ution-realms-and-protocol-planes.i18n.yaml | 6 +-
...or-execution-realms-and-protocol-planes.md | 44 ++-
...execution-realms-and-protocol-planes.zh.md | 44 ++-
.../experimental/inspector/README.i18n.yaml | 4 +-
packages/experimental/inspector/README.md | 2 +-
packages/experimental/inspector/README.zh.md | 2 +-
.../inspector/src/client/bridge/dispatcher.ts | 22 +-
.../inspector/src/client/bridge/transport.ts | 62 +++-
.../inspector/src/client/cdp/runtime.ts | 114 ++++++--
.../inspector/src/client/plugin.ts | 41 ++-
.../inspector/src/host/bridge/dispatcher.ts | 15 +-
.../inspector/src/host/bridge/publisher.ts | 31 +-
.../inspector/src/host/bridge/transport.ts | 1 +
.../inspector/src/host/inspection/network.ts | 8 +
.../experimental/inspector/src/host/plugin.ts | 2 +-
.../inspector/src/shared/bridge/buffer.ts | 16 +-
.../src/shared/bridge/messages/observation.ts | 28 ++
.../shared/bridge/messages/runtime/frames.ts | 62 ++++
.../inspector/src/shared/cdp/operations.ts | 7 +
.../inspector/src/shared/cdp/realm.ts | 9 +-
.../inspector/src/shared/cordis/collector.ts | 21 +-
.../inspector/src/shared/cordis/model.ts | 3 +-
.../inspector/src/shared/cordis/projector.ts | 20 +-
.../inspector/src/worker/bridge/hub.ts | 7 +
.../src/worker/bridge/runtime-rpc.ts | 58 +++-
.../worker/cdp/domains/runtime/cdp-params.ts | 2 +-
.../src/worker/cdp/domains/runtime/session.ts | 23 +-
.../src/worker/inspection/network-store.ts | 35 +--
.../src/worker/realms/client/runtime.ts | 13 +-
.../src/worker/realms/host/runtime.ts | 26 +-
.../tests/client-runtime.client.spec.ts | 49 ++++
.../inspector/tests/cordis-model.host.spec.ts | 225 +++++++++++++++
.../inspector/tests/cordis-tree.host.spec.ts | 102 +++++++
.../tests/fetch-observer.host.spec.ts | 265 +++++++++++++++++-
.../inspector/tests/integration.host.spec.ts | 59 +++-
.../inspector/tests/network.host.spec.ts | 196 +++++++++++++
.../inspector/tests/plugin.client.spec.ts | 71 +++++
.../inspector/tests/plugin.host.spec.ts | 2 +-
.../inspector/tests/protocol.host.spec.ts | 9 +
.../tests/shared-validation.host.spec.ts | 78 ++++++
.../tests/source-buffer.host.spec.ts | 122 +++++++-
vitest.config.ts | 26 +-
45 files changed, 1753 insertions(+), 191 deletions(-)
rename .agents/notes/{proposed => implemented}/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml (51%)
rename .agents/notes/{proposed => implemented}/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md (69%)
rename .agents/notes/{proposed => implemented}/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md (72%)
create mode 100644 packages/experimental/inspector/tests/cordis-model.host.spec.ts
create mode 100644 packages/experimental/inspector/tests/shared-validation.host.spec.ts
diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml
index 505887522c..47b37fad16 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml
@@ -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
diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md
index 2b4860a849..e6e1d48da4 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md
@@ -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.
diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md
index 64e52fe9d5..9d67868caf 100644
--- a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md
@@ -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 反向映射。
diff --git a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml
similarity index 51%
rename from .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml
rename to .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml
index 31a09c0e9a..b175565b1d 100644
--- a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml
@@ -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
diff --git a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md
similarity index 69%
rename from .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md
rename to .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md
index 67f37add1a..e8bff0661d 100644
--- a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md
+++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md
@@ -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.
diff --git a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md
similarity index 72%
rename from .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md
rename to .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md
index be4dd947fd..2db6307d1b 100644
--- a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md
@@ -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 与聚焦行为测试增加了维护工作,但会持续暴露环境泄漏和镜像结构漂移。
diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml
index 6925e0ae2b..69720dbcc8 100644
--- a/packages/experimental/inspector/README.i18n.yaml
+++ b/packages/experimental/inspector/README.i18n.yaml
@@ -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
diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md
index 86357b3a91..09f8b2901e 100644
--- a/packages/experimental/inspector/README.md
+++ b/packages/experimental/inspector/README.md
@@ -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.
## Security
diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md
index ce030fa387..510ca42362 100644
--- a/packages/experimental/inspector/README.zh.md
+++ b/packages/experimental/inspector/README.zh.md
@@ -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 也走相同的取消失败路径。
## 安全
diff --git a/packages/experimental/inspector/src/client/bridge/dispatcher.ts b/packages/experimental/inspector/src/client/bridge/dispatcher.ts
index 535a8ba237..2f1ca54e44 100644
--- a/packages/experimental/inspector/src/client/bridge/dispatcher.ts
+++ b/packages/experimental/inspector/src/client/bridge/dispatcher.ts
@@ -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
diff --git a/packages/experimental/inspector/src/client/bridge/transport.ts b/packages/experimental/inspector/src/client/bridge/transport.ts
index 90ea7164f0..68b7c0cc02 100644
--- a/packages/experimental/inspector/src/client/bridge/transport.ts
+++ b/packages/experimental/inspector/src/client/bridge/transport.ts
@@ -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()
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, { t: 'client-runtime/request' }>,
): Promise {
- 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,
diff --git a/packages/experimental/inspector/src/client/cdp/runtime.ts b/packages/experimental/inspector/src/client/cdp/runtime.ts
index b72a73e2fc..b7ddbac384 100644
--- a/packages/experimental/inspector/src/client/cdp/runtime.ts
+++ b/packages/experimental/inspector/src/client/cdp/runtime.ts
@@ -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()
+ private readonly responseAllocations = new Map()
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 {
+ async execute(
+ frame: ClientRuntimeRequestFrame,
+ signal?: AbortSignal,
+ deferObjectCommit = false,
+ ): Promise {
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 {
+ async execute(
+ command: ClientRuntimeCommand,
+ allocation: ClientObjectAllocation,
+ signal?: AbortSignal,
+ ): Promise {
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,
allocation: ClientObjectAllocation,
+ signal?: AbortSignal,
): Promise {
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,
allocation: ClientObjectAllocation,
+ signal?: AbortSignal,
): Promise {
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,
allocation: ClientObjectAllocation,
+ signal?: AbortSignal,
): Promise {
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 {
- if (timeoutMs === undefined) return await value
+async function awaitWithCancellation(
+ value: unknown,
+ signal: AbortSignal | undefined,
+ timeoutMs?: number,
+): Promise {
+ if (signal?.aborted === true) throw new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled')
let timer: ReturnType | undefined
+ let onAbort: (() => void) | undefined
try {
- return await Promise.race([
- Promise.resolve(value),
- new Promise((_resolve, reject) => {
+ const limits: Promise[] = []
+ if (timeoutMs !== undefined) {
+ limits.push(new Promise((_resolve, reject) => {
timer = setTimeout(() => {
reject(new ClientRuntimeExecutionError('timeout', `Client evaluation exceeded ${String(timeoutMs)}ms`))
}, timeoutMs)
- }),
- ])
+ }))
+ }
+ if (signal !== undefined) {
+ limits.push(new Promise((_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)
}
}
diff --git a/packages/experimental/inspector/src/client/plugin.ts b/packages/experimental/inspector/src/client/plugin.ts
index 5b619a4788..d29ba5cdc0 100644
--- a/packages/experimental/inspector/src/client/plugin.ts
+++ b/packages/experimental/inspector/src/client/plugin.ts
@@ -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, 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')
+}
diff --git a/packages/experimental/inspector/src/host/bridge/dispatcher.ts b/packages/experimental/inspector/src/host/bridge/dispatcher.ts
index 441dcb1512..16e4aba219 100644
--- a/packages/experimental/inspector/src/host/bridge/dispatcher.ts
+++ b/packages/experimental/inspector/src/host/bridge/dispatcher.ts
@@ -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)
diff --git a/packages/experimental/inspector/src/host/bridge/publisher.ts b/packages/experimental/inspector/src/host/bridge/publisher.ts
index 89564261da..6b272ba74a 100644
--- a/packages/experimental/inspector/src/host/bridge/publisher.ts
+++ b/packages/experimental/inspector/src/host/bridge/publisher.ts
@@ -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 {
diff --git a/packages/experimental/inspector/src/host/bridge/transport.ts b/packages/experimental/inspector/src/host/bridge/transport.ts
index fb526a020a..2f5e0a9f68 100644
--- a/packages/experimental/inspector/src/host/bridge/transport.ts
+++ b/packages/experimental/inspector/src/host/bridge/transport.ts
@@ -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}`) },
})
diff --git a/packages/experimental/inspector/src/host/inspection/network.ts b/packages/experimental/inspector/src/host/inspection/network.ts
index aa5225a8c5..8ff9af0477 100644
--- a/packages/experimental/inspector/src/host/inspection/network.ts
+++ b/packages/experimental/inspector/src/host/inspection/network.ts
@@ -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,
diff --git a/packages/experimental/inspector/src/host/plugin.ts b/packages/experimental/inspector/src/host/plugin.ts
index 039eb1b8ca..0e54f3425e 100644
--- a/packages/experimental/inspector/src/host/plugin.ts
+++ b/packages/experimental/inspector/src/host/plugin.ts
@@ -49,7 +49,7 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise {
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)
diff --git a/packages/experimental/inspector/src/shared/bridge/buffer.ts b/packages/experimental/inspector/src/shared/bridge/buffer.ts
index c058fbe18a..032e01afc2 100644
--- a/packages/experimental/inspector/src/shared/bridge/buffer.ts
+++ b/packages/experimental/inspector/src/shared/bridge/buffer.ts
@@ -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
}
}
diff --git a/packages/experimental/inspector/src/shared/bridge/messages/observation.ts b/packages/experimental/inspector/src/shared/bridge/messages/observation.ts
index 1127eb0d85..ac7f1403e8 100644
--- a/packages/experimental/inspector/src/shared/bridge/messages/observation.ts
+++ b/packages/experimental/inspector/src/shared/bridge/messages/observation.ts
@@ -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')
diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts
index a6ed8f620b..aa58dd1290 100644
--- a/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts
+++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts
@@ -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):
}
}
+/**
+ * Parse and rebuild one Worker-to-Client Runtime cancellation.
+ * @param value - Untrusted cancellation frame.
+ * @returns The validated cancellation frame.
+ */
+export function parseClientRuntimeCancelFrame(value: Record): 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,
+): 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.
diff --git a/packages/experimental/inspector/src/shared/cdp/operations.ts b/packages/experimental/inspector/src/shared/cdp/operations.ts
index 56692a22dc..ebfa6d7e5a 100644
--- a/packages/experimental/inspector/src/shared/cdp/operations.ts
+++ b/packages/experimental/inspector/src/shared/cdp/operations.ts
@@ -16,9 +16,15 @@ export type RuntimeCallArgument =
| { 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 {
/** Function invocation request within one inspected realm. */
export interface RuntimeCallFunctionRequest {
readonly functionDeclaration: string
+ readonly context?: RuntimeExecutionContext
readonly receiver?: Handle
readonly arguments?: readonly RuntimeCallArgument[]
readonly objectGroup?: string
diff --git a/packages/experimental/inspector/src/shared/cdp/realm.ts b/packages/experimental/inspector/src/shared/cdp/realm.ts
index 38b61afb11..798b76e68d 100644
--- a/packages/experimental/inspector/src/shared/cdp/realm.ts
+++ b/packages/experimental/inspector/src/shared/cdp/realm.ts
@@ -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,
): Promise>
- /** @returns Names visible in the realm's global lexical scope. */
- globalLexicalScopeNames(): Promise
+ /**
+ * 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
/**
* Release one backend object reference.
* @param handle - Handle owned by this realm session.
diff --git a/packages/experimental/inspector/src/shared/cordis/collector.ts b/packages/experimental/inspector/src/shared/cordis/collector.ts
index 622e4d6158..511a7fa14f 100644
--- a/packages/experimental/inspector/src/shared/cordis/collector.ts
+++ b/packages/experimental/inspector/src/shared/cordis/collector.ts
@@ -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()
+ 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 {
diff --git a/packages/experimental/inspector/src/shared/cordis/model.ts b/packages/experimental/inspector/src/shared/cordis/model.ts
index 86750e508d..b2ed6443b0 100644
--- a/packages/experimental/inspector/src/shared/cordis/model.ts
+++ b/packages/experimental/inspector/src/shared/cordis/model.ts
@@ -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')
diff --git a/packages/experimental/inspector/src/shared/cordis/projector.ts b/packages/experimental/inspector/src/shared/cordis/projector.ts
index d0d02efe77..7ef4f7cdbb 100644
--- a/packages/experimental/inspector/src/shared/cordis/projector.ts
+++ b/packages/experimental/inspector/src/shared/cordis/projector.ts
@@ -69,20 +69,10 @@ function projectContext(node: Extract): 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)}`)
-}
diff --git a/packages/experimental/inspector/src/worker/bridge/hub.ts b/packages/experimental/inspector/src/worker/bridge/hub.ts
index dfdffc2eee..d994b1bcb0 100644
--- a/packages/experimental/inspector/src/worker/bridge/hub.ts
+++ b/packages/experimental/inspector/src/worker/bridge/hub.ts
@@ -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()
}
diff --git a/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts b/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts
index e4e66c5031..6de58b245f 100644
--- a/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts
+++ b/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts
@@ -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
diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts
index b38c40b334..2e7636979b 100644
--- a/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts
+++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts
@@ -194,7 +194,7 @@ export function parseReleaseObjectGroup(params: Readonly
* @returns The validated context selector.
*/
export function parseGlobalLexicalScopeNames(params: Readonly>): CdpExecutionContextSelector {
- exactKeys(params, ['executionContextId', 'uniqueContextId'], 'Runtime.globalLexicalScopeNames params')
+ exactKeys(params, ['executionContextId'], 'Runtime.globalLexicalScopeNames params')
return parseContextSelector(params, 'executionContextId')
}
diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts
index 9b259ab7fb..aa34b37254 100644
--- a/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts
+++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts
@@ -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>): Promise