From 7cdd2db4d7ebfedf8ababbe3b567d6a6a283e920 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 15:09:51 +0800 Subject: [PATCH] docs(runtime): explain public recovery and engine shutdown contracts --- src/lib/canvas-runtime/engine.ts | 82 ++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 25 deletions(-) diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 61af4c4..63ff56e 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -1260,10 +1260,13 @@ export async function openProjectEngine(options: EngineOptions) { // 对外只暴露冻结的控制面;根快照由 editor 读取,异步执行器和持久化内部表不直接交给界面修改。 return Object.freeze({ editor, sessions, imports, - /** 渐进加载控制面:accept 合入同一流的前缀,complete 提交完整结果,resume 重新读取权威模型。 */ + /** 渐进加载控制面:accept 合入同一流前缀,complete 提交完整结果,resume 重新读取权威模型。 + * getState 返回当前根中的状态引用;accept 同步发布候选,complete/resume 成功路径另等本地保存。 */ loading:Object.freeze({getState:()=>editor.getSnapshot().runtime.loading, /** 同一时间共享一个恢复读取。v3+ 等旧请求确认后先 hold 新发送,再读服务端, - * 避免读取期间又发出写入造成模型包含范围不明;finally 释放本次读取的暂停租约。 */ + * 避免读取期间又发出写入造成模型包含范围不明;finally 释放本次读取的暂停租约。 + * 等所有 request.phase=resolved,不只等 HTTP 回调;已 applied 但尚未协调的请求仍会阻止刷新。 + * 不暂停本地编辑,completeLoading 会叠回期间未决意图;此读取不重启导入或替用户解决冲突。 */ resume():Promise{ if(loadingRead)return loadingRead; const task=(async()=>{assertWritable(false);if(!editor.getSnapshot().runtime.loading)return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:[]};if(!options.persistence.read)throw fault('canonical_reader_unavailable');const controller=new AbortController();loadingReadController=controller;let releaseRead:(()=>void)|undefined; @@ -1275,7 +1278,8 @@ export async function openProjectEngine(options: EngineOptions) { })();loadingRead=task.finally(()=>{if(loadingRead===wrapped)loadingRead=undefined;});const wrapped=loadingRead;return wrapped; }, /** 校验来源前缀只能合法扩展,再合并期间已确认的修改并重放 pending; - * 原始读取 nextRead 与 working 分开保存,本地编辑不冒充服务端新增内容。 */ + * 原始读取 nextRead 与 working 分开保存,本地编辑不冒充服务端新增内容。 + * acceptingLoading 仅覆盖本次同步 reconcile,finally 撤销;返回 applied 仍须后台保存,并不等待持久回执。 */ accept(document:Document,versions:ReadonlyMap,state:LoadingState):Result{ try{assertWritable(false);const root=editor.getSnapshot(),previous=root.runtime;if(!previous.loading)throw fault('loading_not_active');const source=previous.loadingRead;validatePrefixExtension(source?.document??previous.acknowledged,source?.versions??previous.versions,previous.loading,document,versions,state,options.registry);const nextRead=Object.freeze({document:createDocument(document.projectId,[...document.canvases.values()],[...document.elements.values()],document.tombstones),versions:readMap(versions)}),proposal=mergeLoadingRead(previous,nextRead,options.registry),acknowledged=proposal.document,working=replayPending(acknowledged,previous.ledger,options.registry);acceptingLoading=true;return editor.reconcile(working,Object.freeze({...previous,loading:freeze(clone(state)),...(state.version>=3?{loadingRead:nextRead}:{}),versions:readMap(proposal.versions),acknowledged}),root.rootRevision);}catch(error){return reject(error);}finally{acceptingLoading=false;} }, @@ -1286,7 +1290,9 @@ export async function openProjectEngine(options: EngineOptions) { collectResources,collectImportSources,inventoryLocalFiles,collectOrphanFiles, getResourcePins:()=>resourceRetentionPins(editor.getSnapshot(),options.registry,displayPins,admissionPins(editor.getSnapshot())), /** 暂停调度/生成轮询/导入,排空在途协调并保存,再读取服务端模型生成审阅会话。 - * prepare 本身不替用户丢弃编辑或改写原请求;发现冲突只返回诊断,发布发生在 apply/resolve。 */ + * 不提前发布恢复候选;在途已准入任务仍可先完成并更新当前根,排空后捕获固定底稿。 + * 暂停、保存、读服务端并验证已生效回执后尝试条件重放;重放冲突以诊断返回会话,不提前改文档。 + * prepare 期间仍保存原 working/history/request 事实,用户取舍引起的新发布发生在 apply/resolve。 */ async prepareRecovery(): Promise { assertWritable(false); if(!options.persistence.read)throw fault('canonical_reader_unavailable'); @@ -1306,6 +1312,7 @@ export async function openProjectEngine(options: EngineOptions) { await persist();assertCurrent(); // 从已知 applied 回执推导可采用的候选证据;先保存现有根,不将未验证的候选提前发布。 const staged=stageAppliedRecovery(editor.getSnapshot(),{persistence:options.persistence,runs:options.runs,assets:options.assets,now:runClock.now()}); + // staged 只在私有候选中解决已验证 applied 证据;它保持底稿 rootRevision,用于后续乐观发布。 const root=staged.snapshot,previous=root.runtime; // 读取后的根 revision 必须仍一致;后续计划不能建立在与当前状态不同的旧底稿上。 const loaded=await network.run(controller.signal,()=>options.persistence.read!(controller.signal));assertCurrent(); @@ -1320,21 +1327,24 @@ export async function openProjectEngine(options: EngineOptions) { let committed:ConflictPlan|'refresh'|undefined,committedIds:readonly Id[]=[]; // 只接受本会话实际生成的 plan 对象,拒绝调用方拼接或跨会话重用的方案。 const plans=new WeakSet(); - /** 已发布后先过保存屏障,再恢复调度/轮询并释放会话。 - * 保存失败继续持有暂停;再次调用可重试屏障,不重复发布相同恢复操作。 */ + /** 发布成功后:persist→scheduler.resume→通知新操作/受影响 run→释放三类 hold。 + * persist 或 resume 失败都保留已提交标记及会话,可再试 finish 而不重复发布恢复操作。 + * 返回 applied 表示本地恢复发布/保存完成,不等于替代操作已获服务端 ACK 或生成任务结束。 */ const finish=async()=>{if(previous.loading&&!editor.getSnapshot().runtime.loading)loadingCompletionRevision=editor.getSnapshot().rootRevision;await persist();assertCurrent();await scheduler.resume();assertCurrent();scheduler.notify(committedIds);runTracker.notify(staged.runIds);active.release();return{status:'applied' as const,revision:editor.getSnapshot().revision,operationIds:committedIds};}; // 返回会话后,未发布可直接退出;已经发布则必须完成保存,cancel 不等于回滚已发布恢复。 active.cancel=async()=>{if(recovery!==active)return;if(committed){await finish();return;}active.release();}; return Object.freeze({baseRootRevision:root.rootRevision,canonical,acknowledgedRequestIds:staged.requestIds, pendingOperationIds:Object.freeze([...previous.ledger.operations.values()].filter(record=>record.phase!=='resolved').map(record=>record.intent.id)), ...(conflict?{conflict}:{}), - /** 在固定根上纯计算明确取舍的恢复方案,分配新操作身份并保存可审计备份。 */ + /** 在固定底稿上计算恢复候选并分配新操作 ID,生成含审计备份的 plan;不发布/写磁盘。 + * WeakSet 只登记本会话生成的对象身份,拒绝外部拼造;createId 的分配计数不会因放弃 plan 回滚。 */ plan(choices:ReadonlyMap){ assertCurrent();if(committed)throw fault('recovery_already_committed');if(editor.getSnapshot().rootRevision!==root.rootRevision)throw fault('stale_plan'); const plan=planConflictRecovery(root,canonical,choices,options.registry,createId,options.historyLimit??100);plans.add(plan);return plan; }, /** 采用本会话方案:重查策略/生成输出适用性,发布文档、历史、账本、审计及资源 pin。 - * 同一方案已发布时只重试 finish;不能接着换另一方案覆盖第一次发布。 */ + * 同一方案已发布时只重试 finish;不能接着换另一方案覆盖第一次发布。 + * 策略拒绝返回 rejected 并保留会话;通过后 reconcile 按原 rootRevision 同时发布 plan 文档/历史/操作和 runtime。 */ async resolve(plan:ConflictPlan):Promise{ assertCurrent();if(!plans.has(plan))throw fault('invalid_recovery_plan');if(committed&&committed!==plan)throw fault('recovery_already_committed'); if(!committed){ @@ -1360,7 +1370,7 @@ export async function openProjectEngine(options: EngineOptions) { if(result.status==='rejected')return result; committed='refresh'; } - // Persistence failure retains the hold; apply/cancel can retry this barrier without republishing. + // 首次发布后记 committed=refresh;保存失败时 apply/cancel 复用 finish,不再发布第二份刷新。 return finish(); }, // 使用更新后的 cancel 语义,已提交会话仍需先完成可靠保存。 @@ -1368,7 +1378,8 @@ export async function openProjectEngine(options: EngineOptions) { }); }catch(error){active.release();throw error;} }, - /** 读取当前项目画布的模型目录,经过共享网络门;不创建生成任务。 */ + /** 读取当前项目画布的模型目录,经过共享网络门;不创建生成任务或更新 Root。 + * 只查画布存在/服务可用及网络门身份,不要求无恢复会话或完整模型;目录返回仍由端口解释。 */ models(boardId: Id, signal: AbortSignal) { if(closing||closed||!options.runs)return Promise.reject(fault('run_interpreter_unavailable')); if(!editor.getSnapshot().document.canvases.has(boardId))return Promise.reject(fault('canvas_missing')); @@ -1385,7 +1396,9 @@ export async function openProjectEngine(options: EngineOptions) { }catch(error){return reject(error);} }, /** 用户明确采用冲突任务的一份输出。校验目标代次/任务成员/输出身份并防重复, - * 通过普通数据编辑取得 history;若内容本已相同,仍登记空 changes 的 run.apply 控制动作。 */ + * 通过普通数据编辑取得 history;若内容本已相同,仍登记空 changes 的 run.apply 控制动作。 + * 文本采用 text;图片采用 src/assetId,不把输出复制成新的正在执行任务或重复上传任务。 + * applied 只表示本地接纳,服务应用回执仍需版本/目标身份核验,appliedOutputIds 在回执协调后更新。 */ applyRunOutput(runId:Id,target:RunApplyInput['target'],outputId:Id):Result { try{ assertWritable();if(!options.runs)throw fault('run_interpreter_unavailable');if(applying)throw fault('reentrant_dispatch'); @@ -1405,7 +1418,9 @@ export async function openProjectEngine(options: EngineOptions) { }catch(error){return reject(error);}finally{applying=undefined;} }, /** 登记生成启动意图,真正受理/执行由服务完成。只允许当前已知的支持类型节点, - * 启动依赖所有旧未决操作,保证服务端生成所见输入先包含当前编辑;不在此直接请求模型。 */ + * 启动依赖所有旧未决操作,保证服务端生成所见输入先包含当前编辑;不在此直接请求模型。 + * 校验通用输入与目标类型/编辑能力,不在这里刷新模型目录或证明远端模型此刻可用。 + * 未决 run.start 按目标 ID 保守占用;已有任务用 ID+incarnation/传播目标及 hasMore/pendingPage 判忙。 */ startRun(input: RunInput): Result { try { assertWritable(); @@ -1428,7 +1443,8 @@ export async function openProjectEngine(options: EngineOptions) { }catch(error){return reject(error);} }, /** 用新的权威模型刷新 ACK,再条件重放本地未决修改;未确认请求存在时拒绝模糊刷新。 - * 与 prepareRecovery 不同,此入口只尝试安全重放,冲突时保留文档并登记诊断。 */ + * 与 prepareRecovery 不同,此入口只尝试安全重放,冲突时保留文档并登记诊断。 + * 不自行读取网络;调用方提供文档/版本。同步 applied 不等待 persist;loading 中转 completeLoading 的同类同步路径。 */ refresh(document: Document, versions: ReadonlyMap): Result { if(editor.getSnapshot().runtime.loading)return completeLoading(document,versions,{fresh:true}); const root = editor.getSnapshot(), previous = root.runtime; @@ -1451,7 +1467,9 @@ export async function openProjectEngine(options: EngineOptions) { /** 查询图片暂存能力;渐进加载仅 v6 已完整画布开放,真正事务还需经过 editor 策略校验。 */ canStageFiles(canvasId:Id){try{assertWritable(false);if(!options.assets)throw fault('asset_interpreter_unavailable');const snapshot=editor.getSnapshot();if(!snapshot.document.canvases.has(canvasId))throw fault('canvas_not_loaded',[canvasId]);if(snapshot.runtime.loading&&(snapshot.runtime.loading.version!==6||!loadingQueryCoverage(snapshot).completeCanvasIds.includes(canvasId)))throw fault('loading_scope_incomplete',[canvasId]);const permission=options.policy?.({type:'changes.apply',changes:[]},snapshot.document);if(permission&&!permission.enabled)throw fault(permission.code,permission.targets);return{enabled:true as const};}catch(error){const result=reject(error);return{enabled:false as const,code:result.code,targets:result.targets};}}, /** 同步图片加入入口:校验字节/身份,暂存 Blob,再由 callback 在同一事务创建资源引用。 - * reducer 同根建立上传意图和资源记录,后续异步摘要/上传;事务失败清理未被根采用的字节。 */ + * reducer 同根建立上传意图和资源记录,后续异步摘要/上传;事务失败清理未被根采用的字节。 + * callback 须同步使用 handles/tx 创建引用,不能 await 后继续提交;这是本地编辑接纳,不等上传完成。 + * 文件名/MIME/长度在此筛选,实际图片解码与摘要另行验证;空净事务撤回本次待写 Blob。 */ stageFiles(files: readonly StagedFile[], callback: (resources: readonly ResourceHandle[], transaction: Transaction) => unknown) { assertWritable(false);if(editor.getSnapshot().runtime.loading&&editor.getSnapshot().runtime.loading?.version!==6)throw fault('model_loading'); if (staged.length||adopting) throw fault('reentrant_dispatch'); @@ -1473,7 +1491,9 @@ export async function openProjectEngine(options: EngineOptions) { } finally { staged = []; stagedDefaultSizes.clear();for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); } }, /** 复用同工作区已存在的图片:先走服务授权/取字节,再用同步事务建立本项目绑定。 - * 这条路径不重复创建上传任务;采用失败则还原原来的 adoptedFiles 暂存值。 */ + * 这条路径不重复创建上传任务;采用失败或无净操作则还原原 adoptedFiles 暂存值。 + * await 授权后重查写入身份与取消,再设置同步 adopting 上下文;授权成功本身不发布元素/绑定。 + * Promise 结束于本地事务结果,已接纳字节随后随相应 Root/depot cut 保存,不在此单独等待 durable 回执。 */ async adoptResource(id:Id,callback:(resource:ResourceHandle&{readonly asset:StoredAsset},transaction:Transaction)=>unknown,settings:{signal?:AbortSignal}={}):Promise{ assertWritable(false);if(!depot||!options.assets?.authority||!options.storage.assetDepot)throw fault('asset_depot_unavailable');if(editor.getSnapshot().runtime.loading&&editor.getSnapshot().runtime.loading?.version!==6)throw fault('model_loading'); if(staged.length||adopting)throw fault('reentrant_dispatch'); @@ -1494,7 +1514,8 @@ export async function openProjectEngine(options: EngineOptions) { adoptionTasks.set(controller,task);void task.finally(()=>adoptionTasks.delete(controller)).catch(()=>{});return task; }, /** 借出图片显示句柄:先 pin 防清理,再取得共享显示 lease,调用方用完必须 release。 - * 外部 signal、项目关闭和失败路径也会释放;Blob URL 由缓存的最后一个使用者撤销。 */ + * 外部 signal、项目关闭和失败路径也会释放;Blob URL 由缓存的最后一个使用者撤销。 + * pin 是本项目瞬态计数,不另存到 Root;共享缓存 key 命中可复用 URL,新句柄仍独立检查身份/取消。 */ async displayResource(id: Id, settings: { signal?: AbortSignal } = {}) { assertDisplayIdentity(); if(collectionReservations.has(id))throw fault('resource_collection_in_progress',[id]); @@ -1524,7 +1545,8 @@ export async function openProjectEngine(options: EngineOptions) { // 缓存键带授权/摘要/操作身份或退休代次,同 ID 换来源不会误用旧 URL。 const key=JSON.stringify(binding?['shared-asset',id,binding.authority,binding.digest]:retained?['asset',id,retained.operationId,retained.retirement?.purgedAt??null]:orphan?['orphan',id,orphan.digest]:source?['source',id,source.sha256??null]:['unmanaged',id]); /** 加载回调先找待保存/本地字节,再尝试共享仓库,最后使用服务端显示 URL。 - * 孤立文件必须再次核对内容证据;所有 await 后复查项目身份,迟到 URL 不交给旧用户。 */ + * 孤立文件重算摘要并核长度/MIME;普通资源/导入源不在这条显示路径统一重新哈希。 + * 在字节采纳及最终句柄交付前检查身份/取消;depot.read 不是新的跨项目 adoption 授权。 */ lease=displayCache.acquire(displayOwner,key,async signal=>{ let bytes=adoptedFiles.get(id)?.bytes??fileWrites.get(id)??await options.storage.readFile(leader,id); if(!bytes&&binding&&depot)bytes=await depot.read(leader,binding,signal); @@ -1545,7 +1567,9 @@ export async function openProjectEngine(options: EngineOptions) { }catch(error){release();assertDisplayIdentity();if(settings.signal?.aborted)throw fault('display_aborted');throw error;} }, /** 用户主动按原始图片比例调整大小,形成独立编辑事务;与创建时默认尺寸补写区别在历史语义。 - * 要求资源确属目标、代次未变且无手势,事务后核对实际几何,策略约束不满足则整次拒绝。 */ + * 要求资源确属目标、代次未变且无手势,在事务草稿中核对实际几何,约束不满足则整次拒绝。 + * 只从本项目 resources 的已解码/服务尺寸取比例,不对共享 binding 自动发起解码或下载。 + * 和默认 grant 补写不同,这次是用户显式几何编辑,由普通 transaction 形成独立历史语义。 */ fitImage(target:Target,resourceId:Id):Result{ try{assertWritable();if(sessions.getSnapshot().gesture)throw fault('gesture_in_progress');const root=editor.getSnapshot(),element=root.document.elements.get(target.id),resource=root.runtime.resources.get(resourceId),size=resource?.image?.version===1&&resource.image.status==='ready'?resource.image:resource?.stored; if(!element||element.incarnation!==target.incarnation||element.structure!=='node'||options.registry.resolve(element)!=='known'||!options.registry.components.get(element.componentId)?.resourceFields?.includes('assetId')||element.data.assetId!==resourceId)throw fault('target_changed');if(!size||!validImageDimensions(size))throw fault('image_dimensions_unavailable'); @@ -1561,10 +1585,13 @@ export async function openProjectEngine(options: EngineOptions) { /** 订阅本地耐久性变化,返回取消订阅;已关闭时不再登记监听。 */ subscribeDurability(listener:()=>void){if(closed)return()=>{};durabilityListeners.add(listener);return()=>{durabilityListeners.delete(listener);};}, /** 显式重试先解决恢复会话和保存屏障,再重置可重试摘要/解码/尺寸授权及来源验证。 - * 最后恢复调度与生成追尾;复用原逻辑事实和冻结请求,不把失败重试变成重复创建。 */ + * 最后恢复调度与生成追尾;复用原逻辑事实和冻结请求,不把失败重试变成重复创建。 + * 只重置约定的可重试状态:不复活 revoked grant、不重启所有已终结导入,也不等待未来服务任务结束。 */ retry: async () => { if(recovery)await recovery.cancel();assertWritable(false);await persist();retryDefaultSizes();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}for(const job of editor.getSnapshot().runtime.imports?.values()??[])if(sourceNeedsSeal(job)){await importLoads.get(job.id);await resetImportReader(job.id);await loadImport(job.id);}await scheduler.resume();await runTracker.retry(); }, /** 排空当前执行链及其派生保存,根变化时再次检查直到稳定。 - * 不等待未来轮询时刻或服务端生成终结;暂停任务和未到期重试也不等于已经同步成功。 */ + * 不等待未来轮询时刻或服务端生成终结;暂停任务和未到期重试也不等于已经同步成功。 + * 不主动取得 hold:期间新增本地根/任务可延长等待;以根 revision、pending 和任务表重新检查稳定性。 + * adoption/copy 的拒绝用 allSettled 等收尾,其它链路仍可能抛错,settle 不是所有领域错误的汇总报告。 */ async settle() { let revision:number; do { @@ -1574,21 +1601,26 @@ export async function openProjectEngine(options: EngineOptions) { await scheduler.settle();await runTracker.settle();await persist();await scheduler.settle();await persist(); } while(revision!==editor.getSnapshot().rootRevision||pending.size||writing||adoptionTasks.size||copyPreparations.size||importLoads.size||verificationTasks.size||digestTasks.size||imageTasks.size||sizingTask); }, - /** 关闭项目所有者:先阻止新准入,中止并排空执行器,再归还显示资源,最后可靠保存与释放领导权。 - * 保存失败时保留 leader/根与字节材料,允许再次 close 重试;视图 detach 不应调用本方法。 */ + /** 关闭项目所有者:先 closing、请求中止后台工作,等待列出的本地任务,再释放显示/保存根/归还 leader。 + * scheduler/runTracker.stop 仅请求 abort 并阻止迟到采纳,这里未另外 await 它们的传输 Promise 全部结束。 + * 末尾 persist 失败时保留 leader/根/待写字节与 closing 状态,后续 close 可重试;不是把实例恢复可编辑。 + * 只有 closed 后重复调用立即返回,没有额外 close 单飞 Promise;宿主负责串行关闭,视图 detach 不应调用。 */ async close() { if (closed) return; // 关闭标志先发布,再逐类取消/等待;共享 display/depot 只有本项目自己创建时才负责关闭整个实例。 closing = true;notifyDurability();loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const {controller}of verificationTasks.values())controller.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]);for(const controller of adoptionTasks.keys())controller.abort();await Promise.allSettled([...adoptionTasks.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop(); - // runner close 保留已提交前缀;等所有载入/校验/摘要/解码结束后才能释放读取器和底层存储。 + // 先等 runner.close、装配/验证,再关闭 proof reader,接着等摘要/解码及尺寸 timer。 + // runner 关闭保留已经提交内容;上述步骤完成才进入最后根保存,不等待服务器生成任务本身终结。 await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(verificationTasks.size)await Promise.allSettled([...verificationTasks.values()].map(task=>task.promise));for(const {reader}of proofReaders.values())reader.close();proofReaders.clear();while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);while(sizingTask)await sizingTask; recovery?.controller.abort();recovery?.release(); // 释放本项目所有显示句柄和 cache owner;其它项目仍可继续使用注入的共享缓存。 for (const release of [...displays]) release();displayCache.releaseOwner(displayOwner);if(!options.displays)displayCache.close(); - // Failure keeps the leader/recovery material available for an explicit retry of close. + // 只有 persist 成功才取消根订阅、dispose editor、释放自建 depot 和 leader,再标 closed。 + // 注入的 storage 连接及共享池由宿主/Workspace 管,engine 不在这里调用 storage.close。 await persist(); unsubscribe(); editor.dispose();if(!options.assetDepot)depot?.close();adoptedFiles.clear(); await leader.release(); closed = true;notifyDurability();durabilityListeners.clear(); }, }); - // 工厂打开失败时释放已取得的领导权;成功返回后的关闭责任交给实例 close。 + // 工厂 try 内失败至少归还已取得的 leader,并传播异常;不是已返回实例 close 的完整清理路径。 + // 成功返回后由宿主调用实例 close 管理项目所有权,视图挂载/卸载不重建这个 leader。 } catch (error) { await leader.release(); throw error; } }