fix: address codex review round 3
Resource-lifecycle and error-classification fixes in the local fetch provider: - Classify a timeout that fires DURING the body read as WEB_FETCH_TIMEOUT, not WEB_ABORTED: thread the controller signal into the body-read translate path and recover the timeout WebError from signal.reason, honoring the public WEB_FETCH_TIMEOUT contract for a stalled response body. - Cancel the response body before every blocked-redirect throw path (cross-origin, invalid target, missing Location), so a rejected redirect with a large or streaming body does not leak the socket after the tool returns WEB_REDIRECT_BLOCKED. - Cancel the body when charset validation fails, matching the unsupported-content-type and over-size paths (the round-1 charset check threw before readCapped owned the stream).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
||||
import { AddressInfo } from 'node:net'
|
||||
import { Context } from 'cordis'
|
||||
@@ -32,6 +32,7 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals()
|
||||
await new Promise<void>(resolve => server.close(() => { resolve() }))
|
||||
})
|
||||
|
||||
@@ -250,6 +251,20 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' }))
|
||||
})
|
||||
|
||||
it('classifies a timeout DURING the body read as WEB_FETCH_TIMEOUT, not WEB_ABORTED', async () => {
|
||||
// Promise body that resolves headers (so fetch() returns) but a content-length
|
||||
// that outlasts the bytes sent, so readCapped()'s reader awaits more and the
|
||||
// timeout fires mid-read — the reader then surfaces a generic AbortError that
|
||||
// must still be recovered as the timeout reason via signal.reason.
|
||||
handler = (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '100' })
|
||||
res.write('partial')
|
||||
// never send the remaining bytes nor end the response
|
||||
}
|
||||
await expect(provider({ timeoutMs: 80 }).fetch({ url: base }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' }))
|
||||
})
|
||||
|
||||
it('maps a connection failure to WEB_PROVIDER_ERROR', async () => {
|
||||
// Port 1 on loopback is not listening: a real connection failure (not abort).
|
||||
await expect(provider().fetch({ url: 'http://127.0.0.1:1/' }))
|
||||
@@ -263,6 +278,46 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalFetchProvider body cancellation on error paths', () => {
|
||||
/** A fake Response whose body.cancel is observable. */
|
||||
type FakeInit = { status: number; headers: Record<string, string>; location?: string }
|
||||
function fakeResponse(init: FakeInit): { response: Response; cancelled: () => boolean } {
|
||||
let cancelled = false
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.location !== undefined) headers.set('location', init.location)
|
||||
const response = {
|
||||
status: init.status,
|
||||
headers,
|
||||
body: { cancel: () => { cancelled = true; return Promise.resolve() } },
|
||||
} as unknown as Response
|
||||
return { response, cancelled: () => cancelled }
|
||||
}
|
||||
|
||||
it('cancels the body when a cross-origin redirect is blocked', async () => {
|
||||
const { response, cancelled } = fakeResponse({ status: 302, headers: {}, location: 'https://elsewhere.test/' })
|
||||
vi.stubGlobal('fetch', vi.fn(async () => response))
|
||||
await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
|
||||
expect(cancelled()).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels the body when an unsupported charset is rejected', async () => {
|
||||
const { response, cancelled } = fakeResponse({ status: 200, headers: { 'content-type': 'text/plain; charset=not-a-charset' } })
|
||||
vi.stubGlobal('fetch', vi.fn(async () => response))
|
||||
await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
|
||||
expect(cancelled()).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels the body when a redirect has no Location header', async () => {
|
||||
const { response, cancelled } = fakeResponse({ status: 302, headers: {} })
|
||||
vi.stubGlobal('fetch', vi.fn(async () => response))
|
||||
await expect(provider().fetch({ url: 'http://127.0.0.1:9/' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
|
||||
expect(cancelled()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('web-fetch-local plugin registration', () => {
|
||||
it('registers the provider into ctx.web (HMR-safe)', async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
Reference in New Issue
Block a user