Slice detached element recovery encoding and audit progressive canvas

This commit is contained in:
2026-11-11 04:53:07 +08:00
parent 5eabca5056
commit 2089b63e79
6 changed files with 175 additions and 14 deletions
+20 -2
View File
@@ -1001,8 +1001,8 @@ 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
subtrees and individual scalar operations remain synchronous. First-time element
normalization can defer only when its detached JSON provenance is established. 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, binding dictionaries and
@@ -1136,3 +1136,21 @@ 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.
First-time element recovery encoding now uses the core's detached-JSON ownership
proof instead of synchronously normalizing every field before the first yield.
Only `detachElementData` results qualify: JSON-cloned, recursively frozen records
with immutable JSON descendants. A shallow freeze, a document factory envelope,
or an arbitrary host object does not qualify. Preparation requires the standard
Object/Array prototype chain without inherited `toJSON`; otherwise it captures
through the original normalizer. Every unknown normalization invalidates this
prototype check because callbacks may change the environment. The cooperative
walk reads the already detached own data and validates depth while building the
same immutable JSON and byte facts. A callback later in capture cannot change an
earlier record's meaning. Cold and cached subtrees both retain the depth limit.
This moves the first full field/row walk into the shared budget; container-member
capture and individual native/scalar operations still have synchronous work.
Step 4's current acceptance evidence, commands and explicit remaining checkpoint
encoding work are recorded in [STEP4_ACCEPTANCE.md](STEP4_ACCEPTANCE.md). Passing
functional fixtures is not a claim that all performance limits are resolved.
+68
View File
@@ -0,0 +1,68 @@
# Step 4:渐进功能与呈现验收
状态:已完成最终功能入口复核,调度余项未完成,整体进度 3/7。此文件随实现提交;完整目标仍包含后续真实服务、迁移和集成验收。
## 范围与直接证据
| 原要求 | 代码与验证入口 | 本次结果 |
| --- | --- | --- |
| 导入、深复制逐步加入并允许编辑 | `imports.ts`、`canvas-runtime/duplicate.ts`;`imports.test.mjs`、`imports-engine.test.mjs`、`import-document-stream.test.mjs`、`duplicate*.test.mjs`;实际页面文件选择/工具栏 | Node 与页面检查通过。SHA 完成前可显示和编辑;完整校验完成且对应 Root 持久化后才发送。单项失败保留已加入内容并报告 partial。复制保留原图、新建身份,父层新增连线,复用合法资源且不自动启动 run。 |
| loader 旧捕获与后续编辑/ACK 协调 | `loading.ts`、`engine.ts`;`loading-engine.test.mjs`、浏览器存储和实际页面 | 当前 loading v1–v6 路径、未读 tail、undo/redo、重开、取消及源校验检查通过。旧数据不覆盖后来的编辑/ACK;已声明的 partial 状态保留。 |
| 公共 hooks 和局部订阅 | `canvas-bindings/react.ts`、`canvas-bindings`;`react-hooks-fixture.tsx`、`renderer.integration.mjs --hooks-only` | 同一最终源码的 StrictMode 入口 10 项通过;包括 owner 切换、engine close、资源句柄与 unloaded/absent。普通字段与 ACK 的无关投影复用由实际页面计数验证。 |
| 输入、呈现与合作调度 | frame queue、scene/paint projection、retained projection;`frame-queue.test.mjs`、`retained-projection.test.mjs`、实际页面与生产规模入口 | 页面验证拖动最终位置、IME、Portal、detach、未知焦点与订阅取消。生产万元素早/中/尾段输入与副本捕获隔离通过;性能仍有限制,见下文。 |
所有 `*.test.mjs` 均位于 `tests/headless/`,源码位于 `src/lib/` 或 `src/components/canvas/`。每个测试文件中的断言决定其覆盖范围;通过总数不代表 Draft 的 T01–T36 或整个项目已经完成。
## 编码与发布边界
1. `writePrefix` 是有序单 writer,逐个保存 Root,异步编码不会跳过中间 Root。
2. 外部对象、getter、custom `toJSON` 和浅冻结值在第一次让出前按原 codec 捕获;私有容器只证明成员固定,内部未知值仍需捕获。
3. core 的 JSON 脱离、深冻结元素可以延后逐字段编码。需要标准 Object/Array 原型且没有继承的 `toJSON`;每次未知值捕获后重新检查环境。外部回调不能改变已经捕获的前项,也不能让后项错误沿用旧环境。
4. 标量归一化、UTF-8 计数、history/import/pending 容器与首次元素遍历共用软预算。已完成的深不可变编码可复用,但不能省略 unsafe occurrence 的求值。
5. 编码、调度或存储失败保留待存 Root;持久化没有确认,不解除网络发送屏障。旧 Root 与编码期间的新编辑保持各自内容。
6. journal 比较先判断子值是否相同,再为变化项分配路径,避免为共享的整个前缀创建临时数组。patch 顺序与字节语义不变。
`recovery-encoding.test.mjs` 验证首次归一化确实在 yield 间发生、继承行为、外部回调、Proxy trap 次数、UTF-8、冷/热深度边界;`engine.test.mjs` 以 17000 元素验证暂停编码期间编辑、逐 Root 落盘和发送屏障。`journal-delta.test.mjs` 验证精确重放及属性顺序;浏览器检查验证真实 IndexedDB 重开。
## 可复现命令
```sh
node --test tests/headless/*.test.mjs
npm run typecheck -- --incremental false
node tests/headless/renderer.integration.mjs
node tests/headless/renderer.integration.mjs --turbopack
node tests/headless/renderer.integration.mjs --hooks-only
node tests/headless/browser-storage.mjs
node tests/headless/package.integration.mjs
HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1 HEADLESS_PERFORMANCE_REPORT=/private/tmp/headless-step4-production-10000.json node tests/headless/renderer.integration.mjs --performance --production --turbopack
```
性能入口须在其它构建/测试结束后单独执行;功能检查可并行。这里验证真实 Chrome、IndexedDB、Web Locks 和实际前端,但 HTTP 为隔离 fixture,不能据此声称已完成真实 Foundry 集成。
## 本次运行结果(2026-11-11)
- Node:780 项通过;全量前端 TypeScript 检查通过。
- 实际页面:webpack 51 项、Turbopack 51 项通过;公共 hooks 10 项通过。
- 浏览器存储:57 组通过;SDK:263 个打包文件,类型、独立消费和编译后的浏览器/Worker 检查通过。
- webpack 曾在全部断言结束后被 Chrome 日志管道阻止退出。`renderer.integration.mjs` 现于 Chrome 退出后释放 stdio;重新运行的 51 项检查自动正常退出。
- 生产规模环境:本机 arm64、macOS 15.6、Node 23.10.0、Chrome 157.0.8103.39,Next production Turbopack;无并行构建/测试、无计时代码插桩。
- 10000-child 展开分区深复制:首副本呈现 799.5ms、本地完成帧 36264.8ms、加入并同步 36469.9ms;帧间隔 P95 33.4ms/max316.6ms;25 次 longtask,max341ms/total1869ms。
- early 五次 input→两次 rAF:50.5/31.3/22.6/20.9/41.3ms;middle(5255 项)21.5ms、tail(9131 项)39.4ms。全部发生于复制活跃期间,DOM/focus 保留;中尾 CDP 往返约18.1/27.4ms。它们不含精确的派发前排队延迟。
- 服务端最终 20005 元素/3 canvas;原/新父层边、原文后续输入、副本旧快照、pan 后副本挂载通过。样本呈现时挂载35节点,全过程最大136,pan 后28;屏幕外 IME/Portal 各保留1,独立应用面板不额外保活,runStartsDuringScenario=0。
原始报告 `/private/tmp/headless-step4-production-10000.json` 和同名 PNG 为本次本机观察;命令与断言已保存在仓库,可独立复现。单次运行不同 adaptive 批次和 GC 负载,不能比较前轮26.2秒而宣称确定改善或回退,更不能说所有停顿已解决。
## 明确剩余项
复制规划的 `createDuplicatePlanner().checkpoint()` 当前返回 `freeze(clone(state))`;它具有 JSON 脱离性质,但这项来源尚未传递给 codec。`preparation.planner` / `source.plan` 因而仍可能经未知父对象走同步 normalization。下一项是由 core 提供同元素一致的、不可伪造的 JSON 脱离来源事实,并在 runtime 自建 checkpoint envelope 中保留这项事实,补首次 checkpoint 编码、外部回调与恢复测试。不能仅以 `Object.freeze` 推定来源,也不能跳过完整 checkpoint/发送屏障。
此项完成并复验前,Step 4 保持未完成。本文件只确认已列入口和观察,未完成项不能转移到后端阶段来虚增进度。
## 调度限制与后续分工
6ms 是合作调度软目标。捕获成员引用、未知值的同步 JSON 语义、单个字符串/native 操作、原子模型发布和 GC 仍可能超过一个时间片。未知调用不能假冒不可变值以换取响应速度;也不能把普通循环停顿一概称为 GC。测试测量 input 到两次 rAF;CDP 往返另含传输和驱动时间,均不是精确的输入派发前排队延迟。
未登记的第三方 Portal/未知输入 owner 保留明确的兼容回退;任意全局 Root selector、结构变化和未知 ACK 不保证 O(1)。renderer 不保存第二份业务文档,不为裁剪丢弃完整模型。
下一阶段由服务/集成工作负责真实 Foundry 协议、权限、迁移、冻结请求幂等与恢复联调,并沿用现有远程 CI。分支推送不等于主线合并或部署,仍需单独报告。
+8 -2
View File
@@ -9,11 +9,17 @@ export function diffJournal(before:Json,after:Json):JournalPatch[]|null {
function visit(a:Json,b:Json,path:(string|number)[]){
if(a===b||patches.length>10000)return;
if(Array.isArray(a)&&Array.isArray(b)){
const common=Math.min(a.length,b.length);for(let i=0;i<common;i++)visit(a[i],b[i],[...path,i]);
const common=Math.min(a.length,b.length);for(let i=0;i<common;i++){
const previous=a[i],next=b[i];
// Shared immutable rows need no path allocation or recursive call.
if(previous!==next)visit(previous,next,[...path,i]);
}
if(a.length!==b.length)patches.push({op:'splice',path,start:common,deleteCount:a.length-common,items:b.slice(common)});
}else if(a&&b&&typeof a==='object'&&typeof b==='object'&&!Array.isArray(a)&&!Array.isArray(b)){
const left=Object.keys(a),right=Object.keys(b);
if(left.length===right.length&&left.every((key,i)=>key===right[i]))for(const key of right)visit(a[key],b[key],[...path,key]);
if(left.length===right.length&&left.every((key,i)=>key===right[i]))for(const key of right){
const previous=a[key],next=b[key];if(previous!==next)visit(previous,next,[...path,key]);
}
else patches.push({op:'set',path,value:b});
}else patches.push({op:'set',path,value:b});
}
+28 -9
View File
@@ -1,3 +1,5 @@
import {hasOwnedElementData} from '../canvas-core/element-data.ts';
import type {ElementRecord} from '../canvas-core/types.ts';
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';
@@ -72,12 +74,20 @@ export function sealRecoveryImage(formatVersion:ImageVersion,rootRevision:number
}
type Scalar = null|string|number|boolean|undefined|symbol;
type Prepared = Scalar | Encoded | {source:object;depth:number;children:Prepared[];keys?:string[]};
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.
// Detached element JSON can also defer after checking its prototype environment.
function prepareEncoding(input:unknown) {
const normalize=createEncoder(),visiting=new Set<object>();
let plainEnvironment:boolean|undefined;
const capture=(value:unknown,depth:number,key:string)=>{
try{return normalize(value,depth,key);}finally{plainEnvironment=undefined;}
};
const detached=(value:object)=>hasOwnedElementData(value as ElementRecord)&&(plainEnvironment??=(
Object.getPrototypeOf(Object.prototype)===null&&Object.getPrototypeOf(Array.prototype)===Object.prototype&&
!Object.getOwnPropertyDescriptor(Object.prototype,'toJSON')&&!Object.getOwnPropertyDescriptor(Array.prototype,'toJSON')
));
function prepare(value:unknown,depth:number,key:string):Prepared {
const kind=typeof value;
if(value===null||kind==='string'||kind==='number'||kind==='boolean'||kind==='undefined'||kind==='symbol'){
@@ -86,15 +96,18 @@ function prepareEncoding(input:unknown) {
// 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);
if(typeof value!=='object')return capture(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);
// Detached element JSON has no accessors, custom prototypes or mutable
// descendants. Its full walk can wait; foreign freezes cannot grant this.
if(detached(value))return {source:value,depth};
if(!isDataContainer(value))return capture(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(hasToJSON)return normalize(value,depth,key);
if(hasToJSON)return capture(value,depth,key);
if(depth>64||visiting.has(value))throw fault('invalid_recovery_payload');
visiting.add(value);
try {
@@ -123,17 +136,23 @@ export async function sealRecoveryImageAsync(formatVersion:ImageVersion,rootRevi
// 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;}
if(depth>64)throw fault('invalid_recovery_payload');
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],name=plan.keys?.[index]??String(index);
const array=Array.isArray(plan.source),keys=plan.children?plan.keys:array?undefined:Object.keys(plan.source);
const length=plan.children?.length??(array?(plan.source as unknown[]).length:keys!.length),result:Json[]|Record<string,Json>=array?[]:{};
for(let index=0;index<length;index++){
const name=keys?.[index]??String(index);
// No children array means detached JSON. Read only its immutable own data
// properties, with the prototype behavior captured during preparation.
const value=plan.children?undefined:(plan.source as Record<string,unknown>)[name];
const entry=plan.children?plan.children[index]:value&&typeof value==='object'?{source:value,depth:plan.depth+1}:value as Scalar;
const child=entry===null||typeof entry!=='object'?normalize(entry,plan.depth+1,name):'height' in entry?entry:yield* encode(entry,plan.depth+1,name);
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;
const name=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;
+50
View File
@@ -112,3 +112,53 @@ test('repeated owned containers reuse facts assembled earlier in the same asynch
const captured=await sealRecoveryImageAsync(17,1,1,dataArray([unsafe,unsafe]));
assert.deepEqual(captured.payload,[{getValue:{value:1}},{getValue:{value:2}}]);
});
const ownedRecord=async()=>{
const {detachElementData}=await import('../../src/lib/canvas-core/element-data.ts');
return detachElementData({id:'large',incarnation:1,structure:'node',ownerCanvasId:'canvas',componentId:'text',dataVersion:1,geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{text:'',rows:Array.from({length:1000},(_,i)=>({index:i,text:'owned-中文😀-'+i}))}});
};
test('first-time detached element JSON is normalized inside slices rather than captured in one walk',async()=>{
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const element=await ownedRecord(),stringify=JSON.stringify;let strings=0,first,tick=0;
JSON.stringify=function(value,...args){if(typeof value==='string'&&value.startsWith('owned-'))strings++;return Reflect.apply(stringify,this,[value,...args]);};
try{
const image=await sealRecoveryImageAsync(17,1,1,element,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{first??=strings;}});
assert.ok(first>=0&&first<128,'detached data was fully encoded before the first yield');assert.equal(strings,1000);
assert.deepEqual(image.payload,element);assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(stringify(image)).byteLength);
}finally{JSON.stringify=stringify;}
});
test('inherited toJSON accessors at admission keep detached elements on the synchronous capture path',async()=>{
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const left=await ownedRecord(),right=await ownedRecord(),previous=Object.getOwnPropertyDescriptor(Object.prototype,'toJSON');let calls=0,yields=0,tick=0;
try{
Object.defineProperty(Object.prototype,'toJSON',{configurable:true,get(){calls++;return undefined;}});
const expected=sealRecoveryImage(17,1,1,left),syncCalls=calls;calls=0;
const image=await sealRecoveryImageAsync(17,1,1,right,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
assert.equal(calls,syncCalls);assert.equal(yields,0);assert.deepEqual(image.payload,expected.payload);
}finally{if(previous)Object.defineProperty(Object.prototype,'toJSON',previous);else delete Object.prototype.toJSON;}
});
test('unknown callbacks invalidate the detached JSON environment without changing earlier captures',async()=>{
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const previous=Object.getOwnPropertyDescriptor(Array.prototype,'toJSON');
const cleanup=()=>{if(previous)Object.defineProperty(Array.prototype,'toJSON',previous);else delete Array.prototype.toJSON;};
const make=async()=>dataRecord({left:await ownedRecord(),bridge:{get value(){Object.defineProperty(Array.prototype,'toJSON',{configurable:true,value(){return ['changed-by-callback'];}});return 'bridge';}},right:await ownedRecord()});
try{
const expected=sealRecoveryImage(17,1,1,await make());cleanup();
const payload=await make();let tick=0,yields=0;
const pending=sealRecoveryImageAsync(17,1,1,payload,{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
cleanup();const image=await pending;assert.ok(yields>0);assert.deepEqual(image.payload,expected.payload);
assert.equal(image.payload.left.data.rows.length,1000);assert.deepEqual(image.payload.right.data.rows,['changed-by-callback']);
}finally{cleanup();}
});
test('deferred detached subtrees enforce the depth bound both cold and after cache reuse',async()=>{
const {dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
const {sealRecoveryImageAsync}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
const nest=leaf=>{for(let i=0;i<61;i++)leaf=dataRecord({child:leaf});return leaf;};
const cold=await ownedRecord();await assert.rejects(sealRecoveryImageAsync(17,1,1,nest(cold)),/invalid_recovery_payload/);
const warm=await ownedRecord();await sealRecoveryImageAsync(17,1,1,warm);
await assert.rejects(sealRecoveryImageAsync(17,1,1,nest(warm)),/invalid_recovery_payload/);
});
+1 -1
View File
@@ -591,7 +591,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
}
} finally {
releaseScopeStream();releaseAbandoned();releaseFirstModel();releaseImport();releaseApply();releaseUpload(); socket?.close();
if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await Promise.race([exit,pause(3000)]);if(chrome.exitCode===null)chrome.kill('SIGKILL');await exit; }
if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await Promise.race([exit,pause(3000)]);if(chrome.exitCode===null)chrome.kill('SIGKILL');await exit;chrome.stdin?.destroy();chrome.stdout?.destroy();chrome.stderr?.destroy(); }
if (next?.pid) { const exit = new Promise(resolve => { if (next.exitCode !== null) resolve(); else next.once('exit', resolve); }); try { process.kill(-next.pid, 'SIGTERM'); } catch {} await Promise.race([exit, pause(3000)]); if (next.exitCode === null) try { process.kill(-next.pid, 'SIGKILL'); } catch {} }
upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve));
await rm(directory, { recursive: true, force: true, maxRetries: 5 }); await rm(profile, { recursive: true, force: true, maxRetries: 5 });