diff --git a/headless/README.md b/headless/README.md index 7d04843..1573bf9 100644 --- a/headless/README.md +++ b/headless/README.md @@ -1005,7 +1005,8 @@ subtrees, first-time immutable normalization and individual scalar operations remain synchronous. This is partial time slicing, not a strict whole-Root latency bound. Grouped history entries now keep shape ownership for newly built changes and operation-ID arrays, including undo/redo lifecycle remaps. Import runners keep it -for their own job envelopes, completed-key arrays and binding dictionaries; newly +for their own job envelopes, completed-key arrays, binding dictionaries and +pending locator tables; newly allocated identity tables use the same private constructor. The engine's source proof refresh preserves this envelope ownership. Arbitrary host checkpoint replacements keep defensive capture. These constructors still freeze nested data @@ -1125,3 +1126,13 @@ and is not a precise browser queue-time measurement. The final server document must retain the original's later edits while the copy retains its earlier source snapshot. Smaller scenarios retain the early burst because they can finish before a later publication threshold is observed. + +Pending import records remain bounded seekable locators, not decoded document +payloads. Runner-created locators, dependency arrays, parser appends and filtered +pending tables retain private shape ownership, so recovery encoding can slice +these waiting records even when endpoints have not arrived. Dependency admission, +capacity checks and durable send barriers are unchanged. The asynchronous encoder +also rechecks its immutable fact cache when assembling a prepared container: +when the same source occurs twice in one image, the second occurrence can reuse +the first completed encoding. Preparation still captures unsafe occurrences +independently; getters and mutable descendants never qualify for this reuse. diff --git a/src/lib/canvas-runtime/imports.ts b/src/lib/canvas-runtime/imports.ts index 9a67ebe..1443357 100644 --- a/src/lib/canvas-runtime/imports.ts +++ b/src/lib/canvas-runtime/imports.ts @@ -162,7 +162,7 @@ export function createImportRunner(ports:ImportRunnerPorts){ function completed(job:ImportJob,units:readonly ImportUnit[],pending:readonly ImportLocator[]){ const bindings:Record=Object.assign(Object.create(null),job.bindings??{}); for(const unit of units)for(const change of unit.changes)if(change.kind==='element'&&change.before===null&&change.after)bindings[change.id]={kind:'element',incarnation:change.after.incarnation};else if(change.kind==='canvas'&&change.before===null&&change.after)bindings[change.id]={kind:'canvas',...(change.after.parent?{entryElementId:change.after.parent.entryElementId}:{})}; - return next(job,{pending,committedSourceKeys:dataArray(freeze([...job.committedSourceKeys,...units.map(unit=>unit.key)])),bindings:dataRecord(freeze({...bindings}))}); + return next(job,{pending:dataArray(freeze(pending)),committedSourceKeys:dataArray(freeze([...job.committedSourceKeys,...units.map(unit=>unit.key)])),bindings:dataRecord(freeze({...bindings}))}); } function commit(units:readonly ImportUnit[],job:ImportJob,value:ImportJob){const base=current();if(base.generation!==job.generation)throw fault('stale_import_job');value=freeze(checkpointEvidence(value));const start=now(),result=ports.commit(units,value,job.generation),elapsed=Math.max(0,now()-start);if(!['applied','rejected'].includes(result?.status)||!equal(current(),result.status==='applied'?value:base))throw fault('invalid_import_commit');if(result.status==='applied')batches.record(units.length,elapsed);return result;} async function pump(signal:AbortSignal){ @@ -220,7 +220,7 @@ export function createImportRunner(ports:ImportRunnerPorts){ if(!members.every(unit=>unit.dependencies.every(key=>internal.has(key)||job.committedSourceKeys.includes(key))))continue; proposed=completed(job,members,job.pending.filter(value=>!internal.has(value.key))); result=await attempt(members,job,proposed);if(!result)return; - if(result.status==='rejected'){const code=result.code;publish(job,{pending:job.pending.filter(value=>!internal.has(value.key)),failures:[...job.failures,...group.map(row=>({key:row.key,cursor:row.cursor,code}))]});} + if(result.status==='rejected'){const code=result.code;publish(job,{pending:dataArray(freeze(job.pending.filter(value=>!internal.has(value.key)))),failures:[...job.failures,...group.map(row=>({key:row.key,cursor:row.cursor,code}))]});} await checkpoint();if(closed||signal.aborted||current().status!=='running')return; if(now()-frame>=budget){await(ports.yieldTask??yieldTask)();frame=now();} } @@ -256,9 +256,9 @@ export function createImportRunner(ports:ImportRunnerPorts){ else try{ const prepared=prepare(line,staged),key=prepared.unit.key; if(staged.committedSourceKeys.includes(key)||staged.pending.some(unit=>unit.key===key)||staged.failures.some(failure=>failure.key===key))throw fault('duplicate_import_source_key'); - const locator={key,cursor:line.cursor,nextCursor:line.nextCursor,dependencies:prepared.unit.dependencies,digest:await digest(prepared.unit),byteLength:new TextEncoder().encode(JSON.stringify(prepared.unit)).byteLength,changeCount:prepared.unit.changes.length}; + const locator=dataRecord(freeze({key,cursor:line.cursor,nextCursor:line.nextCursor,dependencies:dataArray(prepared.unit.dependencies),digest:await digest(prepared.unit),byteLength:new TextEncoder().encode(JSON.stringify(prepared.unit)).byteLength,changeCount:prepared.unit.changes.length})); cache(key,{unit:prepared.unit,digest:locator.digest,bytes:locator.byteLength}); - patch={cursor:line.nextCursor,stableIds:prepared.ids,pending:[...staged.pending,locator]}; + patch={cursor:line.nextCursor,stableIds:prepared.ids,pending:dataArray([...staged.pending,locator])}; }catch(error){patch={cursor:line.nextCursor,failures:[...staged.failures,{key:line.key,cursor:line.cursor,code:(error as {code?:string}).code??'import_decode_failed'}]};} if(closed||signal.aborted||current().generation!==base.generation)return; staged={...staged,...patch};records++; diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts index 3ca88ea..73b73d8 100644 --- a/src/lib/canvas-runtime/recovery-encoding.ts +++ b/src/lib/canvas-runtime/recovery-encoding.ts @@ -118,6 +118,11 @@ export async function sealRecoveryImageAsync(formatVersion:ImageVersion,rootRevi function* encode(plan:Prepared,depth=0,key=''):Generator,Encoded,void>{ if(plan===null||typeof plan!=='object')return normalize(plan,depth,key); if('height' in plan)return plan; + // Preparation must capture every unsafe occurrence before yielding. Once an + // earlier occurrence has assembled a proven immutable source, later plans + // can reuse that fact without rebuilding the same JSON containers. + const cached=immutable.get(plan.source); + if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;} let height=0,bytes=2,reusable=true,properties=0; const array=plan.keys===undefined,result:Json[]|Record=array?[]:{}; for(let index=0;index0);assert.deepEqual(image,sealRecoveryImage(17,1,1,job)); assert.equal(Object.keys(image.payload.stableIds).length,300);assert.equal(Object.keys(image.payload.bindings).length,300);await f.runner.close(); }); + + +test('waiting import locators and dependencies encode cooperatively before their endpoints arrive',async()=>{ + const {sealRecoveryImageAsync,sealRecoveryImage}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'); + const f=fixture(Array.from({length:300},(_,i)=>({id:'waiting-'+i,dependencies:['absent']})));await f.runner.start(); + const pending=f.root.job.pending;assert.equal(pending.length,300);assert.equal(f.root.document.elements.size,0); + let ticks=0,yields=0; + const image=await sealRecoveryImageAsync(17,1,1,pending,{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{yields++;}}); + assert.ok(yields>0);assert.deepEqual(image,sealRecoveryImage(17,1,1,pending)); + assert.deepEqual(image.payload[299].dependencies,['absent']);await f.runner.close(); +}); diff --git a/tests/headless/recovery-encoding.test.mjs b/tests/headless/recovery-encoding.test.mjs index c57898f..49d007f 100644 --- a/tests/headless/recovery-encoding.test.mjs +++ b/tests/headless/recovery-encoding.test.mjs @@ -96,3 +96,19 @@ test('unknown proxies retain the synchronous codec trap order and callback count const actual=await sealRecoveryImageAsync(17,1,1,payload); assert.deepEqual(actual,expected);assert.deepEqual(events,before); }); + + +test('repeated owned containers reuse facts assembled earlier in the same asynchronous image',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'); + const rows=dataArray(Array.from({length:1000},(_,i)=>dataRecord({index:i,text:'shared-δΈ­ζ–‡πŸ˜€'}))); + let tick=0,yields=0; + const image=await sealRecoveryImageAsync(17,1,1,dataRecord({left:rows,right:rows}),{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}}); + assert.ok(yields>0);assert.equal(image.payload.left,image.payload.right); + assert.equal(image.payload.left.length,1000);assert.equal(image.payload.left[999].index,999); + assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength); + // Unsafe descendants still need independent values for every occurrence. + let n=0;const unsafe=dataRecord({getValue:Object.freeze({get value(){return ++n;}})}); + const captured=await sealRecoveryImageAsync(17,1,1,dataArray([unsafe,unsafe])); + assert.deepEqual(captured.payload,[{getValue:{value:1}},{getValue:{value:2}}]); +});