fix(inspector): retain bootstrap request until cancellation settles

This commit is contained in:
fz committed 2026-10-19 23:10:30 +08:00
1 parent ba4395d046
commit e7436013d1
5 files changed
+23 -9

No files matched your search

@@ -18,8 +18,8 @@
en: 6d290aabc543f878
zh: 7ad283b159ae2c29
/deepseek-ai-dsh-experimental-inspector/runtime-layout:
en: 13f495af2fec307d
zh: ec4bebf24a87ec54
en: 7db0c435e07d5005
zh: 1573972326c33893
/deepseek-ai-dsh-experimental-inspector/configuration:
en: d094842893700446
zh: 7a7f880ba795948f
+1 -1
View File
@@ -48,7 +48,7 @@ The frontend uses browser runtime adapters and excludes Node-only transports at
<a id="runtime-layout"></a>
## Runtime layout
The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. A late-loading Client fetches its bootstrap through an authenticated Host route, which the Host registers before it starts the Worker: a request waits for Worker readiness and then receives the bootstrap, or `503` when startup fails; an aborted request stops waiting. When the platform cannot run the Worker and reports Node's `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, such as the browser preview's Worker host, the Host logs one warning and the plugin still starts with only that route, which answers `503`; it provides no `ctx.inspector`, DevTools routes, bootstrap injection, or `devtools://` URL. Any other startup error still rejects the plugin. Bootstrap transport and source-setup failures warn and retain connection-reset recovery for both injected and fetched startup; invalid bootstrap data or service-registration failures reject startup. Source replacement joins old service-consumer cleanup before claiming another source, and unloading an active plugin cancels its pending bootstrap refresh.
The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. A late-loading Client fetches its bootstrap through an authenticated Host route, which the Host registers before it starts the Worker: a request waits for Worker readiness and then receives the bootstrap, or `503` when startup fails; an aborted request stops waiting even if garbage collection runs during startup. When the platform cannot run the Worker and reports Node's `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`, such as the browser preview's Worker host, the Host logs one warning and the plugin still starts with only that route, which answers `503`; it provides no `ctx.inspector`, DevTools routes, bootstrap injection, or `devtools://` URL. Any other startup error still rejects the plugin. Bootstrap transport and source-setup failures warn and retain connection-reset recovery for both injected and fetched startup; invalid bootstrap data or service-registration failures reject startup. Source replacement joins old service-consumer cleanup before claiming another source, and unloading an active plugin cancels its pending bootstrap refresh.
The Host plugin starts the Worker and connects a dedicated `MessagePort`. The Client uses injected or fetched browser-source parameters to open an authenticated WebSocket directly to the Worker; it receives no DevTools URL. Chrome DevTools connects to the Worker's CDP WebSocket. A private `node:inspector.Session` per DevTools connection attaches from the Worker to the Host main thread, so Host debugging remains available while Host JavaScript is paused.
+1 -1
View File
@@ -48,7 +48,7 @@ Host 在相对于应用根目录的 `inspector/devtools/devtools_app.html` 路
<a id="runtime-layout"></a>
## 运行时布局
Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证快照。Cordis 负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和 dispose(资源释放)。后加载的 Client 通过已鉴权的 Host 路由获取 bootstrap。Host 在启动 Worker 之前注册该路由,请求会等到 Worker 就绪后返回 bootstrap,启动失败时返回 `503`;请求中止时立即停止等待。当前平台无法运行 Worker 并报告 Node 的 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 时(例如浏览器预览的 Worker host),Host 记录一条警告,插件仍照常启动,只保留该路由并返回 `503`,不提供 `ctx.inspector`、DevTools 路由、bootstrap 注入或 `devtools://` URL;其他任何启动错误仍使插件启动失败。初始注入与主动获取两条路径中,bootstrap 传输或 source 建立失败均记录警告并保留连接重置后的重试;bootstrap 数据非法或服务注册失败则使启动失败。替换 source 会等待旧服务消费者清理完成,再申请新 source;卸载已激活插件会取消其未完成的 bootstrap 刷新。
Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证快照。Cordis 负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和 dispose(资源释放)。后加载的 Client 通过已鉴权的 Host 路由获取 bootstrap。Host 在启动 Worker 之前注册该路由,请求会等到 Worker 就绪后返回 bootstrap,启动失败时返回 `503`;即使启动期间发生垃圾回收,请求中止时也立即停止等待。当前平台无法运行 Worker 并报告 Node 的 `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM` 时(例如浏览器预览的 Worker host),Host 记录一条警告,插件仍照常启动,只保留该路由并返回 `503`,不提供 `ctx.inspector`、DevTools 路由、bootstrap 注入或 `devtools://` URL;其他任何启动错误仍使插件启动失败。初始注入与主动获取两条路径中,bootstrap 传输或 source 建立失败均记录警告并保留连接重置后的重试;bootstrap 数据非法或服务注册失败则使启动失败。替换 source 会等待旧服务消费者清理完成,再申请新 source;卸载已激活插件会取消其未完成的 bootstrap 刷新。
Host 插件启动 Worker 并连接专用 `MessagePort`。Client 使用注入或主动获取的浏览器 source 参数,直接向 Worker 打开带鉴权的 WebSocket,不接收 DevTools URL。Chrome DevTools 连接 Worker 的 CDP WebSocket。每条 DevTools 连接在 Worker 中独占一个连接 Host 主线程的 `node:inspector.Session`,因此 Host JavaScript 暂停时仍可调试 Host。
@@ -63,7 +63,7 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise<voi
try {
disposers.push(ctx.connection.fetch.register({
path: INSPECTOR_BOOTSTRAP_PATH, methods: ['GET'], requestBody: 'buffered',
fetch: request => bootstrapResponse(ready.promise, request.signal),
fetch: request => bootstrapResponse(ready.promise, request),
}))
const handle = await startInspector(spec).catch((error: unknown) => {
if (!isUnavailableOnPlatform(error)) throw error
@@ -196,10 +196,11 @@ function isUnavailableOnPlatform(error: unknown): boolean {
/**
* Answer one bootstrap request once Host initialization settles or the browser abandons the request.
* @param ready - Resolves with the Client bootstrap, or with undefined after initialization failed or found the platform unavailable.
* @param signal - Request signal; an aborted request stops waiting and receives no endpoint.
* @param request - Retained until settlement so Node keeps forwarding the caller's abort to its signal.
* @returns The bootstrap JSON, or 503 when the Inspector is unavailable or the request was aborted.
*/
async function bootstrapResponse(ready: Promise<InspectorClientBootstrap | undefined>, signal: AbortSignal): Promise<Response> {
async function bootstrapResponse(ready: Promise<InspectorClientBootstrap | undefined>, request: Request): Promise<Response> {
const signal = request.signal
const headers = { 'cache-control': 'no-store' }
const abandoned = Promise.withResolvers<undefined>()
const abandon = () => { abandoned.resolve(undefined) }
@@ -209,6 +210,6 @@ async function bootstrapResponse(ready: Promise<InspectorClientBootstrap | undef
const client = await Promise.race([ready, abandoned.promise])
return client === undefined ? new Response(null, { status: 503, headers }) : Response.json(client, { headers })
} finally {
signal.removeEventListener('abort', abandon)
request.signal.removeEventListener('abort', abandon)
}
}
@@ -5,6 +5,8 @@ import type { Message } from '@deepseek-ai/cordis'
import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver'
import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
import { setFlagsFromString } from 'node:v8'
import { runInNewContext } from 'node:vm'
import open from 'open'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apply, Config, inject, name } from '../src/index.ts'
@@ -154,7 +156,7 @@ describe('experimental Inspector Host bootstrap route', () => {
expect(messages.filter(message => message.type === 'error')).toEqual([])
})
it('stops waiting when a bootstrap request is aborted before the Worker is ready', async () => {
it('stops waiting after garbage collection when a bootstrap request is aborted before the Worker is ready', async () => {
const gate = gateStartup('start')
const { fiber, bootstrap } = mount()
await vi.waitFor(() => { expect(startInspector).toHaveBeenCalledOnce() })
@@ -162,6 +164,7 @@ describe('experimental Inspector Host bootstrap route', () => {
const abort = new AbortController()
const abandoned = bootstrap(abort.signal)
expect(await settledWithin(abandoned, 50)).toBe(false)
await collectGarbage()
abort.abort()
expect((await abandoned).status).toBe(503)
expect((await bootstrap(AbortSignal.abort())).status).toBe(503)
@@ -172,6 +175,16 @@ describe('experimental Inspector Host bootstrap route', () => {
})
})
/** Collect unreachable Request controllers before the caller aborts. */
async function collectGarbage(): Promise<void> {
setFlagsFromString('--expose-gc')
const gc = runInNewContext('gc') as () => void
for (let pass = 0; pass < 5; pass += 1) {
gc()
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
}
/** Report whether a promise settles before a real-time deadline. */
async function settledWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
const timeout = new Promise<false>((resolve) => { setTimeout(() => { resolve(false) }, ms) })