docs(runtime): explain asset display and image decode lifecycles

This commit is contained in:
2026-11-12 13:01:35 +08:00
parent 9ea8463867
commit f6acdac8d8
5 changed files with 51 additions and 19 deletions
+20 -9
View File
@@ -27,32 +27,43 @@ export type AssetDepotPort={
read(leader:StorageLeader,authority:string,id:Id):Promise<DepotEntry|undefined>;
collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise<DepotCollection>;
};
/** 严格验证授权域、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<StorageScope,'accountId'|'workspaceId'>;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise<string>}){
const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map<string,SharedAsset>(),controllers=new Set<AbortController>();
/** 每个 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<AssetDepotPort['collect']>[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();},
});
}
+4 -1
View File
@@ -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<ImageDimensions>((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。
+11 -4
View File
@@ -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<object, Map<string, Entry>>(), retired = new WeakSet<object>();
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<DisplaySource>): 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<string>((resolve, reject) => {
rejectReady = reject;
shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } });
+12 -4
View File
@@ -15,17 +15,25 @@ export type ImageDecoderPort={decode(bytes:Blob,signal?:AbortSignal):Promise<Ima
/** 这里只验证正安全整数和乘积可安全表示,不等同于已应用产品的像素预算。 */
export function validImageDimensions(value:unknown):value is ImageDimensions{const size=value as ImageDimensions;return !!size&&Number.isSafeInteger(size.width)&&size.width>0&&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<ImageDimensions>;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<number,Task>(),active=new Map<number,Task>();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<concurrency&&waiting.size){const task=waiting.values().next().value!;waiting.delete(task.id);active.set(task.id,task);void Promise.resolve().then(()=>{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<ImageDimensions>((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'));}}});
}
+4 -1
View File
@@ -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 全局声明与其它源码脚本命名空间合并。