diff --git a/headless/README.md b/headless/README.md index 12e871a..c102b5f 100644 --- a/headless/README.md +++ b/headless/README.md @@ -978,10 +978,34 @@ changes still commit in one transaction. No Root is skipped. Reopening validates headers/sequence, hashes exact Blob bytes, decodes strict UTF-8, checks the decoded header and then applies later deltas. Future checkpoint versions reject reads and writes; malformed/tampered data is never treated as an empty document. Legacy -recovery, JSON parse, recovery-image encoding, same-cut retry comparison and +recovery, JSON parse, recovery-image capture, same-cut retry comparison and individual native operations still have synchronous work; this is not a strict 6ms bound for all storage paths or a disk-space reduction guarantee. +Before Blob serialization, ProjectEngine now awaits cooperative recovery-image +encoding inside its existing ordered Root writer. Private `dataArray` / `dataRecord` +constructors copy and freeze DTO container membership; they do not claim their +children are immutable. Preparation captures each unknown subtree synchronously, +including getters, custom `toJSON`, Dates and external shallow-frozen records. +Previously validated immutable codec facts can be reused. Only then does a +budgeted traversal assemble the owned containers, preserving key order, omitted +values, sparse-array normalization, depth checks and exact UTF-8 byte facts. +The default uses `scheduler.yield()` when available (with its receiver), otherwise +a timer, sharing the 6ms soft budget / 16384-work ceiling. One shared budget spans +nested containers. Public synchronous encoding and recovery/storage formats stay +unchanged; async images receive the same codec ownership seal without a second +full encoding pass. + +A yield does not publish or skip a Root. Later edits may publish while encoding +is suspended; the old image still contains its captured state, and the next cut +is saved after it. Request dispatch retains the durable-prefix barrier. Encoding +or scheduling failures retain the pending Root for retry and produce no partial +storage commit. Initial DTO membership copying, synchronous preparation, unknown +subtrees, first-time immutable normalization and individual scalar operations +remain synchronous. This is partial time slicing, not a strict whole-Root latency +bound. In particular, large history/import objects still need producer ownership +review before their internal loops can safely be deferred. + A blocked schema upgrade retires its pending open. If the old connection closes later, that abandoned request aborts in `onupgradeneeded` rather than silently upgrading after rejection. A fresh adapter can retry once the blocking client has diff --git a/src/lib/canvas-core/data-containers.ts b/src/lib/canvas-core/data-containers.ts new file mode 100644 index 0000000..fd3aa2b --- /dev/null +++ b/src/lib/canvas-core/data-containers.ts @@ -0,0 +1,10 @@ +// Private shape provenance: values may still be mutable and require capture. +// Always copy; external Object.freeze is never accepted as ownership evidence. +const owned = new WeakSet(); +export function dataArray(values: Iterable): readonly T[] { + const result = Object.freeze(Array.from(values)); owned.add(result); return result; +} +export function dataRecord(value: T): Readonly { + const result = Object.freeze({...value}); owned.add(result); return result; +} +export const isDataContainer = (value: object) => owned.has(value); diff --git a/src/lib/canvas-runtime/engine.ts b/src/lib/canvas-runtime/engine.ts index b3f5260..e4a505b 100644 --- a/src/lib/canvas-runtime/engine.ts +++ b/src/lib/canvas-runtime/engine.ts @@ -37,7 +37,7 @@ import { createRunTracker, validateRunInput, runContainsTarget, type RunPort, ty import { createNetworkGate } from './network.ts'; import { createLedger, type FrozenRequest } from './ledger.ts'; import { operationCanvases, replayPending, validateCanonicalRead } from './reconcile.ts'; -import { decodeRecovery, encodeRecovery } from './recovery.ts'; +import { decodeRecovery, encodeRecoveryAsync } from './recovery.ts'; import { createScheduler, type Clock, type OperationStatus } from './scheduler.ts'; import { planConflictRecovery, type ConflictPlan, type RecoveryChoice } from './conflicts.ts'; import { stageAppliedRecovery } from './applied-recovery.ts'; @@ -213,7 +213,7 @@ export async function openProjectEngine(options: EngineOptions) { const files = [...fileWrites].filter(([id]) => snapshot.runtime.resources.has(id)||sourceFiles.has(id)).map(([id, bytes]) => ({ id, bytes })); const deleteFiles=[...[...snapshot.runtime.resources.values()].filter(resource=>resource.retirement?.purgedAt!==undefined&&!durablyPurged.has(resource.id)).map(resource=>resource.id),...[...sourceFiles].filter(([id,entry])=>entry.file.retirement?.purgedAt!==undefined&&!durablyPurged.has(id)).map(([id])=>id),...[...(snapshot.runtime.orphanFiles??[])].filter(([id,file])=>file.retirement?.purgedAt!==undefined&&!durablyPurged.has(id)).map(([id])=>id)]; const depot=options.assets?.authority&&options.storage.assetDepot?await prepareDepotCut({snapshot,registry:options.registry,storage:options.storage,leader,authority:options.assets.authority.scope,files:fileWrites,adoptedFiles,displays:displayPins,temporary:admissionPins(snapshot)}):undefined; - const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot), files,deleteFiles,...(depot?{depot}:{}) }); + const receipt = await options.storage.commit(leader, { image: await encodeRecoveryAsync(snapshot), files,deleteFiles,...(depot?{depot}:{}) }); if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt'); confirmedLedgerGeneration=Math.max(confirmedLedgerGeneration,snapshot.runtime.ledger.generation); durableRootRevision = revision;durableImportSeals=importSeals(snapshot.runtime.imports); pending.delete(revision);notifyDurability(); diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts index 6a031a7..893217a 100644 --- a/src/lib/canvas-runtime/recovery-encoding.ts +++ b/src/lib/canvas-runtime/recovery-encoding.ts @@ -1,5 +1,7 @@ import type { Json } from '../canvas-core/types.ts'; import { fault, freeze, validJson } from '../canvas-core/values.ts'; +import {isDataContainer} from '../canvas-core/data-containers.ts'; +import {createImportBudget, type ImportScheduling} from './import-budget.ts'; import type { RecoveryImage } from './storage-port.ts'; type Encoded = {value:Json|undefined;height:number;reusable:boolean;bytes:number}; @@ -8,7 +10,7 @@ function utf8Length(text:string){let bytes=0;for(let i=0;iutf8Length(JSON.stringify(value)); /** JSON value semantics, with reuse only for transitively frozen plain data properties. */ -export function encodeRecoveryValue(input:unknown):Json { +function createEncoder() { 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,bytes:scalarBytes(value)};} @@ -50,13 +52,81 @@ export function encodeRecoveryValue(input:unknown):Json { const encoded={value:Object.freeze(result) as Json,height,reusable,bytes};encodedSizes.set(result,bytes);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; + return visit; +} +export function encodeRecoveryValue(input:unknown):Json { + const result=createEncoder()(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|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { +type ImageVersion = RecoveryImage['formatVersion']; +function validateHeader(formatVersion:ImageVersion,rootRevision:number,localRevision:number){ if(![1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image'); - const encoded=encodeRecoveryValue(payload),image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encoded});sealed.add(image);imageSizes.set(image,JSON.stringify({...image,payload:null}).length-4+(encoded&&typeof encoded==='object'?encodedSizes.get(encoded)??scalarBytes(encoded):scalarBytes(encoded)));return image; +} +function finishImage(formatVersion:ImageVersion,rootRevision:number,localRevision:number,encoded:Json):RecoveryImage { + const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encoded});sealed.add(image);imageSizes.set(image,JSON.stringify({...image,payload:null}).length-4+(encoded&&typeof encoded==='object'?encodedSizes.get(encoded)??scalarBytes(encoded):scalarBytes(encoded)));return image; +} +/** Only codec-owned, validated immutable images qualify for the storage copy fast path. */ +export function sealRecoveryImage(formatVersion:ImageVersion,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage { + validateHeader(formatVersion,rootRevision,localRevision); + return finishImage(formatVersion,rootRevision,localRevision,encodeRecoveryValue(payload)); +} + +type Prepared = Encoded | {source:object;depth:number;children:Prepared[];keys?:string[]}; +// Read unknown objects synchronously, before the first suspension. Shape-owned +// containers can include external values; each such occurrence is captured now. +// Only our fresh container shape and previously proven encoded values defer. +function prepareEncoding(input:unknown) { + const normalize=createEncoder(),visiting=new Set(); + function prepare(value:unknown,depth:number,key:string):Prepared { + if(!value||typeof value!=='object')return normalize(value,depth,key); + const cached=immutable.get(value); + if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;} + // Prototype toJSON changes must retain ordinary JSON semantics, including + // accessors. Inspect descriptors without evaluating them an extra time. + let prototype:object|null=value,hasToJSON=false; + while(prototype){if(Object.getOwnPropertyDescriptor(prototype,'toJSON')){hasToJSON=true;break;}prototype=Object.getPrototypeOf(prototype);} + if(!isDataContainer(value)||hasToJSON)return normalize(value,depth,key); + if(depth>64||visiting.has(value))throw fault('invalid_recovery_payload'); + visiting.add(value); + try { + const keys=Array.isArray(value)?undefined:Object.keys(value),children:Prepared[]=[]; + if(keys)for(const name of keys)children.push(prepare((value as Record)[name],depth+1,name)); + else for(let index=0;index<(value as unknown[]).length;index++)children.push(prepare((value as unknown[])[index],depth+1,String(index))); + return {source:value,depth,children,keys}; + }finally{visiting.delete(value);} + } + return prepare(input,0,''); +} + +/** Capture external data immediately; cooperatively encode stable owned tables. + * Time slicing is soft: synchronous capture of an unknown subtree remains indivisible. + * The returned image has exactly the synchronous codec's wire representation. */ +export async function sealRecoveryImageAsync(formatVersion:ImageVersion,rootRevision:number,localRevision:number,payload:unknown,options:ImportScheduling={}):Promise { + validateHeader(formatVersion,rootRevision,localRevision); + const plan=prepareEncoding(payload); + const scheduler=(globalThis as typeof globalThis & {scheduler?:{yield?:()=>Promise}}).scheduler; + const budget=createImportBudget(new AbortController().signal,{...options,yieldTask:options.yieldTask??(typeof scheduler?.yield==='function'?()=>scheduler.yield!():undefined)}); + function* encode(plan:Prepared):Generator,Encoded,void>{ + if('height' in plan)return plan; + let height=0,bytes=2,reusable=true,properties=0; + const array=plan.keys===undefined,result:Json[]|Record=array?[]:{}; + for(let index=0;index64)throw fault('invalid_recovery_payload'); + (result as Json[]).push(child.value??null);bytes+=(index?1:0)+(child.value===undefined?4:child.bytes);height=Math.max(height,1+child.height); + }else if(child.value!==undefined){ + const name=plan.keys![index];bytes+=(properties++?1:0)+scalarBytes(name)+1+child.bytes; + Object.defineProperty(result,name,{value:child.value,enumerable:true});height=Math.max(height,1+child.height); + } + const pause=budget.step();if(pause)yield pause; + } + const encoded={value:Object.freeze(result) as Json,height,reusable,bytes};encodedSizes.set(result,bytes);if(reusable)immutable.set(plan.source,encoded);return encoded; + } + const iterator=encode(plan);let step=iterator.next();while(!step.done){await step.value;step=iterator.next();} + if(step.value.value===undefined)throw fault('invalid_recovery_payload'); + return finishImage(formatVersion,rootRevision,localRevision,step.value.value); } 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 b169898..b93bceb 100644 --- a/src/lib/canvas-runtime/recovery.ts +++ b/src/lib/canvas-runtime/recovery.ts @@ -4,7 +4,9 @@ import {validateLoadingRuntime,type LoadingState} from './loading.ts'; import {validateOrphanFile,type OrphanFile} from './orphan-files.ts'; import {importFiles} from './import-file-retention.ts'; import {resourceRetentionPins} from './resource-retention.ts'; -import { sealRecoveryImage } from './recovery-encoding.ts'; +import {dataArray,dataRecord} from '../canvas-core/data-containers.ts'; +import type {ImportScheduling} from './import-budget.ts'; +import { sealRecoveryImage, sealRecoveryImageAsync } from './recovery-encoding.ts'; import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts'; import { validateDefaultImageSizing } from './default-image-size.ts'; import { validateImportJob, type ImportJob } from './imports.ts'; @@ -24,19 +26,19 @@ import type { RecoveryImage } from './storage-port.ts'; // Membership is stable only for core-owned read views. Never infer ownership // from Object.freeze: a frozen native Map can still change through .set(). const entryViews=new WeakMap(); -const noEntries=Object.freeze([]); +const noEntries=dataArray([]); function mapValue(map?:ReadonlyMap):readonly (readonly [K,V])[]{ if(!map)return noEntries; if(!isOwnedMapView(map))return [...map]; let value=entryViews.get(map); - if(!value){value=Object.freeze([...map].map(pair=>Object.freeze(pair)));entryViews.set(map,value);} + if(!value){value=dataArray([...map].map(pair=>dataArray(pair) as readonly [K,V]));entryViews.set(map,value);} return value as readonly (readonly [K,V])[]; } function encodeDocumentValue(document:Document){ // Freeze our own containers only. The codec still independently proves whether // each nested value can be reused, including external mutable records. - return Object.freeze({projectId:document.projectId,canvases:Object.freeze([...document.canvases.values()]),elements:Object.freeze([...document.elements.values()]),tombstones:Object.freeze([...document.tombstones].map(pair=>Object.freeze(pair)))}); + return dataRecord({projectId:document.projectId,canvases:dataArray(document.canvases.values()),elements:dataArray(document.elements.values()),tombstones:dataArray([...document.tombstones].map(pair=>dataArray(pair)))}); } const documentViews=new WeakMap>(); function documentValue(document:Document){ @@ -44,14 +46,20 @@ function documentValue(document:Document){ let value=documentViews.get(document);if(!value){value=encodeDocumentValue(document);documentViews.set(document,value);}return value; } /** Plain records only: functions, credentials, renderer objects, URLs and Map methods never serialize. */ -export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage { - const payload = { +function recoveryInput(snapshot: EngineSnapshot) { + const payload = dataRecord({ document: documentValue(snapshot.document), - history: { undo: snapshot.history.undo, redo: snapshot.history.redo, entries: mapValue(snapshot.history.entries) }, + history: dataRecord({ undo: snapshot.history.undo, redo: snapshot.history.redo, entries: mapValue(snapshot.history.entries) }), operations: mapValue(snapshot.operations), - runtime: { ...(snapshot.runtime.assetBindings?.size?{assetBindings:mapValue(snapshot.runtime.assetBindings)}:{}), ...(snapshot.runtime.loadingRead?{loadingRead:{document:documentValue(snapshot.runtime.loadingRead.document),versions:mapValue(snapshot.runtime.loadingRead.versions)}}:{}), ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:mapValue(snapshot.runtime.orphanFiles)}:{}), imports:mapValue(snapshot.runtime.imports), recoveries:mapValue(snapshot.runtime.recoveries), runs: mapValue(snapshot.runtime.runs), resources: mapValue(snapshot.runtime.resources), resourcePins: mapValue(snapshot.runtime.resourcePins), acknowledged: documentValue(snapshot.runtime.acknowledged), versions: mapValue(snapshot.runtime.versions), receipts: mapValue(snapshot.runtime.receipts), conflicts: mapValue(snapshot.runtime.conflicts), ledger: { ...snapshot.runtime.ledger, operations: mapValue(snapshot.runtime.ledger.operations), requests: mapValue(snapshot.runtime.ledger.requests) } }, - }; - return sealRecoveryImage([...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.integrityVersion===2)?17:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5)?16:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document')?15:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-json-array')?14:snapshot.runtime.assetBindings?.size?13:snapshot.runtime.loading?(snapshot.runtime.loading.version===6?12:snapshot.runtime.loading.version===5?11:snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8:7):[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload); + runtime: dataRecord({ ...(snapshot.runtime.assetBindings?.size?{assetBindings:mapValue(snapshot.runtime.assetBindings)}:{}), ...(snapshot.runtime.loadingRead?{loadingRead:dataRecord({document:documentValue(snapshot.runtime.loadingRead.document),versions:mapValue(snapshot.runtime.loadingRead.versions)})}:{}), ...(snapshot.runtime.loading?{loading:snapshot.runtime.loading}:{}), ...(snapshot.runtime.orphanFiles?.size?{orphanFiles:mapValue(snapshot.runtime.orphanFiles)}:{}), imports:mapValue(snapshot.runtime.imports), recoveries:mapValue(snapshot.runtime.recoveries), runs: mapValue(snapshot.runtime.runs), resources: mapValue(snapshot.runtime.resources), resourcePins: mapValue(snapshot.runtime.resourcePins), acknowledged: documentValue(snapshot.runtime.acknowledged), versions: mapValue(snapshot.runtime.versions), receipts: mapValue(snapshot.runtime.receipts), conflicts: mapValue(snapshot.runtime.conflicts), ledger: dataRecord({ ...snapshot.runtime.ledger, operations: mapValue(snapshot.runtime.ledger.operations), requests: mapValue(snapshot.runtime.ledger.requests) }) }), + }); + return [[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.integrityVersion===2)?17:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document'&&job.parserVersion===5)?16:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-document')?15:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.kind==='native-json-array')?14:snapshot.runtime.assetBindings?.size?13:snapshot.runtime.loading?(snapshot.runtime.loading.version===6?12:snapshot.runtime.loading.version===5?11:snapshot.runtime.loading.version===4?10:snapshot.runtime.loading.version===3?9:snapshot.runtime.loading.version===2?8:7):[...snapshot.operations.values()].some(intent=>intent.historyEffect)||[...snapshot.runtime.resources.values()].some(resource=>resource.sizing)?6:snapshot.runtime.orphanFiles?.size?5:[...(snapshot.runtime.imports?.values()??[])].some(job=>job.source?.retirement||job.source?.previousFiles?.length)?4:[...snapshot.runtime.resources.values()].some(resource=>resource.retirement)?3:snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload] as const; +} +export function encodeRecovery(snapshot:EngineSnapshot):RecoveryImage { + return sealRecoveryImage(...recoveryInput(snapshot)); +} +export function encodeRecoveryAsync(snapshot:EngineSnapshot,options:ImportScheduling={}):Promise { + return sealRecoveryImageAsync(...recoveryInput(snapshot),options); } 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/tests/headless/engine.test.mjs b/tests/headless/engine.test.mjs index 4b5bf8b..be52ce5 100644 --- a/tests/headless/engine.test.mjs +++ b/tests/headless/engine.test.mjs @@ -348,3 +348,27 @@ test('a failed final publication keeps the raw receipt and waiting claim instead test('receipt compaction is optional and invalid codecs cannot block a valid ACK or discard its raw payload',async()=>{ for(const compactResolvedReceipt of [undefined,()=>{throw Error('compact failed');},()=>undefined]){const storage=storageFixture(),port=persistenceFixture(compactResolvedReceipt?{compactResolvedReceipt}:{}),{engine,errors}=await open(storage,port);try{engine.editor.dispatch({type:'element.data',target,patch:{title:'keep raw'}});await engine.settle();const request=[...engine.getSnapshot().runtime.ledger.requests.values()][0];assert.equal(request.phase,'resolved');assert.deepEqual(engine.getSnapshot().runtime.receipts.get(request.id),{receipt:request.id});assert.equal(engine.getSnapshot().runtime.conflicts.size,0);assert.equal(errors.length,compactResolvedReceipt?1:0);}finally{await engine.close();}} }); + +test('encoding yields preserve the captured Root, ordered durable prefix and send barrier', {timeout:15000},async()=>{ + const previous=Object.getOwnPropertyDescriptor(globalThis,'scheduler'),entered=deferred(),resume=deferred();let hold=false,paused=false,yields=0; + Object.defineProperty(globalThis,'scheduler',{configurable:true,value:{async yield(){yields++;if(hold&&!paused){paused=true;entered.resolve();await resume.promise;}}}}); + let engine; + try{ + const rows=Array.from({length:17000},(_,i)=>({...original.elements.get('a'),id:i===0?'a':`encoding-${i}`})); + const document=createDocument('project',[{id:'canvas',parent:null}],rows),storage=storageFixture(),port=persistenceFixture({async submit(request){port.calls.push(['submit',request]);const stored=storage.image();assert.ok(stored.payload.runtime.ledger.requests.some(([id,row])=>id===request.id&&row.body===request.body&&row.phase==='dispatch-marked'));return{outcome:'unresolved'};}}); + ({engine}=await open(storage,port,{document,clock:idleClock()}));await engine.persist(); + const start=engine.getSnapshot().rootRevision,saved=storage.saved.length;hold=true; + assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'captured-first'}}).status,'applied'); + const captured=engine.getSnapshot(),first=engine.persist();await entered.promise; + assert.equal(storage.saved.length,saved);assert.equal(port.calls.length,0); + assert.equal(engine.editor.dispatch({type:'element.data',target,patch:{title:'edited-during-yield'}}).status,'applied'); + const second=engine.persist();assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'edited-during-yield'); + resume.resolve();await Promise.all([first,second]); + const cuts=storage.saved.slice(saved);assert.ok(yields>0); + assert.deepEqual(cuts.map(cut=>cut.rootRevision),Array.from({length:cuts.length},(_,i)=>start+i+1)); + assert.deepEqual(cuts[0],encodeRecovery(captured)); + assert.equal(cuts[0].payload.document.elements.find(row=>row.id==='a').data.title,'captured-first'); + assert.equal(storage.image().payload.document.elements.find(row=>row.id==='a').data.title,'edited-during-yield'); + assert.equal(engine.getDurability().durableRootRevision,engine.getSnapshot().rootRevision); + }finally{resume.resolve();if(engine)await engine.close();if(previous)Object.defineProperty(globalThis,'scheduler',previous);else delete globalThis.scheduler;} +}); diff --git a/tests/headless/recovery-encoding.test.mjs b/tests/headless/recovery-encoding.test.mjs index df3453d..108885a 100644 --- a/tests/headless/recovery-encoding.test.mjs +++ b/tests/headless/recovery-encoding.test.mjs @@ -31,3 +31,44 @@ test('sealed image byte sizes match UTF-8 JSON for reused data and all codec nor const {sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'),shared=freeze({text:'δΈ­ζ–‡πŸ˜€\ud800\n"\\',items:[null,1,true]}),payloads=[null,'πŸ˜€',-0,{shared,omitted:undefined,array:[,undefined,NaN]},new Date('2026-01-01Z'),{['__proto__']:'保留'},Object.freeze({get text(){return 'εŠ¨ζ€πŸ˜€';}})]; for(const payload of payloads)for(let revision=0;revision<3;revision++){const image=sealRecoveryImage(17,revision,revision,payload);assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength);assert.equal(sealedRecoveryByteLength(structuredClone(image)),undefined);} }); + + +test('cooperative sealing captures unknown values before yielding and retains exact bytes',async()=>{ + const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts'); + const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'); + let current='before',calls=[]; + const mutable={title:'before'},shallow=Object.freeze({mutable}),date=Object.freeze(new Date('2026-01-01Z')); + const custom=Object.freeze({toJSON(key){calls.push(key);return {key,current};}}); + const getter=Object.freeze({get title(){calls.push('getter');return current;}}); + const rows=dataArray(Array.from({length:1000},(_,i)=>dataRecord({id:i,text:'δΈ­ζ–‡πŸ˜€\ud800'}))); + const payload=dataRecord({rows,shallow,date,getter,left:custom,right:custom,['__proto__']:{safe:true}}); + const expected=sealRecoveryImage(17,2,1,payload);calls=[];let yields=0; + const pending=sealRecoveryImageAsync(17,2,1,dataRecord({...payload,rows:dataArray(rows)}),{now:(()=>{let t=0;return()=>t++;})(),frameBudgetMs:1,yieldTask:async()=>{yields++;current='after';mutable.title='after';date.setUTCFullYear(2027);}}); + assert.deepEqual(calls,['getter','left','right']); + mutable.title='outside';current='outside'; + const image=await pending; + // A second uncached table below exercises the work ceiling with a coarse clock. + assert.ok(yields>0);assert.deepEqual(image,expected);assert.equal(isSealedRecoveryImage(image),true); + assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength); + const beforeCoarse=yields; + const large=dataArray(Array.from({length:20000},(_,i)=>i)); + const more=await sealRecoveryImageAsync(17,3,2,large,{now:()=>100,frameBudgetMs:1,yieldTask:async()=>{yields++;}}); + assert.ok(yields>beforeCoarse);assert.deepEqual(more.payload,large); +}); + +test('cooperative preparation evaluates every unsafe occurrence and rejects errors without an image',async()=>{ + const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts'); + const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'); + let value=0; + const dynamic=Object.freeze({get title(){return ++value;}}),shared=dataRecord({dynamic}); + const image=await sealRecoveryImageAsync(17,1,1,dataRecord({left:shared,right:shared})); + assert.deepEqual(image.payload,{left:{dynamic:{title:1}},right:{dynamic:{title:2}}}); + let ticks=0; + await assert.rejects(sealRecoveryImageAsync(17,1,1,dataArray(Array.from({length:1000},(_,i)=>i)),{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{throw Error('yield_failed');}}),/yield_failed/); + const nested=(n,leaf)=>{for(let i=0;i