From 3c8953e8425053e455c537ef30e9e08bb10f289d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 13:10:35 +0800 Subject: [PATCH] docs(runtime): clarify streaming import cursors and byte reads --- src/lib/canvas-runtime/import-byte-reader.ts | 8 +++-- .../canvas-runtime/import-document-stream.ts | 29 ++++++++++++++----- src/lib/canvas-runtime/import-json-array.ts | 15 +++++++--- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/lib/canvas-runtime/import-byte-reader.ts b/src/lib/canvas-runtime/import-byte-reader.ts index 9cef1a1..afb1807 100644 --- a/src/lib/canvas-runtime/import-byte-reader.ts +++ b/src/lib/canvas-runtime/import-byte-reader.ts @@ -6,13 +6,17 @@ import {fault} from '../canvas-core/values.ts'; /** 会破坏整个源读取可信性的错误集合,供上层区分单记录失败与源级暂停/失败。 */ const failures=new Set(['import_source_changed','invalid_import_byte_range','import_read_capacity','import_source_closed','import_source_read_failed','invalid_import_read_proof','import_read_proof_interpreter_unavailable']); -/** 仅按稳定领域错误码分类,不依赖展示 message 或错误文本包含某个词。 */ +/** 只按 code 是否在固定集合分类,不校验错误对象的来源;其它码(含一般 import_aborted)不在集合内。 + * 取消是否应优先由读取调用方看 signal;本分类器不是所有源错误/取消的通用判定器。 */ export const isImportByteFailure=(error:unknown)=>failures.has((error as {code?:string}|undefined)?.code??''); /** Optional immutable-source port. Implementations return exactly the requested bytes. */ /** 可选只读端口声明完整长度,必须返回所请求区间的精确字节,不能截断或换源。 */ export type ImportByteReader={readonly byteLength:number;read(start:number,end:number,signal:AbortSignal):Promise}; -/** 校验安全整数范围,end 可截至EOF;读取前后查取消,外来 reader 结果额外复制以隔离缓冲复用。 */ +/** 校验安全整数范围,允许 end 超出 EOF 并截到 bytes.size;start 超出 EOF 则拒绝。 + * reader 长度须等于 Blob 长度,但本函数不逐字节比较 Blob/reader,也不证明同长度源未被替换。 + * 外来 Uint8Array 结果复制为自有缓冲,默认 Blob 路径返回此次新读缓冲;不缓存结果或做并发合并。 + * 空范围仍会调用 reader/Blob 读取,是否优化零 I/O 由端口决定;bytes 依赖调用方已验证的 Blob 合同。 */ export async function readImportRange(bytes:Blob,reader:ImportByteReader|undefined,start:number,end:number,signal:AbortSignal):Promise>{ if(signal.aborted)throw fault('import_aborted'); if(!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start<0||endbytes.size||reader&&reader.byteLength!==bytes.size)throw fault('invalid_import_byte_range'); diff --git a/src/lib/canvas-runtime/import-document-stream.ts b/src/lib/canvas-runtime/import-document-stream.ts index 7c77b0c..34964f6 100644 --- a/src/lib/canvas-runtime/import-document-stream.ts +++ b/src/lib/canvas-runtime/import-document-stream.ts @@ -22,8 +22,10 @@ const allowed=new Set(['documentFormatVersion','projectId','canvases','elements' /** JSON语法空白字节,不用于处理字符串内容。 */ const whitespace=(byte:number)=>byte===9||byte===10||byte===13||byte===32; -/** Each scan owns its cursor. An aborted scan cannot advance the resumable source. */ -/** 每次扫描从指定位置建立私有cursor,完成边界后外层才更新可恢复源进度。 */ +/** Each scan owns a private byte cursor; shared progress records completed boundaries. */ +/** 从指定字节位置建立私有 cursor;仅外层 scanNext 将完整结构边界写回源级进度。 + * 取消不发布业务结果,但已定位的完整 ranges 可保留用于重试,不承诺整次 read 的缓存/边界都回滚。 + * options/缓存沿用内部引用;scanner 负责定位/单值解码,不验证完整文档引用关系。 */ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={start:-1,bytes:new Uint8Array()}){ const {bytes}=options,budget=createImportBudget(signal,options); let cursor=start; @@ -41,7 +43,9 @@ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={sta async function spaces(){while(whitespace(await peek()))await step();} /** 严格匹配一个结构字节,不修补缺逗号/冒号等源损坏。 */ async function take(byte:number){await spaces();if(await peek()!==byte)throw fault('invalid_document_import');await step();} - /** 引号/转义/栈配对扫描一个完整值range,限制嵌套深度,避免把字符串里的分隔符当边界。 */ + /** 用 quoted/escaped/显式 stack 定位一个值范围,最多 256 层;不收集 payload 字节。 + * 此步只找词法/括号边界,primitive 或字符串内容仍可能是非法 JSON,稍后 decode 才完整解析。 + * 超大值也要扫描完才得到范围,maxRecordBytes 仅约束后续解码分配,不限制扫描总工作量。 */ async function value():Promise{ await spaces();const start=cursor,stack:number[]=[];let quoted=false,escaped=false; while(true){ @@ -57,7 +61,9 @@ function scanner(options:Options,signal:AbortSignal,start=0,cache:ScanCache={sta } if(cursor===start)throw fault('invalid_document_import');return{start,end:cursor}; } - /** 对单range校验大小、fatal UTF8、JSON语法和值域,返回原值,不修改业务ID。 */ + /** 读取单 range,按指定上限控制分配,再 fatal UTF-8/JSON.parse/validJson;不重写业务 ID。 + * 默认 payload 256KiB,header 调用可传独立上限;合法 JSON 不等同元素 schema 或引用关系合法。 + * JSON.parse 本身为同步工作,扫描预算不抢占这一步;字节端口异常保持源级错误,不转成坏记录。 */ async function decode(range:Range,limit=options.maxRecordBytes??256*1024):Promise{ check();if(range.end-range.start>limit)throw fault('record_too_large'); const data=await readImportRange(bytes,options.readBytes,range.start,range.end,signal);let text:string; @@ -75,7 +81,11 @@ function tombstones(value:unknown){ /** Parser 5: canonical headers open before scanning the elements tail. * Out-of-order headers require the indexed interpreter to establish ownership first. * Ordinals remain stable; ranges are retained only as records are requested. */ -/** 打开standard header后即可返回源;真正elements/tail结构检查跟随read进行,未扫描尾部不冒称合法。 */ +/** 浅捕获 options 后打开标准头:源最多 64MiB,单个元数据值上限 4MiB,字段名上限 1KiB。 + * 先解析整份 canvases 并验证单根/父链,再返回 canvas-first 的 ordinal 接口;此时元素/尾部可尚未读取。 + * 头顺序不满足拥有树前提时委托索引解释器,仍暴露 parserVersion=5,不能据版本推断一定渐进扫描。 + * 指纹只是上层传入身份;源字节证明/完整 SHA 与最终元素/入口校验由读取端口和导入准备层负责。 + * options.signal 用于打开阶段,返回后的每次 read 使用自己的 readSignal,不自动合并原 signal。 */ export async function openStreamingDocumentImportSource(input:Options):Promise{ const options:Options=Object.freeze({...input}); const {bytes,signal}=options,max=options.maxRecordBytes??256*1024; @@ -120,7 +130,9 @@ export async function openStreamingDocumentImportSource(input:Options):Promisecanvas.id!==rootId),ranges:Range[]=[]; - // 源级进度只记录完整range边界/phase,serial只串行化延伸;failure最多输出一条终结错误。 + // ranges 随请求积累元素字节范围,children 是已捕获子 canvas;ordinal 与原文件 byte offset 不同。 + // 重开源需重建这些内存范围,持久 runner 保存的是稳定 ordinal/源身份,不是这里的 Promise 链。 + // serial 只串行化范围延伸,failure 最多输出一条终结错误;请求很远的 ordinal 仍会扫描此前全部未定位项。 let cursor=header.position(),phase:'elements'|'tail'|'done'='elements',first=true,failure:string|undefined,serial=Promise.resolve(); /** 延伸一个element range,或验证剩余envelope与EOF;只在结构边界确认后更新cursor/phase。 */ async function scanNext(readSignal:AbortSignal){ @@ -146,7 +158,10 @@ export async function openStreamingDocumentImportSource(input:Options):Promise{ if(readSignal.aborted)throw fault('import_aborted'); if(!Number.isSafeInteger(position)||position<0)throw fault('invalid_import_cursor'); diff --git a/src/lib/canvas-runtime/import-json-array.ts b/src/lib/canvas-runtime/import-json-array.ts index 62677dd..7eaa60e 100644 --- a/src/lib/canvas-runtime/import-json-array.ts +++ b/src/lib/canvas-runtime/import-json-array.ts @@ -9,7 +9,10 @@ import type {ImportSource,ImportLine} from './imports.ts'; import {fault,validJson} from '../canvas-core/values.ts'; /** Seekable JSON array reader. Cursors are byte offsets after '[' or a record comma. * Structure errors end the source; previously published records remain explicit. */ -/** 捕获Blob/指纹与读取预算,缓存一块源字节;本解析器不分配业务ID、不发布history或发送请求。 */ +/** 捕获 Blob/max/chunk 与返回指纹标量,按字节 offset 每次返回一条记录,缓存一个读取块。 + * readBytes 与调度选项仍从原 options 读取,调用方应保持它们稳定;这里不建立源摘要/读取证明。 + * 默认单条 256KiB/块 16KiB,未给 maxRecordBytes 设置总上限,也不限制源文件总长。 + * 不分配业务 ID、不发布 history 或发送请求;缓存不是持久进度,合法边界由 runner 保存。 */ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:string;readBytes?:ImportByteReader;maxRecordBytes?:number;chunkBytes?:number}&ImportScheduling):ImportSource{ const bytes=options.bytes,max=options.maxRecordBytes??256*1024,chunk=options.chunkBytes??16384; if(!(bytes instanceof Blob)||!options.fingerprint||!Number.isSafeInteger(max)||max<1||!Number.isSafeInteger(chunk)||chunk<1||chunk>max)throw fault('invalid_import_source'); @@ -18,6 +21,7 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri /** read每次返回一个ImportLine及nextCursor;EOF返回null,源级I/O失败仍抛出而不伪装坏记录。 */ return Object.freeze({fingerprint:options.fingerprint,parserVersion:3,async read(start:number,signal:AbortSignal):Promise{ if(!Number.isSafeInteger(start)||start<0||start>bytes.size)throw fault('invalid_import_cursor'); + // EOF offset 直接返回 null,不重扫先前数组结构;任意范围内 seek 也不证明是合法记录起点。 if(start>0&&start===bytes.size){if(signal.aborted)throw fault('import_aborted');return null;} if(bytes.size===0)throw fault('invalid_json_array'); let cursor=start;const budget=createImportBudget(signal,options); @@ -32,7 +36,8 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri await spaces();if(start===0){if(await peek()!==91)return failure('invalid_json_array');await advance();await spaces();} if(await peek()===93){await advance();await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');if(start!==0){/* Empty tail is valid only after a record, not a trailing comma. */return failure('invalid_json_array');}return null;} if(await peek()===-1)return failure('incomplete_json_array'); - // quoted/escaped隔离字符串分隔符,stack配对{}与[];超大小后仍扫描至合法边界但不再保留字节。 + // parts 是至多 max 个字节数值的普通数组(内存成本不等于 max 字节),stack 至多保留 256 层括号。 + // quoted/escaped 只定位边界,不完整校验 JSON;超大小后仍扫描但不再收集,坏结构仍可能终结整个源。 const recordStart=cursor,parts:number[]=[],stack:number[]=[];let quoted=false,escaped=false,oversized=false; while(true){const byte=await peek();if(byte===-1)return failure('incomplete_json_array'); if(!quoted&&stack.length===0&&(byte===44||byte===93))break; @@ -41,13 +46,15 @@ export function createJsonArrayImportSource(options:{bytes:Blob;fingerprint:stri else if(byte===34)quoted=true;else if(byte===123||byte===91){stack.push(byte);if(stack.length>256)return failure('json_nesting_limit');}else if(byte===125||byte===93){if(stack.pop()!==(byte===125?123:91))return failure('invalid_json_array');} await advance(); } - // 消费外层逗号或]并检查尾部/尾逗号;内嵌数组的]已由stack处理,不会截断记录。 + // 先确认记录后的外层分隔与尾部,再返回此记录;若尾逗号/垃圾在此处发现,本记录也返回结构失败。 + // 数组内嵌 ] 已由 stack 处理;nextCursor 消费分隔和后续空白,最后一项则推进到真实 EOF。 const delimiter=await peek();await advance(); if(delimiter===93){await spaces();if(cursor!==bytes.size)return failure('invalid_json_array');} else {await spaces();if(await peek()===93||await peek()===-1)return failure('invalid_json_array');} const base={cursor:start,nextCursor:cursor,key:`byte:${start}`}; if(oversized)return{...base,error:'record_too_large'}; - // 只解码这一记录:fatal UTF8防替换字符掩盖损坏,再JSON.parse/validJson校验值域。 + // fatal UTF-8 与 JSON.parse 在边界已确定后处理,失败可随 nextCursor 继续下一项;不做元素业务 schema 校验。 + // JSON.parse 同步完成且无中途取消,记录扫描的预算不抢占解码/解析;调用方仍需在采纳结果前校验生命周期。 let text:string;try{text=new TextDecoder('utf-8',{fatal:true}).decode(new Uint8Array(parts));}catch{return{...base,error:'invalid_utf8'};} try{const value=JSON.parse(text);return validJson(value)?{...base,value}:{...base,error:'invalid_json_value'};}catch{return{...base,error:'invalid_json'};} }});