From fbc5bed19e11d764a0b081b1a32f66ff9e7bd1b7 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:36:11 +0800 Subject: [PATCH 01/78] docs: explain UI bridge callers, control flow and VM ownership --- src/bridge/ui_bridge.rs | 501 ++++++++++++++++++++---- src/bridge/ui_bridge/bootstrap.rs | 65 ++- src/bridge/ui_bridge/calls.rs | 176 ++++++++- src/bridge/ui_bridge/developer_pages.rs | 32 +- src/bridge/ui_bridge/page_input.rs | 51 ++- src/bridge/ui_bridge/signatures.rs | 64 ++- src/bridge/ui_bridge/start_page.endor | 38 ++ src/bridge/ui_bridge/tree.rs | 54 ++- 8 files changed, 878 insertions(+), 103 deletions(-) diff --git a/src/bridge/ui_bridge.rs b/src/bridge/ui_bridge.rs index f849c4e..8b89459 100644 --- a/src/bridge/ui_bridge.rs +++ b/src/bridge/ui_bridge.rs @@ -2,7 +2,11 @@ //! Session 保存托管对象的展示上下文;跨线程发布的是不可变 UiNode/Frame,而不是可以在窗口线程执行的 VM。 //! 刷新按 prepare → layout → paint → commit 推进。挂起工作必须恢复或显式取消;失败不能把未完成的几何和生命周期状态留作已发布结果。 //! -//! Tessera's native visual protocols over Aster's managed object graph. +//! 调用来源有两条:runtime::endor_host 通过 UiBridge 安装环境、推进刷新与清理; +//! Endor VM 则在源程序调用 ui 工厂、类型构造器或实例方法时,进入本模块的 NativeCallable 实现。 +//! Function/TypeMethod/NativeFactory 是语言调用适配,Session 保存跨调用的窗口展示状态; +//! 真正的排版/绘制交给 render,业务对象与 Mount 数据交给 model,均不在这里操作 OS 窗口。 +//! 本模块会在 worker 中访问 VM;Arc/Mutex 只用于共享原生会话,不授权 GUI 线程直接调用 VM。 mod bootstrap; mod calls; mod developer_pages; @@ -26,8 +30,11 @@ use crate::{ document::parse_color, }; +/// new_typed_block 的节点弱表上限,也用于容器工厂的变参数量约束,限制单会话宿主开销。 pub const MAX_UI_NODES: usize = 4096; +/// 新建 Page 时按存活对象检查的上限;输入解码也用它拒绝明显越界的页面索引。 pub const MAX_PAGES: usize = 64; +/// dimension 将语言数字转换成原生布局长度前检查的上界,避免异常尺寸流入后续绘图。 pub const MAX_DIMENSION: f64 = 1_000_000.0; // 编译器已知宿主全局类型/能力名,同时用于 install_globals;仅列名不代表绕过实际 VM 名义类型查找。 @@ -84,6 +91,7 @@ pub const TYPE_NAMES: &[&str] = &[ "DisplayList", "PaintCommand", ]; +/// install_globals 暴露的类型/能力查询函数名称;与 bootstrap 安装到 ui 的入口对应。 pub const QUERY_NAMES: &[&str] = &[ "isInstance", "isSubtype", @@ -97,28 +105,35 @@ pub use tree::{UiKind, UiNode}; #[derive(Debug)] /// 整个 VM 的桥接会话。节点索引跨窗口共享,presentations 按窗口隔离派生状态;current 决定方法当前访问哪份展示上下文。 struct Session { - // Weak visual-object registry belongs to the VM, not a particular window. + // new_typed_block 登记、reclaim 清理的弱引用表;不因宿主记账让已不可达的 Block 保活。 nodes: Vec, + // 每个窗口独立拥有 Mount、布局缓存和待发布画面;切换窗口只改变 current,不移动业务对象。 presentations: std::collections::BTreeMap, + // Deref/DerefMut 选择哪个 Presentation;调用前必须保证对应键仍在 presentations 中。 current: u64, + // 主 desktop 的托管身份;create_window 用它建立副窗口与主资源域的关系。 master: ObjectRef, + // 已分配窗口身份的高水位,关闭窗口后也不降低,避免旧事件误指向新窗口。 next_window: u64, // 跨窗口共用 Mount 高水位,避免某窗口取消/重建后与其他窗口已暴露出现 ID 重合。 next_mount: u64, // 正常关闭副窗口后暂存尚待发布的 Close 等命令;Presentation 已移除也不能丢掉最后的原生退役请求。 retired_commands: Vec, + // 以下是供 stats 读取的累计工作量,并非控制下一次刷新的剩余预算。 refresh_yields: usize, refresh_restarts: usize, layout_cancels: usize, paint_cancels: usize, paint_slices: usize, binding_evaluations: usize, + // shutdown 设置;create_window 等入口读取,阻止卸载回调在退出期间重新建立窗口。 exiting: bool, } impl Session { - /// 收集所有窗口的私有 desktop 身份,用于排除宿主记账读取造成的无关布局失效。 + /// 由刷新/依赖收集流程调用,返回所有窗口的宿主 desktop,排除宿主记账读取造成的无关失效。 fn host_desktops(&self) -> std::collections::HashSet { + // 只取值里的 desktop 而非窗口编号;依赖跟踪按托管 ObjectRef 判断读取归属。 self.presentations .values() .map(|view| view.desktop) @@ -128,7 +143,7 @@ impl Session { impl std::ops::Deref for Session { type Target = crate::presentation::Presentation; - /// 将包装类型的只读访问转交给内部当前上下文。 + /// Rust 在 state.desktop 等字段访问时隐式调用,借用 current 对应窗口的 Presentation。 fn deref(&self) -> &Self::Target { // Session 解引用始终指向 current Presentation;调用源回调前切换窗口,结束后恢复主窗口,不能把它当全局唯一展示。 self.presentations @@ -137,13 +152,15 @@ impl std::ops::Deref for Session { } } impl std::ops::DerefMut for Session { - /// 将包装类型的可变访问转交给内部当前上下文。 + /// Rust 在修改当前展示字段时隐式调用;调用方须先选定仍存在的窗口,不能跨窗口复用缓存。 fn deref_mut(&mut self) -> &mut Self::Target { + // 与只读 Deref 使用相同键;缺项表示桥接选择/退役流程破坏了内部不变量,因此直接 expect。 self.presentations .get_mut(&self.current) .expect("selected presentation") } } +// 各原生函数 payload 共享同一个会话;Mutex 保护宿主字段,不是 VM 托管堆的并发访问许可。 type Shared = Arc>; #[derive(Debug, Clone, Copy)] @@ -215,39 +232,58 @@ enum Operation { } impl Operation { - /// 返回该操作显式参数数量范围;这里不计接收者、Type 首参和源子类字段。 + /// 被 signatures、Function/TypeMethod/NativeFactory 的 arity 查询调用,提供统一显式参数范围。 + /// 这里不计接收者、Type 首参和源子类字段;各适配器负责给范围加上自己的调用前缀。 fn arity(self) -> endor_core::CallArity { use Operation::*; // 此 arity 只含显式参数;TypeMethod 另加 receiver,NativeFactory 另加 Type 和子类字段外壳。 let (minimum, maximum) = match self { + // 类型/能力关系与属性绑定都需要两个显式操作数,不接收可选尾参。 IsInstance | IsSubtype | Bind | Implement | Implements | Supports => (2, 2), + // 这些查询所需上下文来自 Session 或绑定 owner,源调用不再传额外实参。 Stats | Viewport | Window | PageWindow | Mounts => (0, 0), + // 状态工厂接初值,绑定 set 接新值;接收者已存于 Function.owner。 State | StateSet => (1, 1), + // increment 的目标已绑定,递增步长由实现固定,不接用户步长参数。 Increment => (0, 0), + // 对象和属性名必填,字号/颜色可选;类型约束另见 operation_signature。 TextOf => (2, 4), + // 滚动块、二维与三维视口都要求两个尺寸加一个内容/场景对象。 Scroll => (3, 3), CanvasViewport => (3, 3), Viewport3D => (3, 3), + // 3D 族从共用构造元数据读取,避免这里维护另一份容易漂移的参数数量表。 SceneObject(kind) => kind.arity(), + // 控制更新接对象与手势,变换接三个分量;这些操作不使用任意长度变参。 CameraControl => (2, 2), Translate3D | Rotate3D => (3, 3), + // 相机三个参数都可省略;场景孩子则可为空,仍受全局节点数量预算限制。 Camera2D => (0, 3), Scene2D => (0, MAX_UI_NODES), + // 文本内容/矩形宽高必填,其余表现参数可选;不接容器子项尾参。 Text => (1, 3), Rect => (2, 4), + // 单值操作复用同一数量规则,具体参数的类型和语义仍由各自签名与调用分支决定。 MoveTab | MoveWindow | OpenValue | Spacer | SetText | SetColor | Append | Remove | Render | Navigate | OnClick | Present => (1, 1), + // 容器两个布局选项在前,后面最多 MAX_UI_NODES 个孩子;+2 不应错计入孩子预算。 Row | Column | Overlay => (2, MAX_UI_NODES + 2), + // 按钮接初始文本,容器插入/替换/移动接槽位或位置与另一个值。 Button => (1, 1), Insert | Replace | Move => (2, 2), + // 页面/窗口状态操作和查询使用绑定 owner 或当前会话,无显式参数。 Page | Unmount | Close | Activate | Back | Forward | Children | NewWindow | CloseWindow | Detach => (0, 0), + // open 可指定 Window;click 可指定 Mount;invalidate 可指定类别,其余走缺省语义。 Open => (0, 1), Click | Invalidate => (0, 1), + // on 必填事件名和回调,可选捕获阶段;dispatch 必填事件,可选具体出现位置。 On => (2, 3), Dispatch => (1, 2), + // 生命周期/事件钩子由宿主传一个协议上下文,用户覆盖必须遵守同一数量约定。 OnEvent | OnMount | OnUnmount => (1, 1), }; + // 同时保留必填下界和可选/变参上界,供 VM 在进入实际操作前拒绝参数数量错误。 endor_core::CallArity::range(minimum, maximum) } } @@ -255,9 +291,13 @@ impl Operation { #[derive(Debug)] /// 将一个宿主 Operation 及会话环境封装成 VM 可调用对象。 struct Function { + // bootstrap 先留空、类型齐全后补入;方法 wrapper 已校验签名时可用 None 转交同一业务分派。 signature: Option, + // calls::invoke 的分支选择值;名称只用于查询与诊断,不能替代这个明确的操作标签。 operation: Operation, + // 自由工厂为 None,绑定方法保存 receiver;调用参数切片中不再重复包含该 receiver。 owner: Option, + // 回调需要访问所属窗口的展示状态;托管强边另外由 ContextNativeFunction 的 captures 声明。 session: Shared, // 原生构造的实际源子类身份;自由 ui 工厂为 None,类型构造器显式传入 Some。 requested_type: Option, @@ -268,37 +308,45 @@ struct Function { #[derive(Debug)] /// 类型查询/能力方法的原生包装,保存调用所需的声明与会话。 struct TypeMethod { + // 包含 receiver 的完整签名,由 VM 在 native 调用边界检查。 signature: CallSignature, + // 验证接收者后转交 Function 分派的操作,不为每个 Block 复制一个函数实现。 operation: Operation, + // 声明此方法的名义类型;is_instance 允许真正的源子类,但拒绝无关对象。 expected: ObjectRef, + // 方法安装时捕获的桥接会话,决定 owner 操作使用的展示环境。 session: Shared, } -/// 判断方法是否仍为宿主提供的原生事件实现,区分它与用户源覆盖。 +/// 由 snapshot 的事件能力提取调用,判断 onEvent 是否仍为默认原生实现,区分源覆盖。 pub(crate) fn is_intrinsic_event( store: &endor_core::ObjectStore, function: ObjectRef, ) -> Result { + // 先检查 payload 是否为 ContextNativeFunction;源函数或其他 callable 不可能是默认 TypeMethod。 let Some(function) = store .object(function)? .payload() .as_any() .downcast_ref::() else { + // 类型不同即非内建实现;对象读取失败则由前面的 ? 返回错误,不能当作正常 false。 return Ok(false); }; + // 原生函数也可能是其他 UI 操作;只有 TypeMethod 包装的 OnEvent 才符合默认事件入口。 Ok(function .implementation::() .is_some_and(|method| matches!(method.operation, Operation::OnEvent))) } impl NativeCallable for TypeMethod { - /// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。 + /// VM 的 callable 签名查询入口,返回 bootstrap 按声明类型建立的完整 receiver/参数/返回契约。 fn signature(&self) -> Option { Some(self.signature.clone()) } /// 在 Operation 的显式参数范围两端各加一,计入 VM 传入的隐式接收者。 fn arity(&self) -> Option { + // operation 的范围不含 receiver;这里构造的是 VM 实际传入 args 的范围。 let arity = self.operation.arity(); Some(endor_core::CallArity::range( arity.required + 1, @@ -306,13 +354,16 @@ impl NativeCallable for TypeMethod { )) } - /// 校验首参是预期名义类型实例,再剥离接收者,复用 Function 分派执行同一 Operation。 + /// VM 分派实例方法后调用;先校验 args[0],再把 receiver 交给 Function.owner 执行业务操作。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { // 名义方法统一存在类型上,VM 把 receiver 放首参;校验后剥离 receiver 再交 Function 的显式参数分派。 + // 不假设源语法一定来自 obj.method:显式调用同样必须提供有效的对象首参。 let owner = object(args.first())?; if !cx.store().is_instance(owner, self.expected)? { + // 拒绝错误接收者,不进入分派器,防止按某种 Block 布局读取无关 payload。 return Err(invalid("incorrect receiver type for visual method")); } + // 使用临时适配器共享分派;原 wrapper 已提供签名,转交时无需重复携带完整 receiver 签名。 Function { signature: None, operation: self.operation, @@ -329,33 +380,41 @@ impl NativeCallable for TypeMethod { #[derive(Debug)] /// 为源代码调用原生类型构造器提供受检查的分配入口。 struct NativeFactory { + // 对 VM 可见的宽外壳:Type 首参 + 原生参数 + 源子类字段。 signature: Option, + // 仅描述原生选项;invoke 切分字段后再检查,不能用外壳的 Any 变参绕过类型契约。 options: Option, + // 决定实际原生分配器,Function 将据此创建 Block、相机或三维对象。 operation: Operation, + // 与该类型注册域一致的展示会话,跨 VM 的同名 Type 不能混用。 session: Shared, } impl NativeCallable for NativeFactory { - /// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。 + /// VM 在类型构造的原生调用边界查询外壳签名;具体原生参数的窄签名由 invoke 进一步检查。 fn signature(&self) -> Option { self.signature.clone() } /// 要求原生必填参数再加一个请求 Type;上界暂开放,子类字段与原生选项在 invoke 内分别校验。 fn arity(&self) -> Option { + // 原生参数有下界,但源子类可增加字段;不能把 Operation 的上界直接当总实参数量上界。 Some(endor_core::CallArity::range( self.operation.arity().required + 1, usize::MAX, )) } - /// 把请求类型的字段后缀与原生选项前缀分开验证,分配真实原生子类并初始化字段,避免非法字段先触发构造副作用。 + /// VM 调用 NativeTyped 构造器时进入;切开原生选项和子类字段,先验证,再分配并初始化结果。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // VM 类型分派把实际请求的子类放在首位,不能退回工厂所属的基类而丢失源子类身份。 let requested = object(args.first())?; + // 复制字段声明以结束对 store 的借用,后续调用 Function 和写属性需要可变访问 cx。 let fields = cx .store() .payload::(requested, "Type")? .fields() .to_vec(); + // 切掉 Type 首参后的实参由两段组成:原生构造选项在前,按声明顺序的子类字段在后。 let values = &args[1..]; // 构造实参末尾是类型声明的继承/自有字段,前缀才是原生构造选项;checked_sub 防止缺字段造成下溢。 let native_count = values @@ -373,6 +432,7 @@ impl NativeCallable for NativeFactory { "native constructor options", &values[..native_count], )?; + // requested_type 通知原生分配器附上真实子类 Type;owner 为空,因为这是构造而非实例方法。 let result = Function { signature: None, operation: self.operation, @@ -381,42 +441,50 @@ impl NativeCallable for NativeFactory { requested_type: Some(requested), } .invoke(cx, &values[..native_count])?; + // 原生工厂必须同步返回对象;不能把挂起调用或普通标量误当已分配实例继续写字段。 let NativeResult::Return(Value::Object(instance)) = result else { + // 契约错误直接交回 VM,避免把不完整结果作为源程序的构造返回值。 return Err(invalid("native constructor did not return an instance")); }; // 分配器必须返回精确 requested 类型,不能只返回基类实例;之后才初始化子类字段。 if cx.store().type_of(instance)? != Some(requested) { + // 精确身份不符说明分配器漏传/忽略了 requested_type,接受基类会破坏源子类语义。 return Err(invalid( "native constructor returned an incorrect nominal type", )); } for (field, value) in fields.iter().zip(&values[native_count..]) { + // 前面已验证数量与类型,此处按同一声明顺序写入;set_attr 负责托管引用与变更记录。 cx.set_attr(instance, &field.name, value.clone())?; } + // 将完成字段初始化的原生子类交回 VM,后续源代码才可使用它。 Ok(NativeResult::Return(Value::Object(instance))) } } -/// 统一构造本模块的参数错误,让调用方停止处理无效输入。 +/// 各解码器和 NativeCallable 分支调用的参数错误构造器,交由 VM 保留调用诊断上下文。 fn invalid(message: &'static str) -> VmError { VmError::InvalidArgument(message) } -/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。 +/// 供桥接实参/属性解码调用:只接受对象值,不把 nil、标量或缺失实参转换成虚构的对象身份。 fn object(value: Option<&Value>) -> Result { + // Option 同时覆盖缺失参数和实际值;返回 ObjectRef 仅复制句柄,不增加托管拥有根。 match value { Some(Value::Object(value)) => Ok(*value), _ => Err(invalid("expected an object")), } } -/// 把当前源值或文本选择解码成此调用需要的文本表示。 +/// 被工厂、方法及 color 参数解码调用,只接收语言字符串值,不隐式调用对象的 repr。 fn text(value: Option<&Value>) -> Result { + // 拷贝字符串是为了结束源 Value 的借用,让调用者可以把文本移入新的 Block payload。 match value { Some(Value::Text(value)) => Ok(value.clone()), _ => Err(invalid("expected text")), } } -/// 读取整数参数,拒绝非整数或无法在目标范围表示的值。 +/// 被 calls 中的容器索引/槽位等参数解析调用,要求可表示为 i64 的非负整数,再转为 u64。 fn integer(value: Option<&Value>) -> Result { + // 先走数字自身的精确转换,再做有符号到无符号检查;不能用浮点截断接受带小数的索引。 match value { Some(Value::Number(value)) => value .as_i64() @@ -425,18 +493,22 @@ fn integer(value: Option<&Value>) -> Result { _ => Err(invalid("expected a nonnegative integer")), } } -/// 解析颜色值并检查其满足当前原生绘制约定。 +/// 被文本/矩形工厂和颜色修改操作调用;缺省时使用调用方指定颜色,显式传入错误值则报错。 fn color(value: Option<&Value>, default: &str) -> Result { + // transpose 将 Some(Err) 传播出去,同时保留 None;只有未传参数才使用 default。 let value = value .map(|v| text(Some(v))) .transpose()? .unwrap_or_else(|| default.into()); + // 此处只校验并保留源字符串,render/document 在使用颜色时按同一格式转换成像素颜色。 parse_color(&value).map_err(|_| invalid("color must use #RRGGBB or #RRGGBBAA"))?; Ok(value) } -/// 读取布局尺寸参数,检查非负、有限和最大尺寸限制。 +/// 被原生工厂的尺寸/字号等参数解析调用,把 Endor 数字限制在可用的有限非负布局范围。 fn dimension(value: Option<&Value>) -> Result { + // 缺失和非 Number 值都不是合法必填尺寸;默认尺寸由调用方选择,解码器不自行猜测。 let Some(Value::Number(number)) = value else { + // 在访问 number 表示之前退出,防止把 nil 或文本当成零尺寸。 return Err(invalid("expected a finite nonnegative dimension")); }; // 分别支持大整数、十进制和二进制实数表示,不接受非实值;转 f64 后检查有限与范围,再降 f32。 @@ -448,10 +520,12 @@ fn dimension(value: Option<&Value>) -> Result { } .filter(|v| v.is_finite() && (0.0..=MAX_DIMENSION).contains(v)) .ok_or(invalid("dimension exceeds limits"))?; + // MAX_DIMENSION 远小于 f32 的有限上界;通过上面的检查后再按原生几何格式降低精度。 Ok(value as f32) } -/// 为一个托管对象安装绑定到当前会话的原生方法,声明调用名与具体 Operation。 +/// 由 calls 的 state 工厂调用,为状态对象安装 set/increment 等捕获 owner 的原生方法。 +/// 普通 Block 实例方法走 TypeMethod 共享实现;这里处理的是需要固定 owner 的对象属性函数。 fn bind( cx: &mut NativeContext<'_>, owner: ObjectRef, @@ -459,10 +533,12 @@ fn bind( operation: Operation, session: &Shared, ) -> Result<(), VmError> { + // 只在读 desktop 身份时持锁,随后分配/设属性不持有 Session 的锁,避免不必要的嵌套借用。 let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; + // 签名描述显式参数,owner 存入 payload;调用者不需要把状态对象再当首参传入。 let function = cx.allocate(ContextNativeFunction::new( name, Function { @@ -475,34 +551,40 @@ fn bind( // 绑定方法显式捕获 owner 与 desktop,让托管 GC 识别函数的真实强边,Rust Shared 会话本身不隐式保活 VM 对象。 vec![owner, desktop], ))?; + // 名义函数检查与普通源函数一致;把 callable 写成 owner 属性后由该托管强边持有。 attach_desktop_type(cx, function, "Function", desktop)?; cx.set_attr(owner, name, Value::Object(function)) } -/// 使用默认原生类型创建 Block;需要源子类身份的构造走 new_typed_block。 +/// PresentResult/present_value 等内部包装路径调用,创建没有源子类请求的默认原生 Block。 fn new_block( cx: &mut NativeContext<'_>, data: Data, session: &Shared, ) -> Result { + // 复用类型校验和节点/Page 预算,不允许内部包装绕开普通工厂的资源限制。 new_typed_block(cx, data, session, None) } -/// 分配有名义类型和默认位置/缩放的独立相机,供新视口拥有自己的导航状态。 +/// calls 的 Camera2D/CanvasViewport 分配路径调用,为视口建立有名义身份的独立相机状态。 fn new_camera2d( cx: &mut NativeContext<'_>, session: &Shared, requested: Option, args: &[Value], ) -> Result { + // 相机和使用它的视口必须在同一个类型域;查询当前窗口 desktop 取得注册表。 let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; + // 源子类构造保留 requested;自由工厂和视口默认相机使用注册的 Camera2D。 let definition = requested.unwrap_or(desktop_type(cx.store(), "Camera2D", desktop)?); + // payload 标识相机种类,具体 x/y/zoom 则是下面写入的托管属性,供源代码和依赖追踪读取。 let camera = cx.allocate(crate::viewport::Camera2D)?; cx.attach_type(camera, definition)?; for (index, name, fallback) in [(0, "x", 0.0), (1, "y", 0.0), (2, "zoom", 1.0)] { + // 位置缺省为原点、缩放缺省为 1;显式错误值由 coordinate 返回错误,不能静默套用缺省。 let value = args .get(index) .map(crate::scroll::coordinate) @@ -515,13 +597,14 @@ fn new_camera2d( Ok(camera) } -/// 创建原生 Block 并附加请求的 Endor 类型;先验证继承关系,再登记到会话节点索引。 +/// calls 的视觉工厂与 new_block 调用:按 Data.kind 分配 payload,保留请求子类并登记节点弱索引。 fn new_typed_block( cx: &mut NativeContext<'_>, data: Data, session: &Shared, requested: Option, ) -> Result { + // payload 种类确定合法名义基类;同为 Block 也不能把矩形 payload 伪装成文本类型。 let type_name = match data.kind { Kind::Text { .. } => "TextBlock", Kind::Rect { .. } => "RectBlock", @@ -536,28 +619,36 @@ fn new_typed_block( Kind::Button => "Button", Kind::Page => "Page", }; + // 在 data 移入分配器之前记住是否 Page;除通用节点预算外,Page 还受单独数量限制。 let page = matches!(data.kind, Kind::Page); + // 先读取注册域,再释放锁进行托管分配;登记弱表时会重新短暂持锁。 let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; + // expected 是当前 VM 中合法基类的精确身份,type_object 才是实例最终要附加的真实类型。 let expected = desktop_type(cx.store(), type_name, desktop)?; + // 没有源子类请求时使用基类;有请求时仍必须经过下面的继承关系验证。 let type_object = requested.unwrap_or(expected); // 叶/容器原生种类对应不同类型基类,禁止用 TextBlock 分配器创建并非其子类的名义对象。 if !cx.store().is_subtype(type_object, expected)? { return Err(invalid("native constructor cannot allocate this subclass")); } + // 业务 payload 先建立,再附上已验证的名义类型;此处没有布局几何,几何属于后续 Mount。 let block = block::allocate(cx, data)?; cx.attach_type(block, type_object)?; { + // 这个作用域把锁限制在预算检查和弱索引写入期间,返回对象时不再持锁。 let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; // 节点弱表受宿主预算限制;实际死亡弱项通过后续 reclaim 压缩,不能把弱索引当永不增长的免费结构。 if state.nodes.len() >= MAX_UI_NODES { return Err(VmError::ExecutionLimit("4096 node budget")); } if page { + // 弱表可能含已死亡节点,所以 Page 预算数存活页面,而非弱记录总数。 let mut pages = 0; for weak in &state.nodes { + // 只有成功升级且 payload 的 page 标志为真的节点才计入;普通 Block 不占页面预算。 if let Some(node) = cx.upgrade(*weak)? && cx.store().payload::(node, "Block")?.page { @@ -565,38 +656,46 @@ fn new_typed_block( } } if pages >= MAX_PAGES { + // 在登记新节点前失败,运行时仍负责释放此次调用中已经分配但未发布的对象。 return Err(VmError::ExecutionLimit("64 page budget")); } } + // 只登记弱身份,真实拥有关系由返回值、容器子项或页面注册表建立。 state.nodes.push(cx.store().downgrade(block)?); } Ok(block) } -/// 在当前 VM 中建立窗口、desktop 和独立 Presentation,实际 OS 窗口等命令发布后再创建。 +/// calls 中 Window/Detach 操作调用,建立托管窗口和 Presentation;OS 窗口由 GUI 收到发布命令后创建。 fn create_window( cx: &mut NativeContext<'_>, state: &mut Session, definition: ObjectRef, ) -> Result { if cx.store().is_read_only() { + // 布局/绘制只读调用不能借“分配新值”偷偷增加已有窗口注册表和外部资源。 return Err(invalid( "window creation is forbidden during read-only rendering", )); } if state.exiting { + // shutdown 已关闭资源域,卸载回调即使仍能执行也不能重新创建窗口延长退出。 return Err(invalid("runtime is exiting; window creation is forbidden")); } // 同一 VM 最多16窗口(含主壳);ID 单调分配并限制到 i64 正范围,关闭后不复用旧资源身份。 if state.presentations.len() >= 16 { + // 将主窗口一并计入,避免每个副窗口都独立持有缓存和托管根而无界增长。 return Err(VmError::ExecutionLimit("16 window budget")); } + // checked_add 防溢出,i64 上界保证身份能放入语言整数使用的原生窗口命令路径。 let id = state .next_window .checked_add(1) .filter(|id| *id <= i64::MAX as u64) .ok_or(invalid("window identities exhausted"))?; + // model/window 建立托管资源关系,返回窗口对象、私有桌面及页面字典,三者用于不同层次。 let (window, desktop, tabs) = crate::window::create(cx, state.master, id, definition)?; + // 缓存和 Mount 按窗口独立;共享同一业务对象也不应共享它在两个窗口中的几何。 let mut presentation = crate::presentation::Presentation::new(desktop, tabs); // 新窗口即使暂无内容也需发布有限空帧,让 GUI 根据投影创建 OS 窗口,而不是在 VM 中直接创建。 presentation.force_frame = true; @@ -605,30 +704,35 @@ fn create_window( Ok(window) } -/// 从页面登记信息取得所属窗口,保持 Page 的归属与原生资源身份一致。 +/// show_in 与页面/窗口方法调用,遍历真实注册表确定 Page 当前归属;未打开的 Page 返回 None。 fn page_window( store: &endor_core::ObjectStore, state: &Session, page: ObjectRef, ) -> Result, VmError> { for (id, view) in &state.presentations { + // 从每个私有 desktop 取得托管 Window,不能只依据当前活动页判断窗口的全部页面。 let window = object(Some(&store.get_attr(view.desktop, "window")?))?; if crate::window::pages(store, window)?.contains(&page) { + // 返回原生资源 ID 而非 Window ObjectRef,供 Session 的 presentations 索引使用。 return Ok(Some(*id)); } } + // 无归属是新 Page 的正常状态,show_in 会再选 current;注册表读取失败已由 ? 传播。 Ok(None) } -/// 撤销页面在展示会话中的登记,配合模型层处理活动页面与生命周期。 +/// show_in 跨窗口迁移及 calls 关闭页面时调用,撤销登记并为源窗口安排替代活动页。 fn remove_page( cx: &mut NativeContext<'_>, view: &mut crate::presentation::Presentation, page: ObjectRef, ) -> Result<(), VmError> { + // view 决定移除的是哪个窗口的关系;同一个 Page 的业务内容并不在这里销毁。 let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; crate::window::unregister_page(cx, window, page)?; // 双层 pending 区分无请求与请求清空;移除当前/待选页时改选剩余第一页,或显式 pending=None页面。 if view.pending.unwrap_or(view.active) == Some(page) { + // 必须在 unregister_page 之后选取,才不会重新选中刚移走的 Page。 let next = crate::window::pages(cx.store(), window)?.first().copied(); cx.set_attr( view.desktop, @@ -639,30 +743,37 @@ fn remove_page( } Ok(()) } -/// 在指定窗口安排页面展示,并维护目标 Presentation 的活动/待发布状态。 +/// 页面 open/move 等调用与 show 的共同入口:验证目标、迁移注册关系,再安排一次待提交展示。 fn show_in( cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared, target: Option, ) -> Result<(), VmError> { + // 一次持锁覆盖“查旧归属—移除—登记新归属”,避免 Session 内出现中途被其他桥接调用观察的关系。 let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; + // old 是迁移前归属;后面仅在新旧 ID 不同时撤源登记,重复 activate 不应搬动页面。 let old = page_window(cx.store(), &state, page)?; + // target 是托管 Window,id 是对应宿主窗口编号;验证后才能用它索引 Presentation。 let id = match target { // 显式目标窗口既要未关闭,也要对应本会话 Presentation 的同一 desktop,拒绝其他 VM 的窗口身份。 Some(window) => { + // 先检查 payload 种类,再验证资源状态及 desktop 身份,不能只信任传入对象的数字 ID。 let data = cx .store() .payload::(window, "Window")?; if data.closed { + // 已退役窗口仍可能被源代码持有,但不能再作为展示目标。 return Err(invalid("native window is closed")); } + // ID 用于查表,desktop 的相等检查补上所属桥接会话验证。 let id = data.id; if state .presentations .get(&id) .is_none_or(|view| view.desktop != data.desktop) { + // 缺少 Presentation 或 desktop 不匹配都说明不是本会话可操作的窗口。 return Err(invalid("window belongs to another runtime")); } id @@ -683,10 +794,12 @@ fn show_in( page, )?; } + // 目标经过检查后取得可变展示上下文,用于写 pending;不会在这里改 active 或发布帧。 let view = state .presentations .get_mut(&id) .ok_or(invalid("missing target window"))?; + // model 注册表保存 Page 的强拥有关系,也决定标签顺序;只写 pending 不足以完成登记。 let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; crate::window::register_page(cx, window, page)?; cx.set_attr(view.desktop, "pending", Value::Object(page))?; @@ -694,7 +807,7 @@ fn show_in( view.pending = Some(Some(page)); Ok(()) } -/// 在当前窗口安排页面展示,实际几何和画面由后续刷新提交。 +/// calls 的激活/导航及 PresentResult 调用,不指定新目标窗口,委托 show_in 保留既有归属或选择 current。 fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result<(), VmError> { show_in(cx, page, session, None) } @@ -702,26 +815,32 @@ fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result #[derive(Debug)] /// 接收源 present 结果并将其转换成宿主可展示内容的原生续接步骤。 struct PresentResult { + // ui.open 的续接需确保有 Page 并安排展示;ui.present 的续接只返回视觉对象。 open: bool, + // 源 present 返回后仍要回到原桥接域分配包装页/修改 pending,因此续接保存同一会话。 session: Shared, } impl NativeCallable for PresentResult { - /// 接收 present 委托返回的唯一视觉对象,作为后续展示处理的输入。 + /// VM 的原生续接调用只提供被委托 present 的一个返回值;参数数量在进入 invoke 前检查。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// 验证 present 结果为 Block;open 模式必要时包装 Page 并展示,否则只返回转换得到的视觉对象。 + /// VM 完成源 Presentable.present 后调用;验证返回 payload,并按 open 标志选择包装和展示行为。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 类型声明不能替代返回值防御检查,错误返回立即中止,不能把它包装成看似成功的文本。 let [Value::Object(root)] = args else { return Err(invalid("Presentable.present must return a Block")); }; + // payload 检查保证返回的是原生视觉 Block;page 标志决定是否还需外包 Page。 let is_page = cx.store().payload::(*root, "Block")?.page; // present 委托返回值必须为 Block;open=true 必要时包 Page 并展示,open=false 只返回转换结果。 let result = if self.open { + // open 对已是 Page 的值保留身份,对普通 Block 创建单子项 Page,均走同一 show 流程。 let page = if is_page { *root } else { + // 包装只新增页面,不复制返回对象;同一个 Block 可随后产生自己的 Mount。 new_block( cx, Data::new(Kind::Page, vec![*root], 0.0, 0.0), @@ -731,18 +850,21 @@ impl NativeCallable for PresentResult { show(cx, page, &self.session)?; page } else { + // present 是值转换,不能附带把页面激活/打开的外部副作用。 *root }; Ok(NativeResult::Return(Value::Object(result))) } } -/// 返回已有 Block,或把其他值的可读表示包装成 TextBlock;Presentable 委托由外层调用分派负责。 +/// calls 的 open/present 普通值路径调用:已有 Block 保持身份,其余转成默认 TextBlock。 +/// 具有 Presentable 协议的源方法调用在 calls 的外层处理,返回后才进入 PresentResult。 fn present_value( cx: &mut NativeContext<'_>, value: &Value, session: &Shared, ) -> Result { + // 快速路径只接受真实 Block payload,避免对已有视觉对象调用 repr 后丢失其行为与内容。 if let Value::Object(block) = value && cx .store() @@ -751,6 +873,7 @@ fn present_value( .as_any() .is::() { + // 复用对象身份,不在此新增根或克隆业务 payload;调用边界管理返回值的拥有关系。 return Ok(*block); } // 非 Block 的标量/对象转可读文字,Presentable 方法委托在 calls.rs 的更外层处理,本函数不自己调用 present。 @@ -762,8 +885,10 @@ fn present_value( Value::Object(v) => cx.store().repr(*v)?, }; if value.len() > 16_384 { + // 按 UTF-8 字节限制文本开销;这是展示限制,不是“最多 16384 个 Unicode 字符”。 return Err(invalid("presentation exceeds 16 KiB")); } + // 回退视觉使用默认颜色和字号种类;几何留给后续测量,不在转换阶段猜测像素尺寸。 new_block( cx, Data::new( @@ -783,16 +908,23 @@ fn present_value( #[must_use = "pending layout must be resumed on its owning VM"] /// 桥接布局的挂起/完成结果;PendingLayout 必须与其窗口上下文一起继续处理。 pub enum LayoutProgress { + // 交给 endor_host 保存;恢复时必须携带同一 VM、窗口和检查点,不能换域重跑。 Pending(Box), + // 几何已就绪的帧草稿;运行时仍需完成绘制和发布阶段,不等于此刻已显示到屏幕。 Complete(Box), } /// 一次未提交布局的上下文:目标窗口、根、布局/缓存、延后钩子以及用于失败恢复的检查点。 struct LayoutContext { + // 本次布局使用的页面模式/相机参数;窗口 resize 重定向时需要和新约束比较。 surface: crate::page::Surface, + // 不可变视觉树草稿,布局和后续绘制必须使用与这一轮匹配的对象快照。 root: Arc, + // 与本轮页面注册表一致的纯数据标签投影,不能在提交时混入下一轮的顺序。 tabs: Vec, + // 原生输入给出的整窗可用尺寸;不同于页面局部约束,完成布局时据此构造完整帧。 full_viewport: Option, + // 私有宿主对象和资源 ID 一起记录所属窗口,续体恢复不能误用 Session.current 的新值。 desktop: ObjectRef, window: u64, // 布局同步返回的生命周期序列拥有根,暂留到安全提交边界执行/释放,取消时也必须清理。 @@ -803,18 +935,23 @@ struct LayoutContext { /// 挂起布局的拥有者。续体与上下文必须一起保留并最终恢复或取消。 pub struct PendingLayout { + // source_layout 拥有尚未完成的 VM 调用和测量状态,必须继续推进或显式取消。 work: crate::source_layout::Work, + // 与 work 配套的树、窗口和检查点;单独丢弃上下文会使取消后无法恢复未提交 Mount。 context: LayoutContext, } /// 运行时持有的桥接能力句柄,通过共享 Session 协调托管对象与每窗口展示。 pub struct UiBridge { + // runtime 安装全局名字和调用 ui.open 时使用;真正的 VM 拥有根由 Candidate 管理。 pub ui: ObjectRef, + // 工厂/方法与运行时共用展示会话,窗口切换、版本和缓存均从这里协调。 session: Shared, + // 本桥接域的任务调度器,运行时通过 poll_tasks 推进,退出通过 shutdown/cancel_tasks 清理。 tasks: crate::tasks::Scheduler, } -/// 解析对象的方法并在同一 VM 中调用,使源覆盖与原生成员共用方法查找规则。 +/// dispatch_tab/页面事件等桥接控制路径调用,通过 VM 正常查找绑定方法,保留源覆盖与继承语义。 fn invoke_member( vm: &mut Vm, owner: ObjectRef, @@ -824,7 +961,9 @@ fn invoke_member( ) -> Result { // 使用 VM 正常绑定规则创建临时方法值;无论 call 成功/失败,先保存 result 再释放 bound 根。 let bound = vm.bind_attr(owner, method)?; + // 暂存调用结果而非直接 ? 返回,确保即使用户方法报错也会执行后面的 release_value(bound)。 let result = match &bound { + // 方法值为对象才可能调用;VM 再检查对象是否 callable,并应用帧数/指令预算。 Value::Object(function) => vm .call_with_limits( *function, @@ -835,29 +974,36 @@ fn invoke_member( }, ) .map_err(|error| crate::endor_host::runtime_error(vm, error)), + // 普通属性没有函数身份,不能把它当无参 getter 或忽略调用。 _ => Err(anyhow::anyhow!("object method is not callable")), }; vm.release_value(bound)?; result } -/// 将托管页面注册表投影成稳定标签 ID、标题与活动状态,不把托管引用交给原生侧栏。 +/// prepare/刷新流程调用,把页面注册表投影成 GUI 可直接读取的标签数据;不向 GUI 传 ObjectRef。 fn project_tabs( vm: &Vm, state: &mut crate::presentation::Presentation, ) -> Result> { + // 从窗口模型取得完整页面顺序,不能只用当前 active 页面构造标签栏。 let window = object(Some(&vm.get_attr(state.desktop, "window")?))?; + // pages 是本次读取的托管身份列表;下方逐项转为不依赖 VM 的标题、数字 ID 和选中状态。 let pages = crate::window::pages(vm.object_store(), window)?; // 标签选中反映待提交页面(若有请求),否则沿用 active;页面标题和顺序仍从真实窗口注册表提取。 let active = state.pending.unwrap_or(state.active); + // live 重建仍有效的弱身份到标签 ID 映射,过期/移出页面的旧记录不再保留。 let mut live = Vec::new(); + // tabs 是对外返回的纯数据;和 live 分开,避免渲染线程接触托管弱引用。 let mut tabs = Vec::new(); for (index, page) in pages.iter().copied().enumerate() { + // 弱身份识别同一个 Page,即使它改标题或位置也不改变该窗口内的标签 ID。 let weak = vm.object_store().downgrade(page)?; // 同一 Page 弱身份复用该窗口标签 ID,新增页用高水位;移出后 live 更新会移除旧映射,不靠标题匹配。 let id = if let Some((_, id)) = state.tab_ids.iter().find(|(w, _)| *w == weak) { *id } else { + // 新页面才分配单调增长的 ID;checked_add 避免溢出后把旧拖动事件指向另一标签。 state.next_tab = state .next_tab .checked_add(1) @@ -874,38 +1020,47 @@ fn project_tabs( .collect(), _ => format!("Page {}", index + 1), }; + // 对外标签只保存可显示数据;工具标记用于 GUI 识别开发工具入口,不泄露 Page 对象。 tabs.push(crate::tabs::Tab { id, title, developer_tool: match vm.get_attr(page, "_tesseraTool") { + // 仅接受源属性中的字符串工具名,缺失或其他值都投影为普通页面。 Ok(Value::Text(name)) => Some(name), _ => None, }, active: active == Some(page), }); } + // 覆盖映射而不是追加,后续 dispatch_tab 只允许解析这次仍在注册表里的页面身份。 state.tab_ids = live; Ok(tabs) } impl UiBridge { - /// Detach all occurrences without running layout/paint. All windows are - /// already closed before source unmount hooks run, so they cannot remount. - /// 按窗口和展示生命周期执行有界退出清理,允许调用方根据故障状态决定是否执行回调。 + /// endor_host 清理运行时调用,取消任务并退役所有窗口展示;callbacks 决定能否继续执行用户卸载钩子。 + /// 返回窗口、Mount 和取消任务数量供诊断使用;不为退出再启动布局或绘制。 pub fn shutdown(&self, vm: &mut Vm, callbacks: bool) -> Result<(usize, usize, usize)> { // 退出先关闭新任务入口并取消旧任务,再标 session.exiting,避免卸载回调重新创建任务/窗口。 let tasks = self.tasks.quiesce(vm)?; + // 保存各窗口卸载驱动的拥有根,必须等所有窗口先关闭后才逐个调用并释放。 let mut drivers = Vec::new(); + // 统计退出前仍活动的出现位置,不按共享业务 Block 数量计数。 let mut mounts = 0; + // 闭包收集准备阶段错误但不立刻退出外层:此前已生成的驱动仍需释放。 let prepared = (|| -> Result { + // 只在修改窗口表时持锁;用户钩子执行移到锁作用域之外,避免回调重入死锁。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; state.exiting = true; + // 复制键后再逐窗可变借用,保证本轮遍历稳定且不持有 map 迭代借用。 let ids: Vec<_> = state.presentations.keys().copied().collect(); for id in &ids { + // ids 来自同一锁内的表,未删除过键,因此缺项表示内部不变量被破坏。 let view = state.presentations.get_mut(id).expect("known window"); + // 每个窗口自己的 desktop 用于切断挂载关系,不能用主窗口 desktop 代替。 let desktop = view.desktop; mounts += view.mounts.active.len(); // 正常域用空结构 synchronize 安排孩子先卸载的 hooks;故障域直接 retire,不能再次执行用户代码。 @@ -918,11 +1073,14 @@ impl UiBridge { &crate::document::Frame::default(), false, )? { + // synchronize 只准备卸载顺序;现在不运行回调,以便先关闭其他窗口。 drivers.push((*id, hooks)); } } else { + // 故障 VM 不再执行源代码;直接断开挂载关系,把清理限于宿主操作。 view.mounts.retire(vm, desktop)?; } + // 取得 model/window 的资源对象,以便退役页面拥有关系与窗口状态。 let window = object(Some(&vm.get_attr(desktop, "window")?))?; // 所有窗口先标 closed 并断 Page 拥有关系,然后才在下面执行用户卸载 hooks,阻止回调重新打开已关闭窗口。 crate::window::retire(vm, window)?; @@ -934,12 +1092,15 @@ impl UiBridge { } Ok(ids.len()) })(); + // 保留准备失败或数量结果;后续钩子/释放错误仍可把结果转成 Err,但不跳过驱动释放。 let mut result = prepared.map(|windows| (windows, mounts, tasks)); // 若之前清理/回调失败,余下 hooks 不再执行,但所有驱动根仍逐项 release,避免失败路径泄漏。 for (id, hooks) in drivers { if result.is_ok() { + // 只有此前所有退出操作正常时才调用源代码;每个驱动有独立有界 VM 预算。 let call = (|| -> Result<()> { self.select_window(id)?; + // 钩子调用的返回值也是拥有值,即使无业务用途也必须释放。 let value = vm .call_with_limits( hooks, @@ -954,47 +1115,55 @@ impl UiBridge { Ok(()) })(); if let Err(error) = call { + // 一次失败后停止后续用户钩子,继续外层释放所有已准备的驱动根。 result = Err(error); } } if let Err(error) = vm.release(hooks) { + // 无论钩子是否实际调用都释放;释放失败也报告,不能把错误吞作正常退出。 result = Err(error.into()); } } + // 退出方法返回前恢复桥接默认上下文;Presentation 表尚未在本方法内删除主窗口。 self.select_window(crate::window::MAIN_WINDOW)?; result } - /// 查询任务系统是否仍有可推进的协作工作,供 worker 选择等待策略。 + /// endor_host/worker 查询是否仍有协作任务,以决定继续推进还是等待外部事件;不执行任务本身。 pub fn has_cooperative_tasks(&self) -> bool { self.tasks.has_cooperative_work() } - /// 把当前 VM 交给任务调度器推进,限制一轮交付数量。 + /// endor_host 的 VM 轮询调用,在所属 VM 中交付至多 limit 项任务,保持回调串行。 pub fn poll_tasks(&self, vm: &mut Vm, limit: usize) -> Result { self.tasks.poll(vm, limit) } - /// 取消当前域中的任务,撤销关闭/失败后不应再发生的交付。 + /// endor_host 取消/退役运行时调用,取消本域全部任务;已在后台进行的 I/O 也不得再交付旧回调。 pub fn cancel_tasks(&self, vm: &mut Vm) -> Result { self.tasks.cancel_all(vm) } - /// 返回当前仍有效的展示窗口身份,用于稳定遍历一次全窗口刷新。 + /// endor_host 启动全窗口刷新时调用,取得经过迁移退旧优先排序的窗口身份快照。 pub fn window_ids(&self) -> Result> { + // 持锁期间只读/排序原生资源表,不执行 VM 回调;调用者取得独立列表后再开始逐窗工作。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 复制身份,不把 Session 的 map 借用暴露给运行时;后续每窗刷新仍显式 select_window。 let mut ids: Vec<_> = state.presentations.keys().copied().collect(); // 优先处理切离旧 active 页的窗口,使跨窗口迁移先退旧出现再挂新出现;同优先级按窗口 ID 稳定排序。 ids.sort_by_key(|id| { + // 第一个排序键区分“旧 active 即将退出”和其他窗口,第二个键确保同组顺序可重复。 let view = &state.presentations[id]; ( if view.active.is_some() && view.pending.is_some() && view.pending.flatten() != view.active { + // pending = Some(None) 也属于切离旧页,需先安排卸载旧出现位置。 0 } else { + // 无旧页或没有切换请求,无需抢在迁移源窗口之前刷新。 1 }, *id, @@ -1002,9 +1171,10 @@ impl UiBridge { }); Ok(ids) } - /// 判断窗口是否仍登记且没有进入关闭状态。 + /// runtime 在接受窗口输入前查询;锁异常、缺失或 closing 都视为不再可接受,不执行 VM 操作。 pub fn accepts_window(&self, id: u64) -> bool { self.session.lock().is_ok_and(|state| { + // 控制线程只查询原生会话状态;页面对象身份与版本还有单独的后续校验。 state .presentations .get(&id) @@ -1012,31 +1182,38 @@ impl UiBridge { }) } - /// 切换当前 Presentation 上下文;不会创建新窗口或复制 VM。 + /// endor_host 的逐窗刷新、事件及退出钩子调用,选择后续 Session 字段访问的目标窗口。 pub fn select_window(&self, id: u64) -> Result<()> { + // current 的检查与写入放在同一短锁中,Deref 的“选中窗口存在”前提由本方法维护。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; if !state.presentations.contains_key(&id) { + // 先检查再改 current,失败时保留原选择,避免 Deref 命中不存在的 Presentation。 bail!("window is unavailable"); } state.current = id; Ok(()) } - /// 从托管展示域撤销窗口,退役 Mount 并清理该窗口的私有对象关系。 + /// endor_host 故障/关闭路径调用,直接退役副窗口并丢弃未发布命令,不触发源卸载回调。 pub fn retire_window(&self, vm: &mut Vm, id: u64) -> Result<()> { if id == crate::window::MAIN_WINDOW { + // 主壳由原生生命周期管理,不能通过删除 Presentation 假装已关闭 OS 主窗口。 bail!("the primary shell is closed by the OS window control"); } + // 锁覆盖副窗口所有关系的撤销,后续不在此执行源回调,因此无需放锁再进入 VM。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; let Some(view) = state.presentations.get_mut(&id) else { + // 已退役的窗口再次清理是正常幂等操作,不能让重复关闭导致整个运行时失败。 return Ok(()); }; + // 把要清理窗口的身份保存下来,随后依次断 Mount、窗口页面和焦点属性的托管引用。 let desktop = view.desktop; + // model/window 负责拥有关系;Session 中删项本身不足以关闭托管资源。 let window = object(Some(&vm.get_attr(desktop, "window")?))?; view.mounts.retire(vm, desktop)?; crate::window::retire(vm, window)?; @@ -1046,6 +1223,7 @@ impl UiBridge { // Draining is not publication: failed-domain effects are discarded. // 故障宿主退役的命令只丢弃不发布,防止失败域遗留 Resize/Focus 等影响当前原生状态。 let _ = crate::window::take_commands(vm, window)?; + // 从主资源字典删除副窗口强边,允许仍无其他引用的私有窗口对象被回收。 let windows = object(Some(&vm.get_attr(state.master, "windows")?))?; vm.dict_delete(windows, &Value::Text(id.to_string()))?; state.presentations.remove(&id); @@ -1053,8 +1231,9 @@ impl UiBridge { Ok(()) } - /// 收尾已经关闭的展示资源,避免它们参与下一次窗口遍历。 + /// endor_host 在正常全窗口刷新收尾调用,移除已 closing 的副窗口并保留其待发布原生命令。 pub fn finish_closed(&self, vm: &mut Vm) -> Result<()> { + // 正常收尾与故障 retire_window 不同:本锁内先保存命令,再删除托管/原生资源表项。 let mut state = self .session .lock() @@ -1066,9 +1245,12 @@ impl UiBridge { .filter(|(id, view)| **id != crate::window::MAIN_WINDOW && view.closing) .map(|(id, _)| *id) .collect(); + // 该字典是托管拥有关系,需和原生 presentations 的删项保持一致。 let windows = object(Some(&vm.get_attr(state.master, "windows")?))?; for id in ids { + // 键来自上面已筛选列表,持同一把锁时不会被其他路径删掉。 let desktop = state.presentations[&id].desktop; + // 在移除资源表前取出 outbox,避免正常关闭命令失去最后的读取机会。 let window = object(Some(&vm.get_attr(desktop, "window")?))?; state .retired_commands @@ -1079,8 +1261,9 @@ impl UiBridge { state.current = crate::window::MAIN_WINDOW; Ok(()) } - /// 从各托管窗口收集待发布命令;原生执行时还需校验 session。 + /// endor_host/worker 形成发布结果时调用,收集并清空各窗口 outbox;GUI 执行仍要校验 session。 pub fn take_window_commands(&self, vm: &Vm) -> Result> { + // 需要可变锁消费 retired_commands;VM 侧 take_commands 也清空各自 outbox,不在此调用源方法。 let mut state = self .session .lock() @@ -1088,14 +1271,16 @@ impl UiBridge { // 先取已移除窗口的最后命令,再合并仍活动窗口 outbox;按窗口/窗口内序号排序,不丢失正常 Close 请求。 let mut commands = std::mem::take(&mut state.retired_commands); for view in state.presentations.values() { + // 活动和正在关闭但尚未移除的窗口都可能有命令,不能只收集当前窗口。 let window = object(Some(&vm.get_attr(view.desktop, "window")?))?; commands.extend(crate::window::take_commands(vm, window)?); } + // 保证同窗命令按递增序号执行;不同窗口之间只提供稳定顺序,不伪造跨窗口事务顺序。 commands.sort_by_key(|command| (command.window, command.sequence)); Ok(commands) } - /// 校验并更新所属窗口的逻辑尺寸输入;尺寸不变时不制造新布局,改变时标记需要刷新。 + /// endor_host 创建会话/接收 resize 时调用,更新选中窗口的逻辑尺寸,返回是否真的发生变化。 pub fn set_viewport(&self, vm: &mut Vm, viewport: Option) -> Result { if let Some(size) = viewport && (!size.width.is_finite() @@ -1103,18 +1288,23 @@ impl UiBridge { || !(0.0..=MAX_DIMENSION as f32).contains(&size.width) || !(0.0..=MAX_DIMENSION as f32).contains(&size.height)) { + // 在分配托管输入前拒绝 NaN/无穷/负值/过大值,保留原来合法尺寸和发布画面。 bail!("invalid window viewport"); } + // 校验成功后再持锁更新当前窗口的尺寸,其他 Presentation 保留各自 viewport。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; // 尺寸比较在分配新输入对象前完成,避免相同 resize 通知改变 VM 版本而触发自我失效。 if state.layout.viewport == viewport { + // 相同输入返回 false,worker 不必因此强制再排版或修改依赖版本。 return Ok(false); } if let Some(size) = viewport { + // 只在有尺寸时创建托管协议对象;该局部对象先有根,设置 desktop 属性成功后再释放局部根。 let input = vm.new_object()?; + // 闭包暂存写属性错误,确保任意一个字段写入失败也能经过下面的 release(input)。 let result = (|| { vm.set_attr(input, "width", Value::Number((size.width as f64).into()))?; vm.set_attr(input, "height", Value::Number((size.height as f64).into()))?; @@ -1124,6 +1314,7 @@ impl UiBridge { vm.release(input)?; result?; } else { + // 未知尺寸用 nil 明确表示,不把旧尺寸残留在源 ui.viewport 可见输入里。 vm.set_attr(state.desktop, "viewport", Value::Nil)?; } state.layout.viewport = viewport; @@ -1134,10 +1325,10 @@ impl UiBridge { Ok(true) } - /// 将 ui 入口与兼容名称安装到本次程序的全局字典,保持所有调用处在同一个 VM 域。 + /// endor_host 完成 bootstrap 后调用,把 UI 命名空间、兼容名字和类型查询入口写入程序全局字典。 pub fn install_globals(&self, vm: &mut Vm, globals: ObjectRef) -> Result<()> { vm.dict_set(globals, Value::Text("ui".into()), Value::Object(self.ui))?; - // Compatibility spelling used by the original module examples. + // 旧模块示例把 Block 写成 View;别名引用同一 Type,不建立另一个互不相容的同名类型。 vm.dict_set( globals, Value::Text("View".into()), @@ -1145,13 +1336,15 @@ impl UiBridge { )?; // 全局名引用 ui 中同一批名义类型/函数对象,模块和 Console 不会各复制一份类型体系。 for name in TYPE_NAMES.iter().chain(QUERY_NAMES) { + // bootstrap 必须已安装相应名字;取值失败就返回,不能悄悄让程序缺少某个声明的全局。 let value = vm.get_attr(self.ui, name)?; vm.dict_set(globals, Value::Text((*name).into()), value)?; } Ok(()) } - /// 检查当前会话是否已经安排或拥有可展示页面。 + /// endor_host 处理主程序结果时查询,判断源程序是否已自行打开页面,从而避免重复包装/展示返回值。 pub fn has_page(&self) -> Result { + // 检查所有窗口;pending 的 Some(None) 是清空请求,不等于已经有一个新页面。 Ok(self .session .lock() @@ -1160,12 +1353,14 @@ impl UiBridge { .values() .any(|view| view.pending.flatten().is_some() || view.active.is_some())) } - /// 验证标签拖放仍指向有效页面和窗口关系,避免旧拖动操作跨域生效。 + /// runtime 的输入接纳路径调用,验证拖放两端的版本和注册顺序仍与发布画面一致,不修改页面。 pub fn accepts_tab_drop(&self, vm: &Vm, input: &crate::events::Input) -> bool { let Some(target) = &input.tab_drop else { + // 普通事件没有拖放目标,本方法不额外限制;它们由其他输入身份检查处理。 return true; }; let Ok(state) = self.session.lock() else { + // 无法可靠验证时拒绝,而不是让不可用的会话继续重排页面。 return false; }; // 拖放源与目标都必须匹配各自发布版本,且真实 Page 注册顺序仍对应标签弱身份表,防止用陈旧列表下标重排。 @@ -1174,15 +1369,19 @@ impl UiBridge { (target.window, target.layout_version), ] { let Some(view) = state.presentations.get(&id) else { + // 任一端窗口已经退役都使整次拖动失效。 return false; }; if view.closing || view.published_version != version { + // 不仅源端,目标端布局变化也会让落点索引不再可靠。 return false; } let Ok(Value::Object(window)) = vm.get_attr(view.desktop, "window") else { + // 内部关系读取异常在这个布尔验证接口统一映射为拒绝,不产生半次迁移。 return false; }; let Ok(pages) = crate::window::pages(vm.object_store(), window) else { + // 页面注册表不可读时同样不能相信 GUI 发来的标签顺序。 return false; }; if view.tab_ids.len() != pages.len() @@ -1191,29 +1390,36 @@ impl UiBridge { .zip(&view.tab_ids) .all(|(page, (weak, _))| vm.object_store().downgrade(*page).ok() == Some(*weak)) { + // 数量相同也可能已换页/重排,所以逐个比较弱身份而非只比较长度。 return false; } if id == target.window && target.index > pages.len() { + // 允许插到末尾(等于 len),拒绝超出末尾的过期/无效索引。 return false; } } true } - /// 通过展示协议打开源值,必要时创建或选择承载它的页面。 + /// endor_host 的程序结果展示路径调用,经 ui.open 复用 Presentable 协议及普通值包装规则。 pub fn open_value(&self, vm: &mut Vm, value: &Value) -> Result<()> { + // 使用已安装 callable 而不直接调用 new_block,源 Presentable 的覆盖因此仍会执行。 let function = object(Some(&vm.get_attr(self.ui, "open")?))?; + // 结果 Page 已由页面注册表持有,本地返回根用完即释放,避免每次 open 泄漏一个根。 let page = vm.call(function, std::slice::from_ref(value))?; vm.release_value(page)?; Ok(()) } - /// 处理页面激活、关闭、移动和拆分等标签操作,保持窗口所有权与展示状态一致。 + /// dispatch 的标签事件分支调用,按发布时 Tab ID 找回 Page,再调用可被源代码覆盖的页面方法。 fn dispatch_tab(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> { + // 锁内只解析来源和身份,返回 Page 后释放锁,真正用户方法执行不能持有 Session 锁。 let page = { + // 只借用当前窗口已发布的标签映射,不升级成新的页面根;页面注册表仍持有 Page。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; if state.published_version != input.layout_version { + // 标签画面已经更新,旧 ID/顺序可能不再有效;拒绝后由 GUI 使用新发布数据。 bail!("event targets an obsolete presentation"); } // input.mount 在标签路径表示 Tab ID,先映回弱 Page,再确认仍在当前窗口 pages 集合,不是 Mount 几何下标。 @@ -1223,6 +1429,7 @@ impl UiBridge { .find(|(_, id)| *id == input.mount) .map(|(weak, _)| *weak) .ok_or_else(|| anyhow::anyhow!("tab has expired"))?; + // 再在真实窗口注册表中验证弱身份,不能仅凭旧 tab_ids 让已移出的页面继续响应操作。 let window = object(Some(&vm.get_attr(state.desktop, "window")?))?; crate::window::pages(vm.object_store(), window)? .iter() @@ -1231,28 +1438,35 @@ impl UiBridge { .ok_or_else(|| anyhow::anyhow!("Page is no longer open"))? }; if let Some(target) = &input.tab_drop { + // 拖放可能跨窗口;第二段锁只验证目标,避免下方 moveToWindow 源覆盖重入时死锁。 let window = { + // 回调前复制目标窗口身份;释放锁后再执行可覆盖的 moveToWindow。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 输入的窗口 ID 必须仍有展示上下文;不能为过期拖动自动重建一个窗口。 let view = state .presentations .get(&target.window) .ok_or_else(|| anyhow::anyhow!("drop window has closed"))?; if view.published_version != target.layout_version { + // 目标标签布局改变后插入位置不可沿用,即使源页面本身仍存活也要拒绝。 bail!("drop targets an obsolete presentation"); } object(Some(&vm.get_attr(view.desktop, "window")?))? }; if target.window != input.window { + // 跨窗口迁移走名义方法分派,给源子类决定是否接受/重定向,而非直接改宿主字典。 let value = invoke_member(vm, page, "moveToWindow", &[Value::Object(window)], 300_000)?; vm.release_value(value)?; } // An override may deliberately decline or redirect the move. Do // not reorder an unrelated window after that source decision. + // 回调可能改写实际归属,必须在调用结束后重新读取,不能沿用最初 target 当作已成功。 let destination = { + // 重新加锁读取回调后的真实归属,不沿用迁移前的 Session 借用。 let state = self .session .lock() @@ -1261,7 +1475,9 @@ impl UiBridge { }; // 源 moveToWindow 可覆盖并拒绝/改投其他窗口;只有确认实际归属仍为预期目标才继续重排与激活。 if destination == Some(target.window) { + // 用回调之后的真实顺序定位页面,避免回调中增删标签使旧下标错指其他页面。 let pages = crate::window::pages(vm.object_store(), window)?; + // 找不到页面说明迁移过程中它已被关闭/移走;不能继续以默认 0 号位置重排。 let from = pages .iter() .position(|candidate| *candidate == page) @@ -1270,11 +1486,14 @@ impl UiBridge { let position = if input.window == target.window && from < target.index { target.index - 1 } else { + // 跨窗口插入或向前拖动保留原缝位置,不做同窗移除产生的偏移修正。 target.index }; if position >= pages.len() { + // 用户方法改变了目标列表,当前计划位置已不可用,不把它静默夹到末尾。 bail!("drop destination changed during Page method"); } + // 重排同样保留源 moveTab 覆盖;结果是临时拥有值,随后显式释放。 let value = invoke_member( vm, page, @@ -1283,19 +1502,23 @@ impl UiBridge { 300_000, )?; vm.release_value(value)?; + // 最后激活被拖页面;若源 activate 另有决定,下一轮刷新以真实 pending 为准。 let value = invoke_member(vm, page, "activate", &[], 300_000)?; vm.release_value(value)?; } return Ok(()); } + // 无拖放目标的标签事件只需一次页面方法调用;普通标签选择统一映射到 activate。 let method = match input.kind.as_str() { "tab-close" => "close", "tab-detach" => "detachToWindow", _ => "activate", }; + // detach 的返回 Window 还可能用于屏幕定位,因此先保存返回根,不立即释放。 let value = invoke_member(vm, page, method, &[], 1_000_000)?; // detach 返回值可能被源覆盖成其他值,仅确为 Window 且带屏幕位置才调用 moveTo;最后统一释放原返回值。 let placed = (|| -> Result<()> { + // 只有带坐标且真实返回 ManagedWindow 的拆离操作才移动 OS 资源;其他覆盖返回值正常保留语义。 if input.kind == "tab-detach" && let Some([x, y]) = input.screen_position && let Value::Object(window) = &value @@ -1303,6 +1526,7 @@ impl UiBridge { .payload::(*window, "Window") .is_ok() { + // moveTo 也经绑定方法调用,坐标转为语言数字;返回值和外层 detach 返回值分别释放。 let result = invoke_member( vm, *window, @@ -1317,13 +1541,15 @@ impl UiBridge { } Ok(()) })(); + // 即使 moveTo 出错也先释放 detach 返回根,再传播 placed,防止错误路径泄漏。 vm.release_value(value)?; placed?; Ok(()) } - /// 在所属窗口的展示上下文中处理输入,区分页面管理、滚动、焦点、相机与普通事件。 + /// endor_host 已选定输入窗口后调用,校验信封并分派宿主交互或托管事件;源代码仍在同一 VM 串行执行。 pub fn dispatch(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> { + // 信封层校验保证坐标/增量等字段满足事件格式,后面仍要验证窗口、版本、Mount 与可见性。 input .validate() .map_err(|message| anyhow::anyhow!(message))?; @@ -1334,17 +1560,22 @@ impl UiBridge { .current != input.window { + // current 决定 Session 的隐式字段访问;不匹配就拒绝,避免把另一窗口输入应用到当前页。 bail!("event belongs to another window"); } // 窗口关闭通过名义 close 方法,保留源覆盖语义;直接调用宿主原生退役只用于故障清理路径。 if input.kind == "window-close" { + // 复制 desktop 身份后立即释放锁,下面的 close 源覆盖可以安全重入其他 UI 方法。 let desktop = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))? .desktop; + // Window 是源代码可覆盖的资源对象;不是 winit 的 OS Window。 let window = object(Some(&vm.get_attr(desktop, "window")?))?; + // 绑定产生临时拥有值;先保存调用结果,再清理该方法值,保证报错也不跳过释放。 let bound = vm.bind_attr(window, "close")?; + // 保存 Result 以便绑定值始终释放;result? 放在释放之后,避免错误路径留下根。 let result = match &bound { Value::Object(function) => vm.call_with_limits( *function, @@ -1362,36 +1593,44 @@ impl UiBridge { } // 内置工具打开走同一 VM 的 developerTools 能力,返回值只作临时根使用,不跨到 GUI。 if let Some(name) = input.kind.strip_prefix("developer-open:") { + // 只传工具名字,页面工厂和工具状态仍在所属 VM 中创建,GUI 不接收托管返回对象。 let function = object(Some(&vm.get_attr(self.ui, "developerTools")?))?; + // 工具工厂返回的页面已由托管展示关系持有,调用结束释放这里只为接收返回值而持有的根。 let page = vm.call(function, &[Value::Text(name.into())])?; vm.release_value(page)?; return Ok(()); } if matches!(input.kind.as_str(), "page-pan" | "page-zoom") { + // 整页相机操作使用 page_input 中的模式/坐标检查,不走普通 Block.click。 return self.dispatch_page_view(vm, input); } if matches!(input.kind.as_str(), "viewport-pan" | "viewport-zoom") { + // 局部 CanvasViewport 的相机不同于整页相机,需要按命中出现位置转换增量。 return self.dispatch_viewport_view(vm, input); } if matches!( input.kind.as_str(), "scene-pan" | "scene-orbit" | "scene-zoom" ) { + // 三维交互交给场景控制器,保留源更新方法及投影坐标语义。 return self.dispatch_scene_view(vm, input); } if matches!( input.kind.as_str(), "tab-activate" | "tab-close" | "tab-detach" | "tab-drop" ) { + // 标签路径的 input.mount 是 Tab ID,不是 Mount;必须在普通目标解析之前分流。 return self.dispatch_tab(vm, input); } // 普通指针输入取同一锁内的版本/目标/完整出现候选快照;随后不持 session 锁执行源 hitTest 或事件回调。 let (weak, desktop, candidates, viewport, surface) = { + // 一次锁内拍下同版本信息,随后 hitTest/回调在锁外运行,避免读到窗口选择的半次变化。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; if state.published_version != input.layout_version { + // 弱对象尚存活不代表几何仍适合旧坐标;版本不符时整次普通输入失效。 bail!("event targets an obsolete presentation"); } ( @@ -1407,11 +1646,15 @@ impl UiBridge { ) }; if input.kind == "scroll" { + // 升级弱 Mount 获得临时拥有根,保证检查/回调期间不会因源代码删关系而悬空。 let mount = vm .upgrade(weak)? .ok_or_else(|| anyhow::anyhow!("scroll Mount expired"))?; + // 暂存滚动处理错误,以便退出前无条件释放升级得到的 Mount 根。 let result = (|| { + // 读取这一具体出现位置的几何,不使用共享 Block 上的全局坐标。 let data = vm.payload::(mount, "Mount")?; + // scroll 必须有指针位置,才能验证是否仍处于裁剪与页面视口内。 let point = input .point .ok_or_else(|| anyhow::anyhow!("scroll has no pointer"))?; @@ -1425,13 +1668,17 @@ impl UiBridge { { bail!("scroll targets an obsolete or clipped Mount"); } + // 屏幕增量还需转换成 Mount 所属空间,不能直接当作内容滚动的逻辑距离。 let delta = input .delta .ok_or_else(|| anyhow::anyhow!("scroll has no delta"))?; let Some(delta) = data.geometry.logical_delta(input.point, delta) else { + // 投影不可逆/无法形成有效局部增量时忽略此轮,不伪造一个零或屏幕空间增量。 return Ok(()); }; + // 通过托管方法保留滚动协议,调用预算单独有界;方法根与结果根随后各自释放。 let method = vm.bind_attr(mount, "scrollBy")?; + // 数量/类型之外的滚动规则交给该方法;临时绑定值在 result 成功/失败两种情况下都释放。 let result = match method { Value::Object(function) => vm.call_with_limits( function, @@ -1450,6 +1697,7 @@ impl UiBridge { vm.release_value(result?)?; Ok(()) })(); + // 滚动失败也清理临时根;只有释放后才把保存的 result 交回运行时。 vm.release(mount)?; return result; } @@ -1459,24 +1707,30 @@ impl UiBridge { .iter() .any(|point| !surface.contains(*point, viewport)) { + // 按下或释放任一点已离开可见页,不能仅凭旧 Mount 身份触发点击。 return Ok(()); } let Some(mount) = crate::source_hit::resolve(vm, desktop, candidates, press, release)? else { + // resolve 会运行真实源 hitTest;没有共同有效命中目标是正常无事件结果。 return Ok(()); }; mount } else { + // 无坐标输入已有捕获目标,升级弱身份后仍要在下面检查 mounted/interactive。 vm.upgrade(weak)? .ok_or_else(|| anyhow::anyhow!("Mount has expired"))? }; + // 同一所有权模式覆盖三维拾取、指针和普通事件:保存任何错误,最后统一 release(mount)。 let result = (|| { + // 复制 payload 结束 VM 只读借用,后续分配事件或执行用户方法需要可变借用 VM。 let data = vm.payload::(mount, "Mount")?.clone(); // 3D 视口点击先生成 RayHit3D 并附到 Event.hit,再沿视口出现传播,不能只按二维外框假定击中某网格。 if data.geometry.scene3d_viewport && input.kind == "click" && let Some(point) = input.point { + // 用原生 callable 承接 3D 拾取与事件传播;captures 显式保活 Mount 与 desktop。 let function = vm.allocate(ContextNativeFunction::new( "3D pointer click", crate::scene3d_model::PointerPick { @@ -1486,6 +1740,7 @@ impl UiBridge { }, vec![mount, desktop], ))?; + // 先暂存结果,释放临时 callable 根后再传播错误,保持失败路径也无额外根残留。 let result = vm.call_with_limits( function, &[], @@ -1499,15 +1754,19 @@ impl UiBridge { return Ok(()); } if !data.mounted { + // 对象可达但已经卸载,不能把旧事件传给仍被其他地方引用的业务 Block。 bail!("event targets an obsolete presentation"); } if !data.geometry.interactive { + // 几何可能是装饰或明确禁交互;不能因为外框命中就默认接受所有输入。 bail!("Mount does not accept input"); } if input.kind.starts_with("pointer") { + // 原始指针事件用于图形交互,保留具体 kind 与坐标,不转换为高层 click。 let p = input .point .ok_or_else(|| anyhow::anyhow!("pointer needs point"))?; + // PointerDispatch 在 VM 中执行并显式捕获所有托管依赖,不从 GUI 线程运行源处理器。 let f = vm.allocate(ContextNativeFunction::new( "native pointer event", crate::bridge::graphics::PointerDispatch { @@ -1519,6 +1778,7 @@ impl UiBridge { }, vec![data.block, mount, desktop], ))?; + // 一次有界的 VM 调用完成指针分派;先释放 callable 根,再传播执行错误。 let result = vm.call_with_limits( f, &[], @@ -1538,12 +1798,15 @@ impl UiBridge { Value::Number((data.geometry.id as i64).into()), )?; vm.set_attr(desktop, "focusRequested", Value::Bool(true))?; + // click 的 receiver 是 Block,Mount 作为出现上下文;focus 的 receiver 则直接是 Mount。 let (owner, name, args) = match input.kind.as_str() { "click" => (data.block, "click", vec![Value::Object(mount)]), "focus" => (mount, "requestFocus", vec![]), _ => bail!("unsupported native event"), }; + // 保留源方法覆盖;临时绑定值需要在调用成功与失败两种情况下都释放。 let bound = vm.bind_attr(owner, name)?; + // 临时绑定保持 receiver;错误同样先保存,释放绑定值后才传播。 let result = match &bound { Value::Object(function) => vm.call_with_limits( *function, @@ -1562,49 +1825,60 @@ impl UiBridge { vm.release(mount)?; result } - /// 整理不再有效的弱索引与展示根,并配合 VM 回收,避免派生缓存延长业务对象生命周期。 + /// endor_host 在执行/清理边界调用,压缩节点弱表和每窗快照缓存;不改写业务对象拥有关系。 pub fn reclaim(&self, vm: &mut Vm, _roots: &mut Vec) -> Result<()> { // 先回收托管环,再扫描弱节点表,只保留能升级的身份;render_cache.prune 同步清除死亡业务索引。 vm.collect_cycles(); + // VM 环回收结束后再压缩原生弱表,避免用回收前仍能升级的状态保留多余缓存索引。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 重建表只保留当前能升级的弱身份;不能把升级得到的临时强根永久留在宿主索引中。 let mut live = Vec::new(); for weak in &state.nodes { if let Some(object) = vm.upgrade(*weak)? { + // 升级是有拥有权的 VM 操作,探测结束立即释放,再保存原弱引用。 vm.release(object)?; live.push(*weak); } } state.nodes = live; for presentation in state.presentations.values_mut() { + // 同一业务节点可能缓存于多个窗口,逐窗剪枝,避免只清理 current 的派生状态。 presentation.render_cache.prune(vm.object_store()); } Ok(()) } - /// 丢弃未成功发布的展示请求,恢复后续输入可以使用的会话状态。 + /// endor_host 的输入/刷新失败路径调用,修复未发布 pending;不会回滚 REPL 中已成功完成的业务赋值。 pub fn discard_pending(&self, vm: &mut Vm) -> Result<()> { + // 修复宿主 pending/注册关系,不运行用户方法;因此持锁逐窗处理不会进入回调重入路径。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; for presentation in state.presentations.values_mut() { + // 真实 Window 注册表是归属依据,不能单靠失败前的 Presentation.active 恢复页面。 let window = object(Some(&vm.get_attr(presentation.desktop, "window")?))?; // A failed first render must not become visible on the next input // merely because show() already registered its Page with the Window. // 第一次 render 失败时撤销尚未发布的 Page 登记,避免下一次无关输入把失败页意外显示出来。 if presentation.active.is_none() && presentation.last_tabs.is_empty() { + // 从未发布过的窗口不保留失败 render 偶然注册的 Page;拷贝后逐项移除,避免迭代时改集合。 let unpublished = crate::window::pages(vm.object_store(), window)?.to_vec(); for page in unpublished { + // 使用宿主撤销入口,不再执行可能再次失败或产生副作用的源页面方法。 crate::window::unregister_page_host(vm, window, page)?; } } + // 撤销首次失败登记后重新读表;先前成功的 close/move 仍可能已改变现有窗口的拥有关系。 let pages = crate::window::pages(vm.object_store(), window)?; // 失败输入已成功执行的关闭/迁移语句不回滚;重新选择仍在真实窗口注册表中的页,修复 pending 与拥有关系。 let next = if presentation.closing { + // 正在关闭的资源不能为了恢复画面而重新挑一页继续展示。 None } else { + // 原 active 仍登记就保留,否则选择当前真实列表首项;空表自然得到 None。 presentation .active .filter(|page| pages.contains(page)) @@ -1614,8 +1888,10 @@ impl UiBridge { // Repair ownership changes before the next publication so a closed or // moved Page cannot survive in an old context's mount dictionary. presentation.pending = if next != presentation.active || presentation.closing { + // Some(None) 是明确卸载旧页,不等同于无刷新请求的 None。 Some(next) } else { + // 无需改变活动页时清掉失败请求,避免后续输入重复展示失败候选。 None }; vm.set_attr( @@ -1631,33 +1907,39 @@ impl UiBridge { .active .is_some_and(|page| vm.object(page).is_err()) { + // active 的托管对象已不存在时去除 Rust 借用句柄,不能让下一轮继续拿它提取快照。 presentation.active = None; } } Ok(()) } - /// 重新评估已失效的属性绑定,并把结果计入会话统计。 + /// endor_host 在刷新稳定循环中调用,重算失效绑定;回调可继续改变对象,随后 prepare_frame 才排空变化。 pub fn refresh_bindings(&self, vm: &mut Vm) -> Result<()> { // 只在锁内快照弱节点与宿主桌面集合,实际绑定回调在锁外执行,避免回调访问 UI 时死锁。 let (nodes, desktops) = { + // 锁内只复制弱索引与排除集合,锁外 binding::refresh 才真正执行源计算。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; (state.nodes.clone(), state.host_desktops()) }; + // binding::refresh 在所属 VM 中运行受控计算并返回实际评估次数,不把节点总数当作工作量。 let evaluated = crate::binding::refresh(vm, &nodes, &desktops)?; + // 计算回调已返回,重新短暂加锁累计次数,不把会话锁贯穿用户代码。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 诊断计数饱和增长,不让长期会话的统计溢出反过来打断有效程序执行。 state.binding_evaluations = state.binding_evaluations.saturating_add(evaluated); Ok(()) } - /// 排空一次 VM 变化记录并通知各窗口,提取活动页面快照,判断本次是否需要布局与发布。 + /// endor_host 每轮刷新调用,广播 VM 变化后为 current 决定无工作、空帧或带视觉根的新一轮布局。 pub fn prepare_frame(&self, vm: &mut Vm) -> Result>>> { // 一次排空变更必须广播所有 Presentation,不能让先刷新的窗口吃掉其他窗口共享业务数据的失效通知。 let changes = vm.take_changes(); + // 本锁内广播变化与提取只读快照,保证 current 根与各窗口的失效观察来自同一批更新。 let mut state = self .session .lock() @@ -1667,8 +1949,10 @@ impl UiBridge { // One VM change drain feeds every presentation before selecting a root. // Otherwise a window refreshed first could consume another window's work. for presentation in state.presentations.values_mut() { + // 每个窗口都必须先观察同一批变化,即使本轮最后只为 current 提取一个根。 presentation.observe_changes(vm, &changes); } + // 没页面得到 Ok(None);有页面但快照验证失败则传播 Err,不能伪装成正常空帧。 let root = active .map(|v| state.render_cache.snapshot(vm.object_store(), v)) .transpose()?; @@ -1678,11 +1962,14 @@ impl UiBridge { (None, None) => true, _ => false, }; + // 焦点请求是宿主控制状态,视觉树即使复用同一 Arc 也需要一次完整帧处理。 let focus_changed = matches!( vm.get_attr(state.desktop, "focusRequested"), Ok(Value::Bool(true)) ); + // 标签标题/顺序可独立于内容根变化,所以投影后与上次提交结果单独比较。 let tabs = project_tabs(vm, &mut state)?; + // 汇总显式展示请求、视觉树、焦点/滚动及强制失效;任一变化都不能用 Arc 相同快速跳过。 let changed = state.pending.is_some() || !same || focus_changed @@ -1693,7 +1980,9 @@ impl UiBridge { || state.force_frame || tabs != state.last_tabs; if changed { + // 只在确有新帧工作时记录快照诊断,避免空闲轮询覆盖最后一次有效失效原因。 if !same { + // 根身份变化才归因于 snapshot;仅标签/焦点变更不伪造快照失效记录。 state.last_invalid_dependency = format!("snapshot {}", state.render_cache.last_invalidation); } @@ -1703,29 +1992,36 @@ impl UiBridge { // 双层 Option 三态:外 None 无工作;Some(None) 需提交空帧;Some(Some(root)) 需布局/绘制根。 Ok(changed.then_some(root)) } - /// 提交已经完成的出现几何与快照,并处理展示生命周期;不把挂起或失败的布局当成已发布帧。 + /// 布局完成路径调用,提交桥接内部几何和生命周期,供后续 paint 使用;外部发布由 endor_host 稳定循环完成。 pub fn commit_frame( &self, vm: &mut Vm, snapshot: Option>, frame: &crate::document::Frame, ) -> Result<()> { + // 同步 Mount 和更新桥接状态时需要排他访问;调用用户 hooks 前显式 drop(state)。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // pending 的 Some(None) 要清空展示,不能简单 or(active) 又把旧页取回来。 let page = state.pending.unwrap_or(state.active); + // synchronize 必须使用本轮选中窗口的私有资源域,而不是 master 的全局注册表。 let desktop = state.desktop; + // 同步过程可能升级节点,用弱表快照避免同时借用 Session 的其他可变字段。 let nodes = state.nodes.clone(); // 提交几何同步清除临时 layout Context,并产生挂载/卸载序列;提交桥接状态不等于已写 GUI 共享文档。 let hooks = state .mounts .synchronize(vm, desktop, page, nodes, frame, false)?; + // 暂存提交/钩子错误,无论中途哪一步失败都经过下方 hooks 根释放。 let result = (|| { if snapshot.is_none() { + // 空帧不应残留旧出现几何,否则下一次输入可能命中已经退场的内容。 state.layout.clear_occurrences(); } if let Some(page) = state.pending.take() { + // 消费本轮请求;没有请求就沿用 active,不凭快照的 Option 擅自改业务页面身份。 state.active = page; } vm.set_attr(state.desktop, "pending", Value::Nil)?; @@ -1741,6 +2037,7 @@ impl UiBridge { // across user code. Publish only after the refresh loop settles. drop(state); if let Some(hooks) = hooks { + // 用户钩子可能再次修改页面,执行后 endor_host 仍会检查是否需要下一轮刷新。 let value = vm .call_with_limits( hooks, @@ -1756,12 +2053,13 @@ impl UiBridge { Ok(()) })(); if let Some(hooks) = hooks { + // 调用成功、VM 报错或提交字段写入失败都释放驱动拥有根,不能依赖 Rust Option 自动清理。 vm.release(hooks)?; } result } - /// 在已完成的几何上启动绘制协议,保留绘制任务所需的宿主上下文。 + /// endor_host 在 LayoutProgress::Complete 后调用,按帧的出现顺序启动第一片源 paint 工作。 pub fn begin_paint_frame( &self, vm: &mut Vm, @@ -1769,6 +2067,7 @@ impl UiBridge { frame: crate::document::Frame, ) -> Result { let (desktop, mounts) = { + // 取绘制顺序与弱身份只读 Session,source_paint::start 在锁外执行 VM 协议。 let state = self .session .lock() @@ -1778,6 +2077,7 @@ impl UiBridge { .mounts .iter() .map(|mount| { + // 以 frame.mounts 的顺序查活动弱身份;缺项代表布局/挂载不一致,必须报错而非漏画。 state .mounts .active @@ -1789,38 +2089,44 @@ impl UiBridge { .collect::>>()?; (state.desktop, mounts) }; + // 每片预算 256,由 source_paint 保留未完成续体;此处既可能返回 Draft,也可能返回 Work。 let progress = crate::source_paint::start(vm, desktop, mounts, root, frame, 256)?; self.accept_paint_progress(progress) } - /// 恢复同一次源绘制调用,把完成的 Draft 交回发布流程。 + /// endor_host 保存 PendingPaint 后调用,在同一 VM 上继续最多一片工作,不从头重放 paint 源方法。 pub fn resume_paint_frame( &self, vm: &mut Vm, work: crate::source_paint::Work, ) -> Result { + // Work 拥有前片续体和草稿,消费后返回新的挂起 Work 或最终完整 Draft。 let progress = work.resume(vm, 256)?; self.accept_paint_progress(progress) } - /// 区分挂起与完成的绘制进度;完成时保存真实读取依赖并接回发布流程。 + /// begin_paint_frame/resume_paint_frame 共用收尾,只有 Complete 才更新展示域的完整 paint 依赖。 fn accept_paint_progress( &self, progress: crate::source_paint::Progress, ) -> Result { if let crate::source_paint::Progress::Complete(draft) = &progress { + // 借用 Draft 而非消费,调用方仍需把它交回运行时进行验证和文档发布。 + // 只有完整 Draft 写入依赖,Pending 不改变上一轮已提交读取集合。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; // 完成后过滤所有窗口私有 desktop 读取,再拆对象版本与名称依赖;挂起切片不覆盖上一份完成的 paint 依赖。 let desktops = state.host_desktops(); + // 私有 desktop 是宿主记账读,排除后才得到源程序真正依赖的属性/名字集合。 let reads = draft .reads .iter() .filter(|read| !desktops.contains(&read.object())) .cloned() .collect(); + // 按对象版本读取与命名查找拆分,下一轮 observe_changes 使用各自的失效判断。 let (reads, names) = crate::presentation::split_reads(reads); state.paint_reads = reads; state.paint_names = names; @@ -1829,7 +2135,7 @@ impl UiBridge { Ok(progress) } - /// 取消尚未完成的源绘制,释放其续体和未发布上下文。 + /// endor_host 的 resize/输入替换/退役路径调用,取消所属 VM 的 paint 续体并强制该窗口重新刷新。 pub fn cancel_paint_frame( &self, vm: &mut Vm, @@ -1838,6 +2144,7 @@ impl UiBridge { ) -> Result<()> { // 先尝试取消 VM 续体,再尝试选原窗口并强制下一帧;前一操作失败也执行必要的宿主失效准备。 let cancelled = work.cancel(vm); + // 这一步即使 cancelled 是 Err 也先尝试;否则失败的取消会留下“旧帧看似仍有效”的缓存状态。 let invalidated = self.select_window(window).and_then(|()| { self.session .lock() @@ -1845,22 +2152,25 @@ impl UiBridge { .force_frame = true; Ok(()) }); + // 所有必要清理尝试完成后再按顺序传播错误,不让前一个 ? 跳过后一个失效操作。 cancelled?; invalidated } - /// 为目标快照建立 Mount、检查点和布局上下文,再启动可挂起的只读布局求解。 + /// endor_host 从 prepare_frame 得到新根后调用,建立出现结构和恢复检查点,再开始有预算的源布局。 pub fn begin_layout_frame( &self, vm: &mut Vm, root: Option>, ) -> Result { + // 一个 VM 同时只能推进原来的续体;不能在仍只读/挂起时再开另一帧破坏调用与恢复上下文。 anyhow::ensure!( !vm.has_suspended_call() && !vm.object_store().is_read_only(), "cannot prepare another frame during suspended layout" ); // 显式空页面无需源布局,清测量/布局读取并推进空帧版本,再同步退役旧 Mount;不是无工作返回。 let Some(root) = root else { + // 空帧已没有可复用的测量结果和源布局依赖,分三项清理,不能继续引用旧页读取集合。 self.session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))? @@ -1876,11 +2186,13 @@ impl UiBridge { .map_err(|_| anyhow::anyhow!("UI session poisoned"))? .layout_names .clear(); + // 空帧仍有窗口、标签和新版本:GUI 必须能识别旧出现位置已全部退场。 let frame = { let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 不复用旧版本,否则旧输入可能被当成对应这个空页面的有效事件。 state.layout.version = state .layout .version @@ -1893,9 +2205,11 @@ impl UiBridge { ..crate::document::Frame::default() } }; + // 走共同提交路径退役 Mount/运行卸载钩子,不能仅返回一个默认空 Frame 留下旧生命周期。 self.commit_frame(vm, None, &frame)?; return Ok(LayoutProgress::Complete(Box::new(frame))); }; + // 准备私有布局草稿与缓存副本,之后的源调用在锁外运行;正式状态等完成时再写回。 let (mut layout, cache, desktop, page, nodes) = { let state = self .session @@ -1909,6 +2223,7 @@ impl UiBridge { state.nodes.clone(), ) }; + // 标签投影会更新稳定 ID 映射,因此短暂可变借用 Session,而非沿用上面的只读锁。 let tabs = { let mut state = self .session @@ -1918,6 +2233,7 @@ impl UiBridge { }; // 记录真实原生页面视口供页面相机 clamp;兼容 Page.mode 只改变根约束,不永久篡改保存的窗口尺寸。 let full_viewport = layout.viewport; + // 根未声明特殊表面时采用默认页面模式;保存副本用于完成时添加范围和相机 clamp。 let surface = root.surface.clone().unwrap_or_default(); layout.page_mode = root.surface.as_ref().map(|surface| surface.mode); { @@ -1928,8 +2244,10 @@ impl UiBridge { // 按全会话高水位分配结构出现 ID,取消后也不回收已暴露过的数值身份。 layout.reserve_identity_floor(state.next_mount); } + // 先建立出现结构与 ID,尚未执行源 measure/arrange;结构供 Mount 准备与协议调用查上下文。 let mut frame = layout.structure(&root)?; { + // 本轮已暴露的 ID 即使后续取消也不能回收,所以马上推进全会话高水位,而非等最终提交。 let mut state = self .session .lock() @@ -1937,6 +2255,7 @@ impl UiBridge { state.next_mount = state.next_mount.max(layout.identity_high_watermark()); frame.window = state.current; } + // 同步准备阶段可能暂改 Mount;把可恢复检查点、延后钩子及有序弱身份一起交给后续工作。 let (hooks, mounts, checkpoint) = { let mut state = self .session @@ -1944,12 +2263,14 @@ impl UiBridge { .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; // 在准备态 synchronize 改写 Mount 前保留旧字典与 payload;准备失败立即恢复,不能留下半套父子关系。 let checkpoint = state.mounts.checkpoint(vm, desktop)?; + // true 表示准备模式:Mount 关系可供布局读取,但生命周期钩子延后,不在此执行用户代码。 let hooks = match state .mounts .synchronize(vm, desktop, page, nodes, &frame, true) { Ok(hooks) => hooks, Err(error) => { + // 即使结构准备失败也先恢复旧关系;恢复失败本身也必须作为错误报告。 checkpoint.restore(vm, desktop, &mut state.mounts)?; return Err(error.into()); } @@ -1967,27 +2288,30 @@ impl UiBridge { hooks, checkpoint, }; + // Draft 携带布局求解用的私有 runtime/cache;reads 从空开始收集本轮实际依赖。 let draft = crate::source_layout::Draft { frame, runtime: layout, cache, reads: Vec::new(), }; + // 暂存 Result 而不立即 ?,让 accept_layout_progress 在出错时仍能恢复 context 的检查点。 let progress = crate::source_layout::start(vm, desktop, mounts, root, draft, 256); self.accept_layout_progress(vm, context, progress) } - /// 记录本次刷新主动让出 worker 的次数,用于诊断分片执行。 + /// endor_host 在布局/绘制挂起让出 worker 时调用,累计诊断次数,不改变下一片执行预算。 pub fn note_refresh_yield(&self) -> Result<()> { let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 统计饱和而非溢出报错,避免长期运行时仅因诊断计数达到上限而中断。 state.refresh_yields = state.refresh_yields.saturating_add(1); Ok(()) } - /// 记录视口目标替换导致的刷新重启,便于区分正常挂起与取消重试。 + /// endor_host 重定向刷新时调用,分别记录重启以及本轮是否确实取消 layout/paint,供 stats 诊断。 pub fn note_refresh_restart( &self, cancelled_layout: bool, @@ -1997,6 +2321,7 @@ impl UiBridge { .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 一次重启不一定同时取消两个阶段;布尔转 usize 让各计数只增加实际发生的事件。 state.refresh_restarts = state.refresh_restarts.saturating_add(1); state.layout_cancels = state .layout_cancels @@ -2007,8 +2332,9 @@ impl UiBridge { Ok(()) } - /// 撤销未完成布局所保留的窗口、挂载与生命周期上下文。 + /// cancel_layout_frame/布局出错路径调用,恢复所属窗口的检查点并释放尚未执行的生命周期驱动。 fn discard_layout_context(&self, vm: &mut Vm, context: LayoutContext) -> Result<()> { + // 消费 context 后只提取恢复所需的资源,树/标签等纯 Rust 值自然释放,不执行用户回调。 let LayoutContext { surface: _, desktop, @@ -2020,21 +2346,26 @@ impl UiBridge { // 恢复必须在原窗口;若窗口或 session 锁已不可用,至少释放检查点拥有根,不能尝试恢复到另一个窗口。 let selected = self.select_window(window); let restored = if selected.is_ok() { + // 选窗成功也可能锁损坏,分别处理“可恢复”和“只能释放检查点根”两种情况。 match self.session.lock() { Ok(mut state) => { + // restore 还原准备阶段暂改的 Mount 字典/payload;随后强制下一帧重新建立几何。 let result = checkpoint.restore(vm, desktop, &mut state.mounts); state.force_frame = true; result.map_err(anyhow::Error::from) } + // 锁不可用时仍先释放 VM 拥有根,再报告会话损坏,不能泄漏也不能假装恢复成功。 Err(_) => checkpoint .release(vm) .map_err(anyhow::Error::from) .and(Err(anyhow::anyhow!("UI session poisoned"))), } } else { + // 原窗口已不可选择,不拿 current 的其他窗口去恢复旧检查点,仅清理拥有权。 checkpoint.release(vm).map_err(anyhow::Error::from) }; // 无论选择/恢复是否失败都先尝试释放延迟 hooks;保存结果后再按优先级传播错误。 + // 不运行待取消的钩子;Option 转置后保存错误,确保上面的恢复尝试已完成。 let released = hooks.map(|hooks| vm.release(hooks)).transpose(); selected?; restored?; @@ -2042,8 +2373,9 @@ impl UiBridge { Ok(()) } - /// 取消源布局续体并清理对应上下文,保持已发布帧可用。 + /// endor_host 取消/重定向刷新时调用,分别取消 owning VM 续体与恢复准备态 Mount,保留外部已发布帧。 pub fn cancel_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> { + // Work 和 Context 绑定为一对,不能只取消 VM 调用而丢下临时挂载关系。 let PendingLayout { work, context } = pending; // 取消只读执行与恢复 Mount 是两件事,均先尝试;没有继续执行原续体时也不能漏掉检查点恢复。 let cancelled = work.cancel(vm); @@ -2052,31 +2384,34 @@ impl UiBridge { restored } - /// 关闭失败布局的挂起工作并恢复检查点;供错误或候选撤销路径使用。 + /// endor_host 撤销失败候选布局时调用,与正常取消共用同一清理实现,不另起一轮布局回放源代码。 pub fn abort_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> { self.cancel_layout_frame(vm, pending) } - /// 继续同一次尚未发布的布局任务;完成时沿原上下文收尾,不能新建一轮来重放副作用。 + /// endor_host 持有 PendingLayout 后调用,在同一 VM、原窗口和原检查点上恢复最多一片布局工作。 pub fn resume_layout_frame( &self, vm: &mut Vm, pending: PendingLayout, ) -> Result { + // 解包只转移拥有权,仍将这一份 context 原样传到完成/错误收尾,不能重建新的检查点。 let PendingLayout { work, context } = pending; // 恢复时原窗口不存在则取消 owning VM 调用并清上下文,不重新建窗口或重放源布局。 if let Err(error) = self.select_window(context.window) { + // 先保存原选择错误,取消与恢复均尝试执行,最后按清理结果和原错误顺序返回。 let cancelled = work.cancel(vm); let restored = self.discard_layout_context(vm, context); cancelled?; restored?; return Err(error); } + // 不在这里用 ? 丢掉错误,否则失败续体配套的 context 没有机会恢复/释放。 let progress = work.resume(vm, 256); self.accept_layout_progress(vm, context, progress) } - /// 接收布局的 Pending/Complete 结果;保留上下文或执行最终投影和提交。 + /// begin_layout_frame/resume_layout_frame 共用入口,把源布局结果转成桥接进度,集中处理恢复和清理。 fn accept_layout_progress( &self, vm: &mut Vm, @@ -2095,19 +2430,21 @@ impl UiBridge { .finish_layout_frame(vm, context, draft) .map(|frame| LayoutProgress::Complete(Box::new(frame))), Err(error) => { + // 源布局失败不能发布 draft;先撤销准备态关系并释放检查点/钩子,再传播执行错误。 self.discard_layout_context(vm, context)?; Err(error) } } } - /// 依次投影滚动/视口、补页面元数据、保存读取版本、提交 Mount,并处理延后的生命周期调用。 + /// accept_layout_progress 收到 Complete 时调用,投影最终几何、保存依赖和提交 Mount,交回待绘制帧。 fn finish_layout_frame( &self, vm: &mut Vm, context: LayoutContext, draft: crate::source_layout::Draft, ) -> Result { + // 消费开始阶段保存的上下文,所有元数据都来自这一轮,不能临时从另一窗口重取。 let LayoutContext { mut surface, root, @@ -2118,12 +2455,14 @@ impl UiBridge { hooks, checkpoint, } = context; + // 消费完整求解结果;reads/cache 在生命周期回调前保存,才能发现钩子对布局依赖的新修改。 let crate::source_layout::Draft { mut frame, runtime: mut layout, mut cache, mut reads, } = draft; + // 把全部投影/提交错误保存在闭包结果中,外层始终尝试释放 hooks 和 checkpoint 的拥有根。 let result: Result = (|| { self.select_window(window)?; { @@ -2141,16 +2480,19 @@ impl UiBridge { frame.tabs = tabs; frame.window = window; if let Some(root) = frame.mounts.first() { + // 首个出现是根几何,作为页面内容范围;没有出现时保留已有表面范围。 surface.extent = crate::layout::Size { width: root.width, height: root.height, }; } if let Some(viewport) = full_viewport { + // 用真实整页视口约束相机,不能用为特殊 Page.mode 改写过的局部布局约束。 surface.clamp(viewport); } frame.surface = surface; // Capture versions before lifecycle hooks can change a dependency. + // 排除所有窗口的宿主私有记账读取;共享原生方法可能捕获主 desktop,不能只排除 current。 let desktops = self .session .lock() @@ -2162,7 +2504,9 @@ impl UiBridge { // 源布局实际业务读取版本在生命周期 hooks 前保存,hooks 改写依赖后下一轮才能识别失效并重算。 reads.retain(|read| !desktops.contains(&read.object())); cache.exclude_host_desktops(&desktops); + // 对象版本读取和命名查找分开存储,供 Presentation.observe_changes 判断不同类别失效。 let (reads, names) = crate::presentation::split_reads(reads); + // 焦点输入只接受能表示为非负 u64 的数字,nil/错误类型自然表示没有焦点。 frame.focus = match vm.get_attr(desktop, "focus") { Ok(Value::Number(id)) => id.as_i64().and_then(|n| u64::try_from(n).ok()), _ => None, @@ -2172,6 +2516,7 @@ impl UiBridge { .focus .filter(|id| frame.mounts.iter().any(|m| m.id == *id && m.interactive)); { + // 生命周期回调前先写回本轮计算结果;回调产生的新变化随后由运行时刷新循环再观察。 let mut state = self .session .lock() @@ -2185,6 +2530,7 @@ impl UiBridge { // 几何先提交到桥接状态,再执行准备阶段延后的挂载 hooks;其变化由全窗口刷新循环再收敛。 self.commit_frame(vm, Some(root), &frame)?; if let Some(hooks) = hooks { + // 准备阶段暂存的驱动现在才执行;无论返回值业务上是否使用都需释放其 VM 根。 let value = vm .call_with_limits( hooks, @@ -2200,15 +2546,18 @@ impl UiBridge { Ok(frame) })(); // 收尾计算无论成功/失败,都先释放 hooks 与检查点的拥有根;只有所有必要清理通过才返回帧。 + // 两项释放都先求值,不能在 hooks 释放处直接 ? 而跳过 checkpoint 的清理。 let released_hooks = hooks.map(|hooks| vm.release(hooks)).transpose(); + // 此阶段完成/报错均消费检查点根;这里是释放,不是把已提交几何回滚到准备前。 let released_checkpoint = checkpoint.release(vm); + // 所有清理尝试结束后才传播主结果与释放错误,不把失败帧交给绘制/发布调用者。 let frame = result?; released_hooks?; released_checkpoint?; Ok(frame) } - /// 读取当前源程序已经选择的展示内容,为编译入口生成最终文档提供根。 + /// endor_host::compile 的兼容一次性执行路径调用,读取源程序安排展示的 Page 并提取不可变视觉树。 pub fn rendered(&self, vm: &Vm) -> Result>> { let state = self .session @@ -2224,13 +2573,14 @@ impl UiBridge { } } -/// 从会话的类型注册表取得指定类型,再给原生分配的对象附加托管类型身份。 +/// calls 创建状态/协议结果时调用,从当前会话取 desktop,再给对象附上指定的名义类型。 fn attach_named_type( cx: &mut NativeContext<'_>, object: ObjectRef, name: &str, session: &Shared, ) -> Result<(), VmError> { + // 仅复制资源域身份,释放锁后才操作 VM;不会把 Session 锁延伸到类型附加路径。 let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? @@ -2238,18 +2588,19 @@ fn attach_named_type( attach_desktop_type(cx, object, name, desktop) } -/// 按给定 desktop 查找类型并附加,支持不同窗口使用自己的展示上下文。 +/// bridge/model/runtime 的原生分配器调用,给已分配 payload 附上同一 desktop 注册的语言类型。 pub fn attach_desktop_type( cx: &mut NativeContext<'_>, instance: ObjectRef, name: &str, desktop: ObjectRef, ) -> Result<(), VmError> { + // 缺少类型条目直接返回错误,不能自动创建同名 Type 让实例与已有契约不兼容。 let definition = desktop_type(cx.store(), name, desktop)?; cx.attach_type(instance, definition) } -/// 读取 desktop 的托管类型注册表;缺失或类型错误时返回 VM 错误。 +/// 签名构造、能力检查和各分配器调用,查询 bootstrap 注册的精确 Type 身份,不按名字新建类型。 pub(crate) fn desktop_type( store: &endor_core::ObjectStore, name: &str, @@ -2260,10 +2611,12 @@ pub(crate) fn desktop_type( object(store.dict_get(types, &Value::Text(name.into()))?.as_ref()) } -/// 将当前托管或可变状态提取为后续阶段使用的派生表示。 +/// endor_host::compile 在 main 未通过页面 API 自行展示时调用,把 main 返回的 Block 提取成不可变根。 pub fn snapshot(vm: &Vm, value: &Value) -> Result> { + // 兼容 main 必须返回真实对象;标量值的 ui.open 回退规则不在这个直接快照接口中执行。 let Value::Object(root) = value else { bail!("main must return a Block"); }; + // block::snapshot 继续检查 payload/树语义;仅是 Value::Object 还不足以证明它可展示。 block::snapshot(vm.object_store(), *root).map_err(Into::into) } diff --git a/src/bridge/ui_bridge/bootstrap.rs b/src/bridge/ui_bridge/bootstrap.rs index bd4d557..7b11576 100644 --- a/src/bridge/ui_bridge/bootstrap.rs +++ b/src/bridge/ui_bridge/bootstrap.rs @@ -1,42 +1,63 @@ //! 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。 //! 先建立类型与桌面基础设施,再绑定构造器、视觉协议和开发工具工厂;返回的根负责保持这些对象存活。 //! -//! Allocate and connect the managed desktop, type tables and host globals. +//! 调用链:`endor_host::compile` / `ReplSession::new` 创建候选 VM, +//! 随后调用本模块的 `install`;运行时再通过 install_globals 注入源程序使用的 UI 值。 +//! 本文件只建立宿主环境。源程序稍后调用构造器或实例方法时,才会进入 `calls::Function::invoke`。 +//! 安装返回的 UiBridge 由运行时保存;传入的 roots 由候选运行时持有,并在失败或退役时统一释放。 use super::*; -/// 把本模块的类型、方法或能力注册到所属运行时上下文。 +/// 为一次候选程序/REPL 会话安装 Tessera 宿主环境,供运行时后续注入全局值和执行源程序。 +/// +/// 类型要先创建,签名才能引用它们;因此先分配没有签名的函数,再原地补齐 payload。 +/// 所有 `?` 都把安装失败交还候选运行时,不会在这里把半成品替换成活跃程序。 +/// `roots` 保存明确的 VM 拥有根;Rust 的 ObjectRef 数值和 Arc> 均不能替代它们。 pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { // 先建立 desktop/ui/tabs 并加入显式 roots,后面初始化失败时由上层运行时统一释放,避免依赖 Rust 句柄保活。 + // desktop 是此桥接域的类型注册表和窗口资源入口;它不是操作系统桌面或原生窗口。 let desktop = vm.new_object()?; roots.push(desktop); + // 安装时尚无窗口尺寸;运行时收到 viewport 后再填充,避免在 VM 引导期间依赖 GUI 状态。 vm.set_attr(desktop, "viewport", Value::Nil)?; + // tabs 是主窗口的托管页面注册表,Presentation 和 window::install 会使用同一字典身份。 let tabs = vm.new_dict()?; roots.push(tabs); vm.set_attr(desktop, "tabs", Value::Object(tabs))?; + // ui 是对源代码公开的命名空间;desktop 保存内部记账,二者用途不能混同。 let ui = vm.new_object()?; roots.push(ui); // 原生 Session 借用托管身份,主窗口先拥有独立 Presentation;业务对象仍全部位于传入的单一 VM。 let session = Arc::new(Mutex::new(Session { + // 节点弱索引用来检查数量与回收失效记录,不应让已关闭的业务对象继续存活。 nodes: Vec::new(), + // 先登记主窗口上下文;后续窗口通过 create_window 建立各自的 Presentation。 presentations: std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::presentation::Presentation::new(desktop, tabs), )]), + // Session 的 Deref 按 current 选择布局/挂载状态;初始所有操作都落在主窗口。 current: crate::window::MAIN_WINDOW, + // master 始终指向主 desktop,副窗口仍借用同一 VM 的类型和资源域。 master: desktop, + // 分配器从现有最高窗口 ID 继续增长;Mount 则尚无任何已分配出现位置。 next_window: crate::window::MAIN_WINDOW, next_mount: 0, + // 关闭后尚未交给 GUI 的原生命令暂存在这里,不能随 Presentation 删除而丢失。 retired_commands: Vec::new(), + // 累计诊断计数由刷新流程更新,供 ui.stats/开发工具观察本会话的实际工作量。 refresh_yields: 0, refresh_restarts: 0, layout_cancels: 0, paint_cancels: 0, paint_slices: 0, binding_evaluations: 0, + // 清理阶段会置位,create_window 等操作据此拒绝在退出期间重新创建资源。 exiting: false, })); // 先分配函数占位再补签名,因为签名会引用尚未建立的名义类型;保存列表用于第二阶段替换 payload。 + // 保存 ui.name 的函数身份及 Operation,等全部类型注册后再为这些函数补齐精确签名。 let mut public_functions = Vec::new(); + // 另外记录 TypeConstructor 所用的工厂;它们多接收一个 Type 首参,不能套用自由函数签名。 let mut factories = Vec::new(); for (name, operation) in [ ("implement", Operation::Implement), @@ -63,6 +84,8 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("viewport", Operation::Viewport), ("window", Operation::Window), ] { + // 每个名字得到自己的 VM callable,实际操作由 Function 根据 operation 分派。 + // desktop 放在 captures 中,确保函数仍可达时它所依赖的类型注册表也能被 GC 追踪。 let function = vm.allocate(ContextNativeFunction::new( name, Function { @@ -75,6 +98,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { vec![desktop], ))?; roots.push(function); + // 属性供源程序的 ui.text/ui.present 等查询;列表供本函数第二阶段补签名,职责不同。 vm.set_attr(ui, name, Value::Object(function))?; public_functions.push((name, function, operation)); } @@ -83,6 +107,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { roots.extend([root_types.object, root_types.type_type]); vm.set_attr(ui, "Object", Value::Object(root_types.object))?; vm.set_attr(ui, "Type", Value::Object(root_types.type_type))?; + // definitions 汇集全部类型,稍后写入 desktop.types,供 desktop_type 按名称查精确身份。 let mut definitions = vec![ ("Object", root_types.object), ("Type", root_types.type_type), @@ -127,10 +152,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("Task", "Object", None), ("ObjectList", "Object", Some("object-list")), ] { + // 依照表中基类名取得当前 VM 的真实类型,不能用另一个 VM 中的同名 Type 代替。 let base = object(Some(&vm.get_attr(ui, base_name)?))?; + // 根据构造规则选择专用工厂、通用工厂或抽象类型;这里只注册,不分配业务实例。 let constructor = match constructor_name { // ObjectList 与 Event 有特殊的“类型首参+子类字段”构造规则,单独原生适配;其他类型共用 NativeFactory。 Some("object-list") => { + // ObjectList 构造要保持托管元素身份与字段规则,由 object_list::Constructor 处理。 let factory = vm.allocate(ContextNativeFunction::new( "object-list", crate::object_list::Constructor { @@ -140,17 +168,20 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { vec![desktop], ))?; roots.push(factory); + // 工厂本身也是语言中的 Function 实例,挂类型后才能通过名义函数检查。 let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; vm.attach_type(factory, function_type)?; TypeConstructor::NativeTyped(factory) } Some("event") => { + // Event 另有必填事件名;其专用 Constructor 在真正调用时解析事件参数和子类字段。 let factory = vm.allocate(ContextNativeFunction::new( "event", crate::event_dispatch::Constructor { signature: None }, vec![], ))?; roots.push(factory); + // 与列表工厂保持相同函数类型体系;无 desktop 捕获的分配器不额外引入桌面强边。 let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; vm.attach_type(factory, function_type)?; TypeConstructor::NativeTyped(factory) @@ -176,6 +207,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { .map(Operation::SceneObject) .ok_or(invalid("unknown native type allocator"))?, }; + // 通用 NativeFactory 在调用时区分原生参数与源子类字段,并保留 requested Type。 let factory = vm.allocate(ContextNativeFunction::new( constructor_name, NativeFactory { @@ -187,10 +219,12 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { vec![desktop], ))?; roots.push(factory); + // 这个函数属于构造器元数据,不直接放在 ui.name;但仍需标为 Function 实例。 let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; vm.attach_type(factory, function_type)?; TypeConstructor::NativeTyped(factory) } + // 没有公开分配规则的类型仍可用于类型检查/协议;直接把它当构造器调用会由 VM 拒绝。 None => TypeConstructor::Abstract, }; // 记住实际工厂身份,类型构建完成后原地补签名,避免替换类型内指向的构造函数对象。 @@ -198,6 +232,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { { factories.push((tag, factory)); } + // 生成新 Type 后同时公开到 ui,并加入待提交注册表,两个入口必须指向同一对象。 let definition = vm.new_type(name, base, constructor)?; roots.push(definition); vm.set_attr(ui, name, Value::Object(definition))?; @@ -217,6 +252,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("PaintCommand", vec![]), ("CameraGesture", vec![]), ] { + // 表里的字段名转成 VM 可检查的字段声明;map 的每次调用只描述一个字段,不执行布局。 let fields = fields .into_iter() .map(|name| endor_core::ClassField { @@ -225,11 +261,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { type_name: if matches!(name, "children" | "commands") { "Object" } else { + // 余下字段都是测量/几何数值,让 VM 在实例化时先拒绝非数字实参。 "Number" } .into(), }) .collect(); + // 这些协议值只携带源字段,使用普通 class 分配;不需要 Block 的原生 payload。 let definition = vm.new_class( name, root_types.object, @@ -243,14 +281,17 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { // The desktop owns the registry through ordinary traced attributes. The // Rust session only borrows handles and adds no hidden lifetime roots. // 桌面通过普通属性拥有类型字典;后续 nominal 查询必须使用同一 desktop 中注册的精确类型身份。 + // 类型字典由 desktop 属性托管持有;后续桥接调用可从其查找 Block、Event 等精确类型。 let types = vm.new_dict()?; roots.push(types); for (name, definition) in definitions { + // 字符串键是宿主查询约定,值才是参与 is_instance/is_subtype 的真实名义身份。 vm.dict_set(types, Value::Text(name.into()), Value::Object(definition))?; } vm.set_attr(desktop, "types", Value::Object(types))?; vm.attach_type(desktop, root_types.object)?; vm.attach_type(ui, root_types.object)?; + // 安装后续编译函数的默认类型,也用于下方所有方法 wrapper 的 attach_type。 let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; // 让后续 VM 创建的函数使用宿主 Function 名义类型,并打开源类型检查,再安装完整本机签名。 vm.set_function_type(function_type)?; @@ -273,6 +314,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { )?; } for (name, function) in factories { + // 恢复第一阶段记录的分配器类别;Event/ObjectList 不走 NativeFactory,需要单独补外壳。 let operation = match name { "text" => Some(Operation::Text), "rect" => Some(Operation::Rect), @@ -288,6 +330,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "button" => Some(Operation::Button), "page" => Some(Operation::Page), "window-create" => Some(Operation::NewWindow), + // None 在本轮只表示专用构造器,不表示前一阶段的 Abstract 类型。 "event" | "object-list" => None, name => Some( crate::scene3d_model::Constructor::from_function(name) @@ -296,6 +339,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ), }; if let Some(operation) = operation { + // options 只描述原生构造选项,调用时由 NativeFactory 在切掉 Type 和子类字段后校验。 let options = operation_signature(vm.object_store(), desktop, operation)?; // 外层 NativeTyped 工厂接收 Type 首参和任意候选尾参,允许子类字段;NativeFactory 用 options 再核对基构造参数。 let envelope = CallSignature { @@ -305,6 +349,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { returns: options.returns.clone(), effect: options.effect, }; + // 初始强边是注册表;随后追加签名引用的类型,供 ContextNativeFunction::trace 使用。 let mut captures = vec![desktop]; // 参数和返回注解中的名义类型也是托管引用,必须纳入 captures,不能只把它们存进 Rust 签名结构。 captures.extend(options.references()); @@ -323,16 +368,20 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { )?; } else { // 特殊构造器保留宽变参外壳,再由对应 Constructor 按类型字段数拆实参,返回准确 Event/ObjectList 名义类型。 + // 为返回值选取专用名义类型,避免宽变参外壳同时放宽了返回值检查。 let kind = if name == "event" { "Event" } else { "ObjectList" }; + // Event 的 Type 之后还有必填字符串事件名;ObjectList 只有 Type 为必填,其余由实现解释。 let signature = CallSignature { arity: endor_core::CallArity::range( + // 这里包含 Type 首参;源子类字段由构造器验证,所以最大个数保留开放。 if name == "event" { 2 } else { 1 }, usize::MAX, ), + // 固定前缀与 required 数量保持一致,额外实参由 variadic 和字段验证共同约束。 parameters: if name == "event" { vec![AnnotationType::Type.into(), AnnotationType::String.into()] } else { @@ -344,6 +393,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { effect: CallEffect::ReadOnly, }; if name == "event" { + // 原地更新 Event 工厂,TypeConstructor 中已保存的对象引用继续有效。 vm.replace_payload( function, ContextNativeFunction::new( @@ -355,6 +405,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ), )?; } else { + // 这里只有 ObjectList;保留 desktop 捕获,以便专用分配器给结果附加列表类型。 vm.replace_payload( function, ContextNativeFunction::new( @@ -369,6 +420,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { } } } + // 对这批公开入口补 Function 类型身份;不重新分配 callable,ui 属性仍指向原对象。 for name in [ "open", "text", @@ -390,6 +442,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "implements", "supports", ] { + // 从 ui 取回前面已补完签名的函数;名称表与安装表不一致会在这里报错而非静默漏装。 let function = object(Some(&vm.get_attr(ui, name)?))?; vm.attach_type(function, function_type)?; } @@ -437,7 +490,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ("Page", "back", Operation::Back), ("Page", "forward", Operation::Forward), ] { + // expected 同时用于方法所属类型、receiver 校验和签名中的名义参数身份。 let expected = object(Some(&vm.get_attr(ui, type_name)?))?; + // VM 的方法分派负责绑定 receiver;TypeMethod 再验证它并转交 Function 执行业务操作。 let method = vm.allocate(ContextNativeFunction::new( name, TypeMethod { @@ -451,20 +506,26 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { ))?; roots.push(method); vm.attach_type(method, function_type)?; + // 写到类型方法表而非每个实例的属性,实例与源子类可共享同一个原生实现。 vm.define_method(expected, name, method)?; } // 类型与基础方法就绪后再装窗口、默认视觉、事件方法和能力契约,顺序满足各模块查类型/方法的前提。 crate::window::install(vm, roots, desktop, tabs)?; + // 默认 measure/arrange/paint 安装到 Block 体系;源代码之后可按协议覆盖它们。 let block_type = object(Some(&vm.get_attr(ui, "Block")?))?; crate::native_visual::install(vm, roots, desktop, block_type, function_type)?; + // 事件方法需要识别 Event 实例;订阅返回值则使用 Subscription 身份管理取消连接。 let event_type = object(Some(&vm.get_attr(ui, "Event")?))?; + // token_type 传给事件安装器,不能拿 Event 类型代替,否则取消令牌会违反返回契约。 let token_type = object(Some(&vm.get_attr(ui, "Subscription")?))?; crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?; crate::protocols::install(vm, roots, desktop, ui)?; // 每个桥接域配套自己的任务 Scheduler,再接工具入口及托管起始页工厂,最后返回可使用的 UiBridge。 + // Scheduler 随 UiBridge 交还运行时;它还未开始执行用户任务,只完成 ui 异步 API 注册。 let tasks = crate::tasks::install(vm, roots, ui, desktop)?; crate::bridge::graphics::install(vm, roots, ui, desktop)?; developer_pages::install(vm, ui, &session)?; + // 将语言命名空间、展示会话与任务调度打包,运行时随后通过同一 bridge 推进刷新与回调。 let bridge = UiBridge { ui, session, tasks }; developer_pages::install_start_page(vm, &bridge)?; Ok(bridge) diff --git a/src/bridge/ui_bridge/calls.rs b/src/bridge/ui_bridge/calls.rs index 59ad18c..15c80a1 100644 --- a/src/bridge/ui_bridge/calls.rs +++ b/src/bridge/ui_bridge/calls.rs @@ -1,46 +1,57 @@ //! 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。 //! 先检查参数和只读阶段限制,再修改托管状态;对象引用的生命周期仍交给 NativeContext 和 Aster 管理。 //! -//! Native Block operations executed on the owning VM worker. +//! 调用来源:VM 直接执行 bootstrap 注册的 ui 自由函数,或由 TypeMethod/NativeFactory 剥离接收者/Type 后转交。 +//! Function.owner 保存绑定对象,requested_type 保存原生子类构造请求;本文件 args 只包含显式原生参数。 +//! 简单分支同步返回 NativeResult::Return;需要执行源协议/事件处理器的分支返回 Invoke,让 VM 接着推进调用栈。 +//! NativeContext 追踪本次分配和 upgrade 的临时根,返回/委托时提升输出引用,其余在域退出时释放。 use super::*; impl NativeCallable for Function { - /// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。 + /// VM 在调用边界查询 bootstrap/bind 写入的签名;由外层已校验的临时转交 Function 可返回 None。 fn signature(&self) -> Option { self.signature.clone() } - /// 接收已经剥离 receiver 的显式参数,数量沿用 Operation 的范围。 + /// VM 查询自由函数的实参数量,或供转交适配器使用;这里不再给 receiver/Type 首参加一。 fn arity(&self) -> Option { Some(self.operation.arity()) } - /// 统一校验数量与只读限制,再按 Operation 分派构造、树修改、页面、绑定、状态与能力调用,返回托管结果或 VM 续调用。 + /// VM 或外层原生调用适配器进入的实际业务分派,返回借用结果或委托;不负责 GUI 帧的原子发布。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { use Operation::*; + // 外层 TypeMethod/NativeFactory 会直接调用本方法,因此这里仍需防御性检查参数数量。 let arity = self.operation.arity(); + // 显式区间不包含已剥离的 receiver/Type,后面 args[0] 等索引依赖这次验证。 let (minimum, maximum) = (arity.required, arity.maximum); // Function 接收已剥离 receiver 的显式参数;先校验数量,后面索引 args 才安全。 if !(minimum..=maximum).contains(&args.len()) { + // 在任何分配或模型修改之前拒绝数量不符,避免越界或带半次副作用的失败。 return Err(invalid("incorrect UI method argument count")); } if matches!(self.operation, Camera2D) { + // 独立相机不是 Block,不进入下方的视觉 Data.kind 构造;requested_type 保留源子类。 let camera = new_camera2d(cx, &self.session, self.requested_type, args)?; return Ok(NativeResult::Return(Value::Object(camera))); } // 3D 构造与普通 Block 分开,requested_type 保留源子类名义身份,否则从本 desktop 取默认类型。 if let SceneObject(kind) = self.operation { + // 只复制当前类型域身份,放锁后进入三维分配器;该分配器校验 kind 的实参与子类约束。 let desktop = self .session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; + // 类型构造保留请求身份,自由工厂选择当前 desktop 中 kind.name 对应的默认类型。 let definition = self.requested_type .unwrap_or(desktop_type(cx.store(), kind.name(), desktop)?); + // model::scene3d 建立托管几何/场景对象,此刻不投影、不光栅化也不建立 OS 资源。 let object = crate::scene3d_model::allocate(cx, desktop, definition, kind, args)?; return Ok(NativeResult::Return(Value::Object(object))); } if matches!(self.operation, CameraControl) { + // receiver 是控制器,两个显式参数是相机与手势;实际状态更新由 scene3d_model::control 解释。 crate::scene3d_model::control( cx, self.owner @@ -51,6 +62,7 @@ impl NativeCallable for Function { return Ok(NativeResult::Return(Value::Nil)); } if matches!(self.operation, Translate3D | Rotate3D) { + // 三分量实参共用转换入口,末尾布尔值区分旋转/位移;对象缺少 owner 时直接报错。 crate::scene3d_model::transform( cx, self.owner @@ -62,9 +74,12 @@ impl NativeCallable for Function { } // implement 修改显式能力关系;implements 查类型,supports 查对象实际能力,不靠同名字段鸭子类型推断。 if matches!(self.operation, Implement | Implements | Supports) { + // 第二实参必须是托管 Trait,注册和查询共用身份,但标量 supports 仍需先验证 Trait 本身。 let capability = object(args.get(1))?; + // 此分支计算语言返回值,随后统一包 Return;只有 Implement 真正修改类型关系。 let value = match self.operation { Implement => { + // 第一参数是 Type,VM 的 implement_trait 检查能力契约并建立托管引用关系。 cx.implement_trait(object(args.first())?, capability)?; Value::Nil } @@ -73,24 +88,30 @@ impl NativeCallable for Function { .implements_trait(object(args.first())?, capability)?, ), Supports => { - // Validate the contract even for immediate scalar values. + // 即使第一参数是标量也先验证第二参数真为 Trait;不能用 false 掩盖错误能力对象。 cx.store() .payload::(capability, "Trait")?; Value::Bool(match args[0] { + // 对象按实际能力契约查询,标量没有这里的宿主对象能力而返回 false。 Value::Object(value) => cx.store().supports(value, capability)?, _ => false, }) } + // 外层 matches 已限定三种操作,其他枚举值到这里表示分派实现内部不一致。 _ => unreachable!(), }; return Ok(NativeResult::Return(value)); } if matches!(self.operation, IsInstance | IsSubtype) { + // 当前实现要求两者都解码成对象:签名中的 Any 不等于可在这里传 nil 后获得 false。 let candidate = object(args.first())?; + // expected 必须属于此 VM 的真实类型体系;关系查询不按类型名字字符串比较。 let expected = object(args.get(1))?; + // 实例关系检查对象的类型,子类型关系检查两个 Type 的继承关系,不能混为同一查询。 let value = if matches!(self.operation, IsInstance) { cx.store().is_instance(candidate, expected)? } else { + // isSubtype 用于类型间关系,不把 candidate 当实例读取其 type_of。 cx.store().is_subtype(candidate, expected)? }; return Ok(NativeResult::Return(Value::Bool(value))); @@ -98,48 +119,61 @@ impl NativeCallable for Function { // 显式失效属于模型变更,只读阶段拒绝;paint/layout/content 精度不同,后续分别清理对应缓存。 if matches!(self.operation, Invalidate) { if cx.store().is_read_only() { + // measure/paint 等只读域不能靠显式失效反复制造下一轮更新反馈。 return Err(invalid( "invalidation is forbidden during read-only rendering", )); } + // 仅省略参数才默认 layout,错误类型不回退;transpose 保留显式解析失败。 let reason = args .first() .map(|value| text(Some(value))) .transpose()? .unwrap_or_else(|| "layout".into()); if !matches!(reason.as_str(), "paint" | "layout" | "content") { + // 精度标签必须已知,否则不能可靠决定清哪些缓存和依赖集合。 return Err(invalid( "invalidation reason must be paint, layout or content", )); } + // TypeMethod 已把 receiver 移入 owner,没有绑定对象的自由调用不能执行此方法。 let owner = self.owner.ok_or(invalid("invalidation requires a Block"))?; + // 以解析后的共享 Data 身份归并别名,不能只让调用者这一个包装对象失效。 let (data, _) = block::resolve(cx.store(), owner)?; + // 锁内只遍历弱节点并更新派生失效状态,不在这里调用用户布局/绘制方法。 let mut session = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; + // 去重后的 Block 身份集合供每个窗口判断是否包含同一份受影响的共享业务状态。 let mut affected = std::collections::HashSet::new(); // 找出解析到同一底层 Data 的所有别名,失效共享数据时不能只刷新调用者包装身份。 for weak in &session.nodes { if let Some(block) = cx.upgrade(*weak)? && block::resolve(cx.store(), block)?.0 == data { + // NativeContext 持有升级临时根直到本次调用结束,不为弱索引永久添加拥有关系。 affected.insert(block); } } for presentation in session.presentations.values_mut() { + // 同一 Data 可以出现在多个窗口,失效通知必须广播,不能仅清 current 的缓存。 presentation.invalidate(cx, &affected, &reason)?; } return Ok(NativeResult::Return(Value::Nil)); } if matches!(self.operation, Mounts) { + // 返回的是这个 Block 的具体展示出现,不能把底层 Data 相同的其他别名也算到 owner 上。 let owner = self.owner.ok_or(invalid("mount query requires a Block"))?; + // 查全部窗口,锁内只读取原生 Mount 表与托管 payload,不执行源回调。 let session = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; + // 暂留窗口/出现 ID 作为排序键,最后列表只暴露 Mount 对象,不暴露宿主元组。 let mut positions = Vec::new(); for (window, presentation) in &session.presentations { + // 每窗活动表只有当前仍展示的出现,死亡弱项和其他 Block 的出现都不加入结果。 for (id, weak) in &presentation.mounts.active { if let Some(object) = cx.upgrade(*weak)? && cx @@ -154,6 +188,7 @@ impl NativeCallable for Function { } // 跨窗口 Mount 查询按窗口/出现 ID 稳定排序,不依赖 HashMap 遍历顺序;返回每个具体出现而非几何副本。 positions.sort_unstable_by_key(|(window, id, _)| (*window, *id)); + // 用专用托管集合保留结果引用和只读访问契约,不把 Rust Vec 直接当源值返回。 let set = crate::mount::collection( cx, positions.into_iter().map(|(_, _, object)| object).collect(), @@ -162,25 +197,31 @@ impl NativeCallable for Function { return Ok(NativeResult::Return(Value::Object(set))); } if matches!(self.operation, NewWindow) { + // 持锁分配新的窗口 ID 并建立 Presentation,create_window 还会检查只读/退出/数量限制。 let mut state = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; + // Window 只能通过 Type 构造入口创建,缺少 requested_type 说明不是合法的类型调用。 let definition = self .requested_type .ok_or(invalid("Window constructor requires a type"))?; + // 返回托管资源对象,原生窗口由随后发布的帧/命令驱动 GUI 创建。 let window = create_window(cx, &mut state, definition)?; return Ok(NativeResult::Return(Value::Object(window))); } if matches!(self.operation, CloseWindow) { + // 资源对象来自 bound receiver,不用 current 猜要关闭哪个窗口。 let window = self .owner .ok_or(invalid("window close requires a receiver"))?; + // 用 payload 的资源 ID 定位 Presentation;关闭状态由 window::close 在模型中维护。 let id = cx .store() .payload::(window, "Window")? .id; crate::window::close(cx, window)?; + // model 关闭成功后才改宿主展示状态,关闭失败不能提前标记为已退场。 let mut state = self .session .lock() @@ -195,6 +236,7 @@ impl NativeCallable for Function { return Ok(NativeResult::Return(Value::Nil)); } if matches!(self.operation, Window) { + // ui.window() 查询当前选择窗口的资源对象;和 Window() 分配新窗口的路径不同。 let desktop = self .session .lock() @@ -205,15 +247,19 @@ impl NativeCallable for Function { )); } if matches!(self.operation, Viewport) { + // ui.viewport() 从当前窗口的输入对象生成结果,不直接读取 winit/OS 尺寸。 let desktop = self .session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; + // 运行时还没提供尺寸时属性为 nil,明确报不可用,不把它伪装成零宽高。 let Value::Object(input) = cx.store().get_attr(desktop, "viewport")? else { return Err(invalid("window viewport is unavailable")); }; + // 两个普通属性读取参与 VM 依赖跟踪,尺寸变化可让调用方的测量失效。 let width = cx.store().get_attr(input, "width")?; + // 返回 MeasureResult 协议值而非把内部输入对象本身暴露给源程序修改。 let height = cx.store().get_attr(input, "height")?; return Ok(NativeResult::Return(Value::Object( crate::native_visual::record( @@ -227,15 +273,19 @@ impl NativeCallable for Function { // 诊断会读对象总数与宿主变化统计,禁止在只读渲染中观察并形成反馈依赖。 if matches!(self.operation, Stats) { if cx.store().is_read_only() { + // 即使签名标 External,这里仍防御直接适配器调用,防止布局自观察计数形成反馈。 return Err(invalid( "runtime statistics are unavailable during read-only rendering", )); } + // 各窗口共享的计数与当前展示派生计数一起投影,锁内不推进刷新。 let session = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; + // blocks/pages 数的是成功升级的存活业务对象,而不是弱表历史长度或 Mount 出现数。 let mut blocks = 0_i64; + // 页面是 Block 的子集,同一页跨窗口迁移不会增加业务页面计数。 let mut pages = 0_i64; for weak in &session.nodes { if let Some(object) = cx.upgrade(*weak)? { @@ -245,8 +295,11 @@ impl NativeCallable for Function { } } } + // 统计对象总数在分配 stats 结果之前采样,避免把本次诊断结果计入自己。 let objects = cx.store().object_count() as i64; + // session 的 Deref 指向 current,tabs/mounts 与布局计数属于当前窗口,而非跨窗求和。 let tabs = cx.store().dict_items(session.tabs)?.len() as i64; + // 新普通对象只装原生数值/文本,不把 VM store 或 Session 句柄暴露给程序。 let stats = cx.new_object()?; for (name, value) in [ ("objects", objects), @@ -273,8 +326,10 @@ impl NativeCallable for Function { session.dependency_invalidations as i64, ), ] { + // 统一转成语言整数,字段名字是 ui.stats 的观察接口,不影响调度逻辑。 cx.set_attr(stats, name, Value::Number(value.into()))?; } + // 单独保留文本失效原因,数值计数不能解释究竟哪条源属性触发了重算。 cx.set_attr( stats, "lastInvalidDependency", @@ -297,6 +352,7 @@ impl NativeCallable for Function { } // 状态是普通名义 Object,value 属性经 VM 追踪,set/increment 方法捕获同一对象,不额外建立原生状态副本。 if matches!(self.operation, State) { + // value 及绑定方法都是普通托管属性,GC 能追踪回调和状态之间的真实边。 let state = cx.new_object()?; attach_named_type(cx, state, "Object", &self.session)?; cx.set_attr(state, "value", args[0].clone())?; @@ -305,10 +361,13 @@ impl NativeCallable for Function { return Ok(NativeResult::Return(Value::Object(state))); } if matches!(self.operation, StateSet | Increment) { + // 状态对象由 bind 捕获,不能把 args[0] 当 receiver 再切一次参数。 let owner = self.owner.ok_or(invalid("state method has no owner"))?; + // set 接受任意新值;increment 则对当前 value 做精确整数读取与溢出检查。 let value = if matches!(self.operation, StateSet) { args[0].clone() } else { + // 先限制 Number 表示,再从中取可表示 i64 的整数;不截断十进制或二进制小数。 let Value::Number(value) = cx.store().get_attr(owner, "value")? else { return Err(invalid("increment requires an integer state")); }; @@ -319,22 +378,28 @@ impl NativeCallable for Function { .ok_or(invalid("integer state increment exceeds its range"))?; Value::Number(next.into()) }; + // 通过统一属性写入口更新版本,textOf/绑定的依赖失效因此能被下一轮刷新观察。 cx.set_attr(owner, "value", value)?; return Ok(NativeResult::Return(Value::Nil)); } if matches!(self.operation, TextOf) { + // 先保存被观察对象和属性名;这里不是把当前值立即格式化成一个永不更新的字符串。 let source = object(args.first())?; + // 属性名必须是真字符串,不能从任意对象 repr 推导不稳定的名字。 let field = text(args.get(1))?; // textOf 建立前确认字段可读取,具体字符串化与依赖记录发生在快照提取时。 cx.store().get_attr(source, &field)?; + // 字号可选但必须是等级整数,缺省为 1;颜色在后面按独立可选参数解析。 let size = args .get(2) .map(|v| integer(Some(v))) .transpose()? .unwrap_or(1); if !(1..=8).contains(&size) { + // 数字能转为整数不代表是受支持等级,在分配 TextBlock 之前拒绝越界。 return Err(invalid("text size must be 1 through 8")); } + // 初始文字为空,真正内容留给快照读取 source.field;构造不冻结当前属性值。 let mut data = Data::new( Kind::Text { value: String::new(), @@ -355,6 +420,7 @@ impl NativeCallable for Function { } // 普通组件先组装 Data,再统一由 new_typed_block 安装 Cell、属性和名义类型;不直接写原生窗口。 let constructor = match self.operation { + // 滚动视口有限宽高和唯一内容 Block;滚动轴/偏移由属性和 Mount 后续处理。 Scroll => Some(Data::new( Kind::Scroll { width: dimension(args.first())?, @@ -364,6 +430,7 @@ impl NativeCallable for Function { 0.0, 0.0, )), + // 文本是叶节点,size 的 u64→u32 转换和下面统一 1..=8 检查分别限制表示与语义。 Text => Some(Data::new( Kind::Text { value: text(args.first())?, @@ -380,6 +447,7 @@ impl NativeCallable for Function { 0.0, 0.0, )), + // 宽高必填,圆角缺省零,颜色缺省浅蓝;每个尺寸都先经有限非负检查。 Rect => Some(Data::new( Kind::Rect { width: dimension(args.first())?, @@ -395,18 +463,23 @@ impl NativeCallable for Function { 0.0, 0.0, )), + // 空白只提供主轴占位,不带孩子或绘图颜色。 Spacer => Some(Data::new( Kind::Spacer(dimension(args.first())?), vec![], 0.0, 0.0, )), + // 三种容器共用参数解码,Kind 决定后续横排/纵排/叠放,不在这里计算子项坐标。 Row | Column | Overlay => Some(Data::new( if matches!(self.operation, Row) { + // 行布局随后沿水平主轴累计孩子位置。 Kind::Row } else if matches!(self.operation, Overlay) { + // 叠放布局共享内容原点,不采用线性 gap 累加位置。 Kind::Overlay } else { + // 此入口仅剩 Column,后续沿纵向排列。 Kind::Column }, // 线性容器固定前两参为 padding/gap,后续都是孩子对象;组成合法性在 Block 分配前统一检查。 @@ -417,7 +490,9 @@ impl NativeCallable for Function { dimension(args.first())?, dimension(args.get(1))?, )), + // 空 Page 尚未登记到任何窗口,直到 render/open 才安排展示。 Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)), + // 二维场景只有孩子对象序列;各对象的 x/y 等世界属性在快照提取时读取。 Scene2D => Some(Data::new( Kind::Scene2D, args.iter() @@ -427,6 +502,7 @@ impl NativeCallable for Function { 0.0, )), CanvasViewport => { + // 视口自身参加有限布局,第三实参保存 Scene2D 引用,不把场景孩子展开成行列内容。 let mut data = Data::new( Kind::CanvasViewport { width: dimension(args.first())?, @@ -441,12 +517,15 @@ impl NativeCallable for Function { Some(data) } Viewport3D => { + // 当前 desktop 提供相机/控制器的名义类型,所有默认资源在同一 VM 内创建。 let desktop = self .session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; + // 默认相机使用透视投影;这里分配模型对象,不立即生成场景渲染快照。 let definition = desktop_type(cx.store(), "PerspectiveCamera", desktop)?; + // 空参数选择场景模块的默认姿态,之后源属性仍可显式换相机或改参数。 let camera = crate::scene3d_model::allocate( cx, desktop, @@ -454,6 +533,7 @@ impl NativeCallable for Function { crate::scene3d_model::Constructor::Perspective, &[], )?; + // 普通 children 先为空,世界场景独立存于 scene3d 字段;面板 UI 会在提取时派生。 let mut data = Data::new( Kind::Viewport3D { width: dimension(args.first())?, @@ -465,6 +545,7 @@ impl NativeCallable for Function { ); data.camera = Some(camera); data.scene3d = Some(object(args.get(2))?); + // 每个新视口独立控制器,避免两个不同相机的输入共享原生控制状态。 let definition = desktop_type(cx.store(), "OrbitController", desktop)?; // 三维视口默认独立透视相机及 OrbitController,Scene3D 引用则来自调用者,可显式共享世界。 data.controller = Some(crate::scene3d_model::allocate( @@ -478,6 +559,7 @@ impl NativeCallable for Function { } // 按钮标签也是普通 TextBlock 孩子,布局和生命周期沿同一组成树,不是原生按钮硬编码文字。 Button => { + // 先创建真实文字孩子,让按钮尺寸和文本测量、字体缓存、子树生命周期保持一致。 let label = new_block( cx, Data::new( @@ -494,6 +576,7 @@ impl NativeCallable for Function { )?; Some(Data::new(Kind::Button, vec![label], 10.0, 0.0)) } + // 非构造操作继续进入页面/实例方法分派;None 不是失败,也不创建占位对象。 _ => None, }; // 统一检查文本等级和 UTF-8 字节量,再按 requested_type 生成实际对象,保留子类工厂身份。 @@ -501,6 +584,7 @@ impl NativeCallable for Function { if let Kind::Text { size, value, .. } = &data.kind && (!(1..=8).contains(size) || value.len() > 16_384) { + // 两条文本工厂共用语义限制;防止类型工厂参数能转 u32 却超出实际支持字号等级。 return Err(invalid("text exceeds size limits")); } return Ok(NativeResult::Return(Value::Object(new_typed_block( @@ -513,7 +597,9 @@ impl NativeCallable for Function { if matches!(self.operation, OpenValue | Present) && let Value::Object(value) = args[0] { + // 对象值可能实现 Presentable;标量不可能走这一协议路径,稍后按普通文字转换处理。 let (desktop, current) = { + // master 用于共享能力类型,current 用于传给源 present 的窗口资源,二者不能混用。 let state = self .session .lock() @@ -530,15 +616,20 @@ impl NativeCallable for Function { .is::() && cx.store().supports(value, capability)? { + // 只有非 Block 且显式实现能力的对象才执行源 present,不能凭同名属性鸭子类型推断。 + // 能力实现依附于类型,需从该类型的方法表取 present,而非随意的实例属性。 let definition = cx .store() .type_of(value)? .ok_or(invalid("untyped Presentable"))?; + // lookup_method 处理继承规则,缺少实现即能力契约错误。 let function = cx .store() .lookup_method(definition, "present")? .ok_or(invalid("missing present method"))?; + // 源 present 接收实际目标窗口,不能总把主窗口传给副窗口中的调用。 let window = object(Some(&cx.store().get_attr(current, "window")?))?; + // 源方法返回后由 PresentResult 检查真实 Block,并按 open 标志决定是否包装 Page。 let complete = cx.allocate(ContextNativeFunction::new( "validate presentation", PresentResult { @@ -559,6 +650,7 @@ impl NativeCallable for Function { endor_core::Instruction::new(endor_core::Opcode::Call, 1), endor_core::Instruction::simple(endor_core::Opcode::ReturnValue), ]; + // 常量表按字节码的 0..3 索引依次提供续接、源方法、receiver 和 Window。 let code = endor_core::EndorCode::from_instructions( &instructions, vec![ @@ -570,8 +662,11 @@ impl NativeCallable for Function { endor_core::CodeLayout::default(), )? .with_location("Presentable.present", "", 1); + // 模板包装成托管代码后,驱动函数的强边负责维持常量/代码存活。 let code = cx.allocate(endor_core::EndorCodeObject::new(code))?; + // 驱动只访问常量,不需要用户命名空间,私有空字典避免污染全局。 let globals = cx.new_dict()?; + // 返回 Invoke 把驱动接入同一 VM 帧循环,不在 NativeContext 内递归进入 VM。 let driver = cx.new_function("Presentable.present", code, globals)?; attach_desktop_type(cx, driver, "Function", desktop)?; return Ok(NativeResult::Invoke { @@ -582,10 +677,13 @@ impl NativeCallable for Function { } // ui.open 把普通结果转换为根组件,再在必要时包一层 Page 并展示;ui.present 仅转换,不自动打开页面。 if matches!(self.operation, OpenValue) { + // 协议对象已在上面返回 Invoke;这里仅处理已有 Block 或普通值的文字回退。 let root = present_value(cx, &args[0], &self.session)?; + // Page 可直接保留,普通 Block 外包一层新 Page,以便加入窗口的页面注册表。 let page = if cx.store().payload::(root, "Block")?.page { root } else { + // 包装持有原 root,不复制业务对象,因此源对象/状态仍可继续被其他位置展示。 new_block( cx, Data::new(Kind::Page, vec![root], 0.0, 0.0), @@ -596,6 +694,7 @@ impl NativeCallable for Function { return Ok(NativeResult::Return(Value::Object(page))); } if matches!(self.operation, Present) { + // 只做值到视觉的转换,不调用 show,不改变活动页或窗口归属。 return Ok(NativeResult::Return(Value::Object(present_value( cx, &args[0], @@ -605,7 +704,9 @@ impl NativeCallable for Function { // 从这里起都是需要接收者的 Block/Page 方法,未绑定 owner 时不能继续修改任意全局对象。 let owner = self.owner.ok_or(invalid("method has no owner"))?; if matches!(self.operation, Bind) { + // 属性名与计算函数分开解码;connect 建立依赖和可取消 Subscription,不立即变成静态值。 let field = text(args.first())?; + // 函数对象的签名/效果由绑定模块检查,不能只因它是 Object 就当作合法计算闭包。 let compute = object(args.get(1))?; return Ok(NativeResult::Return(Value::Object( crate::binding::connect( @@ -622,17 +723,22 @@ impl NativeCallable for Function { } // moveTab 重排同窗口页面索引并强制下一帧更新标签;移动的是原 Page,非新页面副本。 if matches!(self.operation, MoveTab) { + // 页面位置是当前顺序下标,不是 Tab ID 或子组成 slot,先做平台 usize 表示检查。 let position = usize::try_from(integer(args.first())?) .map_err(|_| invalid("tab position out of range"))?; + // 同锁内找所属窗口并重排,不能只用 current:源代码可持有其他窗口的 Page。 let mut state = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; + // 未登记的 Page 没有可重排标签,不能自动打开它再进行移动。 let id = page_window(cx.store(), &state, owner)?.ok_or(invalid("Page is not open"))?; + // 对真实所属窗口设置强制帧,确保即使内容根没变,标签顺序仍会发布。 let view = state .presentations .get_mut(&id) .ok_or(invalid("missing window"))?; + // model/window 维护实际顺序,Presentation 只负责其派生投影和刷新标记。 let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; crate::window::move_page(cx, window, owner, position)?; view.force_frame = true; @@ -640,13 +746,17 @@ impl NativeCallable for Function { } // 接收者可能是共享转发别名,解析到最终 Data 后修改副本,最后用一次 replace_payload 写入结构边。 let (data_ref, data) = block::resolve(cx.store(), owner)?; + // 在副本上编辑结构,再经末尾 replace_payload 更新强边与版本,避免直接篡改借用 payload。 let mut data = data.clone(); + // 默认返回 Unit/Nil,append/insert/订阅等分支会改成自己的业务结果;查询分支直接返回。 let mut result = Value::Nil; match self.operation { SetText => match &mut data.kind { Kind::Text { value, .. } => { + // 新文字必须为真实字符串且受 UTF-8 字节预算约束,再替换 payload/普通属性。 let next = text(args.first())?; if next.len() > 16_384 { + // 检查在写属性前,错误输入不会先清掉旧 text_binding 或部分改文字。 return Err(invalid("text exceeds 16 KiB")); } *value = next; @@ -654,21 +764,27 @@ impl NativeCallable for Function { data.text_binding = None; cx.set_attr(owner, "text", args[0].clone())?; } + // receiver 的名义类型校验之外再验证 payload,避免对不兼容原生种类写文本。 _ => return Err(invalid("setText requires a text Block")), }, SetColor => match &mut data.kind { Kind::Text { color: value, .. } | Kind::Rect { color: value, .. } => { + // 颜色格式先校验,随后把普通 color 属性与本地 payload 副本保持一致。 *value = color(args.first(), "#242422")?; cx.set_attr(owner, "color", Value::Text(value.clone()))?; } + // 容器/空白不支持这一颜色修改接口,不能往无关 payload 中补出虚构颜色字段。 _ => return Err(invalid("this Block has no color")), }, Append | Insert | Remove | Replace | Move => { if !data.is_container() { + // 只允许有组成语义的容器;叶 Block 不因调用 append 而隐式变成 Column。 return Err(invalid("expected a container Block")); } if matches!(self.operation, Append | Insert) { + // 新增边需要新 slot;append 取末尾,insert 的首参是可插入缝位置。 let (position, argument) = if matches!(self.operation, Insert) { + // 平台索引转换和容器长度检查分开,len 本身允许表示“插到末尾”。 let position = usize::try_from(integer(args.first())?) .map_err(|_| invalid("child position out of range"))?; if position > data.children.len() { @@ -676,11 +792,14 @@ impl NativeCallable for Function { } (position, args.get(1)) } else { + // append 不接位置,唯一实参就是新孩子;不会误把它解析成索引。 (data.children.len(), args.first()) }; + // 统一孩子对象解码;validate 再检查真实 Block 和无环组成。 let child = object(argument)?; // 插入前禁止孩子图回到当前 Data,避免循环组成;position 是插入位置,返回值却是稳定 slot。 block::validate(cx.store(), child, Some(data_ref))?; + // 保存当前高水位作为返回身份,再推进 next_slot,删除旧边也不回退该计数。 let slot = data.next_slot; // slot 单调增加且限制到 i64 正范围,不复用已删除身份;前后插入/重排不会把其他孩子 slot 改号。 data.next_slot = slot @@ -692,6 +811,7 @@ impl NativeCallable for Function { } else { // remove/replace/move 首参为稳定 slot 而非当前数组下标;只有 move 的第二参才是目标位置。 let slot = integer(args.first())?; + // 在当前顺序中查该稳定身份的位置,不能直接把 slot 转 usize 当数组下标。 let index = data .children .iter() @@ -699,31 +819,39 @@ impl NativeCallable for Function { .ok_or(invalid("child slot does not exist"))?; match self.operation { Remove => { + // 删除这一稳定边,不重新编号其余孩子;是否销毁 Block 由托管引用关系决定。 data.children.remove(index); } Replace => { + // 新孩子先过无环/类型校验,再替换旧边对象;边 slot 保持不变。 let child = object(args.get(1))?; block::validate(cx.store(), child, Some(data_ref))?; // replace 保留该组成边 slot,但换实际 Block;出现键还含 Block 身份,因此可能产生新的 Mount 上下文。 data.children[index].block = child; } Move => { + // 第二参是目标顺序下标,首参仍是稳定 slot;目标必须是已有序列内位置。 let to = usize::try_from(integer(args.get(1))?) .map_err(|_| invalid("child position out of range"))?; if to >= data.children.len() { + // move 不新增孩子,因此 len 是越界,不同于 insert 允许插到末尾的 len。 return Err(invalid("child position out of range")); } + // 移动完整 Child 值,slot 与对象身份都保持,不分配新的出现组成键。 let child = data.children.remove(index); data.children.insert(to, child); } + // 外层已排除 append/insert,此处只可能是 remove/replace/move。 _ => unreachable!(), } } } // children 查询返回 slot文本->Block 的托管字典,不把当前顺序假装成稳定 slot 连号数组。 Children => { + // 查询创建新字典但不改原容器;key 是 slot 文本,迭代顺序不作为下标身份保证。 let result = cx.new_dict()?; for child in data.children { + // VM 字典赋值记录真实 Block 强边,调用结果可安全独立持有孩子而非引用 Rust 临时切片。 cx.dict_set( result, Value::Text(child.slot.to_string()), @@ -734,17 +862,21 @@ impl NativeCallable for Function { } Render | Navigate => { if !matches!(data.kind, Kind::Page) { + // 只有 Page 有根替换与导航历史,不能对普通 ContainerBlock 偷用页面语义。 return Err(invalid("expected a Page")); } + // 新内容须真实 Block 且不能包含当前 Page 的共享 Data,防止出现循环组成。 let root = object(args.first())?; block::validate(cx.store(), root, Some(data_ref))?; if matches!(self.operation, Navigate) { // 首次 navigate 把当前根纳入历史,之后新导航先截断前进分支,再追加新根,保持浏览历史语义。 if data.history.is_empty() { if let Some(old) = data.children.first() { + // 首次导航才把现有根作为历史起点;空 Page 没有可保留的起始根。 data.history.push(old.block); } } else { + // 从后退位置发起新导航会丢弃原前进分支,不能保留两条互相矛盾的线性历史。 data.history.truncate(data.history_index + 1); } data.history.push(root); @@ -760,12 +892,15 @@ impl NativeCallable for Function { } Back | Forward => { if data.history.is_empty() { + // 无历史是正常无动作,不把索引减法做在空列表上,也不制造一次空展示请求。 return Ok(NativeResult::Return(Value::Nil)); } // 后退/前进钳到历史首尾,复用已保留根对象而非重新执行旧源码。 let next = if matches!(self.operation, Back) { + // saturating_sub 在首项停住,不绕回列表尾部。 data.history_index.saturating_sub(1) } else { + // 前进上界为最后已有项,不为不存在的未来页分配对象。 (data.history_index + 1).min(data.history.len() - 1) }; data.history_index = next; @@ -777,11 +912,14 @@ impl NativeCallable for Function { } // 分离先创建新 Window,再把同一 Page 展示到它;跨窗口出现上下文由后续展示同步重建。 Detach => { + // 短锁中只创建资源,锁释放后再 show_in,避免内部二次锁定同一个 Session。 let window = { + // 新窗口编号和 Presentation 同步建立,使用主注册域的 Window 默认类型。 let mut state = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; + // detach 创建默认 Window,不继承 Page 的源类型作为窗口类型。 let definition = desktop_type(cx.store(), "Window", state.master)?; create_window(cx, &mut state, definition)? }; @@ -789,24 +927,29 @@ impl NativeCallable for Function { return Ok(NativeResult::Return(Value::Object(window))); } PageWindow => { + // 这是查询:按真实注册表找 owner 的归属,不能直接返回当前窗口冒充其宿主。 let state = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; + // 未打开/已关闭 Page 正常返回 nil,签名为 nullable(Window)。 let value = if let Some(id) = page_window(cx.store(), &state, owner)? { cx.store() .get_attr(state.presentations[&id].desktop, "window")? } else { + // 仍存活的 Page 可以暂时没有窗口归属,不因此新建窗口或报错。 Value::Nil }; return Ok(NativeResult::Return(value)); } Open | Activate | MoveWindow => { + // moveToWindow 必须明确目标;open/activate 的缺省则保留原归属或使用当前窗口。 if matches!(self.operation, MoveWindow) && matches!(args.first(), None | Some(Value::Nil)) { return Err(invalid("Page.moveToWindow requires a Window")); } + // None 与显式 nil 在 open 路径等价;非空实参先解码对象,show_in 再检查 Window payload/域。 let target = match args.first() { None | Some(Value::Nil) => None, Some(value) => Some(object(Some(value))?), @@ -816,20 +959,23 @@ impl NativeCallable for Function { } // unmount 清内容但不关闭 Page 标签,也不直接等同于销毁整个业务对象。 Unmount => { + // 内容强边在统一 replace_payload 时撤销,Page 自己仍在标签注册表中可再次 render。 data.children.clear(); } Close => { // 关闭 Page 当场取消页面和所属 Mount 任务,即使同一输入稍后又打开它,也不能复活旧异步工作。 crate::tasks::cancel_owner(cx, owner)?; + // 找出现位置并撤登记的过程只操作宿主表/任务,不在锁内执行用户生命周期钩子。 let mut state = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; if let Some(id) = page_window(cx.store(), &state, owner)? { - // Closing the tab ends its presentation even if source - // reopens it before the next layout can detach the Mounts. + // 关闭即终止旧出现的任务归属,不等待下一次布局实际卸载才取消。 + // 弱 Page 身份用于精确筛选这个页面的 Mount,不能取消整个窗口其他页面任务。 let page = cx.store().downgrade(owner)?; for weak in state.presentations[&id].mounts.ordered() { + // 升级成功且 Mount.page 匹配才取消;不会把共享 Block 的其他出现一并取消。 if let Some(mount) = cx.upgrade(weak)? && cx .store() @@ -837,9 +983,11 @@ impl NativeCallable for Function { .page == Some(page) { + // 取消 owner/key 调度资格,旧 I/O 即使稍后完成也不能再向此出现交付。 crate::tasks::cancel_owner(cx, mount)?; } } + // 撤注册后为原窗口选择替代页或安排清空,生命周期卸载仍交后续刷新处理。 remove_page( cx, state @@ -852,16 +1000,19 @@ impl NativeCallable for Function { return Ok(NativeResult::Return(Value::Nil)); } OnClick => { + // 简便 onClick 保存单个处理器,validate_handler 检查真实 callable 契约而非仅有对象身份。 let handler = object(args.first())?; crate::event_dispatch::validate_handler(cx.store(), handler)?; data.click = Some(handler); result = Value::Object(owner); } On => { + // 字符串事件名用于传播匹配,按 UTF-8 字节限制长度,不允许空事件名。 let kind = text(args.first())?; if kind.is_empty() || kind.len() > 128 { return Err(invalid("invalid event kind")); } + // 先校验回调再建立 Connection,避免非法处理器留下不可取消的半条订阅。 let handler = object(args.get(1))?; crate::event_dispatch::validate_handler(cx.store(), handler)?; // 订阅默认非捕获;可用 Bool 或 capture/bubble 文本明确阶段,其他值拒绝而不做真值转换。 @@ -875,23 +1026,28 @@ impl NativeCallable for Function { // 清掉已断开连接后再计算容量,保留仍有效订阅顺序;新 Connection 拥有 handler,返回 token 仅弱引用连接。 let mut active = Vec::new(); for token in &data.subscriptions { + // data.subscriptions 实际保存 Connection 身份,已断开者 handler=None 不再占容量。 if cx .store() .payload::(*token, "Connection")? .handler .is_some() { + // 按原序保留仍有效连接,事件路由中的监听顺序因此不因清理而改变。 active.push(*token); } } if active.len() >= MAX_UI_NODES { + // 清理后仍满才拒绝,避免死亡连接永久占满一个 Block 的订阅预算。 return Err(invalid("too many event subscriptions")); } + // Connection 强持 handler,由 Block 的 subscriptions 拥有;任务和事件生命周期可据此断开。 let connection = cx.allocate(crate::event_dispatch::Connection { kind, handler: Some(handler), capture, })?; + // 返回的 Subscription 仅弱指连接,不应因用户保留令牌而让整个已卸载组件和回调常驻。 let token = cx.allocate(crate::event_dispatch::Subscription { connection: cx.store().downgrade(connection)?, })?; @@ -903,6 +1059,7 @@ impl NativeCallable for Function { // 基类事件/挂载回调默认空实现,子类可覆盖;不在这条默认路径生成额外状态。 OnEvent | OnMount | OnUnmount => return Ok(NativeResult::Return(Value::Nil)), Click | Dispatch => { + // 事件/订阅类型来自当前展示域,传播上下文仍由明确 owner/可选 Mount 指定。 let desktop = self .session .lock() @@ -910,17 +1067,22 @@ impl NativeCallable for Function { .desktop; // click 新建种类为 click 的 Event,dispatch 使用调用者提供事件;两者都将可选具体 Mount 交传播模块验证。 let (event, mount) = if matches!(self.operation, Click) { + // click 总创建一份新 Event,调用方只可补具体 Mount;不会把旧事件对象重复分发。 let definition = desktop_type(cx.store(), "Event", desktop)?; ( crate::event_dispatch::new_event(cx, definition, "click")?, args.first(), ) } else { + // dispatch 接现成事件,第二实参才是可选出现;传播模块继续验证两者语义。 (object(args.first())?, args.get(1)) }; + // None 保留“未指定出现”含义,显式非法值传播错误;不从任意当前 Mount 猜目标。 let mount = mount.map(|value| object(Some(value))).transpose()?; + // 返回 Invoke/Return 由事件模块决定,源捕获/目标/冒泡回调仍走同一 VM 帧循环。 return crate::event_dispatch::dispatch(cx, owner, event, mount, desktop); } + // 其余操作应在前面的专用分支处理;漏分派必须显式报错,不能悄悄返回成功 Unit。 _ => return Err(invalid("unsupported Block operation")), } // 非提前返回的结构编辑/订阅操作在末尾统一经过写屏障,触发拥有边更新和变更日志。 diff --git a/src/bridge/ui_bridge/developer_pages.rs b/src/bridge/ui_bridge/developer_pages.rs index 9f8be78..08c590b 100644 --- a/src/bridge/ui_bridge/developer_pages.rs +++ b/src/bridge/ui_bridge/developer_pages.rs @@ -1,30 +1,38 @@ //! 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。 //! -//! Developer workspaces are managed Pages, using the same roots and placement API. +//! bootstrap::install 调用 install 和 install_start_page;前者注册 ui.developerTools,后者注册 ui.newTab。 +//! GUI 发出的 developer-open:* 输入由 UiBridge::dispatch 转成一次 VM 调用,再进入 OpenTool::invoke。 +//! 这里管理 Page 的创建与归属;Console/Network 等工具的原生内容由 shell/developer_tools 按页面标记绘制。 use super::*; #[derive(Debug)] /// 工具打开能力,保存所属会话和 ui 对象;调用时校验工具名,再在该 VM 中展示页面。 struct OpenTool { + // 查找现有页面和安排 show 时使用同一展示域;不另建开发工具专属 VM。 session: Shared, + // 保存 ui.newTab 工厂入口;ContextNativeFunction 的 captures 同时声明这个托管强边。 ui: ObjectRef, } impl NativeCallable for OpenTool { - /// 仅接受一个工具名称字符串,工具类型与白名单在 invoke 检查。 + /// VM 在进入 invoke 前检查一个显式参数;这里只有数量检查,字符串类型与白名单由 invoke 检查。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// 把白名单工具名转换成对应托管 Page;New tab 转交预编译工厂,其余管理页复用已有实例或创建新页。 + /// VM 执行 ui.developerTools 时调用:New tab 委托源工厂,其余工具优先激活已有页面,否则创建标记页。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 同时验证数量和字符串表示;不能把任意对象的可读表示当作工具名字接受。 let [Value::Text(name)] = args else { + // 错误发生在任何页面分配/登记之前,因此无须撤销一次半完成的工具打开。 return Err(invalid("developerTools requires a tool name")); }; // 工具名白名单包含四个管理页和特殊新标签动作,拒绝任意字符串进入工厂/工具标记路径。 if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) { + // 白名单错误交回 VM,不把名字继续写入 _tesseraTool 让 GUI 猜测工具类型。 return Err(invalid("unknown developer tool")); } // 新标签交给预编译 ui.newTab 托管工厂,经 NativeResult::Invoke 回到同一 VM 执行。 if name == "New tab" { + // 只解析函数身份,Invoke 让 VM 自己推进源工厂,保留调用栈/预算与托管返回值语义。 let factory = object(Some(&cx.store().get_attr(self.ui, "newTab")?))?; return Ok(NativeResult::Invoke { function: factory, @@ -39,16 +47,19 @@ impl NativeCallable for OpenTool { .nodes .clone(); for weak in nodes { + // NativeContext::upgrade 的临时引用由原生调用域管理;这里只保留实际选中的工具页作为返回值。 if let Some(page) = cx.upgrade(weak)? && cx.store().get_attr(page, "_tesseraTool").ok() == Some(Value::Text(name.clone())) { // 已有同类工具页则激活并聚焦其所属原生窗口,不每次打开都创建重复 Page。 show(cx, page, &self.session)?; let window = { + // show 可能保留旧归属;重新查所属窗口,Focus 应发给真实窗口而不是当前主窗口。 let state = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; + // show 成功后仍无登记表示桥接关系异常,不能随意选择其他窗口聚焦。 let id = page_window(cx.store(), &state, page)? .ok_or(invalid("tool is not open"))?; object(Some( @@ -56,6 +67,7 @@ impl NativeCallable for OpenTool { .get_attr(state.presentations[&id].desktop, "window")?, ))? }; + // 只登记原生命令,GUI 在发布后再执行系统聚焦;VM worker 不直接操作 OS 窗口。 crate::window::enqueue(cx, window, crate::window::Action::Focus)?; return Ok(NativeResult::Return(Value::Object(page))); } @@ -67,14 +79,16 @@ impl NativeCallable for OpenTool { &self.session, None, )?; + // title 是用户可见标签,_tesseraTool 是宿主识别内容的私有标记,不能只设置其中一个。 cx.set_attr(page, "title", Value::Text(name.clone()))?; cx.set_attr(page, "_tesseraTool", Value::Text(name.clone()))?; show(cx, page, &self.session)?; Ok(NativeResult::Return(Value::Object(page))) } } -/// 把本模块的类型、方法或能力注册到所属运行时上下文。 +/// bootstrap::install 在任务/类型能力就绪后调用,注册开发工具 callable;此时不打开任何页面。 pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()> { + // payload 共享 Session,captures 显式持有 ui;仅存 Rust ObjectRef 不会形成 VM 可追踪强边。 let function = vm.allocate(ContextNativeFunction::new( "developerTools", OpenTool { @@ -89,16 +103,17 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<() Ok(()) } -/// Compile the bundled Endor page once per VM, in a private module namespace. -/// Its functions/classes and callbacks remain ordinary managed VM objects. -/// 编译并安装 VM 私有的起始页工厂,供新页面动作重复调用。 +/// bootstrap 在 UiBridge 组装完成后调用,编译内置起始页模块并把 newTab 工厂挂到 ui。 +/// 每个 VM 仅安装一次,后续新标签操作重复调用工厂;源类/闭包仍由正常 VM 对象系统管理。 pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> { // 源码随 binary 嵌入,每个 VM 安装时解析/编译一次;函数工厂之后重复执行不再重复编译模块。 let source = frontend::EndorReader::from_text( "tessera:start-page.endor", include_str!("start_page.endor"), ); + // 按完整 Module 解析,保证类型与工厂定义属于一次安装环境,而不是用户 REPL 的多个输入片段。 let ast = frontend::parse_source_ast(&source, frontend::ParseMode::Module)?; + // 这里只生成代码模板,后面的 load 才把模板变成当前 VM 的托管代码对象。 let compiled = compiler::compile_ast( &ast, &source, @@ -111,9 +126,12 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> { }, ) .map_err(|error| source.annotate(error))?; + // 私有全局字典防止内置页面辅助类污染用户命名;导出闭包可通过托管边继续持有其环境。 let globals = vm.new_dict()?; + // 暂存安装/执行错误,闭包返回之后仍统一释放 globals 的局部拥有根。 let result = (|| -> Result<()> { bridge.install_globals(vm, globals)?; + // 加载所得代码有独立根,execute 成功/失败后都需 vm.release(code)。 let code = compiled.load(vm)?; // 执行模块定义类和工厂,使用单次预算;这里不主动调用 newTab,不在安装时打开页面。 let value = vm.execute_with_limits( diff --git a/src/bridge/ui_bridge/page_input.rs b/src/bridge/ui_bridge/page_input.rs index 0eb6789..a8c9e7d 100644 --- a/src/bridge/ui_bridge/page_input.rs +++ b/src/bridge/ui_bridge/page_input.rs @@ -1,11 +1,13 @@ //! 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。 //! 输入携带具体出现的 Mount;嵌套世界 UI 先做逆投影,避免把屏幕位移直接当成场景坐标。 //! -//! Camera mutations run on the owning VM worker, just like other page input. +//! UiBridge::dispatch 按输入 kind 分流到下面三个方法:整页、局部二维视口和局部三维场景分别处理。 +//! 它们只在所属 VM worker 上修改托管相机属性或调用 controller.update,不直接改 GUI 已发布帧。 +//! 姿态变化随后由正常依赖/快照刷新观察;任何新帧仍需走 layout/paint/commit 的完整发布路径。 use super::*; impl UiBridge { - /// 解码指定 3D 视口的旋转/平移/缩放输入,再调用其托管相机控制器。 + /// dispatch 的 scene-pan/orbit/zoom 分支调用,把命中出现位置的输入包装成 CameraGesture 交给控制器。 pub(super) fn dispatch_scene_view( &self, vm: &mut Vm, @@ -13,6 +15,7 @@ impl UiBridge { ) -> Result<()> { // 锁内只读取当前发布版本与弱 Mount 身份,离开锁后才进入 VM 操作,避免持 session 锁调用用户代码。 let (weak, desktop) = { + // 当前窗口已由外层选定;本锁内同时读版本和 Mount,避免把旧几何与新弱身份混用。 let state = self .session .lock() @@ -30,9 +33,11 @@ impl UiBridge { state.desktop, ) }; + // upgrade 返回临时拥有根,下方即使任一步失败也通过闭包外的 release 清理。 let mount = vm .upgrade(weak)? .ok_or_else(|| anyhow::anyhow!("3D viewport Mount expired"))?; + // 不能直接 ? 返回整个方法,否则错误时跳过 Mount 根释放;用闭包暂存处理结果。 let result = (|| { // 除 session 发布版本外,再检查目标自身仍挂接、版本匹配且确为 3D 视口,拒绝旧出现操作。 let occurrence = vm.payload::(mount, "Mount")?.clone(); @@ -42,8 +47,11 @@ impl UiBridge { && occurrence.geometry.scene3d_viewport, "obsolete 3D viewport" ); + // resolve 跟随 Block 的共享替换关系,再读取实际视觉状态,不能只信任旧 payload。 let (_, data) = block::resolve(vm.object_store(), occurrence.block)?; + // visual_data 合并当前可见属性,后面的相机/控制器来自模型而非原生画面的旧快照。 let data = block::visual_data(vm.object_store(), data)?; + // 没有显式相机就无法解释手势,拒绝更新而不是猜一个默认相机改变场景语义。 let camera = data .camera .ok_or_else(|| anyhow::anyhow!("missing 3D camera"))?; @@ -51,18 +59,25 @@ impl UiBridge { let controller = data .controller .ok_or_else(|| anyhow::anyhow!("3D controller disabled"))?; + // 同一视觉对象的边框/内边距由快照解析,手势锚点与可用尺寸都应扣除这部分。 let node = block::snapshot(vm.object_store(), occurrence.block)?; + // inset 将外框换算成视口内容盒,避免控制器把边框尺寸算成场景宽高。 let inset = crate::layout::insets(&node); + // 几何来自具体 Mount,两个窗口中共享同一场景仍可能有不同视图投影。 let geometry = &occurrence.geometry; + // 外层二维视图逆变换用于锚点定位;没有视图变换时使用恒等变换。 let outer = geometry.view.unwrap_or_default(); + // delta 的第一分量还会用作缩放倍率;缺失说明输入信封不满足相机事件要求。 let delta = input .delta .ok_or_else(|| anyhow::anyhow!("3D camera delta required"))?; // 缩放使用倍率而非二维位移;旋转/平移通过 logical_delta 处理嵌套二维/面板投影。 // 不可逆映射时忽略本次连续增量,不在无效坐标上继续更新相机。 let local_delta = if input.kind == "scene-zoom" { + // 缩放不产生平移 dx/dy,只把原第一分量写到 gesture.factor。 [0., 0.] } else { + // 平移/旋转先撤销外层投影;映射失败表示本帧增量无法可靠应用。 let Some(delta) = geometry.logical_delta(input.point, delta) else { return Ok(()); }; @@ -79,6 +94,7 @@ impl UiBridge { )); // 将输入封装为名义 CameraGesture 临时对象;不把原生 Input 或窗口指针直接交给源控制器。 let gesture = vm.new_object()?; + // 临时手势也有独立拥有根;把附类型/写字段/控制器错误暂存,外层始终 release(gesture)。 let result = (|| { vm.attach_type( gesture, @@ -95,10 +111,12 @@ impl UiBridge { ("pointX", point.x - geometry.x - inset.left), ("pointY", point.y - geometry.y - inset.top), ] { + // 全部数值通过统一语言数值编码写属性,控制器读取的是普通托管字段。 vm.set_attr(gesture, name, crate::native_visual::value(value))?; } // 绑定 controller.update 保留接收者语义,再带 camera/gesture 两个显式参数进行有界调用。 let function = vm.bind_attr(controller, "update")?; + // callable 校验在 VM 执行边界继续完成,非对象方法值直接作为契约错误返回。 let result = match function { Value::Object(function) => vm.call_with_limits( function, @@ -124,7 +142,7 @@ impl UiBridge { result } - /// 把具体 CanvasViewport 的位移或缩放锚点映射到其显式 Camera2D。 + /// dispatch 的 viewport-pan/zoom 分支调用,逆投影具体 CanvasViewport 输入并改写其 Camera2D 属性。 pub(super) fn dispatch_viewport_view( &self, vm: &mut Vm, @@ -132,6 +150,7 @@ impl UiBridge { ) -> Result<()> { // 二维视口同样先校验发布版本,再从活动表捕获原 Mount,不能因目标消失自动换成另一个视口。 let weak = { + // 锁内只检查发布版本并复制弱身份;下面读取相机和写 VM 属性时不持有会话锁。 let state = self .session .lock() @@ -146,10 +165,13 @@ impl UiBridge { .get(&input.mount) .ok_or_else(|| anyhow::anyhow!("viewport is no longer active"))? }; + // 临时根保证输入处理期间 Mount 仍可读取,方法尾部无论成功失败都释放。 let mount = vm .upgrade(weak)? .ok_or_else(|| anyhow::anyhow!("viewport Mount expired"))?; + // 闭包集中实际处理,使所有 ? 都先回到统一释放路径。 let result = (|| { + // 弱身份能升级不代表仍挂载/版本有效,先验证具体出现上下文。 let occurrence = vm.payload::(mount, "Mount")?; anyhow::ensure!( occurrence.mounted @@ -157,32 +179,44 @@ impl UiBridge { && occurrence.geometry.canvas_viewport, "obsolete viewport occurrence" ); + // 复制几何和 Block 身份,结束 occurrence 对 VM 的借用后才能改写相机。 let geometry = occurrence.geometry.clone(); + // Block 只用于找相机/边距,输入定位仍来自 Mount 的几何。 let block = occurrence.block; + // 共享替换后的实际视觉状态才包含当前相机引用。 let (_, data) = block::resolve(vm.object_store(), block)?; + // 托管属性覆盖 payload 缺省值,保持与布局/绘制同一视觉读取约定。 let data = block::visual_data(vm.object_store(), data)?; + // 局部视口必须有 Camera2D,不把整页 Surface 当作相机替代。 let camera = data .camera .ok_or_else(|| anyhow::anyhow!("viewport has no camera"))?; // 读取已校验的相机快照作为本次基准;共享相机用户会通过普通属性变更观察到相同更新。 let old = crate::viewport::camera(vm.object_store(), camera)?; + // 该分量在 zoom 时表示倍率,在 pan 时表示外部逻辑位移,随后分支分别解释。 let delta = input .delta .ok_or_else(|| anyhow::anyhow!("viewport input has no delta"))?; + // 基于旧快照计算三个新值,完成验证后才依次回写托管属性。 let (mut x, mut y, mut zoom) = (old.x, old.y, old.zoom); if input.kind == "viewport-zoom" { + // 缩放必须有锚点,不能让缺失指针导致隐式绕视口原点缩放。 let pointer = input .point .ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?; + // 先用屏幕/视图坐标做外框检查,再逆投影到布局逻辑坐标。 let pointer = crate::geometry::Point::new(pointer[0], pointer[1]); anyhow::ensure!( geometry.contains(pointer), "zoom anchor is outside viewport" ); + // 外框命中不保证投影可逆;None 作为错误返回,不能继续使用原屏幕数值。 let pointer = geometry .logical_point(pointer) .ok_or_else(|| anyhow::anyhow!("zoom anchor cannot be projected"))?; + // 当前 Block 快照给出布局边距,用它取得正确内容盒的缩放锚点。 let node = block::snapshot(vm.object_store(), block)?; + // inset 与布局模块共用同一规则,避免相机手势与绘制对内容原点理解不同。 let inset = crate::layout::insets(&node); // 已验证指针落在出现外框还不够;扣除 inset 后必须落入实际内容盒,禁止用边框作为缩放锚点。 let local = [ @@ -201,6 +235,7 @@ impl UiBridge { x += local[0] / old.zoom - local[0] / zoom; y += local[1] / old.zoom - local[1] / zoom; } else { + // 平移没有锚点保持公式,只需逆投影增量再除相机自身缩放。 let Some(delta) = geometry.logical_delta(input.point, delta) else { return Ok(()); }; @@ -216,11 +251,12 @@ impl UiBridge { vm.set_attr(camera, "zoom", crate::native_visual::value(zoom))?; Ok(()) })(); + // release 发生在 result? 之前,保证投影/属性写入错误不会泄漏升级得到的 Mount。 vm.release(mount)?; result } - /// 处理旧 Page 模式的兼容相机事件;独立视口使用各自的输入路径。 + /// dispatch 的 page-pan/zoom 分支调用,兼容旧 Page 表面相机;独立 CanvasViewport 不走此方法。 pub(super) fn dispatch_page_view( &self, vm: &mut Vm, @@ -228,6 +264,7 @@ impl UiBridge { ) -> Result<()> { // 兼容 Page 相机不通过 Mount 定位,而是读取当前活动页面及其 Surface 副本;发布版本仍必须一致。 let (page, mut surface, viewport) = { + // 只复制当前已发布页的相机与真实视口,不持锁执行下面的坐标计算或 VM 属性写入。 let state = self .session .lock() @@ -244,17 +281,21 @@ impl UiBridge { state.layout.viewport, ) }; + // 外层信封校验之后仍要求本路径必需的增量字段,防止不完整调用静默改变相机。 let delta = input .delta .ok_or_else(|| anyhow::anyhow!("page view requires delta"))?; if input.kind == "page-zoom" { + // 旧 Page 模式只允许 Canvas 缩放,有限文档或 Window 页面保持各自尺寸语义。 anyhow::ensure!( surface.mode == crate::page::Mode::Canvas, "zoom requires canvas mode" ); + // 以光标为缩放锚点,先要求它存在,再检查可用视口范围。 let pointer = input .point .ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?; + // 转为页面几何共用的 Point,便于调用 Surface::to_world 做缩放前逆变换。 let pointer = crate::geometry::Point::new(pointer[0], pointer[1]); anyhow::ensure!( pointer.x >= 0.0 @@ -270,6 +311,7 @@ impl UiBridge { world.y - pointer.y / surface.zoom, ); } else { + // 平移按当前 zoom 换成世界距离,不直接把屏幕像素累加进世界 pan。 surface.pan.x += delta[0] / surface.zoom; surface.pan.y += delta[1] / surface.zoom; } @@ -277,6 +319,7 @@ impl UiBridge { if let Some(viewport) = viewport { surface.clamp(viewport); } + // 内容模式 clamp 之外再加通用坐标预算;Canvas 可自由漫游,但也不能无限增长偏移。 let limit = MAX_DIMENSION as f32; surface.pan.x = surface.pan.x.clamp(-limit, limit); surface.pan.y = surface.pan.y.clamp(-limit, limit); diff --git a/src/bridge/ui_bridge/signatures.rs b/src/bridge/ui_bridge/signatures.rs index adf6a42..6a95e08 100644 --- a/src/bridge/ui_bridge/signatures.rs +++ b/src/bridge/ui_bridge/signatures.rs @@ -1,10 +1,14 @@ //! 声明宿主方法在 Endor 类型系统中的参数和返回契约。 //! 签名用于构造/调用检查,应与 calls 和各 NativeCallable 实现保持一致;这里不执行实际业务操作。 //! -//! Canonical contracts for native Block methods and constructors. +//! bootstrap 在类型安装的第二阶段调用这两个函数,把完整签名写入 TypeMethod、Function 和 NativeFactory。 +//! ui_bridge::bind 也会为状态对象的绑定函数调用 operation_signature。 +//! VM 随后在调用边界读取这些签名,验证数量、类型及调用效果;业务实现位于 calls/scene3d 等模块。 +//! 参数注解并不决定是否必填:Operation::arity 给出实参数量,注解表则检查实际出现的每一个参数。 use super::*; -/// 根据宿主方法类别提供 Endor 方法的签名,供类型与调用检查使用。 +/// bootstrap 安装实例方法时调用,构造包含 receiver 的 VM 方法契约。 +/// receiver 是方法声明所在类型,不是某次调用的实例;实际接收者可以是该类型的合法子类。 pub(super) fn method_signature( store: &endor_core::ObjectStore, desktop: ObjectRef, @@ -13,11 +17,14 @@ pub(super) fn method_signature( ) -> Result { use AnnotationType::{Any, Function, Nominal, Number, String as TextType, Unit}; use Operation::*; + // 闭包把宿主约定的名字解析成当前 desktop 的真实 Type;查找失败中止安装,不能降级为 Any。 let nominal = |name| desktop_type(store, name, desktop).map(Nominal); // 先描述显式实参,不含隐式 receiver;后面统一在 parameters 首部补接收者。 // ReadOnly/Mutating/External 是 VM 调用效果契约,不是线程调度策略。 let (arguments, returns, effect) = match operation { + // 三个数分别供位置/旋转操作使用;方法修改现有三维对象,没有额外返回值。 Translate3D | Rotate3D => (vec![Number, Number, Number], Unit, CallEffect::Mutating), + // 控制器处理被控对象和相机手势;手势是类型化协议值,不能接任意文本或标量。 CameraControl => ( vec![nominal("Object")?, nominal("CameraGesture")?], Unit, @@ -29,9 +36,13 @@ pub(super) fn method_signature( nominal("Subscription")?, CallEffect::Mutating, ), + // 查询已有出现位置,返回托管列表;不修改对象或展示关系。 Mounts => (vec![], nominal("ObjectList")?, CallEffect::ReadOnly), + // Children 的实际值仍由容器实现解释;这里的 Any 不改变只读效果约束。 Children => (vec![], Any, CallEffect::ReadOnly), + // 可选文本用于标记失效类别;是否可省略由 arity 决定,非此处的 vec 长度决定。 Invalidate => (vec![TextType], Unit, CallEffect::Mutating), + // 修改既有文本/颜色,内容合法性(如颜色格式)由调用实现进一步校验。 SetText | SetColor => (vec![TextType], Unit, CallEffect::Mutating), // append/insert 返回稳定 slot 数值;remove/replace/move 返回 Unit,调用方不能把它们当返回孩子对象的集合方法。 Append => (vec![nominal("Block")?], Number, CallEffect::Mutating), @@ -40,15 +51,19 @@ pub(super) fn method_signature( Number, CallEffect::Mutating, ), + // 数字注解只验证 Number;非负整数、稳定槽位是否存在等语义由 calls 检查。 Remove => (vec![Number], Unit, CallEffect::Mutating), Replace => (vec![Number, nominal("Block")?], Unit, CallEffect::Mutating), Move => (vec![Number, Number], Unit, CallEffect::Mutating), + // 页面内容替换/导航接受可视 Block,并改写待展示状态;此处不提前执行布局。 Render | Navigate => (vec![nominal("Block")?], Unit, CallEffect::Mutating), - // 链式 API 返回接收者名义类型,保留子类返回契约,而不是统一降为 Block。 + // 链式 API 返回声明 receiver 的名义类型;源子类实例也满足此基类契约,不承诺精确动态子类。 OnClick => (vec![Function], Nominal(receiver), CallEffect::Mutating), // 事件和生命周期方法可能执行任意用户回调,使用 External,不能冒充只读视觉查询。 OnEvent => (vec![nominal("Event")?], Unit, CallEffect::External), + // Mount 标识一次具体出现位置;同一 Block 的多次展示不能共用一个隐式上下文。 OnMount | OnUnmount => (vec![nominal("Mount")?], Unit, CallEffect::External), + // click/dispatch 返回实际事件,便于调用者观察传播状态;Mount 可选性由 arity 给出。 Click => ( vec![nominal("Mount")?], nominal("Event")?, @@ -59,28 +74,38 @@ pub(super) fn method_signature( nominal("Event")?, CallEffect::External, ), + // 迁移要求真实 Window 实例并返回页面,窗口是否关闭、是否属于当前运行时由实现继续检查。 MoveWindow => ( vec![nominal("Window")?], Nominal(receiver), CallEffect::Mutating, ), + // 标签位置是数值,Activate 无显式实参;二者都改变展示选择并允许链式使用。 MoveTab => (vec![Number], Nominal(receiver), CallEffect::Mutating), Activate => (vec![], Nominal(receiver), CallEffect::Mutating), + // detach 新建目标窗口,返回 Window 而非当前页面;资源创建属于修改操作。 Detach => (vec![], nominal("Window")?, CallEffect::Mutating), + // 这些操作只修改生命周期或历史状态,均没有业务返回值。 Unmount | Close | CloseWindow | Back | Forward => (vec![], Unit, CallEffect::Mutating), + // 先以 Any 占位,后面用 nullable(Window) 收窄,允许省略/显式 nil 选择默认归属。 Open => (vec![Any], Nominal(receiver), CallEffect::Mutating), + // 页面尚未打开时无所属窗口,稍后把返回 Any 改为 Window | nil。 PageWindow => (vec![], Any, CallEffect::ReadOnly), + // 事件名、回调和可选捕获选项;返回 Subscription 令牌供取消,不直接返回 receiver。 On => ( vec![TextType, Function, Any], nominal("Subscription")?, CallEffect::Mutating, ), + // 自由工厂 Operation 不应误装成实例方法;缺少明确契约就拒绝安装,防止默认放宽检查。 _ => return Err(invalid("unsupported native method signature")), }; // Operation 维护显式参数的必需/最大个数;方法签名整体加一,将 receiver 纳入调用约定。 let arity = operation.arity(); + // parameters[0] 固定为 receiver,因此后面细化显式参数时都必须把索引加一。 let mut parameters = vec![Nominal(receiver)]; parameters.extend(arguments); + // 先把简单 AnnotationType 升格为 CallType;下面再改写少数联合/可空位置。 let mut signature = CallSignature { arity: endor_core::CallArity::range(arity.required + 1, arity.maximum + 1), parameters: parameters.into_iter().map(CallType::from).collect(), @@ -91,15 +116,20 @@ pub(super) fn method_signature( // 少数参数/返回需要联合或可空类型,先用占位 Any 建基础签名,再按下标细化。 // On 第三个显式选项位于 parameters[3],可接受 Bool 或字符串。 match operation { + // 参数允许 nil,但非空值必须是本 desktop 注册的 Window 实例。 Open => signature.parameters[1] = CallType::nullable(nominal("Window")?), + // 没有归属是正常查询结果,与错误窗口对象的类型失败不同。 PageWindow => signature.returns = CallType::nullable(nominal("Window")?), + // 兼容布尔捕获标记和命名阶段字符串;具体字符串语义在事件连接操作中检查。 On => signature.parameters[3] = CallType::Union(vec![AnnotationType::Bool, TextType]), + // 其余操作已经完整表达,不应额外增加可空或联合类型。 _ => {} } Ok(signature) } -/// 将具体宿主 Operation 映射到参数/返回类型契约。 +/// bootstrap 和 ui_bridge::bind 调用,描述不含 receiver 的自由函数/状态绑定函数原生参数。 +/// NativeFactory 复用本结果作为 options 窄签名,再在外部包上 Type 首参和源子类字段。 pub(super) fn operation_signature( store: &endor_core::ObjectStore, desktop: ObjectRef, @@ -107,27 +137,36 @@ pub(super) fn operation_signature( ) -> Result { use AnnotationType::{Any, Bool, Number, String as TextType, Trait, Type, Unit}; use Operation::*; + // 注册表来自所属 VM 的 desktop;同名但不同身份的外部 Type 不能满足当前名义契约。 let nominal = |name| desktop_type(store, name, desktop).map(AnnotationType::Nominal); // 自由 UI 操作没有隐式 receiver;同时声明固定参数、可变尾参、返回名义类型和调用效果。 let (parameters, variadic, returns, effect) = match operation { + // 类型关系查询不改变类型表;IsInstance 签名首参为 Any,但 calls 当前仍要求实际值为对象。 IsInstance => (vec![Any, Type], None, Bool, CallEffect::ReadOnly), IsSubtype => (vec![Type, Type], None, Bool, CallEffect::ReadOnly), + // 注册能力实现会修改类型关系;implements/supports 只查询已有关系。 Implement => (vec![Type, Trait], None, Unit, CallEffect::Mutating), Implements => (vec![Type, Trait], None, Bool, CallEffect::ReadOnly), Supports => (vec![Any, Trait], None, Bool, CallEffect::ReadOnly), + // open 可把普通值包装成视觉/Page 并安排展示,返回供继续操作的 Page。 OpenValue => (vec![Any], None, nominal("Page")?, CallEffect::Mutating), // present 可能转交用户展示协议,效果交由被委托调用确定,不能声明成固定 ReadOnly。 Present => (vec![Any], None, nominal("Block")?, CallEffect::Delegated), + // state 可分配新的状态对象;ReadOnly 允许新对象初始化,不授权修改原有对象。 State => (vec![Any], None, Any, CallEffect::ReadOnly), + // set/increment 的 receiver 已存在 Function.owner 中,不计入本处参数表。 StateSet => (vec![Any], None, Unit, CallEffect::Mutating), Increment => (vec![], None, Unit, CallEffect::Mutating), + // stats 读取宿主计数而非纯托管属性依赖,按 External 描述,不作为只读布局计算接口。 Stats => (vec![], None, Any, CallEffect::External), + // viewport 返回尺寸协议值;窗口尺寸尚缺失等情形仍由实际实现处理。 Viewport => ( vec![], None, nominal("MeasureResult")?, CallEffect::ReadOnly, ), + // ui.window 查询现有窗口,Window 类型构造则创建新资源,二者不能共享调用效果。 Window => (vec![], None, nominal("Window")?, CallEffect::ReadOnly), NewWindow => (vec![], None, nominal("Window")?, CallEffect::Mutating), // 这些构造操作返回新对象,ReadOnly 允许分配并初始化新值,但不允许因此修改已有业务对象。 @@ -137,18 +176,21 @@ pub(super) fn operation_signature( nominal("TextBlock")?, CallEffect::ReadOnly, ), + // TextOf 前两个参数表示对象/属性名;Text 直接接收字符串,其后字号和颜色可选。 Text => ( vec![TextType, Number, TextType], None, nominal("TextBlock")?, CallEffect::ReadOnly, ), + // 矩形宽高为必填,后续数值/颜色的缺省规则由 Operation::arity 与构造实现共同约束。 Rect => ( vec![Number, Number, Number, TextType], None, nominal("RectBlock")?, CallEffect::ReadOnly, ), + // 空白块只需要长度;滚动块额外要求宽高和一个 Block 内容。 Spacer => (vec![Number], None, nominal("Spacer")?, CallEffect::ReadOnly), Scroll => ( vec![Number, Number, nominal("Block")?], @@ -160,15 +202,20 @@ pub(super) fn operation_signature( Row | Column | Overlay => ( vec![Number, Number], Some(nominal("Block")?), + // 共享参数规则,但返回值必须保留具体容器名义类型;matches 只选择类型名,不执行排版。 nominal(if matches!(operation, Row) { + // 主轴水平排列的容器。 "Row" } else if matches!(operation, Overlay) { + // 多个孩子叠放在同一内容区域的容器。 "Overlay" } else { + // 此分支入口只有 Row/Column/Overlay,前两者排除后剩 Column。 "Column" })?, CallEffect::ReadOnly, ), + // 按钮接收初始文本,Page 无显式参数;两者都只创建值,不自动打开窗口。 Button => ( vec![TextType], None, @@ -176,6 +223,7 @@ pub(super) fn operation_signature( CallEffect::ReadOnly, ), Page => (vec![], None, nominal("Page")?, CallEffect::ReadOnly), + // 三维视口的宽高和 Scene3D 在调用边界检查;摄像机、场景元素等由场景模块继续验证。 Viewport3D => ( vec![Number, Number, nominal("Scene3D")?], None, @@ -184,12 +232,15 @@ pub(super) fn operation_signature( ), // 3D 构造器参数来自 Kind 元数据:Number/String 映成基础注解,其余名称从本 desktop 注册表查名义类型。 SceneObject(kind) => { + // 元数据与真实构造器共用一份参数定义,避免桥接手工重复参数表后与 3D 实现漂移。 let parameters = kind .parameters() .iter() .map(|name| match *name { + // 基础数值/字符串是语言原生注解,不需要到宿主类型字典查询。 "Number" => Ok(Number), "String" => Ok(TextType), + // Vec3/Geometry3D 等名称必须解析为本会话真实 Type;未知名让整个签名安装失败。 name => nominal(name), }) .collect::, VmError>>()?; @@ -197,14 +248,17 @@ pub(super) fn operation_signature( parameters, // 3D 集合尾参使用 Object 名义约束;各具体构造操作仍需在实现中检查允许的场景对象类别。 if kind.variadic() { + // 允许额外对象参数的场景组等构造器用 Object 作尾参上界。 Some(nominal("Object")?) } else { + // 固定参数构造器禁止多余实参,不能统一使用 Any 尾参放开上界。 None }, nominal(kind.name())?, CallEffect::ReadOnly, ) } + // 二维场景可接受零到多个 Block;场景视口固定接宽、高、Scene2D,不把孩子直接当场景。 Scene2D => ( vec![], Some(nominal("Block")?), @@ -217,12 +271,14 @@ pub(super) fn operation_signature( nominal("CanvasViewport")?, CallEffect::ReadOnly, ), + // x/y/zoom 都声明为 Number,但可缺省;缩放范围等数值约束由 new_camera2d 校验。 Camera2D => ( vec![Number, Number, Number], None, nominal("Camera2D")?, CallEffect::ReadOnly, ), + // 实例方法专用 Operation 不在自由函数表内,误用在这里应立即暴露安装错误。 _ => return Err(invalid("unsupported UI function signature")), }; // 最终 arity 直接沿用 Operation,参数注解与可变尾参分开保存;不在此处执行操作或分配业务对象。 diff --git a/src/bridge/ui_bridge/start_page.endor b/src/bridge/ui_bridge/start_page.endor index 73eb030..e80700b 100644 --- a/src/bridge/ui_bridge/start_page.endor +++ b/src/bridge/ui_bridge/start_page.endor @@ -1,14 +1,18 @@ // The page, cards and actions belong to Endor. The host only owns its sidebar. // 默认页由真实 Endor 对象组成:宿主负责窗口和侧栏,卡片布局、绘制和按钮回调在源代码中定义。 +// developer_pages::install_start_page 在每个 VM 安装此模块;OpenTool 的 New tab 动作调用下面的 newTab。 +// 渲染器通过视觉协议调用 measure/paint,按钮事件通过 VM 分派到 StartActions 的绑定方法。 // Note 复用 Column 的内容测量,但固定卡片宽度;paint 只提供背景,孩子仍沿正常树绘制。 class Note: Column { // 卡片构造的最后一个子类字段保存背景色;前面的变参仍按 Column 的孩子参数处理。 tint: String + // source_layout 调用这个覆盖方法;显式借用 Column 的测量结果,只替换卡片宽度,保留内容自然高度。 function measure(self: Note, mount: Mount, constraints: Constraints): MeasureResult { // 沿用 Column 内容测量得到高度,但卡片宽固定为 224;不把背景圆角绘制误当布局尺寸。 measured = Column.measure(self, mount, constraints) return MeasureResult(224, measured.height) } + // source_paint 在已完成几何上调用;这里返回绘制命令,未直接向 OS 或像素缓冲写入。 function paint(self: Note, mount: Mount, geometry: Bounds): DisplayList { // 只生成当前卡片背景,命令使用宿主给定 Bounds 的绝对布局位置,孩子仍由各自 paint 绘制。 fill = PaintCommand() @@ -36,25 +40,35 @@ class StartActions { home: Page // 窗口页面是普通有限 Column;可用区域来自宿主发布的逻辑视口。 function blank(self: StartActions, event: Event): Unit { + // “窗口页面”按钮的 click 回调创建独立 Page,标题和内容属于新页面,home 仍指向原画布。 page = Page() + // 标题供 project_tabs 投影到原生侧栏,不影响页面身份。 page.title = "窗口页面" + // 返回按钮保留原 home 的绑定方法,点击时激活已有页而非新建一个画布。 back = Button("返回画布") // 绑定的是原 home 的 activate 方法;此回调恢复已有页身份,不重新构造主画布。 back.onClick(self.home.activate) + // render 设置内容并安排展示,Column 的 padding/gap 只影响这张页的有限布局。 page.render(Column(56, 20, TextBlock("恰好一个窗口", 3), TextBlock("页面跟随可用区域调整。内容和工具栏由 Endor 自己组织。"), back)) } // 延伸方向属于容器与滚动组合:Column 安排内容,有限 ScrollBlock 控制可见范围。 function vertical(self: StartActions, event: Event): Unit { + // “纵向延伸”按钮调用;保持 Page 默认有限布局,滚动行为由后面的 ScrollBlock 承担。 page = Page() + // 标签标题和其他示例区分,Page 本身不切换到旧式无限页面模式。 page.title = "纵向页面" + // 外 Column 累加卡片高度,内 Column 组织说明文字;自然内容高度可超过可见视口。 content = Column(48, 28, Column(0, 12, TextBlock("向下,继续生长", 3), TextBlock("纵向滚动和 Column 排版可以独立组合。"))) + // 每张 Note 的最后一参对应 tint 子类字段,前面的视觉值仍是 Column 的子项。 content.append(Note(24, 16, TextBlock("01 / 收集想法", 2), RectBlock(176, 130, 10, "#E2EADB"), TextBlock("从一个小小的念头开始。"), "#F0F3E9")) content.append(Note(24, 16, TextBlock("02 / 连接片段", 2), RectBlock(176, 130, 10, "#E6DBEF"), TextBlock("内容保留在同一条连续的页面里。"), "#F4EEF7")) content.append(Note(24, 16, TextBlock("03 / 慢慢展开", 2), RectBlock(176, 130, 10, "#EDDCC5"), TextBlock("追加一个子组件,内容自然延伸。"), "#FAF2E7")) content.append(Note(24, 16, TextBlock("04 / 留一点空白", 2), TextBlock("不必把所有想法都放在第一屏。"), "#EDF1F6")) // 固定初始视口内装完整内容;下一行限制滚动轴,flex 和页面 stretch 再让视口服从窗口分配。 view = ScrollBlock(1024, 800, content) + // 只消费纵向滚轮,横向内容不会因普通滚动意外平移。 view.axis = "vertical" + // flex 请求父布局分配剩余主轴空间,crossAlign 让有限视口横向填满页面。 view.flex = 1 page.crossAlign = "stretch" // 最后提交有限视口作为 Page 内容,触发展示请求;真正的测量、绘制与原生发布由宿主刷新流程完成。 @@ -62,18 +76,25 @@ class StartActions { } // Row 内可继续放 Column 卡片;横向滚动不要求改变 Page 的身份或生命周期。 function horizontal(self: StartActions, event: Event): Unit { + // “横向延伸”按钮调用;横向排列和可见区域分别交给 Row 与 ScrollBlock。 page = Page() + // 仅设置标签内容,实际页面资源仍在当前 VM 的 Window 注册表中登记。 page.title = "横向页面" + // Row 沿横轴连接 Note;各 Note 保留自己的纵向 Column 测量/背景绘制覆盖。 content = Row(48, 32, Note(24, 16, TextBlock("从左,走向右", 2), RectBlock(176, 180, 10, "#DDE8F1"), TextBlock("Row 内仍然可以包含 Column。"), "#EDF3F8")) + // append 按顺序创建稳定子 slot,横向坐标由布局累加,不需要手工填写 x/y。 content.append(Note(24, 16, TextBlock("收集", 2), RectBlock(176, 210, 10, "#D7E5DA"), TextBlock("一张卡片,一个想法。"), "#EFF4EC")) content.append(Note(24, 16, TextBlock("整理", 2), RectBlock(176, 170, 10, "#E5D9EB"), TextBlock("横向滚动,保留思考的顺序。"), "#F4EFF7")) content.append(Note(24, 16, TextBlock("继续", 2), RectBlock(176, 230, 10, "#EFDFC7"), TextBlock("Shift + 滚轮浏览更多内容。"), "#FAF3E8")) content.append(Note(24, 16, TextBlock("不止一屏", 2), TextBlock("右侧仍有空间。"), "#EEF1F5")) + // 这些尺寸是构造缺省值,flex/stretch 会让视口接受当前窗口的实际可用空间。 view = ScrollBlock(1024, 800, content) // Row 自然宽度可超出视口,只有横向滚动;卡片内部仍是 Column 的纵向排版。 view.axis = "horizontal" + // 保留横向滚动内容的自然宽度,视口本身仍参加有限页面的弹性布局。 view.flex = 1 page.crossAlign = "stretch" + // 只安排展示,不在 click 回调里递归运行 measure/paint;worker 在回调结束后刷新。 page.render(view) } // 重新调用工厂得到独立页面与相机,已有画布继续保留自己的视角。 @@ -85,8 +106,10 @@ class StartActions { function counter(self: StartActions, event: Event): Unit { // 共享的是同一个托管状态对象;textOf 两次读取该对象 value,increment 更新后两个展示一起刷新。 count = ui.state(0) + // 按钮保留 increment 的绑定 owner,同一状态可同时驱动两个 textOf 派生视觉对象。 add = Button("增加计数") add.onClick(count.increment) + // 创建承载两个观察视图的新页面,状态对象不会因函数返回立即消失:绑定/回调仍持有它。 page = Page() page.title = "共享对象" page.render(Column(48, 24, TextBlock("一个对象,两个视图", 2), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add)) @@ -94,14 +117,21 @@ class StartActions { } // 页面工厂:先建立业务对象/动作,再建立世界组成,最后用一个有限视口显示它。 function newTab(): Page { + // OpenTool 的 New tab 与 StartActions.canvas 都调用这个工厂;每次都创建不同页面身份。 page = Page() + // 后续标签投影读此字段,不使用标题作为查找或去重依据。 page.title = "自由画布" // 先建立引用当前 Page 的动作对象,再绑定按钮;回调中的 home 指向这一轮工厂创建的页面。 actions = StartActions(page) + // 每个按钮只保存动作入口,点击前不提前创建示例 Page。 fixed = Button("窗口页面") + // 绑定 vertical 回调后才会创建纵向滚动内容。 vertical = Button("纵向延伸") + // 横向示例和纵向示例各有独立 Page/ScrollBlock,互不共享滚动偏移。 horizontal = Button("横向延伸") + // 新画布动作再调用工厂,使相机导航状态与此画布隔离。 canvas = Button("新的画布") + // QuietButton 只隐藏原生按钮背景,其 click 事件和孩子文字仍沿普通 Button 协议工作。 shared = QuietButton("试试:一个对象,两个视图 ↗") // 订阅绑定方法而非提前调用;事件触发后在原 VM 中创建/展示相应示例页。 fixed.on("click", actions.blank) @@ -109,37 +139,45 @@ function newTab(): Page { horizontal.on("click", actions.horizontal) canvas.on("click", actions.canvas) shared.on("click", actions.counter) + // hero 内部用有限行列排版,只有整个 hero 在世界场景中的摆放采用显式 x/y。 hero = Column(0, 16, TextBlock("给想法,一点空间。", 3, "#303941"), TextBlock("页面可以是一扇窗口,也可以一直延伸。", 1, "#7D858C"), Spacer(8), Row(0, 10, fixed, vertical), Row(0, 10, horizontal, canvas), shared) // x/y 属于 Scene2D 子组件的世界位置;卡片内部依旧由普通 Row/Column 排版。 hero.x = 316 hero.y = 254 // Scene2D 只规定孩子的世界位置;加入后仍可追加普通卡片,位置不受窗口宽高限制。 world = Scene2D(hero) + // 左侧卡片由 Note 测高并绘制 tint 背景,文字和矩形作为正常孩子分别绘制。 left = Note(24, 14, TextBlock("随手记下", 2), RectBlock(176, 100, 10, "#D5E4DA"), TextBlock("想法不必排成一条直线。"), "#EDF3E9") left.x = 36 left.y = 92 // 将卡片以稳定 slot 加入场景;其 x/y 在前面单独设置,不依赖追加次序推算位置。 world.append(left) + // 顶部卡片只有文本,Note 仍复用内容测量,不需要为没有矩形的情况增加特殊宿主代码。 top = Note(24, 14, TextBlock("四种页面,同一个世界"), TextBlock("窗口 · 纵向 · 横向 · 自由画布", 1, "#7D858C"), "#EFF1F7") top.x = 466 top.y = 58 world.append(top) + // 右侧卡片展示“组件布局”和“相机视角”分开:移动相机不改写这个对象的世界坐标。 right = Note(24, 14, RectBlock(176, 138, 12, "#DDDAEA"), TextBlock("把片段放在一起", 2), TextBlock("布局属于组件,视角属于摄像机。"), "#F1EDF5") right.x = 826 right.y = 190 world.append(right) + // 底部说明中的换行属于源文本内容,不能把它误当实际控制输入或页面模式配置。 bottom = Note(24, 14, TextBlock("不止眼前这一屏", 2), TextBlock("触控板或滚轮平移\n空格拖动 / 鼠标中键拖动\n双指捏合 / ⌘ Ctrl + 滚轮缩放"), "#F7F0E5") bottom.x = 104 bottom.y = 524 world.append(bottom) + // 场景边缘再放一张普通 Note,展示超出初始屏幕范围的内容仍可通过相机漫游到达。 end = Note(24, 14, TextBlock("继续向外探索", 2), RectBlock(176, 100, 10, "#CEDFE7"), TextBlock("画布上的对象仍是普通 Endor 组件。"), "#EAF1F5") end.x = 716 end.y = 614 world.append(end) // 视口参加有限布局并占用剩余空间;移动它的 Camera2D 不改写卡片世界位置。 view = CanvasViewport(1024, 800, world) + // 有限视口申请剩余空间,页面交叉轴拉伸,不把 Scene2D 改造成窗口尺寸的有限容器。 view.flex = 1 page.crossAlign = "stretch" page.render(view) + // render 已登记/安排展示,返回 Page 给 OpenTool/调用者;返回值不是 GUI 线程的原生窗口对象。 return page } diff --git a/src/bridge/ui_bridge/tree.rs b/src/bridge/ui_bridge/tree.rs index e930762..3ece657 100644 --- a/src/bridge/ui_bridge/tree.rs +++ b/src/bridge/ui_bridge/tree.rs @@ -1,7 +1,9 @@ //! 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。 //! 节点保存排版、绘制和协议投影信息;浅比较用于决定是否复用已有快照。 //! -//! Owned immutable visual derivation; no managed VM handles. +//! model::snapshot/Block 快照提取构造 UiNode;render::layout/source_layout、绘制和命中流程读取它。 +//! 这里不持有 ObjectRef,因此 Arc 树可作为纯数据派生结果跨线程传递,不授权另一线程访问 VM。 +//! shallow_eq 由快照缓存判断能否复用旧 Arc;children/has_flex/allocates_children 供结构遍历和布局选择使用。 use std::sync::Arc; #[derive(Debug, Clone, PartialEq)] @@ -9,33 +11,46 @@ use std::sync::Arc; pub struct UiNode { // 只有页面根带兼容 Page 相机;其余字段是普通有限盒子的布局策略。 pub surface: Option, + // 兼容 flex 权重,父布局据此分配剩余主轴空间;不表示节点自身已经测量出的尺寸。 pub flex: f32, + // 显式 grow/shrink 等分配策略;与 flex 数值一起参与缓存相等性和父级分配判断。 pub flex_policy: crate::layout::FlexPolicy, + // 父容器主轴空白的对齐策略,与孩子排列次序和具体尺寸计算分开。 pub main_align: crate::layout::MainAlign, + // 容器为未指定 align_self 的孩子提供的交叉轴缺省策略。 pub cross_align: crate::layout::CrossAlign, + // 孩子对父 cross_align 的单独覆盖;None 表示继承,不能解释为禁止 stretch。 pub align_self: Option, + // 父布局分配时需要预留的外边距,不作为孩子内容绘制的本地原点偏移。 pub margin: crate::layout::Insets, + // 两轴的自然/固定/填充尺寸规则,由盒模型参与测量和最终安排。 pub sizing: crate::box_model::Sizing, // 单边可选覆盖值;None 表示沿用 kind 的统一 padding,不代表边距为零。 pub padding_edges: [Option; 4], pub border: crate::layout::Insets, + // 留原始颜色字符串供显示列表解码;颜色变更也必须使 shallow_eq 为 false。 pub border_color: String, + // 是否把后代限制在内容可视区域内,绘制/命中需要采用同一剪裁关系。 pub clip_children: bool, // 这些字符串只是派生身份/协议版本键,不是可以操作托管堆的 ObjectRef。 pub block_id: String, + // 源视觉协议相关的派生版本键;即使盒模型未变,协议变化仍可能要求重新测量或绘制。 pub protocol_key: String, // slot 保持组成身份;数组次序确定当前绘制/布局顺序,二者不是同一概念。 pub slots: Vec, // 从按钮、点击连接或自定义事件等能力推导的交互资格,供命中宽阶段使用,不代表最终 hitTest 必然成功。 pub interactive: bool, + // 标识原始 pointer 事件能力,与高层 click 的可交互标记分别参与输入分派。 pub pointer_events: bool, + // 具体叶/容器/场景内容,原生测量与软件绘制按此枚举选择规则。 pub kind: UiKind, } impl UiNode { - /// 按节点种类返回其视觉子项;叶节点使用空切片,避免为遍历另建组成。 + /// 布局、快照和命中结构遍历调用,返回已有孩子切片,不分配新列表或递归复制子树。 pub fn children(&self) -> &[Arc] { match &self.kind { + // 这些种类都显式包含 children;三维面板 UI 也复用普通 UiNode 子树遍历。 UiKind::Column { children, .. } | UiKind::Overlay { children, .. } | UiKind::Canvas { children, .. } @@ -45,11 +60,12 @@ impl UiNode { | UiKind::Row { children, .. } | UiKind::Button { children, .. } | UiKind::Scroll { children, .. } => children, + // Text/Rect/Spacer 没有视觉子树;返回静态空切片,调用方无需按种类再分支。 _ => &[], } } - /// 判断是否需要对子项执行弹性主轴分配。 + /// 父布局/allocates_children 查询此孩子是否需要分配空间;判断的是节点需求,不会在此实际改尺寸。 pub fn has_flex(&self) -> bool { // 不仅旧 flex 数值,非默认 FlexPolicy 或任一轴 Fill 也要求父节点执行分配,不能仅按自然尺寸排布。 self.flex > 0.0 @@ -58,17 +74,18 @@ impl UiNode { || self.sizing.height == crate::box_model::Dimension::Fill } - /// 判断当前节点是否会为子项指定实际分配范围,供源布局选择安排路径。 + /// source_layout 选择安排路径时调用,检查任一孩子的弹性需求或交叉轴拉伸是否要求明确分配。 pub fn allocates_children(&self) -> bool { // 任何孩子需要弹性或拉伸即进入分配路径;align_self 优先于父 cross_align。 self.children().iter().any(|child| { + // any 短路即可确定是否需要分配;align_self 优先级高于容器 cross_align。 child.has_flex() || child.align_self.unwrap_or(self.cross_align) == crate::layout::CrossAlign::Stretch }) } - /// 比较节点自身配置和直接子项身份,决定能否复用旧 Arc;不是深拷贝业务图。 + /// model::snapshot 缓存重建节点后调用,判断可否保留旧 Arc;返回 true 不会修改任何节点或 VM 对象。 pub fn shallow_eq(&self, other: &Self) -> bool { // 先比较所有公共视觉/协议配置,任一变化都不能复用旧节点 Arc,即使子内容完全相同。 if self.surface != other.surface @@ -89,6 +106,7 @@ impl UiNode { || self.interactive != other.interactive || self.pointer_events != other.pointer_events { + // 共有字段任一不同都足以否决复用,不再比较 kind 或递归访问后代。 return false; } // 直接孩子按 Arc 指针比较而不是递归深比较;依赖提取保证未变子树复用 Arc,父级只需验证其身份。 @@ -102,6 +120,7 @@ impl UiNode { }; // 同类变体再比较其专有数据和直接子项;变体不同必为 false,避免布局种类切换后误复用缓存。 match (&self.kind, &other.kind) { + // a/b 与 d/e 是尺寸,c/f 是场景视图,g/h 是控制开关,i/j 是面板尺寸;孩子还需同身份。 ( UiKind::Viewport3D { width: a, @@ -120,6 +139,7 @@ impl UiNode { .. }, ) => a == d && b == e && c == f && g == h && i == j && same_children(), + // 二维视口除了宽高还比较相机快照;相机变了即使场景孩子没变也不能复用旧节点。 ( UiKind::CanvasViewport { width: a, @@ -135,14 +155,18 @@ impl UiNode { }, ) => a == d && b == e && c == f && same_children(), (UiKind::Scene2D { positions: a, .. }, UiKind::Scene2D { positions: b, .. }) => { + // 显式世界位置和孩子身份一起决定组成,不能因同一批对象只移动位置而漏掉刷新。 a == b && same_children() } (UiKind::Overlay { padding: a, .. }, UiKind::Overlay { padding: b, .. }) => { + // Overlay 没有 gap/累计主轴位置,专有属性只比 padding,再检查孩子序列。 a == b && same_children() } (UiKind::Canvas { positions: a, .. }, UiKind::Canvas { positions: b, .. }) => { + // 兼容 Page Canvas 与 Scene2D 相同地保留显式位置,但仍要求两边变体相同。 a == b && same_children() } + // 文本叶没有孩子,内容、字号和颜色相等即可;共有字段已在上面验证。 ( UiKind::Text { value: a, @@ -155,6 +179,7 @@ impl UiNode { color: cb, }, ) => a == b && sa == sb && ca == cb, + // 矩形依次比较宽、高、圆角和颜色,不以外框尺寸相同掩盖绘制变化。 ( UiKind::Rect { width: a, @@ -169,7 +194,9 @@ impl UiNode { color: h, }, ) => (a, b, c, d) == (e, f, g, h), + // Spacer 唯一专有属性是占位长度。 (UiKind::Spacer(a), UiKind::Spacer(b)) => a == b, + // 滚动偏移属于 Mount,此处只比轴/有限尺寸/内容规则及孩子,避免共享节点携带单次出现偏移。 ( UiKind::Scroll { axis: aa, @@ -188,6 +215,7 @@ impl UiNode { children: _, }, ) => aa == ab && a == c && b == d && pa == pb && ga == gb && same_children(), + // 这三种容器共用 padding/gap 比较,但模式配对仍只允许 Column 对 Column 等同种比较。 ( UiKind::Column { padding: a, gap: b, .. @@ -212,6 +240,7 @@ impl UiNode { padding: c, gap: d, .. }, ) => a == c && b == d && same_children(), + // 任何不同变体都不能复用,哪怕专有数据恰巧具有相同数值。 _ => false, } } @@ -222,27 +251,36 @@ impl UiNode { pub enum UiKind { // 有限 3D 视口包含不可变场景视图和面板 UI 孩子;panels 与 children/slots 按顺序对应。 Viewport3D { + // 有限布局盒子尺寸,不是三维世界的几何范围。 width: f32, height: f32, + // 已提取的场景、相机和投影数据,render 不需要为每个像素回读 VM。 view: Arc, + // 是否存在可用控制器,输入宽阶段据此判断相机手势能力。 controls: bool, + // 各世界 UI 面板的逻辑尺寸,与 children 次序对应,供投影与布局共用。 panels: Vec, + // 面板 UI 的普通二维树;业务对象身份仍由 UiNode.block_id/slots 描述。 children: Vec>, }, // 二维世界组成保留孩子的显式位置,不使用行列的累计偏移定位。 Scene2D { + // 每个孩子的世界 x/y,数组下标与 children 对齐,不按叠放顺序推算位置。 positions: Vec<[f32; 2]>, children: Vec>, }, // 有限布局盒子观察独立 Scene2D,相机仅影响投影,不改变场景节点原始逻辑位置。 CanvasViewport { + // 参加父布局的有限盒子尺寸;不是 Scene2D 孩子的总包围盒。 width: f32, height: f32, + // 纯数据相机快照,只影响本视口的世界到屏幕映射,不改写原世界树。 camera: crate::viewport::CameraView, children: Vec>, }, // 孩子重叠布置,顺序同时决定叠放次序;普通有限布局与世界 Canvas 不同。 Overlay { + // 统一内边距,单边覆盖仍由 UiNode.padding_edges 提供。 padding: f32, children: Vec>, }, @@ -259,16 +297,21 @@ pub enum UiKind { }, // 文字叶节点保留源字号等级与颜色文本;排版及最终设备比例由后续布局/显示列表转换处理。 Text { + // 已读取的文本内容,字体测量与软件绘制使用同一字符串。 value: String, + // 宿主字号等级,实际字体像素大小由 text/布局映射规则确定。 size: u32, + // 源颜色格式已在提取路径验证,绘制阶段再转成原生颜色值。 color: String, }, + // 纯绘制叶节点;几何尺寸与圆角供布局/显示列表共用,不含托管对象句柄。 Rect { width: f32, height: f32, radius: f32, color: String, }, + // Column 沿纵轴累加孩子位置,Row 沿横轴;二者都保存统一内边距和孩子间距。 Column { padding: f32, gap: f32, @@ -283,6 +326,7 @@ pub enum UiKind { Spacer(f32), // 滚动盒保存有限分配尺寸、允许轴和普通内容;实际偏移按 Mount 出现分别维护,不放到共享节点里。 Scroll { + // axis 指定允许消费的滚动方向;实际可消费距离还取决于具体 Mount 的内容/视口范围。 axis: crate::scroll::Axis, width: f32, height: f32, -- 2.52.0 From 21aa7b837b6f13c357be42939ae8f61a0b36abd7 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:53:32 +0800 Subject: [PATCH 02/78] docs: explain graphics resources and runtime dispatch lifecycles --- src/bridge/graphics.rs | 186 +++++++++++++++++++++++++++++++++- src/runtime/endor_script.rs | 61 ++++++++--- src/runtime/event_dispatch.rs | 112 ++++++++++++++++---- src/runtime/events.rs | 103 ++++++++++++++++--- src/runtime/io_tasks.rs | 66 +++++++++--- src/runtime/vm_sequence.rs | 55 +++++++--- 6 files changed, 507 insertions(+), 76 deletions(-) diff --git a/src/bridge/graphics.rs b/src/bridge/graphics.rs index 35ed907..abbcad7 100644 --- a/src/bridge/graphics.rs +++ b/src/bridge/graphics.rs @@ -1,5 +1,8 @@ -//! General retained graphics resources for source paint: images, fonts, prepared -//! text, geometric exclusions and numerical functions. No application behaviour. +//! 通用保留式图形资源桥接:图片、字体、预排文字、绕排避让形状和数值工具。 +//! ui_bridge::bootstrap 调用 install,把原生 callable 挂到 ui.graphics 与 ui.graphics.math。 +//! Endor 源程序调用这些函数创建模型资源/绘图命令;source_paint 解码图片命令时调用 decode_image。 +//! PointerDispatch 由 UiBridge::dispatch 包装成临时 callable,用同一 VM 分发原始指针事件。 +//! 这里不定义应用剧情或按钮业务。像素资源经 Arc 共享,托管资源身份与引用边仍由 Aster 管理。 use crate::{ document::{RenderCommand, parse_color}, geometry::{Point, Rect}, @@ -14,26 +17,34 @@ use endor_core::{ ObjectStore, Value, Vm, VmError, }; use std::{any::Any, sync::Arc}; +/// 本文件的解码和分派分支统一构造 VM 参数错误,让调用栈继续提供源代码诊断位置。 fn invalid(message: &'static str) -> VmError { VmError::InvalidArgument(message) } +/// 图片/字体/避让对象参数的解码入口,只复制对象句柄;payload 种类由各调用点进一步检查。 fn object(v: &Value) -> Result { + // 不从 nil、字符串或数字猜测资源 ID,避免把源值误指到 VM 堆中任意对象。 if let Value::Object(o) = v { Ok(*o) } else { Err(invalid("expected Object")) } } +/// 图形几何参数调用,先用 Endor 数字转换再限制绝对值,最后降为渲染器使用的 f32。 fn number(v: &Value) -> Result { + // 原生签名没有逐参数类型声明,因此 invoke 内仍必须检查值的实际表示。 let Value::Number(v) = v else { return Err(invalid("expected Number")); }; + // to_f64 传播无法有限转换等错误;通用坐标允许负数,宽高/半径的非负约束由具体操作检查。 let n = v.to_f64()?; if n.abs() > 1_000_000. { + // 在转换 f32 和进入排版/光栅化前限制坐标预算,拒绝极端图形数值。 return Err(invalid("graphics number exceeds bounds")); } Ok(n as f32) } +/// 文件路径、字体名和源文本参数调用,借用真实字符串而不对任意对象隐式执行 repr。 fn text(v: &Value) -> Result<&str, VmError> { if let Value::Text(s) = v { Ok(s) @@ -41,37 +52,52 @@ fn text(v: &Value) -> Result<&str, VmError> { Err(invalid("expected String")) } } +/// command/flow/指针事件把原生几何返回语言时调用,统一把 f32 提升为 Endor Number。 fn n(v: f32) -> Value { Value::Number((v as f64).into()) } #[derive(Debug)] +/// LoadImage/flow 产生的托管资源 payload;Arc 像素可供发布命令持有,不要求 GUI 线程读取 VM。 pub(crate) struct ImageResource(pub Arc); #[derive(Debug)] +/// LoadFont 创建、Prepare 读取的字体族句柄,字体注册/塑形细节由 render::text 负责。 struct FontResource(TextFamily); #[derive(Debug)] +/// Prepare 生成、Flow 消费的字词/字母预排结果,昂贵的塑形可在多次绕排中复用。 struct TextResource { + // 图形流转中仅复制 Arc,不为每一行重复塑形和生成字形图片。 prepared: Arc, + // 每次 Flow 的行带高度,ranges 用它判断避让形状与这一行的相交范围。 line: f32, } #[derive(Debug)] +/// Circle/Rectangle/ImageExclusion 产生,Flow/ranges 读取的纯原生避让形状,不保存 VM 子对象引用。 struct Exclusion { + // 圆用圆心/半径描述;多边形资源保持 None,避免两种形状同时隐式叠加。 circle: Option<(Point, f32)>, + // 图片轮廓或矩形顶点,已经变换到绕排使用的世界/页面逻辑坐标。 polygon: Vec, + // 在多边形行带与横向阻塞范围外再扩展的避让距离。 padding: f32, } +// 四类资源复用 ObjectPayload 适配;没有托管引用字段,因此不需要额外 trace 子对象。 macro_rules! resource { ($t:ty,$name:literal) => { impl ObjectPayload for $t { + // VM 的 payload:: 通过 Any 向下转型,防止把普通 Object 当图形资源读取。 fn as_any(&self) -> &dyn Any { self } fn kind_name(&self) -> &'static str { + // 稳定种类名供类型错误/诊断显示,不代替 Endor 名义 Type。 $name } fn allows_attributes(&self) -> bool { + // 资源宽高等元信息作为普通托管属性暴露,仍经 store 的属性访问规则管理。 true } fn reference_assignment_only(&self) -> bool { + // 赋值共享同一资源对象,不能按值复制资源 payload 后失去既有身份。 true } } @@ -81,21 +107,27 @@ resource!(ImageResource, "image-resource"); resource!(FontResource, "font-resource"); resource!(TextResource, "prepared-text"); resource!(Exclusion, "layout-exclusion"); +/// Function/flow 分配原生资源后调用,附当前宿主域的 Object 名义类型,不创建新的资源专属类。 fn attach(cx: &mut NativeContext<'_>, o: ObjectRef, desktop: ObjectRef) -> Result<(), VmError> { crate::ui_bridge::attach_desktop_type(cx, o, "Object", desktop) } +/// LoadImage/LoadFont/ReadText 的同步文件读取助手;调用者先拒绝布局/绘制只读阶段的 I/O。 fn file(path: &str, limit: u64) -> Result, VmError> { use std::io::Read; if path.len() > 8192 { + // 路径本身也有限长,不能只限制最终文件大小而接受无限字符串操作。 return Err(invalid("resource path too long")); } + // 文件句柄限定在本调用作用域,任何 ? 返回都会由 Rust 关闭,不放进托管图形资源。 let f = std::fs::File::open(path).map_err(|_| invalid("resource file cannot be opened"))?; + // 只接收有界普通文件,拒绝目录/特殊设备等无法靠 metadata 给出可靠预算的输入。 let meta = f .metadata() .map_err(|_| invalid("resource metadata unavailable"))?; if !meta.is_file() || meta.len() > limit { return Err(invalid("resource must be a bounded regular file")); } + // metadata 检查后文件仍可能增长,所以实际读取最多 limit+1 字节并再次检查。 let mut data = Vec::new(); f.take(limit + 1) .read_to_end(&mut data) @@ -105,20 +137,26 @@ fn file(path: &str, limit: u64) -> Result, VmError> { } Ok(data) } +/// decode_image 读取 PaintCommand 的数值属性时调用,缺字段/错误类型直接传播,不自行补缺省。 fn field(s: &ObjectStore, o: ObjectRef, key: &str) -> Result { number(&s.get_attr(o, key)?) } +/// source_paint 的 image 命令分支及 Sprite/ImageExclusion 调用,验证托管命令并提取纯数据图片绘制命令。 pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result { + // 命令引用真实 ImageResource,不能只靠同名 width/height 属性冒充有效图片。 let resource = object(&s.get_attr(c, "image")?)?; + // 克隆像素 Arc,使发布结果拥有资源,不再依赖命令/资源 ObjectRef 的 VM 生命周期。 let image = s .payload::(resource, "ImageResource")? .0 .clone(); + // tint 可缺失/nil;显式错误颜色和其他属性读取失败仍报错,不静默退回无着色。 let tint = match s.get_attr(c, "tint") { Ok(Value::Text(v)) => Some(parse_color(&v).map_err(|_| invalid("invalid image tint"))?), Ok(Value::Nil) | Err(VmError::AttributeNotFound(_)) => None, _ => return Err(invalid("invalid image tint")), }; + // 同名局部 c 从对象身份变为原生命令;所有几何字段均经受控 number 解码。 let c = RenderCommand::Image { x: field(s, c, "x")?, y: field(s, c, "y")?, @@ -131,10 +169,12 @@ pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result, desktop: ObjectRef, @@ -144,6 +184,7 @@ fn command( angle: f32, opacity: f32, ) -> Result { + // origin 是旋转定位点,rect.x/y 是相对该原点的局部图像偏移,不与绝对 x/y 混用。 let c = crate::native_visual::record( cx, desktop, @@ -164,6 +205,7 @@ fn command( Ok(c) } #[derive(Debug, Clone, Copy)] +/// install 的名称表选择实际操作;arity/invoke 同用此枚举,不依赖函数显示名称猜业务分支。 enum Op { LoadImage, LoadFont, @@ -189,34 +231,47 @@ enum Op { Clamp, } #[derive(Debug)] +/// install 分配到 VM 的 callable payload,供源代码经 ui.graphics/math 调用。 struct Function { + // 具体操作决定数量与实现,图形和数值名字可以挂在不同命名空间中共享分派器。 op: Op, + // 资源/命令的名义类型来自该注册域;captures 同时持有对应托管强边。 desktop: ObjectRef, } impl NativeCallable for Function { + /// VM 在进入 invoke 前查询精确参数数目,下面按位置索引 args 依赖此调用边界检查。 fn arity(&self) -> Option { use Op::*; Some(endor_core::CallArity::exact(match self.op { + // 时钟无参数;资源路径和一元数学操作各一参,类型/范围在具体分支校验。 Now => 0, LoadImage | ReadText | Float | Sin | Cos | Sqrt | Abs | Stringify | Floor => 1, + // 二参分别是路径+字体族、两个操作数,或图片命令+避让边距。 LoadFont | Atan2 | Min | Max | ImageExclusion => 2, + // 圆/矩形几何、数值上下界和文本切片都有固定数量,不接任意变参。 Circle | Clamp | Slice => 3, Rectangle => 4, + // Prepare 接文本/字体/字号/行高/颜色/比例;Flow/Sprite 再按各自几何接口解释位置参数。 Prepare => 6, Flow => 7, Sprite => 9, })) } + /// VM 执行注册 callable 时调用;同步资源加载拒绝只读阶段,纯资源派生/数学可供源 paint 使用。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { use Op::*; + // 复制所属类型域身份,后面分配资源和协议值都复用这一个注册表。 let desktop = self.desktop; + // 各分支产出语言值,除提前返回数学分支外统一包成 NativeResult::Return。 let result = match self.op { Now => { if cx.store().is_read_only() { + // 时钟变化不能成为只读绘制中的隐式输入,源程序应在事件/任务里采样并存进模型。 return Err(invalid( "sample the clock in an event/task and store it in the model", )); } + // 进程级首次调用确定单调起点,返回的是相对秒数,不是 UTC 时间戳或每 VM 独立时钟。 static START: std::sync::OnceLock = std::sync::OnceLock::new(); Value::Number( START @@ -228,11 +283,15 @@ impl NativeCallable for Function { } LoadImage => { if cx.store().is_read_only() { + // 源布局/绘制可复用已加载图片,但不允许在只读阶段重新执行文件 I/O。 return Err(invalid("load resources outside paint/layout")); } + // 先限制输入文件 4 MiB,再由 PNG 解码器校验格式和解码图像预算。 let image = Image::png(&file(text(&args[0])?, 4_194_304)?) .map_err(|_| invalid("invalid or oversized PNG"))?; + // 宽高取自解码结果,而非源程序可任意伪造的资源属性。 let (w, h) = (image.width, image.height); + // 托管外壳提供稳定身份,像素仍由 Arc 共享给后续绘图命令。 let resource = cx.allocate(ImageResource(image))?; attach(cx, resource, desktop)?; cx.set_attr(resource, "width", n(w as f32))?; @@ -243,17 +302,21 @@ impl NativeCallable for Function { if cx.store().is_read_only() { return Err(invalid("load resources outside paint/layout")); } + // 显式字体族名用于注册与后续塑形查找,不把文件名直接当字体族名。 let family = text(&args[1])?; if family.len() > 128 { return Err(invalid("font family name too long")); } + // 文件读取预算与字体注册表预算分别检查;错误不返回一个看似有效但会回退的字体资源。 let font = crate::text::register_font(file(text(&args[0])?, 4_194_304)?, family) .map_err(|_| invalid("invalid font family or registry budget"))?; + // FontResource 只保存验证后的族句柄,源代码不能直接改写全局字体数据库。 let resource = cx.allocate(FontResource(font))?; attach(cx, resource, desktop)?; Value::Object(resource) } ReadText => { + // 同步读取小型资源文本供模型初始化使用;后台异步读取仍走 runtime/io_tasks 的接口。 if cx.store().is_read_only() { return Err(invalid("load resources outside paint/layout")); } @@ -263,22 +326,28 @@ impl NativeCallable for Function { ) } Slice => { + // 源文本切片按 Unicode 标量索引而非 UTF-8 字节下标;先保留原文借用再转换边界。 let value = text(&args[0])?; + // number 的通用预算之外,这两个索引还必须为非负、递增的整数值。 let start = number(&args[1])?; + // 结束位置为半开区间上界,不包含该索引上的字符。 let end = number(&args[2])?; if start < 0. || end < start || start.fract() != 0. || end.fract() != 0. { return Err(invalid("invalid text slice")); } + // char_indices 把字符序号换回合法字节边界;超出文本时夹到末尾,不切开多字节字符。 let lo = value .char_indices() .nth(start as usize) .map_or(value.len(), |(i, _)| i); + // 与 lo 同一转换规则保证切片边界总在原字符串范围内。 let hi = value .char_indices() .nth(end as usize) .map_or(value.len(), |(i, _)| i); Value::Text(value[lo..hi].into()) } + // 仅格式化标量,不对任意对象调用 repr,避免资源/图形计算触发未知对象逻辑。 Stringify => Value::Text(match &args[0] { Value::Text(s) => s.clone(), Value::Number(v) => v.to_string(), @@ -287,16 +356,21 @@ impl NativeCallable for Function { _ => return Err(invalid("string expects scalar value")), }), Prepare => { + // 读取源文本并按 UTF-8 字节预算限制一次塑形的内容量。 let value = text(&args[0])?; if value.len() > 16_384 { return Err(invalid("prepared text exceeds 16 KiB")); } + // 必须是 loadFont 返回的原生 FontResource,普通对象的同名字体属性不能冒充资源。 let family = cx .store() .payload::(object(&args[1])?, "FontResource")? .0; + // size 是逻辑字体尺寸,line 是逻辑行带高度,scale 只控制字形预绘制清晰度/资源量。 let size = number(&args[2])?; + // 行高不得小于字号,避免绕排占位与字形高度约定完全失配。 let line = number(&args[3])?; + // 设备比例有独立预算,不能通过极大 scale 把短文本变成巨量像素资源。 let scale = number(&args[5])?; if !(8. ..=96.).contains(&size) || !(size..=192.).contains(&line) @@ -304,8 +378,10 @@ impl NativeCallable for Function { { return Err(invalid("invalid text metrics")); } + // 颜色预先烘入字形图片,后续 flow 不为每个词重复解析/绘制文字。 let color = parse_color(text(&args[4])?).map_err(|_| invalid("invalid text color"))?; + // 交给 text_flow 完成词/字母拆分、塑形及像素墨迹提取,这里只描述样式与预算。 let prepared = Prepared::new( value, TextStyle { @@ -316,7 +392,9 @@ impl NativeCallable for Function { scale, color, ); + // text_flow 的静态错误文本转成 VM 参数错误,源调用得到当前调用栈上下文。 let prepared = prepared.map_err(invalid)?; + // Arc 让多次 flow 复用同一预排结果,line 单独保存供每行障碍相交测试使用。 let resource = cx.allocate(TextResource { prepared: Arc::new(prepared), line, @@ -325,17 +403,27 @@ impl NativeCallable for Function { Value::Object(resource) } Sprite => { + // Sprite 返回一条变换图片命令,不立即画到屏幕;原图对象必须带真实 ImageResource payload。 let image = object(&args[0])?; cx.store() .payload::(image, "ImageResource")?; + // x/y 是旋转定位原点,图像左上角还受 anchor 比例和尺寸影响。 let x = number(&args[1])?; + // 垂直定位与水平定位使用相同逻辑坐标空间。 let y = number(&args[2])?; + // 目标宽高允许缩放原图,但命令 validate 会拒绝非法几何范围。 let w = number(&args[3])?; + // 图片采样继续使用原像素尺寸,不把 h 当作纹理行数。 let h = number(&args[4])?; + // a 按渲染器约定为弧度,绕 origin 旋转局部矩形。 let a = number(&args[5])?; + // ax/ay 把图像内部相对锚点换成局部偏移,0 是边缘,0.5 是中心。 let ax = number(&args[6])?; + // 不在这里默认 clamp 锚点,源代码可以选择矩形之外的旋转中心。 let ay = number(&args[7])?; + // 透明度除通用有限值外还受 RenderCommand::validate 的 0..1 语义限制。 let opacity = number(&args[8])?; + // 本地矩形以负锚点偏移定位,确保绘制与 imageExclusion 共用同一几何变换。 let c = command( cx, desktop, @@ -345,10 +433,12 @@ impl NativeCallable for Function { a, opacity, )?; + // 立即复用实际 paint 解码器验证命令,不等到下一帧才暴露非法 sprite 几何。 decode_image(cx.store(), c)?; Value::Object(c) } ImageExclusion => { + // 从已验证图片命令拿同一套尺寸/偏移/角度,不用近似外接矩形替代透明轮廓。 let RenderCommand::Image { x, y, @@ -361,22 +451,29 @@ impl NativeCallable for Function { .. } = decode_image(cx.store(), object(&args[0])?)? else { + // decode_image 的唯一成功变体是 Image,其他变体到此说明内部接口约定被破坏。 unreachable!() }; + // 避让边距只扩展文字禁用区域,不改图片自身的绘制尺寸。 let padding = number(&args[1])?; if !(0. ..=1000.).contains(&padding) { return Err(invalid("invalid exclusion padding")); } + // 对全部轮廓顶点共用同一旋转三角值,保持与 sprite 绘制的角度一致。 let (sin, cos) = angle.sin_cos(); + // 原图像素轮廓按目标宽高缩放,再加 anchor 偏移、旋转、平移到绕排坐标。 let polygon = image .outline .iter() .map(|p| { + // 归一化原图 x 到目标图像宽度,并还原相对旋转中心的局部偏移。 let px = offset_x + p.x / image.width as f32 * width; + // y 方向使用原图高度归一化,非正方形缩放不能复用宽度比例。 let py = offset_y + p.y / image.height as f32 * height; Point::new(x + cos * px - sin * py, y + sin * px + cos * py) }) .collect(); + // 轮廓已经是普通 Point 数组,避让资源不再需要引用原图片对象。 let e = cx.allocate(Exclusion { circle: None, polygon, @@ -386,12 +483,16 @@ impl NativeCallable for Function { Value::Object(e) } Circle | Rectangle => { + // 圆的前两参是圆心,矩形的前两参是左上角;第三参分别为半径或宽度。 let x = number(&args[0])?; + // 坐标可负,几何 extent 的非负约束单独检查。 let y = number(&args[1])?; + // 共用 extent 检查,但不把圆半径误当直径或矩形面积。 let size = number(&args[2])?; if size < 0. { return Err(invalid("negative exclusion extent")); } + // 圆保留解析几何,矩形用闭合四点多边形,ranges 分别采用相应相交算法。 let e = if matches!(self.op, Circle) { Exclusion { circle: Some((Point::new(x, y), size)), @@ -399,6 +500,7 @@ impl NativeCallable for Function { padding: 0., } } else { + // 矩形独有第四参数为高度,零面积形状允许存在但不制造负方向面积。 let h = number(&args[3])?; if h < 0. { return Err(invalid("negative exclusion height")); @@ -414,28 +516,36 @@ impl NativeCallable for Function { padding: 0., } }; + // 把纯几何避让值包装成有稳定身份的 VM 对象,供 ObjectList 在 flow 时引用。 let e = cx.allocate(e)?; attach(cx, e, desktop)?; Value::Object(e) } + // 绕排工作集中在助手中,复用已预排字形与已验证避让资源,返回 DisplayList。 Flow => flow(cx, desktop, args)?, _ => { + // 数学分支用 f64,和受 1e6 坐标预算的 number 解码区分,允许数学中间量范围更大。 let real = |v: &Value| -> Result { if let Value::Number(n) = v { + // Endor 的整数/十进制/二进制数统一按显式浮点近似参与这些数学函数。 n.to_f64() } else { + // 不进行文本到数字或布尔到整数的隐式强制转换。 Err(invalid("math expects Number")) } }; + // 所有剩余操作至少有一个数字参数,数量已经由 arity 在 VM 调用边界验证。 let x = real(&args[0])?; if matches!(self.op, Floor) { if x.abs() > 1_000_000_000. { + // floor 返回整数,先限定转换预算,不能依靠 Rust 饱和转换掩盖超大数值。 return Err(invalid("floor result exceeds integer conversion budget")); } return Ok(NativeResult::Return(Value::Number( (x.floor() as i64).into(), ))); } + // 按操作计算一个结果,二/三元分支继续解析额外数字,不预读不需要的参数。 let v = match self.op { Float => x, Sin => x.sin(), @@ -446,16 +556,20 @@ impl NativeCallable for Function { Min => x.min(real(&args[1])?), Max => x.max(real(&args[1])?), Clamp => { + // 下界/上界顺序必须合法,否则 f64::clamp 会 panic,不能让源参数引发宿主崩溃。 let lo = real(&args[1])?; + // 上界是第三参数,不能交换两端后悄悄修正调用者的错误范围。 let hi = real(&args[2])?; if lo > hi { return Err(invalid("clamp minimum exceeds maximum")); } x.clamp(lo, hi) } + // 非数学操作已经在外层 match 命中,Floor 也已提前返回,因此不存在其他合法分支。 _ => unreachable!(), }; if !v.is_finite() { + // sqrt 负数等情况会生成 NaN,必须转成语言错误而非让非有限数流入下一轮图形几何。 return Err(invalid("non-finite math result")); } Value::Number(v.into()) @@ -465,97 +579,141 @@ impl NativeCallable for Function { } } +/// flow 为每个行带调用:合并避让形状的水平阻塞范围,返回按 x 递增且至少 8 单位宽的可排文字区间。 +/// 这是按整行带求保守横向范围,不是每个字形轮廓与多边形的精确碰撞检测。 fn ranges(left: f32, right: f32, y: f32, line: f32, exclusions: &[&Exclusion]) -> Vec<(f32, f32)> { + // 每个形状先贡献一个阻塞区间;最后统一排序/扫线,支持多个重叠障碍。 let mut blocked = Vec::new(); for e in exclusions { if let Some((p, r)) = e.circle { + // 行带包含圆心时 gap=0,否则为圆心到最近水平边的垂直距离。 let gap = (y - p.y).max(p.y - y - line).max(0.); if gap < r { + // 勾股关系求该行带可触及的最大半弦长,整段横向范围都留给圆形障碍。 let dx = (r * r - gap * gap).sqrt(); blocked.push((p.x - dx, p.x + dx)); } } + // 多边形先把行带上下扩 padding,随后还会把水平投影左右扩 padding。 let (top, bottom) = (y - e.padding, y + line + e.padding); + // 收集行带内的顶点以及边与上下边界交点,取最小/最大 x 形成保守阻塞范围。 let mut xs = Vec::new(); for i in 0..e.polygon.len() { + // 当前顶点和下一顶点构成一条边;取模闭合最后一条边,不要求调用者重复首顶点。 let a = e.polygon[i]; + // 空多边形不会进入循环,因此这里的取模不会对零求余。 let b = e.polygon[(i + 1) % e.polygon.len()]; if a.y >= top && a.y <= bottom { + // 行带覆盖的顶点直接参与水平包围,避免水平边/角点没有穿越交点时被漏掉。 xs.push(a.x); } for edge in [top, bottom] { if (a.y < edge && b.y > edge) || (b.y < edge && a.y > edge) { + // 只有真正跨过水平边界才插值,分母 b.y-a.y 在此条件下非零。 xs.push(a.x + (b.x - a.x) * (edge - a.y) / (b.y - a.y)); } } } if !xs.is_empty() { + // 凹多边形也取整体横向包围,宁可留更宽避让,不在行带内钻进不可靠的局部空洞。 blocked.push(( xs.iter().copied().fold(f32::INFINITY, f32::min) - e.padding, xs.iter().copied().fold(f32::NEG_INFINITY, f32::max) + e.padding, )); } } + // 先按左端排序,扫描游标便可同时处理重叠和包含关系,避免输出交叉的可用区间。 blocked.sort_by(|a, b| a.0.total_cmp(&b.0)); + // x 是尚未被处理阻塞覆盖的最左位置,始终夹在文字行的 left..right 范围内。 let mut x = left; + // 与函数同名的局部值是结果区间数组,后面 Prepared::row 按这个顺序放词。 let mut ranges = Vec::new(); for (lo, hi) in blocked { if hi <= left || lo >= right { + // 完全位于文本列外的形状不消耗可用宽度,也不移动扫描游标。 continue; } if lo > x { + // 当前阻塞前的空隙才是可用区间;右端不能超过列右边界。 ranges.push((x, lo.min(right))); } + // 重叠阻塞只推进到更右处,不因下一个区间的左端较小而倒退或重复输出空间。 x = x.max(hi).min(right); } if x < right { + // 所有阻塞处理后剩余的尾段仍可放文字,整行无障碍时这里返回完整列宽。 ranges.push((x, right)); } + // 极窄缝不进入字词放置,既降低工作量,也避免在几何边缘生成难以使用的片段。 ranges.into_iter().filter(|(l, r)| r - l >= 8.).collect() } +/// Function 的 Flow 分支调用,把预排文字放入有限矩形并避开障碍,生成包含图片字形的 DisplayList。 fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Result { + // 第一参必须是 Prepare 返回的资源;只靠对象上的同名 prepared 属性不能替代原生 payload。 let resource = object(&args[0])?; + // 借 payload 取资源元数据,随后复制 Arc/行高以结束不可变借用,允许下面继续分配 VM 对象。 let text = cx .store() .payload::(resource, "PreparedText")?; + // 只共享塑形与字形图片,不把每次 Flow 的游标写回资源,多个视图可独立排同一份文字。 let prepared = text.prepared.clone(); + // 行高在 Prepare 时已验证为正,下面 height/line 的行数预算因此有明确含义。 let line = text.line; + // x/y 是本次文字矩形的逻辑原点,与 Exclusion 中的点必须处于同一坐标空间。 let x = number(&args[1])?; + // y 也作为首行 top,不按字体 baseline 参数解释。 let y = number(&args[2])?; + // 列宽约束 ranges 的左右边界,不改变预排字形本身的宽度。 let width = number(&args[3])?; + // 矩形高度限定完整行带数量,行带放不下时停止而非溢出绘制区域。 let height = number(&args[4])?; + // 数字开关决定完整单词是否拆成独立字母图像命令;不是修改词/字母换行规则的开关。 let letters = number(&args[6])? != 0.; if width < 0. || height < 0. || height / line > 1024. { + // 限制布局循环最多 1024 行;可用区间全被遮挡时也不能无界向下寻找空间。 return Err(invalid("text flow bounds exceed budget")); } + // 障碍必须经宿主 ObjectList 的只读元素接口取得,避免在计算中执行任意源迭代协议。 let items = crate::object_list::items(cx.store(), object(&args[5])?)?; if items.len() > 512 { return Err(invalid("text flow exceeds 512 exclusions")); } + // 每个元素验证真实 Exclusion payload;普通对象不因拥有 circle/polygon 属性就成为障碍资源。 let exclusions = items .iter() .map(|&o| cx.store().payload::(o, "Exclusion")) .collect::, _>>()?; + // 第一阶段只计算词/字母摆放,仍未生成任何 VM PaintCommand。 let mut placements = Vec::new(); + // 每次调用从词首开始,资源可复用但消费位置不跨调用保留。 let mut cursor = Cursor::default(); + // 行扫描从原点向下推进,即使一行全被避让也会消耗 line 高度,保证循环有界。 let mut top = y; while top + line <= y + height && cursor.word < prepared.words.len() { + // 当前行的可用区间随障碍形状变化,不能沿用上一行的投影宽度。 let intervals = ranges(x, x + width, top, line, &exclusions); prepared.row(&mut cursor, &intervals, top, &mut placements); top += line; } + // 第二阶段将摆放变成图片命令,字符墨迹已在 Prepare 时形成,无需再次字体塑形。 let mut commands = Vec::new(); for p in placements { + // p.word/p.letter 指回预排资源,同一个词的多个出现共享像素图片。 let word = &prepared.words[p.word]; + // 已拆行的单字母必须只画该字母;完整词可按 letters 开关选择逐字图或整词图。 let inks = if let Some(i) = p.letter { std::slice::from_ref(&word.letters[i]) } else if letters { + // 逐字命令便于源程序后续分别变换,每张图仍来自同一份字形预绘制结果。 &word.letters[..] } else { + // 整词图命令数量更少,布局宽度仍按同一 word 度量,视觉原点与字母模式一致。 std::slice::from_ref(&word.ink) }; + // 字母模式沿词内 advance 累加 x;整词模式只迭代一张图,不重复应用词总宽。 let mut px = p.x; for ink in inks { + // 为图片共享像素创建托管资源外壳,PaintCommand 的 image 属性形成明确 VM 强边。 let image = cx.allocate(ImageResource(ink.image.clone()))?; attach(cx, image, desktop)?; commands.push(command( @@ -569,10 +727,12 @@ fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Resul )?); px += ink.width; if commands.len() > 10_000 { + // 行数预算之外再限制命令数,防止单行极多字母模式产生巨量发布数据。 return Err(invalid("text flow command budget exceeded")); } } } + // DisplayList 采用受检查 ObjectList,不把 Rust Vec 或任意用户对象当绘制命令集合。 let commands = crate::object_list::collection(cx, commands, desktop)?; Ok(Value::Object(crate::native_visual::record( cx, @@ -581,15 +741,18 @@ fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Resul vec![("commands", Value::Object(commands))], )?)) } +/// ui_bridge::bootstrap 在类型/协议安装后调用,注册 ui.graphics 和 math 命名空间,不加载资源或执行绘制。 pub(crate) fn install( vm: &mut Vm, roots: &mut Vec, ui: ObjectRef, desktop: ObjectRef, ) -> Result<(), VmError> { + // 命名空间自身是普通 Object,显式根由候选运行时统一管理,安装失败时也会清理。 let graphics = vm.new_object()?; roots.push(graphics); attach_vm(vm, graphics, "Object", desktop)?; + // 数值辅助函数独立挂在 graphics.math,避免把 sin/now 等名字注入用户全局命名空间。 let math = vm.new_object()?; roots.push(math); attach_vm(vm, math, "Object", desktop)?; @@ -617,6 +780,7 @@ pub(crate) fn install( (math, "max", Op::Max), (math, "clamp", Op::Clamp), ] { + // 每个入口有独立 Op 标签及同一个 desktop 捕获,VM 根据 arity 检查数量再进入 Function::invoke。 let f = vm.allocate(ContextNativeFunction::new( name, Function { op, desktop }, @@ -626,48 +790,66 @@ pub(crate) fn install( attach_vm(vm, f, "Function", desktop)?; vm.set_attr(owner, name, Value::Object(f))?; } + // 圆周率是数值属性而非零参函数,读取无须采样时钟或进入原生调度。 vm.set_attr(math, "pi", Value::Number(std::f64::consts::PI.into()))?; vm.set_attr(graphics, "math", Value::Object(math))?; vm.set_attr(ui, "graphics", Value::Object(graphics))?; Ok(()) } +/// install 使用直接 VM 分配而非 NativeContext,借此把命名空间/函数附上已注册名义类型。 fn attach_vm(vm: &mut Vm, o: ObjectRef, name: &str, desktop: ObjectRef) -> Result<(), VmError> { + // 查精确 Type 身份,不为每个函数重新创建名为 Function 的类型对象。 let t = crate::ui_bridge::desktop_type(vm.object_store(), name, desktop)?; vm.attach_type(o, t) } #[derive(Debug)] +/// UiBridge::dispatch 为一次原始 pointer 输入创建的临时 payload,经 VM 调用进入事件传播。 pub(crate) struct PointerDispatch { + // 接收事件的业务 Block;同一个 Block 可以有多个 Mount,因此不能只存 owner。 pub owner: ObjectRef, + // 本次具体出现位置,提供挂载状态、局部变换与后续传播路径上下文。 pub mount: ObjectRef, + // Event 类型和传播资源域来自当前窗口 desktop,不从 GUI 持有的原生指针取得。 pub desktop: ObjectRef, + // 保留 pointer-down/move/up 等原始事件种类,不自动合成为 click。 pub kind: String, + // 输入坐标先保留外层空间表示,invoke 在所属 Mount 几何上逆投影。 pub point: Point, } impl NativeCallable for PointerDispatch { + /// VM 调用无参数临时驱动,全部输入已捕获在 payload 中,避免调用方再次传不匹配目标。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } + /// VM 分派临时 callable 时验证 Mount,生成带局部 x/y 的 Event,再交事件模块执行捕获/目标/冒泡。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { + // 再检查实时 payload,GUI 命中时的可交互标记不能保证当前对象仍挂载或仍订阅指针事件。 let data = cx .store() .payload::(self.mount, "Mount")?; + // 几何属于具体出现位置,源对象属性上的 x/y 不能替代投影后的 Mount 几何。 let g = &data.geometry; if !data.mounted || !g.pointer_events { return Err(invalid( "pointer target is detached or has no pointer subscriptions", )); } + // 先撤销嵌套视图/面板映射,再撤销该出现的二维视图,失败时不沿用原屏幕坐标。 let local = g .view_point(self.point, false) .map(|p| g.view.unwrap_or_default().inverse(p)) .ok_or(invalid("pointer cannot be projected"))?; + // 目标事件坐标以该出现外框左上角为原点,不是页面/世界绝对位置。 let (x, y) = (local.x - g.x, local.y - g.y); + // Event 必须与当前宿主域的名义类型一致,不能借用另一个会话的类型身份。 let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?; + // NativeContext 负责临时根,返回/委托时提升输出,异常路径也不会遗留新建 Event 根。 let event = crate::event_dispatch::new_event(cx, definition, &self.kind)?; cx.set_attr(event, "x", n(x))?; cx.set_attr(event, "y", n(y))?; + // 事件模块可以返回 Invoke 延续源处理器,仍使用当前 VM 的预算/调用栈而非递归启动解释器。 crate::event_dispatch::dispatch(cx, self.owner, event, Some(self.mount), self.desktop) } } diff --git a/src/runtime/endor_script.rs b/src/runtime/endor_script.rs index b719460..2a3261f 100644 --- a/src/runtime/endor_script.rs +++ b/src/runtime/endor_script.rs @@ -1,9 +1,9 @@ //! 区分完整 Module 与顶层 Script,并保留输入源码位置。 //! 解析包装或片段拼接产生的 span 偏移要递归修正,保证诊断和 Sources 视图仍指向用户原始行列。 //! -//! Feed a source snapshot to Aster's existing module or interactive entry points. -//! Statement syntax, tokens and ASTs all remain Aster-owned. The host only batches -//! complete interactive inputs, just as a REPL feeds consecutive inputs to a VM. +//! runtime::endor_host 的一次性 compile 和持久会话加载调用 parse,再按 roots 进行 AST 预算检查与编译。 +//! 语句、词法和 AST 定义属于 Aster;这里只把完整源码组织成 Module 或连续 Interactive 输入。 +//! 片段先用局部字节位置解析,shift_* 递归加回起点,编译错误和 Sources 浏览才能仍对应原文件。 use anyhow::Result; use frontend::{ ParseMode, SourceUnit, Span, TokenKind, @@ -12,41 +12,54 @@ use frontend::{ /// 待编译输入是完整模块还是顶层脚本;两者保留不同根结构但使用统一后续接口。 pub enum SourcePlan { + // 保留 Aster 完整模块根,后续作为一个编译/执行单元,不拆开函数或类的定义体。 Module(AstRoot), + // 顶层脚本按完整交互输入连续执行;各段仍共享所属运行时的全局环境,而非各建一个 VM。 Script(Vec), } impl SourcePlan { - /// 按输入计划返回需要编译的语法根,隐藏 Script 与 Module 组织差异。 + /// endor_host 的 AST 预算访问和 compile_plan 调用,借用统一根切片,不克隆 AST 或重新解析。 pub fn roots(&self) -> &[AstRoot] { match self { + // 单模块以单元素切片适配,让调用方不必维护两套遍历分支。 Self::Module(root) => std::slice::from_ref(root), + // 脚本直接保留原输入段顺序,声明和后续使用语句的先后关系不能重排。 Self::Script(roots) => roots, } } } -/// 优先解析完整模块,否则按可完成的交互输入分段为 Script,并将 AST/诊断位置映回原文。 +/// endor_host 执行入口调用,优先接受完整 Module,失败则按顶层换行候选边界拼出 Aster 可解析的交互输入。 +/// 模块解析失败本身不立即作为最终错误:普通顶层脚本本就需要走后面的 Interactive 解析路径。 pub fn parse(source: &SourceUnit) -> Result { // 完整模块优先;只有模块解析失败才退回连续交互输入模式,宿主不自行实现 Endor 语法。 if let Ok(module) = frontend::parse_source_ast(source, ParseMode::Module) { + // 模块成功就不再按行切分,避免破坏跨行声明与完整模块语义。 return Ok(SourcePlan::Module(module)); } // 一次词法分析获得原始字节 span,候选分段依赖 token 之间的实际换行而非直接按行切字符串。 let tokens = frontend::lex_source(source)?; + // 只保存已被 Aster 成功接受的交互根,失败候选不留下半棵 AST。 let mut roots = Vec::new(); + // start 为当前待解析片段在原文件中的字节起点,仅在候选成功时前移。 let mut start = 0; + // 深度只是候选边界启发式;括号失配是否合法仍由 Aster 判定,不在宿主偷偷修正。 let mut depth = 0_i64; for (index, token) in tokens.iter().enumerate() { // 追踪圆/花括号嵌套,括号内换行不能直接结束输入;最终语法是否合法仍交给 Aster。 match token.kind { + // token 已由词法器识别,字符串/注释里的括号不会当作结构括号改变深度。 TokenKind::LeftParen | TokenKind::LeftBrace => depth += 1, TokenKind::RightParen | TokenKind::RightBrace => depth -= 1, + // 其他 token 不改变边界嵌套,不能靠普通字符扫描替代此判断。 _ => {} } + // token 尾为原文本字节位置,用它取与下一 token 之间的空白/注释区域查换行。 let end = token.span.end; // 末尾 token 总是尝试解析;非末尾要求顶层换行,且下一 token 不像成员访问、调用或算术续接。 let boundary = tokens.get(index + 1).is_none_or(|next| { + // 尚在括号内,或下一 token 明显续接上个表达式时,不尝试提前提交当前交互片段。 depth == 0 && source.text()[end..next.span.start].contains(['\r', '\n']) && !matches!( @@ -60,11 +73,13 @@ pub fn parse(source: &SourceUnit) -> Result { ) }); if !boundary { + // 保留 start,之后尝试更长的片段;不是丢弃当前 token 对应源码。 continue; } // 当前片段从上一次成功边界 start 开始,保留期间的空白与注释;解析器看到的是片段局部字节位置。 match frontend::parse_ast(&source.text()[start..end], ParseMode::Interactive) { Ok(mut root) => { + // Interactive 模式的成功根必须为 Interactive;违反是前端接口不变量错误。 let AstRoot::Interactive(input) = &mut root else { unreachable!("interactive mode"); }; @@ -73,10 +88,9 @@ pub fn parse(source: &SourceUnit) -> Result { roots.push(root); start = end; } - // An incomplete interactive input can continue on the next line. - // Only Aster decides whether its combined syntax is valid. // 错误恰在片段末尾且还有 token 时,视为可能未完成的交互输入,保持 start 不动继续拼接。 Err(diagnostic) if index + 1 < tokens.len() && diagnostic.span.start == end - start => { + // 只有“诊断正好指向片段末尾”才暂缓,下一候选会连同当前片段一起再交 Aster 解析。 } Err(mut diagnostic) => { // 其他语法错误立即恢复原始偏移,再用 SourceUnit 添加原文件来源与行列诊断。 @@ -87,20 +101,20 @@ pub fn parse(source: &SourceUnit) -> Result { } // 空输入不能静默产生空计划;再次委托交互入口给出解析器自己的空输入诊断与来源。 if roots.is_empty() { - // Produce Aster's original empty-input diagnostic and provenance. + // 不自造宿主错误字符串,保留前端对空输入的原始诊断和 SourceUnit 来源信息。 frontend::parse_source_ast(source, ParseMode::Interactive)?; } Ok(SourcePlan::Script(roots)) } -/// 为单个半开字节范围加回片段起始偏移,不处理其所属节点的子节点。 +/// 下方所有 shift_* 调用的基本操作,给单个半开字节范围加偏移,不递归、不改变源码内容。 fn shift_span(span: &mut Span, offset: usize) { // span 采用原文本字节偏移,start/end 同增 offset 保持长度;不是按字符数或行数偏移。 span.start += offset; span.end += offset; } -/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。 +/// parse 在接受一个 Interactive 根后调用,平移交互根范围,再把语句交给 shift_statement 递归。 fn shift_input(input: &mut InteractiveInput, offset: usize) { shift_span(&mut input.span, offset); // 根范围之外,逐条语句递归处理所有内层节点,避免错误只在顶层位置正确。 @@ -109,18 +123,21 @@ fn shift_input(input: &mut InteractiveInput, offset: usize) { } } -/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。 +/// shift_input/函数体/分支体的递归入口,按语句种类平移自己的范围和所有内嵌表达式/声明。 fn shift_statement(statement: &mut Stmt, offset: usize) { shift_span(&mut statement.span, offset); match &mut statement.kind { StmtKind::If(branch) => { + // If 节点、条件、then 块各有独立 span,平移外层 Stmt 不能自动更新它们。 shift_span(&mut branch.span, offset); shift_expression(&mut branch.condition, offset); shift_span(&mut branch.body.span, offset); for s in &mut branch.body.statements { + // then 内可能继续嵌套 if/while/声明,不能只调整第一层语句外框。 shift_statement(s, offset); } if let Some(body) = &mut branch.otherwise { + // else 可省略;存在时块范围和其中每条语句都使用同一个原片段偏移。 shift_span(&mut body.span, offset); for s in &mut body.statements { shift_statement(s, offset); @@ -128,6 +145,7 @@ fn shift_statement(statement: &mut Stmt, offset: usize) { } } StmtKind::While(loop_) => { + // while 条件和循环体均可含更深的调用/控制流,沿各自 AST 子结构递归而非重新扫描文本。 shift_span(&mut loop_.span, offset); shift_expression(&mut loop_.condition, offset); shift_span(&mut loop_.body.span, offset); @@ -136,11 +154,13 @@ fn shift_statement(statement: &mut Stmt, offset: usize) { } } StmtKind::Binding(binding) => { + // 新绑定独立保存名字和右值范围,诊断未定义符号时需要内部表达式位置正确。 shift_span(&mut binding.span, offset); shift_span(&mut binding.name.span, offset); shift_expression(&mut binding.value, offset); } StmtKind::Assignment(assignment) => { + // 赋值不同于声明,但同样保留名字与右值两个来源范围,不能仅平移整个语句。 shift_span(&mut assignment.span, offset); shift_span(&mut assignment.name.span, offset); shift_expression(&mut assignment.value, offset); @@ -153,41 +173,49 @@ fn shift_statement(statement: &mut Stmt, offset: usize) { shift_expression(&mut assignment.value, offset); } StmtKind::Return(returned) => { + // return 自身 span 必须平移;无返回表达式的 Unit 返回不能访问不存在的 value。 shift_span(&mut returned.span, offset); if let Some(expression) = &mut returned.value { + // 返回值可以是任意复合调用/算术表达式,继续下钻到全部名称和实参。 shift_expression(expression, offset); } } + // 表达式语句复用完整表达式递归,避免调用参数中的错误仍显示片段局部行列。 StmtKind::Expression(expression) => shift_expression(expression, offset), + // 顶层/局部声明转交类与函数专用入口,处理参数、类型名及函数体。 StmtKind::Declaration(declaration) => shift_declaration(declaration, offset), } } -/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。 +/// shift_statement 在声明语句中调用,平移 Decl 外层,再区分函数定义与类定义的内部位置。 fn shift_declaration(declaration: &mut Decl, offset: usize) { shift_span(&mut declaration.span, offset); match &mut declaration.kind { + // 普通函数和类方法使用相同函数结构,统一由 shift_function 平移签名和函数体。 DeclKind::Function(function) => shift_function(function, offset), // 类的名称、基类、字段类型和方法均保存独立 span,不能仅移动声明外层范围。 DeclKind::Class(class) => { shift_span(&mut class.span, offset); shift_span(&mut class.name.span, offset); if let Some(base) = &mut class.base { + // 无显式基类时不新增默认基类 span,保留前端原始 AST 的可选结构。 shift_span(&mut base.span, offset); } for field in &mut class.fields { + // 字段节点整体、字段名、类型名分别保存位置,错误类型注解必须指回原文件。 shift_span(&mut field.span, offset); shift_span(&mut field.name.span, offset); shift_span(&mut field.type_name.span, offset); } for method in &mut class.methods { + // 方法体可能包含更多声明,沿同一套递归入口继续处理。 shift_function(method, offset); } } } } -/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。 +/// shift_declaration 调用,平移函数名、参数/返回注解和函数体,供编译器类型错误使用原输入坐标。 fn shift_function(function: &mut frontend::ast::Function, offset: usize) { shift_span(&mut function.span, offset); shift_span(&mut function.name.span, offset); @@ -200,11 +228,12 @@ fn shift_function(function: &mut frontend::ast::Function, offset: usize) { } shift_span(&mut function.body.span, offset); for statement in &mut function.body.statements { + // 函数体内每条语句继续按 kind 递归,不能用整体函数范围覆盖内部诊断。 shift_statement(statement, offset); } } -/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。 +/// 语句/函数递归助手调用,平移表达式与名称成员的范围;语义值和运算符本身保持不变。 fn shift_expression(expression: &mut Expr, offset: usize) { shift_span(&mut expression.span, offset); match &mut expression.kind { @@ -212,13 +241,16 @@ fn shift_expression(expression: &mut Expr, offset: usize) { ExprKind::Name(_) | ExprKind::Number(_) | ExprKind::String(_) => {} ExprKind::Symbol(name) => shift_span(&mut name.span, offset), ExprKind::Unary { operand, .. } | ExprKind::Await(operand) => { + // unary 与 await 都只有一个子表达式,操作符来源已在外层表达式 span 覆盖。 shift_expression(operand, offset) } ExprKind::Binary { left, right, .. } => { + // 两侧都可能含复合表达式,不能只调整最终报错概率更高的右侧。 shift_expression(left, offset); shift_expression(right, offset); } ExprKind::Member { object, name } => { + // object 可能是调用返回值,name 则是独立标识符,分别调整两类来源。 shift_expression(object, offset); shift_span(&mut name.span, offset); } @@ -226,6 +258,7 @@ fn shift_expression(expression: &mut Expr, offset: usize) { ExprKind::Call { callee, arguments } => { shift_expression(callee, offset); for argument in arguments { + // 保持实参顺序和值不变,只平移每个实参树内部保存的字节位置。 shift_expression(argument, offset); } } diff --git a/src/runtime/event_dispatch.rs b/src/runtime/event_dispatch.rs index aa0ce51..ab3e69a 100644 --- a/src/runtime/event_dispatch.rs +++ b/src/runtime/event_dispatch.rs @@ -1,8 +1,9 @@ //! 托管事件与订阅的传播实现。Connection 持有回调引用,Event 保存目标、阶段与停止传播状态。 //! 分发沿出现关系执行 capture → target → bubble,记录默认行为阻止状态;只有同一 VM 中的回调可以接触托管对象。 //! -//! Managed subscriptions and occurrence-specific propagation. A validated VM -//! function sequences calls, so all handlers share one execution budget. +//! bootstrap 调用 install 安装 Event/Subscription 控制方法;ui_bridge::calls 和 graphics/scene3d 指针适配调用 dispatch。 +//! dispatch 只快照路线并生成驱动,真正回调执行由 VM 调用 Delivery,再通过 Invoke 接回源处理器。 +//! vm_sequence::build 使整次传播共用同一执行预算;每次具体 Mount 是独立传播上下文,不从共享业务树猜父链。 use crate::{block, mount::Mount, ui_bridge::attach_desktop_type}; use endor_core::{ ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef, @@ -11,19 +12,21 @@ use endor_core::{ // 一次分发最多安排 10000 个处理器,另由共享 VM 驱动约束全部回调的总执行预算。 const MAX_DELIVERIES: usize = 10_000; -/// 统一构造本模块的参数错误,让调用方停止处理无效输入。 +/// 订阅检查、事件构造与传播分支共用的参数错误构造器,源诊断位置由外层 VM 调用栈提供。 fn invalid(message: &'static str) -> VmError { VmError::InvalidArgument(message) } -/// 检查订阅回调满足事件处理器调用契约,避免等到实际点击才暴露签名错误。 +/// ui_bridge::calls 安装 on/onClick 时调用,检查零参或单 Event 回调能力,不在此执行用户函数。 pub fn validate_handler( store: &endor_core::ObjectStore, function: ObjectRef, ) -> Result<(), VmError> { if !store.object(function)?.payload().is_function() { + // 普通 Object 即使有 call 属性也不能作为这里的 Function 注册,避免实际分派才暴露类型错误。 return Err(invalid("event handler must be a Function")); } + // 元数据必须明确存在;不能把未知 arity 当作“随便传一个 Event 总能工作”。 let arity = store .call_arity(function)? .ok_or(invalid("event handler requires positional metadata"))?; @@ -72,8 +75,11 @@ impl ObjectPayload for Event { #[derive(Debug, Clone)] /// 实际拥有回调引用的托管连接,挂在目标的订阅关系中参与 GC。 pub struct Connection { + // Block 的 subscriptions 强持 Connection,Connection 再强持 handler;取消时将这一强边置空。 pub handler: Option, + // 与 Event.kind 比较的精确事件字符串,不对事件名称做前缀或真值匹配。 pub kind: String, + // 区分捕获和非捕获队列;目标阶段两种队列仍共享一个传播组。 pub capture: bool, } impl ObjectPayload for Connection { @@ -92,15 +98,16 @@ impl ObjectPayload for Connection { /// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。 fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { if let Some(handler) = self.handler { + // Aster GC 访问者只收到当前仍连接的处理器;None 不再保活旧回调及它捕获的业务对象。 visit(handler); } } } -/// A token never retains its target or callback. The Block owns the connection. #[derive(Debug)] /// 源代码可保留的订阅能力包装,用于取消对应连接。 pub struct Subscription { + // 事件 Connection 或属性 Binding 的弱引用;用户长期保留 token 不应反向保活目标和计算闭包。 pub connection: WeakObjectRef, } impl ObjectPayload for Subscription { @@ -119,7 +126,7 @@ impl ObjectPayload for Subscription { } #[derive(Debug, Clone, Copy)] -/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。 +/// install 的名义方法表选用的操作标签;TypedMethod 提供类型契约,Method 只执行对应状态变化/查询。 enum Method { Stop, StopImmediate, @@ -134,17 +141,21 @@ impl NativeCallable for Method { Some(endor_core::CallArity::exact(1)) } - /// 按操作读取或修改连接/传播状态;断开清除真实 handler/compute,停止标志写回同一 Event。 + /// TypedMethod::invoke 转交,按 receiver payload 查询/取消订阅,或更新当前事件传播控制标志。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 方法接收者仍在 args[0];不像 UiBridge 的 TypeMethod,这个包装没有提前把 owner 剥出来。 let [Value::Object(owner)] = args else { return Err(invalid("event method receiver")); }; + // 取消/查询订阅和传播控制使用不同 payload,先按方法标签分流再进行受检查的向下转型。 let result = match self { Self::Disconnect | Self::Connected => { + // Subscription 不直接拥有 Connection,读弱身份后在当前 NativeContext 中临时升级。 let weak = cx .store() .payload::(*owner, "Subscription")? .connection; + // 已死亡连接正常得到 None,isConnected=false/disconnect 无动作,而不是新建连接。 let connection = cx.upgrade(weak)?; // Subscription 同时包装事件 Connection 和属性 Binding;按 payload 类型清 handler 或 compute。 let connected = if let Some(connection) = connection { @@ -155,10 +166,12 @@ impl NativeCallable for Method { .as_any() .is::() { + // 属性绑定和事件订阅复用同一种取消令牌,但 Binding 的真实强边字段是 compute。 let mut data = cx .store() .payload::(connection, "Binding")? .clone(); + // 返回查询的是操作前连接状态;disconnect 自己最后返回 Unit,不暴露该布尔。 let connected = data.compute.is_some(); // 取消切断真实强拥有边,外部仍可保留 token,但 token 弱引用本身不保活回调或目标。 if matches!(self, Self::Disconnect) { @@ -167,10 +180,12 @@ impl NativeCallable for Method { } connected } else { + // 其余合法令牌包装事件 Connection,不用 Binding 的字段布局读取它。 let mut data = cx .store() .payload::(connection, "Connection")? .clone(); + // handler=None 就是已断开,保留 Connection 对象本身不代表回调仍可执行。 let connected = data.handler.is_some(); if matches!(self, Self::Disconnect) { data.handler = None; @@ -179,24 +194,30 @@ impl NativeCallable for Method { connected } } else { + // 弱目标已经死亡与主动断开都属于不再连接,查询不因此报过期对象错误。 false }; if matches!(self, Self::Disconnect) { + // API 约定取消返回 Unit,只有 isConnected 返回前面采样到的布尔。 Value::Nil } else { Value::Bool(connected) } } _ => { + // 传播标志属于 Event payload,复制后经 replace_payload 写回以保持 store 的写屏障。 let mut event = cx.store().payload::(*owner, "Event")?.clone(); if matches!(self, Self::Prevented) { + // 只读查询不替换 payload,不制造一次虚假的事件变更。 Value::Bool(event.prevented) } else { match self { Self::Stop | Self::StopImmediate => { + // Stop 保留当前组的其余回调,Immediate 额外跳过同组后续 Delivery。 event.stopped_at = Some(event.group); event.immediate |= matches!(self, Self::StopImmediate); } + // 只记录阻止默认,不在本模块额外调用或取消某个 OS 默认动作。 Self::Prevent => event.prevented = true, _ => unreachable!(), } @@ -212,6 +233,7 @@ impl NativeCallable for Method { #[derive(Debug)] /// 安装到托管类型上的原生构造调用适配器。 pub struct Constructor { + // bootstrap 在 Event 类型/签名都建立后补入,VM 据此先检查 Type/kind/字段外壳。 pub signature: Option, } impl NativeCallable for Constructor { @@ -224,8 +246,9 @@ impl NativeCallable for Constructor { Some(endor_core::CallArity::range(2, usize::MAX)) } - /// 先验证事件子类字段数量与注解,再创建原生 Event 并填充声明字段,错误不得留下部分实例。 + /// VM 执行 Event/源子类 NativeTyped 构造器时调用,验证字段前不先分配业务 Event,避免非法字段副作用。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // definition 是实际请求子类,kind 是唯一原生构造参数,其余后缀对应声明字段。 let [Value::Object(definition), Value::Text(kind), values @ ..] = args else { return Err(invalid("Event requires an event kind")); }; @@ -236,19 +259,22 @@ impl NativeCallable for Constructor { .fields() .to_vec(); if fields.len() != values.len() { + // 不用 zip 静默截断少/多实参,完整类型字段数量必须与输入后缀一致。 return Err(invalid("incorrect Event field arguments")); } cx.store() .check_class_field_arguments(*definition, values)?; + // 数量和类型都检查后才创建真实子类 Event;NativeContext 会清理未输出的临时根。 let event = new_event(cx, *definition, kind)?; for (field, value) in fields.iter().zip(values) { + // 用普通属性写入口初始化字段,保持对象引用和依赖记录受 VM 统一管理。 cx.set_attr(event, &field.name, value.clone())?; } Ok(NativeResult::Return(Value::Object(event))) } } -/// 建立本次分发拥有的托管 Event,保存目标、输入与传播控制状态。 +/// Event 构造器、click 和图形/三维指针适配调用,建立尚未分派的带名义身份事件;目标由 dispatch 填写。 pub fn new_event( cx: &mut NativeContext<'_>, definition: ObjectRef, @@ -257,11 +283,13 @@ pub fn new_event( if kind.is_empty() || kind.len() > 128 { return Err(invalid("event kind must contain 1..128 UTF-8 bytes")); } + // payload 默认标志与普通属性分开:停止/默认阻止在 payload,目标/阶段信息通过托管属性公开。 let event = cx.allocate(Event::default())?; cx.attach_type(event, definition)?; cx.set_attr(event, "kind", Value::Text(kind.into()))?; // 新事件尚未递送,目标、出现与 hit 都为空;kind 保留输入种类,phase 初始 none。 for name in ["target", "mount", "currentTarget", "currentMount", "hit"] { + // 初始化明确的 nil,处理器读取未分派事件时不会因字段缺失而失败。 cx.set_attr(event, name, Value::Nil)?; } cx.set_attr(event, "phase", Value::Text("none".into()))?; @@ -269,8 +297,9 @@ pub fn new_event( } #[derive(Debug)] -/// 带事件类型签名和 owner 的原生方法包装。 +/// install 挂到 Event/Subscription 类型上的签名包装;实例 receiver 由 VM 方法绑定机制传入。 struct TypedMethod { + // 无状态操作标签,实际事件/订阅状态都在本次传入 receiver 的托管 payload 中。 method: Method, signature: endor_core::CallSignature, } @@ -285,7 +314,7 @@ impl NativeCallable for TypedMethod { } } -/// 把本模块的类型、方法或能力注册到所属运行时上下文。 +/// ui_bridge::bootstrap 完成 Event/Subscription/Function 类型建立后调用,为两类对象安装控制与查询方法。 pub fn install( vm: &mut Vm, roots: &mut Vec, @@ -307,6 +336,7 @@ pub fn install( ] { // isDefaultPrevented/isConnected 返回 Bool 且只读;控制传播和取消方法返回 Unit 并声明 Mutating。 let query = matches!(method, Method::Prevented | Method::Connected); + // callable 接一个隐式名义 receiver,查询返回 Bool/ReadOnly,修改返回 Unit/Mutating。 let function = vm.allocate(ContextNativeFunction::new( name, TypedMethod { @@ -314,10 +344,13 @@ pub fn install( signature: endor_core::CallSignature::exact( vec![endor_core::AnnotationType::Nominal(definition)], if query { + // isDefaultPrevented/isConnected 的返回类型是布尔,不把“无连接”写成 nil。 endor_core::AnnotationType::Bool } else { + // 控制/取消操作没有业务返回值,VM 用 Unit 检查而非任意 Any。 endor_core::AnnotationType::Unit }, + // 同一个标签同时决定调用效果,不能让断开连接冒充只读查询。 if query { endor_core::CallEffect::ReadOnly } else { @@ -337,18 +370,27 @@ pub fn install( #[derive(Debug)] /// 本次递送选择的对象方法或显式订阅处理器。 enum Handler { + // 捕获 Connection 对象而非函数快照,前面回调 disconnect 后本项会重新观察 handler=None。 Token(ObjectRef), + // onClick 的旧式单处理器是本轮函数快照,不具有独立 Connection 令牌。 Legacy(ObjectRef), + // onEvent 从实际类型的方法表在轮到本项时解析,以保留继承/源覆盖。 ObjectMethod, } #[derive(Debug)] /// 事件传播过程中将要调用的一项处理器及其阶段/目标上下文。 struct Delivery { + // 本轮事件由 captures 显式保活;payload 中的数值句柄本身不构成 VM 强边。 event: ObjectRef, + // 当前路径项的业务对象,不一定是最终 target;祖先捕获/冒泡也用相同适配器。 owner: ObjectRef, + // 该路径项的具体出现,页面上下文无 Mount 时为 None。 mount: Option, + // stopPropagation 的边界编号;目标捕获与非捕获共享组,其他出现/阶段递增。 group: usize, + // 对外公开的 capture/target/bubble 字符串,与 group 的内部停止规则分别保存。 phase: &'static str, + // 处理器的解析策略,决定是否需要临时取连接或补 receiver。 handler: Handler, } impl NativeCallable for Delivery { @@ -357,13 +399,16 @@ impl NativeCallable for Delivery { Some(endor_core::CallArity::exact(0)) } - /// 轮到该处理器时重查传播状态和连接是否仍有效,更新当前阶段/目标,再按真实回调 arity 发出 VM 调用。 + /// vm_sequence 驱动轮到本项时调用,重查停止/连接状态,再用 Invoke 交给同预算的真实处理器。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { + // 不是快照整个传播开始时的标志,前一个回调产生的 stop 必须立刻影响当前项。 let mut event = cx.store().payload::(self.event, "Event")?.clone(); // 真正轮到处理器时重新读取传播状态,之前回调的 stop 操作立即影响后续项,无需重建序列。 if event.immediate || event.stopped_at.is_some_and(|group| group != self.group) { + // 跳过此项仍返回正常 Nil,让驱动继续推进到其他跳过项和最终 Finish 清理。 return Ok(NativeResult::Return(Value::Nil)); } + // 函数与参数前缀一起解析:对象协议需 receiver,独立处理器最初没有显式参数。 let (function, mut arguments) = match self.handler { // 序列只捕获 Connection,不快照 handler;前面处理器取消它后,这里看到 None 并跳过。 Handler::Token(token) => { @@ -372,6 +417,7 @@ impl NativeCallable for Delivery { .payload::(token, "Connection")? .handler else { + // 已断开连接不再调用旧 handler,即使驱动已经为它分配过 Delivery。 return Ok(NativeResult::Return(Value::Nil)); }; (handler, vec![]) @@ -379,9 +425,11 @@ impl NativeCallable for Delivery { Handler::Legacy(function) => (function, vec![]), // 对象协议在执行到该项时解析实际类型的 onEvent;显式 receiver 先放入实参向量。 Handler::ObjectMethod => { + // 动态源子类的 onEvent 可以不同于基类默认空实现,不能在安装时固定函数。 let Some(definition) = cx.store().type_of(self.owner)? else { return Err(invalid("untyped event target")); }; + // 按类型继承查方法,缺少协议方法是契约错误,不降级忽略对象事件能力。 let function = cx .store() .lookup_method(definition, "onEvent")? @@ -406,6 +454,7 @@ impl NativeCallable for Delivery { .call_arity(function)? .is_some_and(|arity| arity.accepts(1)) { + // 单 Event 回调优先;仅真正不接受一参但接受零参的便捷处理器才不追加事件。 arguments.push(Value::Object(self.event)); } Ok(NativeResult::Invoke { @@ -423,8 +472,9 @@ impl NativeCallable for Finish { Some(endor_core::CallArity::exact(0)) } - /// 正常序列结束时清除活跃派发守卫与当前上下文,保留 target/prevented 并返回实际 Event,允许后续重用。 + /// vm_sequence 在所有 Delivery 之后调用,清理当前阶段/守卫并返回最终 Event,供调用方读取默认阻止状态。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { + // 保留事件最终 prevented 等标志,不用 Event::default 抹掉这一轮处理结果。 let mut event = cx.store().payload::(self.0, "Event")?.clone(); // 正常序列结束清递送标记与当前上下文,保留原始 target/mount 和 prevented 供调用方读取。 event.dispatch = None; @@ -436,10 +486,8 @@ impl NativeCallable for Finish { } } -/// Snapshot the selected occurrence's route and listener order. Disconnection -/// before a listener's turn skips it; newly registered listeners wait for the -/// next dispatch. A code event without a Mount visits only its target object. -/// 快照 Mount 路径和订阅顺序,构造共享预算的 capture/target/bubble 驱动并返回 VM 续调。 +/// ui_bridge::calls 的 click/dispatch 及指针适配调用,构造本轮路线/处理顺序,再返回 VM 驱动续调。 +/// 无 Mount 的代码事件只访问 owner,不猜测共享对象位于哪个窗口;新订阅不加入本轮,取消可立即跳过旧项。 pub fn dispatch( cx: &mut NativeContext<'_>, owner: ObjectRef, @@ -447,13 +495,16 @@ pub fn dispatch( mount: Option, desktop: ObjectRef, ) -> Result { + // 先检查真实 Event payload,复制是为了稍后重置传播标志并经写屏障写回。 let mut event = cx.store().payload::(event_ref, "Event")?.clone(); // 只有 guard 仍存活才禁止重入;此前分发失败但 guard 已释放时,不让旧弱身份永久锁住 Event。 if let Some(weak) = event.dispatch && cx.upgrade(weak)?.is_some() { + // 同 Event 不可重入分发;若上轮驱动已因失败释放,弱 guard 失效则允许重新使用。 return Err(invalid("Event is already being dispatched")); } + // kind 可以是源程序改过的普通属性,所以每次 dispatch 都重新验证实际字符串和长度。 let Value::Text(kind) = cx.store().get_attr(event_ref, "kind")? else { return Err(invalid("Event kind must be text")); }; @@ -463,21 +514,26 @@ pub fn dispatch( // 路线由目标向外保存。无 Mount 的代码事件只访问目标对象,不猜测共享 Block 属于哪个出现。 let mut route = vec![(owner, mount)]; if let Some(mount) = mount { + // 克隆出现 payload 后沿其 parent/page 弱边构建路径,和 Block.children 的业务组成分开。 let target = cx.store().payload::(mount, "Mount")?.clone(); // 传入 Mount 必须仍挂接且确实展示 owner;同一 Block 的另一个出现不能替代该上下文。 if !target.mounted || target.block != owner { return Err(invalid("event context does not belong to this Block")); } + // parent 是下一祖先弱身份,目标先放进 seen,防止父链回到自身。 let mut parent = target.parent; // 沿弱 parent 链构建本轮路径快照,检查死亡祖先、脱离与环;不通过 Block 业务孩子关系反推祖先。 let mut seen = std::collections::HashSet::from([mount]); while let Some(weak) = parent { + // NativeContext 跟踪升级根,驱动 captures 将保留真正需要的祖先直到本轮执行结束。 let ancestor = cx .upgrade(weak)? .ok_or(invalid("event route has expired"))?; if !seen.insert(ancestor) { + // 循环父链不能让宿主构造无限驱动,遇重复身份立即拒绝。 return Err(invalid("cyclic event route")); } + // 祖先存在还不够,必须仍挂载,不能把历史出现关系当成当前传播路径。 let data = cx.store().payload::(ancestor, "Mount")?; if !data.mounted { return Err(invalid("event route is detached")); @@ -487,15 +543,17 @@ pub fn dispatch( } // 出现祖先之外再加入 Page 上下文,页面本身无 currentMount;弱页已失效则拒绝该旧路线。 if let Some(weak) = target.page { + // 页面属于传播最外层语义目标,但没有它自己的内容 Mount,所以 route 中配 None。 let page = cx.upgrade(weak)?.ok_or(invalid("event Page has expired"))?; route.push((page, None)); } } // 所有递送和 Finish 显式捕获 guard,Event 只弱引用它;驱动完成/失败释放后 guard 自动结束本轮资格。 let guard = cx.new_object()?; + // 按最终递送顺序存临时 callable,vm_sequence::build 随后让它们成为驱动常量强边。 let mut calls = Vec::new(); + // 每个出现/阶段有自己的停止边界;同业务对象多次出现也不能共用一个 group。 let mut group = 0; - // Each occurrence is its own propagation group, even for shared Blocks. // 先从最外层向目标递送捕获,再从目标向外递送非捕获;目标两次遍历都标记 phase=target。 let traversal = route .iter() @@ -509,15 +567,19 @@ pub fn dispatch( .map(|(index, entry)| (index, entry, false)), ); for (index, &(block, current_mount), capture) in traversal { + // block 可能是共享转发别名,解析到实际 Data 后读取本轮回调/订阅列表。 let (_, data) = block::resolve(cx.store(), block)?; + // 此路径项的候选处理顺序,稍后逐项包装成独立 Delivery。 let mut handlers = Vec::new(); // 本轮先快照符合事件 kind 与 capture 的订阅顺序;回调期间新注册的连接不进入当前分发。 for token in &data.subscriptions { + // subscriptions 保存的是 Connection 而非用户持有的 Subscription 令牌。 let token_data = cx.store().payload::(*token, "Connection")?; if token_data.handler.is_some() && token_data.kind == kind && token_data.capture == capture { + // 这里只快照连接身份,handler 的存活在真正执行 Delivery 时再次读取。 handlers.push(Handler::Token(*token)); } } @@ -527,22 +589,28 @@ pub fn dispatch( if kind == "click" && let Some(handler) = data.click { + // 旧式 onClick 仅匹配 click 且只在非捕获阶段执行,不变成所有事件通用监听器。 handlers.push(Handler::Legacy(handler)); } } for handler in handlers { if calls.len() >= MAX_DELIVERIES { + // 总量包括所有祖先和阶段,不能让每个节点各用 10000 额度放大一次分发。 return Err(invalid("event delivery budget exceeded")); } // 每一项显式保活本轮 Event、guard、目标和可选 Mount/连接;不能仅靠 Rust ObjectRef 值当 GC 根。 let mut captures = vec![event_ref, guard, block]; if let Some(mount) = current_mount { + // captures 保留这一具体出现,不能只保留业务 Block 丢掉几何/祖先上下文。 captures.push(mount); } match handler { + // Connection 或旧函数必须有真实托管强边,Rust 枚举里的句柄数值不保活它们。 Handler::Token(token) | Handler::Legacy(token) => captures.push(token), + // 对象方法在轮到时查找,owner 已在 captures,不提前强持某个可能被覆盖的函数。 Handler::ObjectMethod => {} } + // 分配一个零参步骤;真实用户函数的 receiver/Event 参数由 Delivery::invoke 再决定。 let function = cx.allocate(ContextNativeFunction::new( "event delivery", Delivery { @@ -551,10 +619,13 @@ pub fn dispatch( mount: current_mount, group, phase: if index == 0 { + // 路线 index=0 始终是最终目标,目标捕获/非捕获都对外显示 target。 "target" } else if capture { + // 从外层向目标的祖先遍历显示 capture。 "capture" } else { + // 从目标向外层的祖先遍历显示 bubble。 "bubble" }, handler, @@ -564,13 +635,12 @@ pub fn dispatch( attach_desktop_type(cx, function, "Function", desktop)?; calls.push(function); } - // Capture and bubble at the target are one group: stopPropagation at - // target capture still permits other target listeners. // 目标捕获后不递增 group,使 stopPropagation 仍允许目标非捕获处理器;其他路径/阶段则隔离为新组。 if index != 0 || !capture { group += 1; } } + // Finish 也捕获 guard,保证即使没有任何用户监听器,整次驱动仍维持活跃分发资格到结束。 let finish = cx.allocate(ContextNativeFunction::new( "event finish", Finish(event_ref), @@ -578,6 +648,7 @@ pub fn dispatch( ))?; // 无论传播是否被停止,Finish 都作为序列最后一项执行,它不受 Delivery 的 stopped 判定影响。 calls.push(finish); + // 驱动常量池拥有完整顺序,全部处理器共用调用者 VM 的预算,不能逐个 vm.call 重置额度。 let driver = crate::vm_sequence::build(cx, calls, "", desktop)?; event.dispatch = Some(cx.store().downgrade(guard)?); event.group = 0; @@ -592,6 +663,7 @@ pub fn dispatch( "mount", mount.map(Value::Object).unwrap_or(Value::Nil), )?; + // 仅委托驱动,不在本 Rust 调用栈递归执行用户回调;VM 后续负责返回最终 Event。 Ok(NativeResult::Invoke { function: driver, arguments: vec![], diff --git a/src/runtime/events.rs b/src/runtime/events.rs index 2608160..9f381a4 100644 --- a/src/runtime/events.rs +++ b/src/runtime/events.rs @@ -2,7 +2,9 @@ //! 连续相机和滚动输入出队时可更新帧版本,但不改变捕获目标和所属 VM;离散点击仍保留严格的过期帧语义。 //! 生产端只合并相同目标的相邻绝对指针采样;有序动作是边界。relay 直接进入串行 worker,后台等待确认。 //! -//! Native input transport. Frame/mount identities and optional logical pointer positions cross to the VM worker. +//! shell::app 建立 Client 并发送捕获到的 Input;Client 后台 relay 经 api::RuntimeControl::dispatch_native 交给 worker。 +//! HTTP 路由也反序列化同一 Input 并调用 validate,但不会走原生 relay 的连续输入版本刷新。 +//! 这里只合并同目标相邻 pointermove 的绝对采样;滚动/相机的增量逐条保留,不能用最后一个覆盖累计移动。 use serde::{Deserialize, Serialize}; use std::{ collections::VecDeque, @@ -15,9 +17,11 @@ use winit::event_loop::EventLoopProxy; #[serde(deny_unknown_fields)] /// 拖放目标窗口、目标布局版本和插入位置,源标签身份由外层 Input 保存。 pub struct TabDrop { + // 目标原生资源 ID,不是 GUI 标签索引或 Page 托管句柄。 pub window: u64, // 事件所依据的已发布布局版本,由执行端校验过期帧;原生连续手势有单独刷新逻辑。 pub layout_version: u64, + // 移除前标签序列中的插入缝位置;同窗口向后移动时 dispatch_tab 会做一次下标修正。 pub index: usize, } @@ -26,40 +30,48 @@ pub struct TabDrop { /// 原生或 HTTP 输入的纯数据报文;身份与坐标必须同时通过 worker 和能力校验。 pub struct Input { #[serde(default = "default_window")] + // 省略仅为兼容旧 HTTP 报文,显式 0 永远无效;原生客户端始终记录真实捕获窗口。 pub window: u64, // 产生事件的运行时会话;重载后即使窗口或 Mount 数值相同也不能沿用旧事件。 pub session: u64, + // 产生输入时所见帧的几何版本;同会话内也不能把旧坐标直接用到任意新几何。 pub layout_version: u64, // 捕获的具体出现身份;页面级相机事件使用零,独立视口相机事件要求非零。 pub mount: u64, + // 分派标签:click/scroll/相机/标签/工具等语义由 validate 与 UiBridge::dispatch 分工检查。 pub kind: String, #[serde(default, skip_serializing_if = "Option::is_none")] // 事件位置或缩放锚点;click 与 press_point 配对,具体坐标语义由事件 kind 决定。 pub point: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] + // 高层 click 的按下点,与 point 的释放点一起让 source_hit 确认同一个出现被点击。 pub press_point: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] // 平移/滚动增量,或缩放的 [倍率,0];不能无差别接受任意事件携带该字段。 pub delta: Option<[f32; 2]>, #[serde(default, skip_serializing_if = "Option::is_none")] + // 仅 tab-drop 可携带,源端身份仍由外层 window/layout_version/mount 指定。 pub tab_drop: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + // 拆标签的新窗口位置是屏幕坐标,与页面局部 point 分开;允许多屏环境负坐标。 pub screen_position: Option<[i32; 2]>, } -/// 返回默认主窗口身份,供省略窗口字段的兼容输入使用。 +/// serde 在旧报文缺 window 时调用,返回主资源 ID;不会把显式零值改写成合法窗口。 fn default_window() -> u64 { crate::window::MAIN_WINDOW } impl Input { - /// 按事件种类核对可携带字段、坐标范围和 Mount 是否为零;不替代执行端的会话/能力校验。 + /// API 路由和 UiBridge 分派前调用,验证报文结构;不读取 VM/发布文档,不能证明 session/Mount 仍有效。 pub fn validate(&self) -> Result<(), &'static str> { if self.window == 0 { + // 资源身份零保留为非法值,避免丢失 window 的错误报文误投当前窗口。 return Err("window identity must be nonzero"); } // 标签跨窗口放置仅接受目标元信息;禁止同时夹带指针/滚动/屏幕位置,避免混用两套操作。 if self.kind == "tab-drop" { + // 缺目标就没有可解释的插入窗口/位置,不从 point 或其他字段推断落点。 let Some(target) = &self.tab_drop else { return Err("tab-drop requires target metadata"); }; @@ -70,12 +82,14 @@ impl Input { || self.delta.is_some() || self.screen_position.is_some() { + // 拖放用纯目标元数据,不接受与滚动/点击/拆窗定位混合的多义信封。 return Err("invalid tab drop metadata"); } return Ok(()); } // 非 tab-drop 不得携带目标窗口信息,即使其他字段恰好合法也拒绝。 if self.tab_drop.is_some() { + // 非拖放报文即使 kind 本身合法,也不能夹带被另一层意外消费的重排指令。 return Err("tab_drop is only accepted for tab-drop"); } // 屏幕位置只服务 tab-detach 的新窗口定位,允许负数以支持左侧/上方显示器,但限制绝对范围。 @@ -101,10 +115,12 @@ impl Input { let viewport = self.kind.starts_with("viewport-") || self.kind.starts_with("scene-"); // 所有相机操作必须带有限、有界的增量;point 在非缩放操作可省略。 let valid_delta = self.delta.is_some_and(|delta| { + // 两个增量分量都要有限,不能只验证 zoom 会使用的第一分量而遗漏非法第二分量。 delta .iter() .all(|v| v.is_finite() && v.abs() <= 1_000_000.0) }); + // 非缩放手势可无锚点;存在时仍限制坐标,局部视口可负以支持捕获拖出边界。 let valid_point = self.point.is_none_or(|point| { point .iter() @@ -120,6 +136,7 @@ impl Input { || self.press_point.is_some() || self.screen_position.is_some() { + // 页面相机 mount 必为零,局部视口必非零,防止把同一增量套到两种目标空间。 return Err("invalid page camera input"); } // 缩放必须给锚点并使用正倍率 0.1..=10;第二分量必须为零,不把缩放与二维平移混合编码。 @@ -129,6 +146,7 @@ impl Input { .delta .is_none_or(|delta| !(0.1..=10.0).contains(&delta[0]) || delta[1] != 0.0)) { + // 这里仅检查倍率/锚点格式;锚点是否仍在实际视口内由 page_input 结合几何检查。 return Err("page-zoom requires an anchor and positive scale factor"); } return Ok(()); @@ -144,6 +162,7 @@ impl Input { .iter() .all(|v| v.is_finite() && v.abs() <= 1_000_000.0) => { + // 滚动只接点+增量,具体可消费方向、裁剪和嵌套链仍在桥接/滚动模块决定。 Ok(()) } _ => Err("scroll requires point and finite delta, with no press_point"), @@ -153,6 +172,7 @@ impl Input { self.kind.as_str(), "pointermove" | "pointerdown" | "pointerup" | "pointerleave" | "pointercancel" ) { + // 原始 pointer 报文要求一个明确目标和绝对位置,不允许夹带 click 的成对按下点或增量。 return if self.mount != 0 && self.press_point.is_none() && self.delta.is_none() @@ -161,17 +181,21 @@ impl Input { .point .is_some_and(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.)) { + // 格式合法后还需检查挂载和 pointer_events;此处不把任意 Mount 变成可交互目标。 Ok(()) } else { + // 错误字段或越界坐标统一拒绝,不替发送方删字段修复成另一个事件。 Err("pointer event requires finite point and target") }; } if self.delta.is_some() { + // 到这里相机与滚动路径都已返回,其余离散操作不得再消费 delta。 return Err("delta is only accepted for scroll input"); } // 无指针坐标的离散事件可通过此结构校验;若有指针信息,必须是 click 的按下/释放成对坐标。 // 这里不做完整 kind 白名单授权,具体操作仍由后续分发层检查。 match (self.point, self.press_point) { + // 无坐标的 focus/标签等离散动作交后续 kind 白名单与对象能力检查。 (None, None) => Ok(()), (Some(point), Some(press)) if self.kind == "click" @@ -180,6 +204,7 @@ impl Input { .chain(press) .all(|value| value.is_finite() && value.abs() <= 1_000_000.0) => { + // click 必须同时带两个有效端点,避免只在释放点命中就触发按下在别处的按钮。 Ok(()) } _ => Err("pointer click requires finite press/release coordinates"), @@ -187,29 +212,37 @@ impl Input { } } -/// Bounded producer-side coalescing preserves action barriers while the relay -/// waits for a VM acknowledgement. Motion cannot fill the queue by itself. +/// Client 生产端与 relay 共用的有界状态;只保留尚未提交 worker 的 Input,不包含 VM 对象。 struct Queue { + // 相邻同目标绝对移动可在队尾覆盖;其他动作是序列屏障,不能越过它们合并采样。 items: VecDeque, + // 统计活 Client,而非 Arc 总强引用;后台 relay 自己持 Arc 也不能阻止最后生产者关闭队列。 senders: usize, + // 最后生产者释放后置位,receive 醒来退出,不再尝试分派已无人负责的旧输入。 closed: bool, } +/// 队列锁与条件变量的共享入口,Client 只短暂加锁,后台等待不占用 GUI 事件循环。 struct Inbox { queue: Mutex, ready: Condvar, } +/// shell::app 持有的原生输入发送句柄;后台 relay 负责串行等待 worker 确认,GUI 不等待 VM 执行。 pub struct Client(Arc); impl Clone for Client { + /// GUI 多个生产者复制句柄时同步增加逻辑生产者数,保证 Drop 不把仍在用的队列提前关闭。 fn clone(&self) -> Self { self.0.queue.lock().unwrap().senders += 1; Self(self.0.clone()) } } impl Drop for Client { + /// 原生生产者离开时减少计数;最后一个关闭、清空并唤醒 relay,不因 relay 自己的 Arc 而挂住线程。 fn drop(&mut self) { if let Ok(mut q) = self.0.queue.lock() { + // 锁内计数/关闭与 send/enqueue 互斥,关闭后不留下新的待发报文。 q.senders -= 1; if q.senders == 0 { + // 退出丢弃尚未发送输入,不用旧会话的残留交互拖延窗口关闭。 q.closed = true; q.items.clear(); self.0.ready.notify_all(); @@ -217,37 +250,44 @@ impl Drop for Client { } } } +/// Client::send 在锁内调用,最多保留 64 条,队尾同目标 pointermove 可覆盖成最新绝对采样。 fn enqueue(queue: &mut VecDeque, input: Input) -> bool { if input.kind == "pointermove" && let Some(last) = queue.back_mut() && last.kind == "pointermove" && (last.window, last.session, last.mount) == (input.window, input.session, input.mount) { + // 只覆盖紧邻且同窗/同会话/同 Mount 的移动;按下/抬起或换目标都成为不可跨越的屏障。 *last = input; return true; } if queue.len() >= 64 { + // 容量检查在可合并路径之后,因此单一连续移动不会因同类采样填满队列。 return false; } queue.push_back(input); true } impl Inbox { + /// relay 的接收循环调用,条件变量等待新事件或关闭,唤醒后再次检查以处理虚假唤醒。 fn receive(&self) -> Option { + // 锁损坏视为关闭,不尝试从不可靠队列恢复一条可能重复的输入。 let mut q = self.queue.lock().ok()?; while q.items.is_empty() && !q.closed { + // wait 原子放锁并等待;GUI 生产者可入队,通知后重新取得同一状态锁。 q = self.ready.wait(q).ok()?; } if q.closed { None } else { q.items.pop_front() } } } impl Client { - /// 建立容量 64 的发送队列和单个后台中继线程,事件交给原生 VM worker;线程创建失败则返回错误。 + /// shell::app 初始化时调用,创建有界 Inbox 与单个 relay;RuntimeControl 直达 worker,不绕 HTTP 回环。 pub fn new( proxy: EventLoopProxy, namespace: Arc>, control: crate::api::RuntimeControl, ) -> anyhow::Result { + // 初始生产者就是返回的 Client;receiver 的 Arc 不计入 senders,避免接收线程自保活。 let inbox = Arc::new(Inbox { queue: Mutex::new(Queue { items: VecDeque::new(), @@ -256,19 +296,24 @@ impl Client { }), ready: Condvar::new(), }); + // move 闭包拥有接收 Arc 与发布状态只读入口,线程不取得 VM 所有权。 let receiver = inbox.clone(); thread::Builder::new() .name("tessera-native-events".into()) .spawn(move || { while let Some(mut input) = receiver.receive() { + // 每条输入出队时再看最新权威发布帧,连续采样不沿用排队前已经过时的布局版本。 if let Ok(current) = namespace.read() { if !refresh_continuous_input(&mut input, ¤t) { + // 目标消失/换会话时丢弃,不能把旧手势重定向到同位置的新出现。 continue; } } else { + // 共享发布状态不可读时不分派缺乏校验的输入,等待下一条正常事件。 continue; } if let Err(error) = control.dispatch_native(input) { + // worker 回执错误通过原生事件循环呈现诊断,不直接操作窗口或执行源回调。 let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed { message: error.to_string(), }); @@ -277,26 +322,27 @@ impl Client { })?; Ok(Self(inbox)) } - /// 非阻塞尝试入队;成功仅代表队列接收,满队列或后台线程关闭返回 false。 + /// shell::app 捕获输入时调用,非阻塞入队/合并后通知 relay;true 不表示 VM 已执行或帧已发布。 pub fn send(&self, input: Input) -> bool { + // 锁内仅处理纯数据队列,不把 worker 回执等待放在 GUI 路径。 let Ok(mut q) = self.0.queue.lock() else { return false; }; if q.closed { return false; } + // enqueue 的覆盖合并也算接收成功,relay 会读取最终最新样本。 let queued = enqueue(&mut q.items, input); if queued { + // 单 relay 只需唤醒一个等待者;关闭路径用 notify_all 保证退出不遗留等待。 self.0.ready.notify_one(); } queued } } -// Native gestures retain the captured Mount across publications. Refresh only -// the version at dequeue, never session, window or target. HTTP callers and -// discrete actions still receive strict stale-frame rejection. -/// 连续手势出队时只更新有效目标的帧版本;保留原 session、窗口和 Mount,离散事件不自动重试。 +/// Client relay 出队时调用,仅给指定类别的原生输入追随同目标新帧版本;不修改捕获目标和会话身份。 +/// HTTP 调用不走这个入口;click/标签等其余动作直接保留旧版本,后面仍会严格拒绝过期帧。 pub(crate) fn refresh_continuous_input( input: &mut Input, current: &crate::document::NamespaceState, @@ -315,6 +361,7 @@ pub(crate) fn refresh_continuous_input( | "pointerleave" | "pointercancel" ) { + // 未列入可追随类别的输入既不丢弃也不改写,在执行端按原几何版本验证。 return true; } // 选取事件原窗口的权威帧;窗口已经消失时丢弃,不能把手势改投主窗口。 @@ -328,6 +375,7 @@ pub(crate) fn refresh_continuous_input( .map(|document| &document.frame) }; let Some(frame) = frame else { + // 原窗口不再有权威帧,不能改选 current/main 去继续应用相机或指针动作。 return false; }; // 必须仍属同一运行时会话、帧无故障且原捕获 Mount 具备对应能力,才可更新布局版本。 @@ -337,16 +385,21 @@ pub(crate) fn refresh_continuous_input( || !frame.mounts.iter().any(|mount| { mount.id == input.mount && if input.kind.starts_with("viewport-") { + // 独立二维相机只要求原 Mount 仍为 CanvasViewport。 mount.canvas_viewport } else if input.kind.starts_with("scene-") { + // 3D 还需相机控制器,禁用控制器后旧手势不能继续改模型。 mount.scene3d_viewport && mount.camera_controls } else if input.kind.starts_with("pointer") { + // 原始指针必须仍有相应订阅能力,外框存在不足以证明可以继续接收。 mount.pointer_events } else { + // 剩余唯一可追随类别是 scroll,需保持滚动能力。 mount.scroll.is_some() } }) { + // 保留会话/目标边界,任一权威身份或能力不匹配都不能仅更新版本后继续发送。 return false; } // 只追随新版本,不改 session/window/mount,不重选目标;每条排队增量仍独立发送。 @@ -358,12 +411,14 @@ pub(crate) fn refresh_continuous_input( mod tests { use super::*; #[test] - /// 重复消费旧帧捕获的连续 pan,允许刷新到当前版本且保留每条 delta;离散 click 不改版本,会话/目标替换拒绝手势。 + /// Rust 测试框架调用:用真实持久 VM 反复分派旧帧 pan,验证版本追随不丢增量也不越过会话/目标边界。 fn queued_native_pan_keeps_each_delta_and_rejects_replaced_targets() { + // 建立可观测 camera.x 的最小场景,输入目标从实际发布帧选出,不硬编码出现 ID。 let (mut session, document) = crate::endor_host::ReplSession::load( "view=CanvasViewport(200,100,Scene2D(RectBlock(20,20,0)))\npage=Page()\npage.render(view)", "app.main", 1, 912, ).unwrap(); + // 找真实 CanvasViewport 出现,确保 refresh_continuous_input 的能力判定有实际帧数据。 let mount = document .frame .mounts @@ -371,6 +426,7 @@ mod tests { .find(|mount| mount.canvas_viewport) .unwrap() .id; + // 捕获初始版本的固定输入,后面每轮都克隆它,模拟输入生产速度超过刷新速度的队列。 let original = Input { window: 1, session: 912, @@ -383,6 +439,7 @@ mod tests { tab_drop: None, screen_position: None, }; + // current 表示 relay 每次出队时能读到的最新共享文档,不直接借用 VM 当前内部草稿。 let mut current = crate::document::NamespaceState { runtime_session: 912, document, @@ -390,6 +447,7 @@ mod tests { }; // 反复从旧帧原始手势克隆输入,验证每条增量都被刷新版本并累计,12×3 应得到 36。 for _ in 0..12 { + // 每轮都从最旧版本重建输入,只有 refresh 的行为能让后续 11 条继续有效。 let mut queued = original.clone(); assert!(refresh_continuous_input(&mut queued, ¤t)); current.document = session.dispatch(&queued).unwrap().document.unwrap(); @@ -399,26 +457,33 @@ mod tests { "36" ); // 离散点击不得自动刷新版本;随后验证会话替换和目标移除都会拒绝连续手势。 + // 同一信封改成离散 click,应只通过“不改写”快路,不享受连续手势的版本追随。 let mut click = original.clone(); click.kind = "click".into(); assert!(refresh_continuous_input(&mut click, ¤t)); assert_eq!(click.layout_version, original.layout_version); + // 模拟整程序替换:数字窗口/Mount 即使相同,旧 runtime_session 仍必须使手势失效。 current.runtime_session = 913; assert!(!refresh_continuous_input(&mut original.clone(), ¤t)); current.runtime_session = 912; + // 恢复原会话但删掉目标,证明能力/目标检查不只依赖 session 相等。 current.document.frame.mounts.clear(); assert!(!refresh_continuous_input(&mut original.clone(), ¤t)); } #[test] - /// 对 tab-drop 元数据、位置上限与 tab-detach 桌面坐标逐项改坏,检查辅助字段只在对应事件类型和预算内合法。 + /// Rust 测试框架调用,逐项破坏合法拖放报文,验证种类与辅助字段绑定、标签上限及多屏坐标范围。 fn drop_metadata_is_scoped_to_its_event_kind_and_bounded() { + // 从完整合法 JSON 起步,后面的每次修改只触发一种目标限制,避免无关字段同时掩盖失败原因。 let mut input:Input=serde_json::from_str(r#"{"window":1,"session":1,"layout_version":2,"mount":3,"kind":"tab-drop","tab_drop":{"window":2,"layout_version":4,"index":1}}"#).unwrap(); assert!(input.validate().is_ok()); + // tab_drop 元数据不可由普通 click 夹带,否则某层可能意外消费成跨窗口移动。 input.kind = "click".into(); assert!(input.validate().is_err()); input.kind = "tab-drop".into(); + // 数字 65 超过 MAX_PAGES=64 的结构上界,不需要真实 VM 窗口即可拒绝。 input.tab_drop.as_mut().unwrap().index = 65; assert!(input.validate().is_err()); + // 拆窗使用另一种辅助元数据,先去掉 tab_drop,再验证负屏幕坐标合法但极值不合法。 input.kind = "tab-detach".into(); input.tab_drop = None; input.screen_position = Some([-400, 200]); @@ -428,13 +493,15 @@ mod tests { } #[test] - /// 旧 JSON 未带 window 时默认主窗口以保持兼容;显式 window=0 仍是非法资源,不能当成默认值。 + /// Rust 测试框架调用,区分 serde 的缺字段默认与明确传零,保证兼容不掩盖非法资源身份。 fn legacy_event_window_defaults_but_zero_is_never_a_resource() { + // 故意省略 window,检查反序列化确实调用 default_window,而非字段初始零值。 let mut input: Input = serde_json::from_str(r#"{"session":1,"layout_version":2,"mount":3,"kind":"focus"}"#) .unwrap(); assert_eq!(input.window, crate::window::MAIN_WINDOW); assert!(input.validate().is_ok()); + // 显式修改为零必须拒绝,不能再次触发 default_window 纠正成主窗口。 input.window = 0; assert!(input.validate().is_err()); } @@ -444,28 +511,36 @@ mod tests { mod coalescing_tests { use super::*; #[test] + /// Rust 测试框架调用,验证生产端高频绝对移动可合并,动作顺序和跨窗口目标切换仍保持屏障。 fn producer_side_motion_coalescing_keeps_action_and_target_boundaries() { + // 默认主窗口、固定 session/Mount 的原始绝对位置采样,供后续复制成不同动作。 let template: Input = serde_json::from_str( r#"{"session":1,"layout_version":1,"mount":2,"kind":"pointermove","point":[0,0]}"#, ) .unwrap(); + // 直接测试 enqueue 的纯队列规则,不启动 relay 或 VM,避免异步消费影响可观察顺序。 let mut q = VecDeque::new(); for i in 0..1000 { + // 即使远超容量 64,相邻同目标 pointermove 也应持续覆盖唯一队尾项。 let mut sample = template.clone(); sample.point = Some([i as f32, 0.]); assert!(enqueue(&mut q, sample)); } assert_eq!(q.len(), 1); assert_eq!(q[0].point.unwrap()[0], 999.); + // down 是离散动作屏障,不能被下一次移动覆盖或跨过。 let mut down = template.clone(); down.kind = "pointerdown".into(); enqueue(&mut q, down); + // 新移动只能形成 down 后的一项,不能合并回 down 前的旧移动。 let mut moved = template.clone(); moved.point = Some([1200., 0.]); enqueue(&mut q, moved); + // up 再次切开动作序列,保证处理器观察先按下、后移动、再抬起。 let mut up = template.clone(); up.kind = "pointerup".into(); enqueue(&mut q, up); + // 相同 session/Mount 但换窗口也必须单独成项,目标比较不能漏掉 window。 let mut other = template; other.window = 2; enqueue(&mut q, other); diff --git a/src/runtime/io_tasks.rs b/src/runtime/io_tasks.rs index d1c0046..60d646c 100644 --- a/src/runtime/io_tasks.rs +++ b/src/runtime/io_tasks.rs @@ -2,7 +2,9 @@ //! Lease 由托管任务拥有,释放会撤销交付资格并取消可取消的后台工作;已经完成的结果还要由 VM worker 检查归属后才能交付。 //! 并发、队列、读取字节数、超时和 URL 协议均有边界,避免 provider 绕过运行时资源限制。 //! -//! Background providers carry plain data and weak task identities, never a VM. +//! runtime::tasks 为 readText/fetchText 创建 Task 时调用 Provider::submit,并把返回 Lease 放进 Task payload。 +//! Scheduler 轮询 Provider::take_ready 后在 owning VM 中核验弱身份、owner/key 与取消状态,再调用源回调。 +//! 所有 Provider 共用一个全进程有界线程池;每个运行时仍有自己的结果邮箱与租约额度,不互读 Task 堆。 use endor_core::{VmError, WeakObjectRef}; use std::{ collections::HashMap, @@ -22,13 +24,17 @@ use std::{ const MAX_TEXT: usize = 1024 * 1024; // 每个运行时的租约额度,独立于全进程 4 个执行线程和 64 项工作队列。 const MAX_ACTIVE: usize = 16; +// 全进程实际执行 OS I/O 的线程数;排队不等于已占一个执行线程。 const WORKERS: usize = 4; +// 工作队列全局上限,try_send 失败直接反馈给提交方,不让 VM 阻塞等空位。 const QUEUE: usize = 64; +// 成功/失败都只传字符串;不能把 VmError 内部可能关联的托管值或 VM 句柄送入后台线程。 type Completion = Result; #[derive(Debug, Default)] /// provider 共享的就绪结果与并发计数;内容只有纯数据和弱任务身份。 struct Shared { + // worker 写入、take_ready 排空,Lease::drop 也会移除本任务的晚到/未取结果。 ready: Mutex>, // 统计当前活租约而非正在执行的 OS 操作;完成结果被取走前,租约仍可能占用名额。 active: AtomicUsize, @@ -36,16 +42,18 @@ struct Shared { #[derive(Clone, Debug, Default)] /// 某个运行时拥有的后台 provider 能力,负责提交和取走完成结果。 pub struct Provider { + // Arc 可供后台 Job 访问邮箱,不代表 Job 拥有 Task 或能延长交付租约。 shared: Arc, } #[derive(Debug, Default)] /// 后台工作取消标记与可取消子进程,不拥有托管 VM。 struct Control { + // Release/Acquire 传播取消资格;最终写邮箱时还需在 ready 锁内再次检查。 cancelled: AtomicBool, process: Mutex>, } impl Control { - /// 撤销当前操作的后续交付资格,并执行对应取消清理。 + /// Lease 析构调用,可在 VM/租约线程执行;只撤资格/kill,不在调用线程 wait 进程退出。 fn cancel(&self) { // 先撤销结果交付资格,再尝试终止当前子进程;文件读取无法用此标记中断,只能丢弃晚到结果。 self.cancelled.store(true, Ordering::Release); @@ -61,15 +69,19 @@ impl Control { #[derive(Debug)] /// 后台工作种类及其纯文本参数,源调用的类型检查在提交前完成。 pub enum Request { + // 本地路径已在任务创建时做类型/长度校验,worker 再检查实际文件类型和大小。 ReadText(String), + // URL 会由 fetch_text 再验证 HTTP(S)/authority,传输不用 shell 解释。 FetchText(String), } -/// Only Task payloads own this lease. Provider threads hold plain control data. #[derive(Debug)] /// 仅托管 Task 拥有的交付租约;释放它会取消后台资格并清理晚到结果。 pub struct Lease { + // 归还名额与移除结果所需共享状态;不持有任务本体,避免后台根反向保活源程序。 shared: Arc, + // 带代际的弱身份用于邮箱键,Task 死亡后不能让旧结果命中复用槽位的新任务。 task: WeakObjectRef, + // 取消后台进程/晚到交付的纯原生控制对象,后台 Job 只共享它而不共享 Lease。 control: Arc, } impl Drop for Lease { @@ -78,6 +90,7 @@ impl Drop for Lease { // 租约释放立即取消交付资格;后台 OS 读取即使稍后结束,也不能再次唤醒旧 Task。 self.control.cancel(); if let Ok(mut ready) = self.shared.ready.lock() { + // 删除已到邮箱的结果;worker 同锁复查 cancelled,所以取消之后也不能重新留下结果。 ready.remove(&self.task); } // Lease 包在 Arc 中,只有最后一个拥有者释放才析构并归还一次额度;Job 不持有 Lease。 @@ -86,13 +99,17 @@ impl Drop for Lease { } /// 线程池队列中的纯数据工作,关联弱任务身份、控制和共享完成表。 struct Job { + // 操作种类及参数只含拥有字符串,不借用 VM 对象属性或源参数切片。 request: Request, + // 只用于交付结果地址,后台从不尝试升级弱引用。 task: WeakObjectRef, + // 共享取消状态/进程槽,让租约销毁可终止传输;Job 不持 Lease,因此不阻止取消。 control: Arc, + // 结果写入对应 Provider 的 mailbox,不把不同运行时结果混入一个全局表。 shared: Arc, } impl Provider { - /// 检查并发额度并取得任务 Lease,再把纯数据工作放入有界线程池队列。 + /// tasks 的 I/O 创建路径调用,原子预占额度、建立 RAII Lease 并非阻塞入队,返回交付资格而非执行结果。 pub fn submit( &self, task: WeakObjectRef, @@ -104,6 +121,7 @@ impl Provider { self.shared .active .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + // replacing 只放宽一次替换的瞬时名额,不为普通提交永久提升每运行时上限。 (active < MAX_ACTIVE + usize::from(replacing)).then_some(active + 1) }) .map_err(|_| VmError::ExecutionLimit("16 I/O tasks per runtime"))?; @@ -113,6 +131,7 @@ impl Provider { task, control: Arc::new(Control::default()), }); + // 池初始化也可能失败;lease 已建立,任何后续 ? 都会归还预占额度。 let sender = pool().map_err(|_| VmError::InvalidArgument("I/O provider unavailable"))?; // 非阻塞入队,不让调用 VM 等待后台吞吐;Job 只带参数、弱任务身份和原生取消/结果状态。 sender @@ -125,7 +144,7 @@ impl Provider { .map_err(|_| VmError::ExecutionLimit("I/O provider queue full or unavailable"))?; Ok(lease) } - /// 一次取走当前邮箱内全部 (弱任务身份, 纯文本结果),实际托管交付仍由 VM worker 校验后执行。 + /// Scheduler::poll 在 VM worker 调用,排空此 Provider 的已就绪纯数据,不在邮箱锁中执行源回调。 // 一次 drain 所有已就绪结果;移走纯数据不代表任务仍可交付,VM worker 还需检查弱身份和归属。 pub fn take_ready(&self) -> Result, VmError> { Ok(self @@ -137,14 +156,17 @@ impl Provider { .collect()) } } -/// 懒初始化共享工作线程和有界任务队列;初始化失败反馈给提交方。 +/// Provider::submit 调用,懒初始化全进程线程池;返回借用的 SyncSender,不为每个 VM 新建 I/O 线程。 fn pool() -> Result<&'static SyncSender, &'static str> { // 全进程只初始化一次,初始化失败也会记住失败结果;后续 submit 返回 unavailable,不反复创建线程。 static POOL: OnceLock, String>> = OnceLock::new(); POOL.get_or_init(|| { + // 队列只承载 Job;容量限制排队任务,不代替 Provider 自己的 MAX_ACTIVE 租约预算。 let (sender, receiver) = sync_channel::(QUEUE); + // std Receiver 不可 Clone,短锁共享接收端;拿到一个 Job 后放锁再并行执行 I/O。 let receiver = Arc::new(Mutex::new(receiver)); for index in 0..WORKERS { + // 每线程持接收端 Arc,不持任何 Provider/Task 的租约,空闲时阻塞 recv 而非轮询。 let receiver = receiver.clone(); thread::Builder::new() .name(format!("tessera-io-{index}")) @@ -153,15 +175,18 @@ fn pool() -> Result<&'static SyncSender, &'static str> { // std mpsc Receiver 通过 Mutex 共享;锁只覆盖取任务,实际 I/O 在离开该 match 后并行执行。 let job = match receiver.lock() { Ok(receiver) => receiver.recv(), + // 接收锁损坏时终止该线程,不在不可靠队列状态上继续读任务。 Err(_) => break, }; let Ok(job) = job else { + // 所有 sender 已断开就退出;这不是一个可重试的临时“队列为空”。 break; }; // 执行前先跳过已取消队列项,避免启动无用文件读取或 curl。 if job.control.cancelled.load(Ordering::Acquire) { continue; } + // 只进行原生文件/HTTP 操作,失败也编码成纯文本 Completion,留给 VM 线程交付。 let result = match &job.request { Request::ReadText(path) => read_text(path), Request::FetchText(url) => fetch_text(url, &job.control), @@ -170,6 +195,7 @@ fn pool() -> Result<&'static SyncSender, &'static str> { if let Ok(mut ready) = job.shared.ready.lock() && !job.control.cancelled.load(Ordering::Acquire) { + // 与 Lease::drop 用同一 ready 锁封住取消/插入竞争,晚到结果不能重新激活旧 Task。 ready.insert(job.task, result); } } @@ -181,7 +207,7 @@ fn pool() -> Result<&'static SyncSender, &'static str> { .as_ref() .map_err(|_| "I/O provider unavailable") } -/// 读取受字节上限限制的 UTF-8 文件内容,错误作为任务结果交付。 +/// pool 的 ReadText 分支在后台调用,返回受字节预算限制的 UTF-8 文本或可交付错误字符串。 fn read_text(path: &str) -> Completion { // 先按路径检查普通文件和已知长度,及早拒绝目录/超大源;实际打开后还会复核文件类型。 let metadata = std::fs::metadata(path).map_err(|error| error.to_string())?; @@ -191,6 +217,7 @@ fn read_text(path: &str) -> Completion { if metadata.len() > MAX_TEXT as u64 { return Err("text source exceeds 1 MiB".into()); } + // 路径检查与打开之间可能变化,所以打开后再次检查句柄实际指向的对象类型。 let file = File::open(path).map_err(|error| error.to_string())?; if !file .metadata() @@ -199,6 +226,7 @@ fn read_text(path: &str) -> Completion { { return Err("text source must be a regular file".into()); } + // 原始字节最后才严格转 UTF-8,不用有损替换悄悄改写用户文件内容。 let mut bytes = Vec::new(); // 再用流读取硬上限检测增长中的文件:多读一个字节即可判断超限,不能只依赖之前 metadata 长度。 file.take(MAX_TEXT as u64 + 1) @@ -210,15 +238,15 @@ fn read_text(path: &str) -> Completion { String::from_utf8(bytes).map_err(|_| "text source must be UTF-8".into()) } -/// No shell interpretation, curlrc, URL globbing, or non-HTTP redirects. -/// Authentication/custom headers are deliberately outside this text GET API. -/// 检查网络请求的 URL 协议、长度与凭据等约束,拒绝不受支持的输入。 +/// tasks 创建 fetchText 与 fetch_text 真正传输前都会调用,拒绝错误协议、过长 URL、控制字符和 userinfo。 +/// 这是受限文本 GET 接口的基本格式检查,不负责完整 URL 规范化,也不声明网络地址访问授权。 pub fn valid_url(url: &str) -> bool { // 控制字符必须在写 curl config 前拒绝,否则换行等可能破坏单个 url 配置项边界。 if url.len() > 8192 || url.chars().any(char::is_control) { return false; } let Some((scheme, rest)) = url.split_once("://") else { + // 只接受有显式 scheme 的 URL,不让 curl 猜协议或把路径当作另一类资源地址。 return false; }; if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { @@ -230,8 +258,7 @@ pub fn valid_url(url: &str) -> bool { !authority.is_empty() && !authority.contains('@') && !authority.chars().any(char::is_whitespace) } -/// Created only on an I/O worker. All early exits kill and reap the child. -/// 子进程作用域收尾保护,避免取消/失败路径遗留未回收进程。 +/// fetch_text 仅在 I/O worker 上创建的清理守卫;任何提前返回都取走、kill/wait 尚未回收的子进程。 struct Reaper<'a>(&'a Control); impl Drop for Reaper<'_> { /// 取走残留 curl 子进程后尝试 kill/wait,保证失败或取消路径也回收进程,且不持控制锁等待。 @@ -244,15 +271,18 @@ impl Drop for Reaper<'_> { } } } -/// 在取消、超时、协议和大小限制下取得 HTTP 文本;不把网络执行放在 VM 线程。 +/// pool 的 FetchText 分支调用,用系统 curl 做有界 HTTP GET;所有等待与进程回收都发生在后台线程。 fn fetch_text(url: &str, control: &Control) -> Completion { if !valid_url(url) { return Err("invalid HTTP(S) URL".into()); } #[cfg(unix)] + // Unix 固定系统路径,不从用户 PATH 选择任意同名可执行文件。 let executable = "/usr/bin/curl"; #[cfg(not(unix))] + // 非 Unix 使用系统环境可解析的 curl.exe,找不到时反馈明确的传输不可用错误。 let executable = "curl.exe"; + // 参数数组直接交给进程 API,URL 另外经 stdin 配置传入,全程不经 shell 拼接。 let mut child = Command::new(executable) // --disable 放首项关闭 curlrc,--globoff 禁用 URL 展开,首次请求与重定向均限制 HTTP(S)。 // 限制重定向数、连接/总超时和下载大小;--compressed 后仍会用下面字节读取上限限制解压文本。 @@ -287,26 +317,30 @@ fn fetch_text(url: &str, control: &Control) -> Completion { .spawn() .map_err(|_| "HTTP transport unavailable: system curl is required".to_owned())?; // 取走管道后把 Child 放入共享 Control,取消线程可随时 kill;Reaper 覆盖其后的所有提前返回。 + // 管道配置是上方 spawn 的内部前提,take 后分别归配置写入和正文读取负责。 let mut input = child.stdin.take().expect("piped stdin"); + // 取走 stdout 后 Child 仍可放入共享进程槽供取消线程 kill。 let output = child.stdout.take().expect("piped stdout"); match control.process.lock() { Ok(mut slot) => *slot = Some(child), Err(_) => { + // 尚未安装 Reaper 且不能存入共享槽,当前线程必须亲自 kill/wait 清理已启动子进程。 let _ = child.kill(); let _ = child.wait(); return Err("HTTP process lock poisoned".into()); } } + // 从这里起即使写配置/读正文/解析状态失败,也会通过 Drop 回收进程而不留下僵尸。 let _reaper = Reaper(control); if control.cancelled.load(Ordering::Acquire) { return Err("request cancelled".into()); } - // URL stays out of process arguments. Reject controls before config escaping. // URL 经 curl 配置的引号/反斜杠转义后走 stdin,避免出现在进程参数;这里不经过 shell。 let escaped = url.replace('\\', "\\\\").replace('"', "\\\""); writeln!(input, "url = \"{escaped}\"").map_err(|_| "HTTP transport input failed".to_owned())?; // 关闭 stdin 表示配置结束,否则 curl 可能继续等待输入,无法开始/完成请求。 drop(input); + // stdout 同时含正文与 curl --write-out 状态尾,预算须为尾部保留四字节。 let mut bytes = Vec::new(); output // 预留结尾换行和三位状态码共 4 字节,再额外读 1 字节探测正文超限。 @@ -318,10 +352,12 @@ fn fetch_text(url: &str, control: &Control) -> Completion { } // 读完输出后用 try_wait 检查退出;等待间隔先释放 process 锁,让其他线程仍可取消进程。 let status = loop { + // 短暂持锁查询 child 状态;睡眠前释放槽锁,让 Lease::drop 仍可及时 kill。 let mut slot = control .process .lock() .map_err(|_| "HTTP process lock poisoned".to_owned())?; + // 槽内 Child 在本请求结束前应存在;缺项是状态错误,不继续对另一进程等待。 let child = slot.as_mut().ok_or("HTTP process missing")?; if let Some(status) = child .try_wait() @@ -335,6 +371,7 @@ fn fetch_text(url: &str, control: &Control) -> Completion { thread::sleep(Duration::from_millis(2)); }; if control.cancelled.load(Ordering::Acquire) { + // 完成传输并不自动取得交付资格,取消优先于刚读到的正常 HTTP 返回值。 return Err("request cancelled".into()); } // --write-out 的末尾四字节应是换行加三位状态码;checked_sub 处理空/短输出,不直接倒数切片。 @@ -351,6 +388,7 @@ fn fetch_text(url: &str, control: &Control) -> Completion { return Err(format!("HTTP {code}")); } if !status.success() { + // 区分 curl 的超时/大小错误,其余保留退出码供诊断;信号终止没有普通退出码。 return Err(match status.code() { Some(28) => "HTTP request timed out".into(), Some(63) => "HTTP text exceeds 1 MiB".into(), diff --git a/src/runtime/vm_sequence.rs b/src/runtime/vm_sequence.rs index 341fa1e..098b33d 100644 --- a/src/runtime/vm_sequence.rs +++ b/src/runtime/vm_sequence.rs @@ -1,7 +1,10 @@ //! 构造共享 VM 调用序列与同帧循环驱动的字节码。 //! 用托管函数串联步骤,避免为每个布局节点反复创建独立解释器调用;重复驱动会保留同一个执行帧和预算域。 //! -//! Validated call sequences share the current VM budget and explicit frames. +//! mount::synchronize 与 event_dispatch::dispatch 用 build 串起生命周期/事件回调。 +//! source_layout/source_paint 用 repeat_call 循环推进出现位置,并用 collect_and_continue 收集单次源调用结果。 +//! 本模块只生成驱动字节码和托管函数,不在 Rust 中自行解释语句,也不替调用方重置 VM 预算。 +//! 原生调用返回 Invoke 后,VM 在既有帧循环中执行驱动,因此可挂起工作仍有明确的 owning VM。 use endor_core::{ CodeLayout, EndorCode, EndorCodeObject, Instruction, NativeContext, ObjectRef, Opcode, Value, VmError, @@ -10,6 +13,7 @@ use endor_core::{ /// 将零参数调用列表编成一个托管驱动,按输入顺序执行,返回最后一次调用的值。 /// 空列表返回 Nil。所有调用都在调用者的 VM 预算内运行,避免宿主逐个调用时重置预算。 /// name 用于诊断位置;desktop 用来给生成函数挂接桌面 Function 类型。 +/// 调用者是 Mount 生命周期同步和事件路由;返回值是驱动函数身份,不是已经执行完的最后结果。 pub fn build( cx: &mut NativeContext<'_>, calls: Vec, @@ -18,13 +22,17 @@ pub fn build( ) -> Result { // 每个函数放入常量池。LoadConst/PushNull/Call 组成零参数调用, // 中间结果由 PopTop 丢弃;最后结果留在栈顶交给 ReturnValue。 + // 每个中间调用至多四条指令,额外空间覆盖空序列和 ReturnValue;只是预留容量,不是指令预算。 let mut instructions = Vec::with_capacity(calls.len() * 4 + 2); + // 常量池强持有每个 callable,驱动存活期间不依赖调用方的 Rust Vec 保活。 let mut constants: Vec = calls.into_iter().map(Value::Object).collect(); for index in 0..constants.len() { + // LoadConst 放函数,PushNull 建立 VM Call 所需前缀,Call 0 执行无显式实参的回调。 instructions.push(Instruction::new(Opcode::LoadConst, index as u32)); instructions.push(Instruction::simple(Opcode::PushNull)); instructions.push(Instruction::new(Opcode::Call, 0)); if index + 1 != constants.len() { + // 只丢中间结果,避免把最后一个结果也弹掉而让 ReturnValue 面对空栈。 instructions.push(Instruction::simple(Opcode::PopTop)); } } @@ -34,18 +42,21 @@ pub fn build( instructions.push(Instruction::new(Opcode::LoadConst, 0)); } instructions.push(Instruction::simple(Opcode::ReturnValue)); + // 编码阶段校验字节码布局,诊断位置使用宿主 name 而不是伪造用户源码行列。 let code = EndorCode::from_instructions(&instructions, constants, CodeLayout::default())? .with_location(name, "", 1); + // NativeContext 暂持代码根,输出函数会通过托管边继续拥有代码与常量。 let code = cx.allocate(EndorCodeObject::new(code))?; + // 驱动只读取常量,无需共享用户字典,空私有 globals 不污染 REPL 或主模块。 let globals = cx.new_dict()?; + // 函数作为本次原生调用的输出被提升,其余临时拥有根由 NativeContext 退出时释放。 let driver = cx.new_function(name, code, globals)?; crate::ui_bridge::attach_desktop_type(cx, driver, "Function", desktop)?; Ok(driver) } -/// A fixed-size driver. Each callback returns whether another occurrence is -/// needed; delegated source calls and all iterations share this VM budget. -/// 构造重复调用同一托管函数的驱动,保持共享参数与引用生命周期。 +/// source_layout/source_paint 安装一轮出现遍历时调用,生成固定大小的重复驱动,不按节点数展开字节码。 +/// callback 每次返回 Bool,false 退出;同帧回跳让所有出现和委托源函数共用当前 VM 的执行预算。 pub fn repeat_call( cx: &mut NativeContext<'_>, callback: ObjectRef, @@ -60,6 +71,7 @@ pub fn repeat_call( Instruction::new(Opcode::Call, 0), ]; repeat_while(&mut instructions)?; + // 条件已由 PopJumpIfFalse 消费;退出时重新装 Nil,不能把最后条件冒充业务返回值。 instructions.extend([ Instruction::new(Opcode::LoadConst, 1), Instruction::simple(Opcode::ReturnValue), @@ -73,9 +85,8 @@ pub fn repeat_call( ) } -/// Prepare only the current occurrence, collect its result, then continue the -/// outer driver. The helper frame and its managed captures die before the next. -/// 将一步调用结果收集到驱动状态,再安排下一步调用。 +/// source_layout/source_paint 的推进回调调用,为当前出现生成“调用源方法→collector 收结果→返回 true”的助手。 +/// 每次只捕获当前源函数/实参,不预先为全部节点建立助手帧;外层重复驱动再决定下一次出现。 pub fn collect_and_continue( cx: &mut NativeContext<'_>, collector: ObjectRef, @@ -86,10 +97,13 @@ pub fn collect_and_continue( ) -> Result { // 常量布局固定为 collector、function、各实参、true。 // 先把 collector 的调用前缀压栈,再执行 function,使其返回值直接成为 collector 的唯一实参。 + // count 同时确定 Call 的显式参数数和 true 常量的池位置,不能把 collector 的一参计入它。 let count = arguments.len(); + // 先保存两个 callable,再追加业务实参;这份强边关系使源调用挂起期间输入仍可达。 let mut constants = vec![Value::Object(collector), Value::Object(function)]; constants.extend(arguments); constants.push(Value::Bool(true)); + // 先压 collector 的调用前缀,再压 function 的调用前缀;内部返回值自然成为外部单一实参。 let mut instructions = vec![ Instruction::new(Opcode::LoadConst, 0), Instruction::simple(Opcode::PushNull), @@ -97,6 +111,7 @@ pub fn collect_and_continue( Instruction::simple(Opcode::PushNull), ]; for index in 0..count { + // 参数常量从 2 开始,0/1 已被 collector/function 占用,保持源方法原始实参顺序。 instructions.push(Instruction::new(Opcode::LoadConst, index as u32 + 2)); } // 收集器返回值不参与外层调度;丢弃后返回 true,通知重复驱动继续取下一项。 @@ -110,7 +125,7 @@ pub fn collect_and_continue( driver(cx, instructions, constants, name, desktop) } -/// 建立承载整个步骤序列的托管函数,使续体可以在同一个 VM 帧中恢复。 +/// repeat_call/collect_and_continue 的共同分配助手,把已组装指令与常量变成有宿主 Function 类型的 callable。 fn driver( cx: &mut NativeContext<'_>, instructions: Vec, @@ -118,34 +133,42 @@ fn driver( name: &'static str, desktop: ObjectRef, ) -> Result { + // 编码常量池与指令后带上宿主诊断位置;字节码有效性由 Aster 的代码构造接口检查。 let code = EndorCode::from_instructions(&instructions, constants, CodeLayout::default())? .with_location(name, "", 1); + // NativeContext 跟踪临时代码根;函数的 trace 边持有代码,不能只靠 Rust ObjectRef 数值保活。 let code = cx.allocate(EndorCodeObject::new(code))?; + // 生成驱动不查用户名字,私有 globals 隔离它的空命名环境。 let globals = cx.new_dict()?; + // 返回函数根由原生输出机制提升,代码和 globals 的临时根在域结束后可安全清理。 let function = cx.new_function(name, code, globals)?; crate::ui_bridge::attach_desktop_type(cx, function, "Function", desktop)?; Ok(function) } -/// Consume the condition on the stack: true repeats this same frame from zero, -/// false falls through. Operand widths include EXTENDED_ARG code units. -/// 构造由继续条件决定是否再执行一轮的同帧循环。 +/// repeat_call 和本模块测试调用,在现有指令尾部追加消费 Bool 条件的分支与回跳。 +/// true 回到本驱动偏移零,false 跳过回跳指令;不新建调用帧,因此不会用递归栈模拟循环。 pub fn repeat_while(instructions: &mut Vec) -> Result<(), VmError> { // 跳转单位是编码后的双字节 code unit,不是 Instruction 数量。 // 大操作数会生成 EXTENDED_ARG,因此必须编码后计算实际长度。 let body = Instruction::encode_all(instructions)?.len() / 2; + // 先用最短零操作数估计回跳宽度,下面迭代直到最终操作数与编码宽度自洽。 let mut back = Instruction::new(Opcode::JumpBackward, 0); // 回跳距离包含自身宽度;而距离变大又可能扩大自身编码宽度。 // 迭代到宽度/距离一致后,false 跳过回跳指令,true 回到整个驱动的起点。 loop { + // units 是当前回跳指令实际占用的 code unit,可能因 EXTENDED_ARG 大于一。 let units = Instruction::encode_all(&[back])?.len() / 2; + // 距离覆盖原 body、一个条件分支和回跳自身;目标为开头,不是仅回到上一个 Call。 let distance = u32::try_from(body + 1 + units) .map_err(|_| VmError::ExecutionLimit("layout bytecode"))?; if back.argument == distance { + // 已收敛时 false 跳过 units 个单元,true 落到 JumpBackward 回到开头。 instructions.push(Instruction::new(Opcode::PopJumpIfFalse, units as u32)); instructions.push(back); return Ok(()); } + // 更新操作数后再次编码量宽度;不能只计算一次,跨 255 等边界可能新增 EXTENDED_ARG。 back.argument = distance; } } @@ -155,11 +178,12 @@ mod tests { use super::*; use endor_core::{ExecutionLimits, Vm}; #[test] - /// 生成超过单字节跳距的循环,false 条件正常返回,true 条件只耗尽指令预算而非一帧预算;释放后对象数归零。 + /// Rust 测试框架调用,构造大于单字节跳距的循环,分别验证退出、指令预算及无泄漏清理。 fn repeated_bytecode_handles_extended_operands_and_keeps_one_frame() { // 300 个 Nop 强制跨越单字节跳转距离:false 应正常退出;true 应耗尽指令预算, // 而非耗尽帧预算,从而验证循环确实复用同一个执行帧。 for condition in [false, true] { + // Nop 扩大 body 编码长度,与实际布局节点数量无关,直接覆盖回跳扩展操作数的边界。 let mut instructions = vec![Instruction::simple(Opcode::Nop); 300]; instructions.push(Instruction::new(Opcode::LoadConst, 0)); repeat_while(&mut instructions).unwrap(); @@ -167,15 +191,20 @@ mod tests { Instruction::new(Opcode::LoadConst, 1), Instruction::simple(Opcode::ReturnValue), ]); + // 条件从常量池读取,false 分支跳过回跳后装入 Nil 并正常返回。 let code = EndorCode::from_instructions( &instructions, vec![Value::Bool(condition), Value::Nil], CodeLayout::default(), ) .unwrap(); + // 每种条件独立 VM,统计回收时不受另一轮遗留对象影响。 let mut vm = Vm::new().unwrap(); + // 将纯代码值注册到 VM,执行结束后显式释放此拥有根。 let code = vm.new_code(code).unwrap(); + // 空全局字典满足执行接口,不引入额外用户对象。 let globals = vm.new_dict().unwrap(); + // 故意只允许一帧:如果实现递归重复而非同帧跳转,会得到错误的帧数耗尽。 let result = vm.execute_with_limits( code, globals, @@ -185,11 +214,13 @@ mod tests { }, ); if condition { + // true 永不自然退出,应耗尽 instructions 而非 frames,证明循环复用了同一帧。 assert!(matches!( result, Err(VmError::ExecutionLimit("instructions")) )); } else { + // false 只走一次 body 然后返回 Nil,证明分支跳过长度包含完整扩展回跳指令。 assert_eq!(result.unwrap(), Value::Nil); } vm.release(code).unwrap(); -- 2.52.0 From e7f387ed9f6bc114cd5c9b2b1c6dcc2d99b48f7f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:13:43 +0800 Subject: [PATCH 03/78] docs: explain task scheduling, VM sessions and API control flow --- src/runtime/api.rs | 167 ++++++++++++++++----- src/runtime/endor_host.rs | 307 ++++++++++++++++++++++++++++++-------- src/runtime/tasks.rs | 255 +++++++++++++++++++++++++------ 3 files changed, 581 insertions(+), 148 deletions(-) diff --git a/src/runtime/api.rs b/src/runtime/api.rs index 28329a6..ac91802 100644 --- a/src/runtime/api.rs +++ b/src/runtime/api.rs @@ -2,6 +2,9 @@ //! HTTP 线程不操作 VM;共享 NamespaceState 只保存最近完整发布文档和资源命令。提交序号用来拒绝已被较新 PUT/DELETE 取代的结果。 //! 关闭流程先请求运行时有界清理,再通过完成通知让原生事件循环退出。 //! +//! main::main 调用 start,shell 持有 RuntimeControl;HTTP 请求经 handle/submit 入队,原生输入经 dispatch_native 入同一队列。 +//! worker 子模块拥有实际 ReplSession;本文件的线程循环只决定继续哪个请求、何时等待、如何响应和退出。 +//! 本地端点、观察器和 EventLoopProxy 都是 Tessera 宿主适配,不能把整个文件直接作为无 GUI 的 Nexus 核心。 mod guide; mod worker; @@ -34,34 +37,47 @@ use crate::{ endor_host, }; -// 只监听 IPv4 回环固定端口,接口用于本进程原生输入和本机 agent,不是对外网络服务器。 +// 只监听 IPv4 回环固定端口,供本机 agent/Console HTTP 调用;原生输入现在直接走同一 worker 队列。 pub const API_ADDRESS: &str = "127.0.0.1:4737"; +// 请求流读取与 endor_host 的源预算一致,Content-Length 检查不能替代流上的硬上限。 const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64; +// GUI 更新回调只投递原生通知,不携带 VM/托管引用,也不在调用线程直接绘制窗口。 type Notify = Arc; #[derive(Clone, Debug)] /// 运行时清理的摘要与可选错误,供最终原生退出结果报告。 pub struct ShutdownOutcome { + // worker 生成的清理数量/故障摘要,供退出日志与 shell 最终报告使用。 pub summary: String, + // 清理失败时保留文本,不能因为退出事件已经发出就把失败说成正常关闭。 pub error: Option, } +// 完成槽和条件变量共享同一生命周期,退出发起者可有限等待或由 GUI ShutdownComplete 事件唤醒。 type ShutdownCompletion = Arc<(Mutex>, Condvar)>; #[derive(Clone)] /// 跨线程的退出控制句柄,使用有界通知和完成条件变量,不携带 VM。 pub struct RuntimeControl { + // 独立容量一的关闭通道,即使正常输入队列满了也能通知 worker 优先清理。 sender: SyncSender<()>, + // 合并重复退出请求,并让 HTTP/原生输入尽早拒绝新执行工作。 requested: Arc, + // worker 在退出收尾后填写结果,wait_shutdown 不在 GUI 内自行执行清理。 completion: ShutdownCompletion, + // start_server_observed 一次性装入共享执行队列,不能让每个原生窗口自行创建另一个 VM worker。 native_sender: Arc>>, + // 只读当前 namespace/submission 以给原生 job 盖身份,不包含 VM 对象图。 namespace: Arc>, } impl RuntimeControl { - /// Only the native input relay calls this blocking acknowledgement path. - /// Window threads still use a bounded, nonblocking Input sender. + /// events::Client 的后台 relay 调用,向共享 worker 队列发送原生信封并等回执;GUI 仍只非阻塞发给 Client。 pub(crate) fn dispatch_native(&self, input: crate::events::Input) -> Result<()> { + // 检查退出门闩后才创建回执/工作,不让已关闭域继续排入源回调。 anyhow::ensure!(!self.is_exiting(), "application is exiting"); + // 一个输入一个回执,relay 等待不会阻塞 GUI;容量一足以让 worker 写回后继续自身循环。 let (done, completed) = sync_channel(1); + // 短读锁只复制当前接受身份,发送/等待都在锁外,worker 发布时仍可取得写锁。 let job = { + // 原生来源由构造类型决定,HTTP 头或 JSON 不能伪造 RequestReply::Native。 let state = self .namespace .read() @@ -86,7 +102,7 @@ impl RuntimeControl { .map_err(anyhow::Error::msg) } - /// 只发出一次退出请求,唤醒 worker 执行清理,并通过完成句柄通知原生循环。 + /// shell 关闭流程调用,只通知一次,由 owning worker 清理 VM;true 表示已排入或此前已请求,不等于已清理成功。 pub fn request_shutdown(&self) -> bool { // 原子标志合并重复退出请求;已请求直接返回 true,不在 GUI 线程重复排队清理。 if self.requested.swap(true, Ordering::AcqRel) { @@ -100,14 +116,17 @@ impl RuntimeControl { false } } - /// 读取退出标志,使窗口事件和新输入尽早停止进入运行时。 + /// shell/原生 relay 查询退出请求状态,不读取完成槽,也不能由此推断资源已全部清理。 pub fn is_exiting(&self) -> bool { self.requested.load(Ordering::Acquire) } - /// 在明确超时内等待 worker 的清理结果,不能无限阻塞退出。 + /// shell 最终退出收尾调用,有限等待 worker 保存的 ShutdownOutcome;None 为未取得结果,不是成功。 pub fn wait_shutdown(&self, timeout: std::time::Duration) -> Option { + // 同一锁保护可选结果与等待谓词,避免“检查为空后错过通知”的竞争。 let (lock, ready) = &*self.completion; + // 锁异常直接返回 None,不在未知完成状态下继续阻塞。 let result = lock.lock().ok()?; + // wait_timeout_while 反复检查 Option,返回前重新拿锁,克隆纯文本结果交还 shell。 let (result, _) = ready // 条件变量处理虚假唤醒,直到完成槽非空或超时;返回 None 也可能来自锁中毒,不能当作清理成功。 .wait_timeout_while(result, timeout, |result| result.is_none()) @@ -117,38 +136,53 @@ impl RuntimeControl { } /// worker 持有的关闭接收端、控制状态和 GUI 完成通知代理。 struct Shutdown { + // worker 每个控制边界 try_recv,关闭信号优先于下一片 VM 刷新或下一条普通输入。 receiver: Receiver<()>, + // 保存完成槽及已请求门闩,与 shell 手中的句柄是同一共享状态。 control: RuntimeControl, + // 完成后向原生事件循环发消息,worker 不直接调用窗口退出/绘制 API。 proxy: EventLoopProxy, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] /// 送入执行队列的请求类别,区分完整源码、持久输入、事件与重置。 enum RequestKind { + // 原生或 HTTP 输入,均由已有运行时执行,不启动新的程序堆。 Event, + // 完整程序 PUT:候选成功后才替换旧域和发布文档。 Code, + // 隔离表达式求值,不写活程序或 Console 的 globals。 Expression, + // 在真实持久 globals 上执行一轮交互,运行错误不回滚此前成功语句。 Repl, + // 显式清理/替换 Console 上下文,与完整 DELETE/code 的文档清空不同。 ResetRepl, } -/// 保留原 HTTP 请求与提交身份的执行工作,完成或失败后由 worker 回应。 +/// submit/dispatch_native 产生的纯数据工作信封,保留回应端和接受身份,worker 完成后才回应或确认过期。 struct BuildRequest { + // 原 HTTP 请求的观察包装或原生一次性回执,不能 Clone 成多次响应。 request: RequestReply, + // 原生来源只由进程内构造,HTTP 输入的事件 JSON 仍保存在 source 中由 worker 解码。 native: Option, + // 执行语义标签,不从请求体猜是替换程序还是追加 Console。 kind: RequestKind, + // 已限制字节数并验证 UTF-8 的正文;原生事件无需字符串往返因此为空。 source: String, + // 路由所指 namespace,执行/发布时须仍匹配当前共享状态。 namespace: String, + // 完整提交的代际号,worker 核验后才允许替换发布值,旧结果不能覆盖新 PUT/DELETE。 submission: u64, } -/// 绑定回环 API,启动请求接收与单 VM worker,返回活动日志及一次性退出控制句柄。 +/// main::main 初始化时调用,绑定本机 API 并启动两个线程,返回 shell 使用的活动记录和 RuntimeControl。 pub fn start( namespace: Arc>, proxy: EventLoopProxy, ) -> Result<(Activity, RuntimeControl)> { // 退出控制仅需一个消息槽,正常执行请求另走容量8队列,避免工作排满后无法通知关闭。 let (sender, receiver) = sync_channel(1); + // HTTP、shell 和 worker 共享请求/完成门闩,原生队列指针由实际线程启动函数一次性填入。 let control = RuntimeControl { sender, requested: Arc::new(AtomicBool::new(false)), @@ -156,6 +190,7 @@ pub fn start( native_sender: Arc::new(OnceLock::new()), namespace: namespace.clone(), }; + // worker 专用接收端与通知代理;不复制 Receiver 给多个执行线程抢同一退出消息。 let shutdown = Shutdown { receiver, control: control.clone(), @@ -165,9 +200,12 @@ pub fn start( let server = Server::http(API_ADDRESS) .map_err(|error| anyhow::anyhow!(error)) .with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?; + // 无 VM/文档 payload 的更新通知,GUI 收到后自行读最近完整 NamespaceState。 let notify: Notify = Arc::new(move || { + // 事件循环已退出时通知失败无需再触发 VM 故障,发布值本身仍由共享状态保存。 let _ = proxy.send_event(AppEvent::DocumentUpdated); }); + // Network/Console 观察器共享纯数据有界日志,和 VM 的 globals/堆状态分开。 let activity = Arc::new(Mutex::new(ActivityLog::default())); start_server_observed( Arc::new(server), @@ -182,7 +220,7 @@ pub fn start( } #[cfg(test)] -/// 为本地监听服务建立请求处理线程,并把执行请求转交有界队列。 +/// API 测试调用,使用测试自己绑定的 Server,不安装原生退出/输入端口,复用真实路由与 worker。 fn start_server( server: Arc, namespace: Arc>, @@ -199,7 +237,7 @@ fn start_server( ) } -/// 启动带活动观察的 HTTP 服务,将请求状态变化通知管理界面。 +/// start 与 start_server 共用线程启动器,建立唯一执行队列、VM worker 和只负责路由的 HTTP 接收线程。 fn start_server_observed( server: Arc, namespace: Arc>, @@ -212,18 +250,23 @@ fn start_server_observed( // HTTP 接收与 VM 执行分离,最多8个排队请求;源代码只在单个 endor-worker 中操作 VM。 let (sender, receiver) = sync_channel::(8); if let Some(native_port) = native_port { + // 原生输入接入同一个 sender,不能有绕过串行执行/刷新约束的第二条解释器路径。 native_port .set(sender.clone()) .map_err(|_| anyhow::anyhow!("native input queue already attached"))?; } + // worker 线程与 HTTP 线程各持发布状态 Arc,任何一方都不把读写锁贯穿源代码执行。 let worker_namespace = namespace.clone(); + // worker 的发布通知与 HTTP 观察通知用途不同,均只投递原生事件。 let worker_notify = notify.clone(); + // HTTP 线程仅借门闩判断是否拒绝新执行请求,不持 Shutdown 的独占接收端。 let exiting = shutdown .as_ref() .map(|shutdown| shutdown.control.requested.clone()); thread::Builder::new() .name("tessera-endor-worker".into()) .spawn(move || { + // 所有实际 VM 会话只在这个线程构建/操作,Default 此时尚未创建任何用户程序。 let mut worker = Worker::default(); // 分片请求跨循环保留原 HTTP 请求和继续句柄;同一时刻只推进一个有未完成 VM 执行的输入。 let mut pending: Option<(BuildRequest, PendingRequest)> = None; @@ -234,13 +277,16 @@ fn start_server_observed( // 退出优先于下一量子:先撤去待响应请求,再由 shutdown_runtime 清理 VM 续体与生命周期资源。 if let Some((job, input)) = pending.take() { drop(input); + // 客户端可能已断开,尽力回应后仍继续清理,不能因 socket 失败阻止应用退出。 let _ = job .request .respond(error_response(StatusCode(503), "application is exiting")); } + // 清理完成文本含可能的错误,不因收到了退出请求就直接向 GUI 报成功。 let outcome = worker.shutdown_runtime(); // 排队请求全部明确回应503,不能留着客户端无期限等待已经停止的执行线程。 while let Ok(job) = receiver.try_recv() { + // 已接受但未执行的请求明确发 503;发送失败仅表示接收者已离开,不再重新入队。 let _ = job .request .respond(error_response(StatusCode(503), "application is exiting")); @@ -257,12 +303,14 @@ fn start_server_observed( } } eprintln!("{}", outcome.summary); + // 共享完成槽同时服务有限等待和 GUI 退出事件,先存 outcome 再通知。 let (lock, ready) = &*shutdown.control.completion; if let Ok(mut result) = lock.lock() { *result = Some(outcome.clone()); // 先在锁内保存清理结果再唤醒等待者,同时用 AppEvent 通知原生事件循环退出,两条路径复用同一 outcome。 ready.notify_all(); } + // 完成槽已先保存 outcome,GUI 事件投递失败不抹掉可由 wait_shutdown 取得的结果。 let _ = shutdown.proxy.send_event(AppEvent::ShutdownComplete { error: outcome.error, }); @@ -273,12 +321,11 @@ fn start_server_observed( match worker.resume_request(&job, input, &worker_namespace, &worker_notify) { RequestProgress::Pending(input) => { pending = Some((job, input)); - // A soft VM quantum is a control boundary, not a - // minimum-duration time slice. Yield to other ready - // threads without charging a timer delay per quantum. + // 软量子只提供控制边界,不强制睡固定毫秒,避免慢布局每片额外累计等待延迟。 thread::yield_now(); } RequestProgress::Complete(response) => { + // 一次性回应消费 job;客户端断开不重执行已完成业务输入,否则会重复副作用。 let _ = job.request.respond(response); } } @@ -286,6 +333,7 @@ fn start_server_observed( } // 空闲输入边界先推进视口刷新与任务,待执行刷新/协作任务时零等待,否则最多100ms检查一次。 worker.refresh_viewport(&worker_namespace, &worker_notify); + // 活跃视觉续体/可运行协程应立即再推进;只有未来计时/I/O 等待时才最多阻塞 100ms。 let wait = if worker.repl.as_ref().is_some_and(|(_, runtime)| { runtime.has_pending_refresh() || runtime.has_cooperative_tasks() }) { @@ -295,8 +343,10 @@ fn start_server_observed( }; match receiver.recv_timeout(wait) { Ok(job) => { + // begin_request 既可能立即完成,也可能要求等待旧刷新或保存新的候选续体。 match worker.begin_request(&job, &worker_namespace, &worker_notify) { RequestProgress::Pending(input) => { + // 请求与继续句柄绑定留到下轮,不能先回应成功后再执行它的 UI 刷新。 if job.native.is_none() && matches!(&input, PendingRequest::BeforeInput) { @@ -308,11 +358,13 @@ fn start_server_observed( pending = Some((job, input)); } RequestProgress::Complete(response) => { + // 同步完成也只尝试一次回执,发送失败不回滚 VM 已完成写入或发布文档。 let _ = job.request.respond(response); } } } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + // timeout 不是 worker 已停止,仅是检查拥有资格/继续下一轮空闲推进的边界。 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { if worker.repl.as_ref().is_some_and(|(epoch, runtime)| { worker_namespace.read().is_ok_and(|current| { @@ -341,6 +393,7 @@ fn start_server_observed( // 退出后拒绝写入/执行路由,GET 与 OPTIONS 仍可读取最近发布值或探测接口。 && !matches!(request.method(), Method::Get | Method::Options) { + // 写入/执行在退出门闩后拒绝,GET/OPTIONS 保留观察;响应失败同样继续接收其他只读请求。 let _ = request.respond(error_response(StatusCode(503), "application is exiting")); continue; @@ -358,20 +411,21 @@ fn start_server_observed( .context("start Tessera agent API thread") } -// Capture only allowlisted headers: credentials and cookies never enter the inspector. -/// HTTP 请求的观察包装,在读取正文和响应时维护 Network 工具记录。 -/// HTTP and in-process native jobs share one serial execution/refresh path. -/// Native envelopes cannot be created by an HTTP header or request body. +/// BuildRequest 的一次性回应端,HTTP 与进程内原生工作共享执行/刷新路径但保持不同来源身份。 +/// HTTP 头或正文不能创建 Native 变体,也不能让任意远程报文走原生连续输入的宽松版本追随。 enum RequestReply { Http(ObservedRequest), Native(SyncSender>), } impl RequestReply { + /// worker 线程在请求终结/退出时调用,HTTP 发响应,Native 只写回一次确认或有限错误文本。 fn respond(self, response: Response>>) -> std::io::Result<()> { match self { Self::Http(request) => request.respond(response), Self::Native(done) => { + // 连续原生输入过期可能正常返回 409,按已消费处理,不把每条旧采样都显示成运行时故障。 let status = response.status_code().0; + // 原生侧不解析响应 JSON;非 200/409 取至多 2048 字符错误预览交 GUI 展示。 let result = if status == 200 || status == 409 { Ok(()) } else { @@ -380,6 +434,7 @@ impl RequestReply { .take(2048) .collect()) }; + // relay 可能已因窗口退出丢弃接收端,worker 不应为此把已处理输入重新执行或停止。 let _ = done.send(result); Ok(()) } @@ -387,14 +442,19 @@ impl RequestReply { } } +/// handle 包装 tiny_http 请求,持有直到 worker 最终响应;只允许特定头部进入 Network 观察日志。 struct ObservedRequest { + // 真正 socket 请求所有权,respond 消费它,Deref 只让路由/正文读取复用 tiny_http 接口。 inner: Request, + // 共享有界观察日志,不持 VM 强引用或业务对象身份。 activity: Activity, + // begin 返回的日志记录身份,异步执行后仍能对应正确请求项。 id: u64, + // 日志正文/响应更新后通知 GUI,而非只在实际文档发布时刷新工具。 notify: Notify, } impl ObservedRequest { - /// 从请求提取允许观察的头部与显示来源,创建活动记录并通知工具界面。 + /// handle 在路由前调用,提取白名单头和来源显示标签,不把凭据/Cookie 等复制到观察日志。 fn new(inner: Request, activity: Activity, notify: Notify) -> Self { // 活动观察只保留 Content-Type/Length;X-Tessera-Origin 仅判来源标签,不把任意认证头复制进工具日志。 let headers = inner @@ -415,6 +475,7 @@ impl ObservedRequest { "HTTP" } .to_owned(); + // 创建日志项后只留 id,worker 可以稍后再填响应,不借用锁中的 ActivityLog 记录。 let id = activity.lock().unwrap().begin( inner.method().to_string(), inner.url().to_owned(), @@ -429,15 +490,18 @@ impl ObservedRequest { notify, } } - /// 将受限请求正文记录到活动预览,不在观察器中执行源码。 + /// submit 成功读取有界 UTF-8 正文后调用,写观察预览并通知工具,不执行源码或创建候选 VM。 fn record_body(&self, body: &str) { self.activity.lock().unwrap().body(self.id, body); (self.notify)(); } - /// 发送最终 HTTP 响应,并同步结束该条活动记录。 + /// RequestReply::respond/handle 错误路径调用,先记预构造响应,再消费真实 tiny_http 请求发回客户端。 fn respond(self, response: Response>>) -> std::io::Result<()> { + // 拆开元信息和内存正文 Cursor,记录预览后按原内容重建,避免观察器吃掉实际响应。 let status = response.status_code(); + // 克隆头列表供日志和原响应同时使用,观察器不修改协议状态或内容类型。 let headers = response.headers().to_vec(); + // 保留数据长度给重建响应,不按 Unicode 字符数重算 Content-Length。 let length = response.data_length(); // 响应正文本已在内存 Cursor,记录预览后用原 status/headers/reader/length 重建响应,不消费正文内容。 let reader = response.into_reader(); @@ -459,19 +523,19 @@ impl ObservedRequest { } impl Deref for ObservedRequest { type Target = Request; - /// 将包装类型的只读访问转交给内部当前上下文。 + /// Rust 在路由/头部访问时隐式调用,借用底层 Request;包装日志所有权不转移。 fn deref(&self) -> &Request { &self.inner } } impl DerefMut for ObservedRequest { - /// 将包装类型的可变访问转交给内部当前上下文。 + /// submit/read_code 请求正文读取时隐式调用,访问底层流,不绕过有界读取检查。 fn deref_mut(&mut self) -> &mut Request { &mut self.inner } } -/// 解析 HTTP 方法和路径;只读路由读取发布值,执行路由交给 worker。 +/// HTTP 接收线程逐个请求调用,区分执行入队和纯发布值读取;执行请求由 worker 持有回应,不在此等 VM。 fn handle( request: Request, namespace: &Arc>, @@ -480,7 +544,9 @@ fn handle( activity: &Activity, observer_notify: &Notify, ) { + // 先建观察记录,即使路由非法也能在 Network 看到实际响应,不只记录成功执行请求。 let request = ObservedRequest::new(request, activity.clone(), observer_notify.clone()); + // method 克隆为纯枚举,后面 submit 移动 request 所有权不会留下 Request 借用。 let method = request.method().clone(); // 路由匹配去掉查询串,namespace 从固定路径提取;不把 query 当作命名空间或源代码。 let path = request @@ -490,32 +556,42 @@ fn handle( .unwrap_or(request.url()) .to_owned(); + // 三个 POST 后缀分别对应隔离表达式、持久 Console 和当前程序输入,不能因正文像源码就互换。 let evaluation = namespace_from_route(&path, "/evaluate"); + // DELETE 同一个 /repl 地址表示重置该持久会话。 let repl = namespace_from_route(&path, "/repl"); + // /events 接受 JSON 事件,后续仍由 worker 校验会话/窗口/布局身份。 let event = namespace_from_route(&path, "/events"); // PUT/code 完整替换,POST 在 events/repl/evaluate 中分类,DELETE/repl 是持久会话重置;其他交给资源读取/清空路径。 let route = if method == Method::Put { + // 完整替换只接受 /code,不让 PUT /repl 混成持久追加操作。 namespace_from_path(&path).map(|name| (name, RequestKind::Code)) } else if method == Method::Post { + // 先找事件,再 Console,再表达式;后缀严格匹配,命名空间不能含额外路径段。 event .map(|name| (name, RequestKind::Event)) .or_else(|| repl.map(|name| (name, RequestKind::Repl))) .or(evaluation.map(|name| (name, RequestKind::Expression))) } else if method == Method::Delete { + // 这里只识别需 worker 清理的 /repl,DELETE /code 的发布状态撤销留给 code_request。 repl.map(|name| (name, RequestKind::ResetRepl)) } else { + // GET/OPTIONS 与不支持方法继续同步读取/错误响应,不创建执行 job。 None }; if let Some((addressed, kind)) = route { + // submit 消费请求,队列/输入错误也由它负责响应;handle 不再重复发送第二个响应。 submit(request, addressed, namespace, sender, kind); return; } + // 这些只读/清空资源路径只读写 NamespaceState,不读取活 VM globals 或执行源函数。 let response = if method == Method::Get && path == "/agent" { match namespace.read() { Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)), Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"), } } else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) { + // /code 的 GET/DELETE/OPTIONS 统一走资源处理,路径匹配不等于 namespace 已被授权存在。 code_request(method, &addressed_namespace, namespace, notify) } else if method == Method::Options { text(StatusCode(204), "", "text/plain") @@ -523,20 +599,23 @@ fn handle( error_response(StatusCode(404), "route not found; start with GET /agent") }; + // 同步路由只回应一次;连接已断开也不应再次处理路由或重复 DELETE 的清空副作用。 let _ = request.respond(response); } -/// 处理源码资源的 GET/DELETE 等请求,返回对应版本头或撤销运行时所有权。 +/// handle 对 /code 的同步资源路径调用,GET 返回最近发布源码,DELETE 撤销发布/执行归属并通知 GUI。 fn code_request( method: Method, addressed_namespace: &str, namespace: &Arc>, notify: &Notify, ) -> Response>> { + // 先短读检查 namespace 和 GET 元信息;DELETE 需要下方先 drop 读锁再取得写锁。 let Ok(current) = namespace.read() else { return error_response(StatusCode(500), "namespace lock is poisoned"); }; if addressed_namespace != current.name { + // 单活 namespace 模型不自动创建另一个域,拼错名字直接 404 而非向当前程序投递。 return error_response( StatusCode(404), format!( @@ -548,6 +627,7 @@ fn code_request( // GET 返回最近成功发布的 source 而非正在排队的输入,并通过头部暴露 revision/session/window/layout 及逻辑视口。 if method == Method::Get { + // 响应正文来自成功发布的文档 source,排队中的候选/未稳定 Console 草稿不可提前读取。 let mut response = text( StatusCode(200), ¤t.document.source, @@ -574,6 +654,7 @@ fn code_request( ¤t.document.frame.layout_version.to_string(), ); if let Some(viewport) = current.viewport { + // 尺寸头是逻辑可用 viewport,不是操作系统屏幕大小,未知时不造零值头。 add_header( &mut response, "X-Tessera-Viewport-Width", @@ -594,8 +675,10 @@ fn code_request( .as_ref() .is_some_and(|root| root.kind == "repl-session") { + // transcript 含多个已接受输入,调用者不应把它误当可直接替换的单个源快照。 "repl-transcript" } else { + // 完整程序原文快照与独立 Console transcript 的返回含义明确区分。 "source-snapshot" }, ); @@ -607,6 +690,7 @@ fn code_request( match method { Method::Delete => match namespace.write() { Ok(mut current) => { + // clear 同时推进撤销身份;worker 后续检查归属时不会再提交先前候选的晚到结果。 let revision = match current.clear() { Ok(revision) => revision, Err(error) => return error_response(StatusCode(500), error.to_string()), @@ -629,7 +713,7 @@ fn code_request( } } -/// 读取并检查请求体,分配提交身份,再把工作送进有界执行队列。 +/// handle 的执行路由调用,先读有界 UTF-8 正文,再持发布锁完成入队和接受身份更新,不执行 VM。 fn submit( mut request: ObservedRequest, addressed: &str, @@ -637,16 +721,20 @@ fn submit( sender: &SyncSender, kind: RequestKind, ) { + // 正文读取失败直接 400,不推进 submission 或创建一项只能在执行阶段再失败的队列工作。 let source = match read_code(&mut request) { Ok(source) => source, Err(error) => { + // 正文错误在入队前响应,失败连接不影响其他请求,也不分配新的提交身份。 let _ = request.respond(error_response(StatusCode(400), error.to_string())); return; } }; // 读取成功后才保存受限活动正文预览;超长或非法 UTF-8 请求不会进入执行队列。 request.record_body(&source); + // 写锁把“接受新提交”与 worker 的“发布旧提交”互斥,不能先递增身份再入队失败。 let Ok(mut current) = namespace.write() else { + // 无法可靠读取发布身份时返回服务端错误,不能绕过锁以猜测 submission 接受新代码。 let _ = request.respond(error_response( StatusCode(500), "namespace lock is poisoned", @@ -654,6 +742,7 @@ fn submit( return; }; if current.name != addressed { + // 路由名字只是请求指向,真正执行归属要匹配当前共享 namespace。 let _ = request.respond(error_response(StatusCode(404), "namespace is not active")); return; } @@ -665,10 +754,12 @@ fn submit( } { Ok(id) => id, Err(error) => { + // 身份计数耗尽等错误尚未入队,保留当前有效 submission,不接受无法唯一标识的工作。 let _ = request.respond(error_response(StatusCode(500), error.to_string())); return; } }; + // HTTP 信封明确 native=None,正文即使声明某个字段也不能成为原生后台回执输入。 let job = BuildRequest { request: RequestReply::Http(request), native: None, @@ -679,20 +770,21 @@ fn submit( }; // 持 namespace 写锁完成入队和接受身份更新;worker 提交使用同一锁,保证接受与发布之间没有竞态窗口。 match sender.try_send(job) { - // A rejected queue entry never invalidates accepted work. The worker - // uses this same lock when committing, so publication is atomic. Ok(()) => { + // 只有成功入队的完整 PUT 才把 submission 改成新值,未接受请求不会废掉已有工作。 if kind == RequestKind::Code { current.submission = submission; } } // 队列拒绝时不推进 submission,所以已经接受的旧工作不会因为本次失败请求而失去发布资格。 Err(TrySendError::Full(job)) => { + // try_send 把请求所有权退回,立即回应暂不可用;不等待空位占住 HTTP 接收线程。 let _ = job .request .respond(error_response(StatusCode(503), "build queue is full")); } Err(TrySendError::Disconnected(job)) => { + // worker 已消失与容量暂满分别报告,原 HTTP 请求仍在返回 job 中,可明确回应而非挂起。 let _ = job.request.respond(error_response( StatusCode(503), "execution worker is unavailable", @@ -701,7 +793,7 @@ fn submit( } } -/// 生成较旧请求已被新提交取代的响应,拒绝晚到结果覆盖当前文档。 +/// worker 在执行/发布边界发现提交已被新 PUT/DELETE 取代时调用,返回 409,不覆盖当前文档。 fn superseded(submission: u64) -> Response>> { json( StatusCode(409), @@ -710,19 +802,19 @@ fn superseded(submission: u64) -> Response>> { ) } -/// 从固定路由格式提取命名空间,避免把不匹配的路径当作代码资源。 +/// handle 的各 API 路由解析调用,借用严格前后缀之间的单段 namespace,失败返回 None 不做模糊回退。 fn namespace_from_route<'a>(path: &'a str, suffix: &str) -> Option<&'a str> { // 同时检查前后缀且命名空间非空、无额外斜杠;不做模糊路径前缀匹配。 path.strip_prefix("/v1/namespaces/")? .strip_suffix(suffix) .filter(|namespace| !namespace.is_empty() && !namespace.contains('/')) } -/// 从固定路由格式提取命名空间,避免把不匹配的路径当作代码资源。 +/// handle 为 /code 资源调用的特化解析,复用 namespace_from_route 的单段/非空约束。 fn namespace_from_path(path: &str) -> Option<&str> { namespace_from_route(path, "/code") } -/// 按源码字节上限读取 UTF-8 请求体,读取失败或超限时返回诊断。 +/// submit 在入队之前调用,已知长度预拒绝、真实流再硬限长,任何非 UTF-8 都不能进入 source 字符串。 fn read_code(request: &mut Request) -> Result { if request .body_length() @@ -744,8 +836,9 @@ fn read_code(request: &mut Request) -> Result { Ok(code) } -/// 将前端/编译/执行诊断编码为 API 错误响应,保留用户可理解的位置和消息。 +/// worker 执行/加载失败时调用,将 anyhow 错误链转成 422;有前端来源才附精确行列,不执行 VM 求值。 fn compile_error(error: anyhow::Error) -> Response>> { + // 先保存完整可读错误链,只有确有 SourceDiagnostic 才追加源位置字段。 let mut diagnostic = serde_json::json!({ "severity": "error", "message": format!("{error:#}") }); // 有 SourceDiagnostic 才附行/列和半开字节范围;其他运行时错误仍保留完整错误链消息,不编造源码位置。 @@ -764,7 +857,7 @@ fn compile_error(error: anyhow::Error) -> Response>> { ) } -/// 生成统一 JSON 错误响应,使调用方能区分协议错误与成功结果。 +/// 路由、入队、退出及 worker 的协议/运行状态错误共用助手,保留调用者选择的 HTTP 状态码。 fn error_response( status: StatusCode, message: impl Into, @@ -772,7 +865,7 @@ fn error_response( json(status, serde_json::json!({ "error": message.into() })) } -/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。 +/// 成功/错误 API 分支共用 JSON 编码入口,正文交 text 一次性包装成内存字节 Cursor。 fn json(status: StatusCode, value: serde_json::Value) -> Response>> { text( status, @@ -781,12 +874,12 @@ fn json(status: StatusCode, value: serde_json::Value) -> Response Response>> { text(status, &value, "text/markdown; charset=utf-8") } -/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。 +/// json/markdown 和源码 GET/OPTIONS 共用响应助手,统一状态/内容类型和固定本机 CORS 头。 fn text(status: StatusCode, value: &str, content_type: &str) -> Response>> { // 公共响应头只允许固定本机 Origin/方法/头名,非通配任意来源;这些头不替代回环绑定与路由校验。 let mut response = Response::from_string(value).with_status_code(status); @@ -809,7 +902,7 @@ fn text(status: StatusCode, value: &str, content_type: &str) -> Response>>, name: &str, value: &str) { // 由 HTTP 库验证字段格式,非法头被忽略,不把未校验字符拼进原始响应报文。 if let Ok(header) = Header::from_bytes(name, value) { diff --git a/src/runtime/endor_host.rs b/src/runtime/endor_host.rs index b3b2075..0fd2cd7 100644 --- a/src/runtime/endor_host.rs +++ b/src/runtime/endor_host.rs @@ -2,8 +2,10 @@ //! 完整源码 PUT 使用隔离候选;Console 输入则在已有 VM 上执行,先前发生的写入不等于可以回放或事务回滚。 //! 刷新状态机遍历窗口并逐步推进布局/绘制。完成所有必要轮次才形成文档;取消会释放挂起调用并恢复未提交的 Mount。 //! -//! Compile complete source in a fresh VM or retain a worker-owned Console VM. -//! Console retains mounted native references and publishes immutable frames after successful inputs. +//! api::worker 为完整 PUT 创建 ReplSession 候选,为 Console/事件/resize 调用已有会话;只有该 worker 拥有 VM。 +//! UI 类型与刷新由 bridge 适配;此模块负责源码预算、真实会话、执行结果和挂起刷新的生命周期。 +//! compile/evaluate 是一次性入口,关闭 Candidate 后只返回纯数据;生产持久路径使用增量入口并保存续体。 +//! 本模块仍直接依赖 UI Frame/桥接,不是已经可以独立搬走的通用 Nexus runtime,迁移需先抽出宿主接口。 use anyhow::{Result, bail}; use endor_core::{ExecutionLimits, ObjectRef, Value, Vm, VmError}; use frontend::{ @@ -18,33 +20,46 @@ use crate::{ layout, ui_bridge, }; +/// API、完整加载、Console 与 evaluate 共用的原始输入字节预算;不是字符数或 AST 节点数量。 pub const MAX_SOURCE_BYTES: usize = 1_048_576; +// 每次源执行的硬上限;脚本多片段会分摊指令额度,不能每段重新获得完整额度。 const LIMITS: ExecutionLimits = ExecutionLimits { max_frames: 128, max_instructions: 1_000_000, }; +// AstBudget 访问者的结构预算,在加载代码前拒绝过大或过深语法树。 const MAX_AST_NODES: usize = 10_000; const MAX_AST_DEPTH: usize = 64; -/// 把源输入编译并执行为一次独立结果,最终将可展示对象转换为检查后的文档。 +/// 一次性编译/现有 API 检查路径调用,在临时 VM 执行并提取文档,不返回持久可响应事件的 ReplSession。 pub fn compile(source: &str, namespace: &str, submission: u64) -> Result { if source.len() > MAX_SOURCE_BYTES { + // 先限制输入再进行词法/AST 分配,避免仅靠执行预算保护不了源码解析开销。 bail!("source exceeds the 1 MiB limit"); } + // 把请求来源和提交编号放入诊断 SourceUnit,后续注解错误可显示真实源行列。 let source = EndorReader::from_text(format!("{namespace} [submission {submission}]"), source); + // Module/Script 组织由宿主确定,语法解析和位置映射继续委托 endor_script/Aster。 let plan = endor_script::parse(&source)?; // 各 Script 根共享 AST 计数预算,不能通过拆成很多交互片段绕过整份源码节点上限。 let mut budget = AstBudget::default(); for ast in plan.roots() { + // 同一个 budget 累加每段,不因片段边界重置节点/深度检查。 budget.visit_ast_root(ast)?; } + // 编译得到可加载模板,此时还没有把业务语句在任何 VM 中运行。 let compiled = compile_plan(&source, &plan)?; + // 纯数据代码树供 Sources 浏览,和下面实际可执行代码/托管图不是同一结构。 let root = architecture(&source, &plan, namespace, submission); + // Candidate 是此一次性域的 VM 与根拥有者,所有出口都在下面 close 收尾。 let mut candidate = Candidate::new()?; + // 保存整个执行结果,不能让内部 ? 跳过 Candidate::close。 let result: Result = (|| { + // 同一程序的所有段共享一个 globals,真实引用由 candidate.roots 持有。 let globals = candidate.vm.new_dict()?; candidate.globals = Some(globals); candidate.roots.push(globals); + // 类型安装在执行前完成,全局别名引用同一批名义对象,不在每片脚本重建 UI 类型。 let bridge = ui_bridge::install(&mut candidate.vm, &mut candidate.roots)?; bridge.install_globals(&mut candidate.vm, globals)?; // Script 多段分别执行,指令总额按片段数均分;Module 作为整体获得完整预算,避免每段都重获一百万。 @@ -56,8 +71,10 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result Result bridge .rendered(&candidate.vm)? .ok_or_else(|| anyhow::anyhow!("script must call ui.page().render(root)"))?, // 模块先执行定义再调用 main;若 main 未显式 render,可将其返回值转换成展示根。 SourcePlan::Module(_) => { + // 从真实全局字典找 main,不能通过 AST 名字存在就假定运行结果也是 callable。 let entry = candidate .vm .dict_get(globals, &Value::Text("main".into()))? .ok_or_else(|| { anyhow::anyhow!("declare main or render a page from a top-level script") })?; + // 对象身份是可调用检查的第一步,VM.call 仍继续验证具体 callable 契约。 let Value::Object(entry) = entry else { bail!("main must be a callable function"); }; + // main 返回值有局部拥有根,快照成功/失败都须经过下面 release_value。 let value = candidate .vm .call_with_limits(entry, &[], LIMITS) .map_err(|error| runtime_error(&candidate.vm, error))?; bridge.refresh_bindings(&mut candidate.vm)?; + // 尊重 main 已主动展示的页面,否则用返回 Block 直接提取兼容快照。 let snapshot = bridge .rendered(&candidate.vm) .and_then(|rendered| match rendered { @@ -96,6 +119,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result Result Result { if source.len() > MAX_SOURCE_BYTES { bail!("expression exceeds the 1 MiB limit"); } + // 独立来源名明确区别于持久 Console transcript,不读取其历史或对象堆。 let source = EndorReader::from_text("console expression", source); // 隔离 evaluate 只接受一个表达式,默认编译选项不安装 ui 桥接,也不读已发布程序的 globals。 let ast = frontend::parse_source_ast(&source, frontend::ParseMode::Expression)?; AstBudget::default().visit_ast_root(&ast)?; + // 默认全局只有语言内置,不为 evaluate 暗中安装 ui 或活程序用户全局。 let compiled = compiler::compile_ast(&ast, &source, &compiler::CompileOptions::default()) .map_err(|error| source.annotate(error))?; + // 独立候选统一持有执行根,表达式失败也由 close 释放整个域。 let mut candidate = Candidate::new()?; + // 暂存执行/格式化错误,保证外层 close 不因这里的 ? 被跳过。 let result = (|| { + // 表达式全局环境为空的新字典,不能用运行时 namespace 文档代替真实 globals。 let globals = candidate.vm.new_dict()?; candidate.globals = Some(globals); candidate.roots.push(globals); + // 模板加载到此 VM 后才有真实代码对象,拥有根记入 Candidate。 let code = compiled.load(&mut candidate.vm)?; candidate.roots.push(code); + // 一次有界执行产生拥有返回值,字符串化后显式释放。 let value = candidate .vm .execute_with_limits(code, globals, LIMITS) @@ -149,6 +179,7 @@ pub fn evaluate(source: &str) -> Result { candidate.vm.release_value(value)?; Ok(rendered) })(); + // 先尝试关闭候选,再合并执行和清理结果,不能只因表达式已有字符串结果就跳过清理。 let cleanup = candidate.close(); match (result, cleanup) { (Ok(value), Ok(())) => Ok(value), @@ -156,7 +187,7 @@ pub fn evaluate(source: &str) -> Result { } } -/// 按 Script/Module 计划编译各根节点,为后续执行准备可加载模板。 +/// compile/prepare_program 调用,建立 UI 宿主全局名集合,再复用增量编译助手生成模板序列。 fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result> { compile_plan_with_globals( source, @@ -169,20 +200,19 @@ fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result, ) -> Result> { - // Match Aster module behavior for function forward references. Bindings are - // added in source order; every executable statement still uses Aster's own - // single-input Interactive compiler and the candidate's shared globals. // 先收集全部函数/类声明名以支持前向引用;普通绑定仍按源码执行次序逐段加入已知全局名。 for root in plan.roots() { if let AstRoot::Interactive(input) = root { + // 完整模块的声明可见性由 Aster 自己处理;只有拆成多个交互根时宿主需预收集声明名。 for statement in &input.statements { if let StmtKind::Declaration(decl) = &statement.kind { + // 只预声明函数/类,不提前把任意赋值名当作已存在业务值。 let name = match &decl.kind { DeclKind::Function(function) => &function.name, DeclKind::Class(class) => &class.name, @@ -192,16 +222,21 @@ fn compile_plan_with_globals( } } } + // 每个根保留一个模板,执行顺序必须与原根切片一一对应,不能按名字排序。 let mut compiled = Vec::with_capacity(plan.roots().len()); + /// 本函数在每段成功编译后调用,递归收集后续段可能引用的绑定名,不执行或预测条件真值。 fn assigned(statement: &Stmt, globals: &mut BTreeSet) { match &statement.kind { StmtKind::Binding(b) => { + // 绑定给后续片段的静态名称解析提供名字;字典中的真实值仍在执行时建立。 globals.insert(b.name.text.clone()); } StmtKind::Assignment(a) => { + // 顶层赋值也可能引入名字,按 Aster 交互语义让后续段可以编译引用。 globals.insert(a.name.text.clone()); } StmtKind::If(b) => { + // 两个分支都收集“可能声明”名字,不在编译阶段决定哪条路径会执行。 for s in &b.body.statements { assigned(s, globals); } @@ -212,14 +247,17 @@ fn compile_plan_with_globals( } } StmtKind::While(b) => { + // 循环体可能写入全局;是否真正执行一次由 VM 决定,宿主不展开循环。 for s in &b.body.statements { assigned(s, globals); } } + // 表达式、return、成员赋值等不引入新的普通全局名;函数内部作用域由语言编译器处理。 _ => {} } } for root in plan.roots() { + // 每段得到当前已知全局集合快照,先编译再把本段绑定加入下一段上下文。 let options = compiler::CompileOptions { globals: globals.iter().cloned().collect(), }; @@ -237,12 +275,13 @@ fn compile_plan_with_globals( Ok(compiled) } -/// One real VM, owned only by the existing execution worker. No history replay. -/// The UI bridge shares the same managed object heap and publishes atomic frames. -/// 指定窗口尚未完成的绘制工作,文档与源续体必须保持配对。 +/// refresh_step 挂起 paint 时创建、下一轮恢复/取消时消费,文档草稿与所属窗口续体必须成对保留。 struct PendingPaint { + // 原窗口 ID,恢复/取消前显式 select_window,不能依赖稍后变化的 current。 window: u64, + // 对应本次完成布局的源码/视觉/几何草稿,paint 结束前不得对外发布。 document: RenderDocument, + // owning VM 中未完成的 source_paint Work,取消时显式关闭,Rust drop 本身不替代 VM 清理。 work: Box, } @@ -251,16 +290,23 @@ struct RefreshWork { // 已完成窗口的草稿先留在本轮;所有窗口收敛后才合成一次完整发布。 // RefreshWork 先克隆最近完整文档作为草稿基线,各窗口更新暂存于此;全轮稳定前不写共享 NamespaceState。 documents: std::collections::BTreeMap, + // 当前稳定性遍历轮剩余窗口,按 bridge 的退旧优先次序排队。 windows: std::collections::VecDeque, + // 本片正在推进窗口,pending/paint 的上下文必须与其一致。 current: Option, + // 当前帧不可变根草稿,布局完成再绘制仍使用这一轮同一份快照。 root: Option>, // 续体必须保留其 owning VM;取消时显式关闭,不能靠丢弃 Rust 值。 pending: Option>, + // 与 pending 布局阶段互斥的未完成绘制,完成后才把当前窗口文档写进 documents。 paint: Option>, // pass_changed 要求再跑一轮确认稳定,changed 记录整次刷新是否有可发布变化。 pass_start: bool, + // 一轮是否有变化,决定下一轮还需确认稳定;不等于最终对外是否已有变化。 pass_changed: bool, + // 整次刷新累计变化,用于稳定后决定返回文档还是 None。 changed: bool, + // 整次刷新工作量预算,跨多个 worker 量子累计,不能让每片各得一份完整收敛额度。 work: usize, // 记录本轮尺寸目标,下一次 worker 轮次据此判断是否需要重定向。 viewports: Option>, @@ -268,30 +314,45 @@ struct RefreshWork { /// 刷新仍在进行或已完成;完成也可能没有新文档需要发布。 enum RefreshProgress { + // worker 保留整个 ReplSession,下次继续已有布局/绘制续体,不接新业务写入。 Pending, + // 已稳定;None 为没有新文档,Some 才有可以让 worker 校验/发布的完整值。 Complete(Option>), } /// 持久源运行时与其桥接会话,保存实际全局对象、代码投影、全窗口文档和挂起刷新。 pub struct ReplSession { + // 真正 VM、全局拥有根和清理状态,和 HTTP/UI 读取的文档快照分离。 candidate: Candidate, + // 同 VM 的 Tessera 扩展能力,实际窗口/布局/绘制规则留在 bridge/model/render。 bridge: ui_bridge::UiBridge, + // worker 分配的运行时代际 ID,输入即使数值窗口/Mount 相同也不能跨代沿用。 pub id: u64, + // 已接受 Console 输入数量与累计 AST 节点数,完整源加载和后续输入都受各自预算控制。 inputs: usize, ast_nodes: usize, + // 完整程序或累加后的 Console transcript,供发布和 Sources 使用,不通过回放恢复执行状态。 source: String, + // 交互记录的纯数据代码树,与 Candidate 的真实 globals/函数引用不是同一存储。 nodes: Vec, // 回调/渲染失败后置位,拒绝继续普通业务输入;保留画面不表示堆还能安全执行。 faulted: bool, + // 完整程序的 Sources 根,首次 Console 追加时移入 nodes 保留原程序浏览记录。 program_root: Option, + // 最近完成的逐窗基线文档;未稳定刷新只改 RefreshWork 草稿,不提前替换共享发布状态。 documents: std::collections::BTreeMap, + // 同一个 VM 上至多一份跨片刷新,所有业务写入口先 require_idle_refresh。 refresh: Option, } /// 一次 Console/事件操作的标量显示结果与可选完整文档,避免直接跨线程暴露托管返回值。 pub struct ReplResult { + // 用于 Console/API 的字符串结果,不跨线程暴露拥有根 Value。 pub value: String, + // 只有本轮已经稳定完成刷新才返回完整文档,挂起或无变化时为 None。 pub document: Option, + // 当前 globals 条目数的显示统计,具体计算按 execute_mode 的接口约定处理。 pub bindings: usize, + // 已接受输入编号,供 API/Console 对应 transcript 与代码树记录。 pub input: usize, } impl ReplSession { @@ -317,7 +378,9 @@ impl ReplSession { id: u64, viewport: Option, ) -> Result<(Self, RenderDocument)> { + // 测试只接受主窗口尺寸,先建立真实 VM,再同步排空初始刷新便于直接断言帧内容。 let mut runtime = Self::prepare_program(source, namespace, submission, id, viewport)?; + // 成功执行但没发布页面不算完整 GUI 程序,避免返回一个表面“成功”的空候选。 let document = runtime .refresh_document()? .ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?; @@ -327,7 +390,7 @@ impl ReplSession { Ok((runtime, document)) } - /// 建立可由 worker 分轮推进的完整源码候选,初始布局不必在一次调用里排空。 + /// api::worker 的 PUT 完整源码路径调用,建立候选并只推进有限刷新,未稳定时保留 runtime 等后续量子。 pub fn load_incremental( source: &str, namespace: &str, @@ -335,11 +398,14 @@ impl ReplSession { id: u64, viewport: Option, ) -> Result<(Self, Option)> { + // 源程序执行在隔离 Candidate,旧活跃程序和 GUI 发布文档尚未替换。 let mut runtime = Self::prepare_program(source, namespace, submission, id, viewport)?; + // 首轮只提供主窗口已知尺寸,源程序新建的其他窗口由后续 resize 通知补充。 let viewports = viewport .map(|size| (crate::window::MAIN_WINDOW, size)) .into_iter() .collect(); + // Option 文档为 None 既可能挂起也可能无变化,必须再看 has_pending_refresh 区分。 let document = runtime.refresh_after_mutation(Some(&viewports))?; // 初始刷新若已完成必须确实有 Page 文档才可接受候选;仍挂起则把整个运行时交 worker 下一量子继续。 if !runtime.has_pending_refresh() { @@ -351,7 +417,7 @@ impl ReplSession { Ok((runtime, document)) } - /// 建立隔离候选、安装宿主根并执行完整源码;候选尚未完成布局时不替换活跃程序。 + /// load/load_incremental 的共同准备入口,在新 VM 执行完整源码,验证已安排页面;布局/发布留给外层。 fn prepare_program( source: &str, namespace: &str, @@ -362,29 +428,39 @@ impl ReplSession { if source.len() > MAX_SOURCE_BYTES { bail!("source exceeds the 1 MiB limit"); } + // 来源标注把 API namespace/提交号接入 Aster 错误来源,不改变用户原文。 let unit = EndorReader::from_text(format!("{namespace} [submission {submission}]"), source); + // 按 Module/Script 路径编译,脚本片段仍共享同一个 globals。 let plan = endor_script::parse(&unit)?; + // 一份输入的所有根累计使用同一结构预算,过大 AST 在业务执行前失败。 let mut budget = AstBudget::default(); for ast in plan.roots() { budget.visit_ast_root(ast)?; } + // 全部编译成功后才创建并执行新运行时,解析/编译失败不改变任何活程序对象。 let compiled = compile_plan(&unit, &plan)?; + // new 安装类型和 globals,但不主动打开页面;源程序决定要展示的内容。 let mut runtime = Self::new(id)?; runtime .bridge .set_viewport(&mut runtime.candidate.vm, viewport)?; // 完整程序代码在候选自己的真实 globals 中执行,之后保留该堆用于事件与 Console,不通过重放历史恢复状态。 let globals = runtime.candidate.globals.expect("program globals"); + // 根据已编译根数确定脚本每段硬额度;完整模块仍使用一份 LIMITS 执行模块定义。 let limits = if matches!(plan, SourcePlan::Script(_)) { + // 指令硬额度在脚本段间均分,不因一个输入拆成更多交互段而放大执行总量。 ExecutionLimits { max_instructions: LIMITS.max_instructions / compiled.len().max(1), ..LIMITS } } else { + // 模块作为一个编译单元执行定义,之后 main 有单独一次有界调用。 LIMITS }; for template in &compiled { + // 代码只需此段执行期间持有,执行结果暂存,代码根释放后再传播调用错误。 let code = template.load(&mut runtime.candidate.vm)?; + // 保存 Result 以保证失败执行也经过 vm.release(code)。 let result = runtime .candidate .vm @@ -394,14 +470,17 @@ impl ReplSession { runtime.candidate.vm.release_value(result?)?; } if matches!(plan, SourcePlan::Module(_)) { + // 模块顶层只安装定义,main 从实际 globals 查找,而非只依据 AST 声明列表。 let main = runtime .candidate .vm .dict_get(globals, &Value::Text("main".into()))? .ok_or_else(|| anyhow::anyhow!("declare a main function"))?; + // 同名全局可能被赋为标量,先解码对象,再由 VM 完整检查可调用契约。 let Value::Object(main) = main else { bail!("main must be callable"); }; + // 返回值保留到 has_page/open_value 决策之后;无论打开成功与否都须显式释放。 let value = runtime .candidate .vm @@ -411,12 +490,14 @@ impl ReplSession { let opened = if runtime.bridge.has_page()? { Ok(()) } else { + // 返回对象按 Presentable/普通值规则进入桥接,不要求所有模块都手写 page.render。 runtime.bridge.open_value(&mut runtime.candidate.vm, &value) }; runtime.candidate.vm.release_value(value)?; opened?; } if !runtime.bridge.has_page()? { + // UI 程序必须有实际安排页面,单独计算出一个未打开 Block 不算可发布程序。 bail!("script must call page.render(root) or ui.open(value)"); } runtime.source = source.into(); @@ -426,8 +507,9 @@ impl ReplSession { Ok(runtime) } - /// 建立空的持久 Console 运行时,并安装当前逻辑视口输入。 + /// api::worker 新建 Console 域时调用,在未打开页面的空会话中记录原生主窗口尺寸。 pub fn new_with_viewport(id: u64, viewport: Option) -> Result { + // 新 Console 不回放旧源码,不复制先前程序的可变对象堆;显示的旧文档仍由 worker 决策保留。 let mut runtime = Self::new(id)?; runtime .bridge @@ -435,12 +517,15 @@ impl ReplSession { Ok(runtime) } - /// 建立真实持久 VM、全局字典与 UI 桥接根,尚未执行用户输入或产生窗口帧。 + /// prepare_program/new_with_viewport 调用,分配持久 Candidate、globals 和 UiBridge,不执行用户代码。 pub fn new(id: u64) -> Result { + // Candidate 的 Drop 负责异常返回时的最终清理,不让 bootstrap 半途失败泄漏 VM 根。 let mut candidate = Candidate::new()?; + // globals 的拥有根留在 Candidate,实际用户绑定在以后输入中原地更新这个字典。 let globals = candidate.vm.new_dict()?; candidate.globals = Some(globals); candidate.roots.push(globals); + // 每个会话安装一份类型域,后续输入继续复用,不按 Console 行重复建立名义类型。 let bridge = ui_bridge::install(&mut candidate.vm, &mut candidate.roots)?; bridge.install_globals(&mut candidate.vm, globals)?; Ok(Self { @@ -463,7 +548,7 @@ impl ReplSession { self.execute_mode(source, namespace, None) } - /// 执行一次输入的业务部分,再让展示刷新以可恢复工作跨 worker 轮次推进。 + /// api::worker 的 Console 输入请求调用,执行真实 globals 上的语句,展示部分允许跨片挂起。 pub fn execute_incremental( &mut self, source: &str, @@ -473,7 +558,7 @@ impl ReplSession { self.execute_mode(source, namespace, Some(viewports)) } - /// 在当前真实 VM 中编译并执行新输入,再按同步或分片模式刷新展示;已发生的业务写入不会被源重放回滚。 + /// execute/execute_incremental 共用入口,先完整编译再执行;解析错误不写业务堆,运行错误不回滚此前成功语句。 fn execute_mode( &mut self, source: &str, @@ -482,6 +567,7 @@ impl ReplSession { ) -> Result { self.require_idle_refresh()?; if self.faulted { + // 故障后保留可见画面不代表 VM 安全可继续变更,必须重置会话或替换完整程序。 bail!( "runtime stopped after a callback or rendering failure; reset the session or replace its program" ); @@ -493,11 +579,14 @@ impl ReplSession { if self.inputs >= 256 || self.source.len() + source.len() + 80 > MAX_SOURCE_BYTES { bail!("console session exceeds 256 inputs or 1 MiB; reset the session to continue"); } + // 对每次输入保留会话号/输入号作为来源,累计 transcript 的行号稍后再为 Sources 投影偏移。 let source = EndorReader::from_text( format!("console session {} input {}", self.id, self.inputs + 1), source, ); + // 输入可以是一段或多段交互语句,语法仍由 Aster 的 parser 决定。 let plan = endor_script::parse(&source)?; + // 新输入的结构量先算出,再与会话累计量合并,不为每次输入重新放宽全会话上限。 let mut budget = AstBudget::default(); for ast in plan.roots() { budget.visit_ast_root(ast)?; @@ -505,6 +594,7 @@ impl ReplSession { if self.ast_nodes + budget.nodes > MAX_AST_NODES { bail!("console session exceeds 10000 AST nodes; reset the session to continue"); } + // new 已建立的字典继续原地使用,保存对象身份和闭包,不能通过历史源码重放重建它。 let globals = self.candidate.globals.expect("live console globals"); // 从实际 globals 枚举已有名称供增量编译,支持前次输入创建的真实对象,而非靠解析历史猜名称。 let names = self @@ -513,6 +603,7 @@ impl ReplSession { .dict_items(globals)? .iter() .filter_map(|(key, _)| { + // 只把字符串键变成静态编译全局名,不向编译器传 VM Value 或托管引用。 if let Value::Text(name) = key { Some(name.clone()) } else { @@ -520,9 +611,8 @@ impl ReplSession { } }) .collect(); + // 所有根先编译完,任何语法/类型编译错误都不会执行输入前半部分。 let compiled = compile_plan_with_globals(&source, &plan, names)?; - // Parsing/compilation finish before any new statement runs. Successful earlier - // statements in a runtime-failed input remain live, as in an ordinary REPL. self.bridge.discard_pending(&mut self.candidate.vm)?; self.bridge .reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?; @@ -530,9 +620,11 @@ impl ReplSession { self.inputs += 1; self.ast_nodes += budget.nodes; if let Some(root) = self.program_root.take() { + // 第一次 Console 输入把原完整程序浏览树移入 transcript 节点,不丢原程序的 Sources 内容。 self.nodes.push(root); } if !self.source.is_empty() && !self.source.ends_with('\n') { + // 分隔行不改变 VM 代码语义,只保证后续 Sources 行号和注释不粘到上一行末尾。 self.source.push('\n'); } // 交互源码前加 Console input 注释分隔,投影节点行号再整体平移到累计 transcript 中。 @@ -543,6 +635,7 @@ impl ReplSession { if !self.source.ends_with('\n') { self.source.push('\n'); } + // 本轮输入的代码树先使用局部行号,再用 offset 平移到累计源码中的对应位置。 let mut node = architecture( &source, &plan, @@ -552,22 +645,27 @@ impl ReplSession { node.name = format!("Input {}", self.inputs); shift_node_lines(&mut node, offset); self.nodes.push(node); + // 保存执行/刷新失败供下面丢弃未发布请求并给 Sources 标注,已完成赋值不做事务回滚。 let result: Result = (|| { + // 多根均分本次硬预算,不让后续片段每个都获得完整一百万条指令。 let limits = ExecutionLimits { max_instructions: LIMITS.max_instructions / compiled.len().max(1), ..LIMITS }; + // 依次覆盖为最后一个片段的可显示值;本局部只保存字符串,不延长任意返回对象寿命。 let mut rendered_value = "nil".to_owned(); for (ast, template) in plan.roots().iter().zip(&compiled) { + // 代码根只跨一次 execute,先暂存 Result 再 release(code),失败也有清理。 let code = template.load(&mut self.candidate.vm)?; + // VM 执行真实 globals,不把本轮 AST 当快照去覆盖旧业务状态。 let executed = self .candidate .vm .execute_with_limits(code, globals, limits) .map_err(|error| runtime_error(&self.candidate.vm, error)); self.candidate.vm.release(code)?; + // execute 返回拥有根,后面 repr/全局 _ 更新是否成功都必须释放这份根。 let value = executed?; - // Keep the actual last result accessible as `_`, including unnamed UI objects. // 绑定/赋值语句展示其新变量实际值,普通表达式展示返回值;不是仅显示该语句执行的 Unit。 let display = if let AstRoot::Interactive(input) = ast { match &input.statements[0].kind { @@ -588,6 +686,7 @@ impl ReplSession { }; // 先算 repr 并把实际最后值写入全局 _,保留未命名 UI 对象;随后释放 execute 返回根,错误也逐项传播。 let repr = value_repr(&self.candidate.vm, &display); + // _ 的字典强边独立保留最后值,避免匿名 Block 在 Console 显示后马上丢失身份。 let retained = self.candidate .vm @@ -596,6 +695,7 @@ impl ReplSession { retained?; rendered_value = repr?; } + // 有业务变更后才推进 UI 稳定循环,可能返回挂起时的 None,worker 继续保留该会话。 let document = self.refresh_after_mutation(viewports)?; Ok(ReplResult { value: rendered_value, @@ -611,6 +711,7 @@ impl ReplSession { })(); // 失败丢弃未发布展示请求和原生命令,保留已执行业务写入,并在代码投影上明确标记非事务语义。 if result.is_err() { + // 只丢待发布展示与原生命令,不假装撤销此前已完成的用户赋值或资源关闭。 self.take_window_commands()?; self.bridge.discard_pending(&mut self.candidate.vm)?; if let Some(node) = self.nodes.last_mut() { @@ -626,17 +727,17 @@ impl ReplSession { result } - /// 查询调度器是否仍有需要主动推进的源协程工作。 + /// api::worker 选择空闲等待策略时查询,区分可运行协程与仅等待定时/I/O 的任务,不执行用户代码。 pub fn has_cooperative_tasks(&self) -> bool { self.bridge.has_cooperative_tasks() } - /// 检查是否存在尚未结束的视觉刷新工作,决定能否接收新的业务变更。 + /// worker 和本模块写入口查询,刷新未结束时必须继续原续体,不能插入 Console/任务回调写入。 pub fn has_pending_refresh(&self) -> bool { self.refresh.is_some() } - /// 拒绝在未完成的只读刷新内部再次进行业务变更,防止同一 VM 重入。 + /// execute/dispatch/命令提取等入口的共同前置检查,禁止在原只读视觉调用未结束时重入变更。 fn require_idle_refresh(&self) -> Result<()> { anyhow::ensure!( !self.has_pending_refresh(), @@ -645,7 +746,7 @@ impl ReplSession { Ok(()) } - /// 在业务变化后开始或继续展示刷新,保证后续输入不会进入未结束的只读视觉调用。 + /// prepare_program 后的加载、Console/事件/任务回调后调用,按调用模式同步排空或只推进一个刷新步骤。 fn refresh_after_mutation( &mut self, viewports: Option<&std::collections::BTreeMap>, @@ -654,6 +755,7 @@ impl ReplSession { let Some(viewports) = viewports else { return self.refresh_document(); }; + // 传入视口表示 worker 增量模式,只前进一步;目标尺寸副本保存供后续重定向比较。 let progress = self.poll_refresh(true)?; if let Some(work) = &mut self.refresh { work.viewports = Some(viewports.clone()); @@ -664,10 +766,11 @@ impl ReplSession { }) } - /// 同步排空当前刷新状态机,直到全窗口收敛或出现错误。 + /// 测试同步入口与 drain=true resize 调用,循环推进同一刷新直到稳定,不重执行用户业务语句。 fn refresh_document(&mut self) -> Result> { loop { match self.poll_refresh(false)? { + // Pending 仍是活动 Work,只让出 OS 调度机会,不重建 VM 或重新开始源布局。 RefreshProgress::Pending => std::thread::yield_now(), RefreshProgress::Complete(document) => { return Ok(document.map(|document| *document)); @@ -676,7 +779,7 @@ impl ReplSession { } } - /// 推进一次刷新状态机,保存挂起的 Work;只有全窗口收敛时才生成可发布文档。 + /// refresh_after_mutation/advance_idle_refresh/同步循环共用,消费或保存 RefreshWork 并统一处理故障清理。 fn poll_refresh(&mut self, record_yield: bool) -> Result { // 从 self 取出工作以便可变推进,挂起时放回;首次从已发布各窗口文档克隆初始草稿。 let mut work = self.refresh.take().unwrap_or_else(|| RefreshWork { @@ -692,22 +795,26 @@ impl ReplSession { work: 0, viewports: None, }); + // 暂存 step 错误以便仍能显式取消 Work 内的续体;不能在这里直接 ? 丢掉 work。 let result = (|| { if self.step_refresh(&mut work)? { return Ok(true); } if record_yield { + // 只记录真正把控制权交回 worker 的增量步,同步排空循环不累计这种诊断次数。 self.bridge.note_refresh_yield()?; } Ok(false) })(); match result { Ok(false) => { + // 原样保存根/检查点/窗口队列和续体,下次 poll_refresh 从同一拥有域恢复。 self.refresh = Some(work); Ok(RefreshProgress::Pending) } // 收敛后统一组合文档,再恢复主窗口选择;收尾失败也标故障,不把失败草稿当已发布帧。 Ok(true) => { + // finish_refresh 验证整份多窗口文档后才更新本会话完成基线,仍由 worker 决定共享发布。 let result = self.finish_refresh(work); self.bridge.select_window(crate::window::MAIN_WINDOW)?; if result.is_err() { @@ -736,11 +843,12 @@ impl ReplSession { } } - /// 执行一个刷新步骤:刷新绑定、选择窗口、准备快照、推进布局或绘制,并决定是否需要下一轮。 + /// poll_refresh 每量子调用一次,推进稳定循环、一个窗口或一片布局/绘制;true 只表示全轮无新增变化。 fn step_refresh(&mut self, work: &mut RefreshWork) -> Result { // 阶段一:新一轮先刷新绑定,再取得稳定窗口遍历顺序。 // 每轮先使属性绑定达到固定点,再取窗口顺序;生命周期或绑定可能改变窗口/页面结构,因此需要多轮确认。 if work.pass_start { + // 绑定/生命周期可能改窗口登记,新的稳定轮必须重新刷新绑定并获取窗口顺序。 self.bridge.refresh_bindings(&mut self.candidate.vm)?; work.windows = self.bridge.window_ids()?.into(); work.current = None; @@ -750,12 +858,14 @@ impl ReplSession { } // 阶段二:逐个选择窗口;一轮发生变化时再扫描一轮,直到依赖不再引起新帧。 let Some(id) = work.current else { + // 没有当前窗口时先取队列下一项,本次只做状态推进,下次才进入其 VM 布局/绘制。 if let Some(id) = work.windows.pop_front() { work.current = Some(id); return Ok(false); } // 任何窗口本轮生成新帧,就再扫描一轮检查依赖和挂载回调是否又引起变化,直到整轮无变化。 if work.pass_changed { + // 有帧变化就再完整扫一轮,才能发现 hooks/依赖引起的二次变化;不能第一轮结束就发布。 work.pass_start = true; return Ok(false); } @@ -764,11 +874,13 @@ impl ReplSession { self.bridge.select_window(id)?; // 已进入 paint 的窗口直接恢复原绘制续体,不重放业务回调或布局。 if let Some(paint) = work.paint.take() { + // 取回配对文档与源 paint Work,不重新 measure/arrange,也不把原业务输入再执行一遍。 let PendingPaint { window, document, work: paint_work, } = *paint; + // 同一 owning VM 上恢复固定预算的一片绘制,Complete/Pending 由共同接收器处理。 let progress = self .bridge .resume_paint_frame(&mut self.candidate.vm, *paint_work)?; @@ -776,18 +888,23 @@ impl ReplSession { } // 阶段三:恢复旧布局,或在快照/依赖确实变化时建立新布局。 let progress = if let Some(pending) = work.pending.take() { + // 原布局仍挂起时恢复,不能重新提取另一份根让当前续体读写不同展示上下文。 self.bridge .resume_layout_frame(&mut self.candidate.vm, *pending)? } else if let Some(root) = self.bridge.prepare_frame(&mut self.candidate.vm)? { + // 双层 Option 内层 None 也代表有空帧工作;保存准确根供布局完成后 paint 复用。 work.root = root.clone(); self.bridge .begin_layout_frame(&mut self.candidate.vm, root)? } else { + // 没有任何本窗变化才切下一窗,不能把“需发布空帧”误归为这一无工作分支。 work.current = None; return Ok(false); }; + // 未完成布局没有可用完整几何,必须保留 Work;Complete 才可以组装后续待绘制文档。 let frame = match progress { crate::ui_bridge::LayoutProgress::Pending(pending) => { + // current 和 root 保留不动,下次量子仍为此窗口续体继续执行。 work.pending = Some(pending); return Ok(false); } @@ -813,8 +930,10 @@ impl ReplSession { // 跨所有窗口和稳定轮次累计布局工作,空帧也计一,防止持续无几何变化请求绕过收敛预算。 work.work += document.frame.mounts.len().max(1); if work.work > 200_000 { + // 即使每次单帧都有限,失效反馈也可能永不收敛,这个累计预算禁止无限循环刷新。 bail!("layout dependencies did not stabilize within the rendering work budget"); } + // Frame 转移给 source_paint,document 暂留其他元数据;PendingPaint 会把两者重新配对保存。 let progress = self.bridge.begin_paint_frame( &mut self.candidate.vm, document.ui_root.clone(), @@ -823,7 +942,7 @@ impl ReplSession { Self::accept_window_paint(work, id, document, progress) } - /// 接收指定窗口的绘制进度,将挂起 Work 保留或把完整文档存入本轮结果。 + /// step_refresh 启动/恢复 paint 后调用,挂起就保留配对状态,完成就写逐窗草稿并标记需要稳定性复扫。 fn accept_window_paint( work: &mut RefreshWork, id: u64, @@ -832,6 +951,7 @@ impl ReplSession { ) -> Result { match progress { crate::source_paint::Progress::Pending(paint) => { + // 没完整 DisplayList 时绝不替换 documents,原完整画面仍可供 GUI 使用。 work.paint = Some(Box::new(PendingPaint { window: id, document, @@ -839,6 +959,7 @@ impl ReplSession { })); } crate::source_paint::Progress::Complete(draft) => { + // 绘制完成的 frame 含实际显示列表,验证通过再进入本轮草稿基线。 document.frame = draft.frame; document.validate()?; // 只有整个绘制 Complete 且验证通过才替换当前窗口草稿;Pending 保留配对文档和 Work,不对 GUI 可见。 @@ -850,16 +971,19 @@ impl ReplSession { Ok(false) } - /// 收敛后组合所有窗口文档,保留本轮完整结果;没有变化时不额外制造发布。 + /// poll_refresh 收到全轮稳定后调用,完成关闭资源清理并生成一次多窗口发布值;不会直接写 NamespaceState。 fn finish_refresh(&mut self, mut work: RefreshWork) -> Result> { // 收敛后完成已关闭窗口退役并删其旧文档;没有实际变化时不制造额外 revision。 self.bridge.finish_closed(&mut self.candidate.vm)?; + // 以桥接真实仍登记窗口为准删去旧文档,不让已关闭副窗口在下轮凭缓存“复活”。 let ids = self.bridge.window_ids()?; work.documents.retain(|id, _| ids.contains(id)); if !work.changed { + // 无新展示变化仍保存清理后的基线,但返回 None 避免增加无意义外部 revision。 self.documents = work.documents; return Ok(None); } + // 主窗可能尚无自己的已画文档,构造带 session 的空基线以容纳新副窗口和源码投影。 let mut main = work .documents .get(&crate::window::MAIN_WINDOW) @@ -901,22 +1025,25 @@ impl ReplSession { self.resize_windows_incremental(viewports, true) } - /// 用最新视口替换旧目标,显式取消未完成布局,同时保留已完成窗口结果和已发生的业务效果。 + /// resize_windows_incremental 检测目标尺寸集合变化时调用,取消当前续体并重建稳定轮,不回滚业务写入。 fn retarget_refresh( &mut self, viewports: &std::collections::BTreeMap, ) -> Result<()> { + // 没有挂起刷新就无需重定向,外层随后直接把新尺寸送入正常 set_viewport 路径。 let Some(mut work) = self.refresh.take() else { return Ok(()); }; // 记录实际取消了哪类续体用于诊断;已完成窗口草稿和已发生业务副作用不会因此回滚。 let cancelled = work.pending.is_some(); + // 两个独立诊断位记录实际取消阶段,不能把所有重启都记成布局取消。 let cancelled_paint = work.paint.is_some(); if let Some(paint) = work.paint.take() && let Err(error) = self.bridge .cancel_paint_frame(&mut self.candidate.vm, paint.window, *paint.work) { + // 取消 paint 失败后域不再继续正常执行,不把残留续体草稿交给新一轮目标使用。 self.faulted = true; return Err(error); } @@ -925,6 +1052,7 @@ impl ReplSession { .bridge .cancel_layout_frame(&mut self.candidate.vm, *pending) { + // layout 取消还要恢复 Mount 检查点,失败同样停域,不能仅删 Pending Rust 值。 self.faulted = true; return Err(error); } @@ -932,9 +1060,6 @@ impl ReplSession { self.faulted = true; return Err(error); } - // Keep completed window drafts and callback effects. Only the pending - // window's speculative Mount state was restored. A new external target - // begins a fresh convergence budget, not a continuation of a bad cycle. // 重新从全窗口稳定轮开始,清当前选择/旧根并重置此次外部尺寸目标的收敛工作预算;草稿 documents 保留。 work.windows.clear(); work.current = None; @@ -947,7 +1072,7 @@ impl ReplSession { Ok(()) } - /// 把各窗口最新尺寸送入同一运行时;视口更新可跨轮次挂起,也可由调用方同步排空。 + /// api::worker 的窗口 resize 和挂起刷新推进调用;同目标继续原 Work,新目标才显式取消并重定向。 pub fn resize_windows_incremental( &mut self, viewports: &std::collections::BTreeMap, @@ -963,24 +1088,30 @@ impl ReplSession { .and_then(|work| work.viewports.as_ref()) .is_some_and(|previous| previous != viewports); if retarget { + // 外部目标确实改变才重启,避免每次 worker 轮询都把慢布局从头做一遍。 self.retarget_refresh(viewports)?; } // 同目标 drain=true 同步排空旧刷新并保存完整结果;drain=false 只推进一量子,不能先执行新的尺寸变更。 let previous = if self.has_pending_refresh() && !retarget { if drain { + // 同步调用者要求先完成原目标的完整结果,再处理本次输入的尺寸变更。 let result = self.refresh_document(); self.finish_refresh_update(result)? } else { + // 增量轮本次只继续原视觉调用,不能在其只读挂起中插入 set_viewport 写入。 return self.advance_idle_refresh(); } } else { None }; + // 过滤仍属于此会话的窗口,GUI 可能还保留旧运行时窗口尺寸记录,不能自动创建同 ID 新资源。 let ids = self.bridge.window_ids()?; + // 聚合真正改变尺寸的窗口,重复通知不应引起重新布局或派生版本反馈。 let mut changed = false; // 仅更新桥接仍注册的窗口;旧 GUI 尺寸记录不应在新域里隐式创建窗口资源。 for (id, viewport) in viewports { if ids.contains(id) { + // 每次选明确窗口,使 ui.viewport 等隐式上下文读到对应窗口的尺寸输入。 self.bridge.select_window(*id)?; changed |= self .bridge @@ -993,11 +1124,13 @@ impl ReplSession { return Ok(previous); } if drain { + // 新刷新没变化时仍返回上面已排空的 previous,不让同步路径丢失完整已就绪结果。 let result = self .refresh_document() .map(|document| document.or(previous)); self.finish_refresh_update(result) } else { + // 只推进一次,目标集合随 Work 保存,下轮据此辨别“续跑”还是“真正新 resize”。 let result = self.advance_idle_refresh(); if let Some(work) = &mut self.refresh { work.viewports = Some(viewports.clone()); @@ -1006,8 +1139,9 @@ impl ReplSession { } } - /// 在空闲轮次推进一个刷新步骤,完成时执行统一结果收尾。 + /// resize 增量路径调用,推进一片并将 Pending 映成 None;仍需 has_pending_refresh 判断是否已空闲。 fn advance_idle_refresh(&mut self) -> Result> { + // 先保存错误/完成值供 finish_refresh_update 清理,不能因 poll 报错跳过未发布请求撤销。 let result = self.poll_refresh(true).map(|progress| match progress { RefreshProgress::Pending => None, RefreshProgress::Complete(document) => document.map(|document| *document), @@ -1015,31 +1149,30 @@ impl ReplSession { self.finish_refresh_update(result) } - /// 统一处理刷新失败、未发布状态清理和运行时引用回收。 + /// resize/advance_idle_refresh 共用收尾,错误时丢未发布命令,空闲后再回收不可达托管根。 fn finish_refresh_update( &mut self, result: Result>, ) -> Result> { if result.is_err() { + // GUI 继续使用旧完整文档,失败草稿的副作用命令不能外泄为 Resize/Focus 等原生动作。 self.take_window_commands()?; self.bridge.discard_pending(&mut self.candidate.vm)?; } if !self.has_pending_refresh() { + // 仍挂起时临时对象属于原视觉调用,不能在其中完整回收;只在续体结束边界 reclaim。 self.bridge .reclaim(&mut self.candidate.vm, &mut self.candidate.roots)?; } result } - /// Deliver ready work on the owning VM worker, never from the GUI or a - /// provider thread. A failed callback stops the same shared fault domain. - /// 同步推进可交付任务和必要刷新,供不使用分片 worker 的调用方使用。 + /// 同步调用方/测试使用,在 owning VM 推进就绪任务并排空展示;不允许 GUI 或 provider 线程调用 VM。 pub fn poll_tasks(&mut self) -> Result> { self.poll_tasks_mode(None) } - /// Idle delivery starts at most one callback, then parks its layout work. - /// 按有界任务交付量推进,再以可挂起刷新处理变化。 + /// api::worker 空闲轮调用,每轮至多推进一个任务,然后允许其布局/绘制挂起,给前台输入公平机会。 pub fn poll_tasks_incremental( &mut self, viewports: &std::collections::BTreeMap, @@ -1047,13 +1180,14 @@ impl ReplSession { self.poll_tasks_mode(Some(viewports)) } - /// 按本轮预算交付已完成任务或推进协程,再对其业务变化执行相同的展示刷新流程。 + /// poll_tasks/poll_tasks_incremental 共同执行路径,任务错误停用同一故障域,成功变更走正常刷新状态机。 fn poll_tasks_mode( &mut self, viewports: Option<&std::collections::BTreeMap>, ) -> Result> { // 异步回调不能插入只读布局/绘制续体中,即使后台邮箱已有结果也等刷新空闲边界。 if self.has_pending_refresh() { + // 只读视觉续体尚未结束时不取邮箱/回调写业务属性,先让 worker 继续原刷新。 return Ok(None); } if self.faulted { @@ -1064,17 +1198,21 @@ impl ReplSession { } return Ok(None); } + // 表示确有任务推进/清理,不等于某个协程已经 Completed;用于决定是否需要 reclaim。 let mut completed = false; + // 保留调度/刷新错误供下方统一停域、取消任务、丢弃未发布命令,避免中途直接返回。 let result = (|| { // worker 增量轮至多启动一个任务推进,给前台输入和窗口刷新公平机会;同步路径单轮允许64项。 let limit = if viewports.is_some() { 1 } else { 64 }; if self.bridge.poll_tasks(&mut self.candidate.vm, limit)? == 0 { + // 无实际推进就不启动视觉刷新,等待 I/O/计时器不会凭轮询本身制造新帧。 return Ok(None); } completed = true; self.refresh_after_mutation(viewports) })(); if result.is_err() { + // 任一共享 VM 任务回调故障会停整个域,不只把当前 Task 标失败后继续执行其他不安全回调。 self.faulted = true; self.bridge.cancel_tasks(&mut self.candidate.vm)?; self.take_window_commands()?; @@ -1088,7 +1226,7 @@ impl ReplSession { result } - /// 取得本轮已稳定运行时的窗口命令,挂起刷新时禁止提前取走。 + /// api::worker 准备一次发布/错误丢弃时调用,空闲边界才可排空命令,避免从未稳定草稿提前执行 OS 动作。 pub fn take_window_commands(&self) -> Result> { self.require_idle_refresh()?; self.bridge.take_window_commands(&self.candidate.vm) @@ -1107,6 +1245,7 @@ impl ReplSession { if !self.bridge.set_viewport(&mut self.candidate.vm, viewport)? { return Ok(None); } + // 同步测试 resize 保存刷新错误,随后同样丢弃失败请求并回收引用,不让 ? 跳过清理。 let result = self.refresh_document(); if result.is_err() { self.take_window_commands()?; @@ -1117,9 +1256,10 @@ impl ReplSession { result } - /// 关闭尚未完成的刷新任务,释放续体并恢复未提交 Mount;不能只丢弃 Work 结构。 + /// shutdown 和 ReplSession::drop 调用,显式取消 owning VM 上挂起的 paint/layout,不回放源程序。 fn cancel_pending_refresh(&mut self) -> Result<()> { if let Some(mut work) = self.refresh.take() { + // 从会话移走 Work 后逐项清理,检查点/临时 VM 根不能仅靠 Rust 值析构替代。 if let Some(paint) = work.paint.take() { self.bridge.cancel_paint_frame( &mut self.candidate.vm, @@ -1135,35 +1275,36 @@ impl ReplSession { Ok(()) } - /// 有界关闭任务、挂载和窗口,释放运行时根,并报告清理结果。 + /// api::worker 退役运行时调用,取消视觉续体并关闭任务/挂载/窗口;Candidate 的根仍由其 close/Drop 释放。 pub fn shutdown(&mut self) -> Result<(usize, usize, usize)> { // 先取消视觉续体恢复挂载检查点,再判断原域是否允许正常关闭回调,随后标故障禁止后续普通执行。 self.cancel_pending_refresh()?; + // 正常域允许卸载钩子,已经故障的域只做宿主清理,不能再次执行不可靠的源回调。 let callbacks = !self.faulted; self.faulted = true; self.bridge.shutdown(&mut self.candidate.vm, callbacks) } - /// 撤销指定窗口的运行时展示资源,防止后续刷新重新发布它。 + /// worker 在故障域处理原生关闭时调用,只退役指定副窗口与缓存,不允许再执行源生命周期钩子。 pub fn retire_window(&mut self, id: u64) -> Result<()> { self.require_idle_refresh()?; - // This path is exclusively host cleanup after the domain has stopped. - // No language functions, layout, paint or lifecycle hooks may run here. + // 只用于域已停止后的宿主清理,不能将此路径当作正常 Window.close 源方法的替代。 self.faulted = true; // 专用退役只清宿主资源,不调源生命周期;删除已缓存文档和窗口命令,避免后续重发该副窗口。 self.bridge.retire_window(&mut self.candidate.vm, id)?; self.documents.remove(&id); + // 故障退役只排空命令并丢弃,不能把失败域旧 Focus/Resize 发布到当前原生资源。 let _ = self.take_window_commands()?; self.bridge .reclaim(&mut self.candidate.vm, &mut self.candidate.roots) } - /// 查询当前运行时是否仍接受对应的窗口或拖放目标。 + /// api::worker 接受 tab-drop 前调用,验证两端发布版本/页面顺序,普通非拖放信封由 bridge 返回 true。 pub fn accepts_tab_drop(&self, input: &crate::events::Input) -> bool { self.bridge.accepts_tab_drop(&self.candidate.vm, input) } - /// 查询当前运行时是否仍接受对应的窗口或拖放目标。 + /// api::worker 接纳窗口输入前调用,只查询资源是否仍登记且未 closing,后面仍需 session/Mount 校验。 pub fn accepts_window(&self, id: u64) -> bool { self.bridge.accepts_window(id) } @@ -1179,7 +1320,7 @@ impl ReplSession { self.dispatch_mode(input, None) } - /// 分发一次输入回调,再把视觉刷新交给 worker 分片推进。 + /// api::worker 的原生/HTTP 输入任务调用,在同一会话执行一次回调,然后分片推进其视觉变化。 pub fn dispatch_incremental( &mut self, input: &crate::events::Input, @@ -1188,7 +1329,7 @@ impl ReplSession { self.dispatch_mode(input, Some(viewports)) } - /// 在正确窗口上下文执行输入回调,再刷新展示;故障时停止受影响域并清理未完成工作。 + /// dispatch/dispatch_incremental 共用入口,核对运行时代际/拖放目标,再选窗分派并恢复主窗口上下文。 fn dispatch_mode( &mut self, input: &crate::events::Input, @@ -1207,8 +1348,10 @@ impl ReplSession { } // 将 ui.window/viewport 等隐式上下文切到事件所属窗口,分发后统一恢复主窗口。 self.bridge.select_window(input.window)?; + // 暂存回调/刷新错误,外层始终尝试恢复主窗口选择、丢弃未发布结果并回收空闲根。 let result = (|| { self.bridge.dispatch(&mut self.candidate.vm, input)?; + // 输入已执行一次,后续只续跑视觉刷新,不能为多片 layout 再调用同一 click 回调。 let document = self.refresh_after_mutation(viewports)?; Ok(ReplResult { value: "nil".into(), @@ -1245,15 +1388,16 @@ impl Drop for ReplSession { } } -/// 递归修正代码投影的源行号,使拼接输入仍可定位到原始文本。 +/// execute_mode 为本轮 Sources 根调用,递归把局部一基行号平移到累计 Console transcript,不改变执行源码。 fn shift_node_lines(node: &mut CodeNode, offset: usize) { node.start_line += offset; node.end_line += offset; for child in &mut node.children { + // 所有后代都保存独立行号,父节点平移不会隐式修正内部函数/表达式的选择范围。 shift_node_lines(child, offset); } } -/// 把 VM 返回值转换为 Console 的可显示表示,不让借用的托管引用跨线程。 +/// execute_mode 在保存全局 _ 前调用,把真实返回值/新绑定值转成显示字符串;返回根仍由调用者释放。 fn value_repr(vm: &Vm, value: &Value) -> Result { Ok(match value { Value::Nil => "nil".into(), @@ -1266,8 +1410,11 @@ fn value_repr(vm: &Vm, value: &Value) -> Result { /// 拥有 VM、全局字典与宿主保留根;关闭时按顺序释放,失败也必须收尾。 struct Candidate { + // 实际解释器/托管堆,仅执行 worker 线程操作,不放入共享 NamespaceState。 vm: Vm, + // 宿主安装期间保留的拥有根,close 逆序 pop;ObjectRef Rust 数值本身没有自动 release 行为。 roots: Vec, + // 主全局字典也在 roots 中,先清字典断业务根,再统一释放根,避免闭包环残留。 globals: Option, } @@ -1281,10 +1428,11 @@ impl Candidate { }) } - /// 释放候选拥有的宿主根并清理 VM;通过显式关闭保证错误也能被报告。 + /// compile/evaluate 显式调用,Candidate::drop 兜底调用;按拥有顺序拆根并验证堆没有遗留对象。 fn close(&mut self) -> Result<()> { // 清全局字典先断业务对象可达根,再逆序释放宿主分配根;最后执行循环回收并验证对象计数归零。 if let Some(globals) = self.globals.take() { + // take 使重复 close 不重复清同一身份;先断用户全局再释放类型/函数等宿主根。 self.vm.dict_clear(globals)?; } // pop 逐项移出确保再次 close 不重复释放已成功处理的根;错误仍交显式关闭调用方报告。 @@ -1293,6 +1441,7 @@ impl Candidate { } self.vm.collect_cycles(); if self.vm.object_count() != 0 { + // 源程序返回了纯数据也不掩盖资源泄漏,显式 close 会把这个错误报告给调用者。 bail!("candidate VM retained objects after cleanup"); } Ok(()) @@ -1307,13 +1456,14 @@ impl Drop for Candidate { } } -/// 把 VM 错误与当前源位置/调用栈组合成用户可读的诊断。 +/// 本模块和 bridge/tasks 的 VM 错误映射调用,立即取 last_traceback 拼成纯文本,避免后续调用覆盖故障栈。 pub(crate) fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error { // 错误发生后立即读取 VM traceback,保留函数、源文件与指令偏移;无栈信息时直接保留原 VmError。 let locations = vm .last_traceback() .iter() .map(|frame| { + // 每帧保留来源文件、函数名和指令位置,不把托管异常对象直接跨线程交付。 format!( "{}:{} in {} (instruction {})", frame.filename, frame.first_line, frame.function, frame.instruction_offset @@ -1322,8 +1472,10 @@ pub(crate) fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error { .collect::>() .join("\n"); if locations.is_empty() { + // 无 VM 栈时保留原始 VmError,不能凭当前源码猜一个错误行号。 error.into() } else { + // 含栈则附加每帧可读位置,Console/API 不需读取 VM 就能展示错误。 anyhow::anyhow!("{error}\n{locations}") } } @@ -1331,18 +1483,21 @@ pub(crate) fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error { #[derive(Default)] /// 编译前的语法节点数量/嵌套预算,限制输入结构复杂度。 struct AstBudget { + // 当前递归路径深度,leave_node 归还;不能与累计节点数共用一个计数。 depth: usize, + // 已访问节点总量,多个 Script 根共享累计,leave_node 不减少。 nodes: usize, } impl Visitor for AstBudget { type Error = anyhow::Error; - /// 更新源结构遍历的深度/数量预算,拒绝超出允许范围的语法树。 + /// Aster Visitor 在进入任意语法节点时调用,compile/evaluate/加载/Console 共用这个结构预算。 fn enter_node(&mut self, _span: Span) -> Result<()> { // 每次进入节点累计总数和当前深度;leave 只归还深度,整份输入总量不会因退出子树重置。 self.depth += 1; self.nodes += 1; if self.depth > MAX_AST_DEPTH || self.nodes > MAX_AST_NODES { + // 在加载/执行代码前停止,防止巨大源码结构绕过仅约束运行指令的 LIMITS。 bail!("Aster AST exceeds the 64-level or 10000-node host budget"); } Ok(()) @@ -1353,7 +1508,7 @@ impl Visitor for AstBudget { } } -/// 把输入语法结构转换为 Sources 工具使用的 CodeNode 层次,保存原始源码位置。 +/// compile/prepare_program/execute_mode 调用,将已解析 AST 投影为 Sources 浏览树,不保存可执行托管引用。 fn architecture( source: &SourceUnit, plan: &SourcePlan, @@ -1366,9 +1521,11 @@ fn architecture( next_id: 0, identity: format!("{namespace}@{submission}"), }; + // 把各根按源顺序拼入一个显示根,后续 CodeNode ID 在这一次 tree 构造中递增。 let mut children = Vec::new(); for root in plan.roots() { match root { + // 模块展示声明树,脚本展示每段语句树,表达式模式保留单表达式层次。 AstRoot::Module(program) => children.extend( program .declarations @@ -1381,12 +1538,14 @@ fn architecture( AstRoot::Expression(expression) => children.push(tree.expression(expression)), } } + // 只借根列表取首尾跨度,不克隆整棵 AST;Script 的 span 已由 endor_script 映回原文件。 let roots = plan.roots(); // 整体范围取首根起点至末根终点,Script 的多个交互片段在原始文本中的 span 已恢复偏移。 let span = Span::new( roots.first().map_or(0, |root| root.span().start), roots.last().map_or(0, |root| root.span().end), ); + // 代码根种类是 Sources 显示分类,不参与 VM 模块/脚本执行方式的选择。 let kind = if matches!(plan, SourcePlan::Module(_)) { "module" } else { @@ -1395,13 +1554,15 @@ fn architecture( tree.node(namespace.to_owned(), kind, span, children) } -/// 建立源文本每行起点表,供字节 span 转换为行列位置。 +/// architecture/execute_mode 调用,建立字节偏移→行号的搜索索引,兼容 CRLF、LF 和孤立 CR。 fn line_starts(source: &str) -> Vec { + // Span 使用 UTF-8 字节单位,所以不用 chars().enumerate() 的字符索引匹配它。 let bytes = source.as_bytes(); // 按字节扫描行终止符,CRLF 仅在 LF 记一次,孤立 CR 也换行;空文本仍保留第1行起点0。 let mut starts = vec![0]; for (offset, byte) in bytes.iter().enumerate() { if *byte == b'\n' || (*byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n')) { + // 记录换行符之后的下一行首,CRLF 只在 LF 分支记录一次,空末行也有可定位起点。 starts.push(offset + 1); } } @@ -1410,14 +1571,18 @@ fn line_starts(source: &str) -> Vec { /// 构造 Sources 语法投影所需的源码与定位辅助状态。 struct Architecture { + // 排好序的字节行首,node 通过 partition_point 得到一基行号。 line_starts: Vec, + // 单次代码投影中的序号,高水位只用于 UI ID,不是 VM 对象身份。 next_id: usize, + // namespace@submission 前缀,区分不同提交/Console 输入产生的 AST 节点。 identity: String, } impl Architecture { - /// 分配本投影内节点 ID,并将半开字节 span 映射为1基起止行,组装 CodeNode。 + /// declaration/function/statement/expression 共用的纯数据分配助手,统一 ID 与半开 span→一基行号转换。 fn node(&mut self, name: String, kind: &str, span: Span, children: Vec) -> CodeNode { + // 先取当前序号再递增,父子节点构建顺序可不同但同投影内身份唯一。 let id = self.next_id; self.next_id += 1; // partition_point 返回不大于 offset 的行首数量,天然得到1基行号;span.end 是半开端,先减1找最后实际字符行。 @@ -1432,7 +1597,7 @@ impl Architecture { } } - /// 区分函数与类声明;类下先投影字段,再投影方法。 + /// architecture 的模块声明和 statement 的声明分支调用,区分普通函数与含字段/方法的类。 fn declaration(&mut self, declaration: &Decl) -> CodeNode { match &declaration.kind { DeclKind::Function(function) => self.function(function), @@ -1449,7 +1614,7 @@ impl Architecture { } } - /// 将函数声明及其体内语句投影为一个带源码范围的 function 节点。 + /// declaration 的函数和类方法分支调用,逐条投影函数体;参数包含在源码范围中但不另建显示节点。 fn function(&mut self, function: &frontend::ast::Function) -> CodeNode { // 函数投影按函数体语句顺序递归展开,不单独生成参数节点;展示范围仍覆盖完整函数声明。 let children = function @@ -1466,25 +1631,30 @@ impl Architecture { ) } - /// 按赋值、返回、表达式和声明种类构建 Sources 层次,保留真实子表达式关系。 + /// architecture/function/分支体递归调用,生成语句浏览节点,不执行条件、不改变 AST 或业务对象。 fn statement(&mut self, statement: &Stmt) -> CodeNode { match &statement.kind { StmtKind::If(branch) => { + // 条件是第一个孩子,随后 then/else 依源顺序展开;树不预测运行时会走哪支。 let mut children = vec![self.expression(&branch.condition)]; children.extend(branch.body.statements.iter().map(|s| self.statement(s))); if let Some(body) = &branch.otherwise { + // 没有 else 就不造空显示分支,保持 Sources 仅浏览实际存在的源码。 children.extend(body.statements.iter().map(|s| self.statement(s))); } self.node("if".into(), "if", statement.span, children) } StmtKind::While(loop_) => { + // 只展开一次静态循环体,不能按运行次数复制显示节点或重演循环。 let mut children = vec![self.expression(&loop_.condition)]; children.extend(loop_.body.statements.iter().map(|s| self.statement(s))); self.node("while".into(), "while", statement.span, children) } // 成员赋值投影两个子表达式:目标对象和右值,成员名作为本节点显示名称。 StmtKind::MemberAssignment(assignment) => { + // 成员访问目标也可能是复合调用,独立投影才能浏览到其中的对象表达式。 let object = self.expression(&assignment.object); + // 右值另建子树,不把赋值目标名字混进计算表达式的显示类型。 let value = self.expression(&assignment.value); self.node( assignment.name.text.clone(), @@ -1494,6 +1664,7 @@ impl Architecture { ) } StmtKind::Binding(binding) => { + // 显示绑定名字,孩子保留初始值表达式及其完整范围。 let child = self.expression(&binding.value); self.node( binding.name.text.clone(), @@ -1503,6 +1674,7 @@ impl Architecture { ) } StmtKind::Assignment(assignment) => { + // 和 let 显示类型区分,Sources 可分清声明与之后更新同一名字的语句。 let child = self.expression(&assignment.value); self.node( assignment.name.text.clone(), @@ -1512,6 +1684,7 @@ impl Architecture { ) } StmtKind::Return(returned) => { + // Option::iter 保持无值 return 的孩子列表为空,不凭空补一个 nil AST 节点。 let children = returned .value .iter() @@ -1524,8 +1697,9 @@ impl Architecture { } } - /// 递归投影表达式层次,用简短文本作显示名,并保留完整原始 span。 + /// statement 和 expression 自递归调用,保留运算/成员/调用层次,显示标签可截短但源范围不截短。 fn expression(&mut self, expr: &Expr) -> CodeNode { + // 三元组统一提供 UI 名称、种类和孩子,最后仍用同一个 node 助手分配 ID/行号。 let (name, kind, children) = match &expr.kind { ExprKind::Name(name) => (name.clone(), "name", vec![]), ExprKind::Number(value) => (value.clone(), "number", vec![]), @@ -1542,6 +1716,7 @@ impl Architecture { vec![self.expression(left), self.expression(right)], ), ExprKind::Member { object, name } => { + // 成员名作显示标签,对象表达式保留为孩子,类型/属性读取语义不在此求值。 (name.text.clone(), "member", vec![self.expression(object)]) } ExprKind::Call { callee, arguments } => { diff --git a/src/runtime/tasks.rs b/src/runtime/tasks.rs index ae34f11..05e5363 100644 --- a/src/runtime/tasks.rs +++ b/src/runtime/tasks.rs @@ -2,8 +2,10 @@ //! 任务可能处于等待 I/O、定时器、可执行协程或等待另一任务的状态;取消 owner/key 不能让晚到结果再次执行。 //! 调度使用有界量子和公平推进;await 挂起后保留操作数与帧,不重放此前副作用。失败和依赖环有明确处理路径。 //! -//! Owner-managed asynchronous work. Only weak identities enter the scheduler; -//! callbacks and scopes are ordinary traced edges in the Aster object graph. +//! ui_bridge::bootstrap 调用 install,注册 ui.after/sleep/readText/fetchText/spawn 并取得每 VM 的 Scheduler。 +//! 源 API 经 After 创建 Task;UiBridge/endor_host 的轮询调用 Scheduler::poll,才在所属 VM 交付结果或推进协程。 +//! 页面/窗口关闭调用 cancel_owner,运行时退出调用 quiesce;只取消身份和执行边,不允许旧 I/O 结果复活它们。 +//! 这里同时含通用调度和 Tessera 展示资格检查,未来迁移 Nexus 时需将 scope/eligible 的 UI 规则留在宿主适配中。 use std::{ sync::{ Arc, Mutex, @@ -22,8 +24,11 @@ use crate::{block::Block, mount::Mount, ui_bridge::desktop_type, window::Managed // 普通对象的私有任务字典属性名;Block/Mount/Window 则把同一拥有关系保存在宿主 payload。 const SCOPE: &str = "\0tessera.tasks"; +// 单 VM 弱调度索引的活任务预算;已完成任务可被源变量持有,但不继续占调度槽。 const MAX_TASKS: usize = 1024; +// 单 owner 作用域字典的项数上限;同 key 替换不新增一项,允许在满额度时进行。 const MAX_OWNER_TASKS: usize = 64; +// 一轮 poll 最多完成/推进/取消的工作数,避免任务队列独占 VM worker。 const MAX_READY: usize = 64; // 调度器只保存弱任务身份,不因排队把任务、回调或 owner 隐式保活。 @@ -32,14 +37,19 @@ type Index = Arc>>; #[derive(Debug, Clone, Copy, PartialEq, Eq)] /// 任务对源代码可见的生命周期状态,完成/取消/失败各有不同 await 语义。 enum State { + // 等截止时间、I/O 或首次推进;不表示后台操作尚未开始。 Pending, + // 普通回调正在执行,或协作任务已有保存的协程,仍可能处于 await 等待。 Running, + // 正常终态,允许 await/result 读取保存的结果或 Nil。 Completed, + // 失去 owner/key 资格或主动取消的终态,await 应报告 AwaitCancelled。 Cancelled, + // provider/回调/协程失败,error 保存诊断而非可当作成功值交付的文字。 Failed, } impl State { - /// 返回内部枚举或对象对应的稳定源名称。 + /// Task.status 方法调用,返回稳定的小写 API 状态字符串,不用于控制调度分支。 fn name(self) -> &'static str { match self { Self::Pending => "pending", @@ -56,21 +66,29 @@ impl State { struct Task { // owner 通过 scope 字典强持有 Task,Task 反向弱引用 owner,避免任务把已关闭页面永久保活。 owner: WeakObjectRef, + // owner 内唯一键,作用域字典决定它是否仍是当前任务;不是跨运行时的全局 ID。 key: String, // 单调时钟截止点;ready 与到期是两个独立条件,I/O 完成不受系统日期调整影响。 due: Instant, + // 定时/I/O 的零参回调或 spawn 的初始函数,由 trace 声明真实强边。 callback: Option, + // 真实执行状态;只读 status 可能因失去资格投影为 cancelled,而不立刻改写此字段。 state: State, // 定时/协作任务创建即 ready,但仍等 due;I/O 任务等 provider 结果写回后才 ready。 ready: bool, + // I/O 成功纯文本,普通回调执行期间也可通过 Task.result 查询;非托管字符串不需 trace。 text: Option>, + // provider/执行失败的有界预览,Completed 的成功值不能与此混用。 error: Option>, // 仅任务 payload 持有 I/O 租约;取消/终态清除此边即可撤销后台交付并归还额度。 lease: Option>, + // 仅 spawn 为 true:使用可恢复 Coroutine 和软量子,其余回调一次有界调用完成。 cooperative: bool, + // spawn 首次推进创建的托管协程帧,后续 resume 保留此前副作用与累计硬预算。 coroutine: Option, // 协程 await 的对象是真实托管强边;等待期间由它保活被等待值,不以弱调度索引代替。 waiting: Option, + // 协程最终返回的任意语言值;对象结果也经 trace 保活,取消时清掉。 result: Option, } impl ObjectPayload for Task { @@ -86,13 +104,15 @@ impl ObjectPayload for Task { fn reference_assignment_only(&self) -> bool { true } - /// 把 Task 状态投影成 await 的就绪、等待、取消或失败结果,不在等待期间主动轮询执行。 + /// Aster 的 await 执行和 Scheduler 的等待检查调用,查询状态而不递归推进任务或 VM。 fn poll_await(&self) -> Result { match self.state { + // I/O 已收到 ready 文本但尚未真正处理到终态时,await 仍不能抢先越过回调。 State::Pending | State::Running => Ok(endor_core::AwaitPoll::Pending), // 协作任务优先返回保存的任意 Value,I/O 返回 text,普通定时器默认为 Nil。 State::Completed => Ok(endor_core::AwaitPoll::Ready( self.result.clone().unwrap_or_else(|| { + // 协作返回值优先,否则 I/O 文本转语言字符串,纯计时器/sleep 无值则返回 Nil。 self.text .as_ref() .map(|text| Value::Text(text.to_string())) @@ -107,6 +127,7 @@ impl ObjectPayload for Task { } /// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。 fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { + // Aster GC 只追踪真实拥有边,owner/调度索引的弱身份均不得在这里访问。 if let Some(callback) = self.callback { visit(callback); } @@ -121,42 +142,46 @@ impl ObjectPayload for Task { } } } -/// 统一构造本模块的参数错误,让调用方停止处理无效输入。 +/// API 解码和 Task 方法检查共用错误构造器,调用位置与源栈由外层 VM 诊断补充。 fn invalid(message: &'static str) -> VmError { VmError::InvalidArgument(message) } -/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。 +/// After/Method/作用域遍历解码对象值时调用,只复制句柄,不建立隐藏托管强边。 fn object(value: &Value) -> Result { match value { Value::Object(object) => Ok(*object), _ => Err(invalid("task owner and callback must be managed objects")), } } -/// 取得 owner 关联的托管任务作用域,供 key 替换和取消使用。 +/// After、current、cancel_owner/finish 调用,读取 owner 真正拥有 Task 的字典;不创建新作用域。 fn scope(store: &ObjectStore, owner: ObjectRef) -> Result, VmError> { // scope 的位置依 owner 类别而异;普通对象即使使用私有属性,也要验证该值确为字典。 let payload = store.object(owner)?.payload(); if let Some(value) = payload.as_any().downcast_ref::() { + // Block 的宿主任务边保存在 payload,不能因普通属性同名而被覆盖或伪造。 return Ok(value.tasks); } if let Some(value) = payload.as_any().downcast_ref::() { + // 同业务对象的不同出现可分别拥有任务,Mount 作用域不是 Block 作用域别名。 return Ok(value.tasks); } if let Some(value) = payload.as_any().downcast_ref::() { + // 窗口级任务跟资源生命周期,不随某一页切换自动转移到另一页。 return Ok(value.tasks); } match store.get_attr(owner, SCOPE) { Ok(Value::Object(scope)) => { - // Validate even the private host field before using it as an owner edge. + // 普通对象的私有属性也必须真为字典,不能把任意 Object 当作 owner/key 拥有表。 store.dict_items(scope)?; Ok(Some(scope)) } + // 尚未提交过任务是正常 None,其他读取错误必须传播而非被当作缺省空字典。 Err(VmError::AttributeNotFound(_)) => Ok(None), Ok(_) => Err(invalid("invalid task scope")), Err(error) => Err(error), } } -/// 为可拥有任务的对象安装作用域,真实引用仍由托管图追踪。 +/// After 首次给 owner 创建字典时调用,按 owner 种类建立可追踪强边,不修改其他任务内容。 fn install_scope( cx: &mut NativeContext<'_>, owner: ObjectRef, @@ -165,42 +190,50 @@ fn install_scope( // 宿主 payload 通过 replace_payload 建立受 GC 追踪的强边,普通对象通过 set_attr;均不能绕过写屏障。 let payload = cx.store().object(owner)?.payload(); if let Some(value) = payload.as_any().downcast_ref::() { + // 克隆 payload 后改任务边,replace_payload 负责引用增减与版本记录,不能直接改 store 借用值。 let mut value = value.clone(); value.tasks = Some(scope); return cx.replace_payload(owner, value); } if let Some(value) = payload.as_any().downcast_ref::() { + // 出现位置持字典,不把它挂到共享 Block 导致关闭一个出现时取消另一个窗口的任务。 let mut value = value.clone(); value.tasks = Some(scope); return cx.replace_payload(owner, value); } if let Some(value) = payload.as_any().downcast_ref::() { + // 复用同一写屏障保持窗口资源对象的 GC 关系,不另建 Rust HashMap 持 Task 根。 let mut value = value.clone(); value.tasks = Some(scope); return cx.replace_payload(owner, value); } cx.set_attr(owner, SCOPE, Value::Object(scope)) } -/// 检查任务的 owner 仍有效且任务具备交付条件,避免晚到结果复活已关闭页面。 +/// After 创建、Task.active/status 和 Scheduler 交付前调用,验证 owner 的 Tessera 展示资格,不检查 key 身份。 fn eligible(store: &ObjectStore, owner: ObjectRef, desktop: ObjectRef) -> Result { + // 本函数只做同步借用查询,不升级额外托管根;真正调用者负责在检查/交付期间持有 owner。 let payload = store.object(owner)?.payload(); if let Some(mount) = payload.as_any().downcast_ref::() { if !mount.mounted { + // Mount 对象可被源变量持有,但卸载后任务不能再有展示交付资格。 return Ok(false); } // Mount 仅 mounted 不足以交付,所属 Page 还必须在某个活动窗口页面注册表里,关闭再打开不能恢复旧任务。 if let Some(page) = mount.page { - // No retained root is needed for this synchronous borrowed lookup. + // desktop 指主资源域;在其真实 windows 注册表中找页,不只检查当前 active 页面。 let windows = object(&store.get_attr(desktop, "windows")?)?; for (_, value) in store.dict_items(windows)? { + // 各窗口所有已登记 Page 都算有效,未选中标签不等于页面已关闭。 for candidate in crate::window::pages(store, object(value)?)? { if store.downgrade(*candidate)? == page { + // 比较弱代际身份,避免堆槽复用后把新 Page 误认成原出现的所属页。 return Ok(true); } } } return Ok(false); } + // 没有所属 Page 的 Mount 不具备异步交付资格,不能因 mounted=true 就放行孤立出现。 return Ok(false); } // 窗口 owner 要求未 closed;Page owner 要求仍注册,普通非页面业务 owner 没有额外展示资格要求。 @@ -212,8 +245,10 @@ fn eligible(store: &ObjectStore, owner: ObjectRef, desktop: ObjectRef) -> Result .downcast_ref::() .is_some_and(|block| block.page) { + // Page 需要仍在任一窗口注册表,不能只靠对象可达/标题存在判断是否还可交付。 let windows = object(&store.get_attr(desktop, "windows")?)?; for (_, value) in store.dict_items(windows)? { + // 从字典值解码托管 Window,再按页面身份查登记关系,而非按字符串标题查找。 let window = object(value)?; if crate::window::pages(store, window)?.contains(&owner) { return Ok(true); @@ -221,9 +256,10 @@ fn eligible(store: &ObjectStore, owner: ObjectRef, desktop: ObjectRef) -> Result } return Ok(false); } + // 非展示类普通业务 owner 仅需可达;其作用域/当前 key 条件由 current 另行检查。 Ok(true) } -/// 检查弱索引对应的任务是否仍是 owner/key 当前登记的那一项。 +/// After 替换、Task 方法、Scheduler 和 finish 调用,验证 owner/key 字典仍精确指向本任务,防止旧代际交付。 fn current( store: &ObjectStore, owner: ObjectRef, @@ -233,24 +269,27 @@ fn current( // key 指向的对象必须恰是当前 Task,替换后旧 Task 即使还被外部持有,也不能接收结果或执行回调。 Ok(match scope(store, owner)? { Some(scope) => { + // 外部仍持有旧 Task token 也不能绕过同键替换,必须精确比对 Value::Object 身份。 store.dict_get(scope, &Value::Text(task.key.clone()))? == Some(Value::Object(reference)) } None => false, }) } -/// Called at the actual close/detach boundary, including close-then-reopen in -/// one language input. A later appearance never revives these identities. -/// 取消某个 owner 的全部任务,防止页面/窗口生命周期结束后继续执行回调。 +/// window/页面关闭与 Mount 退役边界调用,取消 owner 全部任务;同一输入 close 后再 open 也不能复活旧任务。 pub fn cancel_owner(cx: &mut NativeContext<'_>, owner: ObjectRef) -> Result<(), VmError> { + // 无作用域表示从未拥有任务,关闭仍正常成功,不为取消动作反而分配一个新字典。 let Some(scope) = scope(cx.store(), owner)? else { return Ok(()); }; // 克隆条目后逐个变更字典;取消 pending/running 的执行边,再删除所有 owner/key 拥有边。 for (key, value) in cx.store().dict_items(scope)?.to_vec() { + // 复制字典项使删除不会使迭代借用失效;每个值必须是当前 VM 的 Task。 let reference = object(&value)?; + // 取消修改副本并经写屏障提交,释放 callback/coroutine/waiting 的真实托管强边。 let mut task = cx.store().payload::(reference, "Task")?.clone(); if matches!(task.state, State::Pending | State::Running) { + // 终态内容不重新改成 Cancelled,但所有 owner/key 字典边最后都会解除。 task.state = State::Cancelled; task.callback = None; task.lease = None; @@ -277,12 +316,19 @@ enum Operation { #[derive(Debug)] /// 创建定时/I/O/协作任务时的原生调用适配器。 struct After { + // 区分计时、无回调 sleep、后台读取与协作 spawn,决定参数形状和初始 ready 状态。 operation: Operation, + // 源 API 和轮询 Scheduler 共用同一个 Provider mailbox,I/O 线程不接触此 VM。 provider: crate::io_tasks::Provider, + // install 按具体 API 构造的完整数量/类型/效果契约,进入 invoke 前由 VM 检查。 signature: endor_core::CallSignature, + // 弱调度索引,不是 Task 的拥有者;真正拥有边在 owner 的作用域字典。 index: Index, + // spawn 创建后置位,让 worker 知道有可推进协程,prune 在轮末重新计算。 runnable: Arc, + // shutdown/quiesce 置位的单向门闩,清理回调不得再提交新任务。 quiescing: Arc, + // 主资源域用于 Task 类型和跨窗口 owner 登记校验,不把 Task 固定到某个 current 页。 desktop: ObjectRef, } impl NativeCallable for After { @@ -295,40 +341,52 @@ impl NativeCallable for After { Some(self.signature.arity) } - /// 先拒绝退出或只读域的新工作,再解码 owner/key/提供方参数,取消同键旧任务并建立受作用域管理的新 Task。 + /// VM 执行 ui.after/sleep/readText/fetchText/spawn 时调用,验证全部输入再建立新 owner/key 身份,最后取消旧项。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { // 退出域先拒绝新任务;只读渲染也禁止异步提交,即使某构造操作只分配新的任务对象。 if self.quiescing.load(Ordering::Acquire) { + // 退出门闩优先于任何分配/外部 I/O,卸载回调不能重建任务延长资源域寿命。 return Err(invalid("runtime is exiting; new tasks are forbidden")); } if cx.store().is_read_only() { + // 提交任务会改作用域/调度资格,即使只分配新 Task 也不是合法的纯测量/绘制操作。 return Err(invalid( "asynchronous work is forbidden during read-only rendering", )); } // 统一解码不同 API 参数:spawn 用零延时与回调,sleep 无回调,I/O 第三个位置是路径/URL。 + // spawn 没有延时参数,借一个局部零值复用后面的毫秒校验,不扩展源 API 参数表。 let zero = Value::Number(0_i64.into()); + // 统一得到 owner/key/输入/可选回调四部分;此处只借源实参,不在尚未验证时分配 Task。 let (owner, key, delay, callback) = match args { [owner, key, callback] if self.operation == Operation::Spawn => { + // spawn 的第三实参是零参源函数,不能误作 delay 或路径解析。 (owner, key, &zero, Some(callback)) } + // sleep 无回调,只通过 await/Task 结果状态通知完成。 [owner, key, delay] if self.operation == Operation::Sleep => (owner, key, delay, None), + // 四参形状服务 after 和带回调 I/O;第三位稍后依据 operation 解释成毫秒或路径。 [owner, key, delay, callback] => (owner, key, delay, Some(callback)), [owner, key, path] if matches!(self.operation, Operation::ReadText | Operation::FetchText) => { + // 三参 I/O 返回可 await Task,不要求为了接收文本而人为提供一个空回调。 (owner, key, path, None) } _ => { + // 元数据检查外仍防御直接调用/不匹配形状,不让后面按错误的位置读取输入。 return Err(invalid( "asynchronous request requires owner, key, delay/path and optional I/O callback", )); } }; + // owner 必须是真正托管对象,随后 eligible 再检查其展示生命周期。 let owner = object(owner)?; + // Some(错误值) 传播类型错误,None 仅表示合法无回调 API,二者不可混同。 let callback = callback.map(object).transpose()?; // 所有回调都按零显式参数执行,必须是 Function 且参数元数据确认接受零参,不能等执行时再碰运气。 if let Some(callback) = callback { + // 是否带 receiver 已由 VM 绑定函数体现,数量校验针对调用者仍需传的未绑定实参。 if !cx.store().object(callback)?.payload().is_function() { return Err(invalid("task callback must be a Function")); } @@ -341,6 +399,7 @@ impl NativeCallable for After { return Err(invalid("task callback requires zero unbound parameters")); } } + // key 是相同 owner 内的替换身份,不允许用任意对象 repr 当作不稳定字典键。 let Value::Text(key) = key else { return Err(invalid("task key must be text")); }; @@ -348,19 +407,24 @@ impl NativeCallable for After { if key.is_empty() || key.len() > 128 || key.chars().any(char::is_control) { return Err(invalid("invalid task key")); } + // I/O 得到零计时延迟和后台 Request,计时/协作则得到毫秒数且没有外部请求。 let (milliseconds, request) = if matches!(self.operation, Operation::ReadText | Operation::FetchText) { + // 原局部名 delay 此时借用的是路径/URL 字符串,按 operation 先分流再判断实际类型。 let Value::Text(path) = delay else { return Err(invalid("I/O input must be a String")); }; + // 只创建纯数据请求,provider 提交发生在 Task 已分配并有可取消租约的位置。 let request = match self.operation { Operation::ReadText => { + // 路径允许平台路径语义但拒绝空、过长或 NUL;文件类型/字节预算由 worker 再查。 if path.is_empty() || path.len() > 4096 || path.contains('\0') { return Err(invalid("invalid text path")); } crate::io_tasks::Request::ReadText(path.clone()) } Operation::FetchText => { + // 在创建后台工作前拒绝不支持 URL,worker 仍再次校验以维护独立边界。 if !crate::io_tasks::valid_url(path) { return Err(invalid( "expected HTTP(S) URL without credentials or control characters", @@ -372,6 +436,7 @@ impl NativeCallable for After { }; (0, Some(request)) } else { + // 只有计时/sleep/spawn 进入这里,spawn 由上面借用零 Number,因此也受同一整数约束。 let Value::Number(delay) = delay else { return Err(invalid( "task delay must be an integer number of milliseconds", @@ -387,16 +452,19 @@ impl NativeCallable for After { (milliseconds, None) }; if !eligible(cx.store(), owner, self.desktop)? { + // 不允许关闭/卸载对象创建新工作,即使源代码仍通过变量持有它的 ObjectRef。 return Err(invalid("task owner is closed or detached")); } // 在分配新任务前找同 key 旧身份,额度检查允许替换但不允许借其他 key 继续增加总量。 let existing_scope = scope(cx.store(), owner)?; + // 读取可选旧 Task,不删除旧项;新任务的参数/预算/分配失败时应保留旧身份。 let previous = existing_scope .map(|scope| cx.store().dict_get(scope, &Value::Text(key.clone()))) .transpose()? .flatten() .map(|value| object(&value)) .transpose()?; + // 短锁覆盖调度索引剪枝及最终换表,过程不运行源回调,避免看到半次替换索引。 let mut index = self .index .lock() @@ -410,16 +478,19 @@ impl NativeCallable for After { State::Pending | State::Running ) { + // NativeContext 临时升级的根在本次调用结束释放,live 保存的仍只有弱身份。 live.push(weak); } } if live.len() >= MAX_TASKS && previous.is_none() { + // 同 key 替换不是扩容;新 key 在总量上限时必须失败,不能通过长期保存 Task token 绕过。 return Err(VmError::ExecutionLimit("1024 pending tasks per runtime")); } if let Some(scope) = existing_scope && cx.store().dict_items(scope)?.len() >= MAX_OWNER_TASKS && previous.is_none() { + // owner 局部额度独立于全运行时额度,同 key 仍可原位更新已有槽。 return Err(VmError::ExecutionLimit("64 pending tasks per owner")); } // 先分配并设置截止点/状态,再安装 I/O 租约和 owner 字典边;弱 owner 不承担保活作用。 @@ -448,6 +519,7 @@ impl NativeCallable for After { // 只有旧任务仍待完成且真的持有 I/O 租约才使用 provider 临时替换额度,不给普通任务扩大 I/O 上限。 let replacing = previous .map(|previous| { + // 只有实际未完成 I/O 能归还一个旧租约,终态/纯定时器不能借来放宽 provider 上限。 cx.store().payload::(previous, "Task").map(|task| { matches!(task.state, State::Pending | State::Running) && task.lease.is_some() @@ -455,9 +527,11 @@ impl NativeCallable for After { }) .transpose()? .unwrap_or(false); + // 非阻塞提交拿到交付资格;后台即使很快完成,VM 交付仍等后续 poll 核验 owner/key。 let lease = self .provider .submit(cx.store().downgrade(task)?, request, replacing)?; + // 把租约写进 Task payload 才成为其生命周期资源,后台 Job 自己不持有 Lease。 let mut value = cx.store().payload::(task, "Task")?.clone(); value.lease = Some(lease); cx.replace_payload(task, value)?; @@ -465,14 +539,15 @@ impl NativeCallable for After { // 首次提交才安装字典;以后同 owner 复用 scope,key 替换始终在同一拥有表中发生。 let task_scope = if let Some(scope) = existing_scope { + // 已有作用域保持原字典身份;替换不使其他 key 的 Task 所有权发生变化。 scope } else { + // 第一个任务才分配字典并建立 owner→scope 强边,之后 dict_set 建立 scope→Task 强边。 let scope = cx.new_dict()?; install_scope(cx, owner, scope)?; scope }; - // Publish the new identity before invalidating the old result. Nothing - // external is dispatched until the worker accepts this VM generation. + // 先保存新旧身份所需弱键,下面登记成功才取消旧 Task;此处不是向 GUI 发布画面。 let previous_weak = previous.map(|old| cx.store().downgrade(old)).transpose()?; // 替换字典边前临时升级旧任务,避免 dict_set 删除最后拥有边后,清理旧 payload 时对象已被回收。 if let Some(weak) = previous_weak { @@ -481,9 +556,9 @@ impl NativeCallable for After { cx.dict_set(task_scope, Value::Text(key.clone()), Value::Object(task))?; // 新 key 身份登记成功后再将旧任务取消,切断回调/协程/等待/结果与 I/O 租约,晚到结果不得复活。 if let Some(previous) = previous { - // The replacement above can release the old Task's last owner edge. - // Preserve it through the replacement even without a saved token. + // 前面临时升级旧身份保护它跨过字典替换,即使用户没有保存旧 Task token 也能安全清理。 if cx.store().object(previous).is_ok() { + // 只取消仍活跃项,已完成/失败的旧 token 保留自己的终态结果供已持有者检查。 let mut old = cx.store().payload::(previous, "Task")?.clone(); if matches!(old.state, State::Pending | State::Running) { old.state = State::Cancelled; @@ -497,12 +572,14 @@ impl NativeCallable for After { } } } + // 新 Task 的真正根已经在作用域字典中,调度器只需要弱索引,不附加第二条隐藏拥有边。 let weak = cx.store().downgrade(task)?; // 弱索引也移除被替换身份;spawn 额外设置 runnable 唤醒标志,使外层知道存在可推进协程。 live.retain(|old| Some(*old) != previous_weak); live.push(weak); *index = live; if self.operation == Operation::Spawn { + // cooperative 工作首次即可执行,唤醒标志让 worker 不误以为只有未来计时器而进入长等待。 self.runnable.store(true, Ordering::Release); } Ok(NativeResult::Return(Value::Object(task))) @@ -519,10 +596,13 @@ enum Query { Error, } #[derive(Debug)] -/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。 +/// install 安装到 Task 类型的原生方法包装;VM 绑定 receiver 后调用,不为每个 Task 单独创建闭包。 struct Method { + // 包含隐式 Task receiver 的签名,查询只读,cancel 修改任务与 owner/key 字典。 signature: endor_core::CallSignature, + // 五个 API 共用 receiver/资格检查后,各按不同状态可读性规则返回结果。 query: Query, + // eligible 的跨窗口注册表检查使用所属 VM 主资源域,不能以 current 窗口替代。 desktop: ObjectRef, } impl NativeCallable for Method { @@ -535,26 +615,32 @@ impl NativeCallable for Method { Some(endor_core::CallArity::exact(1)) } - /// 验证 Task 接收者并按当前状态处理 status/active/result/error/cancel;仍活跃的任务需重新确认 owner/key 身份。 + /// VM 执行 task.status/active/result/error/cancel 时调用,重新核验活任务资格,查询不因失效偷偷改 payload。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { // 方法无显式参数,实际 args 唯一一项是隐式 Task 接收者。 let [value] = args else { return Err(invalid("Task methods accept no arguments")); }; + // 解码 receiver 后验证真实 Task payload,普通对象无法靠同名方法字段伪造任务。 let reference = object(value)?; + // 状态副本用于 cancel 的写屏障提交;只读查询保留原 Task 不变。 let mut task = cx.store().payload::(reference, "Task")?.clone(); + // pending 这里同时涵盖 Running 协程,不等同于严格的 State::Pending 枚举值。 let pending = matches!(task.state, State::Pending | State::Running); + // 只有活任务才需验证 owner;终态结果不因 owner 后续销毁而突然不可读。 let owner = if pending { cx.upgrade(task.owner)? } else { None }; + // active 同时要求展示资格和当前 key 精确身份,单看 Task.state 会漏掉尚未轮询清理的旧项。 let active = if let Some(owner) = owner { eligible(cx.store(), owner, self.desktop)? && current(cx.store(), owner, reference, &task)? } else { false }; + // 各方法返回类型由 install 签名对应;result 的可用性不同于 active,不能共享一个布尔快路。 let value = match self.query { // pending/running 但已失去 owner/key 资格时,对源报告 cancelled;只读查询不因此改写 payload。 Query::Status => Value::Text( @@ -572,6 +658,7 @@ impl NativeCallable for Method { || task.error.is_some() || (task.cooperative && task.state != State::Completed) { + // 协程未完成不能返回半成品;I/O 错误也不能把 text/Nil 当成功返回。 return Err(invalid( "task result is not available; inspect status and error", )); @@ -593,6 +680,7 @@ impl NativeCallable for Method { ), Query::Cancel => { if cx.store().is_read_only() { + // 即使任务已失效也不允许在只读渲染里执行具有取消语义的方法。 return Err(invalid( "task cancellation is forbidden during read-only rendering", )); @@ -600,7 +688,7 @@ impl NativeCallable for Method { if pending { task.state = State::Cancelled; task.callback = None; - // 回调开始前释放 I/O 名额,已收到的 text 仍留在任务内,可由回调查询。 + // 取消立即放弃 I/O 租约与执行边,下面也清掉 text/result;不保留可交付的成功结果。 task.lease = None; task.coroutine = None; task.waiting = None; @@ -611,6 +699,7 @@ impl NativeCallable for Method { && current(cx.store(), owner, reference, &task)? && let Some(scope) = scope(cx.store(), owner)? { + // 必须仍是当前 key 的同一 Task 才删 owner 边,旧 token.cancel 不能删掉替换后的新任务。 cx.dict_delete(scope, &Value::Text(task.key))?; } } @@ -622,41 +711,49 @@ impl NativeCallable for Method { } } -/// 弱任务索引、公平调度位置和 provider,索引不会单独把任务保活。 +/// bootstrap 返回给 UiBridge 的每 VM 调度能力,endor_host 轮询/退出时使用,原生字段不持有 Task 强根。 pub struct Scheduler { + // 弱项顺序也是轮转顺序,实际推进过的任务在 poll 尾部移到末尾。 index: Index, + // worker 等待策略读取此标志;只表示可能存在可运行协程,不保证下次一定完成一个任务。 runnable: Arc, + // 退出后不可重新打开,新任务 API 与本调度器共享门闩。 quiescing: Arc, + // 跨窗口 owner 生命周期核验的主注册域,不持 GUI 窗口指针。 desktop: ObjectRef, + // 后台只提交 Completion 到邮箱,本调度器才有权在 VM 中写入 Task 与执行回调。 provider: crate::io_tasks::Provider, } impl Scheduler { - /// 停止接收该域的新任务工作,为关闭/替换运行时做准备。 + /// UiBridge::shutdown 调用,先关新任务入口再取消所有活任务,允许退出钩子执行但不能重建工作。 pub fn quiesce(&self, vm: &mut Vm) -> Result { // 先设置不可逆的退出门闩再取消全部任务,防止清理回调间隙继续接收新工作。 self.quiescing.store(true, Ordering::Release); self.cancel_all(vm) } - /// 检查是否有可推进的协程,而不是仅仅存在正在等待外部结果的任务。 + /// UiBridge/endor_host 的等待策略查询,不扫描 VM 堆,仅读取上次创建/prune 得到的可运行标志。 pub fn has_cooperative_work(&self) -> bool { self.runnable.load(Ordering::Acquire) } - /// Mutation boundaries collect unreachable owner/callback cycles before - /// this weak lookup. Idle timer polling does not rescan the entire heap. - /// 在当前 VM 中有界交付就绪任务、执行回调或推进协程;开始布局后由外层刷新流程继续。 + /// UiBridge::poll_tasks 由 endor_host 调用,在 owning VM 内交付邮箱、推进至多 limit 项工作并更新公平索引。 + /// 不在空闲轮询中扫描整堆;不可达 owner/回调环由外层变更边界的 collect_cycles 清理。 pub fn poll(&self, vm: &mut Vm, limit: usize) -> Result { + // limit 是本轮工作预算而非毫秒超时,零或超过 MAX_READY 都不能进入调度。 anyhow::ensure!((1..=MAX_READY).contains(&limit), "invalid task poll limit"); - // Provider threads only fill the mailbox. Only this VM worker updates - // managed payloads, and delivery below rechecks owner/key generation. // provider 只产纯数据,VM worker 此处重新核对任务 pending、租约仍在、owner 有效且 key 仍当前。 for (weak, completion) in self.provider.take_ready()? { if let Some(reference) = vm.upgrade(weak)? { + // 保存邮箱交付 Result,外层先释放 upgrade 得到的 Task 根,再传播任意中途错误。 let result = (|| -> Result<()> { + // 任务可能在后台运行时已被源代码取消/替换,不能直接把 Completion 写进旧副本。 let mut task = vm.payload::(reference, "Task")?.clone(); if task.state != State::Pending || task.lease.is_none() { + // 已终态或放弃租约的晚到结果直接忽略,不设置 ready 把旧任务重新变为可执行。 return Ok(()); } + // 临时拥有 owner 跨过资格检查,不能在完成邮箱里保存一个长期 owner 根。 let owner = vm.upgrade(task.owner)?; + // 即使 eligible/current 报错,也先走下面 owner 根释放路径。 let active = (|| -> Result { Ok(if let Some(owner) = owner { eligible(vm.object_store(), owner, self.desktop)? @@ -669,11 +766,13 @@ impl Scheduler { vm.release(owner)?; } if !active? { + // 身份/生命周期已失效,留给后续有界扫描清理,不交付回调或保存可观察成功文本。 return Ok(()); } // 收到后台结果只设置 ready/text/error,实际终态与回调交付由下面的有界调度扫描决定。 task.ready = true; match completion { + // provider 的纯文本结果在 VM 线程转换为 Task 字段,后台不直接改对象版本。 Ok(text) => task.text = Some(text.into()), Err(error) => task.error = Some(error.into()), } @@ -684,6 +783,7 @@ impl Scheduler { result?; } } + // 复制本轮弱索引后释放锁,用户回调可在 poll 期间提交任务,不应因重入同一索引锁死锁。 let snapshot = self .index .lock() @@ -691,22 +791,32 @@ impl Scheduler { .clone(); // 本轮使用同一 now 判断到期,避免长回调执行后在同轮无限吸收后来才到期的定时器。 let now = Instant::now(); + // 取消无效任务也消耗工作预算,避免大量死亡 owner 使一次轮询无限延长。 let mut work = 0; + // 记录本轮实际执行的弱身份,扫描结束后移到队尾形成 round-robin,不边迭代边改原表。 let mut advanced = Vec::new(); for weak in snapshot { if work >= limit { + // 未扫描的任务保留原索引,下一次 poll 继续有机会运行,而非丢弃余下项。 break; } + // Task 已不可达时不复活它,失效弱项由 prune 最后清理。 let Some(reference) = vm.upgrade(weak)? else { continue; }; + // 一项扫描的任何 VM 错误都先回到 Task 根释放,再让外层按运行时故障规则处理。 let result = (|| -> Result<()> { + // 每轮重读当前 payload,不能沿用 mailbox 阶段或上轮协程恢复前的状态副本。 let mut task = vm.payload::(reference, "Task")?.clone(); if !matches!(task.state, State::Pending | State::Running) { + // 已终态仍可被用户变量持有,但不再消耗执行量子,也不能重复执行 callback。 return Ok(()); } + // owner 弱边可能已死亡,按取消处理而不是把当前运行时当作替代 owner。 let owner = vm.upgrade(task.owner)?; + // 内层结果暂存,使任何回调/完成错误都不会跳过 owner 根释放。 let result = (|| -> Result<()> { + // owner 展示资格和同 key 当前身份都必须满足,保留旧 Task token 不能继续取得执行权。 let active = if let Some(owner) = owner { eligible(vm.object_store(), owner, self.desktop)? && current(vm.object_store(), owner, reference, &task)? @@ -720,9 +830,11 @@ impl Scheduler { } // 被等待对象未就绪则不消费量子;被取消会取消等待者,失败等其他结果交回协程 await 语义处理。 if let Some(waiting) = task.waiting { + // 仅查询 AwaitPoll,不递归执行被等待对象,公平调度仍由外层轮询负责。 match vm.object(waiting)?.payload().poll_await() { Ok(endor_core::AwaitPoll::Pending) => return Ok(()), Err(VmError::AwaitCancelled) => { + // 被等待任务取消会取消等待者;失败则留给恢复后的 await 按语言语义抛出。 work += 1; return finish(vm, reference, State::Cancelled); } @@ -730,6 +842,7 @@ impl Scheduler { } } if task.due > now || !task.ready { + // 时间到了不代表 I/O 已就绪,I/O 就绪也不能越过计时截止;两条件均需满足。 return Ok(()); } work += 1; @@ -737,22 +850,26 @@ impl Scheduler { advanced.push(weak); // spawn 使用可恢复协程而非普通 call;其余 I/O/定时器回调作为一次完整有界调用执行。 if task.cooperative { + // 只推进一次软量子,完成/await/仍可运行分别由 advance_coroutine 处理。 return advance_coroutine(vm, reference); } if task.error.is_some() { + // provider 失败不调用成功回调,用 Failed 终态让 await/error 观察原始文本诊断。 return finish(vm, reference, State::Failed); } + // sleep 或无回调 I/O 直接完成,结果已保存在 Task,不需为了 await 虚构空函数。 let Some(callback) = task.callback else { return finish(vm, reference, State::Completed); }; - // The provider is finished. Keep the result in the Task, - // but release its I/O slot before running source callbacks. + // provider 已完成,回调开始前归还 I/O 名额;text 暂留给回调中的 Task.result 查询。 task.lease = None; task.coroutine = None; task.waiting = None; + // 先发布 Running 状态再进入用户代码,回调可查询任务或取消/替换自身。 let mut running = task.clone(); running.state = State::Running; vm.replace_payload(reference, running)?; + // 普通回调必须一次有界完成,不是 spawn 的可恢复协程;预算不因别的任务回调重入而累加到无限。 let result = vm .call_with_limits( callback, @@ -769,6 +886,7 @@ impl Scheduler { let unit = matches!(value, Value::Nil); vm.release_value(value)?; if !unit { + // 已释放非 Unit 返回根后再标记失败并报告,不能因契约错误泄漏任意对象返回值。 fail_callback( vm, reference, @@ -779,6 +897,7 @@ impl Scheduler { finish(vm, reference, State::Completed) } Err(error) => { + // 存有界诊断并清执行资源,同时把实际错误交给 endor_host 的故障处理。 fail_callback(vm, reference, &format!("{error:#}"))?; Err(error) } @@ -792,9 +911,9 @@ impl Scheduler { vm.release(reference)?; result?; } - // Round-robin resumable work so a long call cannot monopolize the - // one-callback idle budget and starve other runnable Tasks. + // 实际推进项移到末尾,空闲 limit=1 时一个长协程不能每轮都抢在其他可执行任务前。 if !advanced.is_empty() { + // 回调可能新建任务,所以重新取得当前索引再移动已推进项,不能拿旧 snapshot 覆盖新登记。 let mut index = self .index .lock() @@ -806,8 +925,9 @@ impl Scheduler { self.prune(vm)?; Ok(work) } - /// 取消当前域全部任务,切断回调、协程和等待交付资格。 + /// UiBridge 取消和 Scheduler::quiesce 调用,逐项 finish 并清空弱索引,不执行用户取消回调。 pub fn cancel_all(&self, vm: &mut Vm) -> Result { + // 不在锁内调用 finish;它会变更 owner 作用域,但索引不需在整个 VM 清理过程中借用。 let snapshot = self .index .lock() @@ -815,11 +935,13 @@ impl Scheduler { .clone(); for weak in snapshot { if let Some(task) = vm.upgrade(weak)? { + // 升级是拥有根,先保存 finish 错误再 release,避免失败时遗留额外 Task 引用。 let result = finish(vm, task, State::Cancelled); vm.release(task)?; result?; } } + // VM 清理完成后再锁索引,取原项数供诊断并一次性清表。 let mut index = self .index .lock() @@ -830,27 +952,34 @@ impl Scheduler { self.runnable.store(false, Ordering::Release); Ok(count) } - /// 移除已释放的弱任务索引,索引本身不让任务或其 owner 存活。 + /// poll 的轮末收尾调用,移除死亡/终态弱项并计算“是否有可运行协程”,防止等待 I/O 时忙循环。 fn prune(&self, vm: &mut Vm) -> Result<()> { + // 本函数不执行用户函数,短暂持锁扫描弱项及查询 AwaitPoll,不需把整个 VM 堆遍历一遍。 let mut index = self .index .lock() .map_err(|_| anyhow::anyhow!("task index poisoned"))?; + // live 保留待处理状态任务,终态由源变量自己持有,不因调度索引延长寿命。 let mut live = Vec::new(); + // 累加是否有任意协程不用等待外部事件即可推进,不把普通未来计时器视为 runnable。 let mut runnable = false; for weak in index.iter().copied() { if let Some(task) = vm.upgrade(weak)? { + // 先保存 payload 查询 Result,让升级临时根在返回错误之前也能被 release。 let pending = vm.payload::(task, "Task").map(|task| { + // 此局部 pending 是状态布尔,与外层承载查询结果的 Result 区分。 let pending = matches!(task.state, State::Pending | State::Running); // 可运行标记仅针对协作任务:无等待对象可立即推进,有等待对象则看其 poll_await 是否不再 Pending。 runnable |= pending && task.cooperative && task.waiting.is_none(); pending }); if pending.as_ref().is_ok_and(|pending| *pending) { + // 只对成功读出的活任务继续看 waiting,失败则留给释放后的 pending? 传播。 let value = vm.payload::(task, "Task")?; if value.cooperative && let Some(waiting) = value.waiting { + // 等待值已完成/失败/取消都可以恢复处理;只有明确 Pending 才继续休眠。 runnable |= !matches!( vm.object(waiting)?.payload().poll_await(), Ok(endor_core::AwaitPoll::Pending) @@ -868,17 +997,20 @@ impl Scheduler { Ok(()) } } -/// 沿等待链检查任务依赖环,防止 await 永远无法被满足。 +/// advance_coroutine 准备保存 await 依赖前调用,沿 Task.waiting 判断自等/互等,非 Task 值由其自身 await 协议处理。 fn cyclic_wait(vm: &Vm, source: ObjectRef, mut target: ObjectRef) -> Result { + // seen 记录目标身份,既识别回到 source,也识别原本已有的封闭依赖环。 let mut seen = std::collections::HashSet::new(); loop { // 等待链到达 source 或重复已有目标都表示环;不是 Task 或没有 waiting 边时链终止,不判环。 if target == source || !seen.insert(target) { return Ok(true); } + // 非本模块 Task 的 awaitable 没有可遍历 waiting 边,不能臆测其内部依赖结构。 let Some(task) = vm.object(target)?.payload().as_any().downcast_ref::() else { return Ok(false); }; + // 一个尚未等待其他对象的任务是链尾,当前这次新增等待不会自行形成环。 let Some(waiting) = task.waiting else { return Ok(false); }; @@ -886,16 +1018,20 @@ fn cyclic_wait(vm: &Vm, source: ObjectRef, mut target: ObjectRef) -> Result Result<()> { + // 保存初始执行边用于第一次准备,运行源代码后必须重读状态,不能让旧副本覆盖自取消结果。 let mut task = vm.payload::(reference, "Task")?.clone(); // 首次推进把 callback 转成托管 Coroutine,后续复用同一帧与预算,不重放已发生的源副作用。 let coroutine = if let Some(coroutine) = task.coroutine { + // 已有协程直接继续原帧,不从 callback 起点回放写属性/I/O 等副作用。 coroutine } else { + // 只有第一次推进才取 callback,成功保存协程后会清 callback 强边避免重复准备。 let callback = task .callback .ok_or_else(|| anyhow::anyhow!("spawn callback missing"))?; + // 协程自身保存硬预算,软量子每轮刷新也不会让总指令限制重新从零计算。 let prepared = vm .new_coroutine( callback, @@ -906,6 +1042,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { }, ) .map_err(|error| crate::endor_host::runtime_error(vm, error)); + // 准备可能因回调/类型/预算错误失败,统一标记任务失败并保留源诊断。 let coroutine = match prepared { Ok(coroutine) => coroutine, Err(error) => { @@ -913,10 +1050,11 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { return Err(error); } }; - // Native preparation can itself cancel or replace this Task. // 准备 coroutine 期间原生代码也可能取消/替换任务,必须重读当前状态,不能把旧 task 副本覆盖回去。 + // 看真实当前 payload 的状态,而不是此函数入口保存的 task.state。 let current = vm.payload::(reference, "Task")?.state; if !matches!(current, State::Pending | State::Running) { + // 已失去执行资格,刚准备出的协程返回根仍需释放,不能写回 Task 复活它。 vm.release(coroutine)?; return Ok(()); } @@ -924,17 +1062,17 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { task.callback = None; task.coroutine = Some(coroutine); // 先由 payload 建立协程拥有边,再释放创建根;安装失败也尝试释放局部根。 + // 先暂存安装结果,让创建根不管写屏障成功/失败都经过下面 release。 let installed = vm.replace_payload(reference, task); vm.release(coroutine)?; installed?; coroutine }; + // 续体结果先保存,运行函数可能主动取消/替换 Task,交付前还要核验真实状态。 let result = vm // 每次最多执行 256 指令软量子,达到量子时仅挂起;总 100000 指令上限仍由协程本身累计。 .resume_coroutine(coroutine, 256) .map_err(|error| crate::endor_host::runtime_error(vm, error)); - // Cancellation/replacement from the running function must win. A completed - // value still owns a root and must be released even when delivery is stale. // 源代码运行期间可取消自身,取消/替换优先于本轮完成;完成值即使不交付也必须释放其拥有根。 let active = matches!( vm.payload::(reference, "Task")?.state, @@ -943,6 +1081,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { match result { Ok(endor_core::CoroutineProgress::Waiting { awaitable, .. }) => { if !active { + // 等待来自已取消旧执行时不再登记 Task.waiting,让取消/替换优先。 return Ok(()); } // 登记等待强边前检查依赖环,拒绝永不满足的互相 await,并把当前任务转失败。 @@ -950,6 +1089,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { fail_callback(vm, reference, "cyclic task await")?; bail!("cyclic task await"); } + // 保存强等待边,让被等待值在协程挂起期间真实可达,而不是靠弱调度表保活。 let mut task = vm.payload::(reference, "Task")?.clone(); task.waiting = Some(awaitable); vm.replace_payload(reference, task)?; @@ -958,6 +1098,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { // 软量子挂起不代表等待外部值,清 waiting 后下一轮仍视作可运行。 Ok(endor_core::CoroutineProgress::Pending { .. }) => { if active { + // 本片量子用尽但并未 await,清旧 waiting 让 prune 把它判为下一轮可运行。 let mut task = vm.payload::(reference, "Task")?.clone(); task.waiting = None; vm.replace_payload(reference, task)?; @@ -966,13 +1107,16 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { } Ok(endor_core::CoroutineProgress::Complete { value, .. }) => { if !active { + // 完成值来自 VM 返回拥有根;即使不再交付也不能直接 drop Rust Value 而不 release。 vm.release_value(value)?; return Ok(()); } + // 重新读可能被源函数更新过的字段,仅附加本次成功结果,避免入口旧副本覆盖新状态。 let mut task = vm.payload::(reference, "Task")?.clone(); // 结果先写入受 trace 管理的 payload,再释放 VM 返回值根,最后 finish 清执行边但保留成功结果。 task.result = Some(value.clone()); task.waiting = None; + // 结果对象先由 Task.trace 保活;局部返回根随后释放,即使存储失败也不跳过释放。 let stored = vm.replace_payload(reference, task); vm.release_value(value)?; stored?; @@ -980,6 +1124,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { } Err(error) => { if active { + // 已终态任务不再改成 Failed,保持自取消/替换结果;实际执行错误仍返回上层。 fail_callback(vm, reference, &format!("{error:#}"))?; } Err(error) @@ -987,8 +1132,9 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> { } } -/// 记录回调执行失败并沿运行时故障规则停止后续不安全的交付。 +/// Scheduler::poll/advance_coroutine 的回调错误分支调用,保存有界诊断,再经 finish 统一清理执行资源。 fn fail_callback(vm: &mut Vm, reference: ObjectRef, message: &str) -> Result<()> { + // 只更新 error 字段,不自行复制 Task 结果或重新分配一份故障任务对象。 let mut task = vm.payload::(reference, "Task")?.clone(); // 错误预览按字符限长,保证 UTF-8 完整;记录后统一走终态清理而非保留失败协程。 task.error = Some(message.chars().take(1024).collect::().into()); @@ -996,8 +1142,9 @@ fn fail_callback(vm: &mut Vm, reference: ObjectRef, message: &str) -> Result<()> finish(vm, reference, State::Failed) } -/// 将任务转入终态并释放回调、provider 租约、协程和等待引用;取消还会清除保存的结果。 +/// poll、cancel_all、协程收尾和 fail_callback 共用的终态转换,清执行边并只撤销仍匹配的 owner/key 拥有关系。 fn finish(vm: &mut Vm, reference: ObjectRef, state: State) -> Result<()> { + // 先核对当前真实状态,已完成/失败/取消不再被另一个迟到的收尾覆盖。 let mut task = vm.payload::(reference, "Task")?.clone(); if !matches!(task.state, State::Pending | State::Running) { return Ok(()); @@ -1009,11 +1156,13 @@ fn finish(vm: &mut Vm, reference: ObjectRef, state: State) -> Result<()> { task.coroutine = None; task.waiting = None; if state == State::Cancelled { + // 取消不保存成功结果;Completed/Failed 的诊断/结果由保留 Task token 的源代码继续观察。 task.text = None; task.result = None; } vm.replace_payload(reference, task.clone())?; if let Some(owner) = vm.upgrade(task.owner)? { + // owner 可能已经回收,无 owner 时无需删表;升级成功则先保留清理结果再 release。 let result = (|| -> Result<()> { // 只有 key 仍指向本任务才删 owner 边,避免旧任务结束时误删同 key 新任务。 if current(vm.object_store(), owner, reference, &task)? @@ -1029,7 +1178,7 @@ fn finish(vm: &mut Vm, reference: ObjectRef, state: State) -> Result<()> { Ok(()) } -/// 把本模块的类型、方法或能力注册到所属运行时上下文。 +/// ui_bridge::bootstrap 在 Task 类型建立后调用,注册五种创建 API 和 Task 实例方法,返回同域 Scheduler。 pub fn install( vm: &mut Vm, roots: &mut Vec, @@ -1038,9 +1187,13 @@ pub fn install( ) -> Result { // 每个运行时一份弱索引、可运行/退出标记和 provider mailbox,UI API 与 Scheduler 共享这些原生状态。 let index = Arc::new(Mutex::new(Vec::new())); + // 初始化没有可推进协程,spawn 及轮末 prune 才会更新这个唤醒提示。 let runnable = Arc::new(AtomicBool::new(false)); + // 运行时退出单向门闩共享给所有 After callable,不能只在 Scheduler 自己保留一个无效本地副本。 let quiescing = Arc::new(AtomicBool::new(false)); + // 所有 API 返回同一 Task 名义类型,不能为 after/sleep 各建一个不兼容 Task 类。 let definition = desktop_type(vm.object_store(), "Task", desktop)?; + // 每运行时独立完成邮箱/租约额度,工作线程池由 Provider 内部全进程共用。 let provider = crate::io_tasks::Provider::default(); for (name, operation) in [ ("after", Operation::Timer), @@ -1049,20 +1202,25 @@ pub fn install( ("fetchText", Operation::FetchText), ("spawn", Operation::Spawn), ] { + // I/O 第三参数为字符串并声明 External;其余初始模板用毫秒 Number/Mutating。 let external = matches!(operation, Operation::ReadText | Operation::FetchText); + // After 共享索引/门闩/Provider,只有 operation 与签名不同,仍保留各源 API 独立名字。 let function = vm.allocate(ContextNativeFunction::new( name, After { operation, provider: provider.clone(), signature: { + // 基础四参模板为 owner、key、delay/path、callback,随后针对无回调/零延时 API 收窄。 let mut signature = endor_core::CallSignature::exact( vec![ endor_core::AnnotationType::Any, endor_core::AnnotationType::String, if external { + // readText/fetchText 使用路径/URL,不能让 API 签名把它误认作计时延迟。 endor_core::AnnotationType::String } else { + // Timer/Sleep 使用整数毫秒,整数性与范围由 After::invoke 再查。 endor_core::AnnotationType::Number }, endor_core::AnnotationType::Function, @@ -1076,13 +1234,16 @@ pub fn install( ); // I/O 可带可选回调;spawn 去掉延时参数,sleep 去掉回调参数,保留各自正确 arity。 if external { + // I/O 允许不传回调并直接 await 返回 Task,但 owner/key/path 三参始终必填。 signature.arity = endor_core::CallArity::range(3, 4); } if operation == Operation::Spawn { + // 第三位改为源函数,去掉延时注解而非只把 required 数量改成 3。 signature.parameters.remove(2); signature.arity = endor_core::CallArity::exact(3); } if operation == Operation::Sleep { + // sleep 无回调,不能把三参 API 的注解末尾遗留成任意函数尾参。 signature.parameters.pop(); signature.arity = endor_core::CallArity::exact(3); } @@ -1106,6 +1267,7 @@ pub fn install( ("result", Query::Result), ("error", Query::Error), ] { + // 方法存在 Task 类型表上,receiver 由 VM 绑定;返回值契约按 query 区分。 let method = vm.allocate(ContextNativeFunction::new( name, Method { @@ -1114,11 +1276,13 @@ pub fn install( signature: endor_core::CallSignature::exact( vec![endor_core::AnnotationType::Nominal(definition)], match query { + // status/error 返回文本,result 保留任意源值,cancel/active 则返回布尔。 Query::Status | Query::Error => endor_core::AnnotationType::String, Query::Result => endor_core::AnnotationType::Any, _ => endor_core::AnnotationType::Bool, }, if matches!(query, Query::Cancel) { + // 查询和取消的效果不同,不能让 cancel 在只读布局里改变任务资格。 endor_core::CallEffect::Mutating } else { endor_core::CallEffect::ReadOnly @@ -1130,6 +1294,7 @@ pub fn install( roots.push(method); vm.define_method(definition, name, method)?; } + // 返回与全部 After callable 相同的共享状态,UiBridge 后续通过它轮询/退出本 VM 的任务。 Ok(Scheduler { index, runnable, -- 2.52.0 From ed1a9d2956ccceafa734703d7d8f8d4c9b1a49d3 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:27:39 +0800 Subject: [PATCH 04/78] docs: explain worker publication and HTTP integration regressions --- src/runtime/api/guide.rs | 8 + src/runtime/api/tests.rs | 209 ++++++++++++++++++++- src/runtime/api/worker.rs | 169 +++++++++++++---- src/runtime/endor_host/manuscript_tests.rs | 55 +++++- 4 files changed, 393 insertions(+), 48 deletions(-) diff --git a/src/runtime/api/guide.rs b/src/runtime/api/guide.rs index c8e6f7f..b12dc57 100644 --- a/src/runtime/api/guide.rs +++ b/src/runtime/api/guide.rs @@ -1,12 +1,16 @@ //! 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。 //! //! 指南正文是返回给客户端的静态 Markdown 模板,只有命名空间、版本、地址和内置示例按参数填充。 +//! api::handle 的 GET /agent 分支在 NamespaceState 读锁内调用 agent_guide,随后按 text/markdown 返回。 +//! 原始字符串中的 ```endor/JSON/HTTP 等代码块是给 API 使用者看的文档数据,不是 Rust 会执行的逻辑。 use super::*; /// 为 /agent 生成可提交代码与查询接口的 Markdown 说明。 /// format! 使用具名插值注入 namespace.name/revision 与监听地址;示例在编译时嵌入, /// 因而请求不会访问示例文件、执行示例或改变会话。正文中的 JSON/Endor 是文档内容。 +/// 函数只读传入状态,不能由“指南显示某版本”推断新候选已成功发布;revision 仍以当前发布状态为准。 pub(super) fn agent_guide(namespace: &NamespaceState) -> String { + // 一次 format 生成完整响应字符串;原始字符串保留文档换行,具名插值在模板结束处统一提供。 format!( r##"# Tessera Agent API @@ -253,9 +257,13 @@ binding. Mouse routing, tab chrome/multiple windows and incremental layout remai in progress; process isolation is future work. Console retains objects only in its explicitly resettable in-memory VM. It is not an untrusted-code sandbox. "##, + // 路由指向当前唯一活 namespace,不由请求查询串或任意模板文件决定。 name = namespace.name, + // 只显示最近发布的 revision,不把正在排队的 submission 当作已经成功的新版本。 revision = namespace.revision, + // 与 api::start 的实际监听常量一致;不扫描其他端口或猜测外部网络地址。 address = API_ADDRESS, + // include_str 在应用构建时嵌入例子,处理 GET 时不读磁盘,也不在 VM 运行这个例子。 example = include_str!("../../../examples/page.endor"), ) } diff --git a/src/runtime/api/tests.rs b/src/runtime/api/tests.rs index f00e04c..e5ec622 100644 --- a/src/runtime/api/tests.rs +++ b/src/runtime/api/tests.rs @@ -1,6 +1,9 @@ //! API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。 //! 测试服务使用独立监听地址和串行 worker;应结合断言理解成功输入、失败候选、过期事件和关闭的不同保留语义。 //! +//! Rust 测试框架调用下方 #[test];大多数测试通过 TestServer::request 访问真实 tiny_http 路由和串行 VM worker。 +//! 少量测试直接安装 Worker 的活会话以精确触发发布身份边界,仍调用真实 ReplSession 而非模拟对象堆。 +//! r# 字符串中的 Endor 是测试夹具,行号/诊断也可能是契约的一部分;注释只加在 Rust 测试逻辑外层。 use super::*; use std::{ io::Write, @@ -13,6 +16,7 @@ use std::{ /// 模拟 reset 后旧画面仍含编号 2 的窗口,而新 Console VM 恰好再次分配编号 2。 /// 关闭 session=0 的旧投影只能移除画面,不能按相同数字误关新 VM 的真实资源。 fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { + // 旧运行时真实创建多个窗口,随后只保留其纯数据画面以模拟 Console reset 后的静态投影。 let (_, mut picture) = endor_host::ReplSession::load( include_str!("../../../examples/windows.endor"), "app.main", @@ -25,6 +29,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { for view in picture.windows.values_mut() { view.frame.session = 0; } + // 新 VM 使用不同 session,但窗口分配器同样从 2 开始,故意形成“同号但不同代”的资源。 let mut fresh = endor_host::ReplSession::new(151).unwrap(); assert!( fresh @@ -32,16 +37,19 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { .is_err() ); assert!(fresh.accepts_window(2)); + // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { document: picture, submission: 1, runtime_session: 151, ..NamespaceState::default() })); + // 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。 let mut worker = Worker { repl: Some((1, fresh)), next_session: 151, }; + // 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。 let notify: Notify = Arc::new(|| {}); namespace.write().unwrap().window_viewports.insert( 2, @@ -55,6 +63,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { namespace.read().unwrap().document.windows[&2].frame.session, 0 ); + // 关闭信封指向 session=0 的旧投影,不能因新 VM 也有 window=2 就退役它。 let response = worker.close_inactive_window( &crate::events::Input { window: 2, @@ -74,6 +83,7 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { ); assert_eq!(response.status_code(), StatusCode(200)); assert!(namespace.read().unwrap().document.windows.is_empty()); + // 从 worker 真实活域验证新窗口仍存在,不仅检查画面副本是否被删。 let runtime = &mut worker.repl.as_mut().unwrap().1; assert_eq!(runtime.id, 151); assert!(runtime.accepts_window(2)); @@ -85,8 +95,11 @@ fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() { /// 一次测试独占的临时 HTTP 服务;端口由系统分配,持有线程句柄以便结束时解除阻塞并回收。 struct TestServer { + // 拥有实际 Server 以便 Drop 调用 unblock,中断 incoming_requests 等待。 server: Arc, + // 每例实际绑定端口,从 server_addr 读取而非硬编码应用默认地址。 address: SocketAddr, + // Option 允许 Drop 取出句柄只 join 一次,不从借用的 self 中移动裸 JoinHandle。 api: Option>, } @@ -94,6 +107,7 @@ impl TestServer { /// 发送带明确 Content-Length 的 UTF-8 文本请求,使用 Connection: close 读至响应结束。 /// 返回状态码、原始头和正文;10 秒超时使服务端死锁表现为测试失败而非无限等待。 fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) { + // 不用直调 handle 代替传输,真实 socket 覆盖 HTTP 接收、正文流和响应编码。 let mut stream = TcpStream::connect(self.address).unwrap(); stream .set_read_timeout(Some(Duration::from_secs(10))) @@ -102,9 +116,12 @@ impl TestServer { .set_write_timeout(Some(Duration::from_secs(10))) .unwrap(); write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap(); + // Connection: close 让响应读到 EOF,超时避免服务端死锁导致整套测试无限卡住。 let mut response = String::new(); stream.read_to_string(&mut response).unwrap(); + // 按 HTTP 空行分头/体,保持响应原文供状态头与 JSON/源码两类断言分别检查。 let (headers, body) = response.split_once("\r\n\r\n").unwrap(); + // 状态行第二词是状态码,输出与 headers/body 一起成为各测试的三元组接口。 let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap(); (status, headers.to_owned(), body.to_owned()) } @@ -115,6 +132,7 @@ impl Drop for TestServer { fn drop(&mut self) { self.server.unblock(); if let Some(api) = self.api.take() { + // 已解除 accept 等待,join 确保本例线程结束;线程异常由具体测试断言报告,不在 Drop 二次 panic。 let _ = api.join(); } } @@ -124,14 +142,19 @@ impl Drop for TestServer { /// 通过 HTTP 提交覆盖 moveTab 的 Page 子类,再发送带帧身份的 tab-drop。 /// 检查源覆盖恰好调用一次、页面顺序改变,以及重放旧事件返回 409 而不损坏运行时。 fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); + // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // Page 子类在 moveTab 中递增共享计数,既检查实际重排,也能发现业务方法被挂起恢复重复调用。 let source = r#"class Tracked: Page { calls: Object function moveTab(self: Tracked, position: Number): Page { self.calls.increment() return Page.moveTab(self,position) } @@ -144,10 +167,14 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { second.title="second" second.render(TextBlock("second")) first.activate()"#; + // 完整 PUT 成功后才从实际发布帧构造标签事件,不能使用 AST/标题推导 Tab ID。 let result = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(result.0, 200, "{}", result.2); + // 用第一标签原 ID 指定目标插入缝 2,移动后版本应变化,同一正文再发必须过期。 let input = { + // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); + // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 let frame = ¤t.document.frame; crate::events::Input { window: 1, @@ -168,9 +195,12 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { }; // 保留同一事件正文用于重放测试,不能重新读取新 layout_version,否则就不是过期输入。 let body = serde_json::to_string(&input).unwrap(); + // 第一遍走真实事件路由与源覆盖,应只重排一次,不因 worker 分片重试业务方法。 let response = client.request("POST", "/v1/namespaces/app.main/events", &body); assert_eq!(response.0, 200, "{}", response.2); + // 再经持久 Console 读同一共享状态,避免仅凭标签排列看不出重复回调。 let response = client.request("POST", "/v1/namespaces/app.main/repl", "calls.value"); + // value 是 Console JSON 字段中的显示字符串,不是测试自己复制的 Endor 数值。 let value: serde_json::Value = serde_json::from_str(&response.2).unwrap(); assert_eq!(value["value"], "1"); assert_eq!( @@ -190,14 +220,19 @@ fn native_tab_drop_route_reorders_live_pages_and_rejects_reused_frames() { /// 零延时任务触发非法窗口尺寸,使后台回调先产生运行时故障。 /// 之后多次 REPL 请求都应被拒绝,同时保留原始 stop/window size 诊断,而非用后续错误覆盖。 fn background_failure_preserves_its_cause_after_rejected_console_input() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); + // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // 零延时任务的源方法触发非法窗口尺寸,产生真正回调故障而非测试直接写 fault 字段。 let source = r#"class Failure { function stop(self: Failure): Unit { ui.window().resize(0,600) } } @@ -205,10 +240,13 @@ fn background_failure_preserves_its_cause_after_rejected_console_input() { page=Page() page.render(TextBlock("preserved")) task=ui.after(ui,"failure",0,failure.stop)"#; + // 源码候选本身先成功;随后 worker 空闲任务阶段才会触发 stop 中的 resize 错误。 let result = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(result.0, 200, "{}", result.2); + // 重复 Console 输入会被停用域拒绝,但不能覆盖首次回调真正的 window size/stop 诊断。 let repl = "/v1/namespaces/app.main/repl"; assert_eq!(client.request("POST", repl, "42").0, 422); + // 保留第一次故障纯文本,对比后续拒绝输入后仍为同一个原因。 let fault = namespace .read() .unwrap() @@ -230,22 +268,30 @@ fn background_failure_preserves_its_cause_after_rejected_console_input() { /// 依次创建未挂载对象、读取共享值、挂载、reset,再用完整 PUT 替换会话。 /// 验证未挂载输入不增 revision,reset 清绑定但保留画面,成功 PUT 才切换到新 VM。 fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); + // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // 同一 Console 路由跨多个请求复用真实 globals,/code 读的是可见源快照而非内部变量表。 let repl = "/v1/namespaces/app.main/repl"; + // 用于确认 reset 后旧画面/源码仍保留,完整 PUT 才切换到新程序。 let code = "/v1/namespaces/app.main/code"; // 先创建对象但不 render;返回值可以跨 REPL 请求复用,发布 revision 此时仍为 0。 let created = client.request("POST", repl, "title = ui.text(\"中文\")"); assert_eq!(created.0, 200, "{}", created.2); + // 将 HTTP 三元组中的正文解析为 API JSON,rendered/revision 与 value 各有独立断言。 let created: serde_json::Value = serde_json::from_str(&created.2).unwrap(); assert_eq!(created["rendered"], false); assert_eq!(created["revision"], 0); + // 第二次请求引用前一请求的对象,证明会话保留真实对象而非每次独立表达式求值。 let read: serde_json::Value = serde_json::from_str(&client.request("POST", repl, "title").2).unwrap(); assert_eq!(read["value"], created["value"]); @@ -253,6 +299,7 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() { client.request("POST", repl, "ui.page().render(title)").0, 200 ); + // render 后 GET 应识别为 transcript;reset 只清 VM 绑定,不应抹去此已发布正文。 let snapshot = client.request("GET", code, ""); assert!( snapshot @@ -280,34 +327,47 @@ fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() { /// 经真实 HTTP 完成指南读取、PUT/GET、失败候选、再次 PUT 和 DELETE。 /// 同时检查诊断位置、源文本/版本头、空文档、错误 namespace 与通知次数,确保仅成功发布改变版本。 fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); + // 原子计数观察真实文档发布通知,不把 HTTP Network 观察更新混入这个断言。 let notifications = Arc::new(AtomicUsize::new(0)); + // move 闭包持克隆 Arc,测试仍可通过原 Arc 在请求完成后读累计次数。 let counter = notifications.clone(); + // GUI 被计数器替代,实际 worker/发布路径保持不变;只有有效发布才应递增。 let notify: Notify = Arc::new(move || { counter.fetch_add(1, Ordering::SeqCst); }); + // 装入计数通知,下面 PUT/DELETE 的总 revision 与通知数必须保持一致。 let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // 一个固定合法 namespace 用于全部 CRUD,最后另测错误 namespace 不应创建新域。 let route = "/v1/namespaces/app.main/code"; + // 使用实际交付示例,成功 PUT 的 GET 原文应与它逐字相同。 let example = include_str!("../../../examples/page.endor"); + // 指南 GET 只能观察状态,不能产生文档 revision 或运行示例。 let (status, _, guide) = client.request("GET", "/agent", ""); assert_eq!(status, 200); assert!(guide.contains("let myui = ui.page()")); + // 首次成功 PUT 产生 revision 1,响应 JSON 的版本应对应共享状态。 let (status, _, body) = client.request("PUT", route, example); assert_eq!(status, 200, "{body}"); assert_eq!( serde_json::from_str::(&body).unwrap()["revision"], 1 ); + // GET 同时检查版本头与实际源正文,不只确认 HTTP 200。 let (status, headers, source) = client.request("GET", route, ""); assert_eq!(status, 200); assert!(headers.to_lowercase().contains("x-tessera-revision: 1")); assert_eq!(source, example); + // 未定义名字触发编译诊断,422 必须保留第 1 行来源且不覆盖前面成功源。 let (status, _, body) = client.request("PUT", route, "function main(): View { return missing }"); assert_eq!(status, 422, "{body}"); @@ -316,6 +376,7 @@ fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { 1 ); assert_eq!(client.request("GET", route, "").2, example); + // 最小可见文字改动生成第二个有效候选;失败候选没有占用 revision,故此时应为 2。 let second = example.replace("Hello Endor", "Second"); assert_eq!(client.request("PUT", route, &second).0, 200); assert_eq!(namespace.read().unwrap().revision, 2); @@ -337,6 +398,7 @@ fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() { /// 直接推进 worker 的 submission,模拟新候选已接受但尚未成功替换的时刻。 /// 旧 VM 的 retained 仍可读;clear 撤销 namespace 后再刷新,才释放 live owner。 fn queued_candidate_keeps_live_owner_until_delete_revokes_it() { + // 真实旧 VM 保留可读 retained 状态,新的 submission=2 模拟接受但尚未成功构建的 PUT。 let (runtime, document) = endor_host::ReplSession::load( "retained = ui.state(9)\npage = Page()\npage.render(ui.textOf(retained,\"value\"))", "app.main", @@ -344,16 +406,19 @@ fn queued_candidate_keeps_live_owner_until_delete_revokes_it() { 277, ) .unwrap(); + // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { document, submission: 2, runtime_session: runtime.id, ..NamespaceState::default() })); + // 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。 let mut worker = Worker { repl: Some((1, runtime)), next_session: 277, }; + // 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。 let notify: Notify = Arc::new(|| {}); worker.refresh_viewport(&namespace, ¬ify); assert_eq!(worker.repl.as_ref().unwrap().0, 2); @@ -369,6 +434,7 @@ fn queued_candidate_keeps_live_owner_until_delete_revokes_it() { "9" ); { + // 再次接受更高序号仍只更新旧 live owner 的 epoch,不能销毁仍有权威 session 的堆。 let mut current = namespace.write().unwrap(); current.submission = 3; worker.preserve_live_owner(¤t); @@ -387,20 +453,29 @@ fn http_delete_remains_available_and_invalidates_queued_put() { sync::{Mutex, mpsc}, time::Instant, }; + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // HTTP 与真实 worker 共用发布状态;断言读取此值,不从测试源代码猜执行结果。 let namespace = Arc::new(RwLock::new(NamespaceState::default())); + // 第一通道通知主测试“worker 已进入首次发布通知”,随后才可靠地安排第二 PUT。 let (entered_tx, entered_rx) = mpsc::channel(); + // 第二通道让作用域守卫无条件解除 worker 阻塞,避免测试 panic 后线程 join 永远挂起。 let (release_tx, release_rx) = mpsc::channel(); // 通知是共享闭包;用 Mutex 包装接收端使其满足线程共享约束,只阻塞第一次通知。 let release_rx = Mutex::new(release_rx); + // 只阻塞第一个通知,DELETE 自身的新通知必须仍能正常返回,不依赖固定 sleep 猜时序。 let calls = AtomicUsize::new(0); + // 此处故意制造受控阻塞;生产通知只投递 GUI 事件,没有这个测试通道等待。 let notify: Notify = Arc::new(move || { if calls.fetch_add(1, Ordering::SeqCst) == 0 { + // 信号先通知测试再等待释放,保证下面读 GET/DELETE 时 worker 确在有界暂停点。 entered_tx.send(()).unwrap(); release_rx.lock().unwrap().recv().unwrap(); } }); + // 用真实 HTTP 接收线程证明 VM worker 被阻塞时,GET/DELETE 仍不会被同一队列堵住。 let api = start_server(server.clone(), namespace.clone(), notify).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, @@ -412,19 +487,28 @@ fn http_delete_remains_available_and_invalidates_queued_put() { impl Drop for Release { // send 失败意味着接收端已结束,此时无需再阻塞测试清理。 fn drop(&mut self) { + // 最佳努力释放:接收端已停止时发送失败无需让析构再 panic。 let _ = self.0.send(()); } } thread::scope(|scope| { + // guard 在作用域返回或断言展开时都释放,随后 scoped 线程才能可靠 join。 let release = Release(release_tx); + // 两个请求线程只借同一客户端地址/服务生命周期,request 每次自己建立 TCP 连接。 let client = &client; + // 全部操作指同一资源,DELETE 的 epoch 变化必须使排队第二个 PUT 失效。 let route = "/v1/namespaces/app.main/code"; + // 首尾都用同一有效源码,第二 PUT 的 409 不能归因于源码编译失败。 let example = include_str!("../../../examples/page.endor"); + // 首次发布将触发通知阻塞;在接到 entered 之前不推断它已经执行到提交点。 let first = scope.spawn(move || client.request("PUT", route, example)); entered_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + // 第二 PUT 由 HTTP 接收线程接受后在 VM 队列等待,source 与第一条完全相同。 let pending = scope.spawn(move || client.request("PUT", route, example)); + // 明确截止时间限制接受序号观察,不把后台线程未调度当作已经过期或成功。 let deadline = Instant::now() + Duration::from_secs(10); while namespace.read().unwrap().submission < 2 { + // 轮询真实 accepted submission,确认第二请求已入队后才发 DELETE,消除盲目等待竞态。 assert!(Instant::now() < deadline, "second PUT was not admitted"); thread::sleep(Duration::from_millis(5)); } @@ -453,6 +537,7 @@ fn extracts_namespace_only_from_code_routes() { #[test] /// 对生成的指南做契约回归:保留代码 CRUD、可运行示例和当前地址,移除旧 export scene/display_list 暴露。 fn guide_contains_only_code_crud_routes() { + // 只生成指南文本,不启动服务或执行内嵌示例;检查静态对外接口的关键用语。 let guide = agent_guide(&NamespaceState::default()); assert!(guide.contains("Code CRUD")); assert!(guide.contains("let myui = ui.page()")); @@ -465,6 +550,7 @@ fn guide_contains_only_code_crud_routes() { /// 创建已经就绪的定时任务,先按前台模式刷新再读取计数,确保输入前不抢跑回调。 /// 随后允许空闲刷新并推进待完成工作,计数应从 0 变为 1。 fn foreground_refresh_does_not_deliver_new_callbacks_ahead_of_input() { + // ready 定时器会把共享 count 从 0 递增,前台模式不应提前消费它,因此提供明确可观察顺序。 let source = r#" class Action { count: Object @@ -476,20 +562,25 @@ ui.after(ui,"ready",0,action.fire) page=Page() page.render(TextBlock("input priority")) "#; + // 同步加载初始画面但不轮询就绪任务,下面才能单独比较前台/空闲两种 worker 行为。 let (runtime, document) = endor_host::ReplSession::load(source, "app.main", 1, 801).unwrap(); + // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { document, submission: 1, runtime_session: 801, ..NamespaceState::default() })); + // 直接设置该例活 VM 与 epoch,以便精确控制尚未替换/已失活等 HTTP 难以定时捕获的边界。 let mut worker = Worker { repl: Some((1, runtime)), next_session: 801, }; + // 无 GUI 的同步状态检查仍走真实 worker;通知不做额外阻塞或改写待测文档。 let notify: Notify = Arc::new(|| {}); // 前台模式仅处理输入前必须收敛的刷新,不引入新的任务交付。 worker.refresh_viewport_mode(&namespace, ¬ify, true); + // 从同一真实 globals 读取 count,若前台边界抢跑 callback 这里会错误地变成 1。 let value = worker .repl .as_mut() @@ -500,8 +591,10 @@ page.render(TextBlock("input priority")) .value; assert_eq!(value, "0"); worker.refresh_viewport(&namespace, ¬ify); + // 空闲轮允许启动任务,若其刷新挂起,只继续视觉工作而不重复执行回调。 let runtime = &mut worker.repl.as_mut().unwrap().1; for _ in 0..64 { + // 有界排空实际 PendingRefresh,退出条件来自运行时状态而非固定猜测需要几帧。 if !runtime.has_pending_refresh() { break; } @@ -509,6 +602,7 @@ page.render(TextBlock("input priority")) .resize_windows_incremental(&std::collections::BTreeMap::new(), false) .unwrap(); } + // 完成空闲推进后 count 应恰为 1,既验证有交付,也排除挂起恢复重复调用。 let value = runtime.execute("count.value", "app.main").unwrap().value; assert_eq!(value, "1"); } @@ -517,7 +611,9 @@ page.render(TextBlock("input priority")) /// 在负世界坐标放置按钮,通过 HTTP 平移/锚定缩放,再发送世界坐标 click。 /// 检查相机数值、旧帧重放 409、按钮计数,以及零倍率 400 不导致 VM fault。 fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 600.0, @@ -525,12 +621,15 @@ fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() { }), ..NamespaceState::default() })); + // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // 按钮放在负世界坐标,主 Page 采用兼容 canvas 模式,事件必须经相机映射而不能只接非负外框位置。 let source = r#" count=ui.state(0) button=Button("world") @@ -541,9 +640,12 @@ page=Page() page.mode="canvas" page.render(button) "#; + // 加载真实画布,下面所有版本/会话由成功发布帧派生,不为测试手写假几何。 let response = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(response.0, 200, "{}", response.2); + // 每次生成时读取新版本;只有显式保存的 pan 原文用于随后重放过期请求。 let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { + // Page 级相机要求 mount=0,不能把内容按钮的 Mount ID 当作整页导航目标。 let frame = &namespace.read().unwrap().document.frame; serde_json::to_string(&crate::events::Input { window: 1, @@ -559,8 +661,11 @@ page.render(button) }) .unwrap() }; + // 真正 HTTP /events 不享受原生版本追随,因此旧正文重发应该是 409。 let event_path = "/v1/namespaces/app.main/events"; + // 负 pan 把负世界位置移入视野,增量不能被当作非负窗口尺寸拒绝。 let pan = camera("page-pan", [-240.0, -160.0], None); + // 第一次合法平移改变实际 Surface;再发同一正文则验证严格版本边界。 let response = client.request("POST", event_path, &pan); assert_eq!(response.0, 200, "{}", response.2); assert_eq!( @@ -568,6 +673,7 @@ page.render(button) crate::geometry::Point::new(-240.0, -160.0) ); assert_eq!(client.request("POST", event_path, &pan).0, 409); + // 倍率 2 和非原点锚点共同决定新 pan,后面的精确期望检查保持锚点世界位置的公式。 let response = client.request( "POST", event_path, @@ -575,6 +681,8 @@ page.render(button) ); assert_eq!(response.0, 200, "{}", response.2); { + // 一次发布读锁内同时断言 zoom 和 pan,不能混读缩放前后不同版本。 + // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); assert_eq!(current.document.frame.surface.zoom, 2.0); assert_eq!( @@ -582,9 +690,13 @@ page.render(button) crate::geometry::Point::new(-180.0, -115.0) ); } + // click 使用实际交互出现和世界负坐标,验证缩放后目标解析仍保留负坐标语义。 let input = { + // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); + // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 let frame = ¤t.document.frame; + // 找真正按钮出现而非 Page 根,确认布局几何本身保留负世界位置。 let mount = frame.mounts.iter().find(|m| m.interactive).unwrap(); assert!(mount.x < 0.0 && mount.y < 0.0); crate::events::Input { @@ -600,14 +712,17 @@ page.render(button) tab_drop: None, } }; + // 成对按下/释放同点走 source_hit 与真实 click 回调,不用直接调用 count.increment 代替命中。 let response = client.request("POST", event_path, &serde_json::to_string(&input).unwrap()); assert_eq!(response.0, 200, "{}", response.2); + // 持久 Console 读取同一个按钮状态,证明 HTTP 事件进入当前 VM 且恰好执行一次。 let response = client.request("POST", "/v1/namespaces/app.main/repl", "count.value"); assert_eq!(response.0, 200, "{}", response.2); assert_eq!( serde_json::from_str::(&response.2).unwrap()["value"], "1" ); + // 零倍率是结构/参数错误,应返回 400 且不污染 frame.fault 或使健康 VM 停止。 let response = client.request( "POST", event_path, @@ -621,7 +736,9 @@ page.render(button) /// 给局部 CanvasViewport 添加父内边距和自身左边框,检验页面坐标到内容相机锚点的转换。 /// 成功平移/缩放后核对相机状态;边框、边界与视口外锚点均须返回 400,保留健康运行时。 fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faulting() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 600.0, @@ -629,12 +746,15 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin }), ..NamespaceState::default() })); + // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // 父 Column 的 20 内边距和视口左边框 10 让内容原点不同于外框,暴露漏减 inset 的锚点错误。 let source = r#" content = Button("inside") content.x = 40 @@ -645,10 +765,14 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin page = Page() page.render(Column(20, 0, view)) "#; + // 正常发布一次基线,所有接下来手势仍指同一显式 Camera2D 对象。 let response = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(response.0, 200, "{}", response.2); + // 按最新权威帧查 CanvasViewport 具体出现,不能用主页面 mount=0 发局部相机输入。 let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { + // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); + // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 let frame = ¤t.document.frame; serde_json::to_string(&crate::events::Input { window: 1, @@ -669,11 +793,15 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin }) .unwrap() }; + // 保留同一事件地址,平移/缩放行为只由输入 kind 和目标能力决定。 let path = "/v1/namespaces/app.main/events"; + // 普通二维线性平移可无 point,仍必须有真实视口 Mount 和有限 delta。 let pan = camera("viewport-pan", [20.0, -10.0], None); + // 首次 pan 应成功,旧正文重发则过期;源模型相机状态后面经 REPL 验证。 let response = client.request("POST", path, &pan); assert_eq!(response.0, 200, "{}", response.2); assert_eq!(client.request("POST", path, &pan).0, 409); + // 局部缩放锚点先撤父偏移/边框再计算,不能直接用屏幕 80/60 作为内容相对坐标。 let response = client.request( "POST", path, @@ -685,6 +813,7 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin ("view.camera.y", "10"), ("view.camera.zoom", "2"), ] { + // 三个源属性分别比对,外观近似正确不能掩盖 x/y/zoom 中某个维度的变换错误。 let response = client.request("POST", "/v1/namespaces/app.main/repl", expression); assert_eq!(response.0, 200, "{}", response.2); assert_eq!( @@ -694,6 +823,7 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin } // 分别覆盖左边框、右侧边界及远离视口的锚点,避免只校验整个窗口范围。 for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] { + // 重新生成当前版本以保证失败来自锚点边界,而不是误用旧版本得到 409。 let response = client.request( "POST", path, @@ -708,7 +838,9 @@ fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faultin /// 通过真实 HTTP 检查透视缩放/orbit、正交缩放,再替换为源实现 PanOnly。 /// 比较相机数值与自定义控制器结果,并验证视口外锚点和禁用控制器的拒绝状态不会停掉 VM。 fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 400., @@ -716,12 +848,15 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { }), ..NamespaceState::default() })); + // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // 提前定义可替换 PanOnly 源控制器,初始仍使用默认 OrbitController 和透视相机作基线。 let source = r##" class PanOnly { function update(self: PanOnly, camera: Object, gesture: CameraGesture): Unit { camera.translate(gesture.dx, gesture.dy, 0) } @@ -730,10 +865,14 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { page = Page() page.render(view) "##; + // 通过实际语言构造 Scene3D/Viewport3D,不在测试宿主侧直接拼帧伪造相机能力。 let response = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(response.0, 200, "{}", response.2); + // 每次按最新帧找到 scene3d_viewport,来源能力和几何版本都交真实 HTTP 路由校验。 let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { + // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); + // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 let frame = ¤t.document.frame; serde_json::to_string(&crate::events::Input { window: 1, @@ -754,12 +893,17 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { }) .unwrap() }; + // 同一事件路由支持不同源/原生控制器,不能为 PanOnly 建一条测试专用快捷调用。 let event_path = "/v1/namespaces/app.main/events"; + // 视口内部锚点与倍率 2 应让默认透视相机距离减半,原正文重发仍必须 409。 let zoom = gesture("scene-zoom", [2., 0.], Some([100., 50.])); + // 保存第一次请求结果并打印正文诊断,便于区分参数错误和运行时调用失败。 let response = client.request("POST", event_path, &zoom); assert_eq!(response.0, 200, "{}", response.2); assert_eq!(client.request("POST", event_path, &zoom).0, 409); + // 经同一持久 Console 查询/修改真实相机,不从 Frame 近似反解相机模型值。 let value = |expression: &str| { + // helper 仍跨 HTTP,确保候选保留的 globals 与事件真正执行域是同一份。 let response = client.request("POST", "/v1/namespaces/app.main/repl", expression); assert_eq!(response.0, 200, "{}", response.2); serde_json::from_str::(&response.2).unwrap()["value"] @@ -768,11 +912,14 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { .to_owned() }; assert_eq!(value("view.camera.z"), "2.5"); + // 默认 orbit 将横向增量解释为角度改变,检查 x 分量符合圆轨道而不是简单加 20。 let response = client.request("POST", event_path, &gesture("scene-orbit", [20., 0.], None)); assert_eq!(response.0, 200, "{}", response.2); + // 数字显示字符串转 f32 只用于三角近似容差,不放宽语言模型本身的类型契约。 let x = value("view.camera.x").parse::().unwrap(); assert!((x - 2.5 * 0.1f32.sin()).abs() < 0.0001); value("view.camera = OrthographicCamera()"); + // 换成正交相机后缩放应改 height,不能仍按透视距离规则改 z。 let response = client.request( "POST", event_path, @@ -781,9 +928,11 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { assert_eq!(response.0, 200, "{}", response.2); assert_eq!(value("view.camera.height"), "2.5"); value("view.controller = PanOnly()"); + // 源控制器收到同一个 CameraGesture,行为应是精确平移 7,证明宿主没强行套默认 Orbit 规则。 let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None)); assert_eq!(response.0, 200, "{}", response.2); assert_eq!(value("view.camera.x"), "7"); + // 视口外锚点在进入控制器前返回 400;既不执行源更新,也不把 VM 标为 fault。 let response = client.request( "POST", event_path, @@ -792,6 +941,7 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { assert_eq!(response.0, 400); assert!(namespace.read().unwrap().document.frame.fault.is_none()); value("view.controller = \"none\""); + // 禁用控制器后原有 3D 外框仍存在,但手势能力失效,必须拒绝而不是自行补默认控制器。 let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None)); assert_eq!(response.0, 409); assert!(namespace.read().unwrap().document.frame.fault.is_none()); @@ -801,7 +951,9 @@ fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() { /// 在旋转的 Panel3D 上嵌入二维 CanvasViewport,经射线/UV 映射解释相机事件。 /// 缺失指针必须拒绝;带指针的平移/缩放只改变面板内相机,外层三维相机保持原值。 fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 400., @@ -809,12 +961,15 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { }), ..NamespaceState::default() })); + // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // 倾斜 Panel3D 内放 CanvasViewport,使屏幕水平位移必须经射线/UV 非线性映射再解释成二维相机 pan。 let source = r##" canvas=CanvasViewport(100,100,Scene2D(Button("UI in a world"))) panel=Panel3D(2,2,canvas) @@ -825,10 +980,14 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { page=Page() page.render(view) "##; + // 初次 PUT 产生真实三维面板投影和嵌套二维 Mount,下面不硬编码其内部 ID。 let response = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(response.0, 200, "{}", response.2); + // 专门选择 canvas_viewport 且 panel 非空的出现,避免误把外层三维相机当成目标。 let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| { + // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); + // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 let frame = ¤t.document.frame; serde_json::to_string(&crate::events::Input { window: 1, @@ -849,6 +1008,7 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { }) .unwrap() }; + // 世界 UI 手势没有锚点不可可靠逆投影;第一条断言应 400 而不是执行近似屏幕平移。 let path = "/v1/namespaces/app.main/events"; assert_eq!( client @@ -856,13 +1016,16 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { .0, 400 ); + // 给定屏幕中心锚点后,同一增量才有可计算的面板局部对应位移。 let response = client.request( "POST", path, &gesture("viewport-pan", [10., 0.], Some([100., 100.])), ); assert_eq!(response.0, 200, "{}", response.2); + // 查询真实模型数值,不只比视觉快照;返回 f32 供投影近似容差断言。 let value = |expression: &str| { + // 每次表达式仍走持久 Console,若被误路由到新 VM 会因 canvas/view 不存在失败。 let response = client.request("POST", "/v1/namespaces/app.main/repl", expression); assert_eq!(response.0, 200, "{}", response.2); serde_json::from_str::(&response.2).unwrap()["value"] @@ -871,10 +1034,12 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { .parse::() .unwrap() }; + // 倾斜投影的有效 x 大于屏幕增量 10,外层 view.camera.x 应保持零,证明目标空间正确。 let pan = value("canvas.camera.x"); // 面板倾斜后的投影位移不等于 UI 内部位移,不能把屏幕 delta 直接写入局部相机。 assert!(pan > 10.); assert_eq!(value("view.camera.x"), 0.); + // 再以同一屏幕锚点缩放,验证前一步平移和当前 UV 锚定公式可以连续组合。 let response = client.request( "POST", path, @@ -887,8 +1052,11 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() { } #[test] +/// 测试框架通过真实 HTTP 发送原始指针,验证局部坐标、按下/离开/取消状态,以及严格版本和结构拒绝不故障。 fn native_pointer_kinds_cross_real_http_route_with_local_coordinates_and_strict_versions() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 500., @@ -896,12 +1064,15 @@ fn native_pointer_kinds_cross_real_http_route_with_local_coordinates_and_strict_ }), ..Default::default() })); + // 启动真实接收线程和串行 VM worker,空通知只省略 GUI,不替换实际路由或执行。 let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // Tracker 只记录事件坐标和 held,目标放在有偏移的 Column 下,暴露漏减出现原点的坐标错误。 let source = r#" class Tracker { x:Number @@ -921,11 +1092,16 @@ e=target.on("pointercancel",tracker.up) page=Page() page.render(Column(20,0,target)) "#; + // 安装原始 pointer 订阅并发布真实帧,不用测试直接赋值 tracker 字段模拟处理器。 let put = client.request("PUT", "/v1/namespaces/app.main/code", source); assert_eq!(put.0, 200, "{}", put.2); + // 除刻意重放 body 外,每次重新读版本,确保状态断言对应当前有效帧上的事件。 let input = |kind: &str| { + // 同一读锁拍下窗口/session/版本和目标,避免构造信封时混用两个刷新时刻的字段。 let current = namespace.read().unwrap(); + // 使用实际发布主帧,不引用 VM 内未稳定草稿;下方输入身份和坐标从同一帧选取。 let frame = ¤t.document.frame; + // 明确选择有原始指针能力的目标,绝对坐标为该出现原点加固定局部 35/27。 let mount = frame.mounts.iter().find(|m| m.pointer_events).unwrap(); crate::events::Input { window: 1, @@ -940,11 +1116,12 @@ page.render(Column(20,0,target)) screen_position: None, } }; + // 保存最初正文作后续过期重放,不能重建最新版本后宣称在验证旧事件拒绝。 let body = serde_json::to_string(&input("pointermove")).unwrap(); + // 第一遍合法移动应把局部坐标传给源 Tracker;它本身不一定触发视觉树变化。 let response = client.request("POST", "/v1/namespaces/app.main/events", &body); assert_eq!(response.0, 200, "{}", response.2); - // Tracker state alone need not publish a new picture. Expire the frame - // through a real visual mutation before asserting strict stale rejection. + // Tracker 业务状态未被画面读取,不保证版本变化;显式改 target.width 产生真实新几何再重放旧正文。 assert_eq!( client .request("POST", "/v1/namespaces/app.main/repl", "target.width=201") @@ -958,6 +1135,7 @@ page.render(Column(20,0,target)) 409 ); for (expr, expected) in [("tracker.x", "35"), ("tracker.y", "27")] { + // 逐轴验证源处理器收到的是 Mount 局部 35/27,而非 Column 偏移后的页面绝对坐标。 let r = client.request("POST", "/v1/namespaces/app.main/repl", expr); assert_eq!(r.0, 200); assert_eq!( @@ -973,18 +1151,21 @@ page.render(Column(20,0,target)) ("pointerdown", "1"), ("pointercancel", "0"), ] { + // 每组都以真实事件改变 held,leave/cancel 必须与 up 一样结束按住状态,而非继续保留 1。 let response = client.request( "POST", "/v1/namespaces/app.main/events", &serde_json::to_string(&input(kind)).unwrap(), ); assert_eq!(response.0, 200, "{}", response.2); + // 通过 Console 读同一对象最终状态,HTTP 200 本身不能证明处理器真的执行正确。 let r = client.request("POST", "/v1/namespaces/app.main/repl", "tracker.held"); assert_eq!( serde_json::from_str::(&r.2).unwrap()["value"], expected ); } + // 只删必填 point,保持当前合法 session/版本,使失败原因明确是结构错误 400 而非过期 409。 let mut invalid = input("pointermove"); invalid.point = None; assert_eq!( @@ -1001,8 +1182,11 @@ page.render(Column(20,0,target)) } #[test] +/// 测试框架直接向原生队列送可信信封,验证动画新帧可追随,但 HTTP 旧帧和旧 VM 身份仍不能执行。 fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() { + // 使用系统分配的回环端口,测试服务与应用固定 4737 端口/其他并行测试互不抢占。 let server = Arc::new(Server::http("127.0.0.1:0").unwrap()); + // 明确设置本例所需发布/视口身份,Arc/RwLock 与真实线程使用同一份权威状态。 let namespace = Arc::new(RwLock::new(NamespaceState { viewport: Some(crate::layout::Size { width: 500., @@ -1010,7 +1194,9 @@ fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() { }), ..Default::default() })); + // 装入实际共享 BuildRequest sender,来源信任来自 Rust 枚举构造,不通过伪造 HTTP 头获得。 let native = Arc::new(OnceLock::new()); + // 复用真实 worker/路由及回执处理,只不安装 GUI 退出句柄和观察通知。 let api = start_server_observed( server.clone(), namespace.clone(), @@ -1021,11 +1207,13 @@ fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() { Some(native.clone()), ) .unwrap(); + // 保存监听地址与 JoinHandle;本例结束时 Drop 会解除接收阻塞并回收 HTTP 线程。 let client = TestServer { address: server.server_addr().to_ip().unwrap(), server, api: Some(api), }; + // 模型保留 pointermove/down/up 三种订阅,后面视觉宽度变化只使版本更新、不替换原目标。 let source = r#" class Tracker { x:Number @@ -1048,9 +1236,13 @@ page.render(Column(20,0,target)) .0, 200 ); + // 捕获一次真实帧,之后所有原生手势故意沿用这个旧版本,以覆盖 worker 中的第二次版本追随。 let old = { + // 同锁读取完整捕获上下文,避免把新 layout_version 与旧几何坐标组合成假信封。 let state = namespace.read().unwrap(); + // f 是已发布主帧,不是后台刷新草稿。 let f = &state.document.frame; + // m 的身份在同一个 VM 中保持稳定,改变宽度不应让旧捕获手势换目标。 let m = f.mounts.iter().find(|m| m.pointer_events).unwrap(); crate::events::Input { window: 1, @@ -1065,11 +1257,16 @@ page.render(Column(20,0,target)) screen_position: None, } }; + // 模拟 relay 构造真正 Native 回执工作,等待有界确认;409 表示被安全拒绝/消费,也可得到 Ok 回执。 let send_native = |input: crate::events::Input| { + // 只采样最新接受 epoch/name,不改输入自己捕获的 session/Mount,后者仍须由 worker 校验。 let state = namespace.read().unwrap(); + // epoch 属于队列接受身份,和 input.session 代表的原 VM 代际分别保留。 let epoch = state.submission; + // Clone 使发队列/等待时无需持 namespace 读锁,否则 worker 发布会被测试自己阻塞。 let name = state.name.clone(); drop(state); + // 每条输入一个回执,最多等 5 秒,确认 worker 已处理后再读取 Tracker 状态。 let (done, complete) = sync_channel(1); native .get() @@ -1088,12 +1285,12 @@ page.render(Column(20,0,target)) .unwrap() .unwrap(); }; - // Every native gesture deliberately carries the old picture version; each - // intervening visual mutation simulates an animation publishing a frame. + // 每条原生事件前先做一次真实视觉变更模拟动画新帧,仍沿用 old 的布局版本验证可追随路径。 for (i, kind) in ["pointermove", "pointerdown", "pointerup"] .into_iter() .enumerate() { + // 宽度每轮不同以确保发布新几何;只写相同值可能不更新版本,不能可靠覆盖追随逻辑。 assert_eq!( client .request( @@ -1104,11 +1301,13 @@ page.render(Column(20,0,target)) .0, 200 ); + // 只改 kind 不改捕获上下文,若 worker 没有在 BeforeInput 后再刷新版本就会拒绝这些合法原生动作。 let mut sample = old.clone(); sample.kind = kind.into(); send_native(sample); } for (code, value) in [("tracker.x", "37"), ("tracker.held", "0")] { + // 先确认跨多帧原生输入实际执行并保持 down/up 顺序,不把单纯回执成功当作处理器成功。 let r = client.request("POST", "/v1/namespaces/app.main/repl", code); assert_eq!( serde_json::from_str::(&r.2).unwrap()["value"], @@ -1131,9 +1330,11 @@ page.render(Column(20,0,target)) .0, 200 ); + // 完整 PUT 之后即使数字 Mount 恰好相同也不能追随旧 session,确认 stale down 不会把新 held 变 1。 let mut stale = old; stale.kind = "pointerdown".into(); send_native(stale); + // Native 回执可能把安全拒绝映为 Ok,最终必须用新 VM 模型值为零证明旧代事件未执行。 let r = client.request("POST", "/v1/namespaces/app.main/repl", "tracker.held"); assert_eq!( serde_json::from_str::(&r.2).unwrap()["value"], diff --git a/src/runtime/api/worker.rs b/src/runtime/api/worker.rs index 3260ea1..de404b0 100644 --- a/src/runtime/api/worker.rs +++ b/src/runtime/api/worker.rs @@ -2,14 +2,18 @@ //! 输入、视口刷新与候选布局可以跨 worker 轮次挂起;中途只公布完整文档,不能让新输入进入未结束的只读调用。 //! 事件身份同时校验窗口、session、layout_version 和 Mount 能力。失败、替换与关闭分别处理,避免旧回调或旧命令泄漏到新 VM。 //! -//! Serial VM ownership, incremental requests, publication and shutdown. +//! api::start_server_observed 的唯一执行线程创建 Worker,每次循环调用 begin_request/resume_request 或 refresh_viewport。 +//! HTTP/原生请求仍由外层持有,PendingRequest 只记录继续哪一步;本文件不重新读取请求流或回放已执行输入。 +//! NamespaceState 是对外权威值,repl 是实际 VM 所有者;submission 是接受顺序,runtime_session 才表示画面仍属哪个 VM。 use super::*; +// 响应已编码成内存字节,外层 RequestReply 可发 HTTP 或转换成原生回执,不需要读取 VM 返回值。 type HttpResponse = Response>>; // 外层 thread::Result 区分 Rust panic,内层 Result 区分可诊断编译/运行错误;正常结果另带待发布窗口命令。 type InputOutcome = std::thread::Result)>>; +// 候选成功必须成套交回持久 VM、完整文档及命令;不能只返回快照后丢掉事件需要的真实运行时。 type CandidateOutcome = std::thread::Result< Result<( endor_host::ReplSession, @@ -22,8 +26,11 @@ type CandidateOutcome = std::thread::Result< pub(super) struct PendingInput { // 输入开始时所属 submission,与 id(VM 会话)分开;新 PUT 接受时可保留正在执行的原 VM 至输入收敛。 epoch: u64, + // 实际运行时代际,和 epoch 一起核验;接受新 PUT 不代表已经切换到新 VM。 id: u64, + // 决定响应 execution 和错误停域规则,恢复展示时不再重新猜原始输入类别。 event: bool, + // 已经执行得到的值/统计,后续只补 document,不重复 callback 或 execute 取相同结果。 result: endor_host::ReplResult, } @@ -31,20 +38,25 @@ pub(super) struct PendingInput { pub(super) enum PendingRequest { // 仅等待旧只读刷新结束,业务输入尚未执行;恢复时可重新走入口,而 Input 阶段不能重放业务副作用。 BeforeInput, + // 输入业务部分已执行,保留活 repl 并只恢复该输入的视觉刷新。 Input(Box), + // 新 PUT 的隔离候选还未发布,活 repl 仍保留旧程序,不能提前替换。 Candidate(Box), } /// 一次 worker 推进的结果:继续保留请求或交付最终响应。 pub(super) enum RequestProgress { + // 外层将原 job 和继续状态一起保存,下一量子进入 resume_request。 Pending(PendingRequest), + // 最终响应值,外层消费原 RequestReply 一次性回应,不再继续这个请求。 Complete(HttpResponse), } -/// 只提取仍属于当前 session 的窗口尺寸,避免旧原生窗口给新候选提供错误约束。 +/// 空闲刷新/输入/恢复路径在发布状态读锁内调用,筛出当前运行时合法尺寸,不执行 VM 或修改窗口表。 fn current_viewports( current: &NamespaceState, ) -> std::collections::BTreeMap { + // 主壳可跨 VM 使用;副窗必须仍在当前 document 且属于 runtime_session,旧数字 ID 本身不算归属。 let mut viewports: std::collections::BTreeMap<_, _> = current .window_viewports .iter() @@ -69,12 +81,15 @@ fn current_viewports( #[derive(Default)] /// 唯一活跃 VM 的串行拥有者,另行管理候选和下一 session 身份。 pub(super) struct Worker { + // 元组第一项是当前接受提交 epoch,第二项是真实 VM;新 PUT 接受时旧 VM 仍可能保持权威。 pub(super) repl: Option<(u64, endor_host::ReplSession)>, + // 单调 session 分配高水位,即使候选失败也不复用已经分配的代际身份。 pub(super) next_session: u64, } impl Worker { - /// 撤销活跃运行时并有界清理窗口、Mount 和任务;故障域不再执行新的业务回调。 + /// api 线程循环收到退出信号时调用,移出活运行时并生成纯文本 ShutdownOutcome,供原生退出报告。 pub(super) fn shutdown_runtime(&mut self) -> ShutdownOutcome { + // take 先撤 worker 的活域拥有关系,没有域时仍返回明确成功摘要而非伪造清理数量。 let Some((_, mut runtime)) = self.repl.take() else { return ShutdownOutcome { summary: "Tessera shutdown: no live runtime".into(), @@ -83,6 +98,7 @@ impl Worker { }; // 正常域允许有界关闭回调,故障域只做宿主清理;catch_unwind 防止清理 panic 跳过原生退出通知。 let callbacks = !runtime.is_faulted(); + // 普通清理错误与 Rust panic 都转换成结果,不能让执行线程提前消失而使 GUI 永远等不到完成通知。 let result = catch_unwind(AssertUnwindSafe(|| runtime.shutdown())); match result { Ok(Ok((windows, mounts, tasks))) => ShutdownOutcome { @@ -92,10 +108,12 @@ impl Worker { error: None, }, result => { + // 内部 panic 没有可安全继续执行的源错误对象,单独说明;正常 Err 则保留错误链。 let error = match result { Ok(Err(error)) => format!("shutdown cleanup failed: {error:#}"), _ => "shutdown cleanup failed after an internal panic".into(), }; + // 按 Unicode 字符限长,不切坏 UTF-8,也不把无限错误链塞入退出消息。 let error: String = error.chars().take(4096).collect(); ShutdownOutcome { summary: error.clone(), @@ -105,7 +123,7 @@ impl Worker { } } - /// 处理当前无法继续正常交互的窗口关闭,撤销原生资源与相应运行时记录。 + /// begin_request 的故障/失活副窗 window-close 分支调用,做纯宿主退役,不运行任何源关闭/生命周期回调。 pub(super) fn close_inactive_window( &mut self, input: &crate::events::Input, @@ -120,11 +138,14 @@ impl Worker { "close the primary shell with its OS window control", ); } + // 只有 epoch/session 都匹配才清该 VM 内部资源;相同窗口 ID 不授权操作另一个会话。 let owns_runtime = self.repl.as_ref().is_some_and(|(owner, runtime)| { *owner == epoch && runtime.id == input.session && input.session != 0 }); + // 退役内部失败时整个域被丢弃,后面需同时撤其他帧的会话/命令资格。 let mut discarded = false; if owns_runtime { + // 接住退役 panic,保证后续仍能从共享文档删除原生副窗投影,而不保留半清理 VM。 let result = catch_unwind(AssertUnwindSafe(|| { self.repl .as_mut() @@ -134,17 +155,19 @@ impl Worker { })); // 退役失败不能继续借用半清理 VM,放弃整个活域并在下方清其他帧的会话与命令资格。 if !matches!(result, Ok(Ok(()))) { - // Never keep a half-cleaned VM live or borrow another generation. + // 不借用其他代际补清理;放弃整个 repl 后下方只操作最近发布的纯数据文档。 self.repl = None; discarded = true; } } + // VM 清理在锁外执行完才申请发布写锁,重新验证期间是否有 PUT/DELETE 接受。 let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock poisoned"); }; if current.submission != epoch { return superseded(epoch); } + // 只查副窗投影,原窗口已消失就返回冲突,不为关闭旧事件重建一个资源。 let Some(view) = current.document.windows.get(&input.window) else { return error_response(StatusCode(409), "window is no longer active"); }; @@ -155,9 +178,11 @@ impl Worker { "window belongs to another runtime generation", ); } + // 在完整已发布文档副本上删目标,保留其他窗口画面;commit 再验证整份跨线程值。 let mut document = current.document.clone(); document.windows.remove(&input.window); if discarded { + // 保留静态画面但所有交互身份失效,不能让旧按钮继续指向已丢弃的托管堆。 document.frame.session = 0; document.frame.focus = None; for view in document.windows.values_mut() { @@ -181,7 +206,7 @@ impl Worker { ) } - /// 在没有前台输入的 worker 轮次推进尺寸和任务引起的展示刷新。 + /// api 的空闲 worker 轮调用,允许推进尺寸和任务回调;有前台输入等待时使用 before_input=true 的变体。 pub(super) fn refresh_viewport( &mut self, namespace: &Arc>, @@ -190,7 +215,7 @@ impl Worker { self.refresh_viewport_mode(namespace, notify, false); } - /// 在输入之间推进视口/任务刷新;提交序号或 session 已变时拒绝旧结果发布。 + /// 空闲循环与 begin_request 共用推进器,锁外执行 VM、写锁内核对 epoch/session 后再发布,防止晚到覆盖。 pub(super) fn refresh_viewport_mode( &mut self, namespace: &Arc>, @@ -200,11 +225,13 @@ impl Worker { // 在读锁内取得一个一致的提交身份和尺寸快照;耗时 VM 工作不占用共享状态写锁。 let (epoch, viewports) = match namespace.read() { Ok(current) => { + // 同一读锁下复制身份和合法尺寸,避免使用来自不同发布世代的两个快照。 let viewports = current_viewports(¤t); (current.submission, viewports) } Err(_) => return, }; + // 无持久运行时就没有任务/尺寸可推进;GET 的旧静态文档本身不创造 VM。 let Some((owner, runtime)) = &mut self.repl else { return; }; @@ -215,28 +242,31 @@ impl Worker { } return; } + // 保存执行前真实 VM 身份,发布时重新检查,不能只核对新的 submission。 let id = runtime.id; + // 耗时刷新不持 namespace 锁,HTTP 可接受新请求;普通错误/panic 留到写锁阶段区分处理。 let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> { if runtime.is_faulted() { + // 故障域 poll_tasks 仅取消/回收旧任务,不再启动新的用户回调。 runtime.poll_tasks()?; return Ok((None, Vec::new())); } + // 先推进已有视觉工作/尺寸,再考虑新任务,不能在只读续体里插入业务写入。 let resized = runtime.resize_windows_incremental(&viewports, false)?; if runtime.has_pending_refresh() { return Ok((None, Vec::new())); } - // An accepted foreground input waits only for the existing - // refresh; do not keep placing new callbacks ahead of it. // 已有前台请求只等当前刷新,不不断把新任务回调插到它前面,避免忙后台工作饿死用户输入。 let completed = if before_input { + // 前台输入的优先等待边界,不再让不断就绪的后台任务排在用户输入前。 None } else { + // 真正空闲才开启一个任务推进,任务引起的展示仍可挂起等待后续量子。 runtime.poll_tasks_incremental(&viewports)? }; // 任务后续刷新未收敛时可以发布较早已完整的 resize 文档,但暂扣任务产生的命令,防止半完成副作用外泄。 if runtime.has_pending_refresh() { - // A complete earlier resize may publish; task effects and their - // native commands wait until the task's refresh has settled. + // resized 已完整可以返回,任务新产生窗口命令仍扣留至其完整刷新结束。 return Ok((resized, Vec::new())); } Ok((completed.or(resized), runtime.take_window_commands()?)) @@ -250,6 +280,7 @@ impl Worker { if current.submission != epoch || current.runtime_session != id { return; } + // success 可仅有命令而无新帧;错误发布故障条,panic 还要丢弃 VM 与命令世代。 let (document, commands) = match result { Ok(Ok((document, commands))) => { if let Some(document) = document { @@ -266,6 +297,7 @@ impl Worker { } } failure => { + // 故障画面基于上次完整发布文档,不把此次失败的半成品几何泄漏出去。 let mut document = current.document.clone(); document.frame.fault = Some(match failure { Ok(Err(error)) => format!("Runtime background update failed: {error:#}") @@ -286,6 +318,7 @@ impl Worker { (document, None) } }; + // 只有提交成功才发布同世代原生命令和通知 GUI,错误/过期结果不能算一次有效刷新。 let changed = matches!(current.commit(epoch, document), Ok(Some(_))); if changed && let Some(commands) = commands { current.publish_window_commands(id, commands); @@ -296,7 +329,7 @@ impl Worker { } } - /// 检查请求种类和归属,必要时先等已有刷新结束,再建立输入或候选任务。 + /// api 线程循环收到 job 或恢复 BeforeInput 时调用;业务未执行前可等待旧刷新,执行后切换 PendingInput。 pub(super) fn begin_request( &mut self, job: &BuildRequest, @@ -305,15 +338,16 @@ impl Worker { ) -> RequestProgress { // 完整 PUT 独立候选,表达式隔离求值;只有 REPL/事件会进入当前持久域并等待已有刷新。 if job.kind == RequestKind::Code { + // PUT 创建隔离候选,不向当前 VM 追加语句;失败可保留旧程序的实际对象图。 return self.begin_program_request(job, namespace, notify); } if job.kind == RequestKind::Expression { + // evaluate 不访问活 VM/显示文档,无需等待当前只读刷新完成。 return RequestProgress::Complete(build(job, namespace, notify)); } - // Advance one existing refresh quantum, then park the HTTP request. - // No input statement executes inside the read-only scope; shutdown and - // newer viewport targets can be observed at every worker turn. + // 先只推进已有刷新一片,关闭和最新尺寸每个 worker 控制边界仍可见;未空闲不运行新语句。 self.refresh_viewport_mode(namespace, notify, true); + // 推进可能跨发布边界,重新读取当前接受序号,不沿用 job 作为最新状态。 let epoch = match namespace.read() { Ok(current) => current.submission, Err(_) => { @@ -324,10 +358,12 @@ impl Worker { } }; if epoch != job.submission { + // 尚未执行的旧输入被新提交撤销,不能借“仍有旧 VM”在新接受顺序后插入旧队列动作。 return RequestProgress::Complete(superseded(job.submission)); } // 重置丢弃内存会话但保留显示文档,清帧 session/focus 使旧画面不再接受旧对象事件。 if job.kind == RequestKind::ResetRepl { + // 丢真正 VM 的内存状态,画面只作为不可交互静态值保留,不声称用户对象仍可响应。 self.repl = None; if let Ok(mut current) = namespace.write() { current.document.frame.session = 0; @@ -350,13 +386,17 @@ impl Worker { .as_ref() .is_some_and(|(_, runtime)| runtime.has_pending_refresh()) { + // BeforeInput 表示尚无业务副作用,恢复可重新走 begin_request;不能把已执行输入也放此状态。 return RequestProgress::Pending(PendingRequest::BeforeInput); } // 事件先做 JSON 结构、kind 白名单和 Input 字段校验,再用权威发布帧核对具体资源能力。 let event = if job.kind == RequestKind::Event { + // 可信原生来源取自进程内 job.native;HTTP 正文只能被严格反序列化成普通事件。 let mut input: crate::events::Input = if let Some(input) = &job.native { + // 原生 Input 仍需下面全部字段/能力验证,可信来源只影响连续输入能否追随新布局版本。 input.clone() } else { + // deny_unknown_fields 和类型检查由 Input 的 serde 声明执行,不按任意 JSON 字段猜动作。 match serde_json::from_str(&job.source) { Ok(input) => input, Err(error) => { @@ -392,14 +432,17 @@ impl Worker { | "window-close" ) { + // 开发工具名字由后续工具白名单再查;其余只接受明确列出的原生输入类别。 return RequestProgress::Complete(error_response( StatusCode(400), "unsupported event kind", )); } if let Err(message) = input.validate() { + // 结构错属于 400,在碰任何活 VM 对象之前回应,不能当作过期帧冲突或源运行错误。 return RequestProgress::Complete(error_response(StatusCode(400), message)); } + // 以下窗口、session、版本与几何能力都从同一个权威发布状态读锁中核对。 let current = match namespace.read() { Ok(current) => current, Err(_) => { @@ -409,8 +452,7 @@ impl Worker { )); } }; - // Rebase trusted captured native gestures only after BeforeInput - // has drained the prior refresh. External HTTP remains strict. + // 原生 relay 排队期间可能又完成了 BeforeInput 刷新;在此再追随一次同目标新版本,HTTP 保持严格。 if job.native.is_some() && !crate::events::refresh_continuous_input(&mut input, ¤t) { @@ -419,9 +461,11 @@ impl Worker { "native target was replaced or detached", )); } + // 必须选输入声明的窗口帧,不能因为副窗缺失就回退到主窗口同数值 Mount。 let frame = if input.window == crate::window::MAIN_WINDOW { ¤t.document.frame } else { + // 副窗只来自 current.document.windows,GUI 残留 OS 窗口不自动代表活域资源。 let Some(document) = current.document.windows.get(&input.window) else { return RequestProgress::Complete(error_response( StatusCode(409), @@ -435,9 +479,11 @@ impl Worker { && frame.window == input.window && frame.session == input.session { + // 有效资源身份但 VM 已停的关闭可进入纯宿主退役,其他输入仍必须拒绝。 let owns = self.repl.as_ref().is_some_and(|(owner, runtime)| { *owner == epoch && runtime.id == input.session && input.session != 0 }); + // 画面故障、无对应活 VM 或 VM 内已 faulted 都说明不能调用可覆盖的源 Window.close。 let stopped = frame.fault.is_some() || !owns || self @@ -445,6 +491,7 @@ impl Worker { .as_ref() .is_some_and(|(_, runtime)| runtime.is_faulted()); if stopped { + // close_inactive_window 需要发布写锁,调用前必须释放这里持有的读锁。 drop(current); return RequestProgress::Complete( self.close_inactive_window(&input, epoch, namespace, notify), @@ -453,6 +500,7 @@ impl Worker { } // 标签拖放同时验证目标窗口的会话、版本、无故障及插入位置,源窗口校验不能替代目标校验。 if let Some(target) = &input.tab_drop { + // 拖放目标同样要找到原窗口权威帧,不能只验证源标签仍存在就信任目标下标。 let target_frame = if target.window == crate::window::MAIN_WINDOW { Some(¤t.document.frame) } else { @@ -463,6 +511,7 @@ impl Worker { .map(|document| &document.frame) }; if target_frame.is_none_or(|frame| { + // 列表插入缝允许等于 tabs.len,超过长度、换代、换几何或故障都拒绝。 frame.window != target.window || frame.session != input.session || frame.layout_version != target.layout_version @@ -473,6 +522,7 @@ impl Worker { .as_ref() .is_none_or(|(_, runtime)| !runtime.accepts_tab_drop(&input)) { + // 错误是本次目标已过期,不让源 moveToWindow 方法运行到一半才发现无法重排。 return RequestProgress::Complete(error_response( StatusCode(409), "drop targets an obsolete or inactive frame", @@ -488,6 +538,7 @@ impl Worker { || input.kind.starts_with("developer-open:") || matches!(input.kind.as_str(), "page-pan" | "page-zoom") { + // 全窗关闭、工具打开与整页相机不指向某个 Mount,必须携带零目标,防止身份多义。 input.mount != 0 } else if matches!( input.kind.as_str(), @@ -496,26 +547,32 @@ impl Worker { // 标签操作的 mount 字段承载 Tab ID,而普通输入承载 Mount ID;两套身份不能混着查询。 !frame.tabs.iter().any(|tab| tab.id == input.mount) } else { + // 普通 Mount 输入查出现表再按 kind 校验对应能力,不能只验证存在同号几何。 !frame.mounts.iter().any(|mount| { mount.id == input.mount && if input.kind.starts_with("viewport-") { + // 二维局部相机仅允许 CanvasViewport,普通可点击矩形不能变成相机目标。 mount.canvas_viewport } else if input.kind.starts_with("scene-") { // 3D 手势还要求显式控制器能力,scroll 要有滚动投影,点击/焦点则要求 interactive。 mount.scene3d_viewport && mount.camera_controls } else if input.kind == "scroll" { + // 滚动投影存在才有滚动语义,不凭 Block 名义类型或外框可点击推测。 mount.scroll.is_some() } else { + // 点击/焦点等至少要求交互资格;具体源 hitTest/指针订阅仍在 bridge 中再查。 mount.interactive } }) } || self.repl.as_ref().is_none_or(|(context, runtime)| { + // 发布帧与实际活域必须匹配;静态保留画面不能用作对不存在 VM 的调用凭据。 *context != epoch || runtime.id != input.session || (input.kind != "window-close" && !runtime.accepts_window(input.window)) }) { + // 同一统一门在任何源回调之前拒绝,不能先执行部分动作再补查会话身份。 return RequestProgress::Complete(error_response( StatusCode(409), "event targets an obsolete or inactive frame", @@ -535,6 +592,7 @@ impl Worker { && !frame.mounts.iter().any(|mount| { mount.id == input.mount && input.point.is_some_and(|point| { + // 先检查出现外框,再用面板/视图逆映射检测真实内容 clip,边框不作缩放锚点。 let point = crate::geometry::Point::new(point[0], point[1]); mount.contains(point) && mount.viewport_clip.is_some_and(|clip| { @@ -548,6 +606,7 @@ impl Worker { }) }) { + // 锚点必须在实际内容区域内,外框/面板反投影验证失败就先拒绝,不运行相机控制器。 return RequestProgress::Complete(error_response( StatusCode(400), "zoom anchor is outside content viewport", @@ -561,6 +620,7 @@ impl Worker { .iter() .any(|mount| mount.id == input.mount && mount.panel.is_some()) { + // 缺锚点的线性二维增量尚可解释,世界面板的非线性映射则无法形成可靠局部位移。 return RequestProgress::Complete(error_response( StatusCode(400), "world-space UI camera input requires a pointer", @@ -568,6 +628,7 @@ impl Worker { } Some(input) } else { + // Console 不是事件,不制造一个空 Input;下面按 source 执行同一持久会话的交互语句。 None }; if self @@ -585,9 +646,11 @@ impl Worker { )); } }; + // 空 Console 初建只沿用主壳尺寸,不从静态旧帧复制可变业务对象或副窗资源。 let viewport = namespace.read().ok().and_then(|current| current.viewport); match endor_host::ReplSession::new_with_viewport(self.next_session, viewport) { Ok(session) => { + // 仅共享状态仍是当前 epoch 才记录新命令世代;真正画面仍等待源程序打开页面。 if let Ok(mut current) = namespace.write() && current.submission == epoch { @@ -603,6 +666,7 @@ impl Worker { } } } + // 业务执行前复制合法视口集合,之后整个 VM 调用不持 namespace 锁。 let viewports = match namespace.read() { Ok(current) => current_viewports(¤t), Err(_) => { @@ -612,9 +676,13 @@ impl Worker { )); } }; + // 惰性创建已保证 repl 存在,借用这个实际会话而非重放历史或建立每请求临时 VM。 let session = &mut self.repl.as_mut().expect("initialized REPL").1; + // 保存实际会话号,执行后可能收到新 PUT,finish_input 会再次核验归属。 let id = session.id; + // 统一收集可诊断错误与内部 panic,后者必须丢弃域,不能继续借损坏对象图交互。 let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> { + // 两种输入只执行一次业务,挂起时 PendingInput 将保存这个返回值,只继续视觉阶段。 let result = if let Some(event) = &event { session.dispatch_incremental(event, &viewports)? } else { @@ -628,6 +696,7 @@ impl Worker { }; Ok((result, commands)) })); + // 是否需对可见帧加故障条取决于 VM 的实际执行状态,普通编译失败不等于整个域已损坏。 let faulted = session.is_faulted(); if session.has_pending_refresh() { if let Ok(Ok((result, _))) = result { @@ -639,7 +708,7 @@ impl Worker { }))); } // 启动失败且残留挂起刷新时直接丢弃域,不能保留一个无法安全推进的 HTTP 请求。 - // A failed start must never leave a request waiting on a broken VM. + // 不给无法恢复的执行错误留下“等待下一片可能成功”的请求,保留完整旧画面另作响应。 self.repl = None; } RequestProgress::Complete(self.finish_input( @@ -654,7 +723,7 @@ impl Worker { )) } - /// 恢复已挂起的请求,保持原 HTTP 响应和运行时身份直到最终完成。 + /// api 外层循环保存原 job 后每量子调用,按继续状态分流,只有 BeforeInput 能重新进入业务入口。 pub(super) fn resume_request( &mut self, job: &BuildRequest, @@ -663,6 +732,7 @@ impl Worker { notify: &Notify, ) -> RequestProgress { match pending { + // 业务尚未执行,可重新核验提交和等待条件;Input/Candidate 都只继续原执行域。 PendingRequest::BeforeInput => self.begin_request(job, namespace, notify), PendingRequest::Input(input) => self.resume_input(job, input, namespace, notify), PendingRequest::Candidate(runtime) => { @@ -671,7 +741,7 @@ impl Worker { } } - /// 继续同一 Console/事件输入的展示刷新,不再次执行其业务回调。 + /// resume_request 的 Input 分支调用,核验原会话并继续原视觉工作,保留先前返回值/统计不重执行语句。 fn resume_input( &mut self, job: &BuildRequest, @@ -679,6 +749,7 @@ impl Worker { namespace: &Arc>, notify: &Notify, ) -> RequestProgress { + // 可继续条件和最新合法尺寸在同一读锁中取得,执行时不长期持有发布锁。 let viewports = match namespace.read() { // 正在执行输入只要仍属当前提交或仍拥有权威 VM 就可收敛;两项皆失效才彻底废弃。 Ok(current) @@ -687,6 +758,7 @@ impl Worker { current_viewports(¤t) } Ok(_) => { + // 提交和权威 VM 都已撤销,不能把旧输入结果重新提交到另一个活动程序上。 self.repl = None; return RequestProgress::Complete(superseded(job.submission)); } @@ -698,6 +770,7 @@ impl Worker { )); } }; + // PendingInput 不自己强持另一个运行时,必须与 Worker 当前拥有的真实 VM 一致。 let Some((epoch, session)) = &mut self.repl else { return RequestProgress::Complete(error_response( StatusCode(409), @@ -707,6 +780,7 @@ impl Worker { if *epoch != input.epoch || session.id != input.id { return RequestProgress::Complete(superseded(job.submission)); } + // 只推进 resize/刷新,catch_unwind 保证源视觉方法 panic 后可撤销整个损坏域。 let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> { // 这里只恢复 pending 展示/尺寸工作,不再调用 execute/dispatch,所以前一个量子的源副作用不会重放。 let document = session.resize_windows_incremental(&viewports, false)?; @@ -715,10 +789,13 @@ impl Worker { } Ok(Some((document, session.take_window_commands()?))) })); + // 恢复失败是否停域由实际 runtime 状态决定,交 finish_input 统一决定画面故障报告。 let faulted = session.is_faulted(); + // Pending 原样保留输入元数据,Complete 才把新文档补入第一次业务执行的结果。 let result = match resumed { Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Input(input)), Ok(Ok(Some((document, commands)))) => { + // 不覆盖 value/input/bindings,避免一次挂起输入变成第二次 Console 记录。 input.result.document = document; Ok(Ok((input.result, commands))) } @@ -738,7 +815,7 @@ impl Worker { } #[allow(clippy::too_many_arguments)] - /// 校验输入仍属于当前域,再一次性发布结果、元数据和原生窗口命令。 + /// begin_request/resume_input 终结输入时调用,写锁内核验接受身份,提交完整文档与同 VM 命令后回应。 fn finish_input( &mut self, job: &BuildRequest, @@ -750,30 +827,33 @@ impl Worker { faulted: bool, result: InputOutcome, ) -> HttpResponse { + // 锁覆盖提交检查/文档/命令世代更新,HTTP 新 PUT 不能插在验证与提交之间制造竞态。 let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock is poisoned"); }; // 接受新 PUT 会使排队旧输入失效,但已执行的活域输入需先收敛,才能在候选失败时留下内部一致的旧程序。 let epoch = if current.submission != epoch { if current.runtime_session != id { + // 已失去实际画面归属才彻底丢旧 VM,避免旧输入在另一代程序上产生回执或发布。 drop(current); self.repl = None; return superseded(job.submission); } - // Accepted PUTs invalidate queued inputs, not a mutation already - // executing on this owner. Settle it before building the candidate - // so a failed replacement can preserve a consistent live heap. + // 新 PUT 撤销未执行旧队列项,但旧 VM 已开始的变更要先收敛,候选失败才有一致的旧堆可保留。 if let Some((owner, runtime)) = &mut self.repl && runtime.id == id { + // 更新活 VM 的接受 epoch,而不改变其 session;不是把它冒充为新候选或复制对象图。 *owner = current.submission; } current.submission } else { + // 接受序号没变,直接沿用原输入 epoch,不制造额外 revision 或 session。 epoch }; match result { Ok(Ok((result, commands))) => { + // 有标题/焦点等命令即使无新文档也需通知 GUI,和 rendered 的含义分别记录。 let projected = !commands.is_empty(); // rendered 只表示此次交付带新文档,成功执行但页面未变仍可返回 value/session;不因此虚增 revision。 let rendered = result.document.is_some(); @@ -785,6 +865,7 @@ impl Worker { } } current.publish_window_commands(id, commands); + // 在释放锁前读取这次确定的 revision,响应不再为字符串化借用共享状态。 let revision = current.revision; drop(current); if rendered || projected { @@ -797,10 +878,8 @@ impl Worker { } Ok(Err(error)) => { if event || faulted { + // 事件/渲染回调故障会影响整个共享 VM;单纯 Console 编译错误可只返回 422 而不覆盖画面。 let mut document = current.document.clone(); - // Preserve the first failure of this VM; subsequent - // rejected Console input must not hide the callback cause. - // A preserved picture from another generation is unrelated. // 同 VM 保留最初故障原因,后续“域已停止”错误不能覆盖真正回调故障;其他世代旧图不沿用故障。 let fault = (document.frame.session == id) .then(|| document.frame.fault.clone()) @@ -810,6 +889,7 @@ impl Worker { for view in document.windows.values_mut() { view.frame.fault = Some(fault.clone()); } + // 尽力提交故障条,主响应仍保留原始执行错误;不能因二次故障覆盖真正原因。 let _ = current.commit(epoch, document); drop(current); notify(); @@ -817,6 +897,7 @@ impl Worker { compile_error(error) } Err(_) => { + // Rust panic 不作为普通语言错误继续执行,清运行时/命令资格但保留上次完整静态画面。 let mut document = current.document.clone(); document.frame.fault = Some("Runtime discarded after an internal execution failure".into()); @@ -826,6 +907,7 @@ impl Worker { // 输入 panic 保留最近画面但撤销运行时/命令并发布故障条,再将 self.repl 丢弃。 current.runtime_session = 0; current.window_commands.clear(); + // 优先保留原始 panic 的响应,故障画面提交尽力而为;不因二次提交失败重新使用已损坏 VM。 let _ = current.commit(epoch, document); drop(current); self.repl = None; @@ -838,29 +920,31 @@ impl Worker { } } - /// An accepted PUT changes the submission, but does not revoke the old - /// runtime. DELETE clears runtime_session and does revoke it. - /// 接受较新提交不等于立即销毁旧 VM;候选失败时仍需保留当前可用运行时。 + /// 新提交/候选过期或失败时调用,若旧 VM 仍拥有发布画面则只跟进 epoch;DELETE 已撤权威则丢弃它。 pub(super) fn preserve_live_owner(&mut self, current: &NamespaceState) { if let Some((epoch, runtime)) = &mut self.repl { if current.runtime_session == runtime.id { + // 旧程序还是真正交互域,接受次序更新但其实际对象和 session 身份都不改变。 *epoch = current.submission; } else { + // 权威已经撤销或切到另一代,不能仅因还持有 Rust ReplSession 就继续执行。 self.repl = None; } } } - /// 为完整 PUT 建立隔离候选,保留旧运行时直到新候选完整成功。 + /// begin_request 的 Code 分支调用,先核验接受身份,再分配新 session 创建隔离候选,旧 self.repl 保持不动。 fn begin_program_request( &mut self, job: &BuildRequest, namespace: &Arc>, notify: &Notify, ) -> RequestProgress { + // 候选仅继承主壳当前逻辑尺寸,不继承旧程序的可变对象或副窗资源。 let viewport = match namespace.read() { Ok(current) if current.submission == job.submission => current.viewport, Ok(current) => { + // 排队期间已有更新 PUT/DELETE,当前候选还没执行就应拒绝,不浪费新 VM 构建。 self.preserve_live_owner(¤t); return RequestProgress::Complete(superseded(job.submission)); } @@ -880,6 +964,7 @@ impl Worker { )); } }; + // 分配过的 session 即使候选失败也不回收,避免排队旧输入命中下一次新域。 let id = self.next_session; // 候选装载运行在独立 ReplSession,未成功前不修改 self.repl,旧程序仍是权威展示来源。 let started = catch_unwind(AssertUnwindSafe(|| { @@ -891,6 +976,7 @@ impl Worker { viewport, ) })); + // 两层 Result 分别区分 panic 和语言/布局错误;None 文档表示保留完整候选继续视觉阶段。 let result = match started { // 候选业务装载成功但布局未完成,整个候选移进 PendingRequest;不能把 None 当成空文档提交。 Ok(Ok((runtime, None))) => { @@ -906,7 +992,7 @@ impl Worker { RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result)) } - /// 推进候选的布局与视口收敛,检查其是否已被新提交撤销。 + /// resume_request 的 Candidate 分支调用,用同一个隔离 VM 继续布局/绘制,期间持续检查提交是否已过期。 fn resume_candidate( &mut self, job: &BuildRequest, @@ -914,10 +1000,9 @@ impl Worker { namespace: &Arc>, notify: &Notify, ) -> RequestProgress { + // 接受身份验证和主壳尺寸采样同锁,旧副窗口尺寸不作为新域的布局输入。 let viewports = match namespace.read() { Ok(current) if current.submission == job.submission => { - // Secondary native resources still belong to the old VM. Only - // the primary shell's current viewport applies to a candidate. // 候选不沿用旧副窗口的视口;副窗口资源归旧 VM,只有主壳当前尺寸可作为新候选分配条件。 current .viewport @@ -936,8 +1021,11 @@ impl Worker { )); } }; + // 保留候选实际 session,不能在每片恢复时重新分配 ID 或重建 UI 类型域。 let id = runtime.id; + // 续跑视觉状态,不重执行顶层语句或 main;错误/panic 都交 finish_candidate 保留旧活程序。 let resumed = catch_unwind(AssertUnwindSafe(|| -> Result<_> { + // 与活域一样,resize 可重定向外部目标或继续原 Work,但始终留在此候选 owning VM 上。 let document = runtime.resize_windows_incremental(&viewports, false)?; if runtime.has_pending_refresh() { return Ok(None); @@ -947,6 +1035,7 @@ impl Worker { document.ok_or_else(|| anyhow::anyhow!("program did not publish a Page"))?; Ok(Some((document, runtime.take_window_commands()?))) })); + // Complete 才把持久 runtime、完整文档和命令作为一组交给切换发布,Pending 继续原 Box。 let result = match resumed { Ok(Ok(None)) => return RequestProgress::Pending(PendingRequest::Candidate(runtime)), Ok(Ok(Some((document, commands)))) => Ok(Ok((*runtime, document, commands))), @@ -956,7 +1045,7 @@ impl Worker { RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result)) } - /// 候选成功才切换活跃 VM 和文档;失败保留先前已发布程序。 + /// 完整加载/恢复候选终结时调用,写锁内二次核验 submission,只有完整成功才切换文档、命令与活 VM。 fn finish_candidate( &mut self, job: &BuildRequest, @@ -965,6 +1054,7 @@ impl Worker { id: u64, result: CandidateOutcome, ) -> HttpResponse { + // 字节码/源视觉执行不持锁,只有最终这个切换边界持锁原子更新外部权威。 let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock poisoned"); }; @@ -990,8 +1080,6 @@ impl Worker { Err(error) => error_response(StatusCode(500), error.to_string()), }, failed => { - // The candidate never mutated the displayed program's heap. - // Restore that program's event epoch when a replacement fails. // 候选失败只更新旧活域 epoch,保留其堆与页面;不存在旧域时也不会借用其他会话伪造成功。 self.preserve_live_owner(¤t); drop(current); @@ -1007,7 +1095,7 @@ impl Worker { } } -/// 处理隔离表达式或一次性文档编译;编译前后核对 submission,只发布仍有效的完整快照。 +/// begin_request 的 Expression 分支和 API 测试一次性构建路径调用;无持久 VM 输出,源码构建前后核验接受身份。 fn build( job: &BuildRequest, namespace: &Arc>, @@ -1015,6 +1103,7 @@ fn build( ) -> Response>> { // 隔离表达式不读写持久 REPL 堆,也不发布文档,响应明确 persistent=false。 if job.kind == RequestKind::Expression { + // 表达式只回文本,不能从已有画面反取 globals,成功也明确 persistent=false。 return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) { Ok(Ok(value)) => json( StatusCode(200), @@ -1029,12 +1118,12 @@ fn build( Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"), _ => {} } - // Only an independent native snapshot crosses into the published state. - // A fresh VM is discarded on success, error and unwind alike. // 一次性构建同样用独立 VM,计算锁外执行,返回后再次核对 submission 才能发布原生快照。 + // 包住一次性 VM 执行,panic 也不会让 HTTP 客户端一直等待失去响应的 worker。 let result = catch_unwind(AssertUnwindSafe(|| { endor_host::compile(&job.source, &job.namespace, job.submission) })); + // 重新取发布写锁再检查 submission,候选计算期间新请求可接受但旧结果无权覆盖。 let Ok(mut current) = namespace.write() else { return error_response(StatusCode(500), "namespace lock is poisoned"); }; diff --git a/src/runtime/endor_host/manuscript_tests.rs b/src/runtime/endor_host/manuscript_tests.rs index 9b90ed2..478eefb 100644 --- a/src/runtime/endor_host/manuscript_tests.rs +++ b/src/runtime/endor_host/manuscript_tests.rs @@ -1,18 +1,27 @@ -//! Integration acceptance through the actual Endor VM and published frames. +//! Rust 测试框架通过 endor_host 的真实 VM/发布帧接口验收 illustrated-manuscript Endor 示例。 +//! 第一项检查指针驱动/火焰生命周期与基本像素,第二项检查 owner-scoped 动画任务、不同视口和绘制几何。 +//! write_png 仅在配置 TESSERA_MANUSCRIPT_SNAPSHOTS 时写图片;测试源码字符串与像素期望都是验收数据。 use super::*; use crate::document::RenderCommand; #[test] +/// 测试框架调用,证明动画由真实源状态和指针事件驱动,取消输入后源粒子会退场而非原生演示循环继续跑。 fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { + // 直接加载仓库随应用交付的示例,避免测试另写一份近似逻辑却无法覆盖实际程序。 let source = include_str!("../../../examples/illustrated-manuscript.endor"); + // 固定会话代际只用于事件身份,几何/Mount ID 从真正发布的文档取出。 let (mut session, mut document) = ReplSession::load(source, "app.main", 1, 735).unwrap(); + /// 本测试的同步求值助手,执行后只在有新文档时替换观察值,返回 Console 的字符串结果供断言。 fn evaluate(session: &mut ReplSession, document: &mut RenderDocument, source: &str) -> String { + // execute 复用持久 globals,不根据浏览文档伪造对象状态。 let result = session.execute(source, "app.main").unwrap(); if let Some(next) = result.document { + // 无变化时保留先前完整帧,不能用默认空文档替换它而误判渲染退场。 *document = next; } result.value } + // 关闭自动计时器,后面显式 dragon.step 的次数才是唯一推进因素,消除定时采样干扰。 evaluate(&mut session, &mut document, "view.timer.cancel()"); document.validate().unwrap(); assert!( @@ -23,13 +32,16 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { .any(|c| matches!(c, RenderCommand::Image { .. })) ); assert!(document.frame.mounts.iter().any(|m| m.pointer_events)); + // 读取模型的真实头段 x 作为基线,不靠图片相似度猜动画是否发生。 let before = evaluate(&mut session, &mut document, "dragon.segments.at(0).x"); + // 目标必须具有 pointer_events,发布几何提供局部区域,不硬编码内部出现编号。 let target = document .frame .mounts .iter() .find(|m| m.pointer_events) .unwrap(); + // 用发布版本与实际目标构造合法原始指针,坐标放在画面内部,避免命中边缘的额外裁剪条件。 let mut input = crate::events::Input { window: 1, session: 735, @@ -51,14 +63,17 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { .document .unwrap_or(document); for _ in 0..8 { + // 只推进源函数,证明位置变化来自已收到的指针目标与程序状态,不是宿主隐藏动画线程。 document = session .execute("dragon.step()", "app.main") .unwrap() .document .unwrap_or(document); } + // 比较同一模型字段的前后值,检查显式推进阶段有实际位置变化,而非只生成更多绘图命令。 let after = evaluate(&mut session, &mut document, "dragon.segments.at(0).x"); assert_ne!(before, after); + // 事件每次使用最新实际几何版本,测试关注行为而非故意触发过期输入拒绝。 input.layout_version = document.frame.layout_version; input.kind = "pointerdown".into(); document = session @@ -67,6 +82,7 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { .document .unwrap_or(document); for _ in 0..3 { + // pointerdown 激活喷火状态,少量显式帧之后应有源粒子实例进入集合。 document = session .execute("dragon.step()", "app.main") .unwrap() @@ -77,13 +93,16 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { evaluate(&mut session, &mut document, "dragon.flames.count"), "0" ); + // 在源模型侧检查粒子尺寸预算,避免仅像素图看起来小却实际对象无限长大。 let max_size = evaluate(&mut session, &mut document, "dragon.flames.at(0).size < 20"); assert_eq!(max_size, "true"); input.layout_version = document.frame.layout_version; input.kind = "pointercancel".into(); + // 取消必须沿同一输入分派路径清 pressed,不直接从测试修改 dragon.pressed 代替处理器。 session.dispatch(&input).unwrap(); assert_eq!(evaluate(&mut session, &mut document, "dragon.pressed"), "0"); for _ in 0..16 { + // 取消后继续更新已有粒子寿命,最终集合归零,验证没有仍被按住状态持续补发新火焰。 document = session .execute("dragon.step()", "app.main") .unwrap() @@ -94,32 +113,44 @@ fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() { evaluate(&mut session, &mut document, "dragon.flames.count"), "0" ); + // 真正发布列表交软件渲染器,不从源模型的“将要绘制”对象构造另一份测试专用画面。 let list = document.display_list(1.).unwrap(); + // 预分配固定尺寸 RGB 像素,下面验证页面奶油背景确实进入最终合成结果。 let mut pixels = vec![0; 1024 * 800]; crate::software_renderer::SoftwareRenderer::render(&list, &mut pixels, 1024, 800).unwrap(); assert!(pixels.contains(&0xf4eee0)); } +/// 第二项集成测试共用的持久执行助手;保留最后完整文档,返回源值的 Console 表示,不重建 VM。 fn execute(session: &mut ReplSession, document: &mut RenderDocument, source: &str) -> String { + // 执行源代码及其同步刷新,Option 文档为空只代表没有新发布,不代表旧画面失效。 let result = session.execute(source, "app.main").unwrap(); if let Some(next) = result.document { + // 有新完整帧才覆盖测试观察值,后续输入版本与快照都从此值读取。 *document = next; } result.value } +/// 第二项测试各阶段调用,先验证实际像素;仅明确配置快照目录时把同一像素编码为 PNG,便于人工检查。 fn write_png(document: &RenderDocument, w: u32, h: u32, scale: f32, name: &str) { + // scale 是物理像素倍率,显示列表和输出尺寸必须配套,不改变源程序使用的逻辑 viewport。 let list = document.display_list(scale).unwrap(); + // 软件渲染覆盖真实像素数组,断言并非只检查命令里有一条背景矩形。 let mut pixels = vec![0; w as usize * h as usize]; crate::software_renderer::SoftwareRenderer::render(&list, &mut pixels, w, h).unwrap(); assert!(pixels.iter().filter(|&&p| p == 0xf4eee0).count() > 1000); if let Ok(dir) = std::env::var("TESSERA_MANUSCRIPT_SNAPSHOTS") { + // 未设置目录时不产生文件;有目录才创建编码器,快照输出是显式选择的测试附加产物。 std::fs::create_dir_all(&dir).unwrap(); + // name 区分宽窄视口及静止/穿行/喷火阶段,图片来自当前实际发布帧而非 ImageGen。 let file = std::fs::File::create(std::path::Path::new(&dir).join(format!("endor-{name}.png"))) .unwrap(); + // 像素是 0xRRGGBB,编码为每通道 8 位 RGB,不能把 u32 内存字节直接当 RGBA 保存。 let mut encoder = png::Encoder::new(file, w, h); encoder.set_color(png::ColorType::Rgb); encoder.set_depth(png::BitDepth::Eight); + // 显式提取高到低三个颜色分量,避免平台端序影响导出的颜色。 let rgb: Vec<_> = pixels .iter() .flat_map(|p| [(p >> 16) as u8, (p >> 8) as u8, *p as u8]) @@ -133,12 +164,15 @@ fn write_png(document: &RenderDocument, w: u32, h: u32, scale: f32, name: &str) } #[test] +/// 测试框架调用,验证真实 owner 定时任务、宽窄/高倍视口、喷火上限和 Page 关闭后的动画取消。 fn manuscript_script_snapshots_and_owned_animation_lifecycle() { + // 只追加测试推进函数,原示例本体不改;每次 step 前刷新 lastMove 以稳定触发运动场景。 let source = format!( "{}\nfunction advanceTest():Unit {{dragon.lastMove=math.now() dragon.step()}}", include_str!("../../../examples/illustrated-manuscript.endor") ); for (width, height, scale) in [(1000., 900., 1.), (420., 760., 2.)] { + // 两种窗口使用独立 VM 与真实逻辑 viewport,窄视图同时覆盖高倍字体/图片采样。 let (mut session, mut document) = ReplSession::load_with_viewport( &source, "app.main", @@ -147,15 +181,19 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { Some(crate::layout::Size { width, height }), ) .unwrap(); - // The actual owner-scoped Task calls script tick, not a native demo loop. + // 真实 owner-scoped Task 调用源 tick,先比较时间字段确认计时器而非宿主演示循环在推进。 let before = execute(&mut session, &mut document, "dragon.time"); + // 等待超过示例定时截止时间,再在所属 VM poll,后台线程不会直接替程序改 time。 std::thread::sleep(std::time::Duration::from_millis(85)); if let Some(next) = session.poll_tasks().unwrap() { + // 只接收调度后真正完整发布文档,不从任务内部未收敛草稿截屏。 document = next; } + // time 变化证明 owner/key 当前任务获得了一次有效交付。 let after = execute(&mut session, &mut document, "dragon.time"); assert_ne!(before, after); execute(&mut session, &mut document, "view.timer.cancel()"); + // 自动动画确认后取消,后续快照阶段由固定次数 step 控制;像素尺寸按逻辑尺寸乘 scale。 let (w, h) = ((width * scale) as u32, (height * scale) as u32); write_png( &document, @@ -164,6 +202,7 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { scale, if width > 500. { "rest" } else { "narrow-rest" }, ); + // 找当前真实指针出现并克隆几何,下面分派会改 document,不能保留对旧文档的借用。 let mount = document .frame .mounts @@ -171,7 +210,9 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { .find(|m| m.pointer_events) .unwrap() .clone(); + // 用视口比例选锚点,宽窄布局都位于同类内容区域,不依赖固定像素偏移。 let point = [mount.x + mount.width * 0.58, mount.y + mount.height * 0.50]; + // 会话/版本/目标均从本轮 VM 和已发布帧取得,确保命中的是此视口而不是另一测试残留。 let mut input = crate::events::Input { window: 1, session: 736, @@ -190,6 +231,7 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { .document .unwrap_or(document); for _ in 0..12 { + // 固定推进源运动,生成穿行状态,快照仍走实际 scene/paint/软件渲染链。 execute(&mut session, &mut document, "advanceTest()"); } write_png( @@ -206,10 +248,11 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { .unwrap() .document .unwrap_or(document); - // Hold long enough to reach the steady-state particle cap and exercise - // the worst paint workload, then release/cancel through the event path. + // 按住并推进足够帧数达到粒子稳态上限,覆盖比静止更重的真实 paint 工作量。 + // 计时仅作诊断输出,测试没有依赖机器速度的性能通过阈值。 let start = std::time::Instant::now(); for _ in 0..16 { + // 每次仍执行源 advanceTest,不能用原生测试循环直接生成粒子绕过语言能力。 execute(&mut session, &mut document, "advanceTest()"); } eprintln!( @@ -220,12 +263,14 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { execute(&mut session, &mut document, "dragon.flames.count <= 12"), "true" ); + // 从源 artwork 与粒子速度对比最终图片角度,验证精灵轴校正符合源模型方向。 execute( &mut session, &mut document, "function verifyFire(): Bool { art=dragon.artwork(0,0,1) c=art.at(art.count-1) p=dragon.flames.at(dragon.flames.count-1) return math.abs(c.angle+p.art.axis-math.atan2(p.vy,p.vx))<0.0001 }", ); assert_eq!(execute(&mut session, &mut document, "verifyFire()"), "true"); + // 单独验证最终绘图宽高,不只看粒子模型 size 字段,防止图片命令放大绕过尺寸预算。 execute( &mut session, &mut document, @@ -242,7 +287,9 @@ fn manuscript_script_snapshots_and_owned_animation_lifecycle() { scale, if width > 500. { "fire" } else { "narrow-fire" }, ); + // Page 关闭须立即取消其 Mount 所有的任务;后续即使保留 dragon 对象也不能继续定时更新。 execute(&mut session, &mut document, "page.close()"); + // 关闭时真实 time 是对照值,等待超过旧定时点再 poll 应完全不变。 let closed = execute(&mut session, &mut document, "dragon.time"); std::thread::sleep(std::time::Duration::from_millis(85)); session.poll_tasks().unwrap(); -- 2.52.0 From 91bd0d4c5a6f5c28cff766847ec0ca4266f7da5a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:39:06 +0800 Subject: [PATCH 05/78] docs: explain host test setup, dependencies and incremental painting --- src/runtime/endor_host/tests.rs | 293 ++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index a9623be..eebbe1a 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -2,10 +2,14 @@ //! 覆盖共享对象/独立 Mount、源覆盖、绑定、窗口、任务、取消、二维/三维投影及像素。局部辅助函数负责取统计值、收敛刷新和构造可重复输入。 //! 阅读时先看每个测试提交的 Endor 程序,再看后续变更与最终断言;编译成功、回调成功、文档发布成功是不同检查点。 //! +//! Rust 测试框架运行各 #[test],ReplSession 的测试同步入口负责排空实际 layout/paint,断言读取已完成文档。 +//! r# 原始字符串中的 Endor 是验收夹具,保持其源码/行号不变;Rust 注释解释夹具意图和随后观察的行为。 use super::*; use crate::document::{NamespaceState, RenderCommand}; +// 一次性模块执行/错误保留测试共用的真实示例,不在测试中另造只为通过断言的近似模块。 const EXAMPLE: &str = include_str!("../../../examples/hello.endor"); +// 页面脚本基线供多个源加载、替换、生命周期测试复用,include_str 在编译时嵌入。 const PAGE: &str = include_str!("../../../examples/page.endor"); /// 可选性能基准:分别测世界 UI 和普通三维场景在 1x/2x DPI 下的相机交互更新、变更绘制和同列表缓存绘制。 @@ -17,6 +21,7 @@ fn scene_interaction_performance() { use std::time::Instant; /// 对非空毫秒样本排序后返回均值、索引中位数和向上取整秩的 p95;调用方每组固定保留 30 次测量。 fn summary(mut values: Vec) -> String { + // total_cmp 提供稳定全序,下面按排序后的样本索引取统计值,不修改被测场景数据。 values.sort_by(f64::total_cmp); format!( "mean={:.3} p50={:.3} p95={:.3}ms", @@ -31,6 +36,7 @@ fn scene_interaction_performance() { ("scene3d", include_str!("../../../examples/scene3d.endor")), ] { for dpi in [1., 2.] { + // 逻辑视口保持 1024×800,后面仅物理像素倍率变化,避免混淆 DPI 与场景布局大小。 let (mut session, mut document) = ReplSession::load_with_viewport( source, "app.main", @@ -42,13 +48,19 @@ fn scene_interaction_performance() { }), ) .unwrap(); + // 软件输出尺寸按逻辑大小乘 DPI;同一组合复用像素缓冲,不把分配计入每帧绘制时间。 let (width, height) = ((1024. * dpi) as u32, (800. * dpi) as u32); + // u32 像素列表由真实软件渲染器覆盖,不用模拟绘图计数代替实际工作量。 let mut pixels = vec![0; (width * height) as usize]; + // 三种样本分别统计派发/刷新、变化列表绘制和同列表再次绘制,不把它们混为一个延迟。 let mut updates = Vec::new(); + // 相机刚变化后首次绘制耗时,和 source 更新/几何发布分开采样。 let mut draws = Vec::new(); + // 同一 DisplayList 的第二次绘制,用于观察缓存复用路径,不再推进源程序。 let mut cached = Vec::new(); // 连续做 33 轮,前三轮预热;每轮使用最新发布版本构造事件,避免基准测到过期输入拒绝。 for sample in 0..33 { + // 每次从最新帧找原始三维视口,身份与版本一致才测到有效 orbit 而非快速过期拒绝。 let target = document .frame .mounts @@ -71,8 +83,11 @@ fn scene_interaction_performance() { // update 只计派发与完整文档刷新;display_list 构造在后面的绘制计时范围之外。 let start = Instant::now(); document = session.dispatch(&input).unwrap().document.unwrap(); + // 统一转毫秒,sample>=3 后才保存预热之外的数值。 let update = start.elapsed().as_secs_f64() * 1000.; + // 显示列表构造不包含在下面 render 计时里,避免把两种阶段成本混为绘制耗时。 let list = document.display_list(dpi).unwrap(); + // 单独计首次变化帧的像素绘制时间。 let start = Instant::now(); crate::software_renderer::SoftwareRenderer::render( &list, @@ -83,6 +98,7 @@ fn scene_interaction_performance() { .unwrap(); // 第一次 render 测相机已变化时的绘制;立即用同一列表再画一次,单独统计缓存重用路径。 let draw = start.elapsed().as_secs_f64() * 1000.; + // 不改变 list/像素尺寸直接再次 render,观察缓存命中绘制,不再发第二次相机输入。 let start = Instant::now(); crate::software_renderer::SoftwareRenderer::render( &list, @@ -122,6 +138,7 @@ fn scene_interaction_performance() { /// 源 measure 直接读取 mount.layoutVersion 并将其作为宽度。 /// 只使 paint 失效后仍必须重新测量;通过新版本宽度和零缓存命中,防止把真实 Mount 读取错误缓存。 fn actual_mount_version_reads_still_bypass_measurement_cache() { + // measure 显式读取 Mount.layoutVersion,将宿主出现版本变成真实依赖,不能按静态对象结果复用。 let (mut session, initial) = ReplSession::load( r#" class Versioned: RectBlock { @@ -138,6 +155,7 @@ page.render(rect) 241, ) .unwrap(); + // 即使只请求 paint 失效,版本依赖仍要求重新测量,不能无条件沿用上轮宽度。 let next = session .execute("rect.invalidate(\"paint\")", "app.main") .unwrap() @@ -153,6 +171,7 @@ page.render(rect) /// 加载布局协商示例,验证同一段落两个出现位置分别获得 280/360 的宽度。 /// 加宽第一个出现位置后高度和后继 y 减小,第二个仍保持 360,且全部 Mount ID 不变。 fn source_allocations_and_measurement_limits_settle_before_publication() { + // 同一 paragraph 在两个约束下展示,验收源布局协商而非给两个独立对象写固定宽度。 let (mut session, initial) = ReplSession::load_with_viewport( include_str!("../../../examples/layout-negotiation.endor"), "app.main", @@ -164,28 +183,33 @@ fn source_allocations_and_measurement_limits_settle_before_publication() { }), ) .unwrap(); + // 保存所有出现身份,后面加宽只允许改变几何,不能销毁重建整棵 Mount 树。 let ids = initial .frame .mounts .iter() .map(|m| m.id) .collect::>(); + // 通过源 Mount API 读取第一个出现实际布局宽,验证不是只在绘图命令中临时缩放。 let width0 = session .execute("paragraph.mounts().at(0).bounds().width", "app.main") .unwrap() .value; + // 第二个出现属于同一业务对象,但有独立父约束,初始应为 360。 let width1 = session .execute("paragraph.mounts().at(1).bounds().width", "app.main") .unwrap() .value; assert_eq!(width0, "280"); assert_eq!(width1, "360"); + // 较窄约束使段落多行,记录原高度用于加宽后的 reflow 比较。 let height = session .execute("paragraph.mounts().at(0).bounds().height", "app.main") .unwrap() .value .parse::() .unwrap(); + // tail 在段落之后,段落高度变化必须传到后继 y,而不只是更新该段落绘制边界。 let y = session .execute("tail.mounts().at(0).bounds().y", "app.main") .unwrap() @@ -195,6 +219,7 @@ fn source_allocations_and_measurement_limits_settle_before_publication() { assert!(height > 20.0); assert!(y >= height + 12.0); assert!(source_measure_stat(&mut session, "layoutPasses") >= 2); + // 源按钮修改第一处宽度,再由正常刷新收敛和发布,不能直接从测试覆盖 Frame 几何。 let changed = session .execute("wide.click()", "app.main") .unwrap() @@ -209,12 +234,14 @@ fn source_allocations_and_measurement_limits_settle_before_publication() { .collect::>(), ids ); + // 变量名 wider 指加宽后的高度;文本行数减少时这个值应小于原 height。 let wider = session .execute("paragraph.mounts().at(0).bounds().height", "app.main") .unwrap() .value .parse::() .unwrap(); + // 同步观察 tail 上移,证明父布局已在发布前消费新的段落高度。 let moved = session .execute("tail.mounts().at(0).bounds().y", "app.main") .unwrap() @@ -242,6 +269,7 @@ fn source_allocations_and_measurement_limits_settle_before_publication() { /// 源 arrange 把子项宽高不断交换,构造不收敛的布局状态。 /// 加载必须报告 cycle,不能发布某个偶然中间态或依靠固定次数静默停止。 fn alternating_source_allocations_are_rejected_as_a_cycle() { + // 源 arrange 在父宽高之间交替指定孩子尺寸,固定点不收敛,应作为 cycle 拒绝而非任意截断。 let source = r#" class Alternating: Column { function arrange(self: Alternating,mount: Mount,geometry: Bounds): Arrangement { @@ -254,6 +282,7 @@ class Alternating: Column { page=Page() page.render(Alternating(0,0,RectBlock(120,60))) "#; + // 从真实初始布局收敛得到错误,断言消息以区分预算耗尽和明确循环检测。 let error = ReplSession::load(source, "app.main", 1, 1).err().unwrap(); assert!( error.to_string().contains("did not converge (cycle)"), @@ -265,6 +294,7 @@ page.render(Alternating(0,0,RectBlock(120,60))) /// 源 paint 只提供 wrapWidth,省略成对需要的 wrapHeight。 /// 检查命令解码明确拒绝不完整文字边界,而非生成测量/绘制不一致的帧。 fn source_text_paint_rejects_incomplete_wrap_bounds() { + // 只写 wrapWidth 不写 wrapHeight,构造解码层必须拒绝的半套文本边界,不能容错生成另一种命令。 let source = r##" class Invalid: TextBlock { function paint(self: Invalid,mount: Mount,geometry: Bounds): DisplayList { @@ -282,6 +312,7 @@ class Invalid: TextBlock { page=Page() page.render(Invalid("invalid")) "##; + // 源类型与函数合法,失败应准确指向遗漏的 wrapHeight,证明进入了实际 PaintCommand 解码。 let error = ReplSession::load(source, "app.main", 1, 1).err().unwrap(); assert!(error.to_string().contains("wrapHeight"), "{error:#}"); } @@ -290,9 +321,12 @@ page.render(Invalid("invalid")) /// 分别对原生 TextBlock 和委托原生方法的源子类运行相同测试。 /// 共享文字在不同内边距下独立换行;视口变宽只更新几何、保留 Mount,直接测量和 setText 也应同步反映尺寸。 fn wrapped_text_tracks_each_occurrence_width_resize_and_sibling_geometry() { + // 长中文段落迫使换行,不能靠一个短词恰好装得下而漏测父约束对自然高度的影响。 let text = "中文段落随着宽度变化而换行,后面的对象跟随段落高度移动。".repeat(8); for custom in [false, true] { + // 原生 TextBlock 与源委托子类应走同一测量/绘制语义,分别执行避免共享一个偶然缓存结果。 let constructor = if custom { "Wrapped" } else { "TextBlock" }; + // 相同 paragraph 放到不同内边距容器,两个出现必须分别获得可用宽度和换行高度。 let source = format!( r#" class Wrapped: TextBlock {{ @@ -305,6 +339,7 @@ page=Page() page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) "# ); + // 初始逻辑宽 240、高度充足,测到换行而非可视裁剪导致的“只看到几行”。 let (mut session, initial) = ReplSession::load_with_viewport( &source, "app.main", @@ -316,7 +351,9 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) }), ) .unwrap(); + // resize 后身份必须保持,缓存/几何更新不应改已捕获的 Mount ID。 let ids: Vec<_> = initial.frame.mounts.iter().map(|m| m.id).collect(); + // 只取实际段落文本命令,排除 tail/其他文本,读取 wrap_bounds 的宽与高。 let commands: Vec<_> = initial .frame .commands @@ -327,6 +364,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) wrap_bounds: Some(bounds), .. } if value == &text => Some(*bounds), + // 非目标段落或无换行边界命令不参与这组独立出现比较。 _ => None, }) .collect(); @@ -336,6 +374,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) assert_eq!(commands[0][0], 240.0); assert_eq!(commands[1][0], 140.0); assert!(commands[1][1] > commands[0][1]); + // 文本绘制 y 也需跟随段落自然高度,不能只有 Mount 的虚拟几何更新。 let tail = initial .frame .commands @@ -346,6 +385,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) }) .unwrap(); assert!(tail >= commands[0][1] + 8.0); + // 只改变窗口逻辑宽,不重新执行 Endor 输入,验证 renderer/源协议在保留 VM 上重排。 let resized = session .resize(Some(crate::layout::Size { width: 400.0, @@ -362,6 +402,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) .map(|m| m.id) .collect::>() ); + // 两个父级仍相差 100 内边距,但分别在新宽度下重测,不能共享一个固定 wrapWidth。 let widths: Vec<_> = resized .frame .commands @@ -376,6 +417,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) }) .collect(); assert_eq!(widths, [400.0, 300.0]); + // 同字串加宽后的实际绘制行高和原来比较,排除仅命令宽字段被改、字形仍按旧行排的情况。 let heights: Vec<_> = resized .frame .commands @@ -390,6 +432,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) }) .collect(); assert!(heights[0] < commands[0][1]); + // 源代码显式在更窄 80 约束下 measure,验证协议返回高度也与 Frame 的宽约束相关。 let direct = session .execute( "paragraph.measure(paragraph.mounts().at(0),Constraints(0,0,80,2000)).height", @@ -403,6 +446,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) session .execute("paragraph.setText(\"short\")", "app.main") .unwrap(); + // 短文本恢复单行,但同业务对象的两个出现仍都在,不能用替换对象逃避共享布局更新。 assert_eq!( session .execute("paragraph.mounts().count", "app.main") @@ -424,6 +468,7 @@ page.render(Column(0,8,Column(0,8,paragraph,tail),Column(50,8,paragraph))) /// 加载回调签名示例,逐项尝试不匹配的事件/绑定/任务回调及参数个数。 /// 注册期错误不能使会话 fault;合法零参数与 Event 参数回调均可执行,最后检查底层方法实际 arity。 fn native_signatures_validate_registration_and_deliver_the_event_argument() { + // 使用带零参/单 Event 回调的真实示例,后续非法注册与合法分发都在同一份活 globals 中执行。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/native-signatures.endor"), "app.main", @@ -438,6 +483,7 @@ fn native_signatures_validate_registration_and_deliver_the_event_argument() { r#"ui.after(ui,"bad",60000,received.set)"#, "count.increment(1)", ] { + // 逐个变坏回调种类或参数数量,错误应在注册/调用边界拒绝,健康运行时仍可继续接受输入。 assert!(session.execute(invalid, "app.main").is_err(), "{invalid}"); assert!( session.documents[&crate::window::MAIN_WINDOW] @@ -446,6 +492,7 @@ fn native_signatures_validate_registration_and_deliver_the_event_argument() { .is_none() ); } + // 合法单 Event 回调把 kind 保存到模型,渲染文字也应读取到 click,覆盖实际参数交付。 let document = session .execute("withEvent.click()", "app.main") .unwrap() @@ -469,7 +516,9 @@ fn native_signatures_validate_registration_and_deliver_the_event_argument() { "1" ); assert!(session.poll_tasks().unwrap().is_none()); + // 最后直接查 VM 元数据,检查绑定状态方法 set 的实际显式 arity,不只看错误字符串。 let globals = session.candidate.globals.unwrap(); + // received 是全局里真实状态对象,所有权仍由 globals 持有,这里只读取其句柄。 let Value::Object(received) = session .candidate .vm @@ -479,6 +528,7 @@ fn native_signatures_validate_registration_and_deliver_the_event_argument() { else { panic!("state") }; + // set 已捕获 receiver,只应接受一个新值,不能额外把 owner 计入未绑定参数。 let Value::Object(set) = session.candidate.vm.get_attr(received, "set").unwrap() else { panic!("set") }; @@ -492,6 +542,7 @@ fn native_signatures_validate_registration_and_deliver_the_event_argument() { /// 尝试错误字段赋值与构造,检查声明字段契约在原生分配副作用之前验证。 /// 已有值和窗口页面数量必须保持不变,随后合法点击仍能递增并发布文字。 fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_values() { + // 声明字段具有真实语言注解,错误数字/字符串构造应在原生分配副作用之前失败。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/field-contracts.endor"), "app.main", @@ -504,6 +555,8 @@ fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_value r#"CounterText("bad", "wrong")"#, "Model(42)", ] { + // 三种错误分别覆盖已有字段写入、原生子类构造与普通类构造,均不能停掉健康域。 + // 保留真实诊断检查 field,避免一个无关语法错误也被测试当作字段类型检查通过。 let error = session.execute(invalid, "app.main").err().unwrap(); assert!(error.to_string().contains("field"), "{error:#}"); assert!( @@ -520,6 +573,7 @@ fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_value session .execute("class CheckedPage: Page { count: Number }", "app.main") .unwrap(); + // 用页面数量作为构造副作用基线,错误 CheckedPage 字段不能偷偷把新页注册到窗口。 let pages = session .execute("ui.window().pages().count", "app.main") .unwrap() @@ -536,6 +590,7 @@ fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_value .value, pages ); + // 前面错误不应破坏活值/回调,合法 click 仍把 9 递增成 10 并反映到实际文字命令。 let updated = session .execute("button.click()", "app.main") .unwrap() @@ -558,6 +613,7 @@ fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_value /// 把 Numeric 视觉引用替换为 Textual,旧 alias 随共享转发指向新对象。 /// 字段检查必须按新类型执行;合法字符串赋值通过,数字写入和返回错误类型的绑定均被拒绝。 fn checked_fields_follow_shared_block_forwarding_and_binding_writes() { + // alias 保留原包装身份,block 替换到不同字段类型子类后,后续字段校验应沿共享转发找真实新类型。 let (mut session, _) = ReplSession::load( r#" class Numeric: TextBlock { value: Number } @@ -575,6 +631,7 @@ page.render(block) session .execute(r#"block = Textual("new", "kept")"#, "app.main") .unwrap(); + // 旧 Numeric 类型不能成为绕过新 Textual 字符串字段契约的入口。 assert!(session.execute("alias.value = 42", "app.main").is_err()); assert_eq!( session.execute("alias.value", "app.main").unwrap().value, @@ -583,6 +640,7 @@ page.render(block) session .execute(r#"alias.value = "updated""#, "app.main") .unwrap(); + // 合法 alias 写入必须从 block 另一名字可见,证明并非复制一份独立字段存储。 assert_eq!( session.execute("block.value", "app.main").unwrap().value, r#""updated""# @@ -598,32 +656,39 @@ page.render(block) /// 在容器中间插入子项,检查新稳定槽、后继 y 变化及旧 Mount 身份保留。 /// 越界、负位置和自包含循环在修改前失败;移除新槽后旧出现位置仍有效。 fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_graphs() { + // 先建 head/tail 两条稳定组成边,中间插入新节点应只为新边分配 slot,而不改旧边身份。 let source = r#"head=TextBlock("head") tail=TextBlock("tail") list=Column(24,8,head,tail) page=Page() page.render(list)"#; + // 保存初始完整几何,后续所有比较沿同一持久 VM,不重载源码制造看似相同的出现。 let (mut session, initial) = ReplSession::load(source, "app.main", 1, 200).unwrap(); session .execute("held=tail.mounts().at(0)", "app.main") .unwrap(); + // 从真实源 Mount 查询稳定 ID,插入后仍应对应同一 tail 出现,只改变位置。 let id = session .execute("held.id", "app.main") .unwrap() .value .parse::() .unwrap(); + // 旧 y 来自插入前发布帧,以便与新帧的同身份出现对比。 let old = initial .frame .mounts .iter() .find(|mount| mount.id == id) .unwrap(); + // 插入位置 1 与新 slot 2 故意不同,防止把顺序下标误当稳定组成身份。 let inserted = session .execute("slot=list.insert(1,TextBlock(\"middle\"))", "app.main") .unwrap(); assert_eq!(inserted.value, "2"); + // 成功结构编辑应产生完整新文档,而非只返回一个槽号却未安排刷新。 let document = inserted.document.unwrap(); + // 同 ID 在新帧仍存在,表示保留出现身份;其 y 增加证明父布局实际插入了中间孩子。 let new = document .frame .mounts @@ -643,6 +708,7 @@ fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_gra "list.insert(0,list)", "list.insert(-1,head)", ] { + // 超出插入缝、将自身包含成环和负位置都应在写入组成前失败,不把整个 VM 标故障。 assert!(session.execute(invalid, "app.main").is_err()); assert!(!session.is_faulted()); } @@ -651,6 +717,7 @@ fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_gra "true" ); session.execute("list.remove(slot)", "app.main").unwrap(); + // 删除新边使用返回的稳定 slot,而非插入位置 1,旧 tail 仍保持同一 ID。 assert_eq!( session .execute("tail.mounts().at(0).id", "app.main") @@ -664,6 +731,7 @@ fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_gra /// 先同窗重排再跨窗移动,确认调用 Page 源覆盖且保留 Page 业务身份。 /// 跨窗目标帧版本不匹配必须拒绝;合法迁移后旧 Mount 失效,新窗口页面表指向原 Page。 fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targets() { + // 示例同时提供多个 Page 和独立窗口,Page 源覆盖把迁移/重排调用次数写进模型计数。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/tab-placement.endor"), "app.main", @@ -672,7 +740,9 @@ fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targe ) .unwrap(); session.take_window_commands().unwrap(); + // Tab ID 与 Page 对象/Mount ID 不同,输入在标签路径用 mount 字段携带标签稳定身份。 let page = initial.frame.tabs[0].id; + // 同窗插入缝 3 测向后移动时的下标修正,而不是把页面重新创建到列表末尾。 let input = crate::events::Input { window: 1, session: 201, @@ -692,6 +762,7 @@ fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targe session .execute("held=list.mounts().at(0)", "app.main") .unwrap(); + // 同窗标签重排不应卸载内容,提前保留当前内容 Mount 以区分重排和跨窗迁移。 let mount = session.execute("held.id", "app.main").unwrap().value; session.dispatch(&input).unwrap(); assert_eq!( @@ -712,6 +783,7 @@ fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targe .value, session.execute("first", "app.main").unwrap().value ); + // 第二步改为跨窗口目标,两端都采用各自最新发布版本,不因用错源版本误判迁移失败。 let input = crate::events::Input { layout_version: session.documents[&1].frame.layout_version, tab_drop: Some(crate::events::TabDrop { @@ -721,12 +793,14 @@ fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targe }), ..input }; + // 只改坏目标版本,源端仍有效,验证目标校验不可被源窗口身份通过所替代。 let mut stale = input.clone(); stale.tab_drop.as_mut().unwrap().layout_version += 1; assert!(!session.accepts_tab_drop(&stale)); assert!(session.dispatch(&stale).is_err()); assert!(!session.is_faulted()); session.dispatch(&input).unwrap(); + // 合法跨窗会调用 moveToWindow 与 moveTab,业务 Page 身份不变,但旧出现应被卸载重建到目标窗。 assert_eq!( session.execute("calls.value", "app.main").unwrap().value, "3" @@ -755,6 +829,7 @@ fn tab_drop_preserves_page_identity_calls_source_methods_and_rejects_stale_targe /// Page.moveToWindow 覆盖只返回 self,模拟源程序拒绝跨窗移动。 /// 拖放不能强行改变窗口归属;随后 detach 使用独立入口,并排出带负桌面坐标的原生命令。 fn drop_respects_declined_source_moves_and_detach_queues_signed_desktop_position() { + // moveToWindow 源覆盖只返回 self 而不实际移动,宿主不能因为回调无错误就假定迁移已经发生。 let source = r#"class Declining: Page { calls: Object function moveToWindow(self: Declining, window: Window): Page { self.calls.increment() return self } @@ -766,8 +841,10 @@ fn drop_respects_declined_source_moves_and_detach_queues_signed_desktop_position target=Page() target.render(TextBlock("target")) target.open(other)"#; + // 初始命令先排空,最后只观察本次 detach 新产生的 MoveTo,而不是启动残留窗口命令。 let (mut session, initial) = ReplSession::load(source, "app.main", 1, 202).unwrap(); session.take_window_commands().unwrap(); + // 目标副窗真实存在,但源覆盖拒绝移动后不应继续去重排那个无关窗口的其他页。 let mut input = crate::events::Input { window: 1, session: 202, @@ -803,11 +880,13 @@ fn drop_respects_declined_source_moves_and_detach_queues_signed_desktop_position .value, "1" ); + // detach 是独立创建窗口入口,清掉 drop 元数据并添加负桌面坐标,覆盖多显示器布局。 input.kind = "tab-detach".into(); input.tab_drop = None; input.screen_position = Some([-400, 120]); input.layout_version = session.documents[&1].frame.layout_version; session.dispatch(&input).unwrap(); + // 命令只登记不实际操作 OS;新资源应为窗口 3,坐标仍为有符号 -400/120。 let commands = session.take_window_commands().unwrap(); assert!(commands.iter().any(|command| command.window == 3 && command.action == crate::window::Action::MoveTo { x: -400, y: 120 })); @@ -825,7 +904,9 @@ fn drop_respects_declined_source_moves_and_detach_queues_signed_desktop_position fn with_file_task_input(contents: &str, run: impl FnOnce(&std::path::Path)) { // 只需唯一递增值,不依赖它同步其他内存,Relaxed 足以保证不同测试取到不同序号。 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + // 每个调用取不同后缀,嵌套 old/new 文件测试也不覆盖同一路径。 let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // 临时目录加 PID/序号只隔离测试文件,不从源模型或当前工作目录推断文件位置。 let path = std::env::temp_dir().join(format!("tessera-text-task-{}-{id}.txt", std::process::id())); // 将文件删除绑定到栈生命周期,避免测试提前失败时留下临时文件。 @@ -833,9 +914,11 @@ fn with_file_task_input(contents: &str, run: impl FnOnce(&std::path::Path)) { impl Drop for Cleanup { // 清理失败不再次 panic,避免覆盖原测试故障或在异常展开时二次崩溃。 fn drop(&mut self) { + // 若 provider 尚在关闭句柄或文件已被移除,最佳努力清理失败不应遮住原断言失败。 let _ = std::fs::remove_file(&self.0); } } + // 守卫必须在 run 之前创建,闭包 panic 时也随栈展开删除本例临时输入。 let _cleanup = Cleanup(path.clone()); std::fs::write(&path, contents).unwrap(); run(&path); @@ -843,6 +926,7 @@ fn with_file_task_input(contents: &str, run: impl FnOnce(&std::path::Path)) { /// 生成 Loader 测试程序:readText 成功回调把 Task.result 写入未挂载的 target。 /// 路径先按 JSON 字符串转义再插入 Endor,tail 用于附加替换/取消等测试操作。 fn file_task_source(path: &std::path::Path, tail: &str) -> String { + // 使用 JSON 字符串转义 Windows/Unix 路径中的斜杠和引号,避免把路径内容拼成 Endor 语法。 let path = serde_json::to_string(&path.to_string_lossy()).unwrap(); format!( "class Loader {{\n target: TextBlock\n task: Object\n function apply(self: Loader): Unit {{ self.target.text = self.task.result() }}\n}}\ntarget = TextBlock(\"waiting\")\nloader = Loader(target,ui.state(0))\npage = Page()\npage.render(TextBlock(\"target stays hidden\"))\nloader.task = ui.readText(loader,\"read\",{path},loader.apply)\n{tail}" @@ -851,7 +935,9 @@ fn file_task_source(path: &std::path::Path, tail: &str) -> String { /// 直接从当前 VM globals 找到 loader.task,按托管类型的方法表调用 status。 /// 不通过 REPL execute 查询,避免状态观察自身引入一次输入、刷新或任务调度副作用。 fn file_task_state(session: &mut ReplSession) -> String { + // 直接查同一个持久 VM 的真实 globals,不执行一条额外 Console 输入改变 transcript/刷新轮次。 let globals = session.candidate.globals.unwrap(); + // loader 是测试程序保存任务的持有者,字典仍拥有它,这里只借身份定位 task 属性。 let Value::Object(loader) = session .candidate .vm @@ -861,9 +947,11 @@ fn file_task_state(session: &mut ReplSession) -> String { else { panic!("loader"); }; + // 即使 Task 已终态也可由 loader 保留;不能从 provider 弱索引推断其状态。 let Value::Object(task) = session.candidate.vm.get_attr(loader, "task").unwrap() else { panic!("Task"); }; + // 查询实际 Task 类型方法,保持与源 task.status() 相同的受检查方法入口。 let definition = session .candidate .vm @@ -871,6 +959,7 @@ fn file_task_state(session: &mut ReplSession) -> String { .type_of(task) .unwrap() .unwrap(); + // lookup_method 取得未绑定方法,所以下面 call 显式补一个 Task receiver。 let method = session .candidate .vm @@ -878,6 +967,7 @@ fn file_task_state(session: &mut ReplSession) -> String { .lookup_method(definition, "status") .unwrap() .unwrap(); + // 返回值必须是语言字符串,状态接口若变成 Nil/对象不能被测试静默格式化成看似合法状态。 let Value::Text(status) = session .candidate .vm @@ -892,8 +982,10 @@ fn file_task_state(session: &mut ReplSession) -> String { /// 短暂休眠让后台文件线程获得执行机会;取消/失败也算终态,超时则明确使测试失败。 fn complete_file_task(session: &mut ReplSession) { for _ in 0..1000 { + // 后台只填 mailbox,必须显式让 owning VM poll 才能执行回调或完成无回调 Task。 session.poll_tasks().unwrap(); if file_task_state(session) != "pending" { + // 不只 Completed,Cancelled/Failed 也结束等待,具体预期由各调用测试单独断言。 return; } std::thread::sleep(std::time::Duration::from_millis(1)); @@ -905,11 +997,15 @@ fn complete_file_task(session: &mut ReplSession) { /// 推进完成后仅新 UTF-8 内容写入 target;target 仍无 Mount,说明业务更新不依赖可见性。 fn file_task_updates_hidden_object_and_replacement_drops_old_delivery() { with_file_task_input("old", |old| { + // 两个真实文件内容不同,才可从 target.text 判断究竟哪一代请求获得交付。 with_file_task_input("new UTF-8 文本", |new| { + // 路径先转义再作为源字符串,中文文本用于覆盖 UTF-8 返回而非仅 ASCII 情形。 let path = serde_json::to_string(&new.to_string_lossy()).unwrap(); + // 保留旧 token 后同 owner/key 再读新文件,替换必须即时取消旧项而不靠旧结果晚到顺序碰巧正确。 let tail = format!( "old = loader.task\nloader.task = ui.readText(loader,\"read\",{path},loader.apply)" ); + // 源加载只创建任务,complete_file_task 才在后续轮询交付,target 从未被挂到页面。 let (mut session, _) = ReplSession::load(&file_task_source(old, &tail), "app.main", 282, 1).unwrap(); complete_file_task(&mut session); @@ -937,6 +1033,7 @@ fn file_task_updates_hidden_object_and_replacement_drops_old_delivery() { /// 二者都不能运行成功回调;后者需保留错误文本但不使共享 VM fault,目标文字仍为 waiting。 fn cancelled_file_task_never_runs_callback_and_missing_file_keeps_vm_live() { with_file_task_input("ignored", |path| { + // 在同一输入创建并 cancel,后台即使已经读完也不能越过失效租约执行成功回调。 let (mut cancelled, _) = ReplSession::load( &file_task_source(path, "loader.task.cancel()"), "app.main", @@ -950,7 +1047,9 @@ fn cancelled_file_task_never_runs_callback_and_missing_file_keeps_vm_live() { cancelled.execute("target.text", "app.main").unwrap().value, "\"waiting\"" ); + // 另一个未创建的后缀路径用于 provider 真实失败,不伪造任务 error 字段。 let missing = path.with_extension("missing"); + // 新独立会话区分 provider 错误与前一例主动取消状态,错误不应停整个 VM。 let (mut failed, _) = ReplSession::load(&file_task_source(&missing, ""), "app.main", 284, 1).unwrap(); complete_file_task(&mut failed); @@ -974,10 +1073,13 @@ fn cancelled_file_task_never_runs_callback_and_missing_file_keeps_vm_live() { /// 确认 result 的托管保留不依赖成功回调,且 error 为空字符串。 fn callback_free_file_task_retains_result_after_completion() { with_file_task_input("callback free", |path| { + // 同一文件接口省略 callback,仅靠保留的 Task.result 接收文字,检查 await 风格 API 的存值语义。 let path = serde_json::to_string(&path.to_string_lossy()).unwrap(); + // Holder 只保存 token,页面展示无关文本;成功结果不能依赖 target 可见或回调消费才存活。 let source = format!( "class Holder {{ task: Object }}\nloader = Holder(ui.state(0))\npage = Page()\npage.render(TextBlock(\"file\"))\nloader.task = ui.readText(ui,\"read\",{path})" ); + // 实际 VM 管理 Task 的拥有图,后台线程从不直接调用 Loader 或写源字段。 let (mut session, _) = ReplSession::load(&source, "app.main", 285, 1).unwrap(); complete_file_task(&mut session); assert_eq!(file_task_state(&mut session), "completed"); @@ -1002,6 +1104,7 @@ fn callback_free_file_task_retains_result_after_completion() { /// 以隐藏 Block 为 owner,用相同 key 替换定时任务。 /// 旧任务立即取消,新回调仅在 poll 后写入 2;另一个被显式取消的任务不能再改变状态。 fn owned_tasks_replace_cancel_and_update_unmounted_objects() { + // hidden Block 没有 Mount 仍可作业务 owner,两个动作故意写不同数值以观察同键替换胜出者。 let source = r#"class Action { state: Object value: Number @@ -1015,6 +1118,7 @@ fn owned_tasks_replace_cancel_and_update_unmounted_objects() { page.render(ui.textOf(state,"value")) old = ui.after(hidden,"load",0,first.apply) next = ui.after(hidden,"load",0,latest.apply)"#; + // 创建任务本身不交付,先看旧 status 和初始 0,再 poll 验证只有最新动作写 2。 let (mut session, _) = ReplSession::load(source, "app.main", 1, 190).unwrap(); assert_eq!( session.execute("old.status()", "app.main").unwrap().value, @@ -1040,6 +1144,7 @@ fn owned_tasks_replace_cancel_and_update_unmounted_objects() { ) .unwrap(); session.poll_tasks().unwrap(); + // 被主动 cancel 的第三任务不能覆盖此前完成结果,token.active 也必须投影为 false。 assert_eq!( session.execute("state.value", "app.main").unwrap().value, "2" @@ -1057,6 +1162,7 @@ fn owned_tasks_replace_cancel_and_update_unmounted_objects() { /// 并列创建 Page/Mount/Block/ui 作用域任务,再关闭并重开页面。 /// 页面与出现位置任务取消,业务对象和应用任务继续;跨窗迁移保留 Page 作用域但取消旧 Mount,关窗取消窗口任务。 fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { + // 相同 Action/共享 count 给不同 owner 类型建任务,计数差异直接揭示哪些作用域还获得交付资格。 let source = r#"class Action { state: Object function apply(self: Action): Unit { self.state.increment() } @@ -1066,6 +1172,7 @@ fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { leaf=TextBlock("owner") page=Page() page.render(leaf)"#; + // 先发布 leaf 的真实出现,再从源 mounts() 获取具体 owner,不能手写一个未挂载 Mount。 let (mut session, _) = ReplSession::load(source, "app.main", 1, 191).unwrap(); session .execute( @@ -1080,6 +1187,7 @@ fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { ) .unwrap(); for name in ["pageTask", "mountTask"] { + // close 后立刻 open 不应复活旧资格;页面与旧出现的任务都已在关闭边界被取消。 assert_eq!( session .execute(&format!("{name}.status()"), "app.main") @@ -1089,6 +1197,7 @@ fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { ); } session.poll_tasks().unwrap(); + // 只有 Block/ui 两个 owner 仍有效,count=2 同时排除错误地取消所有后台工作或全部继续的实现。 assert_eq!( session.execute("state.value", "app.main").unwrap().value, "2" @@ -1121,6 +1230,7 @@ fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { "app.main", ) .unwrap(); + // 窗口 owner 关闭即失效,即使源变量 other 还保留真实窗口对象也不能继续回调。 session.poll_tasks().unwrap(); assert_eq!( session @@ -1135,6 +1245,7 @@ fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { /// 只返回任务令牌而不保留临时 owner,检查弱作用域允许 owner 回收并取消任务。 /// 随后第一个回调失败应停止共享执行域,后续递增不发生;通过 VM 直接读值验证状态仍为 0。 fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() { + // orphan 只返回 Task,owner 无外部根;后半段先排失败回调再排递增,检查共享故障域是否停止后续工作。 let source = r#"class Action { state: Object function apply(self: Action): Unit { self.state.increment() } @@ -1149,6 +1260,7 @@ fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() page=Page() page.render(TextBlock("preserve")) token=orphan()"#; + // 加载后的回收边界应剪掉不可达 owner/作用域环,token 本身不反向强持有 owner。 let (mut session, _) = ReplSession::load(source, "app.main", 1, 192).unwrap(); assert_eq!( session.execute("token.active()", "app.main").unwrap().value, @@ -1162,7 +1274,9 @@ fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() session.execute("broken=ui.after(ui,\"fail\",0,action.fail)\nlater=ui.after(ui,\"later\",0,action.apply)","app.main").unwrap(); assert!(session.poll_tasks().is_err()); assert!(session.is_faulted()); + // 故障域已拒绝 execute,直接只读 VM 属性验证值仍为 0,而不为了观察又执行用户代码。 let globals = session.candidate.globals.unwrap(); + // 原共享状态对象仍由 globals 持有;读取它不创建新根或恢复后续被停用的任务。 let state = match session .candidate .vm @@ -1185,6 +1299,7 @@ fn orphan_task_scope_is_collectable_and_callback_failure_stops_remaining_work() /// 验证源函数、原生函数和绑定方法属于 Function,而类型构造器不属于 Function。 /// 错误参数在函数体副作用前拒绝,错误返回在返回契约检查时拒绝;重定义同名类也不能改变旧实例/方法身份。 fn source_annotations_classify_functions_and_reject_before_body() { + // 示例同时含源函数、宿主工厂和绑定方法,类型对象虽可构造但不是 Function,需分别测试。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/function-types.endor"), "app.main", @@ -1197,6 +1312,7 @@ fn source_annotations_classify_functions_and_reject_before_body() { "isInstance(ui.rect,Function)", "isInstance(actions.read,Function)", ] { + // 三种真实 callable 都应满足同一个 Function 名义契约,不用是否有某个同名属性做鸭子判断。 assert_eq!( session.execute(expression, "app.main").unwrap().value, "true" @@ -1215,6 +1331,7 @@ fn source_annotations_classify_functions_and_reject_before_body() { "app.main", ) .unwrap(); + // 参数错误必须在进入 checked 的 increment 之前拒绝,因此紧接着断言 state 仍为 0。 let error = session .execute("checked(\"wrong\")", "app.main") .err() @@ -1234,6 +1351,7 @@ fn source_annotations_classify_functions_and_reject_before_body() { session .execute("function wrong(): Number { return \"wrong\" }", "app.main") .unwrap(); + // 错误返回发生在函数体正常跑完之后的返回契约检查,消息应和参数错误有所区分。 let error = session.execute("wrong()", "app.main").err().unwrap(); assert!( error.to_string().contains("return expects Number"), @@ -1250,6 +1368,7 @@ fn source_annotations_classify_functions_and_reject_before_body() { "app.main", ) .unwrap(); + // 重定义名字只建立新 Type,不改变旧实例 actions 与此前 savedFunction 的真实身份/行为。 assert_eq!( session .execute("callNumber(savedFunction)", "app.main") @@ -1275,6 +1394,7 @@ fn source_annotations_classify_functions_and_reject_before_body() { /// 先由子窗口按钮回调制造运行时故障,再从宿主回收该窗口。 /// Mount 和页面根必须脱离,onUnmount 的 marker 不可递增;已停止 VM 不能因清理重新执行源代码。 fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() { + // marker 只在 onUnmount 增长,子窗 click 另触发非法 resize 故障,用两条独立状态观察纯宿主退役。 let source = r#"class WatchedPage: Page { marker: Object function onUnmount(self: WatchedPage, mount: Mount): Unit { self.marker.increment() } @@ -1292,11 +1412,14 @@ fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() { page=WatchedPage(marker) page.render(button) page.open(other)"#; + // 保存副窗发布帧和实际持久堆,故障后不重新加载一份“已经清理好”的模拟域。 let (mut session, initial) = ReplSession::load(source, "app.main", 1, 140).unwrap(); session .execute("held=button.mounts().at(0)", "app.main") .unwrap(); + // 故障后只能只读观察真实对象,不能经 execute 调用户函数来检查清理结果。 let globals = session.candidate.globals.unwrap(); + // lookup 仅从原 globals 借出对象身份,持有关系没有被测试添加新的长期根改变。 let lookup = |session: &ReplSession, name: &str| match session .candidate .vm @@ -1307,9 +1430,13 @@ fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() { Value::Object(object) => object, _ => panic!("object"), }; + // held 故意保留旧叶 Mount,退役必须使它 mounted=false,而不能仅把它从窗口显示列表删去。 let held = lookup(&session, "held"); + // 真实窗口资源用于检查 Page 根是否解除拥有关系,和文档副本是否删窗分别验证。 let window = lookup(&session, "other"); + // marker 保持零证明故障清理没有重新进入 onUnmount 源代码。 let marker = lookup(&session, "marker"); + // 用目标 payload 的实际几何 ID 构造输入,当前 session/副窗版本来自初始发布值。 let mount = session .candidate .vm @@ -1334,6 +1461,7 @@ fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() { .is_err() ); assert!(session.is_faulted()); + // 专用 retire_window 不调用源 Window.close/卸载钩子;正常生命周期与故障资源清理不能混为一路。 session.retire_window(2).unwrap(); assert!(!session.accepts_window(2)); assert!( @@ -1360,6 +1488,7 @@ fn stopped_window_teardown_detaches_mounts_without_running_source_hooks() { /// 加载多窗口示例,确认窗口共享业务计数但使用不重叠的 Mount。 /// 迁移保留 Page 身份、替换旧 Mount;关闭并清除全局引用后 Window 可回收,保留旧叶 Mount 也不能留住窗口上下文。 fn native_windows_share_business_state_move_pages_and_release_closed_contexts() { + // 真实多窗口示例在同一个 VM 共享 count,每窗只派生独立 Presentation/Mount,而非复制业务模型。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/windows.endor"), "app.main", @@ -1368,6 +1497,7 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() ) .unwrap(); assert_eq!(initial.windows.len(), 1); + // 全会话出现 ID 高水位应避免不同窗重号,防止输入凭窗口/出现值误指到另一展示。 let main_ids: std::collections::BTreeSet<_> = initial.frame.mounts.iter().map(|mount| mount.id).collect(); assert!( @@ -1378,11 +1508,13 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() .all(|mount| !main_ids.contains(&mount.id)) ); assert_eq!(initial.windows[&2].frame.session, initial.frame.session); + // 同一共享 count 更新应在两个窗的完整文档里同时看到新文字,而不是只刷新 current。 let changed = session .execute("count.increment()", "app.main") .unwrap() .document .unwrap(); + // 仅过滤实际文字命令判断状态投影,不从局部 ui.stats 或源 state 对象推断画面已同步。 let has_one = |frame: &crate::document::Frame| { frame .commands @@ -1393,7 +1525,9 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() session .execute("oldRight = rightButton.mounts().at(0)", "app.main") .unwrap(); + // 记录 Page 的 Console 对象身份表示,迁移后必须仍为同一个业务页。 let page = session.execute("right", "app.main").unwrap().value; + // 正常源方法迁移到主窗,旧副窗 Page 表清空,新 Mount 应在新上下文重建。 let moved = session .execute("right.moveToWindow(ui.window())", "app.main") .unwrap() @@ -1409,7 +1543,9 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() "1" ); assert!(session.execute("oldRight.bounds()", "app.main").is_err()); + // 从真实全局取得副窗身份,创建弱观察句柄后删除原全局强边以检验资源可回收性。 let globals = session.candidate.globals.unwrap(); + // 读取不额外 retain Window,避免测试自己保活对象而掩盖/制造 GC 结论。 let Value::Object(window) = session .candidate .vm @@ -1419,7 +1555,9 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() else { panic!("Window"); }; + // 弱身份只用于回收后探测,不能因为闭窗时仍有 Rust ObjectRef 数值就延长寿命。 let weak = session.candidate.vm.downgrade(window).unwrap(); + // 关闭后的最终完整文档应删除副窗,向已关闭资源 open 也必须被拒绝。 let closed = session .execute("other.close()", "app.main") .unwrap() @@ -1434,6 +1572,7 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() .unwrap(); session.execute("0", "app.main").unwrap(); session.candidate.vm.collect_cycles(); + // oldRight 仍可达不应通过出现的旧上下文反向保留整个 Window/desktop/Page 注册表。 assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); // 外部仍持有旧叶 Mount 时,关闭窗口的上下文也必须可释放;下一次 detach 创建新的资源编号。 let detached = session @@ -1461,6 +1600,7 @@ fn native_windows_share_business_state_move_pages_and_release_closed_contexts() /// 一次输入先关闭子窗口,再用非法 resize 使后续语句失败。 /// 先前关闭副作用仍存在,下一次成功输入必须清除残留展示;Page 可重新打开到主窗口。 fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() { + // 从真实多窗堆开始,输入前一条 close 成功、后一条 resize 失败,验证 REPL 非事务语义的恢复。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/windows.endor"), "app.main", @@ -1474,6 +1614,7 @@ fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() .is_err() ); assert!(!session.accepts_window(2)); + // 下一次成功查询触发必要修复/发布,关闭副作用不回滚,旧窗也不能在缓存文档中残留。 let repaired = session.execute("right.window()", "app.main").unwrap(); assert_eq!(repaired.value, "nil"); assert!(repaired.document.unwrap().windows.is_empty()); @@ -1487,6 +1628,7 @@ fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() session .execute("right.open(ui.window())", "app.main") .unwrap(); + // 已清理 Page 仍可按业务身份重开到主窗口,不能把正常关闭误当整个 Page 值被永久销毁。 assert_eq!( session .execute("right.window().id()", "app.main") @@ -1500,16 +1642,19 @@ fn failed_input_after_window_close_cannot_leave_owned_mounts_in_closed_context() /// 依次通过事件激活页面、修改 title、关闭页面、清空及重开。 /// 检查标签反映真实 Page 方法,稳定业务 ID 与递增展示 ID 各自正确,旧版本事件被桥接层拒绝。 fn tab_projection_uses_page_methods_and_rejects_old_presentations() { + // 页面标题/打开顺序来自真实 Window 注册表,最后一次 render 使 second 初始选中。 let source = r#"first = Page() first.title = "First" first.render(TextBlock("one")) second = Page() second.title = "Second" second.render(TextBlock("two"))"#; + // 保存初始 Tab ID 和版本供后续重命名/重开/过期输入比较,不按标题作为身份。 let (mut session, initial) = ReplSession::load(source, "app.main", 120, 1).unwrap(); assert_eq!(initial.frame.tabs.len(), 2); assert_eq!(initial.frame.tabs[0].title, "First"); assert!(initial.frame.tabs[1].active); + // 标签分派用 mount 字段承载 Tab ID,kind 明确为 tab-activate,不能误入内容几何命中。 let input = crate::events::Input { window: crate::window::MAIN_WINDOW, session: initial.frame.session, @@ -1522,6 +1667,7 @@ fn tab_projection_uses_page_methods_and_rejects_old_presentations() { tab_drop: None, screen_position: None, }; + // 真实页面 activate 切换选中投影并推进版本,随后同一输入应由桥接层拒绝。 let selected = session.dispatch(&input).unwrap().document.unwrap(); assert!(selected.frame.tabs[0].active); assert!(selected.frame.layout_version > initial.frame.layout_version); @@ -1532,6 +1678,7 @@ fn tab_projection_uses_page_methods_and_rejects_old_presentations() { .dispatch(&mut session.candidate.vm, &input) .is_err() ); + // 只改 title 不改 Page 身份,标签文字更新但第一页原稳定 Tab ID 保持。 let renamed = session .execute(r#"second.title = "Renamed""#, "app.main") .unwrap() @@ -1539,6 +1686,7 @@ fn tab_projection_uses_page_methods_and_rejects_old_presentations() { .unwrap(); assert_eq!(renamed.frame.tabs[1].title, "Renamed"); assert_eq!(renamed.frame.tabs[0].id, initial.frame.tabs[0].id); + // 按最新版本关闭 second,注册关系和焦点选择应同步到第一页,不使用静态 sidebar 假数据。 let closed = session .dispatch(&crate::events::Input { window: crate::window::MAIN_WINDOW, @@ -1564,6 +1712,7 @@ fn tab_projection_uses_page_methods_and_rejects_old_presentations() { .value, "nil" ); + // 最后一页关闭也必须发布版本更高的空标签帧,不能把 Some(None) 当作无刷新工作。 let empty = session .execute("first.close()", "app.main") .unwrap() @@ -1571,6 +1720,7 @@ fn tab_projection_uses_page_methods_and_rejects_old_presentations() { .unwrap(); assert!(empty.frame.tabs.is_empty()); assert!(empty.frame.layout_version > closed.frame.layout_version); + // 相同 Page 重开分配新展示 Tab ID,历史旧输入不能因业务对象相同再次变成有效。 let reopened = session .execute("second.open()", "app.main") .unwrap() @@ -1584,6 +1734,7 @@ fn tab_projection_uses_page_methods_and_rejects_old_presentations() { /// 同次成功输入的两次 resize 合并为最后尺寸,并保留 title/focus 命令。 /// 命令只消费一次;失败输入之前积累的 resize 也必须丢弃,不能送到原生窗口。 fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_requests() { + // 空持久会话已有托管主 Window,命令登记不需要先 render 页面,也不在测试实际打开 OS 窗口。 let mut session = ReplSession::new(111).unwrap(); session .execute( @@ -1595,6 +1746,7 @@ fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_reque "app.main", ) .unwrap(); + // 一次取完成功输入命令,两个 Resize 合并为最后一个,title/focus 保留独立动作顺序。 let requests = session.take_window_commands().unwrap(); assert_eq!(requests.len(), 3); assert!(matches!( @@ -1606,6 +1758,7 @@ fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_reque )); assert!(matches!(&requests[1].action,crate::window::Action::SetTitle(title) if title=="Endor")); assert!(session.take_window_commands().unwrap().is_empty()); + // 输入先排合法 resize 再遇非法尺寸,待发布 outbox 整批丢弃,不能泄漏前半条原生命令。 assert!( session .execute("window.resize(1400,900)\nwindow.resize(0,600)", "app.main") @@ -1620,11 +1773,13 @@ fn managed_window_requests_are_coalesced_and_failed_input_discards_pending_reque /// 检查 Window.pages 真实持有打开页面,close 解除归属,重新 open 追加到当前顺序末尾。 /// 页面窗口关系保持双向一致,向非 Window 对象打开则明确失败。 fn window_page_roots_follow_open_close_and_creation_order() { + // 创建顺序与 render/open 登记关系明确分开,Page 存活不代表永远属于窗口页面表。 let source = r#"first = Page() second = Page() first.render(TextBlock("first")) second.render(TextBlock("second")) window = ui.window()"#; + // 所有查询保留同一真实 globals,close/open 后的窗口关系必须从资源模型更新而来。 let (mut session, _) = ReplSession::load(source, "app.main", 112, 1).unwrap(); assert_eq!( session @@ -1633,6 +1788,7 @@ fn window_page_roots_follow_open_close_and_creation_order() { .value, "2" ); + // 两页应指同一 Window;保存显示身份进行比较,不用标题或数组顺序猜归属。 let first = session.execute("first.window()", "app.main").unwrap().value; assert_eq!( first, @@ -1654,6 +1810,7 @@ fn window_page_roots_follow_open_close_and_creation_order() { "1" ); session.execute("first.open(window)", "app.main").unwrap(); + // 重开追加到注册表末尾,second 因此仍在 at(0),而不是按最初创建顺序强行插回 first。 assert_eq!( session .execute("window.pages().count", "app.main") @@ -1675,6 +1832,7 @@ fn window_page_roots_follow_open_close_and_creation_order() { /// 为 Page 建立弱句柄,close 后删除唯一全局绑定并运行循环回收。 /// 弱升级应失败,证明窗口的页面顺序记录不会把已关闭页面意外作为强根保留。 fn closed_page_is_not_kept_alive_by_window_page_order() { + // 简单页面保证唯一显式根就是全局 page;关闭+删绑定后窗口的顺序映射不得成为隐藏强根。 let (mut session, _) = ReplSession::load( "page = Page()\npage.render(TextBlock(\"page\"))", "app.main", @@ -1682,7 +1840,9 @@ fn closed_page_is_not_kept_alive_by_window_page_order() { 1, ) .unwrap(); + // 直接删 globals 避免用“page=nil”产生别名替换语义干扰这个生命周期断言。 let globals = session.candidate.globals.unwrap(); + // 借出身份后只建弱观察,不在测试手工 retain 新拥有根。 let Value::Object(page) = session .candidate .vm @@ -1692,6 +1852,7 @@ fn closed_page_is_not_kept_alive_by_window_page_order() { else { panic!("Page") }; + // close 解除 Window→Page 边,删除全局再收环,弱升级失败才证明没有意外保留。 let weak = session.candidate.vm.downgrade(page).unwrap(); session.execute("page.close()", "app.main").unwrap(); session @@ -1707,6 +1868,7 @@ fn closed_page_is_not_kept_alive_by_window_page_order() { /// 源 measure 内尝试 resize 原生窗口,加载应报告 read-only。 /// 窗口命令也属于副作用,不能因其延后执行而绕过布局只读保护。 fn rendering_cannot_enqueue_native_window_side_effects() { + // measure 返回值本身合法,唯一违规是排入延后 Resize,证明 outbox 也是受只读保护的副作用。 let source = r##"class BadRect: RectBlock { function measure(self: BadRect, mount: Mount, constraints: Constraints): MeasureResult { ui.window().resize(900,600) @@ -1715,6 +1877,7 @@ fn rendering_cannot_enqueue_native_window_side_effects() { } page = Page() page.render(BadRect(80,20,0,"#DDEEFF"))"##; + // 真实加载会运行 source measure,在候选发布前发现错误,不能等 OS 执行命令时才拒绝。 let error = ReplSession::load(source, "app.main", 114, 1).err().unwrap(); assert!(error.to_string().contains("read-only")); } @@ -1722,6 +1885,7 @@ fn rendering_cannot_enqueue_native_window_side_effects() { /// 直接调用 ui.stats 取得某个数字计数,读取后释放返回的 stats 托管对象。 /// 辅助函数避免经 REPL 查询统计带来额外输入/刷新,使缓存命中与测量次数的断言可比较。 fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 { + // 布局/缓存回归共用此直接 VM 助手,绕开 Console transcript 和一次输入引起的额外刷新。 let Value::Object(function) = session .candidate .vm @@ -1730,9 +1894,11 @@ fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 { else { panic!("stats function") }; + // 返回 stats 对象有新的拥有根,读完数字字段必须显式释放,不能让观察指标自身泄漏。 let Value::Object(stats) = session.candidate.vm.call(function, &[]).unwrap() else { panic!("stats") }; + // 要求确为 Number,不能将缺失字段/错误 API 值格式化成零而让缓存断言误通过。 let Value::Number(value) = session.candidate.vm.get_attr(stats, name).unwrap() else { panic!("stat number") }; @@ -1742,7 +1908,9 @@ fn source_measure_stat(session: &mut ReplSession, name: &str) -> i64 { /// 从 globals 中按名称找到业务 Block,再在给定完整文档中按 block_id 找对应 Mount。 /// 构造携带当前会话与该文档版本的 click,返回派发后发布的新文档,供后续动作沿新版本继续。 fn click_named(session: &mut ReplSession, document: &RenderDocument, name: &str) -> RenderDocument { + // 缓存示例测试通过此助手模拟按钮点击;globals 是当前 VM 的业务对象名字表。 let globals = session.candidate.globals.unwrap(); + // 按调用方给出的名字取得 Block 引用;缺失或非对象表示测试夹具已与按钮约定不符。 let Value::Object(block) = session .candidate .vm @@ -1752,12 +1920,15 @@ fn click_named(session: &mut ReplSession, document: &RenderDocument, name: &str) else { panic!("Block") }; + // 将业务 Block 对应到当前文档的出现位置;本助手适用于这些按钮只有一个 Mount 的夹具。 let mount = document .frame .mounts .iter() .find(|mount| mount.block_id == block.to_string()) .unwrap(); + // 使用当前 session/layout_version 派发,避免复用旧文档造成过期事件被丢弃。 + // 不提供坐标,直接验证指定按钮的 click 回调;要求返回新文档供下一次点击使用。 session .dispatch(&crate::events::Input { window: crate::window::MAIN_WINDOW, @@ -1780,6 +1951,7 @@ fn click_named(session: &mut ReplSession, document: &RenderDocument, name: &str) /// 同一个源 paint 对象出现在两个窗口,先执行与显示无关的表达式。 /// 无关输入应不产生文档;修改 paint 实际读取的 tint 后,两个窗口都必须发布新颜色。 fn shared_intrinsic_desktop_does_not_repaint_other_windows_on_repl_input() { + // 同一 Painted 实例挂到两个窗口,paint 读取共享 tint,建立跨窗口的字段依赖。 let (mut session, _) = ReplSession::load( r##" class Painted: RectBlock { @@ -1803,6 +1975,7 @@ page2.moveToWindow(window2) 1, ) .unwrap(); + // 执行不读写业务对象的表达式,检查 worker 不为无关 REPL 输入重新发布桌面。 assert!( session .execute("42", "app.main") @@ -1810,14 +1983,17 @@ page2.moveToWindow(window2) .document .is_none() ); + // 修改 paint 实际读取的字段;返回的桌面文档应同时包含两处更新。 let changed = session .execute("item.tint = \"#3868D9\"", "app.main") .unwrap() .document .unwrap(); + // 主窗口的矩形命令必须采用新颜色,不能只检查有文档发布。 assert!(changed.frame.commands.iter().any(|command| matches!( command, RenderCommand::RoundedRect { color, .. } if color == "#3868D9" ))); + // 再核对第二窗口,捕获只刷新当前窗口而遗漏共享对象另一出现位置的问题。 assert!( changed.windows[&2] .frame @@ -1833,6 +2009,7 @@ page2.moveToWindow(window2) /// 加载缓存示例并用真实 click 分别改变颜色与尺寸。 /// 纯颜色更新复用全部测量,尺寸依赖变化只让受影响的结果失效;按布局 pass 数核对命中而非写死单次计数。 fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes() { + // 以固定视口加载真实缓存示例;CachedRect 和 ViewportBand 分别提供可缓存的源测量。 let (mut session, initial) = ReplSession::load_with_viewport( include_str!("../../../examples/layout-cache.endor"), "app.main", @@ -1844,18 +2021,24 @@ fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes }), ) .unwrap(); + // 初始两项源测量均需执行;后面的统计由每次更新产生,检查更新本身的复用情况。 assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 2); + // 调用上面的事件助手触发颜色按钮;事件路径与正常 UI 输入相同。 let painted = click_named(&mut session, &initial, "blue"); + // 核对颜色已变化,排除因按钮未生效而导致的“没有重新测量”。 assert!(painted.frame.commands.iter().any( |command| matches!(command,RenderCommand::RoundedRect {color,..} if color=="#526AC7") )); + // 同一次更新可能经历多个布局 pass;每个 pass 都应命中两项测量,实际重测数应为零。 let passes = source_measure_stat(&mut session, "layoutPasses"); assert_eq!( source_measure_stat(&mut session, "sourceMeasureHits"), 2 * passes ); assert_eq!(source_measure_stat(&mut session, "measured"), 0); + // 接着沿新文档点击尺寸按钮,改动 CachedRect.measure 读取的宽度模型。 let resized = click_named(&mut session, &painted, "expand"); + // 检查依赖更新已反映为 260×60 的矩形几何。 assert!( resized .frame @@ -1863,6 +2046,7 @@ fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes .iter() .any(|mount| mount.width == 260.0 && mount.height == 60.0) ); + // 此次只有一项测量失效一次;其余 pass/对象仍命中,因此总命中数为 2×pass−1。 let passes = source_measure_stat(&mut session, "layoutPasses"); assert_eq!( source_measure_stat(&mut session, "sourceMeasureHits"), @@ -1875,6 +2059,7 @@ fn source_measure_cache_reuses_independent_results_and_invalidates_model_changes /// 新建尚未 render 的 Console VM,执行普通表达式和 resize。 /// 两者都不应产生发布文档,避免把 reset 后保留的画面替换成空白。 fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() { + // 构造未执行 page.render 的新会话,用来复现 Console reset 后仍保留旧画面的情形。 let mut session = ReplSession::new_with_viewport( 104, Some(crate::layout::Size { @@ -1883,6 +2068,7 @@ fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() { }), ) .unwrap(); + // 普通表达式没有新页面,返回值不能携带空白 RenderDocument。 assert!( session .execute("42", "app.main") @@ -1890,6 +2076,7 @@ fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() { .document .is_none() ); + // 原生 resize 更新视口状态,但没有页面可发布时仍应返回 None。 assert!( session .resize(Some(crate::layout::Size { @@ -1899,6 +2086,7 @@ fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() { .unwrap() .is_none() ); + // 读取 VM 暴露的视口宽度,证明 resize 已生效而非被直接忽略;查询也不产生文档。 let result = session.execute("ui.viewport().width", "app.main").unwrap(); assert_eq!(result.value, "360"); assert!(result.document.is_none()); @@ -1908,6 +2096,7 @@ fn viewport_updates_in_a_fresh_console_do_not_replace_the_preserved_page() { /// 源 measure 返回先前已创建的 MeasureResult,不在调用期间新建结果。 /// 直接改其 width 后刷新必须失效并更新相同 Mount,证明返回值解码读取也进入依赖;再次刷新保持稳定。 fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { + // measure 直接返回全局已有结果;用此夹具验证解码结果字段时也记录依赖。 let source = r##"class HeldRect: RectBlock { result: MeasureResult function measure(self: HeldRect, mount: Mount, constraints: Constraints): MeasureResult { return self.result } @@ -1916,7 +2105,9 @@ fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { rect = HeldRect(80,20,0,"#DDEEFF",result) page = Page() page.render(rect)"##; + // 首次发布建立 width=80 的测量缓存,随后绕过 REPL 直接写堆字段。 let (mut session, initial) = ReplSession::load(source, "app.main", 103, 1).unwrap(); + // 记住原出现位置 ID;宽度变更必须更新该 Mount,而不是新建另一个来掩盖缓存问题。 let id = initial .frame .mounts @@ -1924,7 +2115,9 @@ fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { .find(|mount| mount.width == 80.0) .unwrap() .id; + // 从业务全局表提取 result 对象,与 measure 返回的引用保持一致。 let globals = session.candidate.globals.unwrap(); + // 要求夹具中的 result 确实是 VM 对象,便于通过 set_attr 触发写入观察。 let Value::Object(result) = session .candidate .vm @@ -1934,11 +2127,13 @@ fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { else { panic!("result") }; + // 仅改已有返回对象的 width;源函数本体未读取该字段,依赖应来自返回值解码。 session .candidate .vm .set_attr(result, "width", Value::Number(140.into())) .unwrap(); + // 主动刷新消费字段变化,再检查同一 Mount 的尺寸及恰好一次缓存失效。 let next = session.refresh_document().unwrap().unwrap(); assert!( next.frame @@ -1947,6 +2142,7 @@ fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { .any(|mount| mount.id == id && mount.width == 140.0) ); assert_eq!(source_measure_stat(&mut session, "sourceMeasureMisses"), 1); + // 再次刷新没有新写入,应保持缓存和已发布文档稳定。 assert!(session.refresh_document().unwrap().is_none()); } @@ -1954,6 +2150,7 @@ fn source_measure_cache_tracks_fields_of_an_existing_returned_result() { /// 在有限窗口约束下加载缓存示例,再缩窄真实可用视口。 /// 原 Mount ID 不变,内容宽度按根 padding 改变,且输入计数仍为 0;重复尺寸不发布,NaN 尺寸失败但不停止 VM。 fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_source_input() { + // 固定初始视口,让示例的 ViewportBand 建立对 ui.viewport().width 的测量依赖。 let (mut session, initial) = ReplSession::load_with_viewport( include_str!("../../../examples/layout-cache.endor"), "app.main", @@ -1973,7 +2170,9 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour .iter() .any(|mount| mount.width == 752.0 && mount.height == 24.0) ); + // 保存完整 Mount 顺序和身份,后续检查 resize 只更新几何而不重建出现位置。 let ids: Vec<_> = initial.frame.mounts.iter().map(|mount| mount.id).collect(); + // 模拟原生窗口缩小;该入口应更新布局约束,不执行一条新的 Endor 用户输入。 let document = session .resize(Some(crate::layout::Size { width: 360.0, @@ -1981,6 +2180,7 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour })) .unwrap() .unwrap(); + // 逐项比较原 Mount ID,捕获 resize 导致生命周期重建或排序改变的问题。 assert_eq!( ids, document @@ -1990,6 +2190,7 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour .map(|mount| mount.id) .collect::>() ); + // 新宽度 360 扣除左右各 24 的 padding,应发布 312 的内容宽度。 assert!( document .frame @@ -1997,6 +2198,7 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour .iter() .any(|mount| mount.width == 312.0 && mount.height == 24.0) ); + // resize 不计作 REPL 输入;相同视口重复通知也不应再发布文档。 assert_eq!(session.inputs, 0); assert!( session @@ -2007,6 +2209,7 @@ fn viewport_resize_changes_constraints_and_preserves_mount_identity_without_sour .unwrap() .is_none() ); + // 非法 NaN 在视口边界被拒绝;这是参数错误,不应把仍可用的 VM 标成 faulted。 assert!( session .resize(Some(crate::layout::Size { @@ -2024,7 +2227,9 @@ const SOURCE_LAYOUT: &str = include_str!("../../../examples/source-layout.endor" /// 检查首次 source measure/arrange 已可读取结构关系与子测量结果,产出预期位置。 /// 直接改 Metrics.width 后通过依赖刷新同一 Mount,随后无变化刷新不再发布。 fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependencies() { + // 复用 source-layout 示例,经加载入口执行首次测量、排列和发布。 let (mut session, initial) = ReplSession::load(SOURCE_LAYOUT, "app.main", 91, 1).unwrap(); + // 从已发布几何定位目标子矩形,检查父关系和子测量在首轮布局时已经可用。 let rect = initial .frame .mounts @@ -2032,9 +2237,13 @@ fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependenc .find(|mount| mount.width == 200.0 && mount.height == 60.0) .unwrap(); assert_eq!((rect.x, rect.y), (44.0, 24.0)); + // 保留出现位置身份,后续模型写入应只改变同一位置的宽度。 let id = rect.id; + // 文字的 y 坐标同时验证排列对子测量高度与间距的使用。 assert!(initial.frame.commands.iter().any(|command| matches!(command,RenderCommand::Text { value,y,.. } if value.starts_with("尺寸来自") && *y == 100.0))); + // 取出示例的 Metrics 模型,直接写入源测量读过的字段。 let globals = session.candidate.globals.unwrap(); + // 夹具必须提供 Metrics 对象;类型不符应立即失败而非跳过依赖测试。 let Value::Object(metrics) = session .candidate .vm @@ -2044,12 +2253,15 @@ fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependenc else { panic!("Metrics") }; + // 通过 VM 字段写入通知依赖系统;没有重新执行整段页面源码。 session .candidate .vm .set_attr(metrics, "width", Value::Number(260.into())) .unwrap(); + // 刷新应重算相关几何并发布新文档。 let next = session.refresh_document().unwrap().unwrap(); + // 按旧 ID 找回同一矩形,核对位置仍为 44、宽度更新为 260。 let rect = next .frame .mounts @@ -2057,6 +2269,7 @@ fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependenc .find(|mount| mount.id == id) .unwrap(); assert_eq!((rect.x, rect.width), (44.0, 260.0)); + // 消费完变化后再次刷新应无输出,确认布局已收敛。 assert!(session.refresh_document().unwrap().is_none()); } @@ -2064,14 +2277,18 @@ fn source_layout_uses_initial_mount_links_child_measurements_and_model_dependenc /// 把同一 rect 放入具有不同 Metrics 的两个父容器。 /// 发布宽度必须分别为 200/140,不能把共享 Block 的一份测量结果误用于两个出现位置。 fn shared_block_source_measurements_belong_to_each_occurrence() { + // 保留原父容器,再创建另一份 Metrics;两个父容器共享同一 rect 业务对象。 let source = SOURCE_LAYOUT.replace( "page.render(root)", r#"otherMetrics = Metrics(140, 12) other = OffsetColumn(24, 16, rect, otherMetrics) page.render(Row(0, 0, root, other))"#, ); + // 在同一页面发布两个出现位置,使错误的按 Block 缓存策略可以被观察到。 let (mut session, initial) = ReplSession::load(&source, "app.main", 92, 1).unwrap(); + // 从全局表取得共享 rect 的业务身份,避免按几何误匹配父容器。 let globals = session.candidate.globals.unwrap(); + // 要求共享对象存在,后续用其 ID 同时筛选两个 Mount。 let Value::Object(rect) = session .candidate .vm @@ -2081,6 +2298,7 @@ fn shared_block_source_measurements_belong_to_each_occurrence() { else { panic!("Rect") }; + // 保留文档出现顺序,收集同一 Block 在不同父约束/模型上下文中的测量宽度。 let widths: Vec<_> = initial .frame .mounts @@ -2088,6 +2306,7 @@ fn shared_block_source_measurements_belong_to_each_occurrence() { .filter(|mount| mount.block_id == rect.to_string()) .map(|mount| mount.width) .collect(); + // 两处分别采用各自 Metrics 的 200/140,后续空刷新确认没有持续失效。 assert_eq!(widths, vec![200.0, 140.0]); assert!(session.refresh_document().unwrap().is_none()); } @@ -2096,6 +2315,7 @@ fn shared_block_source_measurements_belong_to_each_occurrence() { /// measure 读取宽度 80,onMount 随后把该依赖写成 140。 /// 首次发布必须已经重测为 140,不能把回调前的旧几何暴露出去,且后续刷新应稳定。 fn layout_dependencies_changed_by_mount_hook_are_measured_again_before_publication() { + // 首次 measure 读取 80,首次 onMount 再改为 140,构造发布前依赖失效的时序。 let source = r##"class Metrics { width: Number } class InitialRect: RectBlock { metrics: Metrics @@ -2108,7 +2328,9 @@ fn layout_dependencies_changed_by_mount_hook_are_measured_again_before_publicati rect = InitialRect(80,20,0,"#DDEEFF",metrics) page = Page() page.render(rect)"##; + // load 完成生命周期与布局收敛后才返回文档,调用方不应见到中间状态。 let (mut session, document) = ReplSession::load(source, "app.main", 93, 1).unwrap(); + // 检查第一份可见文档已经采用回调后的宽度;再刷新不得产生修正帧。 assert!( document .frame @@ -2123,6 +2345,7 @@ fn layout_dependencies_changed_by_mount_hook_are_measured_again_before_publicati /// 逐项修改示例,覆盖测量期提前读 bounds、只读范围业务写入、错误返回类型和伪造 child slot。 /// 每个非法程序都要在发布前给出对应诊断,不让不完整或不属于当前容器的排列通过。 fn source_layout_rejects_premature_geometry_writes_wrong_types_and_foreign_slots() { + // 每次仅替换一个合法源码片段,分别验证测量阶段、写入权限、返回契约与子槽归属边界。 for (before, after, message) in [ ( "return MeasureResult(parent.block.metrics.width, 60)", @@ -2145,7 +2368,9 @@ fn source_layout_rejects_premature_geometry_writes_wrong_types_and_foreign_slots "foreign child slot", ), ] { + // 从完整合法示例派生独立非法程序,避免前一错误留下的状态干扰下一用例。 let source = SOURCE_LAYOUT.replace(before, after); + // 在加载并首次布局时就应拒绝;诊断必须对应当前破坏的协议,不能因其他错误偶然失败。 let error = ReplSession::load(&source, "app.main", 94, 1).err().unwrap(); assert!(error.to_string().contains(message), "{error}"); } @@ -2155,12 +2380,14 @@ fn source_layout_rejects_premature_geometry_writes_wrong_types_and_foreign_slots /// 表驱动检查圆形内点击、圆外但底层可点、按下释放命中不同形状,以及页面外点击。 /// 实际提交仍指向圆 Mount,worker 必须重新按源 hitTest 与 press_point 复核并选择合法目标。 fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() { + // 四组坐标分别覆盖圆内、圆外背景、跨目标释放和全页面外;两个计数表示最终回调归属。 for (press, release, expected_hits, expected_background) in [ ([64.0, 64.0], [64.0, 64.0], "1", "0"), ([144.0, 64.0], [144.0, 64.0], "0", "1"), ([64.0, 64.0], [144.0, 64.0], "0", "0"), ([9999.0, 9999.0], [9999.0, 9999.0], "0", "0"), ] { + // 每组重新加载圆形命中示例,保证计数从零开始、文档版本与事件相符。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/source-hit.endor"), "app.main", @@ -2168,12 +2395,14 @@ fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() { 1, ) .unwrap(); + // 选中可交互的圆 Mount 作为客户端声明目标;宿主还必须用源 hitTest 复核真实坐标。 let circle = initial .frame .mounts .iter() .find(|mount| mount.interactive && mount.width == 160.0) .unwrap(); + // 在同一事件中携带按下与释放位置;即使释放落到背景,跨目标点击也不能误触发。 session .dispatch(&crate::events::Input { window: crate::window::MAIN_WINDOW, @@ -2188,6 +2417,7 @@ fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() { screen_position: None, }) .unwrap(); + // 读取圆的业务计数验证前景回调,再检查背景计数验证穿透或拒绝行为。 assert_eq!( session.execute("hits.value", "app.main").unwrap().value, expected_hits @@ -2206,6 +2436,7 @@ fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() { /// 把 hitTest 改为返回 Number 或试图递增业务计数。 /// 派发应报告 Bool 契约/只读错误并停止运行时,但业务写入与 click 回调均未发生,hits 保持 0。 fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { + // 分别让 hitTest 返回错误类型或试图改业务状态,验证两种错误都先于 click 回调被拦截。 for (body, message) in [ ("return 1", "hitTest return expects Bool"), ( @@ -2213,15 +2444,19 @@ fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { "read-only", ), ] { + // 只替换 hitTest 函数体,保留页面、监听器和命中区域这些有效前提。 let source = include_str!("../../../examples/source-hit.endor") .replace("return point.inCircle(40, 40, 40)", body); + // 加载时无需执行命中函数,预期错误应在随后输入派发期间出现。 let (mut session, initial) = ReplSession::load(&source, "app.main", 82, 1).unwrap(); + // 定位圆的出现位置,用有效版本和圆内坐标确保走到待验证的源命中协议。 let circle = initial .frame .mounts .iter() .find(|mount| mount.interactive && mount.width == 160.0) .unwrap(); + // 取派发错误而非发布结果;任何业务回调执行都发生得太晚,不能挽回只读约束破坏。 let error = session .dispatch(&crate::events::Input { window: crate::window::MAIN_WINDOW, @@ -2237,9 +2472,12 @@ fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { }) .err() .unwrap(); + // 检查错误原因和运行时停止状态,防止将静默忽略错误算作通过。 assert!(error.to_string().contains(message), "{error}"); assert!(session.is_faulted()); + // 会话 faulted 后直接读取 VM,避免 execute 的停止检查遮蔽真实业务值。 let globals = session.candidate.globals.unwrap(); + // 取原 hits 状态对象,核对非法写入与 click 回调均未把零改为一。 let Value::Object(hits) = session .candidate .vm @@ -2249,6 +2487,7 @@ fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { else { panic!("state") }; + // 业务状态必须仍为零,证明写入在发生前被拒绝。 assert_eq!( session.candidate.vm.get_attr(hits, "value").unwrap(), Value::Number(0.into()) @@ -2260,14 +2499,18 @@ fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() { /// 普通 RectBlock 通过 click 订阅变为可交互;断开令牌后新文档应移除所有交互标记。 /// 检验命中资格依赖实际监听器状态,不只由构造类型决定。 fn ordinary_block_subscription_updates_interactivity_after_disconnect() { + // 普通矩形通过连接令牌订阅 click,初始命中资格来自监听器而非专用 Button 类型。 let source = r##"hits = ui.state(0) rect = RectBlock(80, 40, 0, "#DDEEFF") token = rect.on("click", hits.increment) page = Page() page.render(rect)"##; + // 加载时建立连接并发布,先确认至少一个 Mount 被标记为可交互。 let (mut session, initial) = ReplSession::load(source, "app.main", 83, 1).unwrap(); assert!(initial.frame.mounts.iter().any(|mount| mount.interactive)); + // 从源语言断开唯一监听器;连接变化应触发新文档并清除交互标记。 let result = session.execute("token.disconnect()", "app.main").unwrap(); + // 遍历新文档的全部出现位置,不能只清除某一层或遗漏缓存中的交互状态。 assert!( result .document @@ -2282,10 +2525,13 @@ fn ordinary_block_subscription_updates_interactivity_after_disconnect() { /// 生成可调规模的源 paint 压力程序:copies 控制共享对象出现次数,locals 控制单次 paint 指令量。 /// onMount 计数检测取消/恢复是否重复生命周期,tint 让各出现位置绘制结果可统一断言。 fn resumable_paint_source(copies: usize, locals: usize) -> String { + // 下方布局/绘制切片测试调用本生成器;重复同一业务引用,扩大 Mount 数而不扩大业务对象数。 let children = std::iter::repeat_n("item", copies) .collect::>() .join(", "); + // 重复无副作用的局部赋值以消耗 VM 指令预算,让单个 paint 也能跨片暂停。 let locals = "probe = 2\n".repeat(locals); + // 生成完整可加载程序:挂载次数与可变颜色是后续取消、恢复和错误测试的观测点。 format!( "class Painted: RectBlock {{\n tint: String\n mounted: Object\n function onMount(self: Painted, mount: Mount): Unit {{ self.mounted.increment() }}\n function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {{\n{locals}drawing = RectBlock.paint(self,mount,geometry)\ndrawing.commands.at(0).color = self.tint\nreturn drawing\n}}\n}}\nmounted = ui.state(0)\nitem = Painted(2,20,0,\"#DDEEFF\",\"#ED6A5A\",mounted)\npage = Page()\npage.render(Row(0,0,{children}))" ) @@ -2295,15 +2541,19 @@ fn resumable_paint_source(copies: usize, locals: usize) -> String { /// 分别加载含源布局/源绘制的示例并主动 reclaim 临时辅助对象。 /// 随后无业务变化刷新必须为 None,版本不变,证明缓存未把短命 helper 当作业务失效依赖。 fn collecting_lazy_helpers_does_not_invalidate_stable_layout_or_paint() { + // 分别覆盖源布局辅助对象和源绘制辅助对象,不把两套依赖机制混成一个用例。 for source in [ include_str!("../../../examples/flex-constraints.endor"), include_str!("../../../examples/source-paint.endor"), ] { + // 完成一次正常发布,让临时辅助对象及相应缓存都已产生。 let (mut session, initial) = ReplSession::load(source, "app.main", 281, 1).unwrap(); + // 显式执行桥接层回收,保留会话业务根;回收短命 helper 不应被解释为业务状态变化。 session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) .unwrap(); + // 检查没有发布动作,再比对保存的版本,防止仅返回 None 却内部替换文档。 assert!(session.refresh_document().unwrap().is_none()); assert_eq!( session.documents[&crate::window::MAIN_WINDOW] @@ -2318,10 +2568,14 @@ fn collecting_lazy_helpers_does_not_invalidate_stable_layout_or_paint() { /// 用 160/640 个出现位置强制布局和绘制让出一次执行。 /// 首次 Pending 时托管对象增长必须小于固定阈值,不能预建整树 Bounds/collector;显式取消后续体和只读范围都应关闭。 fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() { + // 以两种明显不同的树规模验证准备阶段的增量内存界限,阈值不随总出现次数增长。 for copies in [160, 640] { + // 先构造有效完整帧,随后直接调用桥接分片入口以观察首次 Pending 的中间状态。 let (mut session, initial) = ReplSession::load(&resumable_paint_source(copies, 0), "app.main", 280, 1).unwrap(); + // 记录开始绘制前的 VM 对象数;只计算本次准备与第一片新增的托管辅助对象。 let before = session.candidate.vm.object_count(); + // 对已布局的根和帧启动源绘制,避免把布局开销计入绘制阶段。 let progress = session .bridge .begin_paint_frame( @@ -2330,16 +2584,19 @@ fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() { initial.frame, ) .unwrap(); + // 必须真实让出执行;若一次完成,就没有覆盖待取消的绘制续体。 let crate::source_paint::Progress::Pending(work) = progress else { panic!("paint must yield"); }; // 每片只保留当前出现位置的辅助对象/结果;不能在首次让出前为所有位置预建 Bounds 和 collector。 assert!(session.candidate.vm.object_count() < before + 256); + // 取消未完成帧并检查 VM 续体释放,随后才能独立测试布局路径。 session .bridge .cancel_paint_frame(&mut session.candidate.vm, crate::window::MAIN_WINDOW, *work) .unwrap(); assert!(!session.candidate.vm.has_suspended_call()); + // 改变可用视口,使接下来的布局不能直接沿用原来的约束结果。 session .bridge .set_viewport( @@ -2350,20 +2607,25 @@ fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() { }), ) .unwrap(); + // 准备页面根但不一次性完成布局,后续观察 begin_layout_frame 的第一片。 let root = session .bridge .prepare_frame(&mut session.candidate.vm) .unwrap() .unwrap(); + // 重新取布局阶段基线,排除前一绘制测试已有对象对增长量的影响。 let before = session.candidate.vm.object_count(); + // 只启动布局第一片,不预先为整棵树创建 Bounds/collector。 let progress = session .bridge .begin_layout_frame(&mut session.candidate.vm, root) .unwrap(); + // 要求布局也实际暂停,随后用与绘制相同的固定阈值核对辅助对象增长。 let crate::ui_bridge::LayoutProgress::Pending(work) = progress else { panic!("layout must yield"); }; assert!(session.candidate.vm.object_count() < before + 256); + // 取消布局续体;最后同时核对暂停调用和只读范围均已关闭,避免影响后续业务执行。 session .bridge .cancel_layout_frame(&mut session.candidate.vm, *work) @@ -2377,16 +2639,21 @@ fn layout_and_paint_prepare_keep_only_current_occurrence_managed_helpers() { /// 修改共享 tint 后逐片推进到 paint 挂起,检查旧完整文档仍可见。 /// 换视口取消未发布绘制并重算,最终 80 条命令全为新色,onMount 仍恰好 80 次,取消与切片统计可观察。 fn pending_paint_retargets_without_replaying_completed_mount_hooks() { + // 80 个出现位置已完成首次 onMount;后续只改颜色,不应再调用这些生命周期钩子。 let (mut session, initial) = ReplSession::load(&resumable_paint_source(80, 0), "app.main", 278, 1).unwrap(); + // 空视口更新表表示沿用已有尺寸,让本轮先推进到可取消的源绘制阶段。 let viewports = std::collections::BTreeMap::new(); + // 提交业务颜色变化但采用增量执行,保留观察未完成更新的机会。 session .execute_incremental("item.tint = \"#526AC7\"", "app.main", &viewports) .unwrap(); + // 有界推进刷新各阶段,等待绘制工作出现;超出上限后由下面断言报告未覆盖暂停路径。 for _ in 0..100 { session .resize_windows_incremental(&viewports, false) .unwrap(); + // 只在刷新状态已有 paint 续体时停止,不能把仍在布局中的暂停误算为绘制暂停。 if session .refresh .as_ref() @@ -2395,6 +2662,7 @@ fn pending_paint_retargets_without_replaying_completed_mount_hooks() { break; } } + // 确认刷新状态与 VM 实际暂停调用一致,随后验证尚未发布半成品。 assert!( session .refresh @@ -2402,6 +2670,7 @@ fn pending_paint_retargets_without_replaying_completed_mount_hooks() { .is_some_and(|work| work.paint.is_some()) ); assert!(session.candidate.vm.has_suspended_call()); + // 绘制未完成时应继续展示初始版本,所有旧命令仍保持初始颜色。 assert_eq!( session.documents[&crate::window::MAIN_WINDOW] .frame @@ -2409,6 +2678,7 @@ fn pending_paint_retargets_without_replaying_completed_mount_hooks() { initial.frame.layout_version ); assert!(session.documents[&crate::window::MAIN_WINDOW].frame.commands.iter().all(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#ED6A5A"))); + // 在旧 paint 暂停时提供新尺寸,迫使刷新取消旧目标并基于最新约束重新准备。 let latest = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -2416,17 +2686,21 @@ fn pending_paint_retargets_without_replaying_completed_mount_hooks() { height: 650.0, }, )]); + // 先只执行一次重定向,确认旧的 VM 暂停调用已被清除。 session.resize_windows_incremental(&latest, false).unwrap(); assert!(!session.candidate.vm.has_suspended_call()); + // 用同步入口推进到收敛,检查最终整帧全部为新色且没有丢失出现位置。 let settled = session.resize_windows(&latest).unwrap().unwrap(); assert_eq!(settled.frame.commands.len(), 80); assert!(settled.frame.commands.iter().all( |command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#526AC7") )); + // 业务挂载计数仍为首次的 80;重算绘制不能重放已完成的 onMount。 assert_eq!( session.execute("mounted.value", "app.main").unwrap().value, "80" ); + // 取消应恰好发生一次,多片绘制统计则证明此用例确实跨越过执行切片。 assert_eq!(source_measure_stat(&mut session, "paintCancels"), 1); assert!(source_measure_stat(&mut session, "paintSlices") > 1); } @@ -2435,32 +2709,43 @@ fn pending_paint_retargets_without_replaying_completed_mount_hooks() { /// 利用长 paint 先产生暂停,再在解码时遇到非法颜色。 /// 必须同时验证曾挂起及最终失败;故障关闭 VM 续体/只读范围,保留旧文档版本和原始绘制颜色。 fn paint_error_after_pause_closes_read_only_scope_and_preserves_last_picture() { + // 单个 paint 内放入 300 次局部赋值,让命令解码报错之前有机会真实暂停。 let (mut session, initial) = ReplSession::load(&resumable_paint_source(1, 300), "app.main", 279, 1).unwrap(); + // 保持当前视口,将变量收敛到“暂停后绘制出错”这一条路径。 let viewports = std::collections::BTreeMap::new(); + // 业务字段本身可接受字符串;非法颜色会在后续 paint 命令解码时被拒绝。 session .execute_incremental("item.tint = \"invalid-color\"", "app.main", &viewports) .unwrap(); + // 累计是否曾观察到 paint 续体,防止只测到同步失败。 let mut paused = false; + // 另记最终是否失败,两项必须同时成立才覆盖暂停后的错误清理。 let mut failed = false; + // 有界推进至错误;每个成功切片检查刷新状态,错误分支立即结束推进。 for _ in 0..100 { + // 成功仍可能表示尚未完成,不能以 Ok 直接判定新帧已经发布。 match session.resize_windows_incremental(&viewports, false) { + // 累计 paint 曾挂起的事实;后续阶段清除 refresh 时也保留该证据。 Ok(_) => { paused |= session .refresh .as_ref() .is_some_and(|work| work.paint.is_some()) } + // 记录错误并停止使用这次已故障的刷新,后面的断言检查清理结果。 Err(_) => { failed = true; break; } } } + // 要求两个阶段均被覆盖;故障后 VM 不能残留暂停调用或只读范围。 assert!(paused && failed); assert!(session.is_faulted()); assert!(!session.candidate.vm.has_suspended_call()); assert!(!session.candidate.vm.object_store().is_read_only()); + // 对外仍保留最后成功发布的版本和颜色,不暴露无效颜色生成的半帧。 assert_eq!( session.documents[&crate::window::MAIN_WINDOW] .frame @@ -2474,6 +2759,7 @@ fn paint_error_after_pause_closes_read_only_scope_and_preserves_last_picture() { /// paint 局部变量先引用旧 ObjectList,再重新绑定到新 DisplayList。 /// 允许局部引用重绑不等于允许修改旧业务对象;绘制成功后 previous 仍应为空。 fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it() { + // 局部 drawing 先引用旧列表、后改指向新 DisplayList;用于区分引用重绑与旧对象字段写入。 let source = r##"class ReboundRect: RectBlock { previous: ObjectList function paint(self: ReboundRect, mount: Mount, geometry: Bounds): DisplayList { @@ -2486,8 +2772,10 @@ fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it rect = ReboundRect(80, 40, 0, "#DDEEFF", previous) page = Page() page.render(rect)"##; + // 加载过程执行 paint;成功生成一条命令,证明局部变量赋值未被只读策略误拒绝。 let (mut session, document) = ReplSession::load(source, "app.main", 75, 1).unwrap(); assert_eq!(document.frame.commands.len(), 1); + // 重新读取旧列表长度,确认没有因为重绑而意外往旧业务集合写入命令。 assert_eq!( session.execute("previous.count", "app.main").unwrap().value, "0" @@ -2498,6 +2786,7 @@ fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it /// 源 paint 修改原生基类返回的命令颜色,检查共享对象各出现位置均使用覆盖结果。 /// 直接写堆上的 tint 而不执行 REPL,只靠 paint 的字段依赖就应触发重绘并在下一轮稳定。 fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { + // 加载共享矩形的源绘制示例,让 paint 对 tint 的读取建立真实依赖。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/source-paint.endor"), "app.main", @@ -2505,6 +2794,7 @@ fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { 1, ) .unwrap(); + // 先验证文档结构,再核对两处矩形及文字的源绘制颜色,确保覆盖生效。 initial.validate().unwrap(); assert_eq!(initial.frame.commands.iter().filter(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#ED6A5A")).count(), 2); assert!( @@ -2512,7 +2802,9 @@ fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { |command| matches!(command, RenderCommand::Text { color, .. } if color == "#FFFFFF") ) ); + // 直接取得被 paint 读取的业务矩形对象,不通过重新执行页面源码来刷新。 let globals = session.candidate.globals.unwrap(); + // 对象身份必须来自原会话,使 set_attr 只改变已有字段并触发其依赖。 let Value::Object(rect) = session .candidate .vm @@ -2528,6 +2820,7 @@ fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { .vm .set_attr(rect, "tint", Value::Text("#526AC7".into())) .unwrap(); + // 仅靠写观察驱动刷新,两处共享出现位置都必须更新;再次刷新应稳定。 let updated = session.refresh_document().unwrap().unwrap(); assert_eq!(updated.frame.commands.iter().filter(|command| matches!(command, RenderCommand::RoundedRect { color, .. } if color == "#526AC7")).count(), 2); assert!(session.refresh_document().unwrap().is_none()); -- 2.52.0 From 6f84d6574ab76b1b1a325043f92ba3dd374d7344 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:43:59 +0800 Subject: [PATCH 06/78] docs: explain visual contracts, mount lifecycle and event test assertions --- src/runtime/endor_host/tests.rs | 133 ++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index eebbe1a..e12939c 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -2830,6 +2830,7 @@ fn source_paint_overrides_native_commands_and_reacts_to_heap_field_writes() { /// paint 尝试写 self.tint,必须在写入前被只读保护拒绝并令会话 fault。 /// 原业务颜色不变,错误展开后宿主可正常直接写属性;另测非法颜色命令的发布拒绝。 fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() { + // paint 正在只读范围内执行;夹具尝试修改早已存在的 rect.tint,而非刚创建的绘制结果。 let source = r##"class BadPaint: RectBlock { tint: String function paint(self: BadPaint, mount: Mount, geometry: Bounds): DisplayList { @@ -2840,7 +2841,9 @@ fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() rect = BadPaint(100, 40, 0, "#DDEEFF", "#FF0000") page = Page() page.render(rect)"##; + // 先建立会话再执行,错误后仍能检查候选 VM 中的对象与保护范围。 let mut session = ReplSession::new(72).unwrap(); + // 页面加载触发首帧 paint,要求诊断明确来自 read-only 保护。 assert!( session .execute(source, "app.main") @@ -2849,8 +2852,11 @@ fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() .to_string() .contains("read-only") ); + // 绘制失败应停止当前运行时;但停止状态不能替代检查写入确实没有发生。 assert!(session.is_faulted()); + // 绕过已停止会话的 execute 入口,直接取原业务对象验证错误后的真实状态。 let globals = session.candidate.globals.unwrap(); + // 全局 rect 应已在 paint 前构造完毕,必须保留其原身份与字段值。 let Value::Object(rect) = session .candidate .vm @@ -2860,15 +2866,18 @@ fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() else { panic!("Block") }; + // tint 仍是构造时的红色,说明非法写入在发生前被阻止,而非失败后仅停止后续回调。 assert_eq!( session.candidate.vm.get_attr(rect, "tint").unwrap(), Value::Text("#FF0000".into()) ); + // 宿主直接写字段应恢复为可写,验证异常展开已退出 paint 的只读范围。 session .candidate .vm .set_attr(rect, "tint", Value::Text("#526AC7".into())) .unwrap(); + // 另用独立加载检查无效颜色的命令解码错误,与上面的业务写入拒绝分开覆盖。 let bad_color = include_str!("../../../examples/source-paint.endor").replace("#ED6A5A", "invalid color"); assert!(ReplSession::load(&bad_color, "app.main", 73, 1).is_err()); @@ -2878,13 +2887,19 @@ fn source_paint_rejects_business_mutation_before_it_happens_and_restores_scope() /// 生成 1100 个共享 rect 出现位置,使 Mount 变更通知超过单批容量。 /// 首次仍需完整产出 1100 条命令,随后无变更刷新为 None,避免通知溢出造成永久重绘。 fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() { + // 复用源 paint 类与共享 rect 的定义,保留实际依赖读取行为。 let fixture = include_str!("../../../examples/source-paint.endor"); + // 截去原根容器及后续页面代码,随后构造大量共享出现位置。 let mut source = fixture[..fixture.find("root = Column").unwrap()].to_owned(); + // 创建一个无内边距和间距的 Column,使命令数量直接对应共享 rect 的出现次数。 source.push_str("root = Column(0, 0"); + // 1100 次出现使 Mount 通知超过单批容量;每次追加的是同一业务对象引用。 for _ in 0..1100 { source.push_str(", rect"); } + // 补齐页面入口,确保所有出现位置都经过正常挂载和源绘制。 source.push_str(")\npage = Page()\npage.render(root)"); + // 完成初始发布后检查没有遗漏命令,随后无变化刷新应为 None,不能持续误判失效。 let (mut session, document) = ReplSession::load(&source, "app.main", 74, 1).unwrap(); assert_eq!(document.frame.commands.len(), 1100); assert!(session.refresh_document().unwrap().is_none()); @@ -2894,6 +2909,7 @@ fn source_paint_is_stable_when_mount_notifications_overflow_the_change_batch() { /// 检查可选原生构造参数、可变子项和源子类字段在同一构造中的独立分段。 /// 错误类型在分配前拒绝,Block 数量不增;合法回调有效,并逐一验证已公开 UI 方法的 native signature。 fn constructor_options_and_subclass_fields_have_independent_contracts() { + // 加载省略可选原生参数、带源子类字段及可变子项的组合示例,走真实构造入口。 let (mut session, document) = ReplSession::load( include_str!("../../../examples/constructor-contracts.endor"), "app.main", @@ -2901,7 +2917,9 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { 1, ) .unwrap(); + // 文档必须结构有效,再核对字段位置与类型,防止构造参数分段串位。 document.validate().unwrap(); + // 首组逐项读取原生默认值、源字段、事件和列表成员,验证各段参数各归其位。 for (expression, expected) in [ ("rect.width", "180"), ("rect.radius", "0"), @@ -2911,16 +2929,19 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { ("items.count", "2"), ("ui.isInstance(items.at(0), TaggedRect)", "true"), ] { + // 同一会话内查询实际业务值;失败时附表达式,便于定位哪一段构造规则出错。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected, "{expression}" ); } + // 保存非法构造前的 Block 数量,后面检查类型拒绝是否发生在原生分配副作用之前。 let blocks = session .execute("ui.stats().blocks", "app.main") .unwrap() .value; + // 分别破坏原生参数、可变子项、源子类构造和 Type 参数,要求报告对应契约。 for (expression, expected) in [ ("TaggedRect(\"bad\", 60, \"tag\")", "expects Number"), ("TaggedColumn(0, 0, 7, \"tag\")", "expects Block"), @@ -2929,6 +2950,7 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { ("TaggedEvent(7, \"tag\")", "expects String"), ("ui.isSubtype(7, Block)", "expects Type"), ] { + // 错误调用必须返回 Err;不能靠无输出或错误对象被悄悄忽略来通过。 let error = session .execute(expression, "app.main") .err() @@ -2938,6 +2960,7 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { "{expression}: {error:#}" ); } + // 所有错误尝试结束后,业务 Block 数必须等于基线。 assert_eq!( session .execute("ui.stats().blocks", "app.main") @@ -2945,11 +2968,13 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { .value, blocks ); + // 合法回调继续可用,证明这些参数错误不会把健康会话永久停止。 session.execute("button.click()", "app.main").unwrap(); assert_eq!( session.execute("rect.color", "app.main").unwrap().value, "\"#237A45\"" ); + // 逐个检查公开 UI 入口的 native signature,防止仅部分函数注册了契约元数据。 for name in [ "open", "present", @@ -2972,6 +2997,7 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { "supports", "after", ] { + // 从当前桥接 ui 对象取得真实函数引用,避免验证一份与实际发布函数无关的描述。 let Value::Object(function) = session .candidate .vm @@ -2980,6 +3006,7 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { else { panic!("native Function") }; + // 读取对象存储中绑定到该 native 函数的签名;缺失元数据也必须使测试失败。 let signature = session .candidate .vm @@ -2987,6 +3014,7 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { .native_signature(function) .unwrap() .expect("published UI signature"); + // 使用同一 VM 类型表验证签名引用与结构,确保这些契约可由运行时实际使用。 signature .validate(session.candidate.vm.object_store()) .unwrap(); @@ -2997,8 +3025,11 @@ fn constructor_options_and_subclass_fields_have_independent_contracts() { /// 加载满足参数逆变/返回协变的视觉方法覆盖,验证测量和点击都按动态类型执行。 /// 再分别缩窄参数、放宽返回类型,检查类定义被对应 override 诊断拒绝。 fn source_overrides_accept_wider_inputs_and_more_specific_results() { + // 示例中 WideRect 放宽 measure 参数为 Object,返回更具体的 Sized,保留合法覆写基线。 let source = include_str!("../../../examples/method-contracts.endor"); + // 正常加载时解析覆写契约并执行动态 measure,先确认合法定义可发布。 let (mut session, document) = ReplSession::load(source, "app.main", 164, 1).unwrap(); + // 初始几何通过源 Mount API 读取,不能只凭类定义成功判定动态调用正确。 document.validate().unwrap(); assert_eq!( session @@ -3007,6 +3038,7 @@ fn source_overrides_accept_wider_inputs_and_more_specific_results() { .value, "180" ); + // 通过合法按钮回调改变原矩形宽度,再核对动态测量更新和一次点击计数。 session.execute("button.click()", "app.main").unwrap(); assert_eq!( session @@ -3019,6 +3051,7 @@ fn source_overrides_accept_wider_inputs_and_more_specific_results() { session.execute("count.value", "app.main").unwrap().value, "1" ); + // 从合法程序分别派生“参数变窄”和“返回变宽”,各用独立加载隔离错误。 for (source, reason) in [ ( source.replace("mount: Object", "mount: Point"), @@ -3029,6 +3062,7 @@ fn source_overrides_accept_wider_inputs_and_more_specific_results() { "override widens the return type", ), ] { + // 错误必须指向覆写的方差规则,而不是任意加载失败。 let error = ReplSession::load(&source, "app.main", 165, 1) .err() .expect("invalid override"); @@ -3040,6 +3074,7 @@ fn source_overrides_accept_wider_inputs_and_more_specific_results() { /// 把 Mount/Bounds 的名义类型、事件 Bool|String 选项及可空 Page.window/open 参数串联执行。 /// 合法空值和 union 分支互通,Number 选项则必须以 Bool | String 契约诊断拒绝。 fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() { + // 加载完整视觉协议示例,准备有效矩形、父容器和已打开的页面上下文。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/visual-protocols.endor"), "app.main", @@ -3047,6 +3082,8 @@ fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() 1, ) .unwrap(); + // 按顺序执行:先保存 Mount,再测试 Bounds、联合选项、断连及可空页面窗口。 + // None 表示只要求调用成功;Some 才对序列化后的返回值作精确断言。 for (expression, expected) in [ ("context = rect.mounts().at(0)", None), ("ui.isInstance(context.bounds(), Bounds)", Some("true")), @@ -3063,11 +3100,14 @@ fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() ("page.open(page.window())", None), ("ui.isInstance(page.window(), Window)", Some("true")), ] { + // 各表达式共享前一步建立的 context/listener;顺序是本用例的一部分。 let result = session.execute(expression, "app.main").unwrap(); + // 只比较显式声明的预期值,赋值语句等步骤主要用于建立后续检查条件。 if let Some(expected) = expected { assert_eq!(result.value, expected, "{expression}"); } } + // 数值 7 不属于 Bool | String 的任何分支,注册应给出联合类型诊断。 let error = session .execute("root.on(\"click\", page.close, 7)", "app.main") .err() @@ -3079,6 +3119,7 @@ fn nullable_page_methods_union_event_options_and_nominal_mount_bounds_compose() /// 向 measure/paint、几何方法、resize 和 setColor 传递错误名义类型。 /// 检查每项错误点名所需类型,业务颜色保持不变,随后合法 Constraints 测量仍返回受限宽度。 fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() { + // 先加载合法页面,以有效业务对象为前提逐项测试原生接口的参数类型边界。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/visual-protocols.endor"), "app.main", @@ -3086,9 +3127,11 @@ fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() { 1, ) .unwrap(); + // 保存属于 rect 的真实 Mount;错误用例只破坏指定参数,不混入失效上下文。 session .execute("context = rect.mounts().at(0)", "app.main") .unwrap(); + // 覆盖 measure/paint 的名义类型、几何运算、窗口尺寸及颜色参数,分别要求对应类型名。 for (expression, expected) in [ ("rect.measure(context, Point(0, 0))", "expects Constraints"), ( @@ -3104,6 +3147,7 @@ fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() { ("ui.window().resize(\"bad\", 700)", "expects Number"), ("rect.setColor(7)", "expects String"), ] { + // 每个非法调用必须返回契约错误,错误消息需区分具体被拒绝的参数。 let error = session .execute(expression, "app.main") .err() @@ -3113,6 +3157,7 @@ fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() { "{expression}: {error:#}" ); } + // 先核对示例保存的原始颜色结果仍正确,再执行合法 Constraints 测量确认会话继续可用。 assert_eq!( session.execute("results.color", "app.main").unwrap().value, "\"#DDEEFF\"" @@ -3133,6 +3178,7 @@ fn native_contracts_reject_wrong_types_before_geometry_or_window_effects() { /// 执行示例内直接调用的原生视觉协议,比较托管 MeasureResult/Arrangement/DisplayList 与真实发布命令。 /// 局部命中允许点内/点外判断,但非法约束、错属 Mount 和已卸载上下文不得用于绘制。 fn native_visual_methods_use_managed_values_and_match_real_geometry() { + // 示例直接调用原生 measure/arrange/paint,并保存托管结果,供本测试与真实首帧对比。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/visual-protocols.endor"), "app.main", @@ -3140,7 +3186,9 @@ fn native_visual_methods_use_managed_values_and_match_real_geometry() { 1, ) .unwrap(); + // 确认文档合法后比较保存结果,覆盖原生协议返回值解码及其名义托管类型。 initial.validate().unwrap(); + // 依次检查受限测量尺寸、子项排列坐标和绘制颜色,保留各协议独立观察点。 for (expression, expected) in [ ("results.width", "120"), ("results.height", "60"), @@ -3148,15 +3196,19 @@ fn native_visual_methods_use_managed_values_and_match_real_geometry() { ("results.childY", "54"), ("results.color", "\"#DDEEFF\""), ] { + // 读取示例执行时保存的结果,避免在断言时重新调用协议改变待检数据。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } + // 实际发布的矩形使用页面布局分配的 200×60,而直接受限测量返回 120;两种上下文不能混淆。 assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { width: 200.0, height: 60.0, color, .. } if color == "#DDEEFF"))); + // 取已发布 rect 的 Mount,后续命中与绘制检查都用这个有效的所属上下文。 session .execute("context = rect.mounts().at(0)", "app.main") .unwrap(); + // 本地坐标 (1,1) 位于矩形内,(-1,1) 位于外部,原生 hitTest 应分别返回 true/false。 assert_eq!( session .execute("rect.hitTest(context, Point(1, 1))", "app.main") @@ -3171,6 +3223,7 @@ fn native_visual_methods_use_managed_values_and_match_real_geometry() { .value, "false" ); + // Constraints 的最小宽度大于最大宽度,即使参数类型正确也必须拒绝。 assert!( session .execute( @@ -3179,11 +3232,13 @@ fn native_visual_methods_use_managed_values_and_match_real_geometry() { ) .is_err() ); + // rect 的 Mount 不属于 root,类型正确仍不能越过所属对象检查。 assert!( session .execute("root.paint(context, Bounds(0, 0, 10, 10))", "app.main") .is_err() ); + // 关闭页面后旧 Mount 已卸载,原 rect 也不能继续用这个上下文绘制。 session.execute("page.close()", "app.main").unwrap(); assert!( session @@ -3196,6 +3251,7 @@ fn native_visual_methods_use_managed_values_and_match_real_geometry() { /// 先 invalidate paint 再 invalidate layout,对比 measured 为 0 和 1。 /// 两者都可发布但不替换 Mount;无关查询不发布,未知失效类别明确报错。 fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when_requested() { + // 以稳定已发布页面为起点,单独观察显式 paint/layout 失效请求的不同工作量。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/visual-protocols.endor"), "app.main", @@ -3203,15 +3259,19 @@ fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when 1, ) .unwrap(); + // 在源全局保存原 Mount 引用,供失效后的身份比较使用。 session .execute("context = rect.mounts().at(0)", "app.main") .unwrap(); + // 读取原引用的显示值作为身份基线,避免仅凭相同几何误认同一出现位置。 let original = session.execute("context", "app.main").unwrap().value; + // 仅使 paint 失效也应发布新文档,但 measured 统计必须为零。 let update = session .execute("rect.invalidate(\"paint\")", "app.main") .unwrap() .document .unwrap(); + // 检查新文档有效,再读取本次更新的测量数,不把发布成功当作未重测的证据。 update.validate().unwrap(); assert_eq!( session @@ -3220,6 +3280,7 @@ fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when .value, "0" ); + // 显式 layout 失效应发布并重新测量,随后统计要求恰好一个测量。 assert!( session .execute("rect.invalidate(\"layout\")", "app.main") @@ -3234,6 +3295,7 @@ fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when .value, "1" ); + // 两种失效都应保留原 Mount 身份,不通过销毁重建来刷新。 assert_eq!( session .execute("rect.mounts().at(0)", "app.main") @@ -3241,6 +3303,7 @@ fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when .value, original ); + // 无关的只读查询不能再发布一帧,验证失效已被消费完。 assert!( session .execute("results.width", "app.main") @@ -3248,6 +3311,7 @@ fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when .document .is_none() ); + // 未知失效类别应明确报错,不能默认为最宽泛的重排而掩盖调用错误。 assert!( session .execute("rect.invalidate(\"unknown\")", "app.main") @@ -3259,6 +3323,7 @@ fn explicit_invalidation_publishes_once_preserves_mount_and_remeasures_only_when /// 加载共享子项生命周期示例,通过日志计数检查每个出现位置各自挂载。 /// 普通颜色更新不重复 onMount;关闭时子先父后卸载,保存的旧 Mount 永久失效,重开创建新出现位置。 fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { + // 共享 child 在树中出现两次;生命周期日志区分业务对象与每个独立出现位置。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/mount-lifecycle.endor"), "app.main", @@ -3266,17 +3331,21 @@ fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { 1, ) .unwrap(); + // 首帧已完成钩子引起的状态变化,文档应有效且日志顺序已经稳定。 initial.validate().unwrap(); + // 父先挂载,两个 child 出现随后挂载;最后一个子通知序号为 3。 for (expression, expected) in [ ("log.mounted.value", "2"), ("log.parentMounted", "1"), ("log.childMounted", "3"), ] { + // 逐项读取真实业务日志,避免只比较总次数而遗漏父子通知顺序。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } + // 保存旧 Mount 后仅改颜色;这是普通数据刷新,不能再次调用 onMount。 session .execute( "saved = child.mounts().at(0)\nchild.setColor(\"#237A45\")", @@ -3290,6 +3359,7 @@ fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { .value, "2" ); + // 关闭页面应发布空 Mount 树,并对两个 child 出现各执行一次 onUnmount。 let closed = session .execute("page.close()", "app.main") .unwrap() @@ -3303,6 +3373,7 @@ fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { .value, "2" ); + // 最后一个 child 卸载序号为 5,父卸载为 6,验证卸载阶段子先父后的顺序。 assert_eq!( session .execute("log.childUnmounted", "app.main") @@ -3317,11 +3388,13 @@ fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { .value, "6" ); + // 保留的旧句柄可查询 mounted=false,但不允许再读取有效布局边界。 assert_eq!( session.execute("saved.mounted", "app.main").unwrap().value, "false" ); assert!(session.execute("saved.bounds()", "app.main").is_err()); + // 重开页面创建新出现位置,总挂载次数增为 4;旧句柄仍不能复活。 session.execute("page.open()", "app.main").unwrap(); assert_eq!( session @@ -3340,6 +3413,7 @@ fn mount_hooks_run_per_occurrence_settle_and_unmount_before_parent() { /// 让两个页面使用同一业务根,然后切换活动页面。 /// 每次切换都卸载旧上下文并挂载新上下文;共享 Block 不能让旧 Mount 或 page() 指向继续复用。 fn page_switch_replaces_mount_context_even_for_shared_root() { + // 先打开包含共享 child 的初始页面,建立两个有效 Mount 及其日志基线。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/mount-lifecycle.endor"), "app.main", @@ -3347,12 +3421,14 @@ fn page_switch_replaces_mount_context_even_for_shared_root() { 1, ) .unwrap(); + // 保存旧上下文,再让第二个页面使用同一业务根;活动页面切换仍必须重建 Mount 上下文。 session .execute( "saved = child.mounts().at(0)\nsecond = Page()\nsecond.render(root)", "app.main", ) .unwrap(); + // 新页面挂载两次、旧页面卸载两次;共享根不能使页面之间复用出现位置。 assert_eq!( session .execute("log.mounted.value", "app.main") @@ -3371,6 +3447,7 @@ fn page_switch_replaces_mount_context_even_for_shared_root() { session.execute("saved.mounted", "app.main").unwrap().value, "false" ); + // 新 Mount.page() 必须属于 second,防止业务对象相同却残留旧页面关系。 assert_eq!( session .execute("child.mounts().at(0).page()", "app.main") @@ -3378,6 +3455,7 @@ fn page_switch_replaces_mount_context_even_for_shared_root() { .value, session.execute("second", "app.main").unwrap().value ); + // 再激活原页面也会创建新的两个出现位置,总挂载数应由 4 变为 6。 session.execute("page.activate()", "app.main").unwrap(); assert_eq!( session @@ -3392,17 +3470,22 @@ fn page_switch_replaces_mount_context_even_for_shared_root() { /// onMount 向容器 append 新文字,最终文档应包含该结构变化,验证回调后继续收敛。 /// 另一个 onMount 传入错误 setText 参数,错误应停止共享运行时而非忽略生命周期失败。 fn lifecycle_hook_can_edit_container_without_session_lock_and_failure_stops_domain() { + // GrowingColumn 的 onMount 修改自身子项,验证生命周期回调允许正常结构编辑并继续收敛。 let source = r#"class GrowingColumn: Column { function onMount(self: GrowingColumn, mount: Mount): Unit { self.append(TextBlock("from hook")) } } root = GrowingColumn(0, 0) page = Page() page.render(root)"#; + // 从加载返回的首帧就应看到新增文字,不能等下次输入才补出钩子产生的内容。 let (_, document) = ReplSession::load(source, "app.main", 53, 1).unwrap(); + // 比较真实文字命令,确认 append 已进入最终布局/绘制结果。 assert!(document.frame.commands.iter().any( |command| matches!(command, RenderCommand::Text { value, .. } if value == "from hook") )); + // 另建独立会话测试错误钩子,避免前一合法结构变更影响失败路径。 let mut session = ReplSession::new(54).unwrap(); + // BrokenText 钩子给 setText 传数字,故障必须向会话传播,不能静默忽略生命周期错误。 let source = r#"class BrokenText: TextBlock { function onMount(self: BrokenText, mount: Mount): Unit { self.setText(1) } } @@ -3415,18 +3498,22 @@ fn lifecycle_hook_can_edit_container_without_session_lock_and_failure_stops_doma /// 在标准事件示例后附加 extra 语句,创建独立会话和初始完整文档。 /// 各路由测试只需描述额外监听器,即可共享相同父子树、计数器与默认事件配置。 fn event_session(extra: &str) -> (ReplSession, RenderDocument) { + // 下方事件路由测试调用本助手;追加语句可调整监听器,不必复制整份树和业务模型。 let source = format!( "{}\n{}", include_str!("../../../examples/events.endor"), extra ); + // 正常加载并收敛初始页面,向调用方返回会话和可用于事件版本校验的完整文档。 ReplSession::load(&source, "app.main", 41, 1).unwrap() } /// 按全局 button 的业务身份找给定文档中的可交互 Mount,模拟原生 click。 /// 事件使用该文档的会话与布局版本,返回派发后的完整文档,避免用过期目标绕过真实输入检查。 fn click_document(session: &mut ReplSession, document: &RenderDocument) -> RenderDocument { + // 事件测试助手从当前会话的名字表取得 button,保持业务身份与输入目标一致。 let globals = session.candidate.globals.unwrap(); + // 缺失或非对象说明夹具不满足约定,应直接失败而非挑选任意可交互节点。 let Value::Object(button) = session .candidate .vm @@ -3436,12 +3523,14 @@ fn click_document(session: &mut ReplSession, document: &RenderDocument) -> Rende else { panic!("Button") }; + // 同时按 Block ID 与 interactive 筛选,确保测试输入指向当前文档中的有效按钮出现位置。 let mount = document .frame .mounts .iter() .find(|mount| mount.interactive && mount.block_id == button.to_string()) .unwrap(); + // 携带传入文档的 session/layout_version 派发 click;要求有发布结果供调用方继续验证。 session .dispatch(&crate::events::Input { window: crate::window::MAIN_WINDOW, @@ -3464,14 +3553,18 @@ fn click_document(session: &mut ReplSession, document: &RenderDocument) -> Rende /// 通过原生输入路径触发一次 click,检查捕获、目标方法、目标监听器、冒泡依次记为 1/2/3/4。 /// 派发结束后的模型保留最后阶段信息,订阅令牌仍连接。 fn managed_event_route_orders_capture_method_listeners_and_bubble() { + // 基线事件夹具保留捕获、目标方法、目标监听器和冒泡四个阶段,不追加额外监听器。 let (mut session, initial) = event_session(""); + // 通过模拟原生 click 进入宿主路由,并验证回调产生的新文档结构有效。 click_document(&mut session, &initial).validate().unwrap(); + // 逐阶段核对同一个递增序列,既检查执行次数也检查阶段之间的先后关系。 for (field, expected) in [ ("captureStep", "1"), ("methodStep", "2"), ("targetStep", "3"), ("bubbleStep", "4"), ] { + // 按字段名读取源模型;每个字段由对应阶段回调写入,不能靠最终总数推断顺序。 assert_eq!( session .execute(&format!("model.{field}"), "app.main") @@ -3480,6 +3573,7 @@ fn managed_event_route_orders_capture_method_listeners_and_bubble() { expected ); } + // 模型保存的最后阶段应是 bubble;监听令牌仍连接,正常派发不自动退订。 assert_eq!( session.execute("model.phase", "app.main").unwrap().value, "\"bubble\"" @@ -3497,10 +3591,13 @@ fn managed_event_route_orders_capture_method_listeners_and_bubble() { /// 第一场景在捕获中断开尚未调用的目标监听器,必须跳过它但继续冒泡。 /// 第二场景在目标捕获中 stopPropagation,当前目标组仍执行,祖先冒泡被阻止。 fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_group() { + // 在祖先捕获中追加断开 target 的回调;断开发生在目标监听器尚未轮到执行之前。 let (mut session, initial) = event_session( "function cancel(event: Event): Unit { target.disconnect() }\nconnection = root.on(\"click\", cancel, \"capture\")", ); + // 实际派发一次;捕获与目标方法仍执行,目标监听器跳过后冒泡成为第 3 步。 click_document(&mut session, &initial); + // 检查目标没有写入序号、冒泡继续执行且令牌已断开,三项共同界定同次派发的退订语义。 assert_eq!( session .execute("model.targetStep", "app.main") @@ -3522,9 +3619,11 @@ fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_g .value, "false" ); + // 另建会话,在目标捕获组调用 stopPropagation;不沿用前一场景的已断开监听器。 let (mut session, initial) = event_session( "function stop(event: Event): Unit { event.stopPropagation() }\nconnection = button.on(\"click\", stop, \"capture\")", ); + // 重新派发后目标同组监听器仍为第 3 步,但祖先冒泡保持未执行的 0。 click_document(&mut session, &initial); assert_eq!( session @@ -3546,9 +3645,11 @@ fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_g /// 目标捕获使用 stopImmediatePropagation,应连目标默认方法也跳过。 /// 代码派发 custom 事件可返回同一带 preventDefault 标记的 Event,结束后 phase 为 none,并允许再次派发。 fn immediate_stop_skips_target_method_and_code_dispatch_returns_cancelled_event() { + // 目标捕获阶段立即停止传播;祖先捕获已执行,但目标方法和后续监听器都应跳过。 let (mut session, initial) = event_session( "function stop(event: Event): Unit { event.stopImmediatePropagation() }\nconnection = button.on(\"click\", stop, \"capture\")", ); + // 总计数只能为祖先捕获贡献的 1,区分立即停止与仅阻止祖先冒泡。 click_document(&mut session, &initial); assert_eq!( session @@ -3557,9 +3658,11 @@ fn immediate_stop_skips_target_method_and_code_dispatch_returns_cancelled_event( .value, "1" ); + // 单独测试源码 dispatch:custom 监听器调用 preventDefault,返回 Event 保留取消标记。 let (mut session, _) = event_session( "function prevent(event: Event): Unit { event.preventDefault() }\nconnection = button.on(\"custom\", prevent)\nresult = button.dispatch(Event(\"custom\"))", ); + // 取消标记在返回对象上可见;派发退出后 Event.phase 应恢复 none。 assert_eq!( session .execute("result.isDefaultPrevented()", "app.main") @@ -3581,26 +3684,33 @@ fn immediate_stop_skips_target_method_and_code_dispatch_returns_cancelled_event( /// 回调失败后断开故障监听器,再派发同一 Event,验证活跃派发守卫已释放。 /// 另外保留订阅 token 却删除隐藏目标,回收后目标弱升级失败且 token 断开,证明它不强持有目标。 fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() { + // 复用有效事件页面,追加一个会因 page.render 参数错误而失败的独立事件监听器。 let (mut session, _) = event_session(""); + // 保留 failedEvent 句柄,错误后必须能重新派发同一对象以验证活跃守卫释放。 session.execute("function fail(event: Event): Unit { page.render(1) }\nfailing = button.on(\"failure\", fail)\nfailedEvent = Event(\"failure\")", "app.main").unwrap(); + // 第一次派发必须失败,确保后面的重用检查覆盖异常展开路径。 assert!( session .execute("button.dispatch(failedEvent)", "app.main") .is_err() ); + // 先移除故障回调,再用同一 Event 成功派发;若守卫泄漏,会误报重复派发。 session .execute( "failing.disconnect()\nbutton.dispatch(failedEvent)", "app.main", ) .unwrap(); + // 创建未挂载的按钮并保留订阅 token,随后只删除业务全局强引用。 session .execute( "orphan = Button(\"unmounted\")\ntoken = orphan.on(\"click\", model.target)", "app.main", ) .unwrap(); + // 取得名字表,便于准确删除 orphan,而保留 token 和其回调模型。 let globals = session.candidate.globals.unwrap(); + // 取目标对象句柄以创建弱引用;Rust 局部句柄不应成为 VM 的持久业务根。 let Value::Object(orphan) = session .candidate .vm @@ -3610,16 +3720,20 @@ fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() { else { panic!("Block expected") }; + // 弱句柄用于确认目标确实回收,不只检查 token 对外显示已断开。 let weak = session.candidate.vm.downgrade(orphan).unwrap(); + // 删除 orphan 的最后显式全局强引用;不删除 token,专门检查 token 不强持有目标。 session .candidate .vm .dict_delete(globals, &Value::Text("orphan".into())) .unwrap(); + // 沿真实桥接根集合执行回收,测试弱订阅与 GC 的组合行为。 session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) .unwrap(); + // 目标弱升级必须失败,且仍存活的 token 查询为未连接。 assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); assert_eq!( session @@ -3634,6 +3748,7 @@ fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() { /// 加载原生视觉子类,把旧 label 引用保存到 alias 后替换为新的源实例。 /// 共享转发应让 alias 读写新字段、更新多个绘制位置,同时旧属性存储的弱句柄可回收。 fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { + // 加载带源字段的原生视觉子类,初始文档与类型查询先验证基线合法。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/native-subclasses.endor"), "app.main", @@ -3641,6 +3756,7 @@ fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { 1, ) .unwrap(); + // 子类实例仍应满足 TextBlock/Page 原生名义类型,后续才测试替换与字段转发。 initial.validate().unwrap(); assert_eq!( session @@ -3656,8 +3772,11 @@ fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { .value, "true" ); + // 额外保留旧业务引用,替换 label 后 alias 应通过共享转发访问新的属性存储。 session.execute("alias = label", "app.main").unwrap(); + // 取当前 label 对象,以其真实属性路径定位替换前的底层存储。 let globals = session.candidate.globals.unwrap(); + // 要求目标是原夹具中的 Block 对象,而非 Console 展示文本或另建实例。 let Value::Object(label) = session .candidate .vm @@ -3667,17 +3786,20 @@ fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { else { panic!("Block expected") }; + // 读取属性转发链,末尾存储是本用例要检查是否被替换并回收的对象。 let path = session .candidate .vm .object_store() .attribute_path(label) .unwrap(); + // 只保存旧属性存储的弱句柄,避免测试本身强持有旧存储而阻止回收。 let previous = session .candidate .vm .downgrade(*path.last().unwrap()) .unwrap(); + // 用同名源实例替换 label,首次新文档应已显示新字段内容。 let updated = session .execute( r##"label = CustomStatus("replacement", 2, "#242422", "#237A45", "new field")"##, @@ -3686,24 +3808,29 @@ fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { .unwrap() .document .unwrap(); + // 验证替换已经进入绘制结果,再检查旧存储回收与 alias 读写转发。 assert!(updated.frame.commands.iter().any( |command| matches!(command, RenderCommand::Text { value, .. } if value == "new field") )); + // 回收不再从共享业务身份可达的旧存储;alias 不能让过时属性存储继续存活。 session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) .unwrap(); + // 旧存储应消失,但 alias.message 必须仍读取替换后的 new field。 assert!(session.candidate.vm.upgrade(previous).unwrap().is_none()); assert_eq!( session.execute("alias.message", "app.main").unwrap().value, "\"new field\"" ); + // 点击原按钮触发业务更新,多个文字出现应共同看到替换后的字段值。 let updated = session .execute("button.click()", "app.main") .unwrap() .document .unwrap(); assert_eq!(updated.frame.commands.iter().filter(|command| matches!(command, RenderCommand::Text { value, .. } if value == "new field")).count(), 3); + // 最后从旧 alias 写入字段,新文档也应反映 changed field,覆盖双向转发。 let updated = session .execute("alias.message = \"changed field\"", "app.main") .unwrap() @@ -3718,6 +3845,7 @@ fn source_native_subclasses_share_live_fields_and_release_replaced_storage() { /// 完整源码加载后执行 Console 类型查询,再用原生 click 调用原程序中的源回调。 /// 两个共享文字都应变为 10 且 VM 健康,覆盖混合入口中源 ID/编译单元身份不冲突的行为。 fn loaded_program_console_and_native_events_share_unique_source_ids() { + // 先加载完整源码编译单元,保留其中源回调;随后追加 Console 单元并触发原生事件。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/managed-types.endor"), "app.main", @@ -3725,6 +3853,7 @@ fn loaded_program_console_and_native_events_share_unique_source_ids() { 1, ) .unwrap(); + // 在已有程序上执行 Console 类型查询,验证混合输入可以继续引用原对象和类型。 initial.validate().unwrap(); assert_eq!( session @@ -3733,12 +3862,14 @@ fn loaded_program_console_and_native_events_share_unique_source_ids() { .value, "true" ); + // 从初始帧找可交互按钮,用原帧版本构造原生 click,回调仍来自最初加载的源码。 let mount = initial .frame .mounts .iter() .find(|mount| mount.interactive) .unwrap(); + // 固定会话 ID 与加载一致,避免过期输入拒绝掩盖源 ID/编译单元冲突。 let event = crate::events::Input { window: crate::window::MAIN_WINDOW, session: 1, @@ -3751,8 +3882,10 @@ fn loaded_program_console_and_native_events_share_unique_source_ids() { tab_drop: None, screen_position: None, }; + // 派发原生输入后要求返回新文档并通过结构验证。 let updated = session.dispatch(&event).unwrap().document.unwrap(); updated.validate().unwrap(); + // 两处共享文字都应变为 10,同时 VM 保持健康,证明跨入口回调没有落到错误源码单元。 assert_eq!( updated .frame -- 2.52.0 From 00514825a092239934c64aae37323e22f332a88c Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:47:44 +0800 Subject: [PATCH 07/78] docs: explain scrolling, incremental refresh and flex test invariants --- src/runtime/endor_host/tests.rs | 144 ++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index e12939c..d80989b 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -3904,6 +3904,7 @@ fn loaded_program_console_and_native_events_share_unique_source_ids() { /// 向被裁剪的子项中心发送 click,虽在其原始矩形内也不得触发计数。 /// 随后滚动同一 ScrollBlock 的一个出现位置、scrollIntoView 并扩大容器,分别检查独立偏移、可见性及范围回落到零。 fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { + // 加载两个窗口共享 root/scroller 的实际示例;主窗口 frame 用来构造坐标输入。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/scroll.endor"), "app.main", @@ -3911,7 +3912,9 @@ fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { 240, ) .unwrap(); + // 从当前 VM 全局表取业务按钮,后续通过对象身份定位它的主窗口出现位置。 let globals = session.candidate.globals.unwrap(); + // middle 位于滚动内容的中段,初始未滚动时中心应被容器裁剪。 let Value::Object(middle) = session .candidate .vm @@ -3921,17 +3924,21 @@ fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { else { panic!("expected Block"); }; + // 只在主窗口发布帧中找 middle 的 Mount,避免选到另一个窗口的同一业务对象。 let target = initial .frame .mounts .iter() .find(|m| m.block_id == middle.to_string()) .unwrap(); + // 构造原始矩形的中心点,刻意让几何包含但可见裁剪区域不包含这个位置。 let point = [ target.x + target.width / 2.0, target.y + target.height / 2.0, ]; + // 先验证夹具确实产生裁剪外目标;contains 同时考虑有效可见区域,不能只测原始宽高。 assert!(!target.contains(crate::geometry::Point::new(point[0], point[1]))); + // 携带有效会话和布局版本,按下/释放都在该中心点;唯一拒绝原因应为裁剪命中检查。 session .dispatch(&crate::events::Input { window: 1, @@ -3946,10 +3953,12 @@ fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { delta: None, }) .unwrap(); + // 业务计数仍为零,证明即使客户端直接提供该 Mount,也不能绕过裁剪触发 click。 assert_eq!( session.execute("count.value", "app.main").unwrap().value, "0" ); + // 在第一个 Mount 连续 scrollTo/scrollBy,验证同次源码输入中相对滚动累加到 150。 session.execute("saved = scroller.mounts().at(0)\nsaved.scrollTo(0,100)\nsaved.scrollBy(0,20)\nsaved.scrollBy(0,30)","app.main").unwrap(); assert_eq!( session @@ -3958,6 +3967,7 @@ fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { .value, "150" ); + // 第二个出现位置仍为零,偏移属于 Mount,不应共享到同一 ScrollBlock 的另一窗口。 assert_eq!( session .execute("scroller.mounts().at(1).scrollPosition().y", "app.main") @@ -3965,9 +3975,11 @@ fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { .value, "0" ); + // 请求把底部按钮的第一个出现位置滚入视野,后面读取的是更新后的文档。 session .execute("last.mounts().at(0).scrollIntoView()", "app.main") .unwrap(); + // 仍通过业务身份查找 last,避免用旧文档的过期几何判断 scrollIntoView。 let Value::Object(last) = session .candidate .vm @@ -3977,17 +3989,22 @@ fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { else { panic!("expected Block"); }; + // 取会话最新发布的主窗口帧,检查滚动后的可见性和逐命令裁剪数据。 let frame = &session.documents[&crate::window::MAIN_WINDOW].frame; + // 重新定位底部按钮的最新出现几何,不能沿用滚动前的 target。 let target = frame .mounts .iter() .find(|m| m.block_id == last.to_string()) .unwrap(); + // 按钮中心现在应在有效可见区域内,证明 scrollIntoView 真正改变投影而非只改状态字段。 assert!(target.contains(crate::geometry::Point::new( target.x + target.width / 2.0, target.y + target.height / 2.0 ))); + // 每条命令都应有对应裁剪项,包含源 paint 生成的命令。 assert_eq!(frame.command_clips.len(), frame.commands.len()); + // 增大滚动容器到能容纳全部内容;已有偏移必须随新范围夹回零。 session .execute("scroller.height = 1000", "app.main") .unwrap(); @@ -3998,6 +4015,7 @@ fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { .value, "0" ); + // 同时检查最大滚动量归零,避免偏移偶然为零但范围仍停留在旧高度。 assert_eq!( session .execute("saved.scrollPosition().maxY", "app.main") @@ -4011,15 +4029,21 @@ fn scroll_occurrences_are_independent_and_clipped_pointer_input_is_ignored() { /// 为已有 Mount 建立长延时任务,再开始会挂起的 resize 布局并替换视口目标。 /// 取消应恢复旧几何/挂载身份、清除临时 layout,最终重新收敛且原任务仍 pending,避免把放弃的准备当成卸载。 fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() { + // 重复 40 个同一 item 引用,使源布局有足够工作量进入可暂停路径。 let children = std::iter::repeat_n("item", 40) .collect::>() .join(", "); + // Wide.measure 提供固定尺寸,夹具让挂起发生在布局而非业务程序构造期间。 let source = format!( "class Wide: Row {{\n function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult {{ return MeasureResult(340, 60) }}\n}}\nitem = RectBlock(2, 20)\npage = Page()\npage.render(Wide(0, 0, {children}))" ); + // 先完整发布初始页面,后面的取消需要恢复到这份已生效的 Mount 上下文。 let (mut session, _) = ReplSession::load(&source, "app.main", 272, 1).unwrap(); + // 保存已挂载 Mount,并以它为 owner 注册长延时任务;任务状态用来检测是否误当卸载。 session.execute("saved = item.mounts().at(0)\nreceived = ui.state(0)\ntoken = ui.after(saved, \"keep\", 60000, received.increment)", "app.main").unwrap(); + // 读取源 saved 引用,取消后直接检查其原生 Mount payload。 let globals = session.candidate.globals.unwrap(); + // 要求保留的是实际 Mount 对象;不使用重新查询出的新引用掩盖身份丢失。 let Value::Object(saved) = session .candidate .vm @@ -4029,6 +4053,7 @@ fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() { else { panic!("saved Mount"); }; + // 提交第一个视口目标,启动一轮尚未完成的 resize 布局。 let first = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -4036,13 +4061,17 @@ fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() { height: 600.0, }, )]); + // 有界推进到 VM 真正挂起,确保接下来的新尺寸会取消活跃续体。 for _ in 0..20 { session.resize_windows_incremental(&first, false).unwrap(); + // 观察到真实暂停调用即停止推进,避免把旧布局完成后再 resize 算作取消测试。 if session.candidate.vm.has_suspended_call() { break; } } + // 若始终未挂起则测试没有覆盖取消前提,必须失败。 assert!(session.candidate.vm.has_suspended_call()); + // 以不同宽高替换未完成目标,新布局应以最新视口重新开始。 let latest = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -4050,6 +4079,7 @@ fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() { height: 650.0, }, )]); + // 首次重定向还不发布新帧,同时必须清除旧 VM 暂停调用。 assert!( session .resize_windows_incremental(&latest, false) @@ -4057,14 +4087,17 @@ fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() { .is_none() ); assert!(!session.candidate.vm.has_suspended_call()); + // 直接查看保存句柄的 payload:应恢复已挂载标志、清除临时 layout 并保留旧宽度。 let mount = session .candidate .vm .payload::(saved, "Mount") .unwrap(); + // 取消准备工作不等于卸载;恢复状态后才继续完整收敛新布局。 assert!(mount.mounted); assert!(mount.layout.is_none()); assert_eq!(mount.geometry.width, 2.0); + // 完成最新视口下的发布,再确认会话健康、原 owner 任务仍 pending,且只取消过一次。 assert!(session.resize_windows(&latest).unwrap().is_some()); assert!(!session.is_faulted()); assert_eq!( @@ -4078,28 +4111,36 @@ fn new_viewport_cancels_pending_layout_and_restores_saved_mount_context() { /// 增量加载含 80 个出现位置的新候选,在初始布局尚未发布时替换视口目标。 /// 候选文档在完成前始终为空;重新收敛后构建计数仍为 1、挂载计数恰好 80,证明取消只重做布局。 fn candidate_initial_layout_retargets_without_reexecuting_program_or_hooks() { + // 80 个共享出现位置使首次候选布局跨片执行,为发布前的视口替换提供可观察窗口。 let children = std::iter::repeat_n("item", 80) .collect::>() .join(", "); + // built 仅在程序顶层递增,mounted 在每个 onMount 递增,分别检测源码和生命周期是否重放。 let source = format!( "class Watched: RectBlock {{\n count: Object\n function onMount(self: Watched, mount: Mount): Unit {{ self.count.increment() }}\n}}\nbuilt = ui.state(0)\nbuilt.increment()\nmounted = ui.state(0)\nitem = Watched(2,20,mounted)\npage = Page()\npage.render(Row(0,0,{children}))" ); + // 使用增量加载入口,允许程序已执行但首个完整文档尚未生成。 let (mut candidate, document) = ReplSession::load_incremental(&source, "app.main", 277, 1, None).unwrap(); + // 首次返回应仍待刷新,候选文档表也必须为空,不能发布初始半帧。 assert!(document.is_none()); assert!(candidate.has_pending_refresh()); assert!(candidate.documents.is_empty()); + // 空更新表沿用现有视口,只推进候选刷新到真实布局暂停点。 let initial = std::collections::BTreeMap::new(); + // 限制推进次数;暂停后再次检查文档仍为空,确认没有中途发布。 for _ in 0..20 { candidate .resize_windows_incremental(&initial, false) .unwrap(); + // 以 VM 的暂停调用作为取消前提,不能仅凭 has_pending_refresh 判断当前阶段。 if candidate.candidate.vm.has_suspended_call() { break; } } assert!(candidate.candidate.vm.has_suspended_call()); assert!(candidate.documents.is_empty()); + // 替换候选尚未发布的布局目标;仍沿用已经执行好的业务对象和钩子进度。 let latest = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -4107,10 +4148,12 @@ fn candidate_initial_layout_retargets_without_reexecuting_program_or_hooks() { height: 650.0, }, )]); + // 先触发旧续体取消,再通过同步包装推进到最新目标下的完整首帧。 candidate .resize_windows_incremental(&latest, false) .unwrap(); assert!(!candidate.candidate.vm.has_suspended_call()); + // 最后一个宽 2 的子项位于 79×2=158;正确几何之外还要核对程序仅构建一次、挂载恰好 80 次。 let settled = candidate.resize_windows(&latest).unwrap().unwrap(); assert_eq!(settled.frame.mounts.last().unwrap().x, 158.0); assert_eq!( @@ -4124,6 +4167,7 @@ fn candidate_initial_layout_retargets_without_reexecuting_program_or_hooks() { .value, "80" ); + // 取消统计应恰好一次,证明改变视口确实经过候选布局重定向路径。 assert_eq!(source_measure_stat(&mut candidate, "layoutCancels"), 1); } @@ -4131,14 +4175,19 @@ fn candidate_initial_layout_retargets_without_reexecuting_program_or_hooks() { /// 一次输入递增计数、改宽度并返回值,源执行结束后布局继续 Pending。 /// 此时禁止新 mutation/命令投影,保留旧文档;换视口后最终几何更新,但返回值和递增都只发生一次。 fn incremental_input_retains_value_and_never_replays_source_while_retargeting() { + // 构造 80 个共享 item 的 Row,确保一次宽度更新需要分片布局。 let children = std::iter::repeat_n("item", 80) .collect::>() .join(", "); + // count 记录顶层输入执行次数;item.width 更新后影响全部出现位置。 let source = format!( "count = ui.state(0)\nitem = RectBlock(2,20)\npage = Page()\npage.render(Row(0,0,{children}))" ); + // 先完整发布旧页面,后续 Pending 期间必须继续保留其版本。 let (mut session, initial) = ReplSession::load(&source, "app.main", 275, 1).unwrap(); + // 保持现有视口,先单独提交业务输入并等待布局刷新。 let first = std::collections::BTreeMap::new(); + // 同一条输入递增、改宽并返回计数;后续布局续体不得重新执行这三步。 let result = session .execute_incremental( "count.increment()\nitem.width = 3\ncount.value", @@ -4146,24 +4195,29 @@ fn incremental_input_retains_value_and_never_replays_source_while_retargeting() &first, ) .unwrap(); + // 业务结果此时已经是 1,但没有新文档;待刷新时不能消费窗口命令或提交另一条修改。 assert_eq!(result.value, "1"); assert!(result.document.is_none()); assert!(session.has_pending_refresh()); assert!(session.take_window_commands().is_err()); assert!(session.execute("count.increment()", "app.main").is_err()); + // 有界推进到真实暂停,再发新视口触发取消,覆盖输入执行完而布局未完成的阶段。 for _ in 0..20 { session.resize_windows_incremental(&first, false).unwrap(); + // 只在 VM 实际挂起时结束推进,保证换视口取消的是活跃工作。 if session.candidate.vm.has_suspended_call() { break; } } assert!(session.candidate.vm.has_suspended_call()); + // 旧版本在未完成期间仍对外可见;输入结果和对外文档分别管理。 assert_eq!( session.documents[&crate::window::MAIN_WINDOW] .frame .layout_version, initial.frame.layout_version ); + // 最新目标替代旧布局目标,输入已执行的业务写入和返回值都必须保留。 let latest = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -4171,7 +4225,9 @@ fn incremental_input_retains_value_and_never_replays_source_while_retargeting() height: 650.0, }, )]); + // 先触发重定向,再同步收敛至新帧,不能重新调用 execute_incremental。 session.resize_windows_incremental(&latest, false).unwrap(); + // 宽度已变为 3,最后子项 x=79×3=237;返回值及当前 count 都仍为 1,排除源码重放。 let settled = session.resize_windows(&latest).unwrap().unwrap(); assert_eq!(settled.frame.mounts.last().unwrap().x, 237.0); assert_eq!(result.value, "1"); @@ -4186,19 +4242,24 @@ fn incremental_input_retains_value_and_never_replays_source_while_retargeting() /// 点击回调先改变业务计数,再通过多次增量刷新完成大树布局。 /// 挂起期间旧文档版本不变,最终计数为 1 且 refreshYields 大于零,避免分片时重放 click。 fn incremental_event_runs_callback_once_before_publishing_its_layout() { + // 重复 80 个 item 增加刷新工作量,按钮回调只负责递增 count。 let children = std::iter::repeat_n("item", 80) .collect::>() .join(", "); + // 计数通过 ui.textOf 可视化,点击后必须产生真实布局/绘制刷新而非仅后台字段更新。 let source = format!( "count = ui.state(0)\nitem = RectBlock(2,20)\nbutton = Button(\"increment\")\nbutton.onClick(count.increment)\npage = Page()\npage.render(Column(0,0,button,ui.textOf(count,\"value\"),Row(0,0,{children})))" ); + // 先完整加载基线文档,再从该文档取有效事件目标。 let (mut session, initial) = ReplSession::load(&source, "app.main", 276, 1).unwrap(); + // 选择唯一可交互按钮,保证输入通过正常宿主事件路径调用计数回调。 let mount = initial .frame .mounts .iter() .find(|mount| mount.interactive) .unwrap(); + // 事件身份与初始文档一致;无坐标的直接目标 click 专注测试回调与增量刷新衔接。 let input = crate::events::Input { window: crate::window::MAIN_WINDOW, session: session.id, @@ -4211,18 +4272,24 @@ fn incremental_event_runs_callback_once_before_publishing_its_layout() { tab_drop: None, screen_position: None, }; + // 不额外改变窗口尺寸,只推进该次事件已启动的刷新。 let viewports = std::collections::BTreeMap::new(); + // 回调已被调用,但第一步增量派发尚未完成新文档,应留下 pending 刷新。 let result = session.dispatch_incremental(&input, &viewports).unwrap(); assert!(result.document.is_none()); assert!(session.has_pending_refresh()); + // 对外继续展示旧版本,禁止把单个阶段完成误当作可发布帧。 assert_eq!( session.documents[&crate::window::MAIN_WINDOW] .frame .layout_version, initial.frame.layout_version ); + // 记录是否真的得到最终文档,防止循环达到上限后仍以部分状态通过。 let mut complete = false; + // 反复推进同一刷新;不得重新 dispatch input,否则会重复执行业务回调。 for _ in 0..1000 { + // 只有返回 Some(document) 才标记完成,None 仍表示分片尚未发布。 if session .resize_windows_incremental(&viewports, false) .unwrap() @@ -4232,6 +4299,7 @@ fn incremental_event_runs_callback_once_before_publishing_its_layout() { break; } } + // 必须完成并清除 pending;最后计数仍为 1,统计中应实际观察到过让出。 assert!(complete); assert!(!session.has_pending_refresh()); assert_eq!( @@ -4245,15 +4313,20 @@ fn incremental_event_runs_callback_once_before_publishing_its_layout() { /// 只用原生布局的大树也应进入可恢复路径。 /// Pending 期间保留旧帧,换视口取消后重算仍产出最后子项 x=158/width=2,并记录切片与取消次数。 fn large_intrinsic_layout_yields_and_retargets_without_geometry_loss() { + // 用 80 次共享引用放大原生 Row 的布局,测试不依赖源方法覆盖也能分片。 let children = std::iter::repeat_n("item", 80) .collect::>() .join(", "); + // 业务对象只有一个 item,所有出现各占宽 2,最后一个的预期 x 为 158。 let source = format!( "item = RectBlock(2,20)\nrow = Row(0,0,{children})\npage = Page()\npage.render(row)" ); + // 完成基线帧,保存版本并先确认原始几何正确,后面才测试取消恢复。 let (mut session, initial) = ReplSession::load(&source, "app.main", 273, 1).unwrap(); + // 记录已发布版本,Pending 时不能提前更换对外文档。 let old_version = initial.frame.layout_version; assert_eq!(initial.frame.mounts.last().unwrap().x, 158.0); + // 第一次视口变化启动原生布局增量刷新。 let first = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -4261,19 +4334,23 @@ fn large_intrinsic_layout_yields_and_retargets_without_geometry_loss() { height: 600.0, }, )]); + // 推进直到 VM 真正暂停;若只有刷新标记而无暂停,就没有覆盖续体取消。 for _ in 0..20 { session.resize_windows_incremental(&first, false).unwrap(); + // 暂停一旦出现就停止本轮推进,保留未完成工作供新视口替换。 if session.candidate.vm.has_suspended_call() { break; } } assert!(session.candidate.vm.has_suspended_call()); + // 旧帧版本仍可见,说明原生布局的中间几何未提前发布。 assert_eq!( session.documents[&crate::window::MAIN_WINDOW] .frame .layout_version, old_version ); + // 用新尺寸取消旧布局,必须保留业务对象及完整出现位置集合。 let latest = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -4281,8 +4358,10 @@ fn large_intrinsic_layout_yields_and_retargets_without_geometry_loss() { height: 650.0, }, )]); + // 先触发取消并检查旧暂停已清除,再继续完成新布局。 session.resize_windows_incremental(&latest, false).unwrap(); assert!(!session.candidate.vm.has_suspended_call()); + // 最终仍为 x=158、width=2;取消一次且布局跨多片,证明不是绕开分片直接复用旧帧。 let settled = session.resize_windows(&latest).unwrap().unwrap(); assert_eq!(settled.frame.mounts.last().unwrap().x, 158.0); assert_eq!(settled.frame.mounts.last().unwrap().width, 2.0); @@ -4294,12 +4373,16 @@ fn large_intrinsic_layout_yields_and_retargets_without_geometry_loss() { /// 两个立即就绪任务都递增同一状态,首次 poll 只交付第一个并启动刷新。 /// 刷新未完成时重复 poll 不交付第二个;第一帧完成后再 poll 才发布第二次递增。 fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() { + // 两个零延时任务依次递增同一可见状态,用来检查任务交付与上一回调刷新之间的串行边界。 let (mut session, initial) = ReplSession::load( "state = ui.state(0)\npage = Page()\npage.render(ui.textOf(state,\"value\"))\na = ui.after(ui,\"a\",0,state.increment)\nb = ui.after(ui,\"b\",0,state.increment)", "app.main", 274, 1, ).unwrap(); + // 保存计数仍为零时的文档版本,第一回调刷新未完成前必须保持它。 let old_version = initial.frame.layout_version; + // 不改变视口,只驱动任务引起的刷新。 let viewports = std::collections::BTreeMap::new(); + // 第一次 poll 交付 a 并启动刷新;尚无完成文档,has_pending_refresh 应为真。 assert!( session .poll_tasks_incremental(&viewports) @@ -4307,6 +4390,7 @@ fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() { .is_none() ); assert!(session.has_pending_refresh()); + // 第二次 poll 不能越过未完成刷新再交付 b。 assert!( session .poll_tasks_incremental(&viewports) @@ -4319,6 +4403,7 @@ fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() { .layout_version, old_version ); + // 完成第一回调对应的文档,核对版本前进、状态为 1、a 完成而 b 仍 pending。 let first = session.resize_windows(&viewports).unwrap().unwrap(); assert!(first.frame.layout_version > old_version); assert_eq!( @@ -4333,6 +4418,7 @@ fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() { session.execute("b.status()", "app.main").unwrap().value, "\"pending\"" ); + // 第一帧完成后才允许 b 交付;再收敛其刷新,应看到状态为 2 且 b 完成。 session.poll_tasks_incremental(&viewports).unwrap(); assert!(session.has_pending_refresh()); session.resize_windows(&viewports).unwrap(); @@ -4350,6 +4436,7 @@ fn idle_tasks_wait_for_prior_callback_refresh_before_next_delivery() { /// 启动增量 resize 后尝试普通 mutation,应拒绝但不使 VM fault。 /// 逐次推进时每个未完成返回都保持旧文档版本,只有整体完成才一次发布新版本并清除 pending。 fn incremental_viewport_refresh_retains_published_documents_until_complete() { + // 源 flex 约束示例提供多阶段布局工作,用来检验视口刷新期间的发布原子性。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/flex-constraints.endor"), "app.main", @@ -4357,9 +4444,11 @@ fn incremental_viewport_refresh_retains_published_documents_until_complete() { 1, ) .unwrap(); + // 从会话当前文档表保存完整帧版本,后续每个未完成切片都逐次核对。 let old_version = session.documents[&crate::window::MAIN_WINDOW] .frame .layout_version; + // 窗口 1 的新逻辑尺寸是本轮刷新目标,循环中保持该目标不变。 let viewports = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -4367,6 +4456,7 @@ fn incremental_viewport_refresh_retains_published_documents_until_complete() { height: 600.0, }, )]); + // 第一步启动刷新并返回 None;此时普通修改应被拒绝,但不应让健康 VM fault。 assert!( session .resize_windows_incremental(&viewports, false) @@ -4376,8 +4466,11 @@ fn incremental_viewport_refresh_retains_published_documents_until_complete() { assert!(session.has_pending_refresh()); assert!(session.execute("blocked = 1", "app.main").is_err()); assert!(!session.is_faulted()); + // 记录实际发布完成,避免循环上限耗尽却误判成功。 let mut completed = false; + // 逐片推进直到发布;每次 None 后立即确认旧文档版本仍保持。 for _ in 0..1000 { + // 只在完整新文档返回时检查版本增加与 pending 清除,并停止推进。 if let Some(document) = session .resize_windows_incremental(&viewports, false) .unwrap() @@ -4387,6 +4480,7 @@ fn incremental_viewport_refresh_retains_published_documents_until_complete() { completed = true; break; } + // 仍未完成的分支必须保留原文档版本,不能只在循环结束时检查最终结果。 assert_eq!( session.documents[&crate::window::MAIN_WINDOW] .frame @@ -4394,6 +4488,7 @@ fn incremental_viewport_refresh_retains_published_documents_until_complete() { old_version ); } + // 确认循环获得了新帧且刷新统计确实发生过让出,覆盖增量而非同步完成路径。 assert!(completed); assert!(source_measure_stat(&mut session, "refreshYields") > 0); } @@ -4402,13 +4497,17 @@ fn incremental_viewport_refresh_retains_published_documents_until_complete() { /// 绕过会话同步包装,直接调用桥接 begin_layout_frame 观察首个 Pending。 /// 循环把工作所有权传回 resume,最终 Complete 才断言完整几何,且 VM 不再残留挂起调用。 fn bridge_layout_can_return_pending_before_committing_a_complete_frame() { + // 40 次共享出现加上源 Row.measure,让桥接布局入口返回可恢复工作。 let children = std::iter::repeat_n("item", 40) .collect::>() .join(", "); + // 源测量给定固定父尺寸,最终各子项应依次排列而不丢失任何出现。 let source = format!( "class Wide: Row {{\n function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult {{ return MeasureResult(340, 60) }}\n}}\nitem = RectBlock(2, 20)\npage = Page()\npage.render(Wide(0, 0, {children}))" ); + // 先建立有效 VM/桥接状态,再绕过会话同步包装直接测试桥接分片接口。 let (mut session, _) = ReplSession::load(&source, "app.main", 270, 1).unwrap(); + // 变更视口约束,防止布局完全沿用初始缓存。 session .bridge .set_viewport( @@ -4419,28 +4518,35 @@ fn bridge_layout_can_return_pending_before_committing_a_complete_frame() { }), ) .unwrap(); + // 只准备根节点,后续 begin_layout_frame 才启动可分片布局。 let root = session .bridge .prepare_frame(&mut session.candidate.vm) .unwrap() .unwrap(); + // 保存工作枚举所有权,每次 Pending 都要把续体重新交回 resume。 let mut progress = session .bridge .begin_layout_frame(&mut session.candidate.vm, root) .unwrap(); + // 首次必须为 Pending 且 VM 有暂停调用,否则未测到桥接的恢复契约。 assert!(matches!( progress, crate::ui_bridge::LayoutProgress::Pending(_) )); assert!(session.candidate.vm.has_suspended_call()); + // 按返回状态继续推进,只有 Complete 分支允许读取最终几何。 loop { + // Pending 携带尚未完成的独占工作;Complete 则携带可检查的完整帧。 match progress { + // 将本片返回的工作移动回桥接,不复制也不重新创建布局,保留已完成片段。 crate::ui_bridge::LayoutProgress::Pending(pending) => { progress = session .bridge .resume_layout_frame(&mut session.candidate.vm, *pending) .unwrap(); } + // 最后子项位置为 39×2=78;完成后 VM 不得遗留暂停调用。 crate::ui_bridge::LayoutProgress::Complete(frame) => { assert_eq!(frame.mounts.last().unwrap().x, 78.0); assert!(!session.candidate.vm.has_suspended_call()); @@ -4454,13 +4560,17 @@ fn bridge_layout_can_return_pending_before_committing_a_complete_frame() { /// 同一 item 在 Row 中重复 40 次,布局需跨多个 VM 切片。 /// 完整帧不能丢失出现位置,第 40 个的 x=78 且 Mount 数量覆盖容器与全部子项。 fn layout_slices_preserve_all_shared_occurrences() { + // 同一业务 item 重复出现 40 次;丢失某个 Mount 会直接改变数量或末项几何。 let children = std::iter::repeat_n("item", 40) .collect::>() .join(", "); + // 源父测量配合原生 Row 排列,跨多个 VM 布局切片验证出现位置保留。 let source = format!( "class Wide: Row {{\n function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult {{ return MeasureResult(340, 60) }}\n}}\nitem = RectBlock(2, 20)\npage = Page()\npage.render(Wide(0, 0, {children}))" ); + // 同步包装会排空切片,测试读取最终文档并通过统计确认内部真实发生多片执行。 let (mut session, initial) = ReplSession::load(&source, "app.main", 269, 1).unwrap(); + // 完整帧至少包含容器及 40 个子项,最后一个出现的源 bounds.x 必须为 78。 initial.validate().unwrap(); assert!(initial.frame.mounts.len() >= 41); assert!(source_measure_stat(&mut session, "layoutSlices") > 1); @@ -4477,6 +4587,7 @@ fn layout_slices_preserve_all_shared_occurrences() { /// 有限 Row 把带换行文字的 flex Column 缩窄,文字自然高度必须重新测量,不能沿用早期宽约束下的一行高度。 /// 将文字改短后高度回到 20,确认新求解不把先前分配冻结为永久尺寸。 fn nested_flex_refreshes_natural_height_when_cross_width_changes() { + // 父 Row 只提供 240 宽,内部 flex Column 的文字需在最终分配宽度下重新测量自然高度。 let (mut session, initial) = ReplSession::load( r#" class Narrow: Row { @@ -4494,14 +4605,18 @@ fn nested_flex_refreshes_natural_height_when_cross_width_changes() { 1, ) .unwrap(); + // 先确认发布文档有效,再通过源 Mount API 读取真实排列后的文字高度。 initial.validate().unwrap(); + // 把源返回的数值转为浮点,比较是否多于一行 20;不能冻结首次宽测量得到的单行高度。 let height: f32 = session .execute("paragraph.mounts().at(0).bounds().height", "app.main") .unwrap() .value .parse() .unwrap(); + // 长文字在窄约束下必须换行;随后改短以检验新一轮求解没有沿用旧的分配高度。 assert!(height > 20.0); + // 只改原 paragraph 的文字,下一帧应重测为单行 20,而非替换整棵布局树。 session .execute("paragraph.setText(\"short\")", "app.main") .unwrap(); @@ -4518,6 +4633,8 @@ fn nested_flex_refreshes_natural_height_when_cross_width_changes() { /// 给两个 flex 子项显式 basis、不同增长权重和最大宽度,检查达到上限后剩余空间再分配。 /// 随后切换为收缩并让首项触及最小值,再恢复 auto basis,验证每次新求解都重新计算自然尺寸。 fn bounded_flex_redistributes_growth_and_scaled_shrink() { + // 父宽 340 扣两侧 padding 20、两个 gap 共 20、固定项 60,留给两个 flex 项的总宽为 240。 + // 各 basis 为 100,增长权重 1:3;首项达到 max=105 后,多余空间应重新分配给第二项。 let (mut session, initial) = ReplSession::load( r#" class Wide: Row { @@ -4540,22 +4657,28 @@ fn bounded_flex_redistributes_growth_and_scaled_shrink() { 1, ) .unwrap(); + // 确认最终帧合法,以下查询必须读取出现位置几何,不能只读业务 width 自然值。 initial.validate().unwrap(); + // 本测试闭包按业务名查询首个 Mount 的宽度,供三轮求解共用。 let width = |session: &mut ReplSession, name| { session .execute(&format!("{name}.mounts().at(0).bounds().width"), "app.main") .unwrap() .value }; + // 增长阶段分配为 105/135,总宽仍为 240;首项被上限冻结后剩余增长量转给第二项。 assert_eq!(width(&mut session, "first"), "105"); assert_eq!(width(&mut session, "second"), "135"); + // 切换为无增长、等收缩权重且 basis 均为 200;首项 min=150 触底后,其余收缩应由第二项承担。 session.execute( "first.flex = 0\nsecond.flex = 0\nfirst.flexMax = 300\nfirst.flexMin = 150\nfirst.flexBasis = 200\nsecond.flexBasis = 200\nfirst.flexShrink = 1\nsecond.flexShrink = 1", "app.main", ).unwrap(); + // 收缩结果为 150/90,固定项仍为 60;不能把固定项也拿来吸收缺口。 assert_eq!(width(&mut session, "first"), "150"); assert_eq!(width(&mut session, "second"), "90"); assert_eq!(width(&mut session, "fixed"), "60"); + // 恢复 auto basis 并移除首项界限,求解应从自然宽 20/40 重新开始,不沿用前次分配。 session.execute( "first.flexMin = 0\nfirst.flexMax = 1000000\nfirst.flexBasis = \"auto\"\nsecond.flexBasis = \"auto\"", "app.main", @@ -4568,6 +4691,7 @@ fn bounded_flex_redistributes_growth_and_scaled_shrink() { /// 同一段文字分别出现在两个 Row;首个 Row 的可伸缩兄弟权重改变会挤窄文字。 /// 只第一个出现位置的换行高度增加,第二个保持原高度,证明约束与自然尺寸缓存按 Mount 隔离。 fn native_flex_reflows_shared_text_per_occurrence() { + // 同一 paragraph 分别挂到两个 Row;只有首个 Row 的兄弟 filler 具有后续会改动的增长权重。 let (mut session, initial) = ReplSession::load( r#" paragraph = TextBlock("Shared text has one object identity and separate layout constraints in each occurrence. Changing the sibling weight reflows only the affected occurrence.") @@ -4584,7 +4708,9 @@ fn native_flex_reflows_shared_text_per_occurrence() { 1, ) .unwrap(); + // 先验证完整发布,再按 Mount 索引分别读取共享文字的两个出现高度。 initial.validate().unwrap(); + // 闭包返回指定出现位置的最终高度,显式转 f32 供换行高度比较使用。 let height = |session: &mut ReplSession, index| -> f32 { session .execute( @@ -4596,9 +4722,13 @@ fn native_flex_reflows_shared_text_per_occurrence() { .parse() .unwrap() }; + // 首个出现的初始高度作为受影响位置的基线。 let before = height(&mut session, 0); + // 第二个出现单独保存基线,验证修改另一个父上下文不会污染其测量缓存。 let other = height(&mut session, 1); + // 先确认夹具确实让两个位置获得不同换行高度,而非恰好相同的约束。 assert!(before > other); + // 增大首个 Row 兄弟的权重以挤窄文字;只有第一个出现应增高,第二个必须保持基线。 session.execute("filler.flex = 3", "app.main").unwrap(); assert!(height(&mut session, 0) > before); assert_eq!(height(&mut session, 1), other); @@ -4608,6 +4738,7 @@ fn native_flex_reflows_shared_text_per_occurrence() { /// 有限宽度 Row 先扣 padding、gap 与固定子项,再把 240 剩余宽度按 1:2 分配。 /// 修改权重为 3:1 后同一出现位置实时改为 180/60,验证分配结果反过来约束子测量。 fn weighted_flex_remeasures_children_and_updates_live_weights() { + // 父宽 340 扣除 padding 20、gap 20 和固定项 60,剩余 240 由 first/last 按增长权重分配。 let (mut session, initial) = ReplSession::load(r#" class Wide: Row { function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(340, 40) } @@ -4621,6 +4752,7 @@ fn weighted_flex_remeasures_children_and_updates_live_weights() { page = Page() page.render(row) "#, "app.main", 265, 1).unwrap(); + // 有效首帧中权重 1:2 应分得 80/160,检查 Mount 宽度而不是自然业务 width。 initial.validate().unwrap(); assert_eq!( session @@ -4636,6 +4768,7 @@ fn weighted_flex_remeasures_children_and_updates_live_weights() { .value, "160" ); + // 在同一会话把权重改成 3:1,依赖变化应重新分配为 180/60,不能沿用首次分配缓存。 session .execute("first.flex = 3\nlast.flex = 1", "app.main") .unwrap(); @@ -4659,6 +4792,7 @@ fn weighted_flex_remeasures_children_and_updates_live_weights() { /// 表驱动切换 start/center/end/space-between,逐项比较两个子项的 x。 /// 子项宽度始终为自然宽度,mainAlign 只分配剩余间隙,不应被误当成 flex 拉伸。 fn main_alignment_updates_live_geometry_without_changing_sizes() { + // 固定父宽 300、左右 padding 各 10,两子项自然宽 20/40、基础间距 10,留出可对齐的剩余空间。 let (mut session, initial) = ReplSession::load(r#" class Wide: Row { function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(300, 60) } @@ -4669,16 +4803,20 @@ fn main_alignment_updates_live_geometry_without_changing_sizes() { page = Page() page.render(row) "#, "app.main", 264, 1).unwrap(); + // 确认基线可发布,后面只改 mainAlign 属性,不重新创建容器或子项。 initial.validate().unwrap(); + // 枚举四种主轴对齐模式及两个预期 x:center 平分剩余空间,end 全放前部,space-between 放入项间。 for (mode, first, second) in [ ("start", "10", "40"), ("center", "115", "145"), ("end", "220", "250"), ("space-between", "10", "250"), ] { + // 源字段写入触发实时布局,逐轮使用同一组对象检查对齐策略切换。 session .execute(&format!("row.mainAlign = \"{mode}\""), "app.main") .unwrap(); + // 先核对首项和第二项位置,再检查首项自然宽仍为 20;对齐不应变成 flex 拉伸。 assert_eq!( session .execute("first.mounts().at(0).bounds().x", "app.main") @@ -4707,6 +4845,7 @@ fn main_alignment_updates_live_geometry_without_changing_sizes() { /// 同一 RectBlock 分别放在 Row 和 Column,以两个父容器的 crossAlign 得到独立坐标。 /// alignSelf 显式值覆盖各父策略,恢复 auto 后再次继承,且修改一个父容器不影响另一个出现位置。 fn cross_alignment_is_live_and_specific_to_each_parent_occurrence() { + // 共享矩形分别出现在 Row 与 Column,交叉轴方向不同;父策略分别为 center 与 end。 let (mut session, initial) = ReplSession::load(r#" class Tall: Row { function measure(self: Tall, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) } @@ -4722,18 +4861,22 @@ fn cross_alignment_is_live_and_specific_to_each_parent_occurrence() { page = Page() page.render(Column(0, 0, row, column)) "#, "app.main", 294, 1).unwrap(); + // 先验证初始帧,随后核对两个出现位置各自的坐标,包含第二容器在外层 Column 中的偏移。 initial.validate().unwrap(); + // 首项在 Row 的 y 上居中,次项在 Column 的 x 上靠末端,不能把两个父上下文合并。 for (expression, expected) in [ ("shared.mounts().at(0).bounds().x", "10"), ("shared.mounts().at(0).bounds().y", "40"), ("shared.mounts().at(1).bounds().x", "170"), ("shared.mounts().at(1).bounds().y", "110"), ] { + // 逐表达式读取源 Mount 几何,让位置比较覆盖实际发布布局而非静态属性。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } + // 业务对象上的显式 start 覆盖两个父容器的默认策略,两处交叉轴位置都应回到 padding 起点。 session .execute("shared.alignSelf = \"start\"", "app.main") .unwrap(); @@ -4751,6 +4894,7 @@ fn cross_alignment_is_live_and_specific_to_each_parent_occurrence() { .value, "10" ); + // 恢复 auto 后重新继承各自父策略;只把 Row 改为 end,Column 的另一出现应仍保持原来的 end。 session .execute( "shared.alignSelf = \"auto\"\nrow.crossAlign = \"end\"", -- 2.52.0 From 3ed148c8807e0b74bb554a26007491f57f2bd838 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:51:21 +0800 Subject: [PATCH 08/78] docs: explain box layout, camera projection and 3d picking tests --- src/runtime/endor_host/tests.rs | 113 ++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index d80989b..9e10a88 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -4921,6 +4921,7 @@ fn cross_alignment_is_live_and_specific_to_each_parent_occurrence() { /// 源 arrange 主动把第一个子项 y 改为 7,后续原生 crossAlign 不能覆盖源给定位置。 /// 另一子项比父交叉轴更宽时按起点溢出,center 不应产生负起点。 fn cross_alignment_preserves_source_placements_and_starts_overflow() { + // 一个源 Row.arrange 显式指定 y=7,另一个源 Column.measure 只分配宽 10;分别覆盖源位置优先及自然子项溢出。 let (mut session, initial) = ReplSession::load(r#" class Placed: Row { function arrange(self: Placed, mount: Mount, geometry: Bounds): Arrangement { @@ -4941,7 +4942,9 @@ fn cross_alignment_preserves_source_placements_and_starts_overflow() { page = Page() page.render(Column(0, 0, placed, small)) "#, "app.main", 295, 1).unwrap(); + // 通过正常加载收敛并验证文档,以下检查读取最终 Mount 坐标而非临时 Arrangement。 initial.validate().unwrap(); + // 即使父 crossAlign=end,源 arrange 已给定的 y=7 也不能被后续原生对齐覆盖。 assert_eq!( session .execute("first.mounts().at(0).bounds().y", "app.main") @@ -4949,6 +4952,7 @@ fn cross_alignment_preserves_source_placements_and_starts_overflow() { .value, "7" ); + // 子宽 40 大于父宽 10,center 应从起点溢出,不能用负剩余空间计算出负偏移。 assert_eq!( session .execute("second.mounts().at(0).bounds().x", "app.main") @@ -4962,6 +4966,7 @@ fn cross_alignment_preserves_source_placements_and_starts_overflow() { /// 带不对称 margin 的共享子项分别参与有限 flex Row 和自然尺寸 Row。 /// 检查定位、交叉轴居中、剩余宽度与容器固有尺寸;增加右 margin 同时挤窄 flex 兄弟并扩大自然父宽度。 fn margins_participate_in_intrinsic_size_alignment_and_flex_allocation() { + // 同一 first 带左右 3/7、上下 5/15 的 margin,分别参与有限分配与自然尺寸两种父布局。 let (mut session, initial) = ReplSession::load(r#" class Wide: Row { function measure(self: Wide, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(200, 100) } @@ -4979,7 +4984,10 @@ fn margins_participate_in_intrinsic_size_alignment_and_flex_allocation() { page = Page() page.render(Column(0, 0, row, intrinsic)) "#, "app.main", 296, 1).unwrap(); + // 先验证文档,随后比较位置、兄弟分配和自然父尺寸,覆盖 margin 进入盒模型的各处。 initial.validate().unwrap(); + // 有限 Row 的首项 x=10+3,后项 x=10+3+20+7+10;剩余宽 140 给 flex 项。 + // 自然 Row 宽为 20 padding+30 首项外宽+10 gap+30 后项,高为 20 padding+40 最大外高。 for (expression, expected) in [ ("first.mounts().at(0).bounds().x", "13"), ("first.mounts().at(0).bounds().y", "35"), @@ -4988,15 +4996,18 @@ fn margins_participate_in_intrinsic_size_alignment_and_flex_allocation() { ("intrinsic.mounts().at(0).bounds().width", "90"), ("intrinsic.mounts().at(0).bounds().height", "60"), ] { + // 逐表达式核对真实几何,错误信息保留表达式以区分定位与尺寸求解问题。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected, "{expression}" ); } + // 将共享 first 的右 margin 增加 10,两个出现位置都需刷新,但有限父和自然父的变化方向不同。 session .execute("first.marginRight = 17", "app.main") .unwrap(); + // 有限 Row 总宽不变,flex 兄弟让出 10;自然 Row 则增加 10,证明 margin 不是只影响绘制偏移。 assert_eq!( session .execute("second.mounts().at(0).bounds().width", "app.main") @@ -5017,6 +5028,7 @@ fn margins_participate_in_intrinsic_size_alignment_and_flex_allocation() { /// 在固定尺寸 Overlay 内嵌带边框/不对称 padding 的 Column,子项还有 margin。 /// 比较三层绝对坐标与边框绘制命令,再动态改变 min/max,验证对齐与内容偏移使用同一盒模型。 fn nested_overlay_uses_border_boxes_and_asymmetric_content_insets() { + // 固定 Overlay 中放置固定 Column,Column 有非对称 padding/border,子项另有 margin,构造三层偏移。 let (mut session, initial) = ReplSession::load( r##" child = RectBlock(20, 10) @@ -5043,7 +5055,9 @@ fn nested_overlay_uses_border_boxes_and_asymmetric_content_insets() { 1, ) .unwrap(); + // 完成布局后逐层查几何,让容器对齐、内容内缩和子项 margin 使用同一坐标基准。 initial.validate().unwrap(); + // Column 的外框在 (50,70),子项 x=50+3+11+5=69、y=70+2+7+4=83。 for (expression, expected) in [ ("overlay.mounts().at(0).bounds().width", "300"), ("column.mounts().at(0).bounds().x", "50"), @@ -5052,13 +5066,16 @@ fn nested_overlay_uses_border_boxes_and_asymmetric_content_insets() { ("child.mounts().at(0).bounds().x", "69"), ("child.mounts().at(0).bounds().y", "83"), ] { + // 核对边框盒尺寸和绝对坐标,避免只看像素颜色而遗漏布局数值偏差。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected, "{expression}" ); } + // 红色左边框必须在 Column 外框左缘生成宽 3、高 120 的命令,不能画到内容起点。 assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::RoundedRect { x, y, width, height, color, .. } if *x == 50.0 && *y == 70.0 && *width == 3.0 && *height == 120.0 && color == "#FF0000"))); + // 同一 Column 的显式尺寸还要受 maxWidth/minHeight 夹取,字段更新后应变成 160×140。 session .execute("column.maxWidth = 160\ncolumn.minHeight = 140", "app.main") .unwrap(); @@ -5082,6 +5099,7 @@ fn nested_overlay_uses_border_boxes_and_asymmetric_content_insets() { /// 文字 boxWidth=fill 在有限父宽中获得整个边框盒,真正换行宽度再扣左右 padding。 /// 另构造无界 Column 主轴上的 fill 高度,应明确拒绝无法定义的有限分配。 fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() { + // 有限宽 160 的 Column 为 fill 文字分配边框盒,文字自身 padding 再减少实际排字宽度。 let (mut session, initial) = ReplSession::load(r#" text = TextBlock("Text wrapping follows the finite border box rather than the original unbounded column width.") text.boxWidth = "fill" @@ -5092,6 +5110,7 @@ fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() { page = Page() page.render(column) "#, "app.main", 298, 1).unwrap(); + // 确认文档有效,并通过 Mount 查询核对 fill 后的外框宽度恰为 160。 initial.validate().unwrap(); assert_eq!( session @@ -5100,7 +5119,9 @@ fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() { .value, "160" ); + // 排字起点右移 8,wrap 宽为 160−8−12=140;不能把外框宽直接当作文字可用宽。 assert!(initial.frame.commands.iter().any(|command| matches!(command, RenderCommand::Text { x, wrap_bounds: Some([width, _]), .. } if *x == 8.0 && *width == 140.0))); + // 另一程序让自然高度 Column 中的子项 boxHeight=fill;主轴无有限分配,无法定义填满目标。 let error = ReplSession::load( r#" rect = RectBlock(10, 10) @@ -5114,6 +5135,7 @@ fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() { ) .err() .expect("unbounded fill must fail"); + // 错误应明确说明 fill 缺少有限父分配,不能以任意其他加载失败替代该检查。 assert!( format!("{error:#}").contains("fill requires a finite parent allocation"), "{error:#}" @@ -5124,6 +5146,7 @@ fn fill_wraps_text_inside_finite_column_and_rejects_unbounded_axis() { /// 让子按钮超出带左边框且 overflow=clip 的固定 Row。 /// 子 Mount 保留原几何,但 clip 仅覆盖父内容区域;命中在原按钮矩形内也应因裁剪而失败。 fn clipping_a_box_limits_descendant_paint_and_hit_geometry() { + // 固定 Row 宽 60 且左边框宽 2,前项先占宽 100,后按钮保留位于父内容区之外的原几何。 let (_, initial) = ReplSession::load(r#" class Overflow: RectBlock { function measure(self: Overflow, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(100, 20) } @@ -5138,15 +5161,19 @@ fn clipping_a_box_limits_descendant_paint_and_hit_geometry() { page = Page() page.render(row) "#, "app.main", 300, 1).unwrap(); + // 确认发布结构合法,随后从完整帧定位唯一可交互按钮。 initial.validate().unwrap(); + // 按 interactive 找后按钮;裁剪不能通过删除 Mount 或篡改其原位置来实现。 let child = initial .frame .mounts .iter() .find(|mount| mount.interactive) .unwrap(); + // 有效裁剪只覆盖扣去边框的父内容区 [2,0,58,40],子项 x 仍应在 100 之外。 assert_eq!(child.clip, Some([2.0, 0.0, 58.0, 40.0])); assert!(child.x >= 100.0); + // 选择子矩形内部点调用整帧命中测试,仍必须因父裁剪而找不到目标。 assert!( initial .frame @@ -5159,6 +5186,8 @@ fn clipping_a_box_limits_descendant_paint_and_hit_geometry() { /// 两个 fill 子项分享扣去 margin/gap/固定项后的主轴空间,其中一项触及 maxWidth 后重新分配。 /// 交叉轴 stretch 仍受 maxHeight 和 alignSelf 限制;增大父宽后首项吸收新增空间。 fn main_axis_fill_shares_remaining_space_and_stretch_respects_box_limits() { + // 父宽 240 扣 padding 20、gap 10、首项 margin 5 和固定项 40,留给两项 fill 的总宽为 165。 + // 第二项 maxWidth=50 后首项吸收剩余 115;交叉轴 stretch 另受 maxHeight/alignSelf 限制。 let (mut session, initial) = ReplSession::load( r#" first = RectBlock(10, 10) @@ -5182,7 +5211,9 @@ fn main_axis_fill_shares_remaining_space_and_stretch_respects_box_limits() { 1, ) .unwrap(); + // 先验证整体文档,再独立检查主轴分配、交叉轴限制和固定项居中位置。 initial.validate().unwrap(); + // 首项高度被 maxHeight 夹为 30,第二项填满内容高 60;固定项显式 center 得到 y=30。 for (expression, expected) in [ ("first.mounts().at(0).bounds().width", "115"), ("second.mounts().at(0).bounds().width", "50"), @@ -5190,12 +5221,14 @@ fn main_axis_fill_shares_remaining_space_and_stretch_respects_box_limits() { ("second.mounts().at(0).bounds().height", "60"), ("fixed.mounts().at(0).bounds().y", "30"), ] { + // 从各自 Mount 读取最终分配,不能把业务自然尺寸当作 fill/stretch 的结果。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected, "{expression}" ); } + // 父宽增加 40,第二项仍卡在最大宽 50,新增空间应全部给首项,得到 155。 session.execute("row.boxWidth = 280", "app.main").unwrap(); assert_eq!( session @@ -5210,6 +5243,7 @@ fn main_axis_fill_shares_remaining_space_and_stretch_respects_box_limits() { /// 通过真实 VM 加载盒布局示例并验证完整文档。 /// 共享文字有两个 Mount,父盒宽度不同产生不同分配,防止示例只在说明文档中成立。 fn box_layout_example_runs_through_the_real_source_pipeline() { + // 直接加载仓库中的盒布局示例,以真实 VM、桥接布局和文档发布检验示例可执行性。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/box-layout.endor"), "app.main", @@ -5217,6 +5251,7 @@ fn box_layout_example_runs_through_the_real_source_pipeline() { 1, ) .unwrap(); + // 共享文字必须有两个 Mount,才能比较不同父盒约束下的独立分配。 initial.validate().unwrap(); assert_eq!( session @@ -5225,12 +5260,14 @@ fn box_layout_example_runs_through_the_real_source_pipeline() { .value, "2" ); + // 取得第一个出现的最终宽度并转为数值,避免字符串字典序比较。 let left = session .execute("shared.mounts().at(0).bounds().width", "app.main") .unwrap() .value .parse::() .unwrap(); + // 第二个出现单独读宽,较窄父盒应得到更小分配,不能共享第一次测量结果。 let right = session .execute("shared.mounts().at(1).bounds().width", "app.main") .unwrap() @@ -5244,6 +5281,7 @@ fn box_layout_example_runs_through_the_real_source_pipeline() { /// 固定 Overlay 按内容高度拉伸子项,再受 margin/maxHeight 限制得到 50。 /// 改为显式 boxHeight 后使用 15,证明显式尺寸优先于 stretch,覆盖层不按线性子项重排。 fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() { + // Overlay 高 80 扣上下 padding 10 和 child 顶 margin 3,stretch 原可分配 67,再受 maxHeight=50 限制。 let (mut session, initial) = ReplSession::load( r#" child = RectBlock(20, 10) @@ -5261,6 +5299,7 @@ fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() { 1, ) .unwrap(); + // 文档有效后核对 child 高 50 且 y=5+3=8,验证 margin 在拉伸与定位中一致使用。 initial.validate().unwrap(); assert_eq!( session @@ -5276,6 +5315,7 @@ fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() { .value, "8" ); + // 改为显式高度 15 后应优先采用该值,父 stretch 不得再次覆盖它。 session.execute("child.boxHeight = 15", "app.main").unwrap(); assert_eq!( session @@ -5290,6 +5330,7 @@ fn overlay_stretch_allocates_each_occurrence_without_reflowing_its_siblings() { /// 两个有限视口共享同一 Scene2D,默认相机独立,出现位置各有变换和裁剪。 /// 点击先经变换命中;只改首个相机不测量业务对象,显式共享 camera 后两边才同步缩放。 fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { + // 两个有限 CanvasViewport 共享同一 Scene2D/button,初始只修改 first 的默认相机。 let (mut session, initial) = ReplSession::load( r#" hits = ui.state(0) @@ -5310,6 +5351,7 @@ fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { 1, ) .unwrap(); + // 共享业务按钮应拥有两个 Mount;相机和裁剪属于各出现上下文,而非按钮自身。 initial.validate().unwrap(); assert_eq!( session @@ -5318,19 +5360,24 @@ fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { .value, "2" ); + // 按文档顺序收集两处交互 Mount,分别核对缩放变换与左右面板裁剪。 let buttons: Vec<_> = initial .frame .mounts .iter() .filter(|mount| mount.interactive) .collect(); + // 首面板 zoom=2,第二面板默认相机无额外变换;两者 clip 分别位于 x=0 和 x=220。 assert_eq!(buttons.len(), 2); assert_eq!(buttons[0].view.unwrap().zoom, 2.0); assert!(buttons[1].view.is_none()); assert_eq!(buttons[0].clip, Some([0.0, 0.0, 200.0, 100.0])); assert_eq!(buttons[1].clip, Some([220.0, 0.0, 200.0, 100.0])); + // 保存首个出现身份,后续改相机应保留该 ID。 let first_id = buttons[0].id; + // 选取经首相机投影后位于按钮内的屏幕坐标,命中必须先还原场景位置。 let point = [45.0, 45.0]; + // 先核对整帧命中选择,再沿原生派发路径发送同一坐标,确保几何与事件路由一致。 assert_eq!( initial .frame @@ -5339,6 +5386,7 @@ fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { .id, first_id ); + // 有效文档版本下点击首个出现,只有共享业务计数递增一次。 session .dispatch(&crate::events::Input { window: crate::window::MAIN_WINDOW, @@ -5357,22 +5405,27 @@ fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { session.execute("hits.value", "app.main").unwrap().value, "1" ); + // 只修改 first.camera 平移/缩放,第二面板不能被共享场景对象带着改变。 let changed = session .execute("first.camera.x = -40\nfirst.camera.zoom = 1", "app.main") .unwrap() .document .unwrap(); + // 重新发布后的交互 Mount 仍按各面板独立投影,几何结构应有效。 changed.validate().unwrap(); + // 从新文档重新取出现位置,不能借用旧帧中的 view 判断更新是否生效。 let buttons: Vec<_> = changed .frame .mounts .iter() .filter(|mount| mount.interactive) .collect(); + // 首个 Mount 身份保持、平移变为 40;第二个仍无额外变换,业务测量数为零。 assert_eq!(buttons[0].id, first_id); assert_eq!(buttons[0].view.unwrap().x, 40.0); assert!(buttons[1].view.is_none()); assert_eq!(source_measure_stat(&mut session, "measured"), 0); + // 显式把 second.camera 指向 first.camera 后再缩放,此时两个面板才应共享相机状态。 let shared = session .execute( "second.camera = first.camera\nfirst.camera.zoom = 2", @@ -5381,6 +5434,7 @@ fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { .unwrap() .document .unwrap(); + // 验证新帧并检查所有交互出现都采用 zoom=2,区分显式共享和初始默认独立。 shared.validate().unwrap(); assert!( shared @@ -5396,6 +5450,7 @@ fn two_canvas_viewports_share_a_scene_with_independent_explicit_cameras() { /// 嵌套二维相机的缩放相乘、平移复合,检查最终变换和相交裁剪。 /// 手势只驱动内层相机,外层不动;最后软件绘制采样确认固定 HUD 与变换后内容均位于预期像素。 fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { + // 内相机缩放 0.5、外相机缩放 2,嵌套面板位置参与复合变换;HUD 是外部 Overlay 的独立兄弟。 let (mut session, initial) = ReplSession::load( r##" button = Button("nested") @@ -5418,13 +5473,16 @@ fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { 1, ) .unwrap(); + // 先确认发布结构,再定位嵌套按钮,检查复合投影及可见区域。 initial.validate().unwrap(); + // 唯一可交互节点来自最内 Scene2D,携带两层相机叠加后的有效变换。 let button = initial .frame .mounts .iter() .find(|mount| mount.interactive) .unwrap(); + // 最终 zoom=1,Transform 按 point×zoom+(x,y) 投影,平移为 (20,20);clip 在视图坐标中比较。 assert_eq!( button.view.unwrap(), crate::viewport::Transform { @@ -5434,9 +5492,12 @@ fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { } ); assert_eq!(button.clip, Some([40.0, 40.0, 160.0, 120.0])); + // 屏幕点 (55,55) 同时用于交互命中和选取最内层可控相机。 let point = crate::geometry::Point::new(55.0, 55.0); assert_eq!(initial.frame.hit_test(point).unwrap().id, button.id); + // camera_at 应优先命中内层视口,平移手势不能误控制外层相机。 let inner_id = initial.frame.camera_at(point).unwrap().id; + // 构造针对内层视口的 viewport-pan,delta 通过宿主相机手势入口消费。 let input = crate::events::Input { window: 1, session: initial.frame.session, @@ -5449,6 +5510,7 @@ fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { tab_drop: None, screen_position: None, }; + // 派发后内相机 x 应变为 10,外相机 x 仍为零,验证手势目标选择。 session.dispatch(&input).unwrap(); assert_eq!( session.execute("inner.camera.x", "app.main").unwrap().value, @@ -5458,12 +5520,15 @@ fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { session.execute("outer.camera.x", "app.main").unwrap().value, "0" ); + // 显式使 HUD paint 失效以取得最新完整文档,再检验真实像素而非仅投影字段。 let document = session .execute("hud.invalidate(\"paint\")", "app.main") .unwrap() .document .unwrap(); + // 分配 200×200 软件输出缓冲,1x DPI 下采样点与逻辑坐标一致。 let mut pixels = vec![0; 200 * 200]; + // 执行真实光栅化以核对叠加层与变换后内容,补足仅检查投影字段无法覆盖的绘制路径。 crate::software_renderer::SoftwareRenderer::render( &document.display_list(1.0).unwrap(), &mut pixels, @@ -5471,6 +5536,7 @@ fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { 200, ) .unwrap(); + // HUD 原点蓝色保持固定;场景采样与远处背景不同,证明平移后的内容确实被绘出。 assert_eq!(pixels[5 * 200 + 5], 0x0000FF); assert_ne!(pixels[55 * 200 + 45], pixels[190 * 200 + 190]); } @@ -5479,6 +5545,7 @@ fn nested_canvas_projects_pixels_and_hits_while_overlay_ui_stays_fixed() { /// 在负世界 x 处放红矩形,经相机平移和二倍缩放映射到有限视口。 /// 实际像素需显示固定蓝色覆盖层、可见红块和视口外背景,检验命令投影与裁剪最终进入光栅化。 fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() { + // 红矩形位于负世界 x,经相机平移与二倍缩放进入 50×50 视口;蓝色 HUD 不随相机变化。 let (_, initial) = ReplSession::load( r##" rect = RectBlock(20, 20, 0, "#FF0000") @@ -5495,8 +5562,11 @@ fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() { 1, ) .unwrap(); + // 文档通过结构验证后再做像素测试,区分投影数据有效与实际绘制正确。 initial.validate().unwrap(); + // 输出 80×80,大于有限视口,留出明确的视口外背景采样点。 let mut pixels = vec![0; 80 * 80]; + // 用 1x 显示列表运行软件绘制,包含世界投影、面板裁剪和 Overlay 绘制顺序。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.0).unwrap(), &mut pixels, @@ -5504,6 +5574,7 @@ fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() { 80, ) .unwrap(); + // 依次核对固定蓝层、可见红块、视口外背景以及矩形右侧背景,捕获缩放或裁剪遗漏。 assert_eq!(pixels[5 * 80 + 5], 0x0000FF); assert_eq!(pixels[25 * 80 + 5], 0xFF0000); assert_eq!(pixels[55 * 80 + 5], pixels[79 * 80 + 79]); @@ -5514,6 +5585,7 @@ fn canvas_world_paint_is_zoomed_and_clipped_in_the_software_renderer() { /// 加载场景视口示例并确认共享按钮出现两次。 /// 再实际构造 ScrollView,检查它保持 ScrollBlock 名义类型兼容,而非只在文本说明中存在的别名。 fn scene_viewport_example_and_scroll_view_alias_run_as_real_source() { + // 直接执行场景视口示例,确认共享 increment 按钮在两个视口中各有一个出现。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/scene-viewports.endor"), "app.main", @@ -5521,6 +5593,7 @@ fn scene_viewport_example_and_scroll_view_alias_run_as_real_source() { 1, ) .unwrap(); + // 先检查示例文档和出现数量,再单独验证 ScrollView 的实际构造与名义类型兼容。 initial.validate().unwrap(); assert_eq!( session @@ -5529,12 +5602,14 @@ fn scene_viewport_example_and_scroll_view_alias_run_as_real_source() { .value, "2" ); + // 源码构造 ScrollView 实例并保存在全局,不只是检查名字可查询或文档宣称存在别名。 session .execute( "testScroll = ScrollView(100, 60, Row(0, 0, TextBlock(\"scroll\")))", "app.main", ) .unwrap(); + // 新实例仍应满足 ScrollBlock 名义类型,保证兼容原有滚动 API。 assert_eq!( session .execute("ui.isInstance(testScroll, ScrollBlock)", "app.main") @@ -5548,6 +5623,7 @@ fn scene_viewport_example_and_scroll_view_alias_run_as_real_source() { /// 场景内容在负坐标,按当前 Mount 的有限尺寸 fitView。 /// 比较相机 zoom/pan 与矩形投影后的 48px 留白,验证适配的是场景边界而非业务对象原始正坐标假设。 fn fit_view_uses_finite_scene_bounds_and_this_mounts_size() { + // 场景矩形跨负坐标,视口固定为 296×196,测试 fitView 不假设世界内容从原点向正方向展开。 let (mut session, initial) = ReplSession::load( r#" rect = RectBlock(100, 50) @@ -5562,29 +5638,36 @@ fn fit_view_uses_finite_scene_bounds_and_this_mounts_size() { 1, ) .unwrap(); + // 基线文档有效后,针对视口的具体 Mount 调用 fitView,使用该出现位置的有限尺寸。 initial.validate().unwrap(); + // fitView 更新相机并发布新文档,后续投影必须从新帧取值。 let document = session .execute("view.mounts().at(0).fitView()", "app.main") .unwrap() .document .unwrap(); + // 验证适配后的完整文档,再比较相机缩放和平移结果。 document.validate().unwrap(); + // 扣两边各 48 的留白后,宽约束允许 zoom=2;x/y 还需补偿负世界边界以居中放置内容。 for (expression, expected) in [ ("view.camera.zoom", "2"), ("view.camera.x", "-124"), ("view.camera.y", "-49"), ] { + // 直接读取源相机状态,排除只在绘制命令上临时移动而未更新业务相机的实现。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } + // 从适配后的帧找场景矩形,其逻辑几何保持 100×50,仅视图变换改变。 let rect = document .frame .mounts .iter() .find(|mount| mount.width == 100.0 && mount.height == 50.0) .unwrap(); + // 用出现位置附带的 view 投影矩形左上角,应落在 (48,48),与相机字段断言互相核对。 let screen = rect .view .unwrap() @@ -5596,6 +5679,7 @@ fn fit_view_uses_finite_scene_bounds_and_this_mounts_size() { /// 同一场景用透视和正交相机各显示一次,前景蓝盒应遮住后景红盒。 /// 比较拾取距离、交点和实际中心像素;移动前景并只修改透视相机后,两视图露出后景且相机 z 保持独立。 fn real_scene3d_renders_depth_with_independent_perspective_and_orthographic_cameras() { + // 透视与正交视口共享前蓝后红两个盒子,ambient=1 便于用准确材质色比较遮挡像素。 let (mut session, initial) = ReplSession::load( r##" back = Box3D(2, 2, 2, "#FF0000") @@ -5615,28 +5699,36 @@ fn real_scene3d_renders_depth_with_independent_perspective_and_orthographic_came 1, ) .unwrap(); + // 确认首帧合法,再从文档命令中取两套实际三维视图,不重新构造近似渲染数据。 initial.validate().unwrap(); + // 只保留 Scene3D 命令里的 view,按左右视口顺序用于相机和射线断言。 let commands: Vec<_> = initial .frame .commands .iter() .filter_map(|command| { + // 三维命令携带渲染/拾取使用的同一场景快照,其他 UI 命令不参与本组比较。 if let RenderCommand::Scene3D { view, .. } = command { Some(view) } else { + // 跳过容器等非三维命令,避免把显示列表全部命令数误当作视口数量。 None } }) .collect(); assert_eq!(commands.len(), 2); + // 透视视口中心射线应先击中前盒 z=2 的正面,距离相机 3,不能依场景子项次序选择后盒。 let hit = commands[0].pick(0.5, 0.5, 1.2).unwrap(); assert!((hit.distance - 3.0).abs() < 1e-5); assert_eq!(hit.point, crate::scene3d::math::Vec3::new(0., 0., 2.)); + // 第二个视图必须保留显式正交投影 height=4,不能因共享 scene 误用透视相机。 assert!(matches!( commands[1].camera.projection, crate::scene3d::math::Projection::Orthographic { height: 4.0 } )); + // 输出 250×100,覆盖两个宽 120、间距 10 的视口,左右中心点分别位于 x=60/190。 let mut pixels = vec![0; 250 * 100]; + // 首帧实际光栅化验证深度遮挡,两视口中心均应显示前盒蓝色。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.).unwrap(), &mut pixels, @@ -5646,25 +5738,31 @@ fn real_scene3d_renders_depth_with_independent_perspective_and_orthographic_came .unwrap(); assert_eq!(pixels[50 * 250 + 60], 0x0000FF); assert_eq!(pixels[50 * 250 + 190], 0x0000FF); + // 把前盒移出中心,同时只把透视相机 z 改为 8;正交相机应保持初始 z=5。 let updated = session .execute("front.x=10\nperspective.camera.z=8", "app.main") .unwrap() .document .unwrap(); + // 新文档必须有效,再检查命令快照里的两台相机位置。 updated.validate().unwrap(); + // 从更新后的 Scene3D 命令收集 z,验证共享的是场景而非两个默认相机对象。 let cameras: Vec<_> = updated .frame .commands .iter() .filter_map(|command| { + // 仅三维视图提供 camera.position,仍按发布顺序比较左右相机。 if let RenderCommand::Scene3D { view, .. } = command { Some(view.camera.position.z) } else { + // 非场景命令不提供相机数据,不参与预期的 [8,5] 列表。 None } }) .collect(); assert_eq!(cameras, vec![8., 5.]); + // 复用像素缓冲绘制新帧;两个中心都应露出后盒红色,不能保留上一帧深度或颜色。 crate::software_renderer::SoftwareRenderer::render( &updated.display_list(1.).unwrap(), &mut pixels, @@ -5680,6 +5778,7 @@ fn real_scene3d_renders_depth_with_independent_perspective_and_orthographic_came /// 先通过 Mount.pick3D 查询中心交点与真实 mesh 材质,再从原生输入派发同位置 click。 /// 回调写 event.hit.mesh.color 后业务对象及最终像素都变绿,证明拾取结果保留正确托管业务身份。 fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() { + // 源 Actions 回调通过 event.hit.mesh 改色,验证拾取结果能映射回原托管业务 Mesh。 let (mut session, initial) = ReplSession::load( r##" class Actions { @@ -5699,6 +5798,7 @@ fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() 1, ) .unwrap(); + // 先在源 API 查询中心拾取:交点 z=1 且材质为红,建立原生输入前的拾取基线。 initial.validate().unwrap(); assert_eq!( session @@ -5714,17 +5814,20 @@ fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() .value, "\"#FF0000\"" ); + // 显式请求重绘取得最新帧,确保随后原生事件携带当前布局版本。 let frame = session .execute("view.invalidate(\"paint\")", "app.main") .unwrap() .document .unwrap() .frame; + // 以 scene3d_viewport 标记选实际三维视口 Mount,而非场景内部网格或普通按钮。 let mount = frame .mounts .iter() .find(|mount| mount.scene3d_viewport) .unwrap(); + // 原生 click 的按下/释放均在视口中心;宿主应附带对应 Hit3D 交给源回调。 let updated = session .dispatch(&crate::events::Input { window: 1, @@ -5741,11 +5844,14 @@ fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() .unwrap() .document .unwrap(); + // 从原全局 mesh 读到绿色,证明 event.hit.mesh 没有指向脱离业务对象的临时副本。 assert_eq!( session.execute("mesh.color", "app.main").unwrap().value, "\"#00FF00\"" ); + // 再准备 100×100 软件像素缓冲,检验业务材质改动确实进入绘制依赖与最终渲染。 let mut pixels = vec![0; 100 * 100]; + // 绘制派发返回的新文档,中心像素必须为绿色,与源字段查询结果一致。 crate::software_renderer::SoftwareRenderer::render( &updated.display_list(1.).unwrap(), &mut pixels, @@ -5760,6 +5866,7 @@ fn ray_pick_returns_managed_mesh_and_native_pointer_event_updates_its_material() /// 运行内置三维示例及其旋转动作,再替换为源构造的自定义三角网格。 /// 另检查 Group3D 自循环和越界三角索引的明确错误,非法场景不能进入发布。 fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { + // 执行仓库的三维示例,用源 Actions.rotate 验证实际交互业务可更新场景。 let (mut session, document) = ReplSession::load( include_str!("../../../examples/scene3d.endor"), "app.main", @@ -5767,17 +5874,20 @@ fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { 1, ) .unwrap(); + // 首帧结构必须有效,动作执行后 cube.rotationY 应更新为 40。 document.validate().unwrap(); session.execute("actions.rotate()", "app.main").unwrap(); assert_eq!( session.execute("cube.rotationY", "app.main").unwrap().value, "40" ); + // 在原会话替换为三个顶点、一个三角面的自定义 Geometry3D,要求产生有效新文档。 session.execute(r##" geometry=Geometry3D(ObjectList(Vec3(-1,-1,0),Vec3(1,-1,0),Vec3(0,1,0)),ObjectList(Triangle3D(0,1,2))) custom=Mesh3D(geometry,"#112233") scene.children=ObjectList(custom) "##,"app.main").unwrap().document.unwrap().validate().unwrap(); + // 独立程序令 Group3D.children 包含自己,必须明确拒绝场景图循环而非无限遍历。 let error = ReplSession::load( r##" group=Group3D() @@ -5792,10 +5902,12 @@ fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { ) .err() .unwrap(); + // 检查完整错误链指向三维图循环,避免任意解析错误也通过。 assert!( format!("{error:#}").contains("cyclic 3D scene graph"), "{error:#}" ); + // 另一个程序仅给一个顶点却引用索引 0/1/2,应在 Geometry3D 缓冲验证时拒绝。 let error = ReplSession::load( r##" geometry=Geometry3D(ObjectList(Vec3(0,0,0)),ObjectList(Triangle3D(0,1,2))) @@ -5806,6 +5918,7 @@ fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { ) .err() .unwrap(); + // 区分非法几何缓冲与前一场景循环诊断,确保两条边界都有覆盖。 assert!( format!("{error:#}").contains("invalid Geometry3D buffers"), "{error:#}" -- 2.52.0 From d9c191ec0046ba2f5ea55beffe5e3d1b10f6900f Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 17:56:13 +0800 Subject: [PATCH 09/78] docs: explain world ui, scroll policies and native identity test paths --- src/runtime/endor_host/tests.rs | 135 ++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index 9e10a88..a5e1d36 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -5929,6 +5929,7 @@ fn scene3d_example_custom_geometry_and_cycle_diagnostics_use_real_source() { /// 旋转 Panel3D 的 UI 以独立 100x100 分辨率布局,检查 toView/toLocal、射线命中和纹理像素。 /// 点击及源 paint 字段修改保持响应;加入前景实体后同一点只命中外层三维视口,UI 不可穿透遮挡。 fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { + // 源 Tile.paint 读取 tint 并创建命令,UI 在旋转 Panel3D 的 100×100 纹理空间布局,再投影到 200×200 视口。 let (mut session, initial) = ReplSession::load( r##" class Tile: Button { @@ -5963,7 +5964,9 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { 1, ) .unwrap(); + // 先验证发布文档,再定位携带 panel 投影上下文的可交互按钮,排除普通屏幕 UI。 initial.validate().unwrap(); + // 取得面板内真实 Mount;其逻辑宽高应按 UI 分辨率,而不是面板世界尺寸 2×2。 let button = initial .frame .mounts @@ -5971,12 +5974,14 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { .find(|mount| mount.interactive && mount.panel.is_some()) .unwrap(); assert_eq!((button.width, button.height), (100., 100.)); + // 将纹理中心 (50,50) 经旋转面板投影到屏幕,后面命中、输入和像素采样使用同一位置。 let point = button .panel .as_ref() .unwrap() .project_point(crate::geometry::Point::new(50., 50.)) .unwrap(); + // 允许浮点误差,但投影中心必须落在屏幕 (100,100),整帧命中也必须选择该 UI Mount。 assert!((point.x - 100.).abs() < 0.001 && (point.y - 100.).abs() < 0.001); assert_eq!(initial.frame.hit_test(point).unwrap().id, button.id); assert_eq!( @@ -5986,6 +5991,7 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { .value, "100" ); + // 从源 Mount.toLocal 反投影屏幕中心,数值解析后应近似恢复纹理坐标 50,与 toView 互相校验。 let local = session .execute("button.mounts().at(0).toLocal(100,100).x", "app.main") .unwrap() @@ -5993,7 +5999,9 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { .parse::() .unwrap(); assert!((local - 50.).abs() < 0.001); + // 分配完整视口像素缓冲,接下来验证面板纹理确实被三维软件渲染器采样。 let mut pixels = vec![0; 200 * 200]; + // 绘制初始纹理,中心像素应为源 tint 的红色,不只检查 UI 文档中有矩形命令。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.).unwrap(), &mut pixels, @@ -6002,11 +6010,13 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0xFF0000); + // 显式重绘取得最新布局版本;原按钮 Mount 身份保留,用该新版本构造随后点击。 let latest = session .execute("button.invalidate(\"paint\")", "app.main") .unwrap() .document .unwrap(); + // 原生输入携带投影后的屏幕坐标,宿主需通过面板射线映射到源按钮再调用回调。 let input = crate::events::Input { window: 1, session: latest.frame.session, @@ -6019,16 +6029,19 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { tab_drop: None, screen_position: None, }; + // 点击后共享 count 恰好递增一次,证明命中最终到达真实源回调。 session.dispatch(&input).unwrap(); assert_eq!( session.execute("count.value", "app.main").unwrap().value, "1" ); + // 只改 paint 读取过的 tint;依赖系统应重新生成纹理并发布,不能要求重建面板。 let updated = session .execute("button.tint=\"#00FF00\"", "app.main") .unwrap() .document .unwrap(); + // 新文档有效后复用缓冲绘制,屏幕中心应从红变绿,验证源字段到三维纹理的更新链。 updated.validate().unwrap(); crate::software_renderer::SoftwareRenderer::render( &updated.display_list(1.).unwrap(), @@ -6038,6 +6051,7 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0x00FF00); + // 在面板前方加入实体蓝盒,保持原 UI 仍挂载,单独检验深度遮挡对命中和像素的影响。 let blocked = session .execute( r##" @@ -6050,15 +6064,19 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { .unwrap() .document .unwrap(); + // 遮挡后的文档仍合法,从新帧重取 UI Mount,不使用旧的可见性快照。 blocked.validate().unwrap(); + // 按钮虽仍存在且 interactive,但屏幕中心已被近处网格挡住。 let button = blocked .frame .mounts .iter() .find(|mount| mount.panel.is_some() && mount.interactive) .unwrap(); + // 面板 UI 的 contains 应因遮挡失败;整帧命中回落到外层三维视口,不能穿透到按钮。 assert!(!button.contains(point)); assert!(blocked.frame.hit_test(point).unwrap().scene3d_viewport); + // 最终像素也必须显示前景蓝盒,与命中采用同一场景深度关系。 crate::software_renderer::SoftwareRenderer::render( &blocked.display_list(1.).unwrap(), &mut pixels, @@ -6073,6 +6091,7 @@ fn world_ui_panel_renders_live_endor_and_ray_hits_respect_mesh_occlusion() { /// 把可交互的内层 Viewport3D 作为旋转世界面板的 UI 内容。 /// 中心点 camera_at/hit_test 必须找到内层视口,click 的 hit.mesh 更新内层立方体并通过两层表面呈现绿像素。 fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { + // 外三维场景的旋转面板以另一 Viewport3D 为 UI 内容;内场景立方体颜色由源 click 的 hit.mesh 修改。 let (mut session, initial) = ReplSession::load( r##" class Actions { @@ -6097,17 +6116,22 @@ fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { 1, ) .unwrap(); + // 先确认两层表面发布有效,再找同时具有 scene3d_viewport 与 panel 上下文的内层视口。 initial.validate().unwrap(); + // 必须选内层视口,而非外层 200×200 视口,否则事件可能修改错误场景。 let inner = initial .frame .mounts .iter() .find(|mount| mount.scene3d_viewport && mount.panel.is_some()) .unwrap(); + // 两层中心投影到屏幕 (100,100);相机选择和普通命中均应优先进入内层视口。 let point = crate::geometry::Point::new(100., 100.); assert_eq!(initial.frame.camera_at(point).unwrap().id, inner.id); assert_eq!(initial.frame.hit_test(point).unwrap().id, inner.id); + // 先检查未点击时穿过面板纹理采样到内立方体蓝色,建立渲染基线。 let mut pixels = vec![0; 200 * 200]; + // 对完整外帧光栅化,覆盖内视口渲染、面板纹理采样与外场景投影的组合。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.).unwrap(), &mut pixels, @@ -6116,6 +6140,7 @@ fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { ) .unwrap(); assert_eq!(pixels[100 * 200 + 100], 0x0000FF); + // 以外部屏幕坐标向内层 Mount 派发,宿主必须先反投影面板再执行内场景射线拾取。 let updated = session .dispatch(&crate::events::Input { window: 1, @@ -6132,6 +6157,7 @@ fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { .unwrap() .document .unwrap(); + // 回调改色后要求有效新文档,中心绿像素证明内 Mesh 更新经过两层表面正确呈现。 updated.validate().unwrap(); crate::software_renderer::SoftwareRenderer::render( &updated.display_list(1.).unwrap(), @@ -6147,6 +6173,7 @@ fn nested_world_ui_can_embed_another_3d_viewport_and_preserve_ray_dispatch() { /// 先让 Panel.content 指回包含自身的视口,检查跨 UI/场景组合循环。 /// 再构造九层合法无环嵌套,检查超过八层表面预算的诊断;两种边界分别验证,避免把深度错误混同循环。 fn world_ui_cycles_and_surface_nesting_are_rejected_before_publication() { + // panel.content 指回包含 panel 的 view,形成跨 UI/三维场景的组合环,加载时必须拒绝。 let error = ReplSession::load( r##" panel=Panel3D(2,2,Button("cycle")) @@ -6161,20 +6188,28 @@ fn world_ui_cycles_and_surface_nesting_are_rejected_before_publication() { ) .err() .unwrap(); + // 检查完整诊断链明确报告 Block composition 循环,不能把任意布局错误当成循环保护。 assert!( format!("{error:#}").contains("cyclic Block composition"), "{error:#}" ); + // 另一用例从独立 seed 开始生成无环链,只用来检验表面嵌套深度上限。 let mut source = String::from("seed=Button(\"deep\")\n"); + // 逐层让新 panel 包含前一 view,九层超过允许的八层,但没有引用自身或未来节点。 for index in 0..9 { + // 第一层使用 seed,后续使用前层视口名字,维持单向无环依赖。 let child = if index == 0 { "seed".to_string() } else { + // 引用已构造的前层,避免用循环错误遮蔽真正的深度限制。 format!("view{}", index - 1) }; + // 追加一层固定 UI 分辨率的 Panel3D 与对应 Viewport3D,层数由 index 精确控制。 source.push_str(&format!("panel{index}=Panel3D(2,2,{child})\npanel{index}.uiWidth=100\npanel{index}.uiHeight=100\nview{index}=Viewport3D(100,100,Scene3D(panel{index}))\n")); } + // 仅把最外层 view8 放入页面,加载遍历才能真实经过全部九层。 source.push_str("page=Page()\npage.render(view8)\n"); + // 要求第二程序失败且诊断为 nesting exceeds 8,与前一循环诊断区分。 let error = ReplSession::load(&source, "app.main", 317, 1) .err() .unwrap(); @@ -6188,6 +6223,7 @@ fn world_ui_cycles_and_surface_nesting_are_rejected_before_publication() { /// 同一按钮被两个世界面板共享,两个面板指定不同 UI 分辨率。 /// 两个 Mount 的宽高分别服从各自纹理,投影中心位置及命中 ID 也独立,最终整帧仍可软件绘制。 fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() { + // 两个世界面板共享同一 Button,但各自纹理分辨率为 100×80 和 200×120,业务身份不决定布局大小。 let (mut session, initial) = ReplSession::load( r##" shared=Button("shared UI") @@ -6209,6 +6245,7 @@ fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() 1, ) .unwrap(); + // 验证文档有效,再按出现索引核对共享对象的两个 Mount 及其独立宽高。 initial.validate().unwrap(); assert_eq!( session @@ -6245,28 +6282,34 @@ fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() .value, "120" ); + // 从发布帧收集面板内可交互 Mount,后面用各自 panel 投影各自纹理中心。 let mounts: Vec<_> = initial .frame .mounts .iter() .filter(|mount| mount.panel.is_some() && mount.interactive) .collect(); + // 左纹理中心为 (50,40),经左面板变换得到屏幕命中点。 let first = mounts[0] .panel .as_ref() .unwrap() .project_point(crate::geometry::Point::new(50., 40.)) .unwrap(); + // 右纹理中心为 (100,60),不能沿用左纹理分辨率求中心。 let second = mounts[1] .panel .as_ref() .unwrap() .project_point(crate::geometry::Point::new(100., 60.)) .unwrap(); + // 先检查左右投影顺序,再核对各屏幕点命中各自 Mount,防止共享对象把投影上下文合并。 assert!(first.x < second.x); assert_eq!(initial.frame.hit_test(first).unwrap().id, mounts[0].id); assert_eq!(initial.frame.hit_test(second).unwrap().id, mounts[1].id); + // 整帧输出覆盖两面板,最终 render 成功检查不同纹理尺寸可同时进入软件绘制。 let mut pixels = vec![0; 400 * 240]; + // 使用真实完整显示列表光栅化;本用例检查渲染可完成,不对这里未采样的像素颜色作结论。 crate::software_renderer::SoftwareRenderer::render( &initial.display_list(1.).unwrap(), &mut pixels, @@ -6280,6 +6323,7 @@ fn one_ui_object_has_independent_mounts_and_texture_sizes_in_two_world_panels() /// 以实际有限窗口约束加载世界 UI 示例,重复相同视口/任务轮询应保持空闲。 /// 检查面板内二维视口确实存在,执行 turn/fit/reset 并比较业务相机值,最后尝试完整光栅化。 fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { + // 以实际 1024×800 有限窗口加载 world-ui 示例,避免只在无界默认布局中验证可执行性。 let (mut session, document) = ReplSession::load_with_viewport( include_str!("../../../examples/world-ui.endor"), "app.main", @@ -6291,7 +6335,9 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { }), ) .unwrap(); + // 先验证初始完整文档,再模拟重复原生尺寸通知和空闲任务轮询。 document.validate().unwrap(); + // 更新表与加载视口完全相同,相同尺寸不应让示例持续刷新。 let viewports = std::collections::BTreeMap::from([( crate::window::MAIN_WINDOW, crate::layout::Size { @@ -6299,7 +6345,9 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { height: 800., }, )]); + // 重复三轮验证空闲稳定性;每轮 resize 与 poll 都应返回 None。 for _ in 0..3 { + // 相同视口必须保持空闲,后续任务轮询也不能凭空产生文档。 assert!( session .resize_windows_incremental(&viewports, true) @@ -6314,6 +6362,7 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { .is_none() ); } + // 首帧必须实际包含面板内 CanvasViewport,防止示例加载了近似空页面而误通过。 assert!( document .frame @@ -6321,6 +6370,7 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { .iter() .any(|mount| mount.panel.is_some() && mount.canvas_viewport) ); + // 依次执行示例 turn/fit/reset 业务动作,要求最终有有效文档,并检查 reset 后相机/面板状态。 session .execute("actions.turn()\nactions.fit()\nactions.reset()", "app.main") .unwrap() @@ -6339,7 +6389,9 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { session.execute("view.camera.z", "app.main").unwrap().value, "7" ); + // 最后绘制保存的初始 document;这里只验证示例初始帧可光栅化,动作后的状态由上方查询核对。 let mut pixels = vec![0; 1024 * 800]; + // 输出与加载逻辑尺寸一致的 1x 软件帧,覆盖示例多层 UI/三维命令组合。 crate::software_renderer::SoftwareRenderer::render( &document.display_list(1.).unwrap(), &mut pixels, @@ -6353,6 +6405,7 @@ fn world_ui_example_runs_with_window_constraints_and_programmable_actions() { /// 同一段落进入纵向/横向滚动容器,禁止轴的最大范围与偏移必须为 0。 /// 允许轴保留请求偏移,文字约束按滚动轴不同而独立换行;改策略后原横向偏移应立即清零。 fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { + // 加载共享 paragraph 分别进入纵向、横向 ScrollBlock 的示例,测量约束由各自允许轴决定。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/scroll-axis.endor"), "app.main", @@ -6360,44 +6413,54 @@ fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { 1, ) .unwrap(); + // 先确认首帧合法,随后检查禁止方向的最大滚动范围本来就是零。 initial.validate().unwrap(); + // 纵向容器不得产生横向范围,横向容器不得产生纵向范围。 for expression in [ "vertical.mounts().at(0).scrollPosition().maxX", "horizontal.mounts().at(0).scrollPosition().maxY", ] { + // 逐项查询源滚动状态,避免只检查 UI 看起来没有位移。 assert_eq!(session.execute(expression, "app.main").unwrap().value, "0"); } + // 两容器都请求双轴位移,让后续断言检查禁止轴被夹为零、允许轴保留请求值。 session .execute( "vertical.mounts().at(0).scrollTo(100, 60)\nhorizontal.mounts().at(0).scrollTo(60, 100)", "app.main", ) .unwrap(); + // 分别核对两处偏移:vertical=(0,60)、horizontal=(60,0),不能共享一组滚动状态。 for (expression, expected) in [ ("vertical.mounts().at(0).scrollPosition().x", "0"), ("vertical.mounts().at(0).scrollPosition().y", "60"), ("horizontal.mounts().at(0).scrollPosition().x", "60"), ("horizontal.mounts().at(0).scrollPosition().y", "0"), ] { + // 检查各轴投影更新后的实际状态,而非仅接受 scrollTo 调用成功。 assert_eq!( session.execute(expression, "app.main").unwrap().value, expected ); } + // 纵向滚动需要在有限横向空间内换行,记录第一个出现的最终文字宽度。 let vertical_width: f32 = session .execute("paragraph.mounts().at(0).bounds().width", "app.main") .unwrap() .value .parse() .unwrap(); + // 横向允许内容展开,第二个出现可比纵向更宽;共享业务文字仍需按各父约束独立测量。 let horizontal_width: f32 = session .execute("paragraph.mounts().at(1).bounds().width", "app.main") .unwrap() .value .parse() .unwrap(); + // 核对有限宽约束及两个出现的差异,随后改 axis 检查已有偏移随策略更新。 assert!(vertical_width <= 244.0); assert!(horizontal_width > vertical_width); + // 把原横向容器改为纵向后,已存在的 x=60 必须立即夹回零。 session .execute("horizontal.axis = \"vertical\"", "app.main") .unwrap(); @@ -6414,7 +6477,9 @@ fn scroll_axes_wrap_shared_text_and_clamp_only_the_enabled_offsets() { /// 未指定 axis 的 ScrollBlock 默认为 both。 /// 把已挂载容器策略写为 diagonal 必须给出允许集合诊断,不能默认为某个方向。 fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { + // 新会话中直接构造未挂载 ScrollBlock,用于核对无需布局即可读取的默认 axis。 let mut session = ReplSession::new(262).unwrap(); + // 不传 axis,构造后应公开默认 both,不能依赖首次 render 才补齐默认字段。 session .execute( "scroller = ScrollBlock(20, 10, TextBlock(\"default\"))", @@ -6425,6 +6490,7 @@ fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { session.execute("scroller.axis", "app.main").unwrap().value, "\"both\"" ); + // 独立加载把 axis 写为 diagonal 的已渲染容器,验证布局读取策略时明确拒绝未知值。 let error = ReplSession::load( r#" scroller = ScrollBlock(20, 10, TextBlock("bad")) @@ -6438,6 +6504,7 @@ fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { ) .err() .expect("invalid axis"); + // 诊断应列出 both/vertical/horizontal 允许集合,不得静默退回默认策略。 assert!( error .to_string() @@ -6450,6 +6517,7 @@ fn scroll_axis_defaults_to_both_and_rejects_unknown_policy() { /// 源 arrange 把构造宽度 300 的纵向 scroller 改分配为 120。 /// 文字实际宽度需扣两侧 padding 得到至多 100,横向滚动范围为零,不能继续按构造参数测量。 fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { + // 源 Allocator.arrange 把 scroller 的实际分配改为 120,与构造宽 300 刻意不同以暴露旧约束复用。 let source = r#" class Allocator: Column { function arrange(self: Allocator, mount: Mount, geometry: Bounds): Arrangement { @@ -6465,7 +6533,9 @@ fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { page = Page() page.render(Allocator(0, 0, scroller)) "#; + // 正常加载应完成分配后的重测,发布前让文字按实际视口宽度换行。 let (mut session, initial) = ReplSession::load(source, "app.main", 261, 1).unwrap(); + // 先验证外层 scroller 宽确为 120,再检查内文字宽不超过扣 padding 后的 100。 initial.validate().unwrap(); assert_eq!( session @@ -6474,6 +6544,7 @@ fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { .value, "120" ); + // 从文字的具体 Mount 读最终宽度,避免只看到 scroller 改宽却漏掉内容未重测。 let width: f32 = session .execute("text.mounts().at(0).bounds().width", "app.main") .unwrap() @@ -6481,6 +6552,7 @@ fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { .parse() .unwrap(); assert!(width <= 100.0); + // axis=vertical 必须同时保持 maxX=0,不能因为内容仍按构造宽度展开而产生横向范围。 assert_eq!( session .execute("scroller.mounts().at(0).scrollPosition().maxX", "app.main") @@ -6494,7 +6566,9 @@ fn scroll_axis_uses_source_allocated_viewport_instead_of_constructor_width() { /// 嵌套两个 ScrollBlock,并让其中部分内容使用源 paint。 /// 对末尾按钮 scrollIntoView 要联动内外偏移 128/148;绘制 clip 与新命中几何必须使用同一滚动投影。 fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { + // 独立会话构造两层滚动容器,源 Painted 委托原生 paint,以检验源命令也共享同一裁剪投影。 let mut session = ReplSession::new(241).unwrap(); + // 首帧即应有非空 clip 数据,避免后面的命中检查建立在没有裁剪的近似布局上。 let result = session .execute( r#" @@ -6514,10 +6588,13 @@ fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { .unwrap() .document .unwrap(); + // 至少一条绘制命令带裁剪,说明嵌套滚动上下文已进入文档。 assert!(result.frame.command_clips.iter().any(Option::is_some)); + // 对最深 last 的 Mount 请求 scrollIntoView,需要联动内、外两个祖先而非只滚最近一层。 session .execute("last.mounts().at(0).scrollIntoView()", "app.main") .unwrap(); + // inner 应滚到 128、outer 滚到 148;这组偏移把 last 送入两个裁剪区域的交集。 assert_eq!( session .execute("inner.mounts().at(0).scrollPosition().y", "app.main") @@ -6532,6 +6609,7 @@ fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { .value, "148" ); + // 新屏幕点 (10,110) 应命中 last,核对偏移状态与最终输入几何一致。 assert_eq!( session .execute("last.mounts().at(0).hitTest(10,110)", "app.main") @@ -6545,7 +6623,9 @@ fn nested_scroll_into_view_and_source_paint_share_clipped_geometry() { /// 直接调用 Button.click 得到名义 Event,dispatch 显式事件后返回值应与输入为同一托管对象。 /// 通过底层 globals 值比较身份,避免仅比较 repr 造成不同对象也通过。 fn click_and_dispatch_return_the_actual_delivered_event() { + // 不需页面挂载即可测试源码 click/dispatch 的返回对象契约。 let mut session = ReplSession::new(237).unwrap(); + // click 自动创建事件;dispatch 则接收预先保存的 dispatched,并把返回值保存在 returned。 session .execute( r#"button = Button("event") @@ -6555,6 +6635,7 @@ returned = button.dispatch(dispatched)"#, "app.main", ) .unwrap(); + // 先验证 click 返回名义 Event 且 kind=click,再检查显式 dispatch 的对象身份。 assert_eq!( session .execute("ui.isInstance(clicked, Event)", "app.main") @@ -6566,6 +6647,7 @@ returned = button.dispatch(dispatched)"#, session.execute("clicked.kind", "app.main").unwrap().value, "\"click\"" ); + // 直接读 VM 全局字典里的 Value,比 repr 更严格地比较 returned 与 dispatched 是否为同一托管对象。 let globals = session.candidate.globals.unwrap(); assert_eq!( session @@ -6584,12 +6666,15 @@ returned = button.dispatch(dispatched)"#, #[derive(Debug)] /// 测试用原生转发器:保留公开签名,要求 VM 继续调用 target,用于模拟相同函数身份下实现变化。 struct VisualRedirect { + // 下方方法替换测试指定的源实现;ContextNativeFunction 的 roots 另负责保活。 target: ObjectRef, + // 对外沿用原签名,使本用例只改变实现,不借参数契约变化制造缓存失效。 signature: endor_core::CallSignature, } impl endor_core::NativeCallable for VisualRedirect { /// 返回原方法签名副本,使转发器仍参与同一参数/返回/副作用契约验证。 fn signature(&self) -> Option { + // VM 查询 native 元数据时调用;复制契约,避免把测试转发器当成未声明签名的函数。 Some(self.signature.clone()) } /// 不直接递归执行 target;返回 Invoke 请求,由 VM 在其正常调用栈/预算中处理参数和结果。 @@ -6598,6 +6683,7 @@ impl endor_core::NativeCallable for VisualRedirect { _: &mut endor_core::NativeContext<'_>, arguments: &[Value], ) -> std::result::Result { + // 由 VM 调用此 native payload;参数复制到拥有所有权的 Invoke 请求,供正常栈/切片机制继续执行源函数。 Ok(endor_core::NativeResult::Invoke { function: self.target, arguments: arguments.to_vec(), @@ -6609,9 +6695,13 @@ impl endor_core::NativeCallable for VisualRedirect { /// 从同一 desktop 的第二窗口查到 Block.measure,检查原生快速路径认的是方法与注册表组合。 /// 同函数换成 paint 名称或外来 types 表都不能被认作 intrinsic,避免只凭对象形状或方法名跳过源执行。 fn native_visual_identity_accepts_shared_window_registries_only() { + // 建立同一 desktop 下的会话和第二窗口,验证窗口共享类型注册表可以使用相同原生快速路径。 let mut session = ReplSession::new(238).unwrap(); + // 真实 Window 构造注册第二窗口,不手工拼造一个看似相同的 desktop。 session.execute("second = Window()", "app.main").unwrap(); + // 从全局取得第二窗口,沿其 ManagedWindow payload 找到实际所属 desktop。 let globals = session.candidate.globals.unwrap(); + // 必须为真实 Window 对象,保证后面的注册表比较来自正常窗口生命周期。 let Value::Object(window) = session .candidate .vm @@ -6621,32 +6711,42 @@ fn native_visual_identity_accepts_shared_window_registries_only() { else { panic!("window") }; + // 后续只操作候选 VM,统一使用它的对象存储来查询类型与原生方法身份。 let vm = &mut session.candidate.vm; + // 第二窗口记录的 desktop 是合法共享范围,不能用窗口对象自身替代注册表 owner。 let desktop = vm .payload::(window, "Window") .unwrap() .desktop; + // 在该 desktop 的 types 中查 Block,取得其当前注册的 measure 方法。 let block = crate::ui_bridge::desktop_type(vm.object_store(), "Block", desktop).unwrap(); + // 保存真实方法函数身份,随后分别改变方法名和注册表上下文来测试拒绝条件。 let function = vm .object_store() .lookup_method(block, "measure") .unwrap() .unwrap(); + // 方法身份、名称和 desktop 同时匹配才应视为 intrinsic。 assert!( crate::native_visual::is_intrinsic(vm.object_store(), function, "measure", desktop) .unwrap() ); + // 同一函数不能因是 native 就被当成另一协议 paint 的快速实现。 assert!( !crate::native_visual::is_intrinsic(vm.object_store(), function, "paint", desktop).unwrap() ); + // 构造外来 owner,即便它也有 types 字段,也不属于该函数的原注册身份。 let foreign = vm.new_object().unwrap(); + // 空类型字典模拟结构相似的外来注册表,检验 intrinsic 判断不能仅检查字段形状。 let registry = vm.new_dict().unwrap(); + // 将字典挂到外来 owner 后,measure 身份检查仍必须返回 false。 vm.set_attr(foreign, "types", Value::Object(registry)) .unwrap(); assert!( !crate::native_visual::is_intrinsic(vm.object_store(), function, "measure", foreign) .unwrap() ); + // 释放测试创建的 owner 和字典根,避免无关托管对象残留。 vm.release(foreign).unwrap(); vm.release(registry).unwrap(); } @@ -6655,6 +6755,7 @@ fn native_visual_identity_accepts_shared_window_registries_only() { /// 替换 Block.measure 为源方法后既有实例应按新方法得到宽度 140。 /// 再保持原 paint 对象身份、只替换 native payload 为转发器,刷新也必须识别实现改变并产生空命令。 fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { + // 加载原生矩形和备用源 Hooks;后面在已有实例上替换基类方法,验证运行时协议缓存失效。 let (mut session, _) = ReplSession::load(r#" class Hooks { function measure(self: Object, mount: Mount, constraints: Constraints): MeasureResult { return MeasureResult(140, 70) } @@ -6665,7 +6766,9 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { page = Page() page.render(rect) "#, "app.main", 235, 1).unwrap(); + // 名字表保存基类与 Hooks 类型,后续只提取句柄,不重新执行类定义。 let globals = session.candidate.globals.unwrap(); + // 本地查询闭包要求所有目标均为对象;它只读全局字典,不建立新业务实例。 let get = |name: &str| match session .candidate .vm @@ -6673,22 +6776,30 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { .unwrap() .unwrap() { + // 正常分支返回已有对象身份,供下面 define_method/replace_payload 操作。 Value::Object(value) => value, + // 夹具缺少对象时立即失败,不能将非对象值当作方法句柄继续。 _ => panic!("object"), }; + // 原 Block 基类是方法替换目标,rect 已经使用过它的原生视觉协议。 let base = get("base"); + // 备用 Hooks 提供 width=140 的 measure 和返回空列表的 paint 两个源实现。 let hooks = get("Hooks"); + // 取待安装的源 measure 函数,类型不符表示夹具破坏。 let Value::Object(measure) = session.candidate.vm.get_attr(hooks, "measure").unwrap() else { panic!("measure") }; + // 保留备用源 paint,稍后通过 native 转发器调用而不是直接替换函数引用。 let Value::Object(paint) = session.candidate.vm.get_attr(hooks, "paint").unwrap() else { panic!("paint") }; + // 直接替换 Block.measure 定义,已有 rect 的下一次刷新也必须读取新实现。 session .candidate .vm .define_method(base, "measure", measure) .unwrap(); + // 刷新消费方法变更,再从同一 rect 的 Mount 核对宽度已变为 140。 session.refresh_document().unwrap().unwrap(); assert_eq!( session @@ -6697,6 +6808,7 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { .value, "140" ); + // 取得原 Block.paint 的函数对象,接下来刻意保持其 ObjectRef 不变。 let original = session .candidate .vm @@ -6704,6 +6816,7 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { .lookup_method(base, "paint") .unwrap() .unwrap(); + // 沿用原 paint 签名,只更换 native payload,检验缓存是否跟踪实现版本而非仅句柄。 let signature = session .candidate .vm @@ -6711,6 +6824,7 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { .native_signature(original) .unwrap() .unwrap(); + // 转发器返回 Invoke 到 Hooks.paint,roots 中的 paint 保活目标;原函数身份仍相同。 session .candidate .vm @@ -6726,6 +6840,7 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { ), ) .unwrap(); + // 新实现返回空 DisplayList,刷新应发布空 commands,证明没有错误复用旧 intrinsic paint。 let updated = session.refresh_document().unwrap().unwrap(); assert!(updated.frame.commands.is_empty()); } @@ -6734,7 +6849,9 @@ fn replacing_base_visual_methods_changes_layout_and_same_identity_paint() { /// 保持 Block.onEvent 的函数句柄不变,替换为有副作用的转发 payload。 /// 通过 VM changes 使 RenderCache 失效后 rect 从不交互变为交互,protocol_key 也应变化,证明缓存追踪实现版本。 fn same_identity_event_payload_changes_invalidate_interactivity_cache() { + // 独立会话仅构造未挂载 rect 和 Hooks,用 RenderCache 直接观察交互协议快照。 let mut session = ReplSession::new(236).unwrap(); + // Hooks.onEvent 是后续转发目标,原 Block.onEvent 则保持原函数对象身份。 session .execute( r#" @@ -6745,7 +6862,9 @@ fn same_identity_event_payload_changes_invalidate_interactivity_cache() { "app.main", ) .unwrap(); + // 从同一全局表取基类、备用 Hooks 与待检查矩形。 let globals = session.candidate.globals.unwrap(); + // 只读对象查询闭包供本测试局部复用,任何非对象目标都应立即失败。 let get = |name: &str| match session .candidate .vm @@ -6753,31 +6872,44 @@ fn same_identity_event_payload_changes_invalidate_interactivity_cache() { .unwrap() .unwrap() { + // 正常分支保留原对象句柄,不能通过新建对象人为触发快照更新。 Value::Object(value) => value, + // 非对象意味着测试前提不满足,应直接报告而不是跳过更新路径。 _ => panic!("object"), }; + // Block 基类拥有要更换 payload 的 onEvent 方法。 let base = get("base"); + // 备用源类型提供实际 onEvent 调用目标。 let hooks = get("Hooks"); + // 待快照对象起初没有有效交互行为,更新后应被识别为可交互。 let rect = get("rect"); + // 取备用源回调作为 VisualRedirect 的目标,后面保持引用可达。 let Value::Object(target) = session.candidate.vm.get_attr(hooks, "onEvent").unwrap() else { panic!("onEvent") }; + // 借用候选 VM 统一执行方法查询、payload 变更与 changes 收集。 let vm = &mut session.candidate.vm; + // 取原 onEvent 函数;修改 payload 而非替换这个函数身份。 let function = vm .object_store() .lookup_method(base, "onEvent") .unwrap() .unwrap(); + // 复制原 native 契约,再将 effect 标记为 Mutating,使新实现的交互能力可被协议查询识别。 let mut signature = vm .object_store() .native_signature(function) .unwrap() .unwrap(); signature.effect = endor_core::CallEffect::Mutating; + // 创建独立快照缓存,隔离会话发布流程,专门测试协议键和 VM changes 的衔接。 let mut cache = crate::snapshot::RenderCache::default(); + // 首份快照应不交互,作为相同 rect 在实现变化前的基线。 let initial = cache.snapshot(vm.object_store(), rect).unwrap(); assert!(!initial.interactive); + // 丢弃构造阶段变化,只让随后 payload 替换产生的 changes 驱动失效。 vm.take_changes(); + // 在原函数句柄内安装转发器,vec![target] 保活被调用的源函数。 vm.replace_payload( function, endor_core::ContextNativeFunction::new( @@ -6787,10 +6919,13 @@ fn same_identity_event_payload_changes_invalidate_interactivity_cache() { ), ) .unwrap(); + // 将新实现产生的变化批次送入缓存,不能靠手工清空整个 cache 掩盖依赖遗漏。 cache.invalidate(vm.take_changes()); + // 对同一个 rect 重新取快照,interactive 和 protocol_key 都应反映实现版本变化。 let updated = cache.snapshot(vm.object_store(), rect).unwrap(); assert!(updated.interactive); assert_ne!(initial.protocol_key, updated.protocol_key); + // 源侧 ui.implements 也应报告 EventTarget,核对公开协议查询与内部快照判定一致。 assert_eq!( session .execute("ui.implements(base, EventTarget)", "app.main") -- 2.52.0 From 742fee42955a33ebc176dabb7d3c4acfda288b95 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 18:03:00 +0800 Subject: [PATCH 10/78] docs: explain effects, bindings, publication and shutdown test paths --- src/runtime/endor_host/tests.rs | 256 ++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/src/runtime/endor_host/tests.rs b/src/runtime/endor_host/tests.rs index a5e1d36..222abf0 100644 --- a/src/runtime/endor_host/tests.rs +++ b/src/runtime/endor_host/tests.rs @@ -35,6 +35,7 @@ fn scene_interaction_performance() { ("world-ui", include_str!("../../../examples/world-ui.endor")), ("scene3d", include_str!("../../../examples/scene3d.endor")), ] { + // 每个场景分别使用两种 DPI,比较相同逻辑布局在不同物理像素规模下的真实交互成本。 for dpi in [1., 2.] { // 逻辑视口保持 1024×800,后面仅物理像素倍率变化,避免混淆 DPI 与场景布局大小。 let (mut session, mut document) = ReplSession::load_with_viewport( @@ -323,6 +324,7 @@ page.render(Invalid("invalid")) fn wrapped_text_tracks_each_occurrence_width_resize_and_sibling_geometry() { // 长中文段落迫使换行,不能靠一个短词恰好装得下而漏测父约束对自然高度的影响。 let text = "中文段落随着宽度变化而换行,后面的对象跟随段落高度移动。".repeat(8); + // 原生文字与源委托覆盖分别执行整套测试,检查两条入口在共享出现/重排时保持一致。 for custom in [false, true] { // 原生 TextBlock 与源委托子类应走同一测量/绘制语义,分别执行避免共享一个偶然缓存结果。 let constructor = if custom { "Wrapped" } else { "TextBlock" }; @@ -476,6 +478,8 @@ fn native_signatures_validate_registration_and_deliver_the_event_argument() { 220, ) .unwrap(); + // 枚举非法注册/调用,每轮都检查错误可恢复且旧文档未被标为故障。 + // 每个非法注册/调用独立检查恢复性,覆盖错误回调与显式参数数目。 for invalid in [ r#"withEvent.on("click", ui.textOf)"#, r#"withEvent.on("click", Page)"#, @@ -550,6 +554,7 @@ fn checked_fields_reject_native_constructor_side_effects_and_preserve_live_value 210, ) .unwrap(); + // 同一会话依次尝试字段写入和两类构造错误,便于后续核对原值与页面资源没有副作用。 for invalid in [ r#"counter.value = "wrong""#, r#"CounterText("bad", "wrong")"#, @@ -703,6 +708,7 @@ fn positional_insert_preserves_existing_slots_and_mounts_and_rejects_invalid_gra .value, id.to_string() ); + // 表驱动覆盖位置边界与组合循环,合法插入后的旧 Mount 必须在全部失败尝试后仍有效。 for invalid in [ "list.insert(4,head)", "list.insert(0,list)", @@ -981,9 +987,11 @@ fn file_task_state(session: &mut ReplSession) -> String { /// 最多轮询 1000 次,每轮推进任务并检查其是否离开 pending。 /// 短暂休眠让后台文件线程获得执行机会;取消/失败也算终态,超时则明确使测试失败。 fn complete_file_task(session: &mut ReplSession) { + // 测试调用方依赖此有界等待推进后台文件结果;达到上限必须失败,不能无限挂住测试进程。 for _ in 0..1000 { // 后台只填 mailbox,必须显式让 owning VM poll 才能执行回调或完成无回调 Task。 session.poll_tasks().unwrap(); + // 只要状态离开 pending 就交还调用方,让其按当前测试目的区分成功、取消或失败。 if file_task_state(session) != "pending" { // 不只 Completed,Cancelled/Failed 也结束等待,具体预期由各调用测试单独断言。 return; @@ -1186,6 +1194,7 @@ fn tab_and_mount_tasks_cancel_while_block_and_app_owners_continue() { "app.main", ) .unwrap(); + // 两种展示 owner 都在关闭时失去资格,按各自 token 分别核对,避免只取消其中一种。 for name in ["pageTask", "mountTask"] { // close 后立刻 open 不应复活旧资格;页面与旧出现的任务都已在关闭边界被取消。 assert_eq!( @@ -1307,6 +1316,7 @@ fn source_annotations_classify_functions_and_reject_before_body() { 180, ) .unwrap(); + // 分别查询源函数、原生工厂和绑定方法,三者都应进入 Function 名义类型体系。 for expression in [ "isInstance(identity,Function)", "isInstance(ui.rect,Function)", @@ -6939,7 +6949,9 @@ fn same_identity_event_payload_changes_invalidate_interactivity_cache() { /// 查询简单、转发、递归、动态回调和有副作用函数的静态 call_effect。 /// 绑定方法可借助接收者识别只读性,未绑定方法保守未知;重绑被调用全局函数后结果应失效,marker 始终为 0 证明查询未执行源码。 fn source_effect_queries_use_compiled_instructions_without_running_functions() { + // 独立会话只定义待分析函数,不调用这些函数;marker 用来检测静态查询是否意外执行业务代码。 let mut session = ReplSession::new(234).unwrap(); + // 夹具包含直接返回、全局转调、动态回调、自递归及状态写入;另定义可按已知接收者解析的 helper 方法。 session .execute( r#" @@ -6959,7 +6971,9 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { "app.main", ) .unwrap(); + // 从实际编译后的全局对象取函数句柄,分析对象存储中的指令与调用关系。 let globals = session.candidate.globals.unwrap(); + // 只读直返、可解析转调和无写入递归应识别 ReadOnly;未知回调和业务写入都不能证明只读,返回 None。 for (name, expected) in [ ("simple", Some(endor_core::CallEffect::ReadOnly)), ("forward", Some(endor_core::CallEffect::ReadOnly)), @@ -6967,6 +6981,7 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { ("unknown", None), ("impure", None), ] { + // 每次取同一 VM 中真实源函数,类型不符即失败,不能跳过某一分析场景。 let Value::Object(function) = session .candidate .vm @@ -6976,6 +6991,7 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { else { panic!("function") }; + // 调用 call_effect 只查询效果,不执行函数;尤其递归函数没有终止条件,不应被试运行。 assert_eq!( session .candidate @@ -6986,6 +7002,7 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { expected ); } + // 取具体接收者,绑定方法的 self 类型与 helper 查找都由该对象提供上下文。 let Value::Object(model) = session .candidate .vm @@ -6995,6 +7012,7 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { else { panic!("model") }; + // bind_attr 生成捕获 model 的方法,静态分析因能解析 self.helper 而可判定只读。 let Value::Object(bound) = session.candidate.vm.bind_attr(model, "check").unwrap() else { panic!("bound") }; @@ -7007,11 +7025,14 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { .unwrap(), Some(endor_core::CallEffect::ReadOnly) ); + // 释放 bind_attr 返回的临时拥有引用,不把分析用绑定方法长期留在 VM 根中。 session.candidate.vm.release(bound).unwrap(); + // get_attr 取未绑定方法;没有具体接收者时对 self.helper 的解析应保守返回未知。 let Value::Object(unbound) = session.candidate.vm.get_attr(model, "check").unwrap() else { panic!("method") }; assert_eq!(session.candidate.vm.call_effect(unbound).unwrap(), None); + // 保留原 forward 函数身份,随后只重绑它在全局中读取的 simple,检验分析依赖失效。 let Value::Object(forward) = session .candidate .vm @@ -7021,13 +7042,16 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { else { panic!("forward") }; + // 将被调全局名改指向写状态函数,forward 不再能被证明只读。 session.execute("simple = impure", "app.main").unwrap(); assert_eq!(session.candidate.vm.call_effect(forward).unwrap(), None); + // 恢复原绑定后应重新识别 ReadOnly,避免未知结果被永久缓存。 session.execute("simple = original", "app.main").unwrap(); assert_eq!( session.candidate.vm.call_effect(forward).unwrap(), Some(endor_core::CallEffect::ReadOnly) ); + // marker 必须始终为零,证明所有效果查询及失效重算都没有执行 impure 的函数体。 assert_eq!( session.execute("marker.value", "app.main").unwrap().value, "0" @@ -7038,14 +7062,18 @@ fn source_effect_queries_use_compiled_instructions_without_running_functions() { /// 语言层拒绝用普通实例字段遮蔽有契约的 paint/measure/onEvent。 /// 底层绕过语言写入存储模拟方法阴影,缓存需失效至祖先并报 TraitViolation;删除阴影后可再次生成合法快照。 fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { + // 先构造 root 包含 rect 的父子关系,用祖先快照观察子对象方法阴影是否向上使缓存失效。 let mut session = ReplSession::new(233).unwrap(); + // 无需页面发布,RenderCache 将直接从业务根生成快照,隔离发布调度的影响。 session .execute( "rect = RectBlock(40, 20)\nroot = Column(0, 0, rect)", "app.main", ) .unwrap(); + // 取当前业务对象句柄,不重新构造树来制造失效。 let globals = session.candidate.globals.unwrap(); + // 局部只读查询闭包要求命名值是对象,供 rect/root 两次查找复用。 let lookup = |name: &str| match session .candidate .vm @@ -7053,12 +7081,18 @@ fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { .unwrap() .unwrap() { + // 正常分支返回原身份,用于比较同一棵树更新前后的缓存状态。 Value::Object(value) => value, + // 缺失对象前提应直接失败,不能继续在错误对象上测试协议。 _ => panic!("object"), }; + // 目标子项拥有受契约约束的视觉/事件方法,语言层不允许普通字段遮蔽。 let rect = lookup("rect"); + // 父容器作为缓存入口,检验子项变化是否传播到已缓存祖先。 let root = lookup("root"); + // 分别尝试 paint、measure、onEvent 阴影,错误必须来自 instance attribute shadows 契约保护。 for name in ["paint", "measure", "onEvent"] { + // 使用普通源赋值,先验证高层 API 在写入前就阻止非法遮蔽。 let error = session .execute(&format!("rect.{name} = 1"), "app.main") .err() @@ -7068,24 +7102,31 @@ fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { "{error:#}" ); } + // 后续故意在原始属性存储层制造坏状态,以覆盖快照的防御性验证和缓存失效。 let vm = &mut session.candidate.vm; + // 沿属性转发链找到末尾实际存储,向它写入可绕过实例层的 typed method 阴影检查。 let storage = *vm .object_store() .attribute_path(rect) .unwrap() .last() .unwrap(); + // 独立缓存先生成 root 快照,第二次无变化访问应完全命中。 let mut cache = crate::snapshot::RenderCache::default(); cache.snapshot(vm.object_store(), root).unwrap(); + // 丢弃构造阶段变化;后面的变化批次应只来自人为注入的阴影。 vm.take_changes(); cache.snapshot(vm.object_store(), root).unwrap(); + // visited=0 确认已有命中缓存,后续错误检查才真正覆盖了缓存失效。 assert_eq!(cache.visited, 0); + // 底层存储注入 paint=nil,消费 VM changes 后从 root 快照必须看到子项 TraitViolation。 vm.set_attr(storage, "paint", Value::Nil).unwrap(); cache.invalidate(vm.take_changes()); assert!(matches!( cache.snapshot(vm.object_store(), root), Err(endor_core::VmError::TraitViolation { .. }) )); + // 删除阴影并再次送入变化批次;合法祖先快照应恢复且有节点被重新访问。 vm.del_attr(storage, "paint").unwrap(); cache.invalidate(vm.take_changes()); cache.snapshot(vm.object_store(), root).unwrap(); @@ -7096,8 +7137,11 @@ fn typed_method_shadows_invalidate_cached_ancestors_and_can_be_removed() { /// 为参数/返回契约不兼容的 present 方法声明 Presentable,注册必须失败且方法体 marker 不执行。 /// 参数更宽、返回更具体的合法方法可注册,并实际产出 TextBlock,运行时保持健康。 fn presentable_type_contracts_reject_before_registration_or_body_effects() { + // 独立会话用 marker 观测注册过程是否误执行 present 方法体。 let mut session = ReplSession::new(232).unwrap(); + // 先创建计数状态,再让不兼容的方法体尝试递增;注册失败时计数必须不变。 session.execute("marker = ui.state(0)", "app.main").unwrap(); + // 两类错误分别缩窄 window 参数为 Point 和把返回改为 Number,均不满足 Presentable 方法契约。 for (name, method) in [ ( "WrongParameter", @@ -7108,7 +7152,9 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { "function present(self: WrongResult, window: Window): Number { marker.increment() return 7 }", ), ] { + // 每轮定义独立类型并显式注册,保持“方法存在但契约不符”的错误前提。 let input = format!("class {name} {{ {method} }}\nui.implement({name}, Presentable)"); + // 注册必须在调用方法体前失败;诊断应精确指出 Presentable.present。 let error = session .execute(&input, "app.main") .err() @@ -7119,6 +7165,7 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { .contains("trait Presentable method present"), "{error:#}" ); + // 失败不能留下半注册能力,随后 implements 查询应为 false。 assert_eq!( session .execute(&format!("ui.implements({name}, Presentable)"), "app.main") @@ -7126,11 +7173,13 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { .value, "false" ); + // 方法体没有试运行或副作用,marker 仍为零。 assert_eq!( session.execute("marker.value", "app.main").unwrap().value, "0" ); } + // 合法类放宽参数为 Object、返回具体 TextBlock;显式 implement 后 ui.present 应真正调用它。 session.execute(r#" class Good { function present(self: Good, window: Object): TextBlock { return TextBlock("typed") } @@ -7138,6 +7187,7 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { ui.implement(Good, Presentable) view = ui.present(Good()) "#, "app.main").unwrap(); + // 返回值满足 TextBlock 名义类型,且前面的注册错误不应停止会话。 assert_eq!( session .execute("ui.isInstance(view, TextBlock)", "app.main") @@ -7152,6 +7202,7 @@ fn presentable_type_contracts_reject_before_registration_or_body_effects() { /// 加载显式 Presentable 示例,检查视图的 source 字段仍指原 Product 对象。 /// 改变 product.name 可响应式更新显示;另验证错误 Visual 覆盖在定义时拒绝,不破坏现有会话。 fn presentable_dispatch_preserves_source_identity_and_reactive_display() { + // 加载显式能力展示示例,视图包含原 Product 引用及响应式字段显示。 let (mut session, initial) = ReplSession::load( include_str!("../../../examples/presentable.endor"), "app.main", @@ -7159,8 +7210,11 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { 230, ) .unwrap(); + // 先确认自定义展示已生成预期文字,排除走默认回退却误通过身份检查。 assert!(initial.frame.commands.iter().any(|command| matches!(command,RenderCommand::Text { value, .. } if value == "普通对象的显式展示能力"))); + // 从同一名字表取原业务 product 和生成的视图,直接比较 VM 对象身份。 let globals = session.candidate.globals.unwrap(); + // 原 Product 是展示能力的接收者,后续 view.source 必须指回它。 let Value::Object(product) = session .candidate .vm @@ -7170,6 +7224,7 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { else { panic!("expected Product"); }; + // 视图是可挂载的 Block,不能把复制出来的展示数据当作业务源对象。 let Value::Object(view) = session .candidate .vm @@ -7179,6 +7234,7 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { else { panic!("expected Block"); }; + // source 字段需保存同一 ObjectRef;另用 supports 检查公开能力判定。 assert_eq!( session.candidate.vm.get_attr(view, "source").unwrap(), Value::Object(product) @@ -7190,6 +7246,7 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { .value, "true" ); + // 只改原 product.name,已建立的显示依赖应发布 updated 文字,不重新调用页面构造。 let changed = session .execute("product.name = \"updated\"", "app.main") .unwrap() @@ -7200,6 +7257,7 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { |command| matches!(command,RenderCommand::Text { value, .. } if value == "updated") ) ); + // 错误 Visual 覆写缺少必要参数,类定义时应拒绝,既有健康会话仍可继续。 assert!(session.execute("class Bad: RectBlock { function paint(self: Bad): DisplayList { return DisplayList(ObjectList()) } }","app.main").err().unwrap().to_string().contains("trait Visual method paint")); assert!(!session.is_faulted()); } @@ -7208,7 +7266,9 @@ fn presentable_dispatch_preserves_source_identity_and_reactive_display() { /// 仅有同名且签名匹配的 present 方法不自动声明能力,ui.present 走回退且 marker 不增。 /// 显式 implement 后 ui.open 才执行自定义方法;缺失实现的注册错误不改变已发布文档。 fn matching_present_method_does_not_implicitly_declare_presentable() { + // 新会话定义签名看似匹配的 present,但不注册能力,检测是否存在隐式鸭子类型调用。 let mut session = ReplSession::new(231).unwrap(); + // ui.present(data) 应使用回退;自定义方法若被误调用会使 marker 增加。 session .execute( r#" @@ -7226,6 +7286,7 @@ fn matching_present_method_does_not_implicitly_declare_presentable() { "app.main", ) .unwrap(); + // 回退不执行自定义方法,marker=0 且 supports=false,区分方法存在与能力声明。 assert_eq!( session.execute("marker.value", "app.main").unwrap().value, "0" @@ -7237,6 +7298,7 @@ fn matching_present_method_does_not_implicitly_declare_presentable() { .value, "false" ); + // 显式注册后用 ui.open(data) 走实际展示路径,方法应恰好执行一次。 session .execute( "ui.implement(Data,Presentable)\ncustom = ui.open(data)", @@ -7247,7 +7309,9 @@ fn matching_present_method_does_not_implicitly_declare_presentable() { session.execute("marker.value", "app.main").unwrap().value, "1" ); + // 保存已发布文档集合,接下来注册缺少 present 的类型必须失败且不替换现有帧。 let before = session.documents.clone(); + // Missing 没有实现能力方法,注册失败属于可恢复输入错误,不能发布半成品页面。 assert!( session .execute( @@ -7268,7 +7332,9 @@ fn matching_present_method_does_not_implicitly_declare_presentable() { /// 隐藏 rect 的 width 绑定先随模型更新,挂载到两个窗口后同一业务值更新两个窗口几何。 /// 断开令牌后模型变化不再传递,直接写目标属性仍正常发布。 fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() { + // 先在未挂载 rect 上建立 width 绑定,验证绑定求值不依赖可见 Mount。 let mut session = ReplSession::new(220).unwrap(); + // model.read 读取 width 并驱动 rect.width,token 保留连接用于后续断开。 session .execute( r##" @@ -7287,22 +7353,28 @@ fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() session.execute("rect.width", "app.main").unwrap().value, "80" ); + // 隐藏对象也必须跟随依赖字段写入,从 80 更新为 140。 session.execute("model.width = 140", "app.main").unwrap(); assert_eq!( session.execute("rect.width", "app.main").unwrap().value, "140" ); + // 将同一 rect 挂到两个窗口,之后一次业务 width 更新应传播到两份出现几何。 session.execute("first = Page()\nfirst.render(rect)\nother = Window()\nsecond = Page()\nsecond.open(other)\nsecond.render(rect)", "app.main").unwrap(); + // 把模型改为 260,正常刷新返回主窗口及其他窗口的新文档。 let updated = session .execute("model.width = 260", "app.main") .unwrap() .document .unwrap(); + // 把主 frame 与 windows 中各帧统一检查,每个窗口都应出现宽 260 的目标。 for frame in std::iter::once(&updated.frame) .chain(updated.windows.values().map(|document| &document.frame)) { + // 逐帧验证实际几何,不能只读共享字段就推断另一窗口已刷新。 assert!(frame.mounts.iter().any(|mount| mount.width == 260.0)); } + // 断开后再改模型为 360,目标应留在 260,证明订阅写入已停止。 session .execute("token.disconnect()\nmodel.width = 360", "app.main") .unwrap(); @@ -7310,6 +7382,7 @@ fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() session.execute("rect.width", "app.main").unwrap().value, "260" ); + // 直接给目标 width=180 仍应正常发布;断开绑定不能把目标属性变成不可写。 let direct = session .execute("rect.width = 180", "app.main") .unwrap() @@ -7322,7 +7395,9 @@ fn managed_property_bindings_update_hidden_objects_and_shared_window_geometry() /// 同一目标两个属性各绑定一个返回新 Record 的计算。 /// 二者都应稳定为 value=7,不能因对象输出写入更新目标版本而相互触发无穷重算。 fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() { + // 独立会话把两个不同属性绑定到同一个创建新 Record 的计算,检验输出对象变化的精确依赖。 let mut session = ReplSession::new(222).unwrap(); + // 每次 makeRecord 都分配新身份,若绑定误读整个目标版本,两项会因对方写入而无穷重算。 session .execute( r##" @@ -7337,6 +7412,7 @@ fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() { "app.main", ) .unwrap(); + // 两项都应收敛到 value=7 且 VM 健康;不能只保证其中一个绑定最终被写入。 assert_eq!( session .execute("rect.first.value", "app.main") @@ -7358,6 +7434,7 @@ fn multiple_object_outputs_on_one_target_do_not_invalidate_each_other() { /// 可见和隐藏 rect 都绑定到读取全局 size 的函数,用 bindingEvaluations 比较精确失效。 /// 无关 REPL 结果不重算,size 改变只各算一次;直接破坏一个输出仅重算该绑定并恢复正确值。 fn property_bindings_use_precise_names_and_repair_direct_output_changes() { + // 可见 rect 与隐藏 hidden 都读取全局 size,同一目标名依赖必须在两种可见性下生效。 let (mut session, _) = ReplSession::load( r#" size = 80 @@ -7374,6 +7451,7 @@ page.render(rect) 1, ) .unwrap(); + // 查询累计 bindingEvaluations 并转整数;该只读统计查询本身不应让绑定重新求值。 let count = |session: &mut ReplSession| { session .execute("ui.stats().bindingEvaluations", "app.main") @@ -7382,8 +7460,10 @@ page.render(rect) .parse::() .unwrap() }; + // 初次两项各求值一次,记录基线后用无关表达式检查按全局名字精确失效。 let baseline = count(&mut session); assert_eq!(baseline, 2); + // 普通 42 结果不改 size,也不应发布或增加求值数。 assert!( session .execute("42", "app.main") @@ -7392,12 +7472,14 @@ page.render(rect) .is_none() ); assert_eq!(count(&mut session), baseline); + // 改变被读取的全局名,两项应各重算一次,包括未挂载 hidden。 session.execute("size = 120", "app.main").unwrap(); assert_eq!(count(&mut session), baseline + 2); assert_eq!( session.execute("hidden.width", "app.main").unwrap().value, "120" ); + // 直接破坏一个绑定输出,只需重算 rect 这一项并恢复 120,不能把 hidden 一起误失效。 session.execute("rect.width = 200", "app.main").unwrap(); assert_eq!(count(&mut session), baseline + 3); assert_eq!( @@ -7411,6 +7493,7 @@ page.render(rect) /// 加载 GC 示例后建立 Page/Block/等待协程之间的循环,先确认任务真的停在 await。 /// 关闭页面后任务仍可独立存活;释放业务引用后对象数回基线、弱 Page/Block 失效,证明渲染缓存未额外持根。 fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { + // 真实 GC 示例中 Page、CycleRect 与 await 任务形成环,measure/paint 也读取环中的 Page 字段。 let (mut session, _) = ReplSession::load( include_str!("../../../examples/gc-lifecycle.endor"), "app.main", @@ -7418,12 +7501,15 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { 1, ) .unwrap(); + // 在临时环尚未建立时记录对象数,最终释放应回到同一业务基线。 let baseline = session .execute("ui.stats().objects", "app.main") .unwrap() .value; + // 创建临时页并启动协程;随后 poll 推进到 sleep await,确保存在等待帧而非仅有排队任务。 session.execute("mountCycle()", "app.main").unwrap(); session.poll_tasks().unwrap(); + // phase=1 且任务 running 证明源码已执行到 await,尚未恢复到后续 phase=2。 assert_eq!( session.execute("phase.value", "app.main").unwrap().value, "1" @@ -7435,7 +7521,9 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { .value, "\"running\"" ); + // 取 holder 中保留的临时页面,创建弱观察句柄而不添加新的持久根。 let globals = session.candidate.globals.unwrap(); + // holder 是外部状态容器,value 指向要释放的临时 Page。 let Value::Object(holder) = session .candidate .vm @@ -7445,16 +7533,21 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { else { panic!("holder") }; + // 取 Page 原身份,关闭与清理后应无法通过弱句柄升级。 let Value::Object(page) = session.candidate.vm.get_attr(holder, "value").unwrap() else { panic!("page") }; + // 解析页面内容树的第一个 CycleRect,同时观察页面与子项是否都能回收。 let rect = crate::block::resolve(session.candidate.vm.object_store(), page) .unwrap() .1 .children[0] .block; + // 页面弱引用仅作检查,不应成为回收根。 let page_weak = session.candidate.vm.object_store().downgrade(page).unwrap(); + // 子项也用弱引用检查,防止只回收页面而缓存仍意外保留业务 Block。 let rect_weak = session.candidate.vm.object_store().downgrade(rect).unwrap(); + // 仅关闭展示不等于释放业务引用,pending 仍 running;不能把这种存活误判为缓存泄漏。 session.execute("holder.value.close()", "app.main").unwrap(); assert_eq!( session @@ -7463,6 +7556,7 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { .value, "\"running\"" ); + // 释放 holder/pending 的外部引用后,再检查对象数恢复且两个弱句柄均失效。 session.execute("releaseCycle()", "app.main").unwrap(); assert_eq!( session @@ -7479,7 +7573,9 @@ fn source_render_caches_do_not_root_a_page_block_waiting_frame_cycle() { /// 仅保留绑定 token,删除隐藏目标并回收,目标弱句柄应失效且 token 断开。 /// 另用会写模型的计算函数创建绑定,写入前只读保护失败并停止 VM,原 width 仍为 80。 fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { + // 同一会话先测试 token 对隐藏目标的弱持有,再测试计算函数的只读约束。 let mut session = ReplSession::new(221).unwrap(); + // model.read 是合法计算,model.bad 则试写 width=999,后者只在第二阶段被绑定调用。 session .execute( r##" @@ -7498,7 +7594,9 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { "app.main", ) .unwrap(); + // 保留名字表以删除 rect 的强全局引用,同时继续保留 token 和 model。 let globals = session.candidate.globals.unwrap(); + // 取得原隐藏 rect 的真实身份,后续不能用另建目标代替 GC 观察。 let Value::Object(target) = session .candidate .vm @@ -7508,17 +7606,20 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { else { panic!("expected Block"); }; + // 弱句柄用于证明目标真正消失,token 不能反向强持有该对象。 let weak = session .candidate .vm .object_store() .downgrade(target) .unwrap(); + // 删除 rect 的全局根,随后显式循环回收,token 仍留在 globals。 session .candidate .vm .dict_delete(globals, &Value::Text("rect".into())) .unwrap(); + // 回收后目标弱升级失败且 token.isConnected=false,验证弱连接清理。 session.candidate.vm.collect_cycles(); assert!(session.candidate.vm.upgrade(weak).unwrap().is_none()); assert_eq!( @@ -7528,6 +7629,7 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { .value, "false" ); + // 另建目标绑定 model.bad,计算开始时必须处于只读范围,业务写入应在发生前被拦截。 assert!( session .execute( @@ -7537,6 +7639,7 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { .is_err() ); assert!(session.is_faulted()); + // 会话 faulted 后直接读取 VM 的 model;不能用普通 execute 查询遮蔽原值检查。 let Value::Object(model) = session .candidate .vm @@ -7546,6 +7649,7 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { else { panic!("expected model"); }; + // width 仍为 80,证明错误没有先执行写入再停止后续工作。 assert_eq!( session.candidate.vm.get_attr(model, "width").unwrap(), Value::Number(80.into()) @@ -7556,19 +7660,25 @@ fn binding_tokens_do_not_retain_hidden_targets_and_compute_is_read_only() { /// 两个 textOf 绑定读取同一 counter,按钮调用源辅助函数再执行原生 increment。 /// 点击后两条文字命令都必须更新为 10,覆盖跨源回调与原生写入的依赖传播。 fn native_method_callbacks_update_two_explicit_bindings() { + // 独立会话让 first/second 各自绑定同一 counter.value,测试源回调转调原生 increment 的依赖传播。 let mut session = ReplSession::new(21).unwrap(); + // 初始计数为 9,按钮调用源函数 increment;两条显示文字通过不同绑定读取同一状态。 session.execute("counter = ui.state(9)\nfirst = ui.textOf(counter, \"value\")\nsecond = ui.textOf(counter, \"value\")\nbutton = ui.button(\"increment\")\nfunction increment(): Unit { counter.increment() }\nbutton.onClick(increment)\npage = ui.page()\npage.render(ui.row(0, 8, first, second, button))", "app.main").unwrap(); + // 源码 click 执行回调后应发布新文档,不手工刷新文本或重新建立绑定。 let updated = session .execute("button.click()", "app.main") .unwrap() .document .unwrap(); + // 按发布顺序收集文字值,前两个对应 first/second,排除后面的按钮标题。 let texts: Vec<_> = updated .frame .commands .iter() .filter_map(|command| match command { + // 文字命令借用已拥有的字符串,比较实际发布数据而非重新查询源对象。 RenderCommand::Text { value, .. } => Some(value.as_str()), + // 忽略按钮背景等非文字命令,保持两个绑定输出的相对顺序。 _ => None, }) .collect(); @@ -7579,14 +7689,18 @@ fn native_method_callbacks_update_two_explicit_bindings() { /// 保留 card 业务对象,关闭/重开页面确认隐藏内容修改仍可显示。 /// 再次关闭并删除 Page 全局绑定后回收,Page 弱句柄应失效但 card 仍存在,子项不能反向强持有页面。 fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content() { + // 页面与 card 分别保存在全局;用这种可独立持有的内容检验子项不会反向保活 Page。 let mut session = ReplSession::new(22).unwrap(); + // 先正常挂载 card,建立展示根与内容关系,再开始关闭/重开流程。 session .execute( "card = ui.rect(10, 20)\npage = ui.page()\npage.render(card)", "app.main", ) .unwrap(); + // 后面只删 page 名字,刻意保留 card,以分离页面回收与共享内容存活。 let globals = session.candidate.globals.unwrap(); + // 取原页面身份,弱观察对象必须是这次已挂载的 Page。 let Value::Object(page) = session .candidate .vm @@ -7596,8 +7710,11 @@ fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content( else { panic!("expected Page") }; + // 弱句柄不增加页面持久根,最终可检查闭环引用是否释放。 let weak = session.candidate.vm.downgrade(page).unwrap(); + // 第一次关闭后仍保留 page 全局引用,允许修改隐藏内容并重开验证页面可复用。 session.execute("page.close()", "app.main").unwrap(); + // 隐藏时改原 card;后面重开仍应正常发布,不要求重新创建内容。 session .execute("card.setColor(\"#FFEEDD\")", "app.main") .unwrap(); @@ -7608,12 +7725,15 @@ fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content( .document .is_some() ); + // 第二次关闭后删除 page 的全局根,进入真正的页面回收检查。 session.execute("page.close()", "app.main").unwrap(); + // 只删除页面全局引用,card 全局引用继续保留。 session .candidate .vm .dict_delete(globals, &Value::Text("page".into())) .unwrap(); + // 沿真实桥接根集合回收,Page 弱升级必须失败而 card 仍可从 globals 找到。 session .bridge .reclaim(&mut session.candidate.vm, &mut session.candidate.roots) @@ -7633,9 +7753,13 @@ fn closing_a_page_releases_its_root_without_retaining_it_through_shared_content( /// 保存视觉对象 alias 并嵌入容器,给 card 赋新视觉值应沿共享引用更新嵌套出现位置且身份不变。 /// 包含自身的替换被拒绝;unmount 后更新不发布,重新 render 使用最新值。 fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { + // card、other 与容器子项引用同一共享业务身份,检验替换如何传播到嵌套已挂载位置。 let mut session = ReplSession::new(10).unwrap(); + // 先建立 alias 和嵌套树,随后只对 card 重新赋视觉值。 session.execute("card = ui.rect(10, 20)\nother = card\ncontainer = ui.column(0, 0, other)\npage = ui.page()\npage.render(container)", "app.main").unwrap(); + // 保存 other 的共享身份展示值,替换后新返回值与旧 alias 都应保持该身份。 let identity = session.execute("other", "app.main").unwrap().value; + // card 改为 640×200 的矩形,业务身份保持而实际视觉存储/几何更新。 let updated = session .execute("card = ui.rect(640, 200, 32)", "app.main") .unwrap(); @@ -7644,6 +7768,7 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { session.execute("other", "app.main").unwrap().value, identity ); + // 从替换产生的发布文档检查嵌套绘制尺寸,不只比较 alias 的 repr。 let frame = updated.document.unwrap().frame; assert!(matches!( frame.commands[0], @@ -7653,6 +7778,7 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { .. } )); + // 替换值若包含自身 alias 会形成组合环,必须拒绝;随后只读查询不应产生文档。 assert!( session .execute("card = ui.column(0, 0, other)", "app.main") @@ -7665,12 +7791,14 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { .document .is_none() ); + // page.unmount 清除展示根并发布空 commands,但不删除仍在全局的共享业务对象。 let unmounted = session .execute("page.unmount()", "app.main") .unwrap() .document .unwrap(); assert!(unmounted.frame.commands.is_empty()); + // 未挂载时更新业务值不发布画面,随后重新 render 需采用最新 80×50 几何。 assert!( session .execute("card = ui.rect(80, 50)", "app.main") @@ -7678,6 +7806,7 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { .document .is_none() ); + // 通过旧 alias 重新挂载,证明别名转发在卸载期间也保持最新值。 let remounted = session .execute("page.render(other)", "app.main") .unwrap() @@ -7697,13 +7826,16 @@ fn mounted_aliases_update_nested_positions_unmount_and_reject_cycles() { /// 创建文字但未挂载时只保留 VM 值,title 与最后结果 _ 都可跨输入读取。 /// 挂载后引用替换自动更新画面,成功文档保留 Console transcript;无关查询不重复发布。 fn live_session_retains_objects_and_updates_only_after_mounting() { + // 新会话先创建隐藏文字,测试跨 Console 输入的业务全局和最后结果 _ 保留。 let mut session = ReplSession::new(7).unwrap(); + // 仅构造 title 不调用 render,因此应返回对象结果但没有文档。 let created = session .execute( r##"title = ui.text("你好,Tessera", 2, "#243047")"##, "app.main", ) .unwrap(); + // title 和 _ 都应能在后续输入读回同一结果,隐藏对象不因无画面而消失。 assert!(created.document.is_none()); assert_eq!( session.execute("title", "app.main").unwrap().value, @@ -7713,6 +7845,7 @@ fn live_session_retains_objects_and_updates_only_after_mounting() { session.execute("_", "app.main").unwrap().value, created.value ); + // 创建 Page 并挂载已有 title,首次可见文档应显示最初中文文本。 let rendered = session .execute("page = ui.page()\npage.render(title)", "app.main") .unwrap() @@ -7721,7 +7854,9 @@ fn live_session_retains_objects_and_updates_only_after_mounting() { assert!( matches!(&rendered.frame.commands[0], RenderCommand::Text { value, .. } if value == "你好,Tessera") ); + // 会话文档结构树是 repl-session,区别于单次完整源码编译的 script/module 根。 assert_eq!(rendered.root.as_ref().unwrap().kind, "repl-session"); + // 给已挂载 title 赋新视觉值应自动发布更新,不需要显式重复 render。 assert!( session .execute(r#"title = ui.text("changed")"#, "app.main") @@ -7729,6 +7864,7 @@ fn live_session_retains_objects_and_updates_only_after_mounting() { .document .is_some() ); + // 再显式 render,核对最新文字及 Console 输入历史;最后只读查询不重复发布。 let updated = session .execute("page.render(title)", "app.main") .unwrap() @@ -7751,12 +7887,16 @@ fn live_session_retains_objects_and_updates_only_after_mounting() { /// 编译期错误不能执行同输入前面的 value=2;运行期错误则保留已经发生的 value=3。 /// 失败输入的待发布 render 被丢弃,后续显式 render 可恢复;另检查函数局部赋值正常工作。 fn live_session_compiles_before_execution_and_discards_failed_render() { + // 新会话区分编译期拒绝与运行期部分业务写入;页面发布采用独立成功边界。 let mut session = ReplSession::new(8).unwrap(); + // 建立 value=1 与可用 Page,后续失败输入不能破坏已建立的全局。 session .execute("value = 1\npage = ui.page()", "app.main") .unwrap(); + // 同次输入含未定义名称,完整编译失败时前面的 value=2 不得执行。 assert!(session.execute("value = 2\nmissing", "app.main").is_err()); assert_eq!(session.execute("value", "app.main").unwrap().value, "1"); + // 另一输入先写 value=3、请求 render,再用负尺寸触发运行期错误;业务写入与待发布帧要分别处理。 assert!( session .execute( @@ -7765,6 +7905,7 @@ fn live_session_compiles_before_execution_and_discards_failed_render() { ) .is_err() ); + // 失败前完成的 value=3 仍保留,但后续查询不能自动发布失败输入的 discarded 内容。 let retained = session.execute("value", "app.main").unwrap(); assert_eq!(retained.value, "3"); assert!(retained.document.is_none()); @@ -7777,9 +7918,11 @@ fn live_session_compiles_before_execution_and_discards_failed_render() { assert!(recovered.frame.commands.iter().any(|command| matches!( command, RenderCommand::Text { value, .. } if value == "recovered" ))); + // 恢复帧只能包含成功 render 的内容,不能混入失败输入的待显示命令。 assert!(recovered.frame.commands.iter().all(|command| !matches!( command, RenderCommand::Text { value, .. } if value == "discarded" ))); + // 最后验证函数局部赋值与重绑可正常执行,不因失败恢复路径破坏局部作用域。 session .execute( "function local(value: Number): Number { result = value result = 42 return result }", @@ -7796,8 +7939,10 @@ fn live_session_compiles_before_execution_and_discards_failed_render() { /// 普通顶层 page 脚本直接经 Aster 执行,源码、结构树节点和原始行位置必须保留。 /// 比较绘制几何及兼容 main 入口的命令等价性;两个独立 VM 的业务对象身份应不同。 fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { + // 单次 compile 直接接收 PAGE 顶层源码,源快照必须原样保留,不能插入宿主包装函数。 let document = compile(PAGE, "app.main", 9).unwrap(); assert_eq!(document.source, PAGE); + // 结构树应保留四个顶层语句、名称及原始行号,ID 带当前提交来源。 let tree = document.root.as_ref().unwrap(); assert_eq!(tree.kind, "script"); assert_eq!(tree.children.len(), 4); @@ -7807,6 +7952,7 @@ fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { assert_eq!(tree.children[2].start_line, 3); assert_eq!(tree.children[3].start_line, 4); assert!(tree.id.contains("app.main@9")); + // 布局结果应为两条业务命令,文字与矩形位置按真实测量/间距生成。 assert_eq!(document.frame.commands.len(), 2); assert!( matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor") @@ -7815,6 +7961,7 @@ fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { matches!(&document.frame.commands[1], RenderCommand::RoundedRect { x: 24.0, y: 76.0, width: 240.0, height: 80.0, radius: 12.0, color } if color == "#DCE8FF") ); assert_eq!(document.display_list(2.0).unwrap().commands().len(), 3); + // 另加载兼容 main 示例,序列化比较两种入口的绘制命令等价;不同 VM 对象身份仍应独立。 let wrapped = compile(include_str!("../../../examples/main.endor"), "app.main", 10).unwrap(); assert_eq!( serde_json::to_value(&document.frame.commands).unwrap(), @@ -7831,6 +7978,7 @@ fn top_level_goal_script_renders_without_a_main_or_synthetic_wrapper() { /// 提交含括号注释、辅助函数、多行赋值/调用及跨行成员链的完整源程序。 /// 检查渲染成功与结构树起止行,避免用宿主按行切割或简单括号统计代替 Aster 语法解析。 fn script_helpers_multiline_bindings_calls_and_member_continuations_use_aster() { + // 夹具保留括号注释、多行赋值/调用与成员续行,结构树断言依赖这些原始行号,不改字符串内部内容。 let source = r##"let color = "#242422" // comment with ( and ) function greeting(): Text { return "Hello Endor" @@ -7849,8 +7997,10 @@ let page = ui page .render(ui.column(24, 12, title, rectangle)) "##; + // 完整交给 Aster 解析、编译和执行,不能由宿主逐行切割或计数括号来模拟。 let document = compile(source, "app.main", 2).unwrap(); assert_eq!(document.frame.commands.len(), 2); + // 消费编译产生的结构树,核对辅助函数和多行语句的起止行覆盖完整原文范围。 let tree = document.root.unwrap(); assert_eq!(tree.children[1].name, "greeting"); assert_eq!(tree.children[1].start_line, 2); @@ -7865,18 +8015,24 @@ page /// 分别使用 LF/CRLF/CR,在前面含中文的脚本中制造 missing 引用。 /// 诊断行列、字节 span 切片和提交来源都需对应原文;另检查语法错误的原始行号。 fn script_diagnostics_refer_to_original_unicode_source_and_byte_ranges() { + // 分别测试 LF/CRLF/CR,中文前缀使字符列与字节偏移不同,二者都需对应原始文本。 for ending in ["\n", "\r\n", "\r"] { + // 在第三行构造 missing 引用,换行形式变化不能让诊断误指向宿主生成位置。 let source = format!( "let title = ui.text(\"中文\"){ending}// comment{ending}let broken = missing{ending}ui.page().render(title)" ); + // compile 应返回真实 frontend 诊断,而非包装后失去来源的普通字符串。 let error = compile(&source, "app.main", 27).unwrap_err(); + // 读取行列、字节 span 与 submission 来源,直接切片原文以验证范围。 let diagnostic = error.downcast_ref::().unwrap(); assert_eq!(diagnostic.location.line, 3); assert_eq!(diagnostic.location.column, 14); assert_eq!(diagnostic.diagnostic.span.slice(&source), Some("missing")); assert!(diagnostic.origin.to_string().contains("submission 27")); } + // 另给第二行语法错误,覆盖解析阶段原始行号而非只有名称解析错误。 let error = compile("let page = ui.page()\npage.render(,)", "app.main", 1).unwrap_err(); + // 语法诊断也必须保留 SourceDiagnostic 类型与第二行位置。 let diagnostic = error.downcast_ref::().unwrap(); assert_eq!(diagnostic.location.line, 2); } @@ -7885,12 +8041,14 @@ fn script_diagnostics_refer_to_original_unicode_source_and_byte_ranges() { /// 两个 Page 交替 render 后,把 first.render 保存成函数再调用。 /// 绑定方法必须仍持有原接收者,最后一次 render 选择显示内容 last。 fn pages_bind_render_methods_and_last_render_selects_the_snapshot() { + // 先在两个 Page 分别 render,再把 first.render 保存成 paint 调用,检验绑定方法保留接收者。 let source = r#"let first = ui.page() let second = ui.page() first.render(ui.text("first")) second.render(ui.text("second")) let paint = first.render paint(ui.text("last"))"#; + // 最终 compile 只发布最后一次被选择的页面快照,应只有 last 一条文字命令。 let document = compile(source, "app.main", 1).unwrap(); assert_eq!(document.frame.commands.len(), 1); assert!( @@ -7902,13 +8060,16 @@ paint(ui.text("last"))"#; /// 先发布成功文档,再逐项提交无 render、错误 Page 参数、错误根和 render 后失败的源码。 /// 每个候选都必须失败,已发布 source 与 revision 保持原值,不能出现部分帧。 fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() { + // NamespaceState 保存最后成功文档与 revision,失败候选不能修改它们。 let mut state = NamespaceState { submission: 1, ..NamespaceState::default() }; + // 先提交 PAGE 成功基线,后续每轮失败都与这个已发布状态比较。 state .commit(1, compile(PAGE, "app.main", 1).unwrap()) .unwrap(); + // 分别覆盖无 render、构造/调用参数错、根类型错以及 render 后继续执行失败。 for source in [ "ui.page()", "let title = ui.text(\"no render\")", @@ -7917,7 +8078,9 @@ fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() { "let page = ui.page()\npage.render(\"not a node\")", "let page = ui.page()\npage.render(ui.text(\"partial\"))\nui.missing()", ] { + // 为每个候选取新提交令牌,但不因提交请求本身提升已发布 revision。 state.submission = state.next_submission().unwrap(); + // 要求每个源码候选都失败,随后核对文档 source 与 revision 保持成功基线。 assert!( compile(source, "app.main", state.submission).is_err(), "{source}" @@ -7931,11 +8094,15 @@ fn page_api_errors_and_late_failures_do_not_publish_partial_snapshots() { /// 保留超过 MAX_PAGES 的页面引用后再请求显示,检查明确的 64 page budget 错误。 /// 页面资源预算独立于最终画了多少节点,不能靠只显示一个页面绕过。 fn page_creation_has_a_separate_live_object_budget() { + // 动态生成并保留不同全局 Page 名字,使所有页面同时存活,不能被临时值回收绕过预算。 let mut source = String::new(); + // 包含上界,创建 MAX_PAGES+1 个页面,明确越过页面活对象预算。 for index in 0..=ui_bridge::MAX_PAGES { source.push_str(&format!("let page{index} = ui.page()\n")); } + // 最终只显示 page0,证明页面分配预算独立于可见页面数及绘制节点数。 source.push_str("page0.render(ui.text(\"x\"))"); + // 加载应明确报告 64 page budget,而不是等到渲染图展开预算才失败。 let error = compile(&source, "app.main", 1).unwrap_err(); assert!(error.to_string().contains("64 page budget"), "{error:#}"); } @@ -7944,7 +8111,9 @@ fn page_creation_has_a_separate_live_object_budget() { /// 生成每层重复调用前一函数的指数展开计算,最后才创建文字。 /// 即使尚未触及 UI 节点预算,指令总预算仍应终止计算并报告 instructions。 fn script_execution_is_limited_even_without_creating_ui_nodes() { + // 底层 text0 只返回字符串,后续逐层重复调用它,以大量纯计算消耗 VM 指令预算。 let mut source = "function text0(): Text { return \"Hello Endor\" }\n".to_owned(); + // 每层调用前层两次形成指数工作量,但直到最后都不分配 UI 节点。 for index in 1..=16 { source.push_str(&format!( "function text{index}(): Text {{ let unused = text{}() return text{}() }}\n", @@ -7952,7 +8121,9 @@ fn script_execution_is_limited_even_without_creating_ui_nodes() { index - 1 )); } + // 最后才把 text16 结果交给 ui.text,指令限制应在构造画面前截断计算。 source.push_str("let title = ui.text(text16())\nui.page().render(title)"); + // 检查 instructions 预算诊断,区分 UI 节点预算或递归帧深度限制。 let error = compile(&source, "app.main", 1).unwrap_err(); assert!( error @@ -7966,7 +8137,9 @@ fn script_execution_is_limited_even_without_creating_ui_nodes() { /// 源 main 返回普通文字,再传给 ui.text 并挂载。 /// 验证 VM 的字符串结果可正确被宿主解码成发布命令,而非要求它已经是视觉对象。 fn literal_string_return_from_aster_can_be_displayed_by_the_host() { + // main 返回普通 Text,再由 ui.text 转为视觉对象,检验源值与宿主视觉对象转换边界。 let source = "function main(): Text { return \"Hello Endor\" }\nlet title = ui.text(main())\nui.page().render(title)"; + // 编译执行后检查实际文字命令,不能仅以函数返回值正确推断宿主显示成功。 let document = compile(source, "app.main", 1).unwrap(); assert!( matches!(&document.frame.commands[0], RenderCommand::Text { value, .. } if value == "Hello Endor") @@ -7977,11 +8150,16 @@ fn literal_string_return_from_aster_can_be_displayed_by_the_host() { /// 为相同源码生成两个不同提交的结构树,开发工具先选中旧子节点。 /// 接收新文档后旧 ID 不再有效,选择应回退到新树根,避免沿用旧提交位置。 fn developer_selection_resets_to_the_new_source_snapshot() { + // 同一源码的旧提交结构树,节点 ID 含提交身份。 let first = compile(PAGE, "app.main", 1).unwrap(); + // 新提交生成不同节点 ID,即使源内容未变也不能沿用旧选择。 let second = compile(PAGE, "app.main", 2).unwrap(); + // 模拟开发工具已打开且选中旧子节点,再通过文档更新入口恢复选择。 let mut tools = crate::developer_tools::DeveloperToolsState::default(); + // 设置真实旧选择状态,避免默认空选择直接回退根而未覆盖旧 ID 失效。 tools.open = true; tools.selected_node_id = Some(first.root.as_ref().unwrap().children[0].id.clone()); + // 接收新文档后应选中新根,不能把旧节点位置误映射到新提交。 tools.document_updated(&second); assert_eq!( tools.selected_node_id.as_deref(), @@ -7993,14 +8171,17 @@ fn developer_selection_resets_to_the_new_source_snapshot() { /// 通过兼容模块/main 示例检查结构树、布局、文字实际测量和命令位置。 /// compile 路径清理候选 VM 后,两种显示倍率仍能从拥有的数据生成显示列表,证明发布不依赖悬空 VM 句柄。 fn executes_aster_functions_and_renders_after_vm_cleanup() { + // 兼容模块/main 示例经过单次 compile,返回时候选 VM 已清理,发布文档必须拥有绘制所需数据。 let document = compile(EXAMPLE, "app.main", 1).unwrap(); assert_eq!(document.source, EXAMPLE); + // 模块结构树应保留 namespace、函数名及原始行位置,独立于之后的 VM 生命周期。 let tree = document.root.as_ref().unwrap(); assert_eq!(tree.kind, "module"); assert_eq!(tree.name, "app.main"); assert_eq!(tree.children[0].name, "heading"); assert_eq!(tree.children[1].name, "main"); assert_eq!(tree.children[1].start_line, 5); + // 检查四条业务绘制命令及其绝对位置,后面的显示列表另含背景命令。 assert_eq!(document.frame.commands.len(), 4); assert!( matches!(&document.frame.commands[0], RenderCommand::Text { value, size: 2, x: 24.0, y: 24.0, .. } if value == "Hello Endor") @@ -8015,6 +8196,7 @@ fn executes_aster_functions_and_renders_after_vm_cleanup() { .. } )); + // 使用实际文字度量推导后续文字 x,避免写死字体相关宽度导致位置断言失真。 let first_width = crate::text::measure("Aster VM", crate::text::TextStyle::ui(1)).width; assert!(matches!( &document.frame.commands[3], @@ -8029,14 +8211,17 @@ fn executes_aster_functions_and_renders_after_vm_cleanup() { /// 辅助函数接收借用 child 并把它放入 Row 两次。 /// 编译完成后两个文字命令各自有正确几何,覆盖变量/返回值/共享子项之间的托管引用有效性。 fn borrowed_ui_handles_survive_variables_helpers_and_reused_children() { + // main 局部 child 传给 helper,helper 在 Row 中复用两次,考察借用/返回/共享引用的所有权衔接。 let source = r#"function wrap(child: View): View { return ui.row(4, 8, child, child) } function main(): View { let child = ui.text("shared") return wrap(child) }"#; + // 在任意命名空间编译,两个出现都需保留到文档发布,不能因局部变量退出被提前释放。 let document = compile(source, "arbitrary.namespace", 3).unwrap(); assert_eq!(document.frame.commands.len(), 2); assert!(matches!( &document.frame.commands[0], RenderCommand::Text { x: 4.0, y: 4.0, .. } )); + // 按真实字体测量首文字宽,第二出现 x 应为起点 4+该宽+gap 8。 let width = crate::text::measure("shared", crate::text::TextStyle::ui(1)).width; assert!(matches!( &document.frame.commands[1], @@ -8048,8 +8233,11 @@ function main(): View { let child = ui.text("shared") return wrap(child) }"#; /// 把同一模块示例换成 CRLF 或 CR,源快照必须逐字保留。 /// 结构树的 main 起始行仍为 5,检查行定位对不同换行形式一致。 fn architecture_ranges_follow_crlf_and_cr_source_snapshots() { + // 对同一模块分别使用 CRLF 与 CR,保证原样源快照和语句行号都支持不同换行形式。 for ending in ["\r\n", "\r"] { + // 只替换换行,不修改模块语义或插入包装,main 起始行仍应为 5。 let source = EXAMPLE.replace('\n', ending); + // 完整编译后既比较 source 逐字一致,也核对结构树定位。 let document = compile(&source, "app.main", 1).unwrap(); assert_eq!(document.source, source); assert_eq!(document.root.unwrap().children[1].start_line, 5); @@ -8060,6 +8248,7 @@ fn architecture_ranges_follow_crlf_and_cr_source_snapshots() { /// 表驱动拒绝旧 namespace/scene 语法、缺失或有参 main、错误根类型和各类非法 UI 参数。 /// 同时覆盖尺寸/字号/颜色预算与未知名称,确保兼容入口不会默默接受错误程序。 fn rejects_invalid_entry_objects_arguments_and_old_syntax() { + // 独立编译每个非法程序,覆盖旧语法、main 契约、宿主参数及未知 API/名称。 for source in [ "namespace app.main export scene Main { render { Text(\"old\") } }", "function helper(): View { return ui.text(\"missing main\") }", @@ -8076,6 +8265,7 @@ fn rejects_invalid_entry_objects_arguments_and_old_syntax() { "function main(): View { return unknown(1) }", "function main(): View { return ui.missing() }", ] { + // 本组只要求拒绝,不据此声称某个特定诊断阶段;失败消息列出被意外接受的源码。 assert!( compile(source, "app.main", 1).is_err(), "accepted: {source}" @@ -8087,12 +8277,14 @@ fn rejects_invalid_entry_objects_arguments_and_old_syntax() { /// 在中文字符串之后制造未知引用,检查 SourceDiagnostic 仍指向第三行和 submission 17。 /// 避免宿主包装源代码后把错误定位成合成代码位置。 fn source_diagnostics_keep_unicode_locations_and_submission_origin() { + // 中文字符串后第三行引用 missing,真实 frontend 错误必须保留原源位置和提交 17 来源。 let error = compile( "function main(): View {\n let title = ui.text(\"中文\")\n return missing\n}", "app.main", 17, ) .unwrap_err(); + // 下转为结构化 SourceDiagnostic,而非从渲染后的文本猜测行号。 let error = error.downcast_ref::().unwrap(); assert_eq!(error.location.line, 3); assert!(error.origin.to_string().contains("submission 17")); @@ -8101,6 +8293,7 @@ fn source_diagnostics_keep_unicode_locations_and_submission_origin() { #[test] /// 无终止条件的 main 自递归必须被 VM 帧预算截断,报告 frames 而非让宿主栈溢出。 fn recursive_program_stops_at_the_vm_frame_limit() { + // 无终止条件递归 main,必须由 VM 调用帧上限终止;不应递归穿透到宿主栈溢出。 let error = compile("function main(): View { return main() }", "app.main", 1).unwrap_err(); assert!( error @@ -8112,15 +8305,21 @@ fn recursive_program_stops_at_the_vm_frame_limit() { /// 生成二叉视觉树程序。shared=true 每层只构造一个 child 再复用两次,false 则分别递归构造。 /// 用于把业务对象数量增长和展示出现位置展开增长分开测试,最终由 main 返回最高层节点。 fn nested_functions(levels: usize, shared: bool) -> String { + // 下方预算测试调用本生成器;叶函数只创建一个文字,从 level1 开始逐层生成二叉组合。 let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); + // 按层定义函数,所有引用都指向已定义的前一层,排除名称解析失败干扰预算测试。 for index in 1..=levels { + // 当前层使用的子层编号,与 shared 分支共同控制业务对象数和出现展开数。 let previous = index - 1; + // 先求一次 child 再放入容器两次,使业务对象线性增长、展示出现指数增长。 if shared { source.push_str(&format!("function node{index}(): View {{ let child = node{previous}() return ui.column(0, 0, child, child) }}\n")); } else { + // 两次调用前层函数分别分配子树,业务对象数与展示规模都指数增长。 source.push_str(&format!("function node{index}(): View {{ return ui.column(0, 0, node{previous}(), node{previous}()) }}\n")); } } + // main 只返回最高层,调用方通过 levels/shared 参数选择要触发的资源预算。 source.push_str(&format!( "function main(): View {{ return node{levels}() }}" )); @@ -8131,8 +8330,10 @@ fn nested_functions(levels: usize, shared: bool) -> String { /// 不共享子项的树先触发 4096 构造节点预算;共享树对象较少但展开出现位置更多。 /// 后者需报告 expanded Block graph,证明两类预算分别约束对象分配和渲染规模。 fn construction_and_expanded_layout_have_separate_budgets() { + // 不共享的 12 层二叉树先超出 4096 构造节点预算,验证分配阶段限制。 let error = compile(&nested_functions(12, false), "app.main", 1).unwrap_err(); assert!(error.to_string().contains("4096 node budget"), "{error:#}"); + // 共享的 14 层仅少量业务对象,仍应因出现位置展开过大被另一预算拒绝。 let error = compile(&nested_functions(14, true), "app.main", 2).unwrap_err(); assert!( error.to_string().contains("expanded Block graph"), @@ -8144,14 +8345,18 @@ fn construction_and_expanded_layout_have_separate_budgets() { /// 构造 64 层嵌套容器包住一个文字,完整编译后仍应有一条文字命令。 /// 验证允许的深度不会因宿主遍历截断而悄悄丢掉叶节点。 fn managed_ui_depth_is_not_truncated_at_64_levels() { + // 生成单链而非二叉展开,隔离深度可用性与宽度/节点数量预算。 let mut source = "function node0(): View { return ui.text(\"x\") }\n".to_owned(); + // 64 层每层只包一个 Column,允许的深度必须把唯一叶文字保留到最终帧。 for index in 1..=64 { source.push_str(&format!( "function node{index}(): View {{ return ui.column(0, 0, node{}()) }}\n", index - 1 )); } + // main 从第 64 层返回根,确保完整遍历所有层。 source.push_str("function main(): View { return node64() }"); + // 正常编译后应恰有一条文字命令,不能静默截断深层子项。 let document = compile(&source, "app.main", 1).unwrap(); assert_eq!(document.frame.commands.len(), 1); } @@ -8160,7 +8365,9 @@ fn managed_ui_depth_is_not_truncated_at_64_levels() { /// 直接测试 NamespaceState 的提交令牌:失败编译保留旧文档,新 submission 使旧候选失效。 /// clear 后旧候选同样不能复活画面;随后新令牌的合法候选可继续递增 revision。 fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() { + // 直接使用命名空间状态机模拟异步候选,不依赖真实后台线程即可检查提交令牌的时序边界。 let mut state = NamespaceState::default(); + // 先取新令牌并成功提交,建立 revision=1 的已发布基线。 state.submission = state.next_submission().unwrap(); assert_eq!( state @@ -8168,19 +8375,26 @@ fn failed_updates_preserve_success_and_delete_invalidates_inflight_work() { .unwrap(), Some(1) ); + // 第二次提交的编译失败,应保留上一成功文档和 revision。 state.submission = state.next_submission().unwrap(); assert!(compile("invalid", "app.main", state.submission).is_err()); assert_eq!(state.revision, 1); assert_eq!(state.document.source, EXAMPLE); + // 保存一个即将过期的令牌,用它编译出合法候选,区分“源码合法”与“仍可提交”。 let stale = state.submission; + // 候选本身有效,但后续更新 submission 后应因令牌过期而被忽略。 let candidate = compile(EXAMPLE, "app.main", stale).unwrap(); + // 模拟更新请求先到达,旧合法候选也不能再覆盖当前文档。 state.submission = state.next_submission().unwrap(); assert!(state.commit(stale, candidate.clone()).unwrap().is_none()); + // clear 模拟删除画面;旧候选在删除后同样不能复活,revision 保持 clear 返回值。 let deleted = state.clear().unwrap(); assert!(state.commit(stale, candidate).unwrap().is_none()); assert_eq!(state.revision, deleted); assert!(state.document.source.is_empty()); + // 清理后仍可接受新的合法令牌,不能把一次 clear 当作永久关闭命名空间。 state.submission = state.next_submission().unwrap(); + // 在新令牌下重新编译,成功 commit 应从 deleted revision 再递增一次。 let candidate = compile(EXAMPLE, "app.main", state.submission).unwrap(); assert_eq!( state.commit(state.submission, candidate).unwrap(), @@ -8209,7 +8423,9 @@ pending = ui.sleep(ui,"pending",30000) /// 直接读取退出测试中的 log 托管状态,不通过 execute,因关闭后普通输入已经被禁止。 fn exit_log(session: &ReplSession) -> Value { + // 退出测试调用此助手,直接读取仍保留的业务全局,不经过已禁止输入的 execute。 let globals = session.candidate.globals.unwrap(); + // 要求夹具中的 log 存在且为状态对象,避免关闭后缺失日志被当作零。 let Some(Value::Object(log)) = session .candidate .vm @@ -8218,6 +8434,7 @@ fn exit_log(session: &ReplSession) -> Value { else { panic!("log") }; + // 只读 value 用于比较 onUnmount 执行次数,不恢复或推进任何源代码。 session.candidate.vm.get_attr(log, "value").unwrap() } @@ -8225,13 +8442,17 @@ fn exit_log(session: &ReplSession) -> Value { /// 两个窗口共享文字并保有 pending Task,shutdown 返回窗口/Mount/任务清理统计。 /// 两个出现位置各调用一次 onUnmount,重复 shutdown 不再调用,关闭后的 execute 被拒绝。 fn shutdown_unmounts_all_windows_once_and_cancels_tasks() { + // 两个窗口共享 ExitText,每处独立 Mount;夹具另有待取消 sleep,覆盖退出的三类资源。 let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 701, 1).unwrap(); + // 退出前卸载次数为零,避免把加载期行为算作 shutdown 的清理结果。 assert_eq!(exit_log(&session), Value::Number(0_i64.into())); + // 真实 shutdown 返回资源清理统计;窗口应恰好两处,Mount/任务至少覆盖夹具创建的资源。 let (windows, mounts, tasks) = session.shutdown().unwrap(); assert_eq!(windows, 2); assert!(mounts >= 2); assert!(tasks >= 1); assert_eq!(exit_log(&session), Value::Number(2_i64.into())); + // 重复退出必须幂等,不能再执行两个 onUnmount;随后普通 execute 必须拒绝。 session.shutdown().unwrap(); assert_eq!(exit_log(&session), Value::Number(2_i64.into())); assert!(session.execute("42", "app.main").is_err()); @@ -8241,8 +8462,11 @@ fn shutdown_unmounts_all_windows_once_and_cancels_tasks() { /// 手动将会话标为 faulted 后 shutdown,资源仍需清理但 log 保持 0。 /// 验证停止域的退出清理不能借生命周期回调重新执行源代码。 fn shutdown_stopped_runtime_never_reenters_source_hooks() { + // 建立正常挂载状态,再人为标记 faulted,隔离“已停止运行时退出”这条路径。 let (mut session, _) = ReplSession::load(EXIT_SOURCE, "app.main", 702, 1).unwrap(); + // 停止域只允许资源清理,不得为了卸载通知重新进入源 onUnmount。 session.faulted = true; + // 退出仍应成功,但日志保持零,证明没有执行夹具的源卸载钩子。 session.shutdown().unwrap(); assert_eq!(exit_log(&session), Value::Number(0_i64.into())); } @@ -8251,8 +8475,11 @@ fn shutdown_stopped_runtime_never_reenters_source_hooks() { /// 把退出 onUnmount 改成启动 sleep;关闭期间必须拒绝新任务并报告 exiting 诊断。 /// 错误使运行时 fault,避免退出清理反而产生无法结束的后台工作。 fn shutdown_hooks_cannot_create_new_background_work() { + // 把正常卸载计数替换为创建 sleep,构造退出回调试图增加后台任务的非法行为。 let source = EXIT_SOURCE.replace("self.log.increment()", "ui.sleep(ui,\"late\",30000)"); + // 加载时没有触发卸载,程序应先正常挂载,错误预期发生在 shutdown 内。 let (mut session, _) = ReplSession::load(&source, "app.main", 703, 1).unwrap(); + // 检查退出阶段明确禁止新任务并 fault,会话不能因清理回调反而遗留新后台工作。 let error = session.shutdown().unwrap_err(); assert!(format!("{error:#}").contains("runtime is exiting; new tasks are forbidden")); assert!(session.is_faulted()); @@ -8262,11 +8489,13 @@ fn shutdown_hooks_cannot_create_new_background_work() { /// 以有限 400x300 视口比较 window/vertical/horizontal:普通页受窗口限制,延伸页仅沿声明轴增长。 /// append 新子项后延伸尺寸连同 gap 实时更新,覆盖页面布局模式的旧兼容入口。 fn page_modes_grow_on_the_declared_axis_and_follow_live_children() { + // 同样两个 450×350 子项与 gap=10,比较 window 有限尺寸和旧延伸模式的单轴内容增长。 for (mode, expected) in [ ("window", (400.0, 300.0)), ("vertical", (400.0, 710.0)), ("horizontal", (910.0, 300.0)), ] { + // 每轮独立构造页面,模式是唯一变化,避免前一轮 append 留下的结构影响后续预期。 let source = format!( r#"page=Page() page.mode="{mode}" @@ -8274,6 +8503,7 @@ page.gap=10 page.render(RectBlock(450,350)) page.append(RectBlock(450,350))"# ); + // 统一有限视口 400×300,让 window 模式有明确上限,延伸模式只在指定轴超出。 let (mut session, document) = ReplSession::load_with_viewport( &source, "app.main", @@ -8285,9 +8515,12 @@ page.append(RectBlock(450,350))"# }), ) .unwrap(); + // 首个 Mount 是页面根,核对实际发布的表面布局尺寸而非仅 page.mode 字段。 let root = &document.frame.mounts[0]; assert_eq!((root.width, root.height), expected, "{mode}"); + // 仅纵向模式再追加一项,内容高应为 3×350+2×10=1070。 if mode == "vertical" { + // 沿当前会话追加并取新文档,检查实时子项变化已进入延伸高度。 let next = session .execute("page.append(RectBlock(450,350))", "app.main") .unwrap() @@ -8295,7 +8528,9 @@ page.append(RectBlock(450,350))"# .unwrap(); assert_eq!(next.frame.mounts[0].height, 1070.0); } + // 横向模式追加后宽应为 3×450+2×10=1370,不能错误沿纵轴增长。 if mode == "horizontal" { + // 同样用真实 append 触发刷新,核对新根宽度而非重新加载预设尺寸。 let next = session .execute("page.append(RectBlock(450,350))", "app.main") .unwrap() @@ -8310,6 +8545,7 @@ page.append(RectBlock(450,350))"# /// Canvas 中两次使用同一按钮,业务 ID 相同但 Mount ID 不同,均接受带符号世界坐标。 /// 修改位置更新两处;切到另一普通页再回来,原 Page 的 pan/zoom 与页面身份仍保留。 fn canvas_positions_are_live_signed_and_shared_without_copying_objects() { + // 旧 canvas Page 模式两次放入同一按钮,带负位置与非默认 pan/zoom,用来检验业务共享和页面状态保留。 let (mut session, initial) = ReplSession::load_with_viewport( r#" item=Button("same object") @@ -8331,18 +8567,21 @@ page.zoom=2 }), ) .unwrap(); + // 只取按钮出现,要求业务 block_id 相同、Mount ID 不同;不能通过复制业务对象实现两处显示。 let mounts: Vec<_> = initial .frame .mounts .iter() .filter(|mount| mount.interactive) .collect(); + // 两处出现均保留负世界坐标,表面另记录 panX=-160 和 zoom=2。 assert_eq!(mounts.len(), 2); assert_eq!(mounts[0].block_id, mounts[1].block_id); assert_ne!(mounts[0].id, mounts[1].id); assert_eq!((mounts[0].x, mounts[0].y), (-120.0, 90.0)); assert_eq!(initial.frame.surface.pan.x, -160.0); assert_eq!(initial.frame.surface.zoom, 2.0); + // 写同一业务 item 的 x/y,两处出现都应更新为 (500,-60),不能只移动首个 Mount。 let next = session .execute("item.x=500\nitem.y=-60", "app.main") .unwrap() @@ -8355,7 +8594,9 @@ page.zoom=2 .filter(|m| m.interactive) .all(|m| m.x == 500.0 && m.y == -60.0) ); + // 保存原 canvas 页身份,后面切换到普通页面再返回,必须保留同一页状态。 let old_page = next.frame.surface.page.clone(); + // 新建普通 Page 并 render,当前表面切为 Window 模式,但不销毁原 canvas 页面业务状态。 let window = session .execute( "other=Page()\nother.render(TextBlock(\"other\"))", @@ -8365,6 +8606,7 @@ page.zoom=2 .document .unwrap(); assert_eq!(window.frame.surface.mode, crate::page::Mode::Window); + // 激活原页后核对 page 身份、pan 和 zoom 全部恢复,不以新建页面代替旧页。 let restored = session .execute("page.activate()", "app.main") .unwrap() @@ -8379,6 +8621,7 @@ page.zoom=2 /// 通过 ui.newTab 加载内置起始页并确认二维视口存在。 /// 逐个按画面文字位置点击示例入口,检查它们都使用 Window 表面、仅延伸示例带 ScrollBlock,再用真实 tab-activate 回到首页。 fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { + // 以 1024×800 窗口初始化会话,测试实际内置起始页在有限约束下的交互入口。 let mut session = ReplSession::new_with_viewport( 302, Some(crate::layout::Size { @@ -8387,11 +8630,13 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { }), ) .unwrap(); + // 通过公开 ui.newTab 创建起始页,后续每次派发都沿最新文档继续,避免版本过期。 let mut document = session .execute("ui.newTab()", "app.main") .unwrap() .document .unwrap(); + // 起始页采用 Window 表面且实际包含 CanvasViewport,二维场景应由独立视口承载。 assert_eq!(document.frame.surface.mode, crate::page::Mode::Window); assert!( document @@ -8400,8 +8645,10 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { .iter() .any(|mount| mount.canvas_viewport) ); + // 依次点击普通窗口、纵向延伸和横向延伸入口;只有后两者应出现独立 ScrollBlock。 for (label, scrolling) in [("窗口页面", false), ("纵向延伸", true), ("横向延伸", true)] { + // 先保存首页标签 ID,进入示例后用真实 tab-activate 返回它,而不是重新创建首页。 let home_tab = document .frame .tabs @@ -8409,22 +8656,27 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { .find(|tab| tab.title == "自由画布") .unwrap() .id; + // 按当前绘制文字找到入口屏幕位置,检验可见标签确实对应可点击目标。 let point = document .frame .commands .iter() .find_map(|command| match command { + // 匹配目标标签后在字内取一点,不用硬编码按钮 Mount 或跳过真实命中检查。 RenderCommand::Text { x, y, value, .. } if value == label => { Some(crate::geometry::Point::new(x + 2.0, y + 2.0)) } + // 其他文字/绘制命令都不参与此次入口定位。 _ => None, }) .unwrap(); + // 整帧 hit_test 必须从标签坐标找到有效按钮,保证显示和交互几何一致。 let mount = document .frame .hit_test(point) .expect("button label must be clickable") .id; + // 使用当前 session/layout_version 与屏幕坐标派发原生 click,并保存新发布文档。 document = session .dispatch(&crate::events::Input { window: 1, @@ -8441,11 +8693,13 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { .unwrap() .document .unwrap(); + // 三个示例均使用 Window 表面;延伸由内部滚动容器实现,不能退回旧表面模式。 assert_eq!( document.frame.surface.mode, crate::page::Mode::Window, "{label}" ); + // 检查 scroll 上下文是否与当前入口预期一致,并要求整个帧没有 fault。 assert_eq!( document .frame @@ -8456,6 +8710,7 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { "{label}" ); assert!(document.frame.fault.is_none()); + // 以示例文档当前版本激活保存的首页标签,不复用进入示例前的旧布局版本。 let input = crate::events::Input { window: 1, session: document.frame.session, @@ -8468,6 +8723,7 @@ fn bundled_start_page_uses_independent_layout_scroll_and_scene_viewports() { tab_drop: None, screen_position: None, }; + // 返回首页后的新文档作为下一轮入口查找基准,覆盖连续来回切页的版本更新。 document = session.dispatch(&input).unwrap().document.unwrap(); } } -- 2.52.0 From dbf0bb580042882217d17824e73588073832f92c Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 18:08:16 +0800 Subject: [PATCH 11/78] docs: explain binding convergence and managed block model ownership --- src/model/binding.rs | 34 +++++++++- src/model/block.rs | 152 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 2 deletions(-) diff --git a/src/model/binding.rs b/src/model/binding.rs index 395b572..6a78b87 100644 --- a/src/model/binding.rs +++ b/src/model/binding.rs @@ -1,6 +1,9 @@ //! 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。 //! 缓存记录读取版本而不是复制业务模型;依赖变化才重新计算。托管连接对象追踪其拥有的引用。 //! +//! 调用路径:UI 的 Block.bind 原生方法调用 connect;UiBridge 刷新入口在 VM 序列中调用 refresh。 +//! connect 建立目标拥有的连接,refresh 在发布布局前反复求值到稳定;隐藏 Block 也参与刷新。 +//! //! Target-owned managed bindings. Dependency versions are borrowed identities; //! the host's weak Block registry never roots an otherwise unreachable graph. use std::{ @@ -25,7 +28,7 @@ struct Cached { } #[derive(Debug, Clone)] -/// 托管属性连接,强持有计算/目标所需引用,并保留只读计算的版本缓存。 +/// 托管属性连接:由目标强持有,连接只强持有 compute,不反向保活目标;缓存共享只读计算的观察结果。 pub struct Binding { // 目标属性名;目标 Block 通过 bindings 列表拥有本连接。 field: String, @@ -49,6 +52,7 @@ impl ObjectPayload for Binding { } /// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。 fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { + // Aster GC 调用 trace 时只追踪有效计算;reads/output 都是借用观察,不能增加长期拥有边。 if let Some(compute) = self.compute { visit(compute); } @@ -67,13 +71,16 @@ pub fn connect( if field.is_empty() || field.len() > 128 || field == "type" { return Err(VmError::InvalidArgument("invalid binding property")); } + // 沿共享赋值解析到底层 Data 身份,避免给旧别名副本建立不再生效的连接。 let (reference, data) = block::resolve(cx.store(), target)?; + // 复制宿主描述以编辑 bindings;最终 replace_payload 才更新托管拥有边,不能原地绕过 VM 引用追踪。 let mut data = data.clone(); // 只允许属性存储里已存在的字段;方法名即使可读取,也不能作为绑定写入目标。 let properties = data .properties .ok_or(VmError::InvalidArgument("missing property storage"))?; cx.store().get_attr(properties, field)?; + // 计算必须是 Function(含绑定函数),可构造的类型对象等 callable 不能替代绑定函数。 if !cx.store().object(compute)?.payload().is_function() { return Err(VmError::InvalidArgument( "binding compute must be a Function", @@ -94,15 +101,20 @@ pub fn connect( } // 同一字段只保留一个有效计算。其他字段连接沿用;旧同字段连接失效并清缓存, // 使外部仍持有的 Subscription 不会继续驱动被替换的计算。 + // 收集仍有效且属于其他字段的连接,随后以这份列表替换原 bindings。 let mut retained = vec![]; + // 遍历目标拥有的旧连接,已失效项不再保留;同字段有效项必须取消以保证单字段单计算。 for connection in &data.bindings { + // 克隆 payload 以修改 compute,缓存 Arc 仍共享原连接的观察状态。 let mut binding = cx .store() .payload::(*connection, "Binding")? .clone(); + // 其他字段仍有效的连接原样保留,其计算函数继续由目标间接保活。 if binding.compute.is_some() && binding.field != field { retained.push(*connection); } else if binding.compute.is_some() { + // 只取消仍有效的同字段旧连接;已失效项无需再次清理或加入 retained。 binding.compute = None; *binding .reads @@ -111,9 +123,11 @@ pub fn connect( cx.replace_payload(*connection, binding)?; } } + // 预算按替换后仍有效的其他字段计数,给新连接预留一个槽,避免单 Block 连接无限增长。 if retained.len() >= 128 { return Err(VmError::ExecutionLimit("128 bindings per Block")); } + // 新连接初始无缓存,第一次 refresh 必须执行 compute;分配后目标的 Data 负责持有它。 let connection = cx.allocate(Binding { field: field.into(), compute: Some(compute), @@ -141,27 +155,39 @@ pub fn refresh( host_desktops: &HashSet, ) -> Result { // work 跨所有轮次累计。属性 A 的写入可能使已检查的 B 失效,所以不能只扫描一次。 + // 返回实际执行的计算次数,UiBridge 用它统计 bindingEvaluations,而非统计扫描节点数量。 let mut work = 0; + // 反复扫描弱注册表,直到一整轮没有计算;后面绑定写入可能使本轮已处理项重新失效。 loop { + // 记录本轮是否至少执行过一次计算,即使输出没变也再扫一轮核对依赖。 let mut evaluated = false; // 每轮按解析后的真实 Block 身份去重,多个包装对象共享数据时只处理一次。 let mut seen = HashSet::new(); + // nodes 是宿主保存的弱 Block 索引,不作为 GC 根;隐藏但仍由业务持有的对象同样会被检查。 for weak in nodes { + // 升级只为本轮临时保活;已经回收的目标直接跳过,不重建它或延续其绑定。 let Some(target) = vm.upgrade(*weak)? else { continue; }; // 用闭包暂存失败,让下方 release 无论处理成功与否都能释放 upgrade 的强引用。 let result: Result<()> = (|| { + // 按最终 Data 身份去重,多个包装别名不能在同轮重复评估同一组 bindings。 let (reference, data) = block::resolve(vm.object_store(), target)?; + // 本轮已处理这个 Data 时跳过,目标强引用仍由外层统一 release。 if !seen.insert(reference) { return Ok(()); } + // 先复制连接句柄列表以结束 Data 借用,后续 VM 调用可能修改堆或更新属性。 let connections = data.bindings.clone(); + // 目标拥有这些连接;逐个检查其取消状态和缓存,避免只刷新可见快照中的属性。 for connection in connections { + // 取当前 payload 快照,compute 与缓存必须来自同一连接状态。 let binding = vm.payload::(connection, "Binding")?.clone(); + // 断开或被替换的连接不再执行,即使外部仍持有旧 Subscription。 let Some(compute) = binding.compute else { continue; }; + // 锁只用于复制缓存,源 compute 执行期间不持锁,避免执行源代码时占着宿主缓存互斥量。 let cached = binding .reads .lock() @@ -182,6 +208,7 @@ pub fn refresh( } // 防止相互写入的绑定持续振荡;预算跨轮累计,超限向宿主报告不稳定。 work += 1; + // 已评估次数跨轮累计,超过预算就报告不收敛,而非丢弃剩余绑定继续发布。 if work > 10_000 { bail!("property bindings did not stabilize within the execution budget"); } @@ -197,6 +224,7 @@ pub fn refresh( }, ) .map_err(|error| crate::endor_host::runtime_error(vm, error))?; + // 暂存应用阶段错误,让下方 release_value 无论校验/写入是否成功都释放计算返回值。 let applied: Result<()> = (|| { // 宿主桌面对象不纳入此缓存依赖;保留业务读取,避免宿主发布状态驱动重算。 let reads = reads @@ -204,12 +232,15 @@ pub fn refresh( .into_iter() .filter(|read| !host_desktops.contains(&read.object())) .collect(); + // 计算结束后重新解析目标,不能沿用调用前借用的 Data;随后只在副本上试解码视觉值。 let (_, data) = block::resolve(vm.object_store(), target)?; // 先在数据副本上验证属性类型/范围,验证通过后才写回托管属性。 block::apply_visual_property(&mut data.clone(), &binding.field, &value)?; + // 相等输出不再写入,避免无意义的版本增长使其他绑定失效;对象值按身份比较。 if vm.get_attr(target, &binding.field)? != value { vm.set_attr(target, &binding.field, value.clone())?; } + // 成功应用后才保存新依赖和输出身份;缓存不 retain 输出对象,也不解引用旧对象句柄。 *binding .reads .lock() @@ -226,6 +257,7 @@ pub fn refresh( } Ok(()) })(); + // 无论 result 成功还是失败,先释放弱升级带来的临时强引用,再传播错误。 vm.release(target)?; result?; } diff --git a/src/model/block.rs b/src/model/block.rs index 31b62e7..7962e5f 100644 --- a/src/model/block.rs +++ b/src/model/block.rs @@ -2,6 +2,10 @@ //! Data 保存子组件 slot、布局配置、绑定和订阅。属性读写需要沿有效的类型/存储路径解析,不能把快照当成可变业务对象。 //! 提取视觉数据时将托管属性覆盖到原生描述;校验负责拒绝循环、非法尺寸和不符合类型的组成。 //! +//! 调用路径:UI 构造器经 allocate 建立托管图;Children/赋值入口经 validate 检查新组成。 +//! RenderCache 经 resolve_tracked/visual_data 提取本轮描述,绑定刷新也用 apply_visual_property 预校验结果。 +//! ObjectPayload 钩子由 Aster 属性查询、赋值及 GC 调用,不直接保存窗口或每次出现的几何。 +//! //! Live visual objects. All semantic references are owned by Aster's heap; //! native Arcs are reserved for immutable rendering snapshots. use std::sync::Arc; @@ -15,41 +19,54 @@ use crate::ui_bridge::UiNode; pub struct Child { // slot 标识父组件中这条组成边,移动行序不改变它;同一 Block 可通过不同 slot 出现多次。 pub slot: u64, + // 组成边强持有的业务 Block;同一对象可通过不同 slot 多次出现。 pub block: ObjectRef, } #[derive(Debug, Clone)] /// 原生组件分类,只描述业务组件种类;每种展示几何由布局和 Mount 另行生成。 pub enum Kind { + // 原生文字默认内容/离散样式;换行宽高来自各出现位置的约束。 Text { value: String, size: u32, color: String, }, + // 原生矩形自然宽高、圆角和填充色,最终像素尺寸由布局分配。 Rect { width: f32, height: f32, radius: f32, color: String, }, + // 单一自然间距叶节点,不持有子组成。 Spacer(f32), + // 有限滚动视口的自然尺寸,滚动偏移和范围属于 Mount。 Scroll { width: f32, height: f32, }, + // 线性横向容器,孩子组成顺序由 Data.children 保存。 Row, + // 线性纵向容器,与 Row 共享通用盒模型及弹性策略。 Column, + // 叠放容器,子项共享内容区域,各自独立对齐而非线性累积。 Overlay, + // 二维世界组成根,子项业务 x/y 可带符号。 Scene2D, + // 承载 Scene3D 的有限视口,场景与相机引用保存于 Data。 Viewport3D { width: f32, height: f32, }, + // 承载唯一 Scene2D 根的有限二维视口,独立相机负责世界到视图投影。 CanvasViewport { width: f32, height: f32, }, + // 具有默认按钮视觉/事件协议的容器种类。 Button, + // 页面业务根,包含导航和兼容表面状态,生命周期有别于普通 Block。 Page, } @@ -60,7 +77,9 @@ pub enum Kind { pub struct Block { // 共享赋值单元 Cell;其值可为最终 Data 或另一个 Block,从而保留别名身份并转发属性/类型查询。 pub state: ObjectRef, + // 包装是否为 Page,限制共享赋值不能跨越页面与普通组件的生命周期边界。 pub page: bool, + // 该业务 owner 的任务作用域,独立于 Data 替换保留;trace 负责向 VM 暴露拥有边。 pub tasks: Option, } @@ -71,6 +90,7 @@ impl ObjectPayload for Block { } /// 沿 Block 的共享 Cell 定位属性存储,使别名替换后读取到同一份新 Data。 fn attribute_target(&self, store: &ObjectStore) -> Result, VmError> { + // VM 属性查询先读共享 Cell;不是对象意味着内部状态损坏,不能返回一个空属性集来掩盖。 let Value::Object(value) = store.cell_get(self.state)? else { return Err(VmError::InvalidArgument("Block state is not an object")); }; @@ -90,9 +110,11 @@ impl ObjectPayload for Block { /// 从 Block 当前 Data 解析其类型转发目标,使共享替换后的类型查询仍一致。 fn type_target(&self, store: &ObjectStore) -> Result, VmError> { + // VM 类型查询同样沿 Cell 解析,防止别名的属性与名义类型指向不同替代对象。 let Value::Object(value) = store.cell_get(self.state)? else { return Err(VmError::InvalidArgument("Block state is not an object")); }; + // 若仍是包装 Block 则继续转发;到最终 Data 时保留当前包装本身的名义类型。 if store.object(value)?.payload().as_any().is::() { Ok(Some(value)) } else { @@ -116,7 +138,9 @@ impl ObjectPayload for Block { } /// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。 fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { + // Aster GC 追踪共享 Cell 与 owner 任务作用域;别名链不能隐藏在宿主 Arc 图中。 visit(self.state); + // 存在业务 owner 任务作用域时强持有它,替换视觉 Data 不会把该作用域漏出追踪。 if let Some(tasks) = self.tasks { visit(tasks); } @@ -127,6 +151,7 @@ impl ObjectPayload for Block { source: ObjectRef, store: &ObjectStore, ) -> Result, VmError> { + // VM 询问赋值是否共享 Cell 时,只接收另一 Block 包装;其他值交回常规赋值规则。 if !store.object(source)?.payload().as_any().is::() { return Ok(None); } @@ -145,36 +170,61 @@ impl ObjectPayload for Block { #[derive(Debug, Clone)] /// 组件当前业务描述与组成。与一次求出的屏幕几何分开保存,替换或属性变化会触发派生快照失效。 pub struct Data { + // 构造时原生种类及其默认视觉值;当前托管属性由 visual_data 覆盖到副本。 pub kind: Kind, + // 有顺序的组成边,slot 保留身份,Vec 顺序决定布局/绘制展开顺序。 pub children: Vec, + // 容器统一内边距,单边 padding_edges 为 Some 时优先覆盖。 pub padding: f32, + // 线性子项间距,参与自然尺寸、排列和剩余空间分配。 pub gap: f32, + // 父主轴有剩余空间时的增长权重,不是已分配像素宽高。 pub flex: f32, + // 主轴 basis、min/max 与 shrink 规则,供布局求解分配尺寸。 pub flex_policy: crate::layout::FlexPolicy, + // 容器主轴剩余空间的对齐策略,与子项 flex 尺寸分配区分。 pub main_align: crate::layout::MainAlign, + // 容器交叉轴默认对齐,子项 align_self 可单独覆盖。 pub cross_align: crate::layout::CrossAlign, + // None 编码 auto,表示继承当前父出现位置策略;共享孩子在不同父下可有不同几何。 pub align_self: Option, + // 子项外边距,参与父固有尺寸与分配,不包含在本项边框盒宽高中。 pub margin: crate::layout::Insets, + // 边框盒 auto/fill/显式尺寸及 min/max 限制,解码后由布局应用。 pub sizing: crate::box_model::Sizing, // 四边覆盖顺序为上、右、下、左;None 表示回退统一 padding,不等于零。 pub padding_edges: [Option; 4], + // 四边边框厚度,占据边框盒内部空间,并生成独立绘制命令。 pub border: crate::layout::Insets, + // 已验证颜色文本,供边框绘制使用;不是业务内容自身的填充色。 pub border_color: String, + // overflow=clip 时限制后代的绘制与命中,不自动赋予滚动能力。 pub clip_children: bool, + // CanvasViewport/Viewport3D 使用的相机强引用;共享相机需源代码显式复用对象。 pub camera: Option, + // 三维视口的 Scene3D 业务根,面板 UI 由场景提取流程展开。 pub scene3d: Option, + // 三维视口自定义控制器;提取时检查名义 update,输入路径才调用它。 pub controller: Option, + // ScrollBlock 可滚动方向;实际偏移与有限范围按每个 Mount 保存。 pub scroll_axis: crate::scroll::Axis, + // Scene2D/canvas 等定位使用的带符号业务坐标,不能当作已投影屏幕位置。 pub position: [f32; 2], + // Page 兼容表面模式及 pan/zoom,具体页面身份在发布时补入。 pub surface: crate::page::Surface, // 下次插入的单调 slot,初始孩子按 0..len 编号,后续重排不把旧 slot 重新编号。 pub next_slot: u64, // Page 导航历史是真实托管拥有边;后退/前进可重新展示同一个对象,而非复制快照。 pub history: Vec, + // Page 当前导航历史位置,与 history 一起维护前进/后退关系。 pub history_index: usize, + // 目标拥有的属性连接,连接再持有 compute;取消令牌不反向保活目标。 pub bindings: Vec, + // 旧 textOf 源对象与字段名推导入口;通用 text 绑定建立后清除以避免竞争。 pub text_binding: Option<(ObjectRef, String)>, + // 兼容 onClick 直接回调,事件路径调用,GC 需保活该函数。 pub click: Option, + // 对象拥有的事件连接,派发按这些连接决定监听器资格。 pub subscriptions: Vec, // 源可读写的视觉字段存储;Data 自身保存结构边与构造默认值,提取时再将当前属性覆盖到派生副本。 pub properties: Option, @@ -185,6 +235,8 @@ impl Data { pub fn new(kind: Kind, children: Vec, padding: f32, gap: f32) -> Self { // 先为输入孩子分配稳定 slot,next_slot 从当前长度继续,后续追加不会复用被删除条目的编号。 let next_slot = children.len() as u64; + // UI 构造器传入的种类/孩子/统一 padding 与 gap 形成默认描述,其余布局策略先取无约束默认值。 + // 顺序编号孩子形成独立组成边,后续插入/删除只移动 Vec,不重编号旧 slot。 Self { kind, children: children @@ -257,33 +309,43 @@ impl ObjectPayload for Data { /// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。 fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { // 所有结构字段中出现的托管强引用必须枚举:场景/相机、属性存储、孩子、历史、绑定、订阅和回调。 + // 三维场景根属于业务拥有边,不能只在渲染快照中借用而漏掉 GC。 if let Some(scene) = self.scene3d { visit(scene); } + // 自定义控制器可能持有源模型/闭包,需要进入循环回收图。 if let Some(controller) = self.controller { visit(controller); } + // 相机可被多个视口共享,Data 对它的每条强边都要被追踪。 if let Some(camera) = self.camera { visit(camera); } + // 普通源字段存储可能反向引用组件或页面,必须让 VM 看到这类环。 if let Some(properties) = self.properties { visit(properties); } + // 每条组成边都拥有孩子,不能因业务身份重复而漏计托管引用。 for child in &self.children { visit(child.block); } + // 页面历史需要保留可再次展示的业务对象,不是临时绘制快照。 for item in &self.history { visit(*item); } + // 旧 textOf 推导源仍是强引用,直到该入口被清除。 if let Some((source, _)) = &self.text_binding { visit(*source); } + // 连接由目标拥有,连接 payload 再追踪有效 compute。 for binding in &self.bindings { visit(*binding); } + // 事件连接持有回调所需图,取消和回收由事件连接状态决定。 for token in &self.subscriptions { visit(*token); } + // 兼容单回调同样要保活,不能只追踪通用 subscriptions。 if let Some(handler) = self.click { visit(handler); } @@ -292,6 +354,7 @@ impl ObjectPayload for Data { /// 分配 Data、共享 Cell 和 Block 包装,并让托管图拥有必要引用。 pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result { + // UI 构造器调用 allocate;先固定包装的 Page 标记,后续共享赋值据此检查类别兼容。 let page = matches!(data.kind, Kind::Page); // 分配拥有边前检查每个孩子都是有效有限 Block 图;非法组成直接失败,不截断部分 UI。 for child in &data.children { @@ -299,10 +362,12 @@ pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result Cell -> Data -> properties/children 拥有链,均在 VM 中参与追踪和循环回收。 let state = cx.new_cell(Some(Value::Object(data)))?; @@ -317,6 +382,7 @@ pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result