diff --git a/headless/README.md b/headless/README.md index c102b5f..7d04843 100644 --- a/headless/README.md +++ b/headless/README.md @@ -1003,8 +1003,21 @@ 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. +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 +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 +before freezing their container, retaining the prior deep-freeze behavior; their +brand alone never permits mutable child values to escape capture. + +Primitive values captured in these tables cannot change while suspended. Their +normalization and UTF-8 sizing now run in the cooperative traversal rather than +the preparation scan. Unknown object/proxy reflection stays entirely within the +original normalizer; preparation adds no proxy traps or callback invocations. +Membership copying and the preparation scan itself remain synchronous, as do +unknown subtrees and individual native operations. A blocked schema upgrade retires its pending open. If the old connection closes later, that abandoned request aborts in `onupgradeneeded` rather than silently @@ -1102,3 +1115,13 @@ 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. + +The 10k production copy/input probe keeps one native editor focused from the +first presented copy through the 50% and 90% publication thresholds, read from +the actual import status UI. Samples record phase, committed count, focus/DOM +retention and input-to-two-rAF delay; middle/tail samples also record CDP dispatch +round-trip time separately. That round trip includes transport and driver work, +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. diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 4cbfca5..9391329 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -1,3 +1,4 @@ +import {dataArray,dataRecord} from './data-containers.ts'; import {stageCreation,materializeCreations} from './creation-draft.ts'; import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts'; import { applyChanges, createDocument, invert, targetElement, validateDocument } from './document.ts'; @@ -151,7 +152,7 @@ export function createEditor(options: EditorOptions(options: EditorOptions([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) }))); undo.push(historyEntryId); } while (undo.length > limit) entries.delete(undo.shift()!); @@ -266,8 +267,8 @@ export function createEditor(options: EditorOptions>; @@ -20,7 +21,7 @@ export function createImportIdentities(){ (ids as Record)[key]=id;used.add(id);return id; }, has:(id:Id)=>used.has(id), - finish:():Ids=>owned?{...ids}:ids, + finish:():Ids=>owned?dataRecord(ids):ids, }); }; return Object.freeze(Object.assign(capture,{clear(){cached=undefined;}})); diff --git a/src/lib/canvas-runtime/imports.ts b/src/lib/canvas-runtime/imports.ts index 382e81c..9a67ebe 100644 --- a/src/lib/canvas-runtime/imports.ts +++ b/src/lib/canvas-runtime/imports.ts @@ -1,3 +1,4 @@ +import {dataArray,dataRecord} from '../canvas-core/data-containers.ts'; import {createImportBatches} from './import-batches.ts'; import {createImportIdentities} from './import-identities.ts'; import {validateImportReadProof,type ImportReadProof} from './import-read-proof.ts'; @@ -146,7 +147,7 @@ export function createImportRunner(ports:ImportRunnerPorts){ else if(!equal(job.source,value.source))throw fault('invalid_import_checkpoint'); return value; } - function next(job:ImportJob,patch:Partial):ImportJob{const value={...job,...patch,version:3 as const,generation:job.generation+1};if(value.error===undefined)delete value.error;return freeze(checkpointEvidence(value));} + function next(job:ImportJob,patch:Partial):ImportJob{const value={...job,...patch,version:3 as const,generation:job.generation+1};if(value.error===undefined)delete value.error;const checked=checkpointEvidence(value);return checked===value?dataRecord(freeze(value)):freeze(checked);} function publish(job:ImportJob,patch:Partial){const value=next(job,patch);ports.publish(value,job.generation);if(!equal(current(),value))throw fault('invalid_import_publish');return value;} function prepare(line:ImportLine,job:ImportJob){ const ids=identities(job.stableIds,allocateIdentity); @@ -161,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:[...job.committedSourceKeys,...units.map(unit=>unit.key)],bindings:{...bindings}}); + return next(job,{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){ diff --git a/src/lib/canvas-runtime/recovery-encoding.ts b/src/lib/canvas-runtime/recovery-encoding.ts index 893217a..3ca88ea 100644 --- a/src/lib/canvas-runtime/recovery-encoding.ts +++ b/src/lib/canvas-runtime/recovery-encoding.ts @@ -71,21 +71,30 @@ export function sealRecoveryImage(formatVersion:ImageVersion,rootRevision:number return finishImage(formatVersion,rootRevision,localRevision,encodeRecoveryValue(payload)); } -type Prepared = Encoded | {source:object;depth:number;children:Prepared[];keys?:string[]}; +type Scalar = null|string|number|boolean|undefined|symbol; +type Prepared = Scalar | 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 kind=typeof value; + if(value===null||kind==='string'||kind==='number'||kind==='boolean'||kind==='undefined'||kind==='symbol'){ + if(depth>64&&value!==undefined&&typeof value!=='symbol')throw fault('invalid_recovery_payload'); + // Primitives cannot change while suspended. Defer normalization/UTF-8 work + // as well as allocation of their Encoded facts to the budgeted traversal. + return value as Scalar; + } + if(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;} + if(!isDataContainer(value))return normalize(value,depth,key); // 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(hasToJSON)return normalize(value,depth,key); if(depth>64||visiting.has(value))throw fault('invalid_recovery_payload'); visiting.add(value); try { @@ -95,7 +104,7 @@ function prepareEncoding(input:unknown) { return {source:value,depth,children,keys}; }finally{visiting.delete(value);} } - return prepare(input,0,''); + return {plan:prepare(input,0,''),normalize}; } /** Capture external data immediately; cooperatively encode stable owned tables. @@ -103,15 +112,17 @@ function prepareEncoding(input:unknown) { * 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 {plan,normalize}=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>{ + 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; 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'); diff --git a/tests/headless/core.test.mjs b/tests/headless/core.test.mjs index ba6f117..2e305e3 100644 --- a/tests/headless/core.test.mjs +++ b/tests/headless/core.test.mjs @@ -226,3 +226,15 @@ test('transport preparation cannot rewrite a committed document change or its id const before=editor.getSnapshot(),result=editor.dispatch({type:'element.data',target:target('a'),patch:{text:'must not publish'}}); assert.equal(result.code,'invalid_prepared_intent');assert.equal(editor.getSnapshot(),before); }); + +test('growing grouped history can yield during recovery encoding without altering undo data',async()=>{ + const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'); + const editor=setup(); + for(let i=0;i<300;i++)assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{title:'step-'+i}},{groupId:'large-history'}).status,'applied'); + const entry=editor.getSnapshot().history.entries.get(editor.getSnapshot().history.undo.at(-1)); + assert.equal(entry.changes.length,300);let tick=0,yields=0; + const pending=sealRecoveryImageAsync(17,1,1,entry,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}}); + assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{title:'later'}},{groupId:'large-history'}).status,'applied'); + const image=await pending;assert.ok(yields>0);assert.deepEqual(image.payload,JSON.parse(JSON.stringify(entry)));assert.equal(image.payload.changes.length,300); + assert.equal(editor.history.undo().status,'applied');assert.equal(editor.getSnapshot().document.elements.get('a').data.title,'a'); +}); diff --git a/tests/headless/imports.test.mjs b/tests/headless/imports.test.mjs index c68c12d..e169ecc 100644 --- a/tests/headless/imports.test.mjs +++ b/tests/headless/imports.test.mjs @@ -162,3 +162,12 @@ test('a cheap runner can use the engine-sized ceiling without dropping records o test('the larger ceiling still backs off when a measured commit exceeds the unchanged time budget',async()=>{ let clock=0;const records=Array.from({length:500},(_,index)=>({id:`cost-${index}`})),f=fixture(records,{ports:{maxBatch:128,now:()=>clock}}),commit=f.ports.commit;f.ports.commit=(units,...args)=>{clock+=units.length>40?12:1;return commit(units,...args);};await f.runner.start();const sizes=f.calls.map(batch=>batch.length),wide=sizes.findIndex(size=>size>40);assert.ok(wide>=0);assert.ok(sizes[wide+1]row.id));assert.equal(f.errors.length,0);await f.runner.close(); }); + +test('growing import tables yield when encoded while keeping captured IDs and bindings stable',async()=>{ + const {sealRecoveryImageAsync,sealRecoveryImage}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'); + const f=fixture(Array.from({length:300},(_,i)=>({id:'encode-'+i})));await f.runner.start(); + const job=f.root.job;assert.equal(job.committedSourceKeys.length,300);let ticks=0,yields=0; + const image=await sealRecoveryImageAsync(17,1,1,job,{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{yields++;}}); + assert.ok(yields>0);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(); +}); diff --git a/tests/headless/recovery-encoding.test.mjs b/tests/headless/recovery-encoding.test.mjs index 108885a..c57898f 100644 --- a/tests/headless/recovery-encoding.test.mjs +++ b/tests/headless/recovery-encoding.test.mjs @@ -72,3 +72,27 @@ test('cooperative preparation evaluates every unsafe occurrence and rejects erro assert.ok(await sealRecoveryImageAsync(17,1,1,nested(64,dataRecord({omitted:undefined})))); await assert.rejects(sealRecoveryImageAsync(17,0,1,{}),/corrupt_recovery_image/); }); + +test('primitive normalization and UTF-8 sizing occur inside the cooperative slice',async()=>{ + const {dataArray}=await import('../../src/lib/canvas-core/data-containers.ts'); + const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'); + const input=dataArray(Array.from({length:1000},(_,i)=>'budgeted-中文😀-'+i)),stringify=JSON.stringify; + let strings=0,tick=0,atFirstYield; + JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('budgeted-'))strings++;return Reflect.apply(stringify,this,[value,...args]);}; + try{ + const image=await sealRecoveryImageAsync(17,1,1,input,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{atFirstYield??=strings;}}); + assert.ok(atFirstYield>0&&atFirstYield<=128,'all scalar encoding ran before the first yield'); + assert.equal(strings,1000);assert.deepEqual(image.payload,input); + assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(stringify(image)).byteLength); + }finally{JSON.stringify=stringify;} +}); + +test('unknown proxies retain the synchronous codec trap order and callback count',async()=>{ + const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts'); + const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts'); + let events=[]; + const proxy=new Proxy({text:'external'},Object.fromEntries(['get','getPrototypeOf','getOwnPropertyDescriptor','ownKeys','isExtensible'].map(name=>[name,(...args)=>{events.push([name,String(args[1]??'')]);return Reflect[name](...args);}]))); + const payload=dataRecord({proxy}),expected=sealRecoveryImage(17,1,1,payload),before=events;events=[]; + const actual=await sealRecoveryImageAsync(17,1,1,payload); + assert.deepEqual(actual,expected);assert.deepEqual(events,before); +}); diff --git a/tests/headless/renderer-copy-input.mjs b/tests/headless/renderer-copy-input.mjs index ee7f009..c676b6a 100644 --- a/tests/headless/renderer-copy-input.mjs +++ b/tests/headless/renderer-copy-input.mjs @@ -22,7 +22,7 @@ export async function editDuringCopy({call,evaluate,wait,count}){ await evaluate(`(()=>{ const element=document.activeElement,samples=[]; const input=()=>{ - const sample={inputAt:performance.now(),value:element.value,copyActive:window.__canvasPerformance.read().localCompleteMs===null}; + const sample={inputAt:performance.now(),value:element.value,phase:window.__copyInputProbe.phase,committed:window.__copyInputProbe.progress(),copyActive:window.__canvasPerformance.read().localCompleteMs===null}; samples.push(sample); requestAnimationFrame(()=>requestAnimationFrame(()=>{ sample.frameAt=performance.now();sample.frameDelayMs=sample.frameAt-sample.inputAt; @@ -31,7 +31,10 @@ export async function editDuringCopy({call,evaluate,wait,count}){ })); }; element.addEventListener('input',input); - window.__copyInputProbe={element,samples,dispose(){element.removeEventListener('input',input);}}; + window.__copyInputProbe={element,samples,phase:'early',progress(){ + const status=document.querySelector('[aria-label="导入任务 1"] p[role="status"]')?.textContent??''; + return Number(status.match(/·\\s*(\\d+)\\s*项/)?.[1]??0); + },dispose(){element.removeEventListener('input',input);}}; })()`); await call('Input.imeSetComposition',{text:'复',selectionStart:1,selectionEnd:1}); await call('Input.insertText',{text:'复制期间输入'}); @@ -39,13 +42,25 @@ export async function editDuringCopy({call,evaluate,wait,count}){ await call('Input.insertText',{text}); await wait(`window.__copyInputProbe.samples.at(-1)?.frameAt!==undefined`,'native input did not reach its next frame'); } + // Keep the same native editor alive through later publication and ACK work. + // Small scenarios can finish during the early burst; the scale gate uses 10k. + if(count>=10000)for(const [phase,fraction,text] of [['middle',.5,' M'],['tail',.9,' T']]){ + await wait(`window.__copyInputProbe.progress()>=${Math.floor((count+3)*fraction)}`,'copy did not reach '+phase+' publication'); + assert.equal(await evaluate('window.__canvasPerformance.read().localCompleteMs===null'),true,'copy completed before '+phase+' input'); + await evaluate(`window.__copyInputProbe.phase=${JSON.stringify(phase)};true`); + const started=performance.now();await call('Input.insertText',{text}); + const dispatchRoundTripMs=performance.now()-started; + await wait(`window.__copyInputProbe.samples.at(-1)?.phase===${JSON.stringify(phase)}&&window.__copyInputProbe.samples.at(-1)?.frameAt!==undefined`,phase+' input did not reach a frame'); + await evaluate(`window.__copyInputProbe.samples.at(-1).dispatchRoundTripMs=${dispatchRoundTripMs};true`); + } const result=await evaluate(`(()=>{ const probe=window.__copyInputProbe,result={samples:probe.samples,value:probe.element.value,retained:probe.element.isConnected&&document.activeElement===probe.element}; probe.dispose();probe.element.blur();delete window.__copyInputProbe;return result; })()`); assert.equal(result.retained,true,'copy discarded native input DOM or focus'); - assert.ok(result.value.endsWith('复制期间输入 A B C'),'native composition/typing was overwritten'); + assert.ok(result.value.endsWith(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'native composition/typing was overwritten'); assert.ok(result.samples.some(sample=>sample.copyActive),'all native input happened after copy completion'); + if(count>=10000)for(const phase of ['middle','tail'])assert.ok(result.samples.some(sample=>sample.phase===phase&&sample.copyActive),phase+' input was not observed before completion'); assert.ok(result.samples.every(sample=>sample.retained),'copy interrupted an input presentation frame'); assert.ok(result.samples.every(sample=>sample.mountedNodes<100),'native editing mounted offscreen nodes during copy'); return result; diff --git a/tests/headless/renderer-performance.mjs b/tests/headless/renderer-performance.mjs index 68b67f2..6e1ee9f 100644 --- a/tests/headless/renderer-performance.mjs +++ b/tests/headless/renderer-performance.mjs @@ -94,7 +94,7 @@ export async function measureRenderer({call,evaluate,documents,calls=[],root,par document.querySelector('[aria-label="深复制选中内容"]').click(); })()`); copying=true;console.log('PERF copy requested'); - if(concurrentInput){duringCopyInput=await editDuringCopy({call,evaluate,wait,count});assert.equal(duringCopyInput.value,sourceText+'复制期间输入 A B C','concurrent typing lost pre-existing source text');console.log('PERF concurrent input '+JSON.stringify(duringCopyInput));} + if(concurrentInput){duringCopyInput=await editDuringCopy({call,evaluate,wait,count});assert.equal(duringCopyInput.value,sourceText+(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'concurrent typing lost pre-existing source text');console.log('PERF concurrent input '+JSON.stringify(duringCopyInput));} await wait(`document.querySelectorAll('.react-flow__node').length>0&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'large copy did not finish rendering and synchronization'); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); const internalsBatches=await evaluate('window.__fixtureInternalsBatches??[]');assert.ok(internalsBatches.some(size=>size>1),'mounted nodes did not share a renderer measurement batch');