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/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..14f0a3ee6b 100644 --- a/packages/experimental/inspector/src/shared/bridge/buffer.ts +++ b/packages/experimental/inspector/src/shared/bridge/buffer.ts @@ -6,6 +6,17 @@ import type { InspectorRecordInput, SourceAppendFrame, SourceReplaceFrame } from import { INSPECTOR_PROTOCOL_VERSION } from './version.ts' const SOURCE_FRAME_OVERHEAD_BYTES = 4_096 +/** Upper bound on the JSON around one replacement state payload: array, `monotonicMs`, and a 128-code-unit escaped topic. */ +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 { 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..39310a4504 100644 --- a/packages/experimental/inspector/tests/source-buffer.host.spec.ts +++ b/packages/experimental/inspector/tests/source-buffer.host.spec.ts @@ -1,10 +1,14 @@ /** 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 { 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 +79,32 @@ 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('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)