From 72a8e011144827023ed7c25de467dc7cc523cb5d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 11 Nov 2026 01:56:43 +0800 Subject: [PATCH] Reuse validated immutable history ranges during resource retention --- headless/README.md | 15 +++++++++ src/lib/canvas-core/history-transition.ts | 27 +++++++++++++++- src/lib/canvas-runtime/resources.ts | 3 +- tests/headless/history-transition.test.mjs | 36 ++++++++++++++++++++++ tests/headless/resource-pin-scan.test.mjs | 13 ++++++-- 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/headless/README.md b/headless/README.md index 99b4024..39be290 100644 --- a/headless/README.md +++ b/headless/README.md @@ -1028,3 +1028,18 @@ latest value and retained focus. These frame opportunities exclude time before the browser dispatches the input event; they are not end-to-end key latency or proof that every intermediate composition string reached the display. Mixed workload timings must be kept separate from the no-input copy baseline. + +Resource retention reuses flattened history-transition changes across durability +cuts only after successful validation and a descriptor-based proof of immutable +plain data. Proofs are weakly held. Every own property must be a data property; +shallow freezing, accessors, custom prototypes, sparse arrays and hidden cycles +do not qualify. Cached ranges are frozen. Mutable evidence remains live and is +validated on every read. This cache contains neither resource IDs nor schema +results: each scan still applies the current registry and keeps working/ACK +owners separate. Explicit `validateHistoryTransition(..., checkChanges)` calls +always execute the caller's validator. No persistence/wire format changes. + +CPU/allocation diagnostics found repeated history-transition validation under +resource retention. The production mixed-input probe and a same-machine old +history-path control both pass, but observed total runtimes are similar; this +change demonstrates reduced repeated interpretation, not an end-to-end speedup. diff --git a/src/lib/canvas-core/history-transition.ts b/src/lib/canvas-core/history-transition.ts index 0c0bd06..5304c61 100644 --- a/src/lib/canvas-core/history-transition.ts +++ b/src/lib/canvas-core/history-transition.ts @@ -5,6 +5,24 @@ type Splice={readonly offset:number;readonly beforeLength:number;readonly aft type Meta={readonly label:string;readonly groupId?:Id}; type EntryPatch={readonly id:Id;readonly before:Meta|null;readonly after:Meta|null;readonly changes:Splice;readonly operationIds:Splice}; export type HistoryTransition={readonly version:1;readonly rootRevision:number;readonly entryId?:Id;readonly before:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly after:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly entries:readonly EntryPatch[]}; +// Resource scans revisit the same transition across many durability cuts. Cache +// only its flattened changes, never registry-dependent references or callbacks. +const transitionChanges=new WeakMap(); +const immutableData=new WeakSet(); +function immutablePlainData(value:unknown,depth=0):boolean{ + if(depth>64)return false; + if(value===null||typeof value!=='object')return typeof value!=='function'; + if(immutableData.has(value))return true; + if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false; + if(Array.isArray(value))for(let i=0;i{if(!entry)return null;const{id:_id,changes:_changes,operationIds:_operations,...remaining}=entry;return remaining;}; function splice(before:readonly T[],after:readonly T[]):Splice{ let offset=0,suffix=0;while(offset!entries.has(id)))throw fault('history_transition_conflict'); return Object.freeze({undo:freeze([...destination.undo]),redo:freeze([...destination.redo]),entries:readMap(entries)}); } -export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{validateHistoryTransition(transition);return transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);} +export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{ + const cached=transitionChanges.get(transition);if(cached)return cached; + validateHistoryTransition(transition); + const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]); + const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after')); + if(!ownRanges||!immutablePlainData(transition))return changes; + const result=Object.freeze(changes);transitionChanges.set(transition,result);return result; +} diff --git a/src/lib/canvas-runtime/resources.ts b/src/lib/canvas-runtime/resources.ts index 2085207..6f2955d 100644 --- a/src/lib/canvas-runtime/resources.ts +++ b/src/lib/canvas-runtime/resources.ts @@ -81,7 +81,8 @@ export function createResourcePinScan(history:HistoryState,ledger:LedgerSnapshot for(const entry of history.entries.values())rememberTargets(entry.changes); let interpreted=true; for(const record of ledger.operations.values()){ - // Validate every understood transition, including resolved evidence, as before. + // Obtain validated ranges for every understood transition, including resolved + // evidence. The core may reuse only proven immutable transition data. if(record.intent.historyTransition?.version===1)transitions.set(record,historyTransitionChanges(record.intent.historyTransition)); if(record.phase!=='resolved')rememberTargets(record.intent.changes); if(record.intent.historyEffect&&record.intent.historyEffect.version!==1)interpreted=false; diff --git a/tests/headless/history-transition.test.mjs b/tests/headless/history-transition.test.mjs index 0f8cb85..63fd5a3 100644 --- a/tests/headless/history-transition.test.mjs +++ b/tests/headless/history-transition.test.mjs @@ -97,3 +97,39 @@ test('history-disabled replacement evidence cannot be omitted merely because the test('adoption evidence preserves replacement sequence even when all history transitions are no-ops',async()=>{ const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first',{history:false});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'second'}},{history:false});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map([...engine.getSnapshot().runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,2);image.payload.runtime.recoveries[0][1].historyReplay.events.reverse();assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();} }); + + +test('resource scans reuse validated immutable transition ranges without skipping caller validation',()=>{ + const editor=createEditor({registry,document});edit(editor,'cached'); + const transition=[...editor.getSnapshot().operations.values()].at(-1).historyTransition; + const first=historyTransitionChanges(transition),second=historyTransitionChanges(transition); + assert.equal(first,second);assert.ok(Object.isFrozen(first));assert.throws(()=>first.push(first[0]),TypeError); + let checked=0;validateHistoryTransition(transition,()=>{checked++;});assert.equal(checked,2*transition.entries.length); + assert.throws(()=>validateHistoryTransition(transition,()=>{throw Error('current schema rejected');}),/current schema rejected/); +}); + +test('mutable, shallow frozen and accessor transition evidence remains live across resource scans',()=>{ + const editor=createEditor({registry,document});edit(editor,'source'); + const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition; + const mutable=structuredClone(original),shallow=Object.freeze(structuredClone(original)); + for(const transition of [mutable,shallow]){ + const first=historyTransitionChanges(transition);transition.entries[0].changes.after[0]={...first[0],after:'changed'}; + const second=historyTransitionChanges(transition);assert.notEqual(first,second);assert.equal(second[0].after,'changed'); + transition.entries[0].changes.offset=999;assert.throws(()=>historyTransitionChanges(transition),/invalid_history_transition/); + } + let current=structuredClone(original).entries; + const accessor=Object.freeze({...original,get entries(){return current;}}); + const first=historyTransitionChanges(accessor);current=structuredClone(original).entries;current[0].changes.after[0]={...first[0],after:'getter changed'}; + assert.equal(historyTransitionChanges(accessor)[0].after,'getter changed'); +}); + +test('hidden frozen cycles and sparse transition data do not qualify for cached ranges',()=>{ + const editor=createEditor({registry,document});edit(editor,'source'); + const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition; + const cycle={};Object.defineProperty(cycle,'hidden',{value:cycle});Object.freeze(cycle); + // Non-enumerable metadata is outside JSON validation but must not recurse forever. + const hidden={...original};Object.defineProperty(hidden,'hidden',{value:cycle});Object.freeze(hidden); + assert.notEqual(historyTransitionChanges(hidden),historyTransitionChanges(hidden)); + const sparse=Object.freeze([,]);const withSparse={...original};Object.defineProperty(withSparse,'hidden',{value:sparse});Object.freeze(withSparse); + assert.notEqual(historyTransitionChanges(withSparse),historyTransitionChanges(withSparse)); +}); diff --git a/tests/headless/resource-pin-scan.test.mjs b/tests/headless/resource-pin-scan.test.mjs index 098819d..d6a40bb 100644 --- a/tests/headless/resource-pin-scan.test.mjs +++ b/tests/headless/resource-pin-scan.test.mjs @@ -4,9 +4,9 @@ import {createDocument,createRegistry,builtinComponents,describeHistoryTransitio import {createResourcePinScan,resourcePins} from '../../src/lib/canvas-runtime/resources.ts'; import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts'; const registry=createRegistry(builtinComponents()),empty={undo:[],redo:[],entries:new Map()},node=(id,asset)=>({id,incarnation:1,ownerCanvasId:'root',componentId:asset?'image':'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...(asset?{assetId:asset}:{})}}),doc=(records=[])=>createDocument('p',[{id:'root',parent:null}],records); -test('one retention pass interprets historical evidence once for working and ACK while preserving their separate owners',()=>{ +test('immutable history ranges are reused across retention passes while working and ACK keep separate owners',()=>{ const entry={id:'h',label:'h',changes:[{kind:'element',id:'past',before:null,after:node('past','historical')}],operationIds:[]},raw=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1);let reads=0;const transition=new Proxy(raw,{get(target,key,receiver){if(key==='entries')reads++;return Reflect.get(target,key,receiver);}}),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])},scan=createResourcePinScan(empty,ledger,registry),preparationReads=reads;assert.ok(preparationReads>0);scan(doc([node('text')]));scan(doc([node('remote','ack')]));assert.equal(reads,preparationReads); - const snapshot={document:doc([node('text')]),history:empty,runtime:{ledger,acknowledged:doc([node('remote','ack')]),resourcePins:new Map(),resources:new Map(),runs:new Map()}};reads=0;const result=resourceRetentionPins(snapshot,registry);assert.equal(reads,preparationReads);assert.equal(result.complete,true);assert.deepEqual([...result.pins.get('historical')],['history-transition:o']);assert.deepEqual([...result.pins.get('ack')],['acknowledged:element:remote:1']); + const snapshot={document:doc([node('text')]),history:empty,runtime:{ledger,acknowledged:doc([node('remote','ack')]),resourcePins:new Map(),resources:new Map(),runs:new Map()}};reads=0;const result=resourceRetentionPins(snapshot,registry);assert.equal(reads,0);assert.equal(result.complete,true);assert.deepEqual([...result.pins.get('historical')],['history-transition:o']);assert.deepEqual([...result.pins.get('ack')],['acknowledged:element:remote:1']); }); test('historical data fields retain schemas from resolved creations, conservative fallback pins and explicit owner order',()=>{ const field={kind:'field',target:{id:'deleted',incarnation:1},field:'data.assetId',before:'old',after:'new'},history={undo:['h'],redo:[],entries:new Map([['h',{id:'h',label:'h',changes:[field],operationIds:[]}]])},ledger={operations:new Map([['resolved',{phase:'resolved',intent:{id:'resolved',sequence:1,operationType:'document.change',changes:[{kind:'element',id:'deleted',before:null,after:node('deleted','unreferenced')}]} }]])},snapshot={document:doc(),history},result=resourcePins(snapshot,ledger,registry,new Map([['explicit',['old','extra']]]));assert.equal(result.complete,true);assert.deepEqual([...result.pins].map(([id,owners])=>[id,[...owners]]),[['old',['history:h','explicit']],['new',['history:h']],['extra',['explicit']]]);ledger.operations.clear();assert.equal(resourcePins(snapshot,ledger,registry,new Map()).complete,false); @@ -14,3 +14,12 @@ test('historical data fields retain schemas from resolved creations, conservativ test('new passes reread mutable input membership and still reject malformed resolved evidence with no resource fields',()=>{ const history={undo:[],redo:[],entries:new Map()},ledger={operations:new Map()},snapshot={document:doc(),history};assert.equal(resourcePins(snapshot,ledger,registry,new Map()).pins.size,0);history.entries.set('h',{id:'h',label:'h',changes:[{kind:'element',id:'image',before:null,after:node('image','late')}],operationIds:[]});assert.ok(resourcePins(snapshot,ledger,registry,new Map()).pins.has('late'));ledger.operations.set('bad',{phase:'resolved',intent:{id:'bad',sequence:1,changes:[],historyTransition:{version:1,rootRevision:-1,before:{undo:[],redo:[]},after:{undo:[],redo:[]},entries:[]}}});assert.throws(()=>resourcePins(snapshot,ledger,registry,new Map()),/invalid_history_transition/); }); + + +test('cached immutable transition ranges still use each current registry resource definition',()=>{ + const element={...node('historical','first'),data:{...node('historical','first').data,otherAsset:'second'}},entry={id:'h',label:'h',changes:[{kind:'element',id:element.id,before:null,after:element}],operationIds:[]}; + const transition=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])}; + const otherRegistry=createRegistry(builtinComponents().map(component=>component.id==='image'?{...component,resourceFields:['otherAsset']}:component)); + const first=createResourcePinScan(empty,ledger,registry)(doc()),second=createResourcePinScan(empty,ledger,otherRegistry)(doc()); + assert.deepEqual([...first.pins.keys()],['first']);assert.deepEqual([...second.pins.keys()],['second']);assert.equal(first.complete,true);assert.equal(second.complete,true); +});