fix(inspector): reserve record overhead in the Cordis snapshot budget

The collector pruned snapshots to maxFrameBytes - 4096, but setState also counts the retained record's JSON around the payload, so a pruned snapshot near the budget threw and crashed the process.

Refs #7424
This commit is contained in:
Turtle
2026-11-04 14:43:50 +08:00
parent 7053f882a8
commit 57aae95097
5 changed files with 48 additions and 4 deletions
@@ -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) => {
@@ -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,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 {
@@ -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,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)