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,