diff --git a/src/lib/canvas-runtime/host.ts b/src/lib/canvas-runtime/host.ts index 7867fd8..cc47837 100644 --- a/src/lib/canvas-runtime/host.ts +++ b/src/lib/canvas-runtime/host.ts @@ -11,7 +11,10 @@ import { createScopedLoader, type LoadPort, type ScopedLoader } from './loader.t import type { CanonicalReadUpdate } from './contracts.ts'; /** Host lifetime matches the signed-in application, independently of navigation. */ -/** 宿主注入 workspace/loader 工厂;current 闭包绑定精确账号与 epoch,作为异步结果准入围栏。 */ +/** 一个应用级宿主:workspaces/readers 按当前账号下的 workspaceId 复用,retired 单独保留待关闭实例。 + * registry 由注入方按不可变定义合同共享,本函数不冻结或复制它;也不保存 token/密码等认证凭证。 + * current 闭包绑定账号+authEpoch,拒绝旧身份结果;业务/存储实际副作用仍需 adapters 自行遵守该围栏。 + * 后台退休关闭可与新身份运行并行;Host 不为 closeRetired 建单飞锁,workspace.close 应支持重复/并发调用。 */ export function createRuntimeHost(options: { registry: Registry; workspace(accountId: Id, workspaceId: Id, authEpoch: number, current: () => boolean): WorkspaceRuntime; modelLoader?(accountId:Id,workspaceId:Id,authEpoch:number,current:()=>boolean):LoadPort; onError?: (error: unknown) => void; onObserverError?: (error: unknown) => void }) { let accountId: Id | null = null, authEpoch = 0, disposed = false; // active 表只含当前身份,retired 持有尚未成功关闭的旧 runtime,不能因换账号就丢掉待保存内容。 @@ -25,7 +28,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou }; /** 同步撤销当前身份:先换 epoch,再关闭读取句柄/移走 workspace;实际存储收尾另行等待。 */ function retire() { authEpoch++; accountId = null; for (const reader of readers.values()) reader.close();readers.clear();for (const runtime of workspaces.values()) retired.add(runtime); workspaces.clear(); } - /** 账号变化才退休旧作用域;后台关闭错误走 onError,报告失败不能改变新账号的身份事实。 */ + /** 相同账号复用 epoch,换账号先 retire 再同步启用新账号,不等待旧 workspace 关闭。 + * 旧关闭失败通过 onError 报告且保留 retired,后续身份退休/close 可重试;异常报告也被隔离。 */ function activate(account: Id) { if (disposed) throw fault('host_disposed'); if (accountId !== account) { @@ -42,7 +46,10 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou identity: () => Object.freeze({ accountId, authEpoch }), /** 返回当前 workspace 表的安全只读快照,外部不能手动增删运行实例。 */ getWorkspaces: () => readMap(workspaces), - /** 通过共享 scoped loader 读 canonical 源;增量可产生只读预览,terminal 完整后才返回最终模型。 */ + /** 只读加载不创建 workspace engine/storage;每 workspace 复用 scoped loader、每订阅维护独立 preview/seen。 + * 导航代次属于订阅者,授权 epoch 才参与共享身份;已完成读取不作长期缓存(maxCacheEntries=0)。 + * ready 后核对身份与终结完整标志,再返回 ready 模型或其它终态;不是在 Host 再做全量 Document/schema 校验。 + * preview/observer 出错只停止此订阅预览并上报 onObserverError,不取消其它订阅或替换共享读取事实。 */ async loadModel(account:Id,workspace:Id,canvas:Id,settings:{signal?:AbortSignal;navigationGeneration?:number;onPreview?:(preview:LiveModelPreview)=>void}={}) { activate(account); const readEpoch=authEpoch; @@ -60,7 +67,8 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou }; // key 纳入账号、workspace、格式、查询与 authEpoch;view 导航代次不替代授权代次。 const handle=reader.acquire({accountId:account,workspaceId:workspace,projectId:null,canvasId:canvas,viewKey:'canonical-project',format:'canonical-model-v1',queryFingerprint:'full-project',source:'foundry',authEpoch,navigationGeneration:settings.navigationGeneration??0},{signal:settings.signal,onUpdate}); - // ready 后再查身份和 terminal/complete;finally 始终释放本订阅,不能泄漏 reader 引用。 + // handle 成功取得后,ready 成功/拒绝都在 finally 释放订阅并 close preview;不关闭共享 reader/其它订阅。 + // 端口返回模型的领域合法性仍由 canonical adapter/预览解释器和后续 engine 准入负责。 try{const snapshot=await handle.ready;if(disposed||accountId!==account||authEpoch!==readEpoch)throw fault('identity_changed');const last=snapshot.chunks.at(-1);if(!last||!last.complete||last.value.status==='receiving')throw fault('incomplete_load');return last.value.status==='ready'?Object.freeze({...last.value,sourceToken:last.snapshotToken}):last.value;}finally{handle.release();preview?.close();} }, /** 按当前账号激活并懒创建 workspace;导航到同工作区不会重新创建资源池或项目。 */ @@ -74,9 +82,11 @@ export function createRuntimeHost(options: { registry: Registry; workspace(accou } return runtime; }, - /** 退出身份但保留 host 可再次绑定;返回旧 runtime 收尾 Promise,让宿主处理未保存错误。 */ + /** 同步清账号并递增 epoch,关闭读句柄,再等待退休 runtime;不永久 disposed,之后可重新绑定。 + * 不等待 close 成功也已使旧 current 失效;失败不会恢复旧身份或删除退休实例。 */ retireIdentity() { retire(); return closeRetired(); }, - /** 永久关闭 host 并退休全部作用域;失败仍可再次调用 close 重试退休集合。 */ + /** 永久 disposed 后再退休/等待关闭,后续 workspace/bind/load 准入拒绝。 + * 失败可再次 close 重试;每次都会再次 retire/推进 epoch,返回值不是共享的单飞关闭 Promise。 */ close() { disposed = true; retire(); return closeRetired(); }, }); } diff --git a/src/lib/canvas-runtime/workspace.ts b/src/lib/canvas-runtime/workspace.ts index d232168..16f1515 100644 --- a/src/lib/canvas-runtime/workspace.ts +++ b/src/lib/canvas-runtime/workspace.ts @@ -23,7 +23,10 @@ export type WorkspaceOptions = { }; /** Workspace owns persistent projects; a view only borrows one and never closes it. */ -/** 创建工作区容器,不立刻打开所有项目,只有 open/openPartial 才取得项目 engine。 */ +/** 建立 workspace 共享池和 projectId→打开 Promise 表,不在构造时 claim 项目存储或恢复文档。 + * 注入的 digests/images/storage 也由本容器在最终 close 时关闭,跨 workspace 共享它们需宿主另行管理所有权。 + * options 仍由闭包读取,身份/端口应保持稳定;公开标量与 assetDepot scope 并非每次随外部 options 重写。 + * getProjects 捕获只读表成员,但 Promise 兑现的 engine 仍是业务 API,不是不可操作的纯数据快照。 */ export function createWorkspaceRuntime(options: WorkspaceOptions) { const digests=options.digests??createDigestPool(); const displays=createDisplayCache({onError:options.onError}); @@ -33,7 +36,9 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) { /** 准入同时要求工作区仍接受任务和宿主身份仍有效,关闭一开始就让旧调用失效。 */ const current = () => accepting && !closed && options.isCurrentIdentity(); const assetDepot=options.storage.assetDepot?createAssetDepot({scope:options,port:options.storage.assetDepot,current,digest:(bytes,signal)=>digests.digest(bytes,signal)}):undefined; - /** 取得/创建共享项目;打开失败只移除同一个 Promise,迟到失败不能删掉后来重开的实例。 */ + /** current 通过后按 document.projectId 复用在途/已打开 Promise,避免同 workspace 同项目重复 claim leader。 + * 命中时不以新 document/versions/loading 替换初始参数;open/openPartial 随后再协调新输入。 + * 打开拒绝仅移除仍对应自己的 Promise,准入有效时下次可重试;adapters 的同步抛错直接传出。 */ function acquire(document:Document,versions:ReadonlyMap,loading?:LoadingState){ if(!current())return Promise.reject(fault('identity_changed')); const existing=projects.get(document.projectId);if(existing)return existing; @@ -46,20 +51,29 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) { assetDepot, /** 查看项目打开句柄的只读表,不授予外部关闭/替换内部 ownership 的能力。 */ getProjects: () => readMap(projects), - /** 完整源与已有 engine 协调:同捕获完成走 loading.complete,旧版本不覆盖较新的本地基线。 */ + /** 取得 engine 后复查身份:同 sourceToken 的 loading 用 complete,否则尝试普通 refresh。 + * 任一传入版本低于当前已知值就跳过整个 refresh;其余一致性由 engine 校验,不在这里合并版本向量。 + * complete 拒绝总会抛错;refresh 拒绝仅在原 engine 仍 loading 时抛错,非 loading 可保留原 engine 返回。 + * 因此返回 engine 表示复用成功,不等于传入 document 已被采纳或服务器写入已完成。 */ open(document: Document, versions: ReadonlyMap,settings:{sourceToken?:string}={}) { return acquire(document,versions).then(async engine=>{if(!current())throw fault('identity_changed');const loading=engine.loading.getState();if(loading&&settings.sourceToken===loading.sourceToken){const result=await engine.loading.complete(document,versions,{sourceToken:settings.sourceToken});if(result.status==='rejected')throw fault(result.code,result.targets);}else{const previous=engine.getSnapshot().runtime.versions;if(![...versions].some(([id,value])=>(previous.get(id)??0)>value)){const result=engine.refresh(document,versions);if(result.status==='rejected'&&loading)throw fault(result.code,result.targets);}}return engine;}); }, - /** 接入部分源并复用项目 engine,仅相同 sourceToken 的后续块继续 accept。 */ + /** 新项目以 partial 文档/loading 打开;已有项目只在当前 loading.sourceToken 匹配时 accept。 + * 不同 token 或已无 loading 时直接返回原 engine,不替换其文档/加载任务;匹配但 accept 拒绝则抛错。 */ openPartial(document:Document,versions:ReadonlyMap,loading:LoadingState){ return acquire(document,versions,loading).then(engine=>{if(!current())throw fault('identity_changed');const previous=engine.loading.getState();if(previous?.sourceToken===loading.sourceToken){const result=engine.loading.accept(document,versions,loading);if(result.status==='rejected')throw fault(result.code,result.targets);}return engine;}); }, - /** view 离开后工作区仍可补完项目加载;await 前后都检查身份,错误交宿主报告。 */ + /** view 离开后仍可 resume 已打开项目的背景加载;等待 opening 前后检查 current。 + * loading.resume 的在途身份/结果围栏由 engine 负责,此方法在其返回后不额外再查 current。 + * 错误仅在当前身份仍有效时报告 onError,随后原样拒绝;不吞错或清除项目句柄。 */ async resumeLoading(projectId:Id){ try{if(!current())throw fault('identity_changed');const opening=projects.get(projectId);if(!opening)throw fault('project_not_open');const engine=await opening;if(!current())throw fault('identity_changed');const result=await engine.loading.resume();if(result.status==='rejected')throw fault(result.code,result.targets);return result;} catch(error){if(current()){try{options.onError?.(error);}catch{}}throw error;} }, - /** 幂等/可重试关闭:复用当前 closing Promise,全部 engine 成功后才释放池和 storage。 */ + /** 先 accepting=false 永久停止此实例新准入,再单飞等待全部在途打开/engine 关闭。 + * 打开失败视为无需关闭;engine 关闭失败聚合抛错并保留项目/共享池,后续 close 可重试但不重新 accepting。 + * 项目全成功后才依次同步关闭共享端口;这段不是原子事务,注入端口 close 应可安全重复,抛错也允许重试。 + * 全部收尾成功才清 projects/置 closed;此接口不证明外部服务或服务器生成任务也已停止。 */ close() { accepting = false; // Fence commands/transports before awaiting pending opens or storage. if (closed) return Promise.resolve();