Yield recovery container encoding without changing durable cuts

This commit is contained in:
2026-11-11 04:04:24 +08:00
parent 57dab91a9c
commit b4c40d3087
7 changed files with 195 additions and 18 deletions
+25 -1
View File
@@ -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
+10
View File
@@ -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<object>();
export function dataArray<T>(values: Iterable<T>): readonly T[] {
const result = Object.freeze(Array.from(values)); owned.add(result); return result;
}
export function dataRecord<T extends object>(value: T): Readonly<T> {
const result = Object.freeze({...value}); owned.add(result); return result;
}
export const isDataContainer = (value: object) => owned.has(value);
+2 -2
View File
@@ -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();
+75 -5
View File
@@ -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;i<text.length;i++){cons
const scalarBytes=(value:unknown)=>utf8Length(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<object>();
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<object>();
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<string,unknown>)[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<RecoveryImage> {
validateHeader(formatVersion,rootRevision,localRevision);
const plan=prepareEncoding(payload);
const scheduler=(globalThis as typeof globalThis & {scheduler?:{yield?:()=>Promise<void>}}).scheduler;
const budget=createImportBudget(new AbortController().signal,{...options,yieldTask:options.yieldTask??(typeof scheduler?.yield==='function'?()=>scheduler.yield!():undefined)});
function* encode(plan:Prepared):Generator<Promise<void>,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<string,Json>=array?[]:{};
for(let index=0;index<plan.children.length;index++){
const entry=plan.children[index],child='height' in entry?entry:yield* encode(entry);
reusable&&=child.reusable;
if(array){
if(child.value===undefined&&plan.depth+1>64)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);}
+18 -10
View File
@@ -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<object,readonly (readonly [unknown,unknown])[]>();
const noEntries=Object.freeze([]);
const noEntries=dataArray([]);
function mapValue<K,V>(map?:ReadonlyMap<K,V>):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<Document,ReturnType<typeof encodeDocumentValue>>();
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<RecoveryImage> {
return sealRecoveryImageAsync(...recoveryInput(snapshot),options);
}
type ObjectValue = Record<string, unknown>;
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
+24
View File
@@ -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;}
});
+41
View File
@@ -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<n;i++)leaf=dataRecord({child:leaf});return leaf;};
const leaf=freeze({value:1});encodeRecoveryValue(leaf);
await assert.rejects(sealRecoveryImageAsync(17,1,1,nested(64,leaf)),/invalid_recovery_payload/);
await assert.rejects(sealRecoveryImageAsync(17,1,1,nested(64,dataArray([undefined]))),/invalid_recovery_payload/);
assert.ok(await sealRecoveryImageAsync(17,1,1,nested(64,dataRecord({omitted:undefined}))));
await assert.rejects(sealRecoveryImageAsync(17,0,1,{}),/corrupt_recovery_image/);
});