diff --git a/packages/experimental/inspector/src/host/inspection/network.ts b/packages/experimental/inspector/src/host/inspection/network.ts index 9d316da444..7446934e92 100644 --- a/packages/experimental/inspector/src/host/inspection/network.ts +++ b/packages/experimental/inspector/src/host/inspection/network.ts @@ -15,8 +15,11 @@ export interface FetchCaptureOptions { } /** - * Keeps each wrapper-created Request reachable while either of its response body streams is reachable. - * Node's fetch forwards the caller's abort signal only while that Request is reachable. + * Keeps each wrapper-created Request reachable while the caller's response body stream is reachable. + * Node's fetch forwards the caller's abort signal only while that Request is reachable. The capture + * clone shares one source with the caller's body stream and keeps that stream reachable while capture + * reads. Entries are set after `response.clone()`, which replaces `response.body`. The map is + * module-level so that responses obtained before `stop()` keep their Request reachable afterwards. */ const requestsByBody = new WeakMap, Request>() @@ -119,7 +122,6 @@ export function installFetchObserver( try { const responseClone = response.clone() - if (responseClone.body !== null) requestsByBody.set(responseClone.body, request) track(captureBody( responseClone.body, options.maxResponseBodyBytes, diff --git a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts index 94c1ce7a5a..3896842e56 100644 --- a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts +++ b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts @@ -119,7 +119,13 @@ describe('full fetch observer', () => { expect(records.some(record => record.topic === 'fetch/error')).toBe(false) }) - it('forwards a caller abort to native fetch after the wrapper Request is no longer referenced', async () => { + it.each([ + { label: 'while capture is still reading the clone', maxResponseBodyBytes: 1_024, captureTruncated: false }, + { label: 'after capture has stopped at its byte limit', maxResponseBodyBytes: 4, captureTruncated: true }, + ])('forwards a caller abort to native fetch after the wrapper Request is unreferenced $label', async ({ + maxResponseBodyBytes, + captureTruncated, + }) => { const server = createServer((_request, response) => { response.writeHead(200, { 'content-type': 'text/event-stream' }) response.write('data: first\n\n') @@ -131,24 +137,25 @@ describe('full fetch observer', () => { publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { records.push({ topic, payload, monotonicMs }) }, - }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 1_024 }) + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes, maxChunkBytes: 1_024 }) const abort = new AbortController() const { port } = server.address() as AddressInfo const response = await fetch(`http://127.0.0.1:${String(port)}/open-stream`, { signal: abort.signal }) const reader = response.body!.getReader() await reader.read() - // Node's fetch follows the caller signal only while the wrapper-created Request is reachable. - setFlagsFromString('--expose-gc') - const gc = runInNewContext('gc') as () => void - for (let pass = 0; pass < 5; pass += 1) { - gc() - await new Promise((resolve) => { setImmediate(resolve) }) + if (captureTruncated) { + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) }) } + // Node's fetch follows the caller signal only while the wrapper-created Request is reachable. + await collectGarbage() abort.abort() await expect(reader.read()).rejects.toMatchObject({ name: 'AbortError' }) await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) }) - expect(String(payload(records, 'fetch/end').responseCaptureError)).toContain('AbortError') + const end = payload(records, 'fetch/end') + expect(end.responseBodyTruncated).toBe(true) + if (captureTruncated) expect(end.responseCaptureError).toBeUndefined() + else expect(String(end.responseCaptureError)).toContain('AbortError') } finally { server.closeAllConnections() await new Promise((resolve) => { server.close(() => { resolve() }) }) @@ -434,6 +441,19 @@ describe('full fetch observer', () => { }) }) +/** + * Run several full collections. `--expose-gc` stays enabled for the rest of this forked Vitest worker + * process; only new vm contexts can obtain `gc`, so other specs in the worker may observe it there. + */ +async function collectGarbage(): Promise { + setFlagsFromString('--expose-gc') + const gc = runInNewContext('gc') as () => void + for (let pass = 0; pass < 5; pass += 1) { + gc() + await new Promise((resolve) => { setImmediate(resolve) }) + } +} + function payload(records: readonly InspectorRecordInput[], topic: string): Record { const record = records.find(candidate => candidate.topic === topic) expect(record).toBeDefined()