From 03c3a0dc669b7c27dd28edb4cb6c56a406129822 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 00:46:34 +0800 Subject: [PATCH] Share scoped display leases and retain incremental resource views --- .../canvas/headless-graph-canvas.tsx | 24 +++---- src/lib/canvas-bindings/resource-view.ts | 49 ++++++++++++++ src/lib/canvas-runtime/display-cache.ts | 66 +++++++++++++++++++ src/lib/canvas-runtime/engine.ts | 45 ++++++++----- src/lib/canvas-runtime/workspace.ts | 6 +- tests/headless/browser-storage-cases.mjs | 24 ++++++- tests/headless/display-cache.test.mjs | 56 ++++++++++++++++ tests/headless/resource-view.test.mjs | 42 ++++++++++++ tests/headless/resources.test.mjs | 53 +++++++++++++++ 9 files changed, 333 insertions(+), 32 deletions(-) create mode 100644 src/lib/canvas-bindings/resource-view.ts create mode 100644 src/lib/canvas-runtime/display-cache.ts create mode 100644 tests/headless/display-cache.test.mjs create mode 100644 tests/headless/resource-view.test.mjs diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index da9ce0d..838f52b 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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()); + const [error, setError] = useState(""); + const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0}); + const resourceView=useRef|null>(null); const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map } | null>(null); const projectionCache = useRef | 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();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 {showAI && aiElement && setShowAI(false)} />} {showRecovery && setShowRecovery(false)} />} - {error &&
{error}
} + {error &&
{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&}
} ; } export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers }) { diff --git a/src/lib/canvas-bindings/resource-view.ts b/src/lib/canvas-bindings/resource-view.ts new file mode 100644 index 0000000..6e1ecc6 --- /dev/null +++ b/src/lib/canvas-bindings/resource-view.ts @@ -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; + onChange(sources: Map, failures: number): void; + onError?(error: unknown): void; +}) { + const records = new Map(); let targets = new Map(), 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(); + 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(); }, + }); +} diff --git a/src/lib/canvas-runtime/display-cache.ts b/src/lib/canvas-runtime/display-cache.ts new file mode 100644 index 0000000..fb37749 --- /dev/null +++ b/src/lib/canvas-runtime/display-cache.ts @@ -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; release(): void }; +type Entry = { readonly key: string; readonly owner: object; readonly controller: AbortController; readonly leases: Set<() => void>; promise: Promise; 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>(), retired = new WeakSet(); + 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): 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 }; + 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((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; diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index 07a5144..0ba5097 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -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; - storage: StoragePort; images?:Pick; digests?:Pick; persistence: PersistencePort; assets?: AssetPort; runs?: RunPort; createId?: () => Id; + storage: StoragePort; displays?: DisplayCache; images?:Pick; digests?:Pick; 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) }; 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(),collectionReservations=new Set(),durablyPurged=new Set([...(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|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; }, diff --git a/src/lib/canvas-runtime/workspace.ts b/src/lib/canvas-runtime/workspace.ts index a20560e..1cb45da 100644 --- a/src/lib/canvas-runtime/workspace.ts +++ b/src/lib/canvas-runtime/workspace.ts @@ -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>; 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>(); let accepting = true, closed = false, closing: Promise | 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; diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 941fa27..e37a597 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -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); diff --git a/tests/headless/display-cache.test.mjs b/tests/headless/display-cache.test.mjs new file mode 100644 index 0000000..67370bb --- /dev/null +++ b/tests/headless/display-cache.test.mjs @@ -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'); +}); diff --git a/tests/headless/resource-view.test.mjs b/tests/headless/resource-view.test.mjs new file mode 100644 index 0000000..51b0f02 --- /dev/null +++ b/tests/headless/resource-view.test.mjs @@ -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(); +}); diff --git a/tests/headless/resources.test.mjs b/tests/headless/resources.test.mjs index 9eb2504..618deaa 100644 --- a/tests/headless/resources.test.mjs +++ b/tests/headless/resources.test.mjs @@ -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);};