docs(runtime): explain host and workspace ownership lifecycles
This commit is contained in:
@@ -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<CanonicalReadUpdate>; 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(); },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Id,number>,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<Id, number>,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<Id,number>,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();
|
||||
|
||||
Reference in New Issue
Block a user