Share scoped display leases and retain incremental resource views
This commit is contained in:
@@ -13,6 +13,7 @@ import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cach
|
||||
import {hitProjectedCanvas} from "@/lib/canvas-bindings/hit-query";
|
||||
import {compatConnector,measuredConnector} from "@/lib/canvas-bindings/connectors";
|
||||
import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache";
|
||||
import { createResourceView, type ResourceViewSource } from "@/lib/canvas-bindings/resource-view";
|
||||
import { ancestors, commonCanvas } from "@/lib/canvas-core/document";
|
||||
import { projectCanvas, toLocal, transformPath, type ProjectionNode } from "@/lib/canvas-bindings/projection";
|
||||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||||
@@ -42,7 +43,9 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
|
||||
useEffect(() => { focusEpoch.current++; setEditorFocused(false); return () => { focusEpoch.current++; }; }, [engine,viewId]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
|
||||
const [error, setError] = useState(""), [sources, setSources] = useState(new Map<string, string>());
|
||||
const [error, setError] = useState("");
|
||||
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
|
||||
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
|
||||
const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | null>(null);
|
||||
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
|
||||
projectionCache.current ??= createProjectionCache();
|
||||
@@ -122,18 +125,15 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
|
||||
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
|
||||
report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } }));
|
||||
}
|
||||
const resourceKey = JSON.stringify(projection.nodes.filter(node => node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||||
const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId]));
|
||||
useEffect(() => {
|
||||
if (!liveView) return;
|
||||
let active = true;
|
||||
const handles: { release(): void }[] = [], entries: [Id, string][] = [];
|
||||
void Promise.all((JSON.parse(resourceKey) as [Id, Id][]).map(async ([id, resource]) => {
|
||||
const handle = await engine.displayResource(resource);
|
||||
if (!active) { handle.release(); return; }
|
||||
handles.push(handle); entries.push([id, handle.url]);
|
||||
})).then(() => { if (active) setSources(new Map(entries)); }).catch(e => { if (active) setError(errorText(e)); });
|
||||
return () => { active = false; for (const handle of handles) handle.release(); };
|
||||
}, [engine, resourceKey, Boolean(liveView)]);
|
||||
const view=createResourceView({resolve:(id,signal)=>engine.displayResource(id,{signal}),onChange:(sources,failures)=>{if(resourceView.current===view)setDisplayState({engine,viewId,sources,failures});},onError:error=>{if(resourceView.current===view)setError(errorText(error));}});
|
||||
resourceView.current=view;
|
||||
return ()=>{if(resourceView.current===view)resourceView.current=null;view.dispose();};
|
||||
}, [engine,viewId,Boolean(liveView)]);
|
||||
useEffect(()=>{try{resourceView.current?.sync(JSON.parse(resourceKey) as [Id,Id][]);}catch(error){setError(errorText(error));}},[engine,viewId,resourceKey,Boolean(liveView)]);
|
||||
const sources=useMemo(()=>{if(displayState.engine!==engine||displayState.viewId!==viewId)return new Map<Id,string>();const wanted=new Map(JSON.parse(resourceKey) as [Id,Id][]);return new Map([...displayState.sources].filter(([id,source])=>wanted.get(id)===source.resourceId).map(([id,source])=>[id,source.url]));},[displayState,engine,viewId,resourceKey]);
|
||||
function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); }
|
||||
const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved');
|
||||
const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0;
|
||||
@@ -258,7 +258,7 @@ function CanvasView({ engine, board, viewId, renderers }: { engine: ProjectEngin
|
||||
<HeadlessImports engine={engine} canvasId={view.canvasId} />
|
||||
{showAI && aiElement && <HeadlessAIPanel key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
|
||||
{showRecovery && <HeadlessRecoveryDialog engine={engine} close={() => setShowRecovery(false)} />}
|
||||
{error && <div className={styles.error} role="alert">{error} <button onClick={() => setError('')}>关闭</button></div>}
|
||||
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
||||
</div>;
|
||||
}
|
||||
export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers }) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Id } from '../canvas-core/types.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
import type { DisplayHandle } from '../canvas-runtime/resources.ts';
|
||||
|
||||
type Record = { readonly resource: Id; readonly controller: AbortController; handle?: DisplayHandle; failed: boolean };
|
||||
export type ResourceViewSource = { readonly resourceId: Id; readonly url: string };
|
||||
/** A view owns subscriptions/leases, never a resource or document store. Retain
|
||||
* unchanged nodes, publish each ready source independently, and fence late work.
|
||||
*/
|
||||
export function createResourceView(options: {
|
||||
resolve(resource: Id, signal: AbortSignal): Promise<DisplayHandle>;
|
||||
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
|
||||
onError?(error: unknown): void;
|
||||
}) {
|
||||
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), disposed = false;
|
||||
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } };
|
||||
const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } };
|
||||
const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } };
|
||||
const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); };
|
||||
function sync(next: readonly (readonly [Id, Id])[]) {
|
||||
if (disposed) throw fault('resource_view_disposed');
|
||||
const desired = new Map<Id, Id>();
|
||||
for (const [id, resource] of next) {
|
||||
if (typeof id !== 'string' || !id || typeof resource !== 'string' || !resource || desired.has(id)) throw fault('invalid_resource_view');
|
||||
desired.set(id, resource);
|
||||
}
|
||||
targets = desired; let changed = false;
|
||||
for (const [id, record] of records) if (desired.get(id) !== record.resource) { records.delete(id); release(record); changed = true; }
|
||||
for (const [id, resource] of desired) if (!records.has(id)) {
|
||||
const record: Record = { resource, controller: new AbortController(), failed: false }; records.set(id, record); changed = true;
|
||||
void Promise.resolve().then(() => {
|
||||
if (disposed || records.get(id) !== record) throw fault('display_aborted');
|
||||
return options.resolve(resource, record.controller.signal);
|
||||
}).then(handle => {
|
||||
if (disposed || records.get(id) !== record) { releaseHandle(handle); return; }
|
||||
record.handle = handle; publish();
|
||||
}, error => {
|
||||
if (disposed || records.get(id) !== record || record.controller.signal.aborted) return;
|
||||
record.failed = true; publish(); report(error);
|
||||
});
|
||||
}
|
||||
if (changed) publish();
|
||||
}
|
||||
return Object.freeze({
|
||||
sync,
|
||||
retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); },
|
||||
dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
|
||||
export type DisplaySource = { readonly url: string; release?(): void };
|
||||
export type DisplayLease = { readonly ready: Promise<string>; release(): void };
|
||||
type Entry = { readonly key: string; readonly owner: object; readonly controller: AbortController; readonly leases: Set<() => void>; promise: Promise<DisplaySource>; source?: DisplaySource; retired: boolean };
|
||||
|
||||
/** Ephemeral display ownership only. The caller checks authorization and pins
|
||||
* durable bytes before acquiring. Scope objects are never serialized or reused
|
||||
* across project engines/auth epochs. There is no idle URL retention.
|
||||
*/
|
||||
export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) {
|
||||
const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>();
|
||||
let closed = false;
|
||||
function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } }
|
||||
function retire(entry: Entry) {
|
||||
if (entry.retired) return;
|
||||
entry.retired = true;
|
||||
const entries = owners.get(entry.owner);
|
||||
if (entries?.get(entry.key) === entry) { entries.delete(entry.key); if (!entries.size) owners.delete(entry.owner); }
|
||||
entry.controller.abort();
|
||||
if (entry.source) { dispose(entry.source); entry.source = undefined; }
|
||||
}
|
||||
function releaseOwner(owner: object) {
|
||||
retired.add(owner);
|
||||
for (const entry of [...(owners.get(owner)?.values() ?? [])]) {
|
||||
for (const release of [...entry.leases]) release();
|
||||
retire(entry);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
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');
|
||||
let entries = owners.get(owner); if (!entries) { entries = new Map(); owners.set(owner, entries); }
|
||||
let entry = entries.get(key);
|
||||
if (!entry) {
|
||||
const created: Entry = { owner, key, controller: new AbortController(), leases: new Set(), retired: false, promise: undefined as unknown as Promise<DisplaySource> };
|
||||
entry = created; entries.set(key, created);
|
||||
created.promise = Promise.resolve().then(() => {
|
||||
if (created.retired) throw fault('display_released');
|
||||
return load(created.controller.signal);
|
||||
}).then(source => {
|
||||
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'); }
|
||||
if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); }
|
||||
created.source = source; return source;
|
||||
}, error => { retire(created); throw error; });
|
||||
}
|
||||
const shared = entry;
|
||||
let released = false, rejectReady: (error: unknown) => void = () => {};
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true; shared.leases.delete(release); rejectReady(fault('display_released'));
|
||||
if (!shared.leases.size) retire(shared);
|
||||
};
|
||||
shared.leases.add(release);
|
||||
const ready = new Promise<string>((resolve, reject) => {
|
||||
rejectReady = reject;
|
||||
shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } });
|
||||
});
|
||||
return Object.freeze({ ready, release });
|
||||
},
|
||||
releaseOwner,
|
||||
close() { if (closed) return; closed = true; for (const owner of [...owners.keys()]) releaseOwner(owner); },
|
||||
});
|
||||
}
|
||||
export type DisplayCache = ReturnType<typeof createDisplayCache>;
|
||||
@@ -2,6 +2,7 @@ import {validImageDimensions,type ImageDecoderPort} from './image-decoder.ts';
|
||||
import {unitScale} from '../canvas-core/units.ts';
|
||||
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
|
||||
import { captureDefaultImageSizing, prepareDefaultImageSizing } from './default-image-size.ts';
|
||||
import { createDisplayCache, type DisplayCache, type DisplayLease } from './display-cache.ts';
|
||||
import {orphanCoverage,prepareOrphanFiles,abortableFileRead,type OrphanFile} from './orphan-files.ts';
|
||||
import {importFiles,terminalImport,markImportFiles,prepareImportFileRetention,type FileRetirement} from './import-file-retention.ts';
|
||||
import {resourceRetentionPins,prepareResourceRetention} from './resource-retention.ts';
|
||||
@@ -35,9 +36,10 @@ import type { StoragePort, StorageScope } from './storage.ts';
|
||||
const digestFailures=new Set(['digest_capacity','digest_worker_failed','digest_worker_unavailable','invalid_digest_response','digest_pool_closed']);
|
||||
export type EngineOptions = {
|
||||
scope: StorageScope; registry: Registry; document: Document; versions: ReadonlyMap<Id, number>;
|
||||
storage: StoragePort; images?:Pick<ImageDecoderPort,'decode'>; digests?:Pick<DigestPort,'digest'>; persistence: PersistencePort; assets?: AssetPort; runs?: RunPort; createId?: () => Id;
|
||||
storage: StoragePort; displays?: DisplayCache; images?:Pick<ImageDecoderPort,'decode'>; digests?:Pick<DigestPort,'digest'>; persistence: PersistencePort; assets?: AssetPort; runs?: RunPort; createId?: () => Id;
|
||||
historyLimit?: number; queryBudget?:EditorOptions['queryBudget']; policy?: EditorOptions['policy']; traversalPolicy?: EditorOptions['traversalPolicy']; clock?: Clock;
|
||||
onError?: (error: unknown) => void;
|
||||
isCurrentIdentity?: () => boolean;
|
||||
};
|
||||
export type RecoverySession = {
|
||||
readonly baseRootRevision: number;
|
||||
@@ -68,6 +70,8 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const network = createNetworkGate(2);
|
||||
const runClock: Clock = options.clock ?? { now: () => Date.now(), setTimer: (fn, delay) => setTimeout(fn, delay), clearTimer: handle => clearTimeout(handle as ReturnType<typeof setTimeout>) };
|
||||
const displays = new Set<() => void>();
|
||||
const displayOwner = Object.freeze({}), displayCache = options.displays ?? createDisplayCache({ onError: options.onError });
|
||||
const assertDisplayIdentity=()=>{if(closing||closed)throw fault('engine_closing');if(options.isCurrentIdentity&&!options.isCurrentIdentity())throw fault('identity_changed');};
|
||||
const displayPins=new Map<Id,number>(),collectionReservations=new Set<Id>(),durablyPurged=new Set<Id>([...(recovered?.runtime.resources.values()??[])].filter(resource=>resource.retirement?.purgedAt!==undefined).map(resource=>resource.id));
|
||||
type CollectionReport={readonly markedIds:readonly Id[];readonly purgedIds:readonly Id[];readonly complete:boolean;readonly checkedRootRevision:number;readonly rootRevision:number;readonly durableRootRevision:number};
|
||||
let sourceCollection:Promise<CollectionReport>|undefined;
|
||||
@@ -830,14 +834,16 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
return result;
|
||||
} finally { staged = []; stagedDefaultSizes.clear();for (const resource of resources) if (!editor.getSnapshot().runtime.resources.has(resource.id)) fileWrites.delete(resource.id); }
|
||||
},
|
||||
async displayResource(id: Id) {
|
||||
if (closing || closed) throw fault('engine_closing');
|
||||
async displayResource(id: Id, settings: { signal?: AbortSignal } = {}) {
|
||||
assertDisplayIdentity();
|
||||
if(collectionReservations.has(id))throw fault('resource_collection_in_progress',[id]);
|
||||
displayPins.set(id,(displayPins.get(id)??0)+1);
|
||||
let released=false,url:string|undefined,local=false;
|
||||
const release=()=>{if(released)return;released=true;displays.delete(release);const count=displayPins.get(id)??1;if(count<=1)displayPins.delete(id);else displayPins.set(id,count-1);if(local&&url)URL.revokeObjectURL(url);};
|
||||
let released=false, lease: DisplayLease | undefined;
|
||||
const release=()=>{if(released)return;released=true;settings.signal?.removeEventListener('abort',release);displays.delete(release);const count=displayPins.get(id)??1;if(count<=1)displayPins.delete(id);else displayPins.set(id,count-1);lease?.release();};
|
||||
displays.add(release);
|
||||
settings.signal?.addEventListener('abort',release,{once:true});
|
||||
try{
|
||||
if(settings.signal?.aborted)throw fault('display_aborted');
|
||||
const retained=editor.getSnapshot().runtime.resources.get(id);
|
||||
const orphan=editor.getSnapshot().runtime.orphanFiles?.get(id);if(orphan?.retirement?.purgedAt!==undefined)throw fault('local_file_retired',[id]);
|
||||
if(orphan?.retirement){const result=editor.updateRuntime(previous=>Object.freeze({...previous}));if(result.status==='rejected')throw fault(result.code,result.targets);}
|
||||
@@ -845,21 +851,28 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const result=editor.updateRuntime(previous=>{const resources=new Map(previous.resources),{retirement:_,...kept}=retained;resources.set(id,freeze(kept));return Object.freeze({...previous,resources:readMap(resources)});});
|
||||
if(result.status==='rejected')throw fault(result.code,result.targets);
|
||||
}
|
||||
const source=!retained?importFiles(editor.getSnapshot().runtime.imports).get(id)?.file:undefined;
|
||||
if(!retained){
|
||||
const source=importFiles(editor.getSnapshot().runtime.imports).get(id)?.file;
|
||||
if(source?.retirement?.purgedAt!==undefined)throw fault('import_source_retired',[id]);
|
||||
if(source?.retirement){const result=editor.updateRuntime(previous=>Object.freeze({...previous}));if(result.status==='rejected')throw fault(result.code,result.targets);}
|
||||
}
|
||||
const bytes = fileWrites.get(id) ?? await options.storage.readFile(leader, id);
|
||||
if (closing || closed) throw fault('engine_closing');
|
||||
if(orphan&&(!bytes||bytes.size!==orphan.byteLength||bytes.type!==orphan.mimeType||await sourceDigest(bytes)!==orphan.digest))throw fault('file_inventory_changed',[id]);
|
||||
if(closing||closed)throw fault('engine_closing');
|
||||
const resource=editor.getSnapshot().runtime.resources.get(id);
|
||||
if(!bytes&&resource?.retirement?.purgedAt!==undefined&&!resource.stored)throw fault('resource_bytes_unavailable',[id]);
|
||||
if (!bytes && !options.assets) throw fault('resource_missing', [id]);
|
||||
local=!!bytes;url=bytes?URL.createObjectURL(bytes):options.assets!.displayURL(id);
|
||||
if(released)throw fault('display_aborted');
|
||||
const key=JSON.stringify(retained?['asset',id,retained.operationId,retained.retirement?.purgedAt??null]:orphan?['orphan',id,orphan.digest]:source?['source',id,source.sha256??null]:['unmanaged',id]);
|
||||
lease=displayCache.acquire(displayOwner,key,async signal=>{
|
||||
const bytes=fileWrites.get(id)??await options.storage.readFile(leader,id);
|
||||
assertDisplayIdentity();if(signal.aborted)throw fault('display_aborted');
|
||||
if(orphan&&(!bytes||bytes.size!==orphan.byteLength||bytes.type!==orphan.mimeType||await sourceDigest(bytes,signal)!==orphan.digest))throw fault('file_inventory_changed',[id]);
|
||||
assertDisplayIdentity();if(signal.aborted)throw fault('display_aborted');
|
||||
const resource=editor.getSnapshot().runtime.resources.get(id);
|
||||
if(!bytes&&resource?.retirement?.purgedAt!==undefined&&!resource.stored)throw fault('resource_bytes_unavailable',[id]);
|
||||
if(!bytes&&!options.assets)throw fault('resource_missing',[id]);
|
||||
const url=bytes?URL.createObjectURL(bytes):options.assets!.displayURL(id);
|
||||
return Object.freeze({url,...(bytes?{release:()=>URL.revokeObjectURL(url)}:{})});
|
||||
});
|
||||
const url=await lease.ready;
|
||||
assertDisplayIdentity();if(released||settings.signal?.aborted)throw fault('display_aborted');
|
||||
return Object.freeze({url,release});
|
||||
}catch(error){release();throw error;}
|
||||
}catch(error){release();assertDisplayIdentity();if(settings.signal?.aborted)throw fault('display_aborted');throw error;}
|
||||
},
|
||||
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;
|
||||
@@ -878,7 +891,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
closing = true;unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
|
||||
await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);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();
|
||||
for (const release of [...displays]) release();
|
||||
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.
|
||||
await persist(); unsubscribe(); editor.dispose(); await leader.release(); closed = true;
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fault, readMap } from '../canvas-core/values.ts';
|
||||
import { openProjectEngine, type EngineOptions } from './engine.ts';
|
||||
import {createDigestPool,type DigestPort} from './digest-pool.ts';
|
||||
import type { StoragePort } from './storage.ts';
|
||||
import { createDisplayCache } from './display-cache.ts';
|
||||
|
||||
export type ProjectEngine = Awaited<ReturnType<typeof openProjectEngine>>;
|
||||
export type WorkspaceOptions = {
|
||||
@@ -15,6 +16,7 @@ export type WorkspaceOptions = {
|
||||
/** Workspace owns persistent projects; a view only borrows one and never closes it. */
|
||||
export function createWorkspaceRuntime(options: WorkspaceOptions) {
|
||||
const digests=options.digests??createDigestPool();
|
||||
const displays=createDisplayCache({onError:options.onError});
|
||||
const projects = new Map<Id, Promise<ProjectEngine>>();
|
||||
let accepting = true, closed = false, closing: Promise<void> | undefined;
|
||||
const current = () => accepting && !closed && options.isCurrentIdentity();
|
||||
@@ -26,7 +28,7 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
|
||||
if (!current()) return Promise.reject(fault('identity_changed'));
|
||||
const existing = projects.get(document.projectId);
|
||||
if (existing) return existing.then(engine => { if (!current()) throw fault('identity_changed'); engine.refresh(document, versions); return engine; });
|
||||
const opening = openProjectEngine({ scope: { accountId: options.accountId, workspaceId: options.workspaceId, projectId: document.projectId }, registry: options.registry, document, versions, storage: options.storage, digests, images:options.images, queryBudget:options.queryBudget, ...options.adapters(document.projectId, current), onError: options.onError,
|
||||
const opening = openProjectEngine({ scope: { accountId: options.accountId, workspaceId: options.workspaceId, projectId: document.projectId }, registry: options.registry, document, versions, storage: options.storage, displays, digests, images:options.images, queryBudget:options.queryBudget, ...options.adapters(document.projectId, current), onError: options.onError,isCurrentIdentity:current,
|
||||
traversalPolicy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
|
||||
policy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
|
||||
});
|
||||
@@ -46,7 +48,7 @@ export function createWorkspaceRuntime(options: WorkspaceOptions) {
|
||||
}));
|
||||
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
|
||||
if (errors.length) throw new AggregateError(errors, 'workspace_close_failed');
|
||||
digests.close();options.images?.close();options.storage.close(); projects.clear(); closed = true;
|
||||
displays.close();digests.close();options.images?.close();options.storage.close(); projects.clear(); closed = true;
|
||||
})();
|
||||
closing = task.finally(() => { closing = undefined; });
|
||||
return closing;
|
||||
|
||||
Reference in New Issue
Block a user