From f6acdac8d86d3246e8a281b3617fc10e562cbf39 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 13:01:35 +0800 Subject: [PATCH] docs(runtime): explain asset display and image decode lifecycles --- src/lib/canvas-runtime/asset-depot.ts | 29 ++++++++++++++++-------- src/lib/canvas-runtime/browser-images.ts | 5 +++- src/lib/canvas-runtime/display-cache.ts | 15 ++++++++---- src/lib/canvas-runtime/image-decoder.ts | 16 +++++++++---- src/lib/canvas-runtime/image.worker.ts | 5 +++- 5 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/lib/canvas-runtime/asset-depot.ts b/src/lib/canvas-runtime/asset-depot.ts index 9335899..0ecf51f 100644 --- a/src/lib/canvas-runtime/asset-depot.ts +++ b/src/lib/canvas-runtime/asset-depot.ts @@ -27,32 +27,43 @@ export type AssetDepotPort={ read(leader:StorageLeader,authority:string,id:Id):Promise; collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise; }; -/** 严格验证授权域、64位小写十六进制摘要、图片 MIME、正尺寸/字节及像素/20MiB上限。 */ +/** 校验版本、非空授权域/URL、64 字符小写摘要、图片 MIME、正整数尺寸/字节及像素/20MiB上限。 + * 只检查元数据:URL 未限制协议/域,摘要未重算,尺寸未通过实际解码核实;授权真实性由服务端口保证。 */ export function validateSharedAsset(value:SharedAsset):void{ if(!value||value.version!==1||typeof value.authority!=='string'||!value.authority||value.authority.length>8192||typeof value.digest!=='string'||!/^([a-f0-9]{64})$/.test(value.digest))throw fault('invalid_shared_asset'); const asset=value.asset;if(!asset||typeof asset.id!=='string'||!asset.id||typeof asset.url!=='string'||!asset.url||asset.url.length>8192||![asset.width,asset.height,asset.bytes].every(n=>Number.isSafeInteger(n)&&n>0)||asset.width*asset.height>40_000_000||asset.bytes>20*1024*1024||!['image/png','image/jpeg','image/gif','image/webp'].includes(asset.mime_type))throw fault('invalid_shared_asset'); } -/** 验证后捕获并冻结资源元信息,隔离调用者可变的 asset 对象。 */ +/** 验证后重建标准外层字段并浅复制 asset,再递归冻结,隔离标准标量元信息。 + * asset 的扩展属性仍由 spread 保留,嵌套扩展值不深克隆,不能当作任意外部对象的完整隔离入口。 */ export function sharedAssetRef(value:SharedAsset):SharedAsset{validateSharedAsset(value);return freeze({version:1 as const,authority:value.authority,digest:value.digest,asset:{...value.asset}});} /** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */ const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b)); /** Immutable metadata cache only. Every new adoption resolves current permission first. */ -/** 建立工作区门面,绑定作用域与当前身份,不把跨账号缓存混入同一个 records 表。 */ +/** 捕获 account/workspace 标量,建立仅存元信息的 Map;字节仍由 port 持有,不建立内存 Blob 缓存。 + * 宿主 current/leader 校验约束使用生命周期,实际存储 fence/权限检查仍由端口实现。 + * 每次 getResources 返回成员快照,旧快照不随 remember/close 改变;它不另做授权或关闭检查。 */ export function createAssetDepot(options:{scope:Pick;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise}){ const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map(),controllers=new Set(); - /** 每个 await 前后使用:宿主仍有效且 leader 属于同账号/工作区;允许不同项目共享仓库。 */ + /** 入口及异步结果采纳前检查宿主/leader,允许同账号工作区的不同项目共享。 + * 不比较 projectId,不读取远端 fence;在途 I/O 是否立即停止由端口/信号决定。 */ const check=(leader:StorageLeader)=>{if(closed||!options.current())throw fault('identity_changed');if(!leader.active||leader.scope.accountId!==scope.accountId||leader.scope.workspaceId!==scope.workspaceId)throw fault('asset_depot_scope_mismatch');}; /** 缓存同 authority/assetId 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */ function remember(ref:SharedAsset){validateSharedAsset(ref);const key=JSON.stringify([ref.authority,ref.asset.id]),previous=records.get(key);if(previous&&!same(previous,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);records.set(key,sharedAssetRef(ref));} return Object.freeze({scope,getResources:()=>readMap(records), - /** 新采纳先远端 resolve,再比本地身份/读取原字节并验长度+SHA;每个 await 后重新检查取消/身份。 - * 成功只返回 ref/bytes 和记元信息,目标 binding 与字节持久化由 engine 的单 Root 完成。 */ + /** 新采纳先 resolve 当前权限,再核对缓存身份;有缓存字节仍验长度/摘要,否则向 authority 读取。 + * combined 信号交端口,异步返回后再检查;入口不先 throwIfAborted,已取消信号仍可能传给 resolve。 + * remember 捕获元信息,但返回 ref 沿用 resolve 结果(只冻结返回 envelope),端口须提供不可变身份。 + * 成功不写共享字节/项目 binding;这些由 engine 同 Root 持久化,不是新上传任务。 */ async authorize(leader:StorageLeader,authority:AssetAuthority,id:Id,signal:AbortSignal){check(leader);const controller=new AbortController(),combined=AbortSignal.any([signal,controller.signal]);controllers.add(controller);try{const ref=await authority.resolve(id,combined);check(leader);combined.throwIfAborted();validateSharedAsset(ref);if(ref.authority!==authority.scope||ref.asset.id!==id)throw fault('asset_authorization_mismatch');const cached=await options.port.read(leader,authority.scope,id);check(leader);combined.throwIfAborted();if(cached&&!same(cached.ref,ref))throw fault('shared_asset_identity_conflict',[id]);let bytes=cached?.bytes;if(!bytes)bytes=await authority.read(id,combined);check(leader);combined.throwIfAborted();if(bytes.size!==ref.asset.bytes||await options.digest(bytes,combined)!==ref.digest)throw fault('shared_asset_bytes_changed',[id]);check(leader);combined.throwIfAborted();remember(ref);return Object.freeze({ref,bytes});}finally{controllers.delete(controller);}}, - /** 读取已绑定本地共享字节并验身份/摘要;不是新 adoption 的授权捷径,缺字节返回 undefined。 */ + /** 按已绑定 ref 读取本地共享字节并验身份/摘要,不调用 authority.resolve 或回源下载。 + * 无记录直接 undefined;有记录无字节也记下元信息再返回 undefined,不能据此判定服务器资产消失。 + * 输入 ref 沿用调用方身份;本方法不单独证明调用者有权新采纳该资源。 */ async read(leader:StorageLeader,ref:SharedAsset,signal?:AbortSignal){check(leader);signal?.throwIfAborted();const row=await options.port.read(leader,ref.authority,ref.asset.id);check(leader);signal?.throwIfAborted();if(!row)return;if(!same(row.ref,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);if(row.bytes&&(row.bytes.size!==ref.asset.bytes||await options.digest(row.bytes,signal)!==ref.digest))throw fault('shared_asset_bytes_changed',[ref.asset.id]);check(leader);signal?.throwIfAborted();remember(ref);return row.bytes;}, - /** 作用域检查后委托存储扫描/保留期回收,结束再检查身份,不自行删除服务器资产。 */ + /** 作用域检查后直接委托 port.collect,结果由端口负责结构与回收证明,不在此重验。 + * 返回后再核对身份;检查失败不代表端口之前的本地存储事务未执行,也不删除服务器资产。 */ async collect(leader:StorageLeader,input:Parameters[1]){check(leader);const result=await options.port.collect(leader,input);check(leader);return result;}, - /** 关闭门面并 abort 授权任务、清元缓存;不抹掉共享存储中的持久身份/字节。 */ + /** 关闭后拒绝新操作,abort 本门面追踪的 authorize 并清元缓存;不删除持久身份/字节。 + * read/collect 没有被加入 controllers,在途结果由后续 check 拒绝采纳;close 不等待底层 I/O 退出。 */ close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();}, }); } diff --git a/src/lib/canvas-runtime/browser-images.ts b/src/lib/canvas-runtime/browser-images.ts index 96b2c1e..97febdb 100644 --- a/src/lib/canvas-runtime/browser-images.ts +++ b/src/lib/canvas-runtime/browser-images.ts @@ -4,7 +4,9 @@ */ import {fault} from '../canvas-core/values.ts'; import {createImageDecodePool,type ImageDimensions} from './image-decoder.ts'; -/** 返回 ImageDecoderPort;版本/id/字节数验证响应归属,尺寸合法性由 pool 再检查。 */ +/** 将每次 execute 转成专用 module Worker,请求 ID 在此 decoder 实例内递增。 + * 外围池使用默认并发/排队预算;此适配按响应版本/id/输入长度核对归属,尺寸合法性由池检查。 + * 没有超时;Worker 无响应时要由外部取消/close 才能终止,构造失败也不回退主线程解码。 */ export function createBrowserImageDecoder(){let next=0;return createImageDecodePool({execute:(bytes,signal)=>new Promise((resolve,reject)=>{ if(typeof Worker==='undefined'){reject(fault('image_decoder_unavailable'));return;} // 每任务一个 Worker,取消只 terminate 自己,不会打断其它图片的解码。 @@ -12,6 +14,7 @@ export function createBrowserImageDecoder(){let next=0;return createImageDecodeP /** finish 幂等解除消息/错误/abort 监听并 terminate;abort 在监听安装后再检查一次覆盖竞态。 */ const id=++next;let done=false;const finish=(error?:unknown,size?:ImageDimensions)=>{if(done)return;done=true;signal.removeEventListener('abort',abort);worker.onmessage=null;worker.onerror=null;worker.onmessageerror=null;worker.terminate();if(error)reject(error);else resolve(size!);},abort=()=>finish(fault('image_decode_aborted')); signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;} + // Worker error 会 preventDefault 并转稳定错误,消息无法反序列化则拒绝;所有路径共用 finish 终止线程。 // 只采纳精确匹配请求的成功尺寸或已知错误;未知错误码/错误协议作为 invalid response 拒绝。 worker.onmessage=event=>{const value=event.data;if(!value||value.version!==1||value.id!==id||value.byteLength!==bytes.size){finish(fault('invalid_image_response'));return;}if(value.error){finish(fault(value.error==='image_decode_failed'?'image_decode_failed':'invalid_image_response'));return;}finish(undefined,{width:value.width,height:value.height});};worker.onerror=event=>{event.preventDefault();finish(fault('image_decode_failed'));};worker.onmessageerror=()=>finish(fault('invalid_image_response')); // 直接传不可变 Blob,不先在 UI 线程转成像素或整份 ArrayBuffer。 diff --git a/src/lib/canvas-runtime/display-cache.ts b/src/lib/canvas-runtime/display-cache.ts index 61dffeb..561ef76 100644 --- a/src/lib/canvas-runtime/display-cache.ts +++ b/src/lib/canvas-runtime/display-cache.ts @@ -16,12 +16,15 @@ type Entry = { readonly key: string; readonly owner: object; readonly controller * durable bytes before acquiring. Scope objects are never serialized or reused * across project engines/auth epochs. There is no idle URL retention. */ -/** 创建纯显示生命周期容器;owner 应绑定 engine/auth epoch,不能序列化或跨账号复用。 */ +/** 创建纯显示生命周期容器,owners 两层 Map 用对象身份+字符串 key 索引共享条目。 + * owner 应绑定 engine/auth epoch;本模块不解释账号、资源 ID、URL 权限或实际文件 pin。 + * 没有容量上限/空闲保留,最后 lease 释放即清理;永久撤销 owner 则另记到 WeakSet。 */ export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) { // owners 仅保留活条目;retired WeakSet 禁止退役 owner 再 acquire,但不强持有其内存。 const owners = new Map>(), retired = new WeakSet(); let closed = false; - /** 统一调用来源 release 并隔离错误,清理/日志失败不阻止释放其它 URL。 */ + /** 同步调用来源 release,隔离其抛错和 onError 的抛错;onError 只报告清理失败。 + * load 的失败通过各 lease.ready 传出,不在这里自动记录或重试;release 合同也不是异步清理任务。 */ function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } } /** 幂等退休共享条目,仅删除仍指向自己的表项,避免旧任务删掉同 key 的新 entry。 */ function retire(entry: Entry) { @@ -41,7 +44,9 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void } } return Object.freeze({ - /** 借用显示来源:同 owner/key 合并加载,但每个调用者可独立取消自己的等待。 */ + /** 同 owner/key 复用进行中或已加载来源,命中时忽略本次传入的 load,不逐次重验权限。 + * 每个调用者独立 ready/release;释放已兑现的 ready 不会改变 Promise 的成功结果,但 URL 可能随最后释放失效。 + * 不自动接收 AbortSignal,取消通过 lease.release;单个释放不取消仍有其它消费者的共享加载。 */ acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise): DisplayLease { if (closed || retired.has(owner)) throw fault('display_scope_closed'); if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key'); @@ -55,6 +60,7 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void if (created.retired) throw fault('display_released'); return load(created.controller.signal); }).then(source => { + // 这里只检查 URL 为非空字符串及可选 release 为函数,不验证 URL 协议/可访问性或图片内容。 if (!source || typeof source.url !== 'string' || !source.url || source.release !== undefined && typeof source.release !== 'function') { if (typeof source?.release === 'function') dispose(source); throw fault('invalid_display_source'); } // 即使 load 不响应 abort,迟到返回也只能 dispose,不允许挂回已无消费者的缓存。 if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); } @@ -70,7 +76,8 @@ export function createDisplayCache(options: { onError?: (error: unknown) => void if (!shared.leases.size) retire(shared); }; shared.leases.add(release); - // 将共享 promise 映射成每租约的独立 ready;来源失败时本租约也自动归还,不泄漏计数。 + // 映射共享 promise 到各 lease.ready,失败逐个归还;校验 source 时抛错也沿这条失败路径清理。 + // 缓存不吞 ready 的拒绝,消费者需接住取消/加载错误;释放后迟到成功不能再次 resolve 本租约。 const ready = new Promise((resolve, reject) => { rejectReady = reject; shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } }); diff --git a/src/lib/canvas-runtime/image-decoder.ts b/src/lib/canvas-runtime/image-decoder.ts index ff12fb1..dd64439 100644 --- a/src/lib/canvas-runtime/image-decoder.ts +++ b/src/lib/canvas-runtime/image-decoder.ts @@ -15,17 +15,25 @@ export type ImageDecoderPort={decode(bytes:Blob,signal?:AbortSignal):Promise0&&Number.isSafeInteger(size.height)&&size.height>0&&Number.isSafeInteger(size.width*size.height);} /** Bounded read-only compute. Cancellation rejects the caller; a slot remains occupied until execution settles. */ -/** 默认2并发/64等待,最多8并发;取消结果与执行容量分开,防止无视 abort 的任务挤爆池。 */ +/** 默认 2 并发/64 等待,最多 8 并发;waiting/active 各用按插入顺序的 Map,不是持久任务队列。 + * maxQueued 只限制尚未进入 active 的任务;active 包括已取消但执行器仍未退出的任务。 + * 池只检查 Blob 的 image/ MIME 前缀和尺寸数值,不限制文件字节/实际像素预算,也不缓存解码结果。 */ export function createImageDecodePool(options:{execute:(bytes:Blob,signal:AbortSignal)=>Promise;concurrency?:number;maxQueued?:number}):ImageDecoderPort{ const concurrency=options.concurrency??2,maxQueued=options.maxQueued??64; if(!Number.isSafeInteger(concurrency)||concurrency<1||concurrency>8||!Number.isSafeInteger(maxQueued)||maxQueued<1)throw fault('invalid_image_decode_budget'); /** 每个解码请求独立控制器/完成标记;cleanup 解除外部取消监听,迟到执行结果不再采纳。 */ type Task={id:number;bytes:Blob;controller:AbortController;resolve:(size:ImageDimensions)=>void;reject:(error:unknown)=>void;done:boolean;cleanup():void}; const waiting=new Map(),active=new Map();let next=0,closed=false; - /** 幂等 settle 调用者,成功捕获冻结尺寸;不提前归还仍在执行的 active 槽。 */ + /** 幂等 settle 调用者并清理等待项;成功浅复制/冻结执行器结果,扩展属性不会被过滤。 + * error 用真值区分成败,执行器应以 Error 等真值拒绝;falsy rejection 不保证按失败解释。 + * 不删除 active,取消后的名额仍要等 execute 链的 finally 才归还。 */ function finish(task:Task,error?:unknown,size?:ImageDimensions){if(task.done)return;task.done=true;task.cleanup();waiting.delete(task.id);if(error)task.reject(error);else task.resolve(freeze({...size!}));} - /** FIFO 取任务并在微任务执行,校验返回尺寸,最后 finally 删除 active 再继续泵;异常不毒化后续队列。 */ + /** 取 waiting 首项立即占 active,再在微任务调用 execute;启动前复查内部 abort。 + * 成功尺寸先校验再 finish;正常错误交 finish,finally 负责释放 active 并继续 FIFO。 + * execute 若不响应取消且永不 settle,名额会持续占用;池没有超时或强制终止通用执行器的能力。 */ function pump(){while(!closed&&active.size{if(task.controller.signal.aborted)throw fault('image_decode_aborted');return options.execute(task.bytes,task.controller.signal);}).then(size=>{if(!validImageDimensions(size))throw fault('invalid_image_dimensions');finish(task,undefined,size);}).catch(error=>finish(task,error)).finally(()=>{active.delete(task.id);pump();});}} - /** decode 校验图片 MIME/容量并建立任务;close 取消所有调用结果,active 实际结束仍由 finally 收尾。 */ + /** decode 拒绝关闭/预先取消/非图片 Blob/等待容量已满,随后创建任务、绑定外部 abort 并入 waiting。 + * abort 转发给内部 controller,立即拒绝调用者并移除 waiting;运行中的 active 留待 finally。 + * close 幂等取消全部调用结果并禁止继续泵,不返回 drain Promise,也不证明所有 execute 已停止。 */ return Object.freeze({decode(bytes:Blob,signal?:AbortSignal){if(closed)return Promise.reject(fault('image_decoder_closed'));if(signal?.aborted)return Promise.reject(fault('image_decode_aborted'));if(!(bytes instanceof Blob)||!bytes.type.startsWith('image/'))return Promise.reject(fault('invalid_image_input'));if(active.size>=concurrency&&waiting.size>=maxQueued)return Promise.reject(fault('image_decode_capacity'));return new Promise((resolve,reject)=>{const abort=()=>{task.controller.abort();finish(task,fault('image_decode_aborted'));pump();},task:Task={id:++next,bytes,controller:new AbortController(),resolve,reject,done:false,cleanup:()=>signal?.removeEventListener('abort',abort)};signal?.addEventListener('abort',abort,{once:true});waiting.set(task.id,task);pump();});},close(){if(closed)return;closed=true;for(const task of [...waiting.values(),...active.values()]){task.controller.abort();finish(task,fault('image_decode_aborted'));}}}); } diff --git a/src/lib/canvas-runtime/image.worker.ts b/src/lib/canvas-runtime/image.worker.ts index cc1bed6..8d3bf81 100644 --- a/src/lib/canvas-runtime/image.worker.ts +++ b/src/lib/canvas-runtime/image.worker.ts @@ -4,11 +4,14 @@ */ /** 仅声明本入口需要的 Worker 消息能力,不导入 renderer 或 DOM 状态。 */ const worker=globalThis as unknown as{onmessage:((event:MessageEvent)=>void)|null;postMessage(value:unknown):void}; -/** 接受正确版本/id/Blob,按 from-image 选项解码;失败回稳定 image_decode_failed。 */ +/** 仅接收 version=1、安全整数 id、Blob;非法请求直接忽略,不回错误消息。 + * 不在此限制 id 为正数、MIME、字节或像素预算;单任务 Worker 的创建/并发/取消由宿主控制。 + * 异步 handler 本身不排队,重复消息可并行解码;正常宿主每 Worker 只发一个请求。 */ worker.onmessage=async event=>{ const {version,id,bytes}=event.data??{};if(version!==1||!Number.isSafeInteger(id)||!(bytes instanceof Blob))return; // bitmap 是临时原生资源,尺寸读取即使抛错也由 finally close;成功只发送 width/height 和输入长度。 try{const bitmap=await createImageBitmap(bytes,{imageOrientation:'from-image'});let width:number,height:number;try{width=bitmap.width;height=bitmap.height;}finally{bitmap.close();}worker.postMessage({version:1,id,byteLength:bytes.size,width,height});} + // 解码/读取尺寸/close/成功回信失败均尝试发送稳定错误;若通信本身不可用,宿主仍需取消/错误事件收尾。 catch{worker.postMessage({version:1,id,byteLength:bytes.size,error:'image_decode_failed'});} }; // 标记为独立 TypeScript 模块,避免 Worker 全局声明与其它源码脚本命名空间合并。