From bf4edb5bdb7697372109cf262c377c6b95c98512 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 14 Oct 2026 19:23:12 -0700 Subject: [PATCH] fix(director): discard an asset cancelled during reader cleanup The finally block awaits reader.cancel(), so a cancel arriving there still let the bytes return. Rechecks the signal after cleanup, with a test that keeps the caller's abort reason, and wraps the current-state note. --- docs/CURRENT-STATE.md | 4 +- .../packs/asset-stream-cancellation.test.mjs | 37 +++++++++++++++++++ src/director/packs/source.js | 2 + 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8a70f30e9..de4ca26ca 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -396,7 +396,9 @@ and geographic placement remain distinct; Stop/replacement releases resources. Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene content, assets and credit remain unchanged. -The asset directory source rejects with the caller's abort reason if cancellation arrives during any read, including the final one. +The asset directory source rejects with the caller's abort reason if +cancellation arrives during any read, including the final one, or while the +reader is being released. Director version-4 documents add named geographic anchors and optional explicit camera moves with easing, duration and holds. Playback and seek share one diff --git a/src/director/packs/asset-stream-cancellation.test.mjs b/src/director/packs/asset-stream-cancellation.test.mjs index cd4f2490d..eb67244ff 100644 --- a/src/director/packs/asset-stream-cancellation.test.mjs +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -40,3 +40,40 @@ test('cancellation during the terminating read cannot return a scene asset', asy assert.equal(released, 1); } }); + +test('cancellation during reader cleanup cannot return a scene asset', async () => { + const controller = new AbortController(); + const reason = new DOMException('scene changed', 'AbortError'); + let released = 0; + const source = createAssetDirectorySource({ + baseUrl: 'https://fixture.example/assets/', + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + body: { + getReader() { + let reads = 0; + return { + async read() { + reads++; + return reads === 1 + ? { done: false, value: new Uint8Array([1]) } + : { done: true }; + }, + async cancel() { + controller.abort(reason); + }, + releaseLock() { + released++; + }, + }; + }, + }, + }), + }); + await assert.rejects( + source({ path: 'scene.bin', signal: controller.signal }), + (error) => error === reason, + ); + assert.equal(released, 1); +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 052581a70..0ad920670 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -51,6 +51,8 @@ export function createAssetDirectorySource({ await reader.cancel().catch(() => {}); reader.releaseLock(); } + // Cleanup awaits too; a cancel during it must still discard the asset. + signal?.throwIfAborted(); const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) {