diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 822f4178c0..09fb248050 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -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 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index 9e955ae2ff..ff1aa86ee5 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -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. ## Security diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index dc2e8228be..213086441a 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -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`。 ## 安全 diff --git a/packages/experimental/inspector/src/host/inspection/network.ts b/packages/experimental/inspector/src/host/inspection/network.ts index 8ff9af0477..aa5225a8c5 100644 --- a/packages/experimental/inspector/src/host/inspection/network.ts +++ b/packages/experimental/inspector/src/host/inspection/network.ts @@ -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, diff --git a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts index 69652b869d..a79551a75d 100644 --- a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts +++ b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts @@ -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({ @@ -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 () => { diff --git a/packages/experimental/inspector/tests/integration.host.spec.ts b/packages/experimental/inspector/tests/integration.host.spec.ts index 5b214c7fd5..b50ea4aced 100644 --- a/packages/experimental/inspector/tests/integration.host.spec.ts +++ b/packages/experimental/inspector/tests/integration.host.spec.ts @@ -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((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 | 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 { diff --git a/packages/experimental/inspector/tests/network.host.spec.ts b/packages/experimental/inspector/tests/network.host.spec.ts index 69b34b440d..6ad8c3e846 100644 --- a/packages/experimental/inspector/tests/network.host.spec.ts +++ b/packages/experimental/inspector/tests/network.host.spec.ts @@ -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,