From 8a58d9785c82726027bdc139db66f81c6f9f79fd Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 16:42:40 +0800 Subject: [PATCH] perf(canvas): reuse immutable recovery encoding subtrees --- src/lib/canvas-runtime/recovery-encoding.ts | 59 +++++++++++++++++++++ src/lib/canvas-runtime/recovery.ts | 8 +-- src/lib/canvas-runtime/storage.ts | 3 +- tests/headless/browser-storage-cases.mjs | 14 +++++ tests/headless/recovery-encoding.test.mjs | 28 ++++++++++ 5 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 src/lib/canvas-runtime/recovery-encoding.ts create mode 100644 tests/headless/recovery-encoding.test.mjs diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts new file mode 100644 index 0000000..bf41cd9 --- /dev/null +++ b/src/lib/canvas-runtime/recovery-encoding.ts @@ -0,0 +1,59 @@ +import type { Json } from '../canvas-core/types.ts'; +import { fault, freeze, validJson } from '../canvas-core/values.ts'; +import type { RecoveryImage } from './storage.ts'; + +type Encoded = {value:Json|undefined;height:number;reusable:boolean}; +const immutable=new WeakMap(),sealed=new WeakSet(); + +/** JSON value semantics, with reuse only for transitively frozen plain data properties. */ +export function encodeRecoveryValue(input:unknown):Json { + const visiting=new Set(); + function visit(value:unknown,depth:number,key:string):Encoded { + if(value===null||typeof value==='string'||typeof value==='boolean'){if(depth>64)throw fault('invalid_recovery_payload');return{value,height:0,reusable:true};} + if(typeof value==='number'){if(depth>64)throw fault('invalid_recovery_payload');return{value:Number.isFinite(value)?(Object.is(value,-0)?0:value):null,height:0,reusable:true};} + if(value===undefined||typeof value==='symbol')return{value:undefined,height:0,reusable:true}; + const fallback=()=>{ + // Preserve the previous JSON codec for toJSON, boxed values and custom prototypes. + // These can depend on hidden mutable state and are deliberately never cached. + const text=JSON.stringify({[key]:value}),encoded=JSON.parse(text)[key] as Json|undefined; + if(encoded!==undefined&&!validJson(encoded,depth))throw fault('invalid_recovery_payload'); + return{value:freeze(encoded),height:0,reusable:false}; + }; + if(typeof value==='bigint'||typeof value==='function')return fallback(); + if(typeof value!=='object')throw fault('invalid_recovery_payload'); + const object=value as object,cached=immutable.get(object); + if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;} + const prototype=Object.getPrototypeOf(object); + // A callable toJSON getter must also be evaluated afresh on every encode. + if(typeof (object as {toJSON?:unknown}).toJSON==='function'||(Array.isArray(object)?prototype!==Array.prototype&&prototype!==null:prototype!==Object.prototype&&prototype!==null))return fallback(); + if(depth>64)throw fault('invalid_recovery_payload'); + if(visiting.has(object))throw fault('invalid_recovery_payload');visiting.add(object); + const toJSON=Object.getOwnPropertyDescriptor(object,'toJSON'); + let reusable=Object.isFrozen(object)&&(!toJSON||Object.hasOwn(toJSON,'value')),height=0; + try { + if(Array.isArray(object)){ + const result:Json[]=[]; + for(let index=0;index64)throw fault('invalid_recovery_payload');result.push(child.value??null);height=Math.max(height,1+child.height);reusable&&=child.reusable; + } + const encoded={value:Object.freeze(result) as unknown as Json,height,reusable};if(reusable)immutable.set(object,encoded);return encoded; + } + const result:Record={}; + for(const name of Object.keys(object)){ + const descriptor=Object.getOwnPropertyDescriptor(object,name);if(!descriptor||!Object.hasOwn(descriptor,'value'))reusable=false; + const child=visit((object as Record)[name],depth+1,name);reusable&&=child.reusable; + if(child.value!==undefined){Object.defineProperty(result,name,{value:child.value,enumerable:true});height=Math.max(height,1+child.height);} + } + const encoded={value:Object.freeze(result) as Json,height,reusable};if(reusable)immutable.set(object,encoded);return encoded; + }finally{visiting.delete(object);} + } + const result=visit(input,0,'');if(result.value===undefined)throw fault('invalid_recovery_payload');return result.value; +} + +/** Only codec-owned, validated immutable images qualify for the storage copy fast path. */ +export function sealRecoveryImage(formatVersion:1|2,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { + if(![1,2].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); + const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image; +} +export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);} diff --git a/src/lib/canvas-runtime/recovery.ts b/src/lib/canvas-runtime/recovery.ts index b4b5db8..d4c40f2 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -1,3 +1,4 @@ +import { sealRecoveryImage } from './recovery-encoding.ts'; import { validateImportJob, type ImportJob } from './imports.ts'; import { validateElisions, withLocalLifecycles } from './elision.ts'; import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; @@ -15,14 +16,13 @@ import type { RecoveryImage } from './storage.ts'; const documentValue = (document: Document) => ({ projectId: document.projectId, canvases: [...document.canvases.values()], elements: [...document.elements.values()], tombstones: [...document.tombstones] }); /** Plain records only: functions, credentials, renderer objects, URLs and Map methods never serialize. */ export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage { - const payload = JSON.parse(JSON.stringify({ + const payload = { document: documentValue(snapshot.document), history: { undo: snapshot.history.undo, redo: snapshot.history.redo, entries: [...snapshot.history.entries] }, operations: [...snapshot.operations], runtime: { imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } }, - })) as Json; - if (!validJson(payload)) throw fault('invalid_recovery_payload'); - return freeze({ formatVersion: snapshot.runtime.imports?.size ? 2 : 1, rootRevision: snapshot.rootRevision, localRevision: snapshot.revision, payload }); + }; + return sealRecoveryImage(snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload); } type ObjectValue = Record; function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; } diff --git a/src/lib/canvas-runtime/storage.ts b/src/lib/canvas-runtime/storage.ts index bf4f7c7..87636de 100644 --- a/src/lib/canvas-runtime/storage.ts +++ b/src/lib/canvas-runtime/storage.ts @@ -1,3 +1,4 @@ +import { isSealedRecoveryImage } from './recovery-encoding.ts'; import type { Json } from '../canvas-core/types.ts'; import { clone, equal, fault, validJson } from '../canvas-core/values.ts'; @@ -117,7 +118,7 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?: }, async commit(leader: StorageLeader, batch: DurableBatch) { // Copy before awaiting: callers cannot change the durable cut while a transaction queues. - const image = clone(batch.image), files = [...(batch.files ?? [])]; validateImage(image); + const image = isSealedRecoveryImage(batch.image) ? batch.image : clone(batch.image), files = (batch.files ?? []).map(file => ({id:file.id,bytes:file.bytes})); if(!isSealedRecoveryImage(image))validateImage(image); const stored: StoredImage = { image, checksum: await checksum(image) }; return execute(leader, ['checkpoints', 'journal', 'files'], 'readwrite', async (transaction, metadata, key) => { const previous = metadata.throughRevision; diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 8033104..d347f1c 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -1,3 +1,4 @@ +import {sealRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts'; import {planDuplicateAsync} from '../../src/lib/canvas-runtime/duplicate.ts'; import {replayHistoryEvidence,sameHistory} from '../../src/lib/canvas-runtime/history-replay.ts'; import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts'; @@ -7,6 +8,17 @@ 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'; +async function ownedImageStorageCase(name,assert){ + const storage=createBrowserStorage({name:`${name}-owned-image`}),scope={accountId:'owner',workspaceId:'workspace',projectId:'project'};let leader; + try{ + leader=await storage.claim(scope);const payload={nested:{title:'captured'}},descriptor={id:'captured-file',bytes:new Blob(['captured bytes'])},raw={formatVersion:1,rootRevision:0,localRevision:0,payload}; + const first=storage.commit(leader,{image:raw,files:[descriptor]});payload.nested.title='changed';descriptor.id='changed-file';descriptor.bytes=new Blob(['changed bytes']);await first; + assert((await storage.recover(leader)).payload.nested.title==='captured','unowned image changed while checksum was pending');assert(await(await storage.readFile(leader,'captured-file')).text()==='captured bytes','file descriptor changed while checksum was pending');assert(await storage.readFile(leader,'changed-file')===undefined,'late descriptor identity leaked into storage'); + const sealed=sealRecoveryImage(1,1,1,{nested:{title:'sealed'}});await storage.commit(leader,{image:sealed});assert((await storage.recover(leader)).payload.nested.title==='sealed','sealed image did not survive IndexedDB/checksum'); + const nested={title:'before'},impostor=Object.freeze({formatVersion:1,rootRevision:2,localRevision:1,payload:{nested}}),pending=storage.commit(leader,{image:impostor});nested.title='after';await pending;assert((await storage.recover(leader)).payload.nested.title==='before','a shallow-frozen impostor bypassed the defensive copy'); + }finally{await leader?.release();storage.close();indexedDB.deleteDatabase(`${name}-owned-image`);} +} + async function cooperativeCopyCase(assert) { const registry=createRegistry(builtinComponents()),source=createDocument('copy-project',[{id:'copy-root',parent:null}],Array.from({length:1500},(_,index)=>({id:`source-${index}`,incarnation:1,structure:'node',ownerCanvasId:'copy-root',componentId:'text',dataVersion:1,data:{title:`item ${index}`,text:''},geometry:{x:index,y:0,width:100,height:100,rotation:0}}))); let next=0,timerRan=false; @@ -513,6 +525,8 @@ export async function run() { results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit'); await resourceRecoveryCase(name, assert); results.push('staged file and image recover from real IndexedDB; lost upload ACK queries stable resource identity'); + await ownedImageStorageCase(name,assert); + results.push('owned immutable recovery images survive IndexedDB checksums; mutable images and file descriptors are captured before asynchronous work'); await cooperativeCopyCase(assert); results.push('cooperative duplicate planning yields to actual browser tasks and cancellation without mutating the source'); await duplicateRecoveryCase(name,assert); diff --git a/tests/headless/recovery-encoding.test.mjs b/tests/headless/recovery-encoding.test.mjs new file mode 100644 index 0000000..584abb7 --- /dev/null +++ b/tests/headless/recovery-encoding.test.mjs @@ -0,0 +1,28 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {freeze,validJson} from '../../src/lib/canvas-core/values.ts'; +import {encodeRecoveryValue,sealRecoveryImage,isSealedRecoveryImage} from '../../src/lib/canvas-runtime/recovery-encoding.ts'; +const json=value=>JSON.parse(JSON.stringify(value)); + +test('recovery encoding preserves JSON value semantics including omitted, sparse, special-key and custom values',()=>{ + const value={number:-0,infinite:Infinity,missing:undefined,symbol:Symbol('s'),fn(){},array:[,undefined,()=>{},Symbol('s'),NaN],date:new Date('2026-01-01T00:00:00Z'),boxed:new Number(4),custom:{toJSON(key){return{key,text:'custom'};}},['__proto__']:{safe:true}}; + const encoded=encodeRecoveryValue(value);assert.deepEqual(encoded,json(value));assert.equal(validJson(encoded),true);assert.equal(Object.getPrototypeOf(encoded),Object.prototype);assert.equal(Object.hasOwn(encoded,'__proto__'),true);assert.ok(Object.isFrozen(encoded.array)); +}); +test('only frozen data subtrees are reused and the encoded image is detached and immutable',()=>{ + const shared=freeze({items:[{title:'stable',unknown:{retained:true}}]}),left=encodeRecoveryValue({shared}),right=encodeRecoveryValue({shared});assert.equal(left.shared,right.shared);assert.notEqual(left.shared,shared);assert.throws(()=>{left.shared.items[0].title='changed';},TypeError); + const mutable={title:'before'},shallow=Object.freeze({mutable});assert.equal(encodeRecoveryValue(shallow).mutable.title,'before');mutable.title='after';assert.equal(encodeRecoveryValue(shallow).mutable.title,'after');assert.equal(encodeRecoveryValue(shallow).mutable===mutable,false); +}); +test('frozen getters, custom prototypes and internally mutable objects are evaluated again',()=>{ + let current='first';const getter=Object.freeze({get title(){return current;}});assert.equal(encodeRecoveryValue(getter).title,'first');current='second';assert.equal(encodeRecoveryValue(getter).title,'second'); + let enabled=false;const dynamic={value:'ordinary'};Object.defineProperty(dynamic,'toJSON',{get(){return enabled?()=>({changed:true}):undefined;}});Object.freeze(dynamic);assert.deepEqual(encodeRecoveryValue(dynamic),{value:'ordinary'});enabled=true;assert.deepEqual(encodeRecoveryValue(dynamic),{changed:true}); + const date=Object.freeze(new Date('2026-01-01Z'));const parent=Object.freeze({date});assert.deepEqual(encodeRecoveryValue(parent),json(parent));date.setUTCFullYear(2027);assert.deepEqual(encodeRecoveryValue(parent),json(parent)); + const prototype=Object.create(Array.prototype);prototype[0]='old';const array=new Array(1);Object.setPrototypeOf(array,prototype);Object.freeze(array);assert.deepEqual(encodeRecoveryValue(array),['old']);prototype[0]='new';assert.deepEqual(encodeRecoveryValue(array),['new']); +}); +test('cached subtree height cannot bypass the depth bound and omitted object fields do not count',()=>{ + const nest=(count,leaf)=>{for(let i=0;iencodeRecoveryValue(nest(64,leaf)),/invalid_recovery_payload/); + assert.equal(validJson(encodeRecoveryValue(nest(64,Object.freeze({optional:undefined})))),true);assert.throws(()=>encodeRecoveryValue(nest(64,Object.freeze([undefined]))),/invalid_recovery_payload/); + const cycle={};cycle.self=cycle;assert.throws(()=>encodeRecoveryValue(cycle),/invalid_recovery_payload/);assert.throws(()=>encodeRecoveryValue({big:1n})); +}); +test('only factory-owned images are recognized; immutable-looking impostors retain defensive-copy handling',()=>{ + const mutable={title:'before'},image=sealRecoveryImage(2,3,2,{mutable});assert.equal(isSealedRecoveryImage(image),true);assert.equal(isSealedRecoveryImage(structuredClone(image)),false);assert.equal(isSealedRecoveryImage(Object.freeze({...image})),false);mutable.title='after';assert.equal(image.payload.mutable.title,'before');assert.ok(Object.isFrozen(image.payload.mutable));assert.throws(()=>sealRecoveryImage(2,1,2,{}),/corrupt_recovery_image/);assert.throws(()=>sealRecoveryImage(3,1,1,{}),/corrupt_recovery_image/); +});