/** * 资源显示租约缓存测试。按 owner 和资源键共享进行中的读取/URL,最后租约释放即清理,不保留空闲 URL。 * 每个等待者可独立取消;旧读取迟到结果应清理自身,不能替换同键的新代次或破坏其他 owner。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createDisplayCache } from '../../src/lib/canvas-runtime/display-cache.ts'; // 手动 gate 可返回成功显示源或抛错,让取消和迟到清理时序可控。 const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; // 推进事件循环等待 load/清理执行,不做真实磁盘或浏览器图片加载。 const tick = () => new Promise(resolve => setImmediate(resolve)); // 两个租约共享一次 load 和同一 URL,release 幂等;最后释放清理后再 acquire 必须重新 load,关闭清理新源。 test('concurrent display leases share one source until the last idempotent release; there is no idle URL retention', async () => { const cache = createDisplayCache(), owner = {}; let loads = 0, disposed = 0; // 可观测显示源工厂:每次真实读取产生新 URL,源 release 计数代表底层资源释放。 const load = async () => ({ url: `blob:${++loads}`, release: () => disposed++ }); const a = cache.acquire(owner, 'asset', load), b = cache.acquire(owner, 'asset', load); assert.deepEqual(await Promise.all([a.ready, b.ready]), ['blob:1', 'blob:1']); assert.equal(loads, 1); a.release(); a.release(); assert.equal(disposed, 0); b.release(); assert.equal(disposed, 1); const c = cache.acquire(owner, 'asset', load); assert.equal(await c.ready, 'blob:2'); c.release(); cache.close(); assert.equal(disposed, 2); }); // 取消一个等待租约只拒绝它自己的 ready,共享 AbortSignal 仍有效;最后一个 reader 释放才 abort 并清理。 test('cancelling one waiting lease does not abort a shared load or another reader', async () => { const cache = createDisplayCache(), owner = {}, gate = deferred(); let signal, disposed = 0; // 捕获共享读取的 AbortSignal 并挂起,区分释放单个租约与终止整个底层读取。 const load = async value => { signal = value; return gate.promise; }; const a = cache.acquire(owner, 'asset', load), b = cache.acquire(owner, 'asset', load); await tick(); const cancelled = assert.rejects(a.ready, { code: 'display_released' }); a.release(); await cancelled; assert.equal(signal.aborted, false); gate.resolve({ url: 'blob:shared', release: () => disposed++ }); assert.equal(await b.ready, 'blob:shared'); b.release(); assert.equal(signal.aborted, true); assert.equal(disposed, 1); }); // 最后旧租约释放即 abort,随后同键可新建;旧源迟到只释放自身,不能替换/逐出新缓存条目。 test('last release aborts a load; a late source cannot replace or evict a newly acquired entry', async () => { const cache = createDisplayCache(), owner = {}, gate = deferred(); let signal, oldDisposed = 0, newDisposed = 0; const old = cache.acquire(owner, 'asset', async value => { signal = value; return gate.promise; }); await tick(); const cancelled = assert.rejects(old.ready, { code: 'display_released' }); old.release(); await cancelled; assert.equal(signal.aborted, true); const next = cache.acquire(owner, 'asset', async () => ({ url: 'blob:new', release: () => newDisposed++ })); assert.equal(await next.ready, 'blob:new'); gate.resolve({ url: 'blob:old', release: () => oldDisposed++ }); await tick(); assert.equal(oldDisposed, 1); assert.equal(newDisposed, 0); // peer 的 load 一旦调用就抛错;成功读到 blob:new 证明迟到旧源没有逐出当前同键条目。 const peer = cache.acquire(owner, 'asset', async () => { throw Error('unexpected duplicate read'); }); assert.equal(await peer.ready, 'blob:new'); peer.release(); next.release(); assert.equal(newDisposed, 1); }); // 读取失败保留原错误码并移除失败条目,新 acquire 可重新读取成功,不永久缓存错误。 test('read failure retains its cause and removes the failed cache entry for a fresh retry', async () => { const cache = createDisplayCache(), owner = {}; const a = cache.acquire(owner, 'asset', async () => { throw Object.assign(Error('disk_failed'), { code: 'disk_failed' }); }); await assert.rejects(a.ready, { code: 'disk_failed' }); const b = cache.acquire(owner, 'asset', async () => ({ url: '/remote' })); assert.equal(await b.ready, '/remote'); b.release(); cache.close(); }); // 同资源 ID 不同 owner 各有源;releaseOwner 只关闭自己的作用域并禁止再次领取,不影响另一个 owner 的共享租约。 test('project owner scopes isolate identical IDs and retired owners cannot reacquire', async () => { const cache = createDisplayCache(), a = {}, b = {}; let disposed = 0; const first = cache.acquire(a, 'same-id', async () => ({ url: 'blob:a', release: () => disposed++ })); const second = cache.acquire(b, 'same-id', async () => ({ url: 'blob:b', release: () => disposed++ })); assert.deepEqual(await Promise.all([first.ready, second.ready]), ['blob:a', 'blob:b']); cache.releaseOwner(a); assert.equal(disposed, 1); assert.throws(() => cache.acquire(a, 'same-id', async () => ({ url: '/forbidden' })), { code: 'display_scope_closed' }); const peer = cache.acquire(b, 'same-id', async () => { throw Error('unexpected'); }); assert.equal(await peer.ready, 'blob:b'); peer.release(); second.release(); assert.equal(disposed, 2); }); // close 立即拒绝挂起 ready,迟到源仍需释放;关闭后新 acquire 拒绝,多次 close 安全。 test('cache close rejects waiting readers immediately and disposes late data without reopening a scope', async () => { const cache = createDisplayCache(), gate = deferred(); let disposed = 0; const lease = cache.acquire({}, 'asset', () => gate.promise); await tick(); const cancelled = assert.rejects(lease.ready, { code: 'display_released' }); cache.close(); await cancelled; gate.resolve({ url: 'blob:late', release: () => disposed++ }); await tick(); assert.equal(disposed, 1); assert.throws(() => cache.acquire({}, 'asset', async () => ({ url: '/late' })), { code: 'display_scope_closed' }); cache.close(); }); // 空 URL 等非法源需清理已分配资源;某个 release 抛错通过 onError 报告,不能阻止其他 owner 的清理。 // disposed 在这里直接累加每次底层 release 调用,与幂等句柄内部去重后的有效释放计数不同。 test('invalid sources clean their allocation and cleanup errors do not interrupt other owners', async () => { const errors = [], cache = createDisplayCache({ onError: error => errors.push(error) }); let disposed = 0; const invalid = cache.acquire({}, 'invalid', async () => ({ url: '', release: () => disposed++ })); await assert.rejects(invalid.ready, { code: 'invalid_display_source' }); assert.equal(disposed, 1); const a = cache.acquire({}, 'a', async () => ({ url: 'blob:a', release: () => { throw Error('cleanup_failed'); } })); const b = cache.acquire({}, 'b', async () => ({ url: 'blob:b', release: () => disposed++ })); await Promise.all([a.ready, b.ready]); cache.close(); assert.equal(disposed, 2); assert.equal(errors[0].message, 'cleanup_failed'); });