diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..a7f8ca53d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Director assets cancelled during the final stream read are discarded with reader cleanup. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..20e346aad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(director): reject cancellation during the final asset stream read + +Re-check caller cancellation immediately after every awaited read, including done reads, while retaining reader cancellation and lock cleanup. 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..cd4f2490d --- /dev/null +++ b/src/director/packs/asset-stream-cancellation.test.mjs @@ -0,0 +1,42 @@ +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); + } +}); diff --git a/src/director/packs/source.js b/src/director/packs/source.js index 4828827af..052581a70 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');