diff --git a/src/director/packs/packs.test.mjs b/src/director/packs/packs.test.mjs index 036f31c02..f31d65090 100644 --- a/src/director/packs/packs.test.mjs +++ b/src/director/packs/packs.test.mjs @@ -338,3 +338,28 @@ test('byte and integrity checks run before rendering; adapter names never resolv assert.equal(mounted, 1); session.destroy(); }); + +test('failed responses release their body and an already-cancelled source sends no request', async () => { + let cancelled = 0, + requests = 0; + const source = createAssetDirectorySource({ + baseUrl: 'https://example.org/packs/', + fetchImpl: async () => { + requests++; + return new Response( + new ReadableStream({ + cancel() { + cancelled++; + }, + }), + { status: 403 }, + ); + }, + }); + await assert.rejects(source({ path: 'a.json' }), /unavailable/); + assert.equal(cancelled, 1); + const controller = new AbortController(); + controller.abort(); + await assert.rejects(source({ path: 'a.json', signal: controller.signal })); + assert.equal(requests, 1); +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 50f4d2e53..4828827af 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -19,6 +19,7 @@ export function createAssetDirectorySource({ ); return async ({ path, signal, maxBytes = PACK_LIMITS.bytes }) => { validateAssetPath(path); + signal?.throwIfAborted(); const response = await fetchImpl(new URL(path, base).href, { signal, credentials: 'omit', @@ -26,7 +27,10 @@ export function createAssetDirectorySource({ referrerPolicy: 'no-referrer', cache: 'no-store', }); - if (!response.ok) throw new Error('Asset unavailable'); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error('Asset unavailable'); + } const reader = response.body?.getReader(); if (!reader) throw new Error('Asset stream unavailable'); const chunks = [];