From a5417608b2401891aeac3791fa3542a7851abfdd Mon Sep 17 00:00:00 2001 From: Alejandro-94 Date: Wed, 16 Sep 2026 20:37:49 -0400 Subject: [PATCH 1/2] fix: keep fetch timeout active until response body completes --- src/directory/fetch.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/directory/fetch.ts b/src/directory/fetch.ts index 7aed460..7526ba0 100644 --- a/src/directory/fetch.ts +++ b/src/directory/fetch.ts @@ -101,7 +101,9 @@ async function fetchRaw(url: string, accept: string, options: FetchOptions): Pro if (Number.isFinite(declared) && declared > cap) { throw new FetchProblem(`${target} declared ${declared} bytes`); } - return readCapped(response, cap); + // Keep the timeout and caller's abort listener until the body is read. + // Returning the promise directly runs finally as soon as headers arrive. + return await readCapped(response, cap); } throw new FetchProblem(`${url} redirected too many times`); } catch (error) { From 36554d1d68b51304b0ed26d8727d9aae0a041e9c Mon Sep 17 00:00:00 2001 From: Alejandro-94 Date: Wed, 16 Sep 2026 20:38:57 -0400 Subject: [PATCH 2/2] test: cover timeout and cancellation while reading response bodies --- test/fetch-body-timeout.test.ts | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 test/fetch-body-timeout.test.ts diff --git a/test/fetch-body-timeout.test.ts b/test/fetch-body-timeout.test.ts new file mode 100644 index 0000000..4cebe5e --- /dev/null +++ b/test/fetch-body-timeout.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test, type TestContext } from 'node:test'; +import { fetchText, FetchProblem } from '../src/directory/fetch.ts'; + +// Headers arrive immediately, but the body remains open. No external requests. +function delayedBody(t: TestContext, delayMs: number) { + t.mock.method(globalThis, 'fetch', async (_url: unknown, init: RequestInit) => { + const signal = init.signal!; + return new Response(new ReadableStream({ + start(controller) { + const abort = () => { + clearTimeout(timer); + controller.error(new DOMException('Aborted', 'AbortError')); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', abort); + controller.enqueue(new TextEncoder().encode('complete')); + controller.close(); + }, delayMs); + signal.addEventListener('abort', abort, { once: true }); + t.after(() => clearTimeout(timer)); + }, + })); + }); +} + +test('the timeout covers reading the body after headers arrive', async (t) => { + delayedBody(t, 200); + await assert.rejects(fetchText('https://example.com', { timeoutMs: 20 }), + (error: unknown) => error instanceof FetchProblem && /timed out/.test(error.message)); +}); + +test('caller cancellation still interrupts an in-progress body', async (t) => { + delayedBody(t, 200); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 20); + t.after(() => clearTimeout(timer)); + await assert.rejects(fetchText('https://example.com', { + timeoutMs: 1000, signal: controller.signal, + }), FetchProblem); +}); + +test('a body completed before the deadline is returned', async (t) => { + delayedBody(t, 5); + assert.equal(await fetchText('https://example.com', { timeoutMs: 1000 }), 'complete'); +});