docs(tests): explain worker pools hashes and copy budgets
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* 浏览器摘要适配器的协议故障测试:使用可控 Worker 替身检查请求/响应身份,以及启动和运行失败的错误区分。
|
||||
* 不启动真实线程,也不验证浏览器打包;每个用例恢复 globalThis.Worker,避免污染其他测试。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createBrowserDigests} from '../../src/lib/canvas-runtime/browser-digests.ts';
|
||||
|
||||
// Worker 回传错误 id 即使摘要格式正确也必须拒绝;故障 worker 需 terminate,不能把别的任务结果交给当前调用者。
|
||||
test('browser digest adapter rejects mismatched worker identity and terminates the worker',async()=>{
|
||||
const previous=globalThis.Worker;let terminated=0;
|
||||
globalThis.Worker=class{postMessage(value){queueMicrotask(()=>this.onmessage({data:{version:1,id:value.id+1,byteLength:value.bytes.size,sha256:'a'.repeat(64)}}));}terminate(){terminated++;}};
|
||||
globalThis.Worker=class{/* 模拟线程稍后回信,故意把 id 加一;其余字段合法以隔离身份校验。 */ postMessage(value){queueMicrotask(()=>this.onmessage({data:{version:1,id:value.id+1,byteLength:value.bytes.size,sha256:'a'.repeat(64)}}));}/* 计数故障后的终止动作。 */ terminate(){terminated++;}};
|
||||
const pool=createBrowserDigests();try{await assert.rejects(pool.digest(new Blob(['bytes'])),/invalid_digest_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
});
|
||||
// 构造器抛错映射为 worker_unavailable,onerror 映射为 worker_failed;两个路径都不能给调用者返回伪造摘要。
|
||||
test('browser digest adapter exposes worker startup and runtime failures without returning a false hash',async()=>{
|
||||
const previous=globalThis.Worker;let pool;
|
||||
try{globalThis.Worker=class{constructor(){throw Error('denied');}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_unavailable/);pool.close();let terminated=0;globalThis.Worker=class{postMessage(){queueMicrotask(()=>this.onerror({preventDefault(){}}));}terminate(){terminated++;}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_failed/);assert.equal(terminated,1);}finally{pool?.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
try{globalThis.Worker=class{/* 在线程创建阶段抛错,尚未进入 postMessage。 */ constructor(){throw Error('denied');}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_unavailable/);pool.close();let terminated=0;globalThis.Worker=class{/* 在线程运行阶段触发 onerror,区别于构造失败。 */ postMessage(){queueMicrotask(()=>this.onerror({preventDefault(){}}));}/* 计数故障后的终止动作。 */ terminate(){terminated++;}};pool=createBrowserDigests();await assert.rejects(pool.digest(new Blob()),/digest_worker_failed/);assert.equal(terminated,1);}finally{pool?.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 复制快照编解码的协作式预算测试。调度只影响让出时机,不能影响快照字节、墓碑、索引读取或取消语义。
|
||||
* 同时覆盖普通/索引快照及旧 prepared duplicate 源格式;假时钟与工作量上限分别控制耗时和粗时钟场景。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDocument,createRegistry,builtinComponents,planDuplicate} from '../../src/lib/canvas-core/index.ts';
|
||||
@@ -5,29 +9,38 @@ import {encodeCopySnapshot,decodeCopySnapshot} from '../../src/lib/canvas-runtim
|
||||
import {encodeIndexedCopySnapshot,openIndexedCopySnapshot} from '../../src/lib/canvas-runtime/copy-snapshot-index.ts';
|
||||
import {encodeDuplicateSource} from '../../src/lib/canvas-runtime/import-duplicate.ts';
|
||||
import {createCopyBudget} from '../../src/lib/canvas-runtime/copy-budget.ts';
|
||||
// 内置定义和独立 AbortSignal 工厂,避免一个场景的取消状态传入另一个场景。
|
||||
const registry=createRegistry(builtinComponents()),signal=()=>new AbortController().signal;
|
||||
// 包含中文、emoji、转义文本的节点工厂,让字节一致性比较覆盖多字节编码。
|
||||
const node=i=>({id:`n-${i}`,incarnation:1,ownerCanvasId:'canvas',componentId:'text',dataVersion:1,structure:'node',data:{title:`卡片 ${i}`,text:'emoji 👀\n"quoted"'},geometry:{x:i,y:0,width:100,height:100,rotation:0}});
|
||||
// 512 个节点及一个墓碑组成统一快照,既覆盖批量处理,也覆盖删除身份信息。
|
||||
const document=createDocument('project',[{id:'canvas',parent:null}],Array.from({length:512},(_,i)=>node(i)),new Map([['retired',2]]));
|
||||
// cheap 时钟固定为零,costly 每次前进 10;记录 yield 次数,区分按预算让出与固定条数休眠。
|
||||
function scheduling(slow=false){let yields=0,tick=0;return{options:{now:()=>slow?(++tick)*10:0,yieldTask:async()=>{yields++;}},get yields(){return yields;}};}
|
||||
|
||||
// 普通/索引格式在便宜和昂贵时间切片下编码完全相同;解码保留 512 节点及墓碑,索引格式还读取末项核对内容。
|
||||
test('copy snapshot bytes and decoded records are invariant across cheap and expensive scheduling slices',async()=>{
|
||||
for(const [encode,decode,indexed] of [[encodeCopySnapshot,decodeCopySnapshot,false],[encodeIndexedCopySnapshot,openIndexedCopySnapshot,true]]){
|
||||
const cheap=scheduling(),costly=scheduling(true),a=await encode(document,signal(),cheap.options),b=await encode(document,signal(),costly.options);assert.equal(await a.text(),await b.text());assert.equal(cheap.yields,0,'cheap work slept at a fixed record count');assert.ok(costly.yields>0);const before=costly.yields,snapshot=await decode(b,'project',registry,signal(),costly.options),restored=indexed?snapshot.document:snapshot;assert.equal(restored.elements.size,512);assert.deepEqual([...restored.tombstones],[['retired',2]]);assert.ok(costly.yields>before);if(indexed)assert.deepEqual(await snapshot.readElement('n-511',signal()),document.elements.get('n-511'));else assert.deepEqual([...restored.elements],[...document.elements]);
|
||||
}
|
||||
});
|
||||
// 编码、普通解码及打开索引快照各自在第一次 yield 中取消;必须报 copy_aborted,不能返回可用的半成品,原文档不变。
|
||||
test('abort at a scheduled yield prevents copy encoders and readers from returning a partial snapshot',async()=>{
|
||||
const old=await encodeCopySnapshot(document,signal(),scheduling().options),indexed=await encodeIndexedCopySnapshot(document,signal(),scheduling().options);
|
||||
for(const run of [(s,o)=>encodeCopySnapshot(document,s,o),(s,o)=>encodeIndexedCopySnapshot(document,s,o),(s,o)=>decodeCopySnapshot(old,'project',registry,s,o),(s,o)=>openIndexedCopySnapshot(indexed,'project',registry,s,o)]){const controller=new AbortController();let time=0,yields=0;await assert.rejects(run(controller.signal,{now:()=>time+=10,yieldTask:async()=>{yields++;controller.abort();}}),error=>error.code==='copy_aborted');assert.equal(yields,1);}
|
||||
assert.equal(document.elements.size,512);assert.equal(document.elements.get('n-0').data.title,'卡片 0');
|
||||
});
|
||||
// 时钟不动时仍按调用者累计的工作量上限让出,避免大记录霸占主线程;取消后 step 及预先取消的预算创建都立即拒绝。
|
||||
test('large records consume work budget and coarse clocks still yield without changing copy cancellation errors',async()=>{
|
||||
const controller=new AbortController();let yields=0;const budget=createCopyBudget(controller.signal,{now:()=>0,yieldTask:async()=>{yields++;}});for(let i=0;i<64;i++)await budget.step(256);assert.ok(yields>0,'coarse clock allowed an unbounded byte workload');controller.abort();assert.throws(()=>budget.step(),error=>error.code==='copy_aborted');
|
||||
const aborted=new AbortController();aborted.abort();assert.throws(()=>createCopyBudget(aborted.signal),error=>error.code==='copy_aborted');
|
||||
});
|
||||
// 拒绝非正或非有限 frameBudgetMs;yieldTask 的宿主错误保留原对象,不转换成伪造的复制取消。
|
||||
test('copy budget rejects invalid scheduling and preserves host scheduling failures',async()=>{
|
||||
for(const frameBudgetMs of [0,-1,NaN,Infinity])await assert.rejects(encodeIndexedCopySnapshot(document,signal(),{frameBudgetMs}),error=>error.code==='invalid_copy_budget');
|
||||
const failure=Error('host unavailable');let time=0;await assert.rejects(encodeCopySnapshot(document,signal(),{now:()=>time+=10,yieldTask:async()=>{throw failure;}}),error=>error===failure);
|
||||
});
|
||||
// 旧式 prepared duplicate 源在不同切片下保持相同字节;预算边界取消时拒绝而非返回部分输出。
|
||||
test('legacy prepared duplicate sources preserve exact bytes and abort before returning on a budget boundary',async()=>{
|
||||
let id=0;const plan=planDuplicate(document,registry,[...document.elements.values()].map(({id,incarnation})=>({id,incarnation})),{createId:()=>`copy-${++id}`}),cheap=scheduling(),costly=scheduling(true),a=await encodeDuplicateSource(document,registry,plan,signal(),cheap.options),b=await encodeDuplicateSource(document,registry,plan,signal(),costly.options);assert.equal(await a.text(),await b.text());assert.equal(cheap.yields,0);assert.ok(costly.yields>0);const controller=new AbortController();let time=0;await assert.rejects(encodeDuplicateSource(document,registry,plan,controller.signal,{now:()=>time+=10,yieldTask:async()=>controller.abort()}),error=>error.code==='copy_aborted');
|
||||
});
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* 摘要池的容量与生命周期测试。活动执行数和 FIFO 等待数分别受限;调用者取消不等于底层执行器已经退出。
|
||||
* 以固定格式摘要和手动完成的执行器隔离哈希算法,专门验证调度、关闭及输出校验。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createDigestPool} from '../../src/lib/canvas-runtime/digest-pool.ts';
|
||||
// hash 仅作合法输出样本;tick 让执行器的微任务/定时器推进,便于检查当前实际启动次序。
|
||||
const hash='a'.repeat(64),tick=()=>new Promise(resolve=>setTimeout(resolve,0));
|
||||
// 两个执行槽加两个排队名额:第五个拒绝容量,取消排队的 C 后仅 A/B 启动,A 完成才按 FIFO 启动 D;关闭后拒绝新任务。
|
||||
test('digest pool bounds execution and FIFO queue, rejects overload and drops queued cancellation',async()=>{
|
||||
const started=[],release=[];const pool=createDigestPool({concurrency:2,maxQueued:2,execute:async bytes=>{started.push(await bytes.text());return new Promise(resolve=>release.push(()=>resolve(hash)));}}),cancel=new AbortController(),a=pool.digest(new Blob(['A'])),b=pool.digest(new Blob(['B'])),c=pool.digest(new Blob(['C']),cancel.signal),d=pool.digest(new Blob(['D']));const rejected=assert.rejects(c,/import_aborted/);await assert.rejects(pool.digest(new Blob(['overflow'])),/digest_capacity/);cancel.abort();await rejected;await tick();assert.deepEqual(started,['A','B']);release[0]();await a;await tick();assert.deepEqual(started,['A','B','D']);release[1]();release[2]();await Promise.all([b,d]);pool.close();await assert.rejects(pool.digest(new Blob()),/digest_pool_closed/);
|
||||
});
|
||||
// 取消活动 first 后先拒绝调用者,但底层仍挂起时 second 不能启动;first 真正退出后才复用槽位,并忽略其迟到输出。
|
||||
test('active cancellation cannot free a compute slot before its executor stops; late output is ignored',async()=>{
|
||||
let release;const started=[],controller=new AbortController(),pool=createDigestPool({concurrency:1,execute:async bytes=>{started.push(await bytes.text());if(started.length===1)await new Promise(resolve=>release=resolve);return hash;}}),first=pool.digest(new Blob(['first']),controller.signal),rejected=assert.rejects(first,/import_aborted/),second=pool.digest(new Blob(['second']));await tick();controller.abort();await rejected;await tick();assert.deepEqual(started,['first']);release();assert.equal(await second,hash);pool.close();
|
||||
});
|
||||
// close 幂等地取消活动/排队调用并向实际执行器传递 abort;新池中格式错误的 execute 输出必须拒绝。
|
||||
test('closing digest pool aborts active/queued tasks once and malformed executor output is rejected',async()=>{
|
||||
const signals=[],pool=createDigestPool({concurrency:1,execute:(_bytes,signal)=>{signals.push(signal);return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(Error('stopped')),{once:true}));}}),a=pool.digest(new Blob()),b=pool.digest(new Blob()),failures=[assert.rejects(a,/import_aborted/),assert.rejects(b,/import_aborted/)];await tick();pool.close();pool.close();await Promise.all(failures);assert.equal(signals.length,1);assert.equal(signals[0].aborted,true);const invalid=createDigestPool({execute:async()=> 'bad'});await assert.rejects(invalid.digest(new Blob()),/invalid_digest_response/);invalid.close();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Worker 内部摘要算法选择测试。WebCrypto 可用且未超过单次内存预算时走原生路径,否则使用流式 SHA-256。
|
||||
* 原生读取/计算失败必须保留原错误,不能默默换算法重试而掩盖资源或执行环境问题。
|
||||
*/
|
||||
import {test} from 'node:test';import assert from 'node:assert/strict';import {createHash} from 'node:crypto';
|
||||
import {digestWorkerSource,nativeDigestByteLimit} from '../../src/lib/canvas-runtime/digest-worker-source.ts';
|
||||
|
||||
// 空输入、Unicode 和二进制分别比较原生/流式结果,确认预算内原生路径只调用一次正确的 SHA-256 算法。
|
||||
test('native Worker hashing and streaming fallback produce identical SHA256 for empty, UTF8 and binary sources',async()=>{
|
||||
for(const bytes of [new Blob(),new Blob(['中文 😀']),new Blob([new Uint8Array([0,255,128,10])])]){
|
||||
let calls=0;const native={digest(algorithm,input){calls++;assert.equal(algorithm,'SHA-256');assert.equal(input.byteLength,bytes.size);return crypto.subtle.digest(algorithm,input);}};
|
||||
@@ -8,14 +13,19 @@ test('native Worker hashing and streaming fallback produce identical SHA256 for
|
||||
}
|
||||
});
|
||||
|
||||
// 整块读取失败、原生 digest 抛错或返回错误长度均明确拒绝;不能改走流式路径生成看似成功的结果。
|
||||
test('native read or digest failures stay visible and never silently retry through the streaming fallback',async()=>{
|
||||
// Blob 替身让整块读取失败,同时把 slice 设为陷阱;若代码误走流式兜底会产生另一错误。
|
||||
class BrokenRead extends Blob{arrayBuffer(){return Promise.reject(Error('read failed'));}slice(){throw Error('unexpected fallback');}}
|
||||
await assert.rejects(digestWorkerSource(new BrokenRead(['input'])),/read failed/);
|
||||
const bytes=new Blob(['input']);await assert.rejects(digestWorkerSource(bytes,{digest(){throw Error('native failed');}}),/native failed/);await assert.rejects(digestWorkerSource(bytes,{async digest(){return new ArrayBuffer(1);}}),/invalid_digest_response/);
|
||||
});
|
||||
|
||||
// 超过原生分配预算的输入禁止整块读取及 WebCrypto;仅按最多 1 MiB 块计算,结果与 Node 独立摘要一致。
|
||||
test('inputs above the native allocation budget use bounded chunks and never request a whole buffer',async()=>{
|
||||
// 构造刚超过原生预算的逻辑输入,不实际申请整块大数组;Node createHash 独立计算相同零字节序列。
|
||||
const size=nativeDigestByteLimit+1,hash=createHash('sha256');let largest=0;
|
||||
// 伪 Blob 报告大尺寸,禁止整块 arrayBuffer,只允许 slice 返回有限大小的零字节块并记录峰值。
|
||||
class ChunkOnly extends Blob{get size(){return size;}arrayBuffer(){throw Error('unbounded read');}slice(start=0,end=size){const count=Math.max(0,Math.min(size,end)-start);largest=Math.max(largest,count);return new Blob([new Uint8Array(count)]);}}
|
||||
for(let cursor=0;cursor<size;cursor+=1024*1024)hash.update(new Uint8Array(Math.min(1024*1024,size-cursor)));
|
||||
const digest=await digestWorkerSource(new ChunkOnly(),{digest(){throw Error('native budget exceeded');}});assert.equal(digest,hash.digest('hex'));assert.ok(largest<=1024*1024);
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
/**
|
||||
* 图片解码池的并发、排队、取消与响应校验测试。尺寸元数据必须合法,调用者取消不能提前释放仍在运行的槽位。
|
||||
* 使用解码执行器及 Worker 替身,不实际解码图片像素或启动浏览器线程。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createImageDecodePool} from '../../src/lib/canvas-runtime/image-decoder.ts';
|
||||
import {createBrowserImageDecoder} from '../../src/lib/canvas-runtime/browser-images.ts';
|
||||
// 手动完成的 Promise 控制解码退出;bytes 的 MIME 只是输入标签,替身执行器直接返回已知尺寸。
|
||||
const deferred=()=>{let resolve;const promise=new Promise(yes=>resolve=yes);return{promise,resolve};},bytes=new Blob(['image'],{type:'image/png'});
|
||||
// 一个活动加一个等待名额,第三个拒绝;释放屏障后按 FIFO 完成,执行器计数峰值始终为一。
|
||||
test('image decoder bounds real execution slots, preserves FIFO and rejects excess work',async()=>{
|
||||
const gate=deferred(),started=[];let active=0,peak=0;const pool=createImageDecodePool({concurrency:1,maxQueued:1,execute:async file=>{active++;peak=Math.max(peak,active);started.push(await file.text());await gate.promise;active--;return{width:4,height:2};}});try{const first=pool.decode(bytes),second=pool.decode(new Blob(['second'],{type:'image/png'}));await assert.rejects(pool.decode(bytes),/image_decode_capacity/);gate.resolve();assert.deepEqual(await first,{width:4,height:2});assert.deepEqual(await second,{width:4,height:2});assert.deepEqual(started,['image','second']);assert.equal(peak,1);}finally{gate.resolve();pool.close();}
|
||||
});
|
||||
// 活动调用取消后,底层解码尚未结束时新任务仍排队;直到 gate 完成才启动下一次执行。
|
||||
test('cancelling active work does not free its slot before the underlying decoder settles',async()=>{
|
||||
const gate=deferred(),started=deferred(),cancel=new AbortController();let executions=0;const pool=createImageDecodePool({concurrency:1,execute:async()=>{executions++;started.resolve();await gate.promise;return{width:4,height:2};}});try{const first=pool.decode(bytes,cancel.signal),rejected=assert.rejects(first,/image_decode_aborted/);await started.promise;cancel.abort();await rejected;const second=pool.decode(bytes);await Promise.resolve();assert.equal(executions,1);gate.resolve();await second;assert.equal(executions,2);}finally{gate.resolve();pool.close();}
|
||||
});
|
||||
// 零宽尺寸不能成为可用元数据;Worker 返回错误请求 id 也要拒绝并终止线程替身,finally 恢复全局 Worker。
|
||||
test('invalid dimensions and failed workers do not produce usable metadata; worker identity is checked',async()=>{
|
||||
const invalid=createImageDecodePool({execute:async()=>({width:0,height:2})});await assert.rejects(invalid.decode(bytes),/invalid_image_dimensions/);invalid.close();const previous=globalThis.Worker;let terminated=0;globalThis.Worker=class{postMessage(message){queueMicrotask(()=>this.onmessage({data:{version:1,id:message.id+1,byteLength:message.bytes.size,width:1,height:1}}));}terminate(){terminated++;}};const pool=createBrowserImageDecoder();try{await assert.rejects(pool.decode(bytes),/invalid_image_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
const invalid=createImageDecodePool({execute:async()=>({width:0,height:2})});await assert.rejects(invalid.decode(bytes),/invalid_image_dimensions/);invalid.close();const previous=globalThis.Worker;let terminated=0;globalThis.Worker=class{/* 异步回信的尺寸合法,唯独 id 错误,应被视为其他请求的结果。 */ postMessage(message){queueMicrotask(()=>this.onmessage({data:{version:1,id:message.id+1,byteLength:message.bytes.size,width:1,height:1}}));}/* 错误响应必须清理 worker,不能留在池内继续复用。 */ terminate(){terminated++;}};const pool=createBrowserImageDecoder();try{await assert.rejects(pool.decode(bytes),/invalid_image_response/);assert.equal(terminated,1);}finally{pool.close();if(previous===undefined)delete globalThis.Worker;else globalThis.Worker=previous;}
|
||||
});
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
/**
|
||||
* 增量 SHA-256 与分块导入摘要测试,使用 Node crypto 的独立实现作为结果参照。
|
||||
* 覆盖填充边界、不同 update 块宽、重复最终化读取,以及异步读块/让出时取消的迟到结果隔离。
|
||||
*/
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHash,randomBytes} from 'node:crypto';
|
||||
import {createSha256} from '../../src/lib/canvas-runtime/sha256.ts';
|
||||
import {importSourceDigest} from '../../src/lib/canvas-runtime/import-integrity.ts';
|
||||
// 用 Node createHash 计算参照摘要;既不复用待测算法,也不以自身输出作为预期值。
|
||||
const expected=bytes=>createHash('sha256').update(bytes).digest('hex');
|
||||
// 空值、标准字符串、百万个 a 及长度 0–259 的随机输入,分别按六种块宽更新;hex 可重复读取,但最终化后禁止 update。
|
||||
test('incremental SHA-256 matches known vectors, every padding boundary and varied chunk splits',()=>{
|
||||
for(const bytes of [Buffer.alloc(0),Buffer.from('abc'),Buffer.from('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq'),Buffer.alloc(1000000,97),...Array.from({length:260},(_,n)=>randomBytes(n))]){
|
||||
for(const chunk of [1,7,63,64,65,8192]){const hash=createSha256();for(let offset=0;offset<bytes.length;offset+=chunk)hash.update(bytes.subarray(offset,offset+chunk));assert.equal(hash.hex(),expected(bytes),`${bytes.length}/${chunk}`);assert.equal(hash.hex(),expected(bytes));assert.throws(()=>hash.update(new Uint8Array()),/digest_finalized/);}
|
||||
}
|
||||
});
|
||||
// 1 MiB+13 字节按 64 KiB 读取为 17 块并让出 16 次;摘要匹配 Node,且没有整块源 Blob 读取。
|
||||
test('source digest reads bounded slices, yields and never materializes the entire Blob',async()=>{
|
||||
const data=randomBytes(1024*1024+13),reads=[];let yields=0;
|
||||
// 禁止对整个源 Blob 调 arrayBuffer,slice 记录请求块长并交给真实 Blob 读字节。
|
||||
class SlicedBlob extends Blob{arrayBuffer(){throw Error('whole Blob read');}slice(start,end){reads.push(end-start);return super.slice(start,end);}}
|
||||
assert.equal(await importSourceDigest(new SlicedBlob([data]),undefined,{chunkBytes:65536,yieldTask:async()=>{yields++;}}),expected(data));assert.equal(reads.length,17);assert.ok(reads.every(size=>size<=65536));assert.equal(yields,16);
|
||||
});
|
||||
// 分别在读块挂起时 abort 和 yield 回调内 abort;迟到块及调度返回后都不能完成摘要,统一拒绝 import_aborted。
|
||||
test('source digest cancellation fences a late chunk and a scheduler yield',async()=>{
|
||||
let release,entered;const gate=new Promise(resolve=>release=resolve),ready=new Promise(resolve=>entered=resolve),controller=new AbortController();class Chunk extends Blob{async arrayBuffer(){entered();await gate;return super.arrayBuffer();}}class Source extends Blob{slice(...args){return new Chunk([super.slice(...args)]);}}
|
||||
let release,entered;const gate=new Promise(resolve=>release=resolve),ready=new Promise(resolve=>entered=resolve),controller=new AbortController();/* 读出的分块再套一层 gate,保证 abort 发生在 arrayBuffer 真正返回之前。 */ class Chunk extends Blob{async arrayBuffer(){entered();await gate;return super.arrayBuffer();}}/* 保留真实 slice 字节,但让每一块都经过上述可暂停读取。 */ class Source extends Blob{slice(...args){return new Chunk([super.slice(...args)]);}}
|
||||
const pending=importSourceDigest(new Source(['abc']),controller.signal);await ready;controller.abort();release();await assert.rejects(pending,/import_aborted/);const second=new AbortController();await assert.rejects(importSourceDigest(new Blob(['abcdef']),second.signal,{chunkBytes:2,yieldTask:async()=>second.abort()}),/import_aborted/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user