fix(inspector): preserve responses after caller abort

This commit is contained in:
imccyu
2026-08-27 16:16:28 +08:00
parent 777489dfc5
commit a031b95fdb
7 changed files with 71 additions and 28 deletions
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,