mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user