diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4a8b5a5605..541f35bf98 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bb0d0cadc3..ddb66d4024 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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 diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 32a171be97..db0b17c1e6 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -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 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index b4b78ff4da..2167e6d003 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -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 | diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index a1ededb4ad..ed2f13ae08 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -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 等待发送的编码字节数 | diff --git a/packages/experimental/inspector/src/client/plugin.ts b/packages/experimental/inspector/src/client/plugin.ts index 38e2579803..e6ab26babf 100644 --- a/packages/experimental/inspector/src/client/plugin.ts +++ b/packages/experimental/inspector/src/client/plugin.ts @@ -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) => { diff --git a/packages/experimental/inspector/src/host/bridge/controller.ts b/packages/experimental/inspector/src/host/bridge/controller.ts index aa25dac90c..486ce2a1d7 100644 --- a/packages/experimental/inspector/src/host/bridge/controller.ts +++ b/packages/experimental/inspector/src/host/bridge/controller.ts @@ -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') } diff --git a/packages/experimental/inspector/src/host/plugin.ts b/packages/experimental/inspector/src/host/plugin.ts index cf61103e6b..b0eaf28c7f 100644 --- a/packages/experimental/inspector/src/host/plugin.ts +++ b/packages/experimental/inspector/src/host/plugin.ts @@ -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 { diff --git a/packages/experimental/inspector/src/shared/bridge/buffer.ts b/packages/experimental/inspector/src/shared/bridge/buffer.ts index 032e01afc2..1638c600ee 100644 --- a/packages/experimental/inspector/src/shared/bridge/buffer.ts +++ b/packages/experimental/inspector/src/shared/bridge/buffer.ts @@ -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)}`) diff --git a/packages/experimental/inspector/tests/fixtures/client-source.client.ts b/packages/experimental/inspector/tests/fixtures/client-source.client.ts index 156a3c4ac7..47689130d7 100644 --- a/packages/experimental/inspector/tests/fixtures/client-source.client.ts +++ b/packages/experimental/inspector/tests/fixtures/client-source.client.ts @@ -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 diff --git a/packages/experimental/inspector/tests/source-buffer.host.spec.ts b/packages/experimental/inspector/tests/source-buffer.host.spec.ts index 0e72d6e6db..769c763b12 100644 --- a/packages/experimental/inspector/tests/source-buffer.host.spec.ts +++ b/packages/experimental/inspector/tests/source-buffer.host.spec.ts @@ -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)