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;
|
||||
|
||||
@@ -10,9 +10,28 @@ import { createDocument, createRegistry, builtinComponents, applyChanges, canvas
|
||||
import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts';
|
||||
import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts';
|
||||
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
|
||||
import { createResourceView } from '../../src/lib/canvas-bindings/resource-view.ts';
|
||||
|
||||
async function sharedDisplayReadersCase(engine, storage, assert) {
|
||||
const resource = engine.getSnapshot().document.elements.get('picture').data.assetId, read = storage.readFile, createURL = URL.createObjectURL, revokeURL = URL.revokeObjectURL;
|
||||
const revision = engine.getSnapshot().rootRevision, errors = []; let reads = 0, created = 0, revoked = 0, first = new Map(), second = new Map();
|
||||
const a = createResourceView({ resolve: (id, signal) => engine.displayResource(id, { signal }), onChange: sources => { first = sources; }, onError: error => errors.push(error) });
|
||||
const b = createResourceView({ resolve: (id, signal) => engine.displayResource(id, { signal }), onChange: sources => { second = sources; }, onError: error => errors.push(error) });
|
||||
const wait = async predicate => { for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 5)); } throw Error('shared display did not settle'); };
|
||||
try {
|
||||
storage.readFile = (...args) => { reads++; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL.call(URL, bytes); }; URL.revokeObjectURL = url => { revoked++; return revokeURL.call(URL, url); };
|
||||
a.sync([['picture', resource]]); await wait(() => first.has('picture')); const url = first.get('picture').url;
|
||||
b.sync([['other-view', resource]]); await wait(() => second.has('other-view')); assert(second.get('other-view').url === url && reads === 1 && created === 1, 'two views did not share one IndexedDB read and Blob URL');
|
||||
a.sync([['picture', resource], ['copy', resource]]); await wait(() => first.has('copy')); assert(first.get('copy').url === url && reads === 1 && created === 1 && revoked === 0, 'adding an image reference replaced existing display handles');
|
||||
a.dispose(); assert(revoked === 0 && (await (await fetch(url)).arrayBuffer()).byteLength > 0, 'closing one view revoked another view image');
|
||||
assert(engine.getResourcePins().pins.get(resource).has('display'), 'remaining view lost its display pin'); b.dispose(); assert(revoked === 1 && !engine.getResourcePins().pins.get(resource).has('display'), 'last view did not release its URL and display pin');
|
||||
const fresh = await engine.displayResource(resource); assert(fresh.url !== url && reads === 2 && created === 2, 'released URL was retained as an idle cache'); fresh.release(); assert(revoked === 2, 'fresh handle did not revoke');
|
||||
assert(errors.length === 0 && engine.getSnapshot().rootRevision === revision, 'display caching changed the business Root or reported a reader failure');
|
||||
} finally { a.dispose(); b.dispose(); storage.readFile = read; URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; }
|
||||
}
|
||||
|
||||
async function defaultImageSizeCase(name, assert) {
|
||||
const storage = createBrowserStorage({ name: `${name}-default-size`, checkpointEvery: 3 }), images = createBrowserImageDecoder();
|
||||
const storage = {...createBrowserStorage({ name: `${name}-default-size`, checkpointEvery: 3 })}, images = createBrowserImageDecoder();
|
||||
const scope = { accountId: 'sizing-account', workspaceId: 'sizing-workspace', projectId: 'sizing-project' }, registry = createRegistry(builtinComponents());
|
||||
const original = createDocument(scope.projectId, [{ id: 'root', parent: null }], []), receipts = new Map(), writes = [], queries = [], uploads = [];
|
||||
let server = original, version = 1, next = 0, engine;
|
||||
@@ -34,6 +53,7 @@ async function defaultImageSizeCase(name, assert) {
|
||||
engine = await openProjectEngine(options); await engine.settle();
|
||||
assert(queries.some(query => query.id === request.id && query.body === body), 'lost size receipt did not query the frozen original request');
|
||||
assert(writes.filter(write => write.id === request.id).length === 1, 'lost size receipt repeated the write');
|
||||
await sharedDisplayReadersCase(engine, storage, assert);
|
||||
assert(engine.editor.history.undo().status === 'applied', 'sized creation could not undo after reopening'); await engine.settle(); assert(!server.elements.has('picture'), 'undo left the server image behind');
|
||||
assert(engine.editor.history.redo().status === 'applied', 'sized creation could not redo after reopening'); await engine.settle();
|
||||
assert(server.elements.get('picture').incarnation === 2 && server.elements.get('picture').geometry.height === 130, 'redo lost size or reused a deleted incarnation'); assert(uploads.length === 1, 'size recovery repeated the upload');
|
||||
@@ -708,7 +728,7 @@ export async function run() {
|
||||
await resourceRecoveryCase(name, assert);
|
||||
results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity');
|
||||
await defaultImageSizeCase(name, assert);
|
||||
results.push('real image Worker enriches creation history in recovery v6; IndexedDB reopening queries a lost size ACK with the original body, then undo/redo preserves size and upload identity');
|
||||
results.push('real image Worker enriches creation history in recovery v6; IndexedDB reopening queries a lost size ACK; concurrent views share one read/URL, retain leases incrementally and release only the final display pin before sized undo/redo');
|
||||
await imageDecodeWorkerCase(assert);
|
||||
results.push('real module image workers decode intrinsic PNG dimensions with bounded concurrency, reject bad bytes, abort safely and release their bitmap workers');
|
||||
await digestWorkerCase(assert);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDisplayCache } from '../../src/lib/canvas-runtime/display-cache.ts';
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('concurrent display leases share one source until the last idempotent release; there is no idle URL retention', async () => {
|
||||
const cache = createDisplayCache(), owner = {}; let loads = 0, disposed = 0;
|
||||
const load = async () => ({ url: `blob:${++loads}`, release: () => disposed++ });
|
||||
const a = cache.acquire(owner, 'asset', load), b = cache.acquire(owner, 'asset', load);
|
||||
assert.deepEqual(await Promise.all([a.ready, b.ready]), ['blob:1', 'blob:1']); assert.equal(loads, 1);
|
||||
a.release(); a.release(); assert.equal(disposed, 0); b.release(); assert.equal(disposed, 1);
|
||||
const c = cache.acquire(owner, 'asset', load); assert.equal(await c.ready, 'blob:2'); c.release(); cache.close(); assert.equal(disposed, 2);
|
||||
});
|
||||
test('cancelling one waiting lease does not abort a shared load or another reader', async () => {
|
||||
const cache = createDisplayCache(), owner = {}, gate = deferred(); let signal, disposed = 0;
|
||||
const load = async value => { signal = value; return gate.promise; };
|
||||
const a = cache.acquire(owner, 'asset', load), b = cache.acquire(owner, 'asset', load); await tick();
|
||||
const cancelled = assert.rejects(a.ready, { code: 'display_released' }); a.release(); await cancelled; assert.equal(signal.aborted, false);
|
||||
gate.resolve({ url: 'blob:shared', release: () => disposed++ }); assert.equal(await b.ready, 'blob:shared'); b.release(); assert.equal(signal.aborted, true); assert.equal(disposed, 1);
|
||||
});
|
||||
test('last release aborts a load; a late source cannot replace or evict a newly acquired entry', async () => {
|
||||
const cache = createDisplayCache(), owner = {}, gate = deferred(); let signal, oldDisposed = 0, newDisposed = 0;
|
||||
const old = cache.acquire(owner, 'asset', async value => { signal = value; return gate.promise; }); await tick();
|
||||
const cancelled = assert.rejects(old.ready, { code: 'display_released' }); old.release(); await cancelled; assert.equal(signal.aborted, true);
|
||||
const next = cache.acquire(owner, 'asset', async () => ({ url: 'blob:new', release: () => newDisposed++ })); assert.equal(await next.ready, 'blob:new');
|
||||
gate.resolve({ url: 'blob:old', release: () => oldDisposed++ }); await tick(); assert.equal(oldDisposed, 1); assert.equal(newDisposed, 0);
|
||||
const peer = cache.acquire(owner, 'asset', async () => { throw Error('unexpected duplicate read'); }); assert.equal(await peer.ready, 'blob:new'); peer.release(); next.release(); assert.equal(newDisposed, 1);
|
||||
});
|
||||
test('read failure retains its cause and removes the failed cache entry for a fresh retry', async () => {
|
||||
const cache = createDisplayCache(), owner = {};
|
||||
const a = cache.acquire(owner, 'asset', async () => { throw Object.assign(Error('disk_failed'), { code: 'disk_failed' }); });
|
||||
await assert.rejects(a.ready, { code: 'disk_failed' });
|
||||
const b = cache.acquire(owner, 'asset', async () => ({ url: '/remote' })); assert.equal(await b.ready, '/remote'); b.release(); cache.close();
|
||||
});
|
||||
test('project owner scopes isolate identical IDs and retired owners cannot reacquire', async () => {
|
||||
const cache = createDisplayCache(), a = {}, b = {}; let disposed = 0;
|
||||
const first = cache.acquire(a, 'same-id', async () => ({ url: 'blob:a', release: () => disposed++ }));
|
||||
const second = cache.acquire(b, 'same-id', async () => ({ url: 'blob:b', release: () => disposed++ }));
|
||||
assert.deepEqual(await Promise.all([first.ready, second.ready]), ['blob:a', 'blob:b']); cache.releaseOwner(a); assert.equal(disposed, 1);
|
||||
assert.throws(() => cache.acquire(a, 'same-id', async () => ({ url: '/forbidden' })), { code: 'display_scope_closed' });
|
||||
const peer = cache.acquire(b, 'same-id', async () => { throw Error('unexpected'); }); assert.equal(await peer.ready, 'blob:b'); peer.release(); second.release(); assert.equal(disposed, 2);
|
||||
});
|
||||
test('cache close rejects waiting readers immediately and disposes late data without reopening a scope', async () => {
|
||||
const cache = createDisplayCache(), gate = deferred(); let disposed = 0;
|
||||
const lease = cache.acquire({}, 'asset', () => gate.promise); await tick();
|
||||
const cancelled = assert.rejects(lease.ready, { code: 'display_released' }); cache.close(); await cancelled;
|
||||
gate.resolve({ url: 'blob:late', release: () => disposed++ }); await tick(); assert.equal(disposed, 1);
|
||||
assert.throws(() => cache.acquire({}, 'asset', async () => ({ url: '/late' })), { code: 'display_scope_closed' }); cache.close();
|
||||
});
|
||||
test('invalid sources clean their allocation and cleanup errors do not interrupt other owners', async () => {
|
||||
const errors = [], cache = createDisplayCache({ onError: error => errors.push(error) }); let disposed = 0;
|
||||
const invalid = cache.acquire({}, 'invalid', async () => ({ url: '', release: () => disposed++ })); await assert.rejects(invalid.ready, { code: 'invalid_display_source' }); assert.equal(disposed, 1);
|
||||
const a = cache.acquire({}, 'a', async () => ({ url: 'blob:a', release: () => { throw Error('cleanup_failed'); } }));
|
||||
const b = cache.acquire({}, 'b', async () => ({ url: 'blob:b', release: () => disposed++ })); await Promise.all([a.ready, b.ready]); cache.close(); assert.equal(disposed, 2); assert.equal(errors[0].message, 'cleanup_failed');
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createResourceView } from '../../src/lib/canvas-bindings/resource-view.ts';
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
function fixture() {
|
||||
const requests = [], updates = [], errors = [], released = [];
|
||||
const view = createResourceView({ resolve(resource, signal) { const gate = deferred(); requests.push({ resource, signal, gate }); return gate.promise; }, onChange(sources, failures) { updates.push({ sources, failures }); }, onError: error => errors.push(error) });
|
||||
const finish = (index, url = `blob:${index}`) => { let disposed = false; requests[index].gate.resolve({ url, release() { if (!disposed) { disposed = true; released.push(index); } } }); };
|
||||
return { view, requests, updates, errors, released, finish, latest: () => updates.at(-1) };
|
||||
}
|
||||
test('ready images display individually; adding a node preserves existing handles and reads', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); await tick();
|
||||
assert.deepEqual([...f.latest().sources], [['a', { resourceId: 'r1', url: 'blob:0' }]]);
|
||||
f.view.sync([['a', 'r1'], ['b', 'r2'], ['c', 'r3']]); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r3']); assert.deepEqual(f.released, []);
|
||||
f.finish(2); await tick(); assert.equal(f.latest().sources.size, 2); f.finish(1); await tick(); assert.equal(f.latest().sources.size, 3); f.view.dispose(); assert.deepEqual(f.released.sort(), [0, 1, 2]);
|
||||
});
|
||||
test('a failed reader does not hide other ready images and retry touches only failures', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.requests[1].gate.reject(Error('read_failed')); await tick();
|
||||
assert.equal(f.latest().sources.get('a').url, 'blob:0'); assert.equal(f.latest().failures, 1); assert.equal(f.errors.length, 1);
|
||||
f.view.retry(); await tick(); assert.deepEqual(f.requests.map(request => request.resource), ['r1', 'r2', 'r2']); assert.deepEqual(f.released, []);
|
||||
f.finish(2); await tick(); assert.equal(f.latest().failures, 0); assert.equal(f.latest().sources.size, 2); f.view.dispose();
|
||||
});
|
||||
test('replacement aborts the old reader and fences a late result that ignored cancellation', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'old']]); await tick(); f.view.sync([['a', 'new']]); await tick(); assert.equal(f.requests[0].signal.aborted, true);
|
||||
f.finish(1, 'blob:new'); await tick(); f.finish(0, 'blob:old'); await tick();
|
||||
assert.deepEqual(f.latest().sources.get('a'), { resourceId: 'new', url: 'blob:new' }); assert.deepEqual(f.released, [0]); f.view.dispose(); assert.deepEqual(f.released, [0, 1]);
|
||||
});
|
||||
test('removing one loaded node retains the other; repeated disposal does not release twice', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1'], ['b', 'r2']]); await tick(); f.finish(0); f.finish(1); await tick();
|
||||
f.view.sync([['b', 'r2']]); assert.deepEqual(f.released, [0]); assert.equal(f.latest().sources.get('b').url, 'blob:1');
|
||||
f.view.dispose(); f.view.dispose(); assert.deepEqual(f.released, [0, 1]); assert.equal(f.latest().sources.size, 0); assert.throws(() => f.view.retry(), { code: 'resource_view_disposed' });
|
||||
});
|
||||
test('disposal cleans late handles without publishing into a retired view or reporting cancellation as an error', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.view.dispose(); const count = f.updates.length; f.finish(0); await tick();
|
||||
assert.equal(f.updates.length, count); assert.deepEqual(f.released, [0]); assert.deepEqual(f.errors, []);
|
||||
});
|
||||
test('invalid target lists reject before changing active subscriptions', async () => {
|
||||
const f = fixture(); f.view.sync([['a', 'r1']]); await tick(); f.finish(0); await tick();
|
||||
assert.throws(() => f.view.sync([['a', 'r2'], ['a', 'r3']]), { code: 'invalid_resource_view' }); assert.deepEqual(f.released, []);
|
||||
f.view.sync([['a', 'r1']]); await tick(); assert.equal(f.requests.length, 1); assert.equal(f.latest().sources.get('a').resourceId, 'r1'); f.view.dispose();
|
||||
});
|
||||
@@ -326,6 +326,59 @@ test('a display pins before its asynchronous read and resets retirement; releasi
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
test('shared display reads and URLs retain byte protection until the last independent reader releases', async () => {
|
||||
const f = fixture(), engine = await f.open({ historyLimit: 1 }), ref = addImage(engine), gate = deferred(), entered = deferred();
|
||||
const read = f.storage.readFile, createURL = URL.createObjectURL, revokeURL = URL.revokeObjectURL;
|
||||
let reads = 0, created = 0, revoked = 0;
|
||||
try {
|
||||
await engine.settle(); await unreferenceImage(engine);
|
||||
f.storage.readFile = async (...args) => { reads++; entered.resolve(); await gate.promise; return read(...args); };
|
||||
URL.createObjectURL = bytes => { created++; return createURL(bytes); }; URL.revokeObjectURL = url => { revoked++; return revokeURL(url); };
|
||||
const cancel = new AbortController(), first = engine.displayResource(ref.id, { signal: cancel.signal }), second = engine.displayResource(ref.id);
|
||||
await entered.promise; assert.equal(reads, 1);
|
||||
const cancelled = assert.rejects(first, { code: 'display_aborted' }); cancel.abort(); await cancelled;
|
||||
assert.ok(engine.getResourcePins().pins.get(ref.id).has('display')); assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, []);
|
||||
gate.resolve(); const b = await second, c = await engine.displayResource(ref.id);
|
||||
assert.equal(b.url, c.url); assert.equal(created, 1); assert.equal(reads, 1);
|
||||
b.release(); b.release(); assert.equal(revoked, 0); assert.ok(engine.getResourcePins().pins.get(ref.id).has('display'));
|
||||
c.release(); assert.equal(revoked, 1); assert.equal(engine.getResourcePins().pins.has(ref.id), false);
|
||||
assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, [ref.id]);
|
||||
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; }
|
||||
});
|
||||
|
||||
test('project close releases a pending display immediately and never creates a URL from a late read', async () => {
|
||||
const f = fixture(), engine = await f.open(), ref = addImage(engine), gate = deferred(), entered = deferred(), read = f.storage.readFile, createURL = URL.createObjectURL;
|
||||
let created = 0;
|
||||
try {
|
||||
await engine.settle(); f.storage.readFile = async (...args) => { entered.resolve(); await gate.promise; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL(bytes); };
|
||||
const pending = engine.displayResource(ref.id); await entered.promise;
|
||||
const rejected = assert.rejects(pending, { code: 'engine_closing' }); await engine.close(); await rejected;
|
||||
assert.equal(created, 0); gate.resolve(); await new Promise(resolve => setImmediate(resolve)); assert.equal(created, 0);
|
||||
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
||||
});
|
||||
|
||||
test('display failures retain their cause and a fresh reader can retry without uploading again', async () => {
|
||||
const f = fixture(), engine = await f.open(), ref = addImage(engine), read = f.storage.readFile;
|
||||
try {
|
||||
await engine.settle(); f.storage.readFile = async () => { throw Object.assign(Error('disk_failed'), { code: 'disk_failed' }); };
|
||||
await assert.rejects(engine.displayResource(ref.id), { code: 'disk_failed' });
|
||||
assert.equal([...engine.getResourcePins().pins.get(ref.id)].includes('display'), false);
|
||||
f.storage.readFile = read; const handle = await engine.displayResource(ref.id); assert.match(handle.url, /^blob:/); handle.release(); assert.equal(f.uploads.length, 1);
|
||||
} finally { await engine.close(); }
|
||||
});
|
||||
|
||||
test('identity retirement fences new readers and discards a pending local read before creating its URL', async () => {
|
||||
const f = fixture(); let current = true;
|
||||
const engine = await f.open({ isCurrentIdentity: () => current }), ref = addImage(engine), read = f.storage.readFile, gate = deferred(), entered = deferred();
|
||||
const createURL = URL.createObjectURL; let created = 0;
|
||||
try {
|
||||
await engine.settle(); f.storage.readFile = async (...args) => { entered.resolve(); await gate.promise; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL(bytes); };
|
||||
const pending = engine.displayResource(ref.id); await entered.promise; current = false;
|
||||
await assert.rejects(engine.displayResource(ref.id), { code: 'identity_changed' }); const rejected = assert.rejects(pending, { code: 'identity_changed' }); gate.resolve(); await rejected;
|
||||
assert.equal(created, 0); assert.equal([...engine.getResourcePins().pins.get(ref.id)].includes('display'), false);
|
||||
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
||||
});
|
||||
|
||||
test('only recollection of the reserved resource is fenced during deletion; unrelated editing stays available',async()=>{
|
||||
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),gate=deferred(),entered=deferred(),commit=f.storage.commit;try{
|
||||
await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(args[1].deleteFiles?.length){entered.resolve();await gate.promise;}return commit(...args);};
|
||||
|
||||
Reference in New Issue
Block a user