mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(inspector): preserve responses after caller abort
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md
|
||||
README.md: 9e955ae2ff14c7d6331c4081e373183cc768c212
|
||||
README.zh.md: dc2e8228bedad4262793109204fd4743b55be744
|
||||
README.md: ff1aa86ee5920b1641d1a0b13e04b95b5691d036
|
||||
README.zh.md: 213086441ac7280b22c4c0ffba7b48b863f69fb9
|
||||
|
||||
@@ -113,7 +113,7 @@ Fetch capture is on by default and records the complete URL, all request and res
|
||||
|
||||
The configured body limits bound retention rather than select fields: capture keeps the prefix and marks the result truncated. `Network.getRequestPostData` and `Network.getResponseBody` read the Worker's retained bytes. `Network.streamResourceContent` returns the buffered prefix and adds later response bytes to `Network.dataReceived` for that DevTools connection, which drives live Response and EventStream views. Direct Undici Client/Dispatcher calls and fetch references retained before plugin activation are outside this observer.
|
||||
|
||||
After response headers arrive, bytes captured before a caller-side abort remain available through `Network.getResponseBody`, while the request ends with `Network.loadingFailed { canceled: true }`. A fetch rejection before response headers follows the same canceled failure path.
|
||||
After response headers arrive, a caller-side abort can stop the observer's clone; captured bytes remain available through `Network.getResponseBody`, capture metadata records the error and truncation, and CDP emits `Network.loadingFinished` because fetch returned a Response. A fetch rejection before response headers emits `Network.loadingFailed`, with `canceled: true` for an abort.
|
||||
|
||||
<a id="security"></a>
|
||||
## Security
|
||||
|
||||
@@ -113,7 +113,7 @@ fetch 采集默认开启,记录完整 URL、全部请求与响应 headers、
|
||||
|
||||
配置的 body 上限限制保留量,而不选择字段:采集保留前缀并标记 truncated。`Network.getRequestPostData` 与 `Network.getResponseBody` 读取 Worker 保留的字节。`Network.streamResourceContent` 返回已缓冲的前缀,并仅为发起调用的 DevTools 连接把后续 response 字节附加到 `Network.dataReceived`,以驱动实时 Response 与 EventStream 视图。直接调用 Undici Client/Dispatcher,以及插件激活前保存的 fetch 引用,不在观察范围内。
|
||||
|
||||
response headers 到达后,如果调用方 abort,已采集的字节仍可通过 `Network.getResponseBody` 读取,同时请求以 `Network.loadingFailed { canceled: true }` 结束。response headers 到达前发生的 fetch rejection 也走相同的取消失败路径。
|
||||
response headers 到达后,调用方 abort 可能会终止 observer clone;已采集的字节仍可通过 `Network.getResponseBody` 读取,采集 metadata 记录错误与截断,并且 CDP 因 fetch 已返回 Response 而发送 `Network.loadingFinished`。response headers 到达前发生的 fetch rejection 会发送 `Network.loadingFailed`,其中 abort 对应 `canceled: true`。
|
||||
|
||||
<a id="security"></a>
|
||||
## 安全
|
||||
|
||||
@@ -120,14 +120,6 @@ export function installFetchObserver(
|
||||
controller.signal,
|
||||
(data) => { publisher.publish('fetch/response-body-chunk', { requestId, data }) },
|
||||
).then((outcome) => {
|
||||
if (request.signal.aborted && outcome.captureError !== undefined) {
|
||||
publisher.publish('fetch/error', {
|
||||
requestId,
|
||||
message: outcome.captureError,
|
||||
canceled: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
publisher.publish('fetch/end', {
|
||||
requestId,
|
||||
capturedBytes: outcome.capturedBytes,
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('full fetch observer', () => {
|
||||
expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 4, responseBodyTruncated: true })
|
||||
})
|
||||
|
||||
it('retains captured bytes and reports cancellation after response headers', async () => {
|
||||
it('finishes response capture when the caller aborts after response headers', async () => {
|
||||
const records: InspectorRecordInput[] = []
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
value: vi.fn(async (request: Request) => new Response(new ReadableStream<Uint8Array>({
|
||||
@@ -104,14 +104,15 @@ describe('full fetch observer', () => {
|
||||
const response = await fetch('https://example.test/cancel-body', { signal: abort.signal })
|
||||
abort.abort()
|
||||
await expect(response.text()).rejects.toThrow()
|
||||
await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/error')).toBe(true) })
|
||||
await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) })
|
||||
|
||||
expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('first')
|
||||
expect(payload(records, 'fetch/error')).toMatchObject({
|
||||
message: 'AbortError: aborted',
|
||||
canceled: true,
|
||||
expect(payload(records, 'fetch/end')).toMatchObject({
|
||||
capturedBytes: 5,
|
||||
responseBodyTruncated: true,
|
||||
responseCaptureError: 'AbortError: aborted',
|
||||
})
|
||||
expect(records.some(record => record.topic === 'fetch/end')).toBe(false)
|
||||
expect(records.some(record => record.topic === 'fetch/error')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports a fetch rejected before response headers as a canceled request', async () => {
|
||||
|
||||
@@ -574,6 +574,51 @@ describe('experimental Inspector real Worker', () => {
|
||||
continueResponse.resolve(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps captured EventSource data readable when the caller aborts after response headers', async () => {
|
||||
const eventStream = 'data: first\n\ndata: [DONE]\n\n'
|
||||
server = createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' })
|
||||
response.write(eventStream)
|
||||
})
|
||||
await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) })
|
||||
const port = (server.address() as import('node:net').AddressInfo).port
|
||||
inspector = await startInspector({ port: 0 })
|
||||
cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl)
|
||||
await cdp.call('Network.enable')
|
||||
const abort = new AbortController()
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${String(port)}/aborted-events`, { signal: abort.signal })
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('SSE response did not expose a body')
|
||||
expect(Buffer.from((await reader.read()).value ?? []).toString('utf8')).toBe(eventStream)
|
||||
|
||||
let requestId: string | undefined
|
||||
await vi.waitFor(() => {
|
||||
const received = cdp!.events.find(event =>
|
||||
event.method === 'Network.responseReceived'
|
||||
&& String((event.params?.response as Record<string, unknown> | undefined)?.url).includes('/aborted-events'))
|
||||
requestId = received?.params?.requestId as string | undefined
|
||||
expect(requestId).toBeTypeOf('string')
|
||||
expect(cdp!.events.filter(event =>
|
||||
event.method === 'Network.eventSourceMessageReceived'
|
||||
&& event.params?.requestId === requestId).map(event => event.params?.data)).toEqual(['first', '[DONE]'])
|
||||
})
|
||||
abort.abort()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(cdp!.events.some(event =>
|
||||
event.method === 'Network.loadingFinished'
|
||||
&& event.params?.requestId === requestId)).toBe(true)
|
||||
})
|
||||
expect(cdp.events.some(event =>
|
||||
event.method === 'Network.loadingFailed'
|
||||
&& event.params?.requestId === requestId)).toBe(false)
|
||||
const body = await cdp.call('Network.getResponseBody', { requestId })
|
||||
expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe(eventStream)
|
||||
expect(body.result?.dshInspectorTruncated).toBe(true)
|
||||
expect(String(body.result?.dshInspectorCaptureError)).toContain('AbortError')
|
||||
})
|
||||
})
|
||||
|
||||
async function clientContext(client: TestCdpClient): Promise<number> {
|
||||
|
||||
@@ -170,29 +170,34 @@ describe('Inspector Network domain', () => {
|
||||
expect(replay).toHaveBeenNthCalledWith(3, 'Network.loadingFinished', expect.any(Object))
|
||||
})
|
||||
|
||||
it('retains partial response bytes while reporting a post-header cancellation as failed', () => {
|
||||
it('finishes a response whose observer clone ended with a capture error', () => {
|
||||
const sendEvent = vi.fn()
|
||||
const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 })
|
||||
const network = new NetworkDomain(store)
|
||||
network.enable({ sendEvent })
|
||||
const records = requestRecords('canceled', 'partial')
|
||||
const records = requestRecords('capture-error', 'partial')
|
||||
store.append(source, [
|
||||
...records.slice(0, 3),
|
||||
{
|
||||
sequence: 4,
|
||||
monotonicMs: 4,
|
||||
topic: 'fetch/error',
|
||||
payload: { requestId: 'canceled', message: 'AbortError: aborted', canceled: true },
|
||||
topic: 'fetch/end',
|
||||
payload: {
|
||||
requestId: 'capture-error',
|
||||
capturedBytes: 7,
|
||||
responseBodyTruncated: true,
|
||||
responseCaptureError: 'AbortError: aborted',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(sendEvent).toHaveBeenCalledWith('Network.loadingFailed', expect.objectContaining({
|
||||
requestId: requestId('canceled'),
|
||||
type: 'Fetch',
|
||||
errorText: 'AbortError: aborted',
|
||||
canceled: true,
|
||||
expect(sendEvent).toHaveBeenCalledWith('Network.loadingFinished', expect.objectContaining({
|
||||
requestId: requestId('capture-error'),
|
||||
encodedDataLength: 7,
|
||||
dshInspectorTruncated: true,
|
||||
}))
|
||||
expect(network.handle('Network.getResponseBody', { requestId: requestId('canceled') }, { sendEvent: vi.fn() }))
|
||||
expect(sendEvent.mock.calls.some(call => call[0] === 'Network.loadingFailed')).toBe(false)
|
||||
expect(network.handle('Network.getResponseBody', { requestId: requestId('capture-error') }, { sendEvent: vi.fn() }))
|
||||
.toMatchObject({
|
||||
body: Buffer.from('partial').toString('base64'),
|
||||
dshInspectorTruncated: true,
|
||||
|
||||
Reference in New Issue
Block a user