Merge pull request #7425 from deepseek-harness/turtle/inspector-crash-bdc81a501c00

fix(inspector): reserve record overhead in the Cordis snapshot budget
This commit is contained in:
Turtle
2026-11-04 15:01:37 +08:00
committed by GitHub
11 changed files with 74 additions and 13 deletions
+1 -1
View File
@@ -1231,7 +1231,7 @@ export interface InspectorOptions {
readonly maxJournalBytes?: number
/** Maximum active and completed fetch requests retained by the Worker. */
readonly maxRetainedRequests?: number
/** Maximum encoded bytes accepted in one source transport frame. */
/** Maximum encoded bytes accepted in one source transport frame; values below 5121 leave no Cordis snapshot budget and fail. */
readonly maxSourceFrameBytes?: number
/** Maximum observation records accepted in one source batch. */
readonly maxSourceRecordsPerFrame?: number
+1 -1
View File
@@ -1233,7 +1233,7 @@ export interface InspectorOptions {
readonly maxJournalBytes?: number
/** Maximum active and completed fetch requests retained by the Worker. */
readonly maxRetainedRequests?: number
/** Maximum encoded bytes accepted in one source transport frame. */
/** Maximum encoded bytes accepted in one source transport frame; values below 5121 leave no Cordis snapshot budget and fail. */
readonly maxSourceFrameBytes?: number
/** Maximum observation records accepted in one source batch. */
readonly maxSourceRecordsPerFrame?: number
@@ -21,8 +21,8 @@
en: 7db0c435e07d5005
zh: 1573972326c33893
/deepseek-ai-dsh-experimental-inspector/configuration:
en: d094842893700446
zh: 7a7f880ba795948f
en: 8da502df555ab5b6
zh: 849c39827b9b8365
/deepseek-ai-dsh-experimental-inspector/observation-api:
en: d32f994e6c9d0c4a
zh: ac2f35f5799aeae0
+1 -1
View File
@@ -78,7 +78,7 @@ The Host plugin injects `webServer` and `connection` and accepts these fields:
| `maxBodyChunkBytes` | 48 KiB | Raw bytes carried by one body record before base64 encoding |
| `maxJournalBytes` | 256 MiB | Worker-retained request and response body bytes |
| `maxRetainedRequests` | `2000` | Active and completed requests retained by the Worker |
| `maxSourceFrameBytes` | 128 KiB | Encoded source-frame limit |
| `maxSourceFrameBytes` | 128 KiB | Encoded source-frame limit; values below 5121 bytes fail at load because they leave no room for the Cordis tree snapshot |
| `maxSourceRecordsPerFrame` | `128` | Records in one source batch |
| `maxQueuedRecords` | `2048` | Per-producer records waiting for transport |
| `maxQueuedBytes` | 16 MiB | Per-producer queued encoded bytes |
+1 -1
View File
@@ -78,7 +78,7 @@ Host 插件注入 `webServer` 和 `connection`,接受以下字段:
| `maxBodyChunkBytes` | 48 KiB | base64 编码前一条 body 记录携带的原始字节数 |
| `maxJournalBytes` | 256 MiB | Worker 保留的请求与响应 body 总字节数 |
| `maxRetainedRequests` | `2000` | Worker 保留的进行中与已完成请求总数 |
| `maxSourceFrameBytes` | 128 KiB | 编码后的 source frame 上限 |
| `maxSourceFrameBytes` | 128 KiB | 编码后的 source frame 上限;小于 5121 字节时加载失败,因为容纳不下 Cordis 树快照 |
| `maxSourceRecordsPerFrame` | `128` | 每个 source batch 的记录数 |
| `maxQueuedRecords` | `2048` | 每个 producer 等待发送的记录数 |
| `maxQueuedBytes` | 16 MiB | 每个 producer 等待发送的编码字节数 |
@@ -7,6 +7,7 @@ import { INSPECTOR_BOOTSTRAP_ROUTE } from '../shared/web.ts'
import { parseInspectorClientBootstrap } from '../shared/bridge/control-codec.ts'
import { createInspectorService, type InspectorService as SharedInspectorService } from '../shared/service.ts'
import { publishCordisTree } from './inspection/cordis.ts'
import { maxStatePayloadBytes } from '../shared/bridge/buffer.ts'
import { startInspectorClient } from './bridge/controller.ts'
import { disposeInspectorResources } from '../shared/dispose.ts'
@@ -130,7 +131,7 @@ class InspectorClientSession {
this.lifetime.signal.throwIfAborted()
disposers.push(publishCordisTree(this.ctx, source, {
maxNodes: bootstrap.maxCordisNodes,
maxBytes: bootstrap.maxFrameBytes - 4_096,
maxBytes: maxStatePayloadBytes(bootstrap.maxFrameBytes),
}))
disposers.push(this.ctx.provide('inspector', createInspectorService(source)))
const panel = this.ctx.inject(['slots', 'shortcuts', 'locale'], async (ctx) => {
@@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { MessageChannel, Worker, type MessagePort, type WorkerOptions } from 'node:worker_threads'
import type { InspectorClientBootstrap, InspectorWorkerBoot, InspectorWorkerConfig } from '../../shared/bridge/messages/control.ts'
import { maxStatePayloadBytes } from '../../shared/bridge/buffer.ts'
import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts'
import type { InspectorConnection } from '../../shared/bridge/publisher.ts'
import { installFetchObserver, NETWORK_TOPICS, type FetchObserver } from '../inspection/network.ts'
@@ -51,7 +52,7 @@ export interface InspectorOptions {
readonly maxJournalBytes?: number
/** Maximum active and completed fetch requests retained by the Worker. */
readonly maxRetainedRequests?: number
/** Maximum encoded bytes accepted in one source transport frame. */
/** Maximum encoded bytes accepted in one source transport frame; values below 5121 leave no Cordis snapshot budget and fail. */
readonly maxSourceFrameBytes?: number
/** Maximum observation records accepted in one source batch. */
readonly maxSourceRecordsPerFrame?: number
@@ -168,6 +169,9 @@ export function resolveInspectorOptions(options: InspectorOptions = {}): Inspect
if (largestEncodedChunk > spec.maxSourceFrameBytes) {
throw new Error('inspector: maxSourceFrameBytes cannot carry one base64 body chunk')
}
if (maxStatePayloadBytes(spec.maxSourceFrameBytes) <= 0) {
throw new Error('inspector: maxSourceFrameBytes cannot carry a Cordis tree snapshot')
}
if (spec.clientReconnectMaxMs < spec.clientReconnectBaseMs) {
throw new Error('inspector: clientReconnectMaxMs must be at least clientReconnectBaseMs')
}
@@ -11,6 +11,7 @@ import { resolveInspectorOptions, startInspector, type InspectorHandle, type Ins
import type { InspectorClientBootstrap } from '../shared/bridge/messages/control.ts'
import { createInspectorService } from '../shared/service.ts'
import { publishCordisTree } from './inspection/cordis.ts'
import { maxStatePayloadBytes } from '../shared/bridge/buffer.ts'
import { INSPECTOR_BOOTSTRAP_PATH, INSPECTOR_CLIENT_QUERY, readInspectorClientSelection } from '../shared/web.ts'
import { disposeInspectorResources } from '../shared/dispose.ts'
@@ -79,7 +80,7 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise<voi
const { default: serveStatic } = await import('serve-static')
disposers.push(publishCordisTree(ctx, handle.source, {
maxNodes: spec.maxCordisNodes,
maxBytes: spec.maxSourceFrameBytes - 4_096,
maxBytes: maxStatePayloadBytes(spec.maxSourceFrameBytes),
}))
disposers.push(ctx.provide('inspector', createInspectorService(handle.source)))
disposers.push(ctx.on('webserver/index-inject', (table: IndexInjection[]) => {
@@ -6,6 +6,21 @@ import type { InspectorRecordInput, SourceAppendFrame, SourceReplaceFrame } from
import { INSPECTOR_PROTOCOL_VERSION } from './version.ts'
const SOURCE_FRAME_OVERHEAD_BYTES = 4_096
const MAX_TOPIC_CODE_UNITS = 128
/**
* Upper bound on the JSON around one replacement state payload: 38 bytes of punctuation and keys,
* at most 24 for `monotonicMs`, and at most 6 per escaped topic code unit plus quotes (770).
*/
const STATE_RECORD_OVERHEAD_BYTES = 1_024
/**
* Compute the payload budget of a source's single retained state topic.
* @param maxFrameBytes - Source-frame byte limit.
* @returns Encoded payload bytes that {@link InspectorSourceBuffer.setState} accepts when no other state topic is retained.
*/
export function maxStatePayloadBytes(maxFrameBytes: number): number {
return maxFrameBytes - SOURCE_FRAME_OVERHEAD_BYTES - STATE_RECORD_OVERHEAD_BYTES
}
/** Limits and declared topics shared by both source transports. */
export interface InspectorSourceBufferOptions {
@@ -129,8 +144,8 @@ export class InspectorSourceBuffer {
}
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')
if (topic.length === 0 || topic.length > MAX_TOPIC_CODE_UNITS) {
throw new Error(`inspector: topic must contain 1 to ${String(MAX_TOPIC_CODE_UNITS)} characters`)
}
if (!this.options.topics.includes('*') && !this.options.topics.includes(topic)) {
throw new Error(`inspector: source does not declare topic ${JSON.stringify(topic)}`)
@@ -6,6 +6,7 @@ import WebSocket from 'ws'
import { ClientInspectorSource } from '../../src/client/bridge/transport.ts'
import { ClientSourceCatalog } from '../../src/client/cdp/sources.ts'
import { publishCordisTree } from '../../src/client/inspection/cordis.ts'
import { maxStatePayloadBytes } from '../../src/shared/bridge/buffer.ts'
import { inspectorId } from '../../src/shared/bridge/ids.ts'
import type { InspectorClientBootstrap } from '../../src/shared/bridge/messages/control.ts'
import type { InspectorJsonValue } from '../../src/shared/json.ts'
@@ -69,7 +70,7 @@ const sourceCatalog = input.sourceCatalog === undefined
const source = new ClientInspectorSource(input.bootstrap, input.label, sourceCatalog)
const disposeCordis = publishCordisTree(context, source, {
maxNodes: input.bootstrap.maxCordisNodes,
maxBytes: input.bootstrap.maxFrameBytes - 4_096,
maxBytes: maxStatePayloadBytes(input.bootstrap.maxFrameBytes),
})
const service = createInspectorService(source)
let addedFiber: Fiber | undefined
@@ -1,10 +1,15 @@
/** Worker-side source buffer behavior. */
import { MessageChannel } from 'node:worker_threads'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { resolveInspectorOptions } from '../src/host/bridge/controller.ts'
import { HostBridgePublisher } from '../src/host/bridge/publisher.ts'
import { inspectorId } from '../src/shared/bridge/ids.ts'
import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../src/shared/bridge/buffer.ts'
import { InspectorSourceBuffer, maxStatePayloadBytes, type InspectorSourceBufferOptions } from '../src/shared/bridge/buffer.ts'
import { CORDIS_TREE_TOPIC } from '../src/shared/bridge/messages/cordis.ts'
import { CordisTreeCollector } from '../src/shared/cordis/collector.ts'
import { jsonByteLength, type InspectorJsonValue } from '../src/shared/json.ts'
import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts'
const sourceId = inspectorId<'InspectorSourceId'>('source-buffer-test', 'sourceId')
@@ -75,6 +80,40 @@ describe('Inspector source buffer', () => {
])
})
it('accepts a single state payload at its budget with the largest record envelope', () => {
const maxFrameBytes = 8_192
const budget = maxStatePayloadBytes(maxFrameBytes)
const topic = '\u0001'.repeat(128)
const payload = 'x'.repeat(budget - 2)
expect(jsonByteLength(payload)).toBe(budget)
const records = buffer(4, { maxFrameBytes })
records.setState(topic, payload, -Number.MAX_VALUE)
const frame = JSON.stringify(records.replacement(sourceId, generation))
expect(new TextEncoder().encode(frame).byteLength).toBeLessThanOrEqual(maxFrameBytes)
})
it('rejects a source-frame limit that leaves no state payload budget at option resolution', () => {
const smallest = 5_121
expect(maxStatePayloadBytes(smallest)).toBe(1)
expect(resolveInspectorOptions({ maxSourceFrameBytes: smallest, maxBodyChunkBytes: 1 }).maxSourceFrameBytes).toBe(smallest)
expect(() => resolveInspectorOptions({ maxSourceFrameBytes: smallest - 1, maxBodyChunkBytes: 1 }))
.toThrow('maxSourceFrameBytes cannot carry a Cordis tree snapshot')
})
it('retains every pruned Cordis snapshot whose collector uses the state budget', async (test) => {
const root = new Context()
test.onTestFinished(async () => { await root.fiber.dispose() })
for (let index = 0; index < 200; index++) await root.plugin({ name: `plugin-${String(index)}`, apply() {} })
for (let maxFrameBytes = 8_192; maxFrameBytes < 8_448; maxFrameBytes++) {
const collector = new CordisTreeCollector(root, { maxNodes: 1_000, maxBytes: maxStatePayloadBytes(maxFrameBytes) })
const snapshot = collector.snapshot()
collector.close()
expect(snapshot.truncated).toBe(true)
const payload = JSON.parse(JSON.stringify(snapshot)) as InspectorJsonValue
buffer(4, { maxFrameBytes }).setState(CORDIS_TREE_TOPIC, payload, performance.now())
}
})
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)