docs(tests): explain project resource lifecycle and collection
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* 项目引擎资源生命周期测试:本地图片先发布,文件及账本持久化后上传,依赖文档操作等待资源确认。
|
||||
* 默认尺寸授权、解码元数据和显式 fitImage 各有独立语义;迟到解码不能覆盖用户调整或绑定到旧元素身份。
|
||||
* 本地回收需综合 working/history/ACK/任务/显示引用,先标记、再等待宽限期,并将物理删除与恢复墓碑一同保存。
|
||||
* fixture 使用内存端口,验证引擎协调及故障处理;不证明真实数据库的事务隔离或浏览器实际解码能力。
|
||||
*/
|
||||
import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -6,26 +12,38 @@ import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
import { resourcePins } from '../../src/lib/canvas-runtime/resources.ts';
|
||||
|
||||
// 内置 registry 提供图片资源字段与几何能力;固定作用域让重开时读取同一个项目的持久化 image。
|
||||
const registry = createRegistry(builtinComponents());
|
||||
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' };
|
||||
const geometry = { x: 0, y: 0, width: 100, height: 100, rotation: 0 };
|
||||
// 构造独立 image 元素;允许无 assetId 的基线节点 a 充当与上传无关的编辑目标。
|
||||
const node = (id, data = {}) => ({ id, incarnation: 1, structure: 'node', componentId: 'image', dataVersion: 1, ownerCanvasId: 'canvas', geometry, data: { title: id, text: '', ...data } });
|
||||
// 初始只有 canvas 和 a,新增 picture 可通过元素数量及服务端写次数区分,避免把恢复误当再次创建。
|
||||
const original = createDocument('project', [{ id: 'canvas', parent: null }], [node('a')]);
|
||||
// 可手动完成的 Promise 屏障,用于停住解码、上传、文件读取或删除落盘,控制迟到结果的时序。
|
||||
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
|
||||
// 最多轮询 100 次并让出定时器,等待特定异步阶段出现;超时直接失败,不无限等待。
|
||||
async function until(predicate) { for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 1)); } assert.fail('condition did not become true'); }
|
||||
// 为每个用例创建文件/提交/回执表和假服务端;storage 与服务端保留状态以支持关闭后重新打开引擎。
|
||||
function fixture() {
|
||||
let image = null, owner, counter = 0, server = original, version = 1;
|
||||
const files = new Map(), batches = [], uploads = [], writes = [], receipts = new Map(), assetReceipts = new Map();
|
||||
const storage = {
|
||||
fileDeletion: 'atomic-v1',
|
||||
// 单 owner 领取;release 同时清空当前 owner 并将旧 handle 标为 inactive,便于引擎关闭后再开。
|
||||
async claim(scope) { assert.equal(owner, undefined); const handle = { scope, fence: 1, active: true, async release() { owner = undefined; handle.active = false; } }; owner = handle; return handle; },
|
||||
// 克隆先前 image,使新引擎从持久化数据重建而不是共享旧快照对象。
|
||||
async recover() { return structuredClone(image); },
|
||||
// 模拟 atomic-v1 文件提交:新增文件 ID 不能覆盖,删除与 image 在同一调用保存;真实回滚不由此内存实现证明。
|
||||
async commit(_leader, batch) { for (const file of batch.files ?? []) { assert.equal(files.has(file.id), false); files.set(file.id, file.bytes); } for(const id of batch.deleteFiles??[])files.delete(id); image = structuredClone(batch.image); batches.push({ ...batch, image }); return { durableRootRevision: image.rootRevision }; },
|
||||
async readFile(_leader, id) { return files.get(id); }, close() {},
|
||||
// 按资源 ID 取原始 Blob;用例会替换它来制造磁盘错误、共享读取及迟到结果。
|
||||
async readFile(_leader, id) { return files.get(id); }, /* 内存存储无外部句柄;owner 由 release 归还。 */ close() {},
|
||||
};
|
||||
const assets = {
|
||||
sourceURL: id => `https://assets.example/${id}`, displayURL: id => `/assets/${id}`,
|
||||
// 上传前断言文件已落盘、image 中已有 dispatch-marked 证据;随后保存可查询的成功资产回执。
|
||||
async upload(request, bytes) {
|
||||
// 资源字节和 dispatch 证据都必须在上传前可恢复;这两条断言验证持久化屏障的顺序。
|
||||
const payload = JSON.parse(request.body); assert.equal(await files.get(payload.resource_id).text(), await bytes.text());
|
||||
assert.ok(image.payload.runtime.ledger.requests.some(([id, row]) => id === request.id && row.phase === 'dispatch-marked'));
|
||||
uploads.push(request);
|
||||
@@ -33,20 +51,29 @@ function fixture() {
|
||||
assetReceipts.set(request.id, asset);
|
||||
return { outcome: 'applied', canonical: asset };
|
||||
},
|
||||
// 上传回执按原 requestId 查询;存在则返回 applied 元数据,不存在仍为 unresolved。
|
||||
async query(request) { return assetReceipts.has(request.id) ? { outcome: 'applied', canonical: assetReceipts.get(request.id) } : { outcome: 'unresolved' }; },
|
||||
// 此 fixture 直接交出 canonical,由引擎继续验证资源身份;Foundry wire 回执校验在 adapter 专项测试中覆盖。
|
||||
accept: (_request, status) => status.canonical,
|
||||
};
|
||||
const persistence = {
|
||||
// 恢复读取当前假服务端文档和 canvas 版本,不能直接拿本地 working 代替 ACK。
|
||||
async read(){return{document:server,versions:new Map([['canvas',version]])};},
|
||||
// 简化请求把完整意图放入 payload,并读取参与 canvas 的 ACK 版本,不进行真实 wire 压缩。
|
||||
compile(intent, id, snapshot) { return { payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(intent.canvasIds.map(id => [id, snapshot.runtime.versions.get(id)])) }; },
|
||||
// 文档写入应用 changes,递增服务端版本并按 requestId 保存 canonical 回执,供后续接纳恢复使用。
|
||||
async submit(request) { const { intent } = JSON.parse(request.body); writes.push(request); server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['canvas', ++version]]) }); return { outcome: 'applied' }; },
|
||||
// 文档回执查询与上传回执表分开,存在才表示该文档请求已应用。
|
||||
async query(request) { return receipts.has(request.id) ? { outcome: 'applied' } : { outcome: 'unresolved' }; },
|
||||
// 成功文档请求取其保存的 document/versions,不凭当前本地文档猜测服务端状态。
|
||||
canonical(request) { return receipts.get(request.id); },
|
||||
};
|
||||
return { storage, assets, persistence, files, batches, uploads, writes, assetReceipts, image: () => image,
|
||||
// 以相同存储和服务端端口打开新引擎;extra 可替换时钟、解码器、策略或历史容量。
|
||||
open: extra => openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, assets, persistence, createId: () => `id-${++counter}`, ...extra }),
|
||||
};
|
||||
}
|
||||
// stageFiles 在同一同步事务创建图片与资源工作;defaultSize 决定是否授予后续自动尺寸修正权限。
|
||||
function addImage(engine, id = 'picture', defaultSize = false) {
|
||||
let resource;
|
||||
const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }), defaultSize }], ([ref], tx) => {
|
||||
@@ -56,28 +83,33 @@ function addImage(engine, id = 'picture', defaultSize = false) {
|
||||
assert.equal(result.status, 'applied'); return resource;
|
||||
}
|
||||
|
||||
// 解码前先移动和改名,自动尺寸结果随后回写创建历史且不增加 undo 单元;重开后改名和移动可分别撤销,尺寸随创建保留,解码和上传均只一次。
|
||||
test('automatic image sizing publishes with creation history across intervening edits and survives reopening', async () => {
|
||||
const f = fixture(), gate = deferred(), entered = deferred(); let calls = 0;
|
||||
const images = { decode: async () => { calls++; entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } };
|
||||
let engine = await f.open({ images });
|
||||
try {
|
||||
const ref = addImage(engine, 'picture', true); await entered.promise;
|
||||
// 订阅只记录尺寸已经生效的发布,随后核对 grant 与创建历史是否在同一 Root 中同步变化。
|
||||
const observations = [];
|
||||
const unsubscribe = engine.editor.subscribe((_commit, snapshot) => { if (snapshot.document.elements.get('picture')?.geometry.height === 50) observations.push(snapshot); });
|
||||
const target = { id: 'picture', incarnation: 1 };
|
||||
engine.editor.dispatch({ type: 'element.geometry', target, patch: { x: 70 } });
|
||||
engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed before decode' } });
|
||||
// 记录解码前栈内容;完成后必须还是这几个历史 ID,尺寸补偿不能另塞一个用户撤销条目。
|
||||
const undo = [...engine.getSnapshot().history.undo]; gate.resolve(); await engine.settle();
|
||||
const root = engine.getSnapshot(); assert.equal(root.document.elements.get('picture').geometry.height, 50);
|
||||
assert.equal(root.document.elements.get('picture').geometry.x, 70); assert.deepEqual(root.history.undo, undo);
|
||||
assert.equal(root.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
|
||||
assert.ok(observations.length > 0);
|
||||
// 不能先发尺寸、后补 history:任何被观察到的生效快照都要具备对应授权终态与补偿证据。
|
||||
for (const snapshot of observations) {
|
||||
assert.equal(snapshot.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
|
||||
assert.equal(snapshot.history.entries.get(undo[0]).changes[0].after.geometry.height, 50);
|
||||
assert.equal([...snapshot.operations.values()].some(intent => intent.historyEffect), true);
|
||||
}
|
||||
unsubscribe();
|
||||
// 自动尺寸是 asset-resolution 效果,关联历史证据;资源含新字段时不能把 image 格式降级为旧版本。
|
||||
const effect = [...root.operations.values()].find(intent => intent.historyEffect);
|
||||
assert.equal(effect.origin, 'asset-resolution'); assert.equal(effect.historyTransition.entries.length, 2);
|
||||
assert.equal(f.image().formatVersion, 6); decodeRecovery(f.image(), registry);
|
||||
@@ -95,6 +127,7 @@ test('automatic image sizing publishes with creation history across intervening
|
||||
} finally { gate.resolve(); await engine.close(); }
|
||||
});
|
||||
|
||||
// 用户已提交 resize 即永久撤销默认尺寸授权;即使在解码完成前 undo resize,也不能重新授权自动调整。
|
||||
test('a committed user resize permanently revokes default sizing even if undone before decoding', async () => {
|
||||
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
||||
try {
|
||||
@@ -108,6 +141,7 @@ test('a committed user resize permanently revokes default sizing even if undone
|
||||
} finally { gate.resolve(); await engine.close(); }
|
||||
});
|
||||
|
||||
// 关闭时解码尚未完成,持久化保留 pending grant 和源字节;重开用新解码器继续修正,创建仍是一个历史单元。
|
||||
test('a pending sizing grant and source bytes survive closing before decode completion', async () => {
|
||||
const f = fixture(), gate = deferred(), entered = deferred(); let engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
||||
const ref = addImage(engine, 'picture', true); await entered.promise;
|
||||
@@ -118,6 +152,7 @@ test('a pending sizing grant and source bytes survive closing before decode comp
|
||||
finally { await engine.close(); }
|
||||
});
|
||||
|
||||
// 手势预览期间暂不发布默认尺寸;提交移动后再修正高度,保留移动位置,undo 移动不会撤销尺寸补偿。
|
||||
test('default sizing waits for a geometry gesture to finish and retains its committed movement', async () => {
|
||||
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
||||
try {
|
||||
@@ -134,6 +169,7 @@ test('default sizing waits for a geometry gesture to finish and retains its comm
|
||||
} finally { gate.resolve(); await engine.close(); }
|
||||
});
|
||||
|
||||
// 图片被换成另一资源后,旧 grant 撤销;旧解码器迟到完成不能更改当前几何。
|
||||
test('replacing an image resource revokes its pending grant and a late decoder cannot change geometry', async () => {
|
||||
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
||||
try {
|
||||
@@ -145,6 +181,7 @@ test('replacing an image resource revokes its pending grant and a late decoder c
|
||||
} finally { gate.resolve(); await engine.close(); }
|
||||
});
|
||||
|
||||
// 策略拒绝自动 geometry 命令时保留 blocked 授权;显式 retry 消费原 grant,仍只有一个创建历史和一次上传。
|
||||
test('a denied automatic resize records a recoverable block and explicit retry consumes the original grant', async () => {
|
||||
const f = fixture(); let denied = true;
|
||||
const engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) }, policy(command) { return command.type === 'element.geometry' && denied ? { enabled: false, code: 'forbidden', targets: ['picture'] } : { enabled: true }; } });
|
||||
@@ -159,6 +196,7 @@ test('a denied automatic resize records a recoverable block and explicit retry c
|
||||
} finally { await engine.close(); }
|
||||
});
|
||||
|
||||
// 未来版本 sizing 作为不透明数据保留,资源扫描不再声称完整;无关改名仍可保存,不能把未来授权当已知规则运行。
|
||||
test('future default-sizing metadata stays opaque, blocks GC and permits unrelated editing', async () => {
|
||||
const f = fixture(); let engine = await f.open(); const ref = addImage(engine); await engine.settle(); await engine.close();
|
||||
const future = { version: 2, opaque: { reference: 'keep' } }; f.image().formatVersion = 6; f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing = future;
|
||||
@@ -172,6 +210,7 @@ test('future default-sizing metadata stays opaque, blocks GC and permits unrelat
|
||||
} finally { await engine.close(); }
|
||||
});
|
||||
|
||||
// stageFiles 立即显示元素与历史,字节和恢复状态同批保存;上传挂起时无关编辑可先同步,复制/撤销复用资源不重复上传。
|
||||
test('pending images publish immediately, bytes share the durable cut, and independent canvas edits overtake upload', async () => {
|
||||
const f = fixture(), gate = deferred(), upload = f.assets.upload;
|
||||
f.assets.upload = async (...args) => { await gate.promise; return upload(...args); };
|
||||
@@ -185,6 +224,7 @@ test('pending images publish immediately, bytes share the durable cut, and indep
|
||||
engine.editor.dispatch({ type: 'element.data', target: { id: 'a', incarnation: 1 }, patch: { title: 'independent' } });
|
||||
await until(() => f.writes.length === 1);
|
||||
assert.equal(f.uploads.length, 0);
|
||||
// 检查实际交给 storage 的批次,而非只看当前内存状态,证明文件与图片内容同 cut 保存。
|
||||
const fileBatch = f.batches.find(batch => batch.files?.length);
|
||||
assert.equal(fileBatch.image.payload.document.elements.some(element => element.id === 'picture'), true);
|
||||
assert.equal(fileBatch.image.payload.runtime.resources.length, 1);
|
||||
@@ -202,6 +242,7 @@ test('pending images publish immediately, bytes share the durable cut, and indep
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 上传服务已成功但响应丢失,依赖图片文档仍等待;重开查询同一冻结 ID/body,恢复原资源后只提交一次图片文档。
|
||||
test('lost upload acknowledgment recovers original byte identity and query receipt without recreating a node', async () => {
|
||||
const f = fixture(), upload = f.assets.upload;
|
||||
f.assets.upload = async (...args) => { await upload(...args); return { outcome: 'unresolved' }; };
|
||||
@@ -209,9 +250,11 @@ test('lost upload acknowledgment recovers original byte identity and query recei
|
||||
const frozen = f.uploads[0]; assert.ok(frozen); assert.equal(f.writes.length, 0);
|
||||
const decoded = decodeRecovery(f.image(), registry);
|
||||
assert.equal(decoded.runtime.resources.get(ref.id).id, ref.id); assert.equal(decoded.operations.size, 1); assert.equal(decoded.runtime.ledger.operations.size, 2);
|
||||
// 破坏上传 operationId 的交叉引用应使恢复拒绝,避免把正确字节绑到另一上传工作。
|
||||
const bad = structuredClone(f.image()); bad.payload.runtime.resources[0][1].operationId = 'not-the-upload';
|
||||
assert.throws(() => decodeRecovery(bad, registry), /corrupt_recovery_image/);
|
||||
await engine.close();
|
||||
// 重开时让时钟超过重试截止时间,要求先查原回执,不创建新上传请求或新节点。
|
||||
const queries = [], query = f.assets.query;
|
||||
f.assets.query = async request => { queries.push(request); return query(request); };
|
||||
const recovered = await f.open({ clock: { now: () => Date.now() + 100000, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: id => clearTimeout(id) } });
|
||||
@@ -223,6 +266,7 @@ test('lost upload acknowledgment recovers original byte identity and query recei
|
||||
await recovered.close();
|
||||
});
|
||||
|
||||
// 替换图片在本地立即可见,上传完成前服务端保留旧引用;undo/redo 和重开只切换引用,不重复上传或更换元素身份。
|
||||
test('replacing an image keeps the old server reference during upload and undo/reopen/redo never uploads again',async()=>{
|
||||
const f=fixture();let engine=await f.open();const original=addImage(engine);await engine.settle();const upload=f.assets.upload,gate=deferred(),entered=deferred();let replacement;
|
||||
f.assets.upload=async(...args)=>{entered.resolve();await gate.promise;return upload(...args);};
|
||||
@@ -237,6 +281,7 @@ test('replacing an image keeps the old server reference during upload and undo/r
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 创建重复 ID 被拒或回调为空时,不留下文件字节、资源记录或上传任务;失败事务不能泄漏已暂存工作。
|
||||
test('rejected and empty staging do not retain file bytes or create upload work', async () => {
|
||||
const f = fixture(), engine = await f.open();
|
||||
const files = [{ name: 'image.png', bytes: new Blob(['image'], { type: 'image/png' }) }];
|
||||
@@ -248,6 +293,7 @@ test('rejected and empty staging do not retain file bytes or create upload work'
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 未知组件的资源 schema 使扫描不完整;明确注册的额外 owner 仍可单独保留已知资源。
|
||||
test('unknown resource reference schemas prevent claiming complete garbage-collection coverage', async () => {
|
||||
const f = fixture(), engine = await f.open();
|
||||
const opaque = { ...node('future'), componentId: 'future-image', data: { privateResource: 'unknown' } };
|
||||
@@ -259,11 +305,13 @@ test('unknown resource reference schemas prevent claiming complete garbage-colle
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 上传回执声称成功但 assetId 错误时,保留原回执及 waiting 请求,不释放文档依赖,也不能把错误资源标为 stored。
|
||||
test('invalid applied asset metadata retains the receipt and pending document without releasing the dependency', async () => {
|
||||
const f = fixture(), upload = f.assets.upload;
|
||||
f.assets.upload = async (...args) => { const result = await upload(...args); return { ...result, canonical: { ...result.canonical, id: 'different-resource' } }; };
|
||||
const errors = [], engine = await f.open({ onError: error => errors.push(error) });
|
||||
const ref = addImage(engine); await engine.settle();
|
||||
// applied 是服务端执行事实,waiting 是本地尚未接纳:错误元数据不能把事实改回未执行,也不能解锁后续文档。
|
||||
const request = f.uploads[0], stored = engine.getSnapshot().runtime.ledger.requests.get(request.id);
|
||||
assert.equal(stored.outcome, 'applied'); assert.equal(stored.phase, 'waiting'); assert.equal(f.writes.length, 0);
|
||||
assert.equal(engine.getSnapshot().runtime.receipts.get(request.id).id, 'different-resource');
|
||||
@@ -272,6 +320,7 @@ test('invalid applied asset metadata retains the receipt and pending document wi
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
// 资源和 image 的首次持久化失败后,本地图片仍可见,但上传与文档均不发送;关闭也报错,修复存储再完成可靠保存。
|
||||
test('a failed image durable cut keeps the visible element and sends neither bytes nor document', async () => {
|
||||
const f = fixture(), errors = [], engine = await f.open({ onError: error => errors.push(error) });
|
||||
await engine.persist();
|
||||
@@ -282,12 +331,14 @@ test('a failed image durable cut keeps the visible element and sends neither byt
|
||||
assert.equal(f.uploads.length, 0); assert.equal(f.writes.length, 0); assert.equal(f.files.size, 0);
|
||||
assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id);
|
||||
await assert.rejects(() => engine.close(), /quota/);
|
||||
// 关闭失败不丢弃待保存成果;恢复原端口后再次关闭完成持久化,随后检查文件和历史。
|
||||
f.storage.commit = commit; await engine.close();
|
||||
assert.equal(await f.files.get(ref.id).text(), 'image bytes');
|
||||
assert.equal(decodeRecovery(f.image(), registry).history.undo.length, 1);
|
||||
assert.ok(errors.length);
|
||||
});
|
||||
|
||||
// 服务端上传已成功、accept 暂时失败时,通过恢复会话接纳 stored 身份并释放依赖文档;history 和冻结字节不变,无二次上传。
|
||||
test('known successful upload coordination recovers stored identity and releases the dependent image without uploading again',async()=>{
|
||||
const f=fixture(),accept=f.assets.accept;let fail=true;f.assets.accept=(...args)=>{if(fail)throw Error('codec_temporarily_unavailable');return accept(...args);};
|
||||
const engine=await f.open(),resource=addImage(engine);await until(()=>f.uploads.length===1);await engine.settle();const request=f.uploads[0],history=engine.getSnapshot().history;
|
||||
@@ -298,21 +349,27 @@ test('known successful upload coordination recovers stored identity and releases
|
||||
});
|
||||
|
||||
|
||||
// 摘要 worker 故障保留原文件和明确 blocker,不伪装成缺少字节,也不忙循环;显式 retry 恢复同一资源及创建历史。
|
||||
test('digest worker failure remains distinct from missing bytes and explicit retry resumes the original resource',async()=>{
|
||||
const {importSourceDigest}=await import('../../src/lib/canvas-runtime/import-integrity.ts'),f=fixture(),errors=[];let calls=0;const engine=await f.open({digests:{async digest(bytes,signal){if(++calls===1)throw Object.assign(Error('worker failed'),{code:'digest_worker_failed'});return importSourceDigest(bytes,signal);}},onError:error=>errors.push(error)});
|
||||
try{const ref=addImage(engine);await engine.settle();const resource=engine.getSnapshot().runtime.resources.get(ref.id);assert.deepEqual(engine.getSnapshot().runtime.ledger.operations.get(resource.operationId).blockers,['digest_worker_failed']);assert.equal(f.files.size,1);assert.equal(f.uploads.length,0);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(calls,1,'failed worker retried in a busy loop');await engine.retry();await engine.settle();assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(errors.length,1);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 墙上时间可手动推进,重试定时器仍用真实 setTimeout;用于精确检查回收宽限期。
|
||||
const resourceClock=()=>{let now=1000;return{advance:ms=>{now+=ms;},now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};};
|
||||
// 删除 picture 后编辑 a,配合 historyLimit=1 淘汰图片撤销记录,再等待 ACK,真正移除旧图片引用。
|
||||
async function unreferenceImage(engine){assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'evict old image history'}}).status,'applied');await engine.settle();}
|
||||
|
||||
// 无引用资源先保存 soft marker,再满 100ms 宽限期后把文件删除和 purge 墓碑一起保存;重开可用远端 URL,不重新上传。
|
||||
test('local collection persists a soft marker, waits retention and atomically deletes bytes with a v3 recovery tombstone',async()=>{
|
||||
const f=fixture(),clock=resourceClock();let engine=await f.open({historyLimit:1,clock});const ref=addImage(engine);try{
|
||||
await engine.settle();await unreferenceImage(engine);assert.equal(engine.getResourcePins().pins.has(ref.id),false);
|
||||
const content=engine.getSnapshot().document,history=engine.getSnapshot().history;
|
||||
// 首次扫描只标记无引用时间;后续扫描必须同时满足完整覆盖和宽限期,才能删除物理文件。
|
||||
const soft=await engine.collectResources({retentionMs:100});assert.deepEqual(soft.markedIds,[ref.id]);assert.deepEqual(soft.purgedIds,[]);assert.ok(f.files.has(ref.id));assert.equal(f.image().formatVersion,3);decodeRecovery(f.image(),registry);
|
||||
clock.advance(99);assert.deepEqual((await engine.collectResources({retentionMs:100})).purgedIds,[]);clock.advance(1);
|
||||
const collected=await engine.collectResources({retentionMs:100});assert.deepEqual(collected.purgedIds,[ref.id]);assert.equal(f.files.has(ref.id),false);assert.equal(engine.getSnapshot().document,content);assert.equal(engine.getSnapshot().history,history);
|
||||
// 用保存过的 batch 关联 deleteFiles 与 purgedAt,确保 purge 墓碑持久化时,对应文件删除也已包含在该批次中。
|
||||
const deletion=f.batches.find(batch=>batch.deleteFiles?.includes(ref.id));assert.equal(deletion.image.payload.runtime.resources.find(([id])=>id===ref.id)[1].retirement.purgedAt,1100);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);
|
||||
const cut=decodeRecovery(f.image(),registry);assert.equal(cut.runtime.resources.get(ref.id).retirement.purgedAt,1100);
|
||||
for(const version of [1,2]){const bad=structuredClone(f.image());bad.formatVersion=version;assert.throws(()=>decodeRecovery(bad,registry),/unsupported_recovery_version/);}
|
||||
@@ -320,6 +377,7 @@ test('local collection persists a soft marker, waits retention and atomically de
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 文档、历史、显式 pin 或未知覆盖均阻止删除;资源重新被引用时清除退休标记,之后再次无引用需重新计时。
|
||||
test('document/history/ACK/explicit pins and unknown coverage prevent collection, and repinning resets the soft retention clock',async()=>{
|
||||
const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);try{
|
||||
await engine.settle();assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]);
|
||||
@@ -332,6 +390,7 @@ test('document/history/ACK/explicit pins and unknown coverage prevent collection
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// display 在异步磁盘读取前就取得 pin 并清除退休状态;句柄重复 release 安全,最后释放后重新启动回收宽限期。
|
||||
test('a display pins before its asynchronous read and resets retirement; releasing it starts a fresh retention window',async()=>{
|
||||
const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);const read=f.storage.readFile,gate=deferred(),entered=deferred();let blocked=false;try{
|
||||
await engine.settle();await unreferenceImage(engine);await engine.collectResources({retentionMs:100});clock.advance(200);
|
||||
@@ -340,6 +399,7 @@ test('a display pins before its asynchronous read and resets retirement; releasi
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 两个 reader 共用一次磁盘读取及 URL;取消其中一个不释放另一个的 pin,直到最后句柄 release 才撤销 URL 并允许回收。
|
||||
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;
|
||||
@@ -348,18 +408,21 @@ test('shared display reads and URLs retain byte protection until the last indepe
|
||||
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); };
|
||||
// 两个调用各自持有 reader 生命周期;共享底层读取不能让一个 AbortSignal 取消另一个调用。
|
||||
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);
|
||||
// release 幂等且按句柄计数;另一个句柄仍在时不能撤销共享 URL 或移除字节保护。
|
||||
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; }
|
||||
});
|
||||
|
||||
// 关闭引擎立即拒绝挂起 display;底层文件读取后来完成也不能创建 URL 或复活显示工作。
|
||||
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;
|
||||
@@ -371,6 +434,7 @@ test('project close releases a pending display immediately and never creates a U
|
||||
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
||||
});
|
||||
|
||||
// 磁盘读取错误保留原错误码,失败 reader 释放显示 pin;恢复端口后新 reader 可重试而不再次上传。
|
||||
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 {
|
||||
@@ -381,6 +445,7 @@ test('display failures retain their cause and a fresh reader can retry without u
|
||||
} finally { await engine.close(); }
|
||||
});
|
||||
|
||||
// 账户身份失效同时挡住新 reader 和挂起读取的迟到结果,不创建 URL,所有 display pin 均释放。
|
||||
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();
|
||||
@@ -393,6 +458,7 @@ test('identity retirement fences new readers and discards a pending local read b
|
||||
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
||||
});
|
||||
|
||||
// 物理删除落盘期间预留目标资源,禁止再引用或 display 该资源;无关元素编辑保持可用。
|
||||
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);};
|
||||
@@ -402,6 +468,7 @@ test('only recollection of the reserved resource is fenced during deletion; unre
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 删除落盘失败后保留字节及 reservation,旧持久化 image 没有 purge 墓碑;显式 persist 成功后才删除并释放,显示回退远端 URL。
|
||||
test('failed deletion keeps bytes, pending recovery cut and reservation until an explicit persistence retry succeeds',async()=>{
|
||||
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),commit=f.storage.commit;let fail=false;try{
|
||||
await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk unavailable');return commit(...args);};fail=true;
|
||||
@@ -410,14 +477,17 @@ test('failed deletion keeps bytes, pending recovery cut and reservation until an
|
||||
}finally{fail=false;await engine.close();}
|
||||
});
|
||||
|
||||
// 订阅者在 purge 发布回调内同步调用 persist,也不能把 purge 墓碑先于 deleteFiles 单独保存。
|
||||
test('a synchronous publication listener cannot persist a purge marker without its file deletion',async()=>{
|
||||
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine);let unsubscribe;try{
|
||||
await engine.settle();await unreferenceImage(engine);unsubscribe=engine.editor.subscribe(()=>{void engine.persist().catch(()=>{});});await engine.collectResources({retentionMs:0});assert.equal(f.files.has(ref.id),false);
|
||||
// 即使发布监听器重入 persist,第一份携带 purgedAt 的持久化批次也必须已经包含物理删除。
|
||||
const purged=f.batches.filter(batch=>batch.image.payload.runtime.resources.some(([id,resource])=>id===ref.id&&resource.retirement?.purgedAt!==undefined));assert.ok(purged.length);assert.ok(purged[0].deleteFiles.includes(ref.id),'first durable purge cut omitted physical deletion');
|
||||
}finally{unsubscribe?.();await engine.close();}
|
||||
});
|
||||
|
||||
|
||||
// ACK 独有内容、活跃导入、不透明 run 输出及临时 copy pin 都参与引用保留;未知组件/字段 schema 使 complete=false,不能据可见性删字节。
|
||||
test('retention covers ACK-only content, opaque job envelopes and historical field schemas independently of visibility',async()=>{
|
||||
const f=fixture(),engine=await f.open();try{
|
||||
const root=engine.getSnapshot(),empty=createDocument('project',[...root.document.canvases.values()],[]),resource={id:'kept',operationId:'upload',name:'x',mimeType:'image/png',byteLength:1},base={...root,document:empty,runtime:{...root.runtime,resources:new Map([['kept',resource]]),acknowledged:createDocument('project',[...empty.canvases.values()],[node('remote',{assetId:'kept'})])}};
|
||||
@@ -433,20 +503,25 @@ test('retention covers ACK-only content, opaque job envelopes and historical fie
|
||||
}finally{await engine.close();}
|
||||
});
|
||||
|
||||
// 普通解码完成只发布元数据,不覆盖用户新几何;显式 fitImage 才按比例改尺寸并增加一个正常 undo 单元。
|
||||
test('late image metadata never changes edited geometry; explicit fitting creates one normal undo unit',async()=>{
|
||||
const f=fixture(),gate=deferred(),entered=deferred();const images={decode:async()=>{entered.resolve();await gate.promise;return{width:400,height:200};}},engine=await f.open({images}),ref=addImage(engine);try{await entered.promise;assert.equal(engine.editor.dispatch({type:'element.geometry',target:{id:'picture',incarnation:1},patch:{width:300,height:70}}).status,'applied');gate.resolve();await engine.settle();assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,{version:1,status:'ready',width:400,height:200,source:'decode'});assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.getSnapshot().history.undo.length,2);assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);assert.equal(engine.getSnapshot().history.undo.length,3);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.editor.history.redo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);await engine.settle();decodeRecovery(f.image(),registry);assert.equal(f.uploads.length,1);
|
||||
}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
// 解码失败不阻塞资源上传;retry 只重新解码并保持资源、元素、上传及历史身份。
|
||||
test('image decode failure does not block upload and retry keeps resource, element and upload identities',async()=>{
|
||||
const f=fixture();let fail=true,calls=0;const engine=await f.open({images:{decode:async()=>{calls++;if(fail)throw Error('bad decoder');return{width:40,height:20};}}}),ref=addImage(engine);try{await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'failed');assert.equal(f.uploads.length,1);fail=false;await engine.retry();await engine.settle();assert.equal(calls,2);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{await engine.close();}
|
||||
});
|
||||
// 资源元数据可跨元素删除/恢复继续使用,但 fitImage 必须匹配当前 incarnation 与 assetId,旧身份或替代资源都拒绝。
|
||||
test('decoder metadata can cross deletion/restoration, but fitting cannot target a stale incarnation or replaced resource',async()=>{
|
||||
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:20,height:10};}}}),ref=addImage(engine);try{await entered.promise;engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});engine.editor.history.undo();const restored=engine.getSnapshot().document.elements.get('picture');assert.equal(restored.incarnation,2);gate.resolve();await engine.settle();assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},'another-resource').code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},ref.id).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,50);}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
// 恢复预览 hold 期间解码虽完成也不能发布新状态根;取消会话释放协调边界后才接纳 ready 元数据。
|
||||
test('recovery holds defer image completion metadata until the coordinated root is released',async()=>{
|
||||
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:10,height:5};}}}),ref=addImage(engine);try{await entered.promise;const recovery=await engine.prepareRecovery(),revision=engine.getSnapshot().rootRevision;gate.resolve();await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'pending');await recovery.cancel();await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');}finally{gate.resolve();await engine.close();}
|
||||
});
|
||||
|
||||
// 未来版本 image 元数据不触发重解码或静默迁移,原样持久化并阻止完整 GC 声明,仍允许无关编辑。
|
||||
test('future image metadata remains opaque without blocking unrelated editing or being silently reinterpreted',async()=>{
|
||||
const f=fixture();let engine=await f.open();const ref=addImage(engine);try{await engine.settle();await engine.close();const future={version:2,status:'future',payload:{retain:true}};f.image().payload.runtime.resources.find(([id])=>id===ref.id)[1].image=future;let calls=0;engine=await f.open({images:{decode:async()=>{calls++;return{width:1,height:1};}}});await engine.settle();assert.equal(calls,0);assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,future);assert.equal(engine.getResourcePins().complete,false);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'can edit'}}).status,'applied');await engine.settle();assert.deepEqual(decodeRecovery(f.image(),registry).runtime.resources.get(ref.id).image,future);assert.equal(f.uploads.length,1);}finally{await engine.close();}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user