diff --git a/CHANGELOG.md b/CHANGELOG.md index b72aeefd5..335a5b9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Director assets cancelled during the final stream read are discarded with reader cleanup. + - Analyst queries preserve useful rows when an upstream timestamp is outside the supported date range. - Fire detections with missing or impossible positions are skipped while genuine zero coordinates remain valid. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 251b71a7e..323bb104e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -398,6 +398,10 @@ 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, 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 coordinate sampler; navigation/manual input revokes authored motion and pending diff --git a/src/director/packs/asset-stream-cancellation.test.mjs b/src/director/packs/asset-stream-cancellation.test.mjs new file mode 100644 index 000000000..eb67244ff --- /dev/null +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAssetDirectorySource } from './source.js'; +test('cancellation during the terminating read cannot return a scene asset', async () => { + for (const cancel of [true, false]) { + const controller = new AbortController(); + let reads = 0, + cancelled = 0, + released = 0; + const source = createAssetDirectorySource({ + baseUrl: 'https://fixture.example/assets/', + fetchImpl: async () => ({ + ok: true, + headers: new Headers({ 'content-type': 'application/octet-stream' }), + body: { + getReader() { + return { + async read() { + reads++; + if (reads === 1) + return { done: false, value: new Uint8Array([1, 2]) }; + if (cancel) controller.abort(); + return { done: true }; + }, + async cancel() { + cancelled++; + }, + releaseLock() { + released++; + }, + }; + }, + }, + }), + }); + const pending = source({ path: 'scene.bin', signal: controller.signal }); + if (cancel) await assert.rejects(pending, { name: 'AbortError' }); + else assert.deepEqual([...(await pending).bytes], [1, 2]); + assert.equal(cancelled, 1); + 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 4828827af..0ad920670 100644 --- a/src/director/packs/source.js +++ b/src/director/packs/source.js @@ -41,6 +41,7 @@ export function createAssetDirectorySource({ for (;;) { signal?.throwIfAborted(); const { done, value } = await reader.read(); + signal?.throwIfAborted(); if (done) break; length += value.byteLength; if (length > maxBytes) throw new Error('Asset exceeds byte limit'); @@ -50,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) {